2828 editorUserRE = regexp .MustCompile (`^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$` )
2929 editorHostRE = regexp .MustCompile (`^[a-zA-Z0-9._-]{1,253}$` )
3030 editorAliasRE = regexp .MustCompile (`^sb-[0-9a-z]{26}$` )
31+ // editorNameRE gates a sandbox's friendly name before it lands on the
32+ // `Host <id> <name>` line. SSH's Host aliases forbid whitespace, `?`,
33+ // and `*`; we further restrict to plain identifier chars so a mutated
34+ // name can't smuggle newlines / control bytes.
35+ editorNameRE = regexp .MustCompile (`^[a-zA-Z0-9._-]{1,64}$` )
3136)
3237
3338// One block per sandbox id in ~/.ssh/config; re-runs rewrite in place.
@@ -105,6 +110,10 @@ positional).`,
105110 Name : "remove" ,
106111 Usage : "Remove this sandbox's block from ~/.ssh/config and exit" ,
107112 },
113+ & cli.BoolFlag {
114+ Name : "no-sweep" ,
115+ Usage : "Skip auto-cleanup of ~/.ssh/config blocks for sandboxes that no longer exist" ,
116+ },
108117 },
109118 Action : runEditor ,
110119 }
@@ -240,12 +249,26 @@ func runEditor(c *cli.Context) error {
240249 }
241250 sp .Success ("sshd running in the sandbox" )
242251
252+ // Sweep other ~/.ssh/config blocks that reference sandboxes the user
253+ // no longer owns / that no longer exist. Runs before we write the
254+ // fresh block so the atomic tmp-rewrite flushes the pruning too.
255+ if ! c .Bool ("no-sweep" ) {
256+ if pruned , serr := sweepStaleBlocks (c .Context , client , alias ); serr == nil && len (pruned ) > 0 {
257+ pterm .Info .Printfln ("cleaned up %d stale entr%s: %s" ,
258+ len (pruned ), plural (len (pruned ), "y" , "ies" ), strings .Join (pruned , ", " ))
259+ }
260+ }
261+
243262 // --- 6. Write the ~/.ssh/config block -----------------------------------
244263 gwHost , gwPort := gatewayAddr ()
245264 if ! editorHostRE .MatchString (gwHost ) {
246265 return fmt .Errorf ("refusing shell-unsafe gateway host %q" , gwHost )
247266 }
248- block , err := renderSSHBlock (alias , mode , id , sbIP , gwHost , gwPort , user , privPath )
267+ sbName := ""
268+ if sb .Name != nil {
269+ sbName = * sb .Name
270+ }
271+ block , err := renderSSHBlock (alias , mode , id , sbIP , gwHost , gwPort , user , privPath , sbName )
249272 if err != nil {
250273 return err
251274 }
@@ -441,10 +464,23 @@ func bytesTrimRight(b []byte, cutset string) []byte {
441464 return b
442465}
443466
467+ // hostLine builds the `Host` line — dual alias when a friendly name
468+ // passes the regex, id-only otherwise. Same-name-as-id, blanks, and
469+ // anything with unsafe chars fall back to id-only.
470+ func hostLine (alias , name string ) string {
471+ name = strings .TrimSpace (name )
472+ if name == "" || name == alias || ! editorNameRE .MatchString (name ) {
473+ return alias
474+ }
475+ return alias + " " + name
476+ }
477+
444478// renderSSHBlock builds the ~/.ssh/config stanza for the sandbox.
445- func renderSSHBlock (alias , mode , sandboxID , sbIP , gwHost string , gwPort int , user , identity string ) (string , error ) {
479+ // `name` is the sandbox's friendly name; empty or unsafe → id-only alias.
480+ func renderSSHBlock (alias , mode , sandboxID , sbIP , gwHost string , gwPort int , user , identity , name string ) (string , error ) {
446481 begin := fmt .Sprintf (sshConfigBlockBegin , alias )
447482 end := fmt .Sprintf (sshConfigBlockEnd , alias )
483+ host := hostLine (alias , name )
448484 switch mode {
449485 case "vpn" :
450486 return fmt .Sprintf (`%s
@@ -456,7 +492,7 @@ Host %s
456492 StrictHostKeyChecking accept-new
457493 UserKnownHostsFile ~/.ssh/known_hosts_createos
458494%s
459- ` , begin , alias , sbIP , user , identity , end ), nil
495+ ` , begin , host , sbIP , user , identity , end ), nil
460496 case "tunnel" :
461497 // The inner `ssh -W` for the gateway needs its own
462498 // StrictHostKeyChecking + UserKnownHostsFile — it doesn't inherit
@@ -472,7 +508,7 @@ Host %s
472508 StrictHostKeyChecking accept-new
473509 UserKnownHostsFile ~/.ssh/known_hosts_createos
474510%s
475- ` , begin , alias , user , identity , sandboxID , gwHost , gwPort , identity , end ), nil
511+ ` , begin , host , user , identity , sandboxID , gwHost , gwPort , identity , end ), nil
476512 default :
477513 return "" , fmt .Errorf ("unknown mode %q" , mode )
478514 }
@@ -763,3 +799,109 @@ func printFollowup(alias string) {
763799 pterm .Info .Printfln (" code --remote ssh-remote+%s /root" , alias )
764800 pterm .Info .Printfln (" cursor --remote ssh-remote+%s /root" , alias )
765801}
802+
803+ // sweepStaleBlocks walks ~/.ssh/config for our editor-owned blocks and
804+ // prunes any whose sandbox the server can't find or considers dead
805+ // (destroyed / failed). Skips the alias the caller is about to write.
806+ // Returns the aliases actually removed.
807+ //
808+ // Concurrency budget: parallel GetSandbox calls with a small pool so the
809+ // sweep doesn't inflate editor-command latency on users with many
810+ // entries. Errors that aren't "not found" (network hiccups, 5xx) leave
811+ // the block alone — we never destroy user config on transient failures.
812+ func sweepStaleBlocks (ctx context.Context , client * api.SandboxClient , keep string ) ([]string , error ) {
813+ path , err := sshConfigPath ()
814+ if err != nil {
815+ return nil , err
816+ }
817+ data , err := os .ReadFile (path ) //nolint:gosec // #nosec G304 -- own ssh config
818+ if err != nil {
819+ if os .IsNotExist (err ) {
820+ return nil , nil
821+ }
822+ return nil , err
823+ }
824+
825+ aliases := collectCreateosAliases (string (data ))
826+ if len (aliases ) == 0 {
827+ return nil , nil
828+ }
829+
830+ // Check each alias in parallel — bounded pool of 8.
831+ type verdict struct {
832+ alias string
833+ gone bool
834+ }
835+ sem := make (chan struct {}, 8 )
836+ ch := make (chan verdict , len (aliases ))
837+ for _ , a := range aliases {
838+ if a == keep {
839+ continue
840+ }
841+ sem <- struct {}{}
842+ go func () {
843+ defer func () { <- sem }()
844+ gone := isSandboxGone (ctx , client , a )
845+ ch <- verdict {alias : a , gone : gone }
846+ }()
847+ }
848+ // Drain semaphore so we know all goroutines finished.
849+ for i := 0 ; i < cap (sem ); i ++ {
850+ sem <- struct {}{}
851+ }
852+ close (ch )
853+
854+ pruned := make ([]string , 0 , len (aliases ))
855+ for v := range ch {
856+ if ! v .gone {
857+ continue
858+ }
859+ if _ , rerr := removeSSHBlock (v .alias ); rerr == nil {
860+ removeDedicatedKey (v .alias )
861+ pruned = append (pruned , v .alias )
862+ }
863+ }
864+ return pruned , nil
865+ }
866+
867+ // collectCreateosAliases returns every alias marked by our "# BEGIN
868+ // createos <alias>" delimiter in ~/.ssh/config.
869+ func collectCreateosAliases (cfg string ) []string {
870+ out := make ([]string , 0 )
871+ const prefix = "# BEGIN createos "
872+ for _ , line := range strings .Split (cfg , "\n " ) {
873+ if ! strings .HasPrefix (line , prefix ) {
874+ continue
875+ }
876+ alias := strings .TrimSpace (strings .TrimPrefix (line , prefix ))
877+ if editorAliasRE .MatchString (alias ) {
878+ out = append (out , alias )
879+ }
880+ }
881+ return out
882+ }
883+
884+ // isSandboxGone returns true only when we're confident the sandbox no
885+ // longer belongs to the user or has reached a terminal state. A network
886+ // error, 5xx, or auth failure returns false so we don't nuke config on
887+ // transient issues.
888+ func isSandboxGone (ctx context.Context , client * api.SandboxClient , alias string ) bool {
889+ sctx , cancel := context .WithTimeout (ctx , 4 * time .Second )
890+ defer cancel ()
891+ sb , err := client .GetSandbox (sctx , alias )
892+ if err != nil {
893+ return api .IsNotFound (err )
894+ }
895+ switch sb .Status {
896+ case "destroyed" , "failed" :
897+ return true
898+ }
899+ return false
900+ }
901+
902+ func plural (n int , singular , plural string ) string {
903+ if n == 1 {
904+ return singular
905+ }
906+ return plural
907+ }
0 commit comments