feat(config): add snapshot restore process configuration
- commit
- 5ebf319507fc393c5fae96b2c1535874b7d4f61c
- author
- brice · · 2026-07-06 07:37 UTC
- parent
- 7b6607d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +195 -0
M internal/adapters/config/config.go +31 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | 9 | "os" | |
| 10 | + | "regexp" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| @@ -16,6 +17,8 @@ import ( | |||
| 16 | 17 | ||
| 17 | 18 | const pollInterval = 2 * time.Second | |
| 18 | 19 | ||
| 20 | + | var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | |
| 21 | + | ||
| 19 | 22 | // Parse reads vev's flat action = value config format. Duplicate action keys | |
| 20 | 23 | // are accepted with a warning; the last value wins while first-seen action order | |
| 21 | 24 | // is preserved for binding conflict resolution. | |
| @@ -67,6 +70,9 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 67 | 70 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| 68 | 71 | } | |
| 69 | 72 | cfg.Codes[codeKey] = value | |
| 73 | + | case key == "snapshot.restore_processes": | |
| 74 | + | cfg.Snapshot.RestoreProcesses = parseProcessList(value, lineNo, &warnings) | |
| 75 | + | cfg.Snapshot.RestoreProcessesSet = true | |
| 70 | 76 | default: | |
| 71 | 77 | if seenBindingKeys[key] { | |
| 72 | 78 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| @@ -164,6 +170,31 @@ func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { | |||
| 164 | 170 | } | |
| 165 | 171 | } | |
| 166 | 172 | ||
| 173 | + | func parseProcessList(value string, lineNo int, warnings *[]domain.Warning) []string { | |
| 174 | + | if strings.TrimSpace(value) == "" { | |
| 175 | + | return nil | |
| 176 | + | } | |
| 177 | + | parts := strings.Split(value, ",") | |
| 178 | + | out := make([]string, 0, len(parts)) | |
| 179 | + | seen := make(map[string]struct{}, len(parts)) | |
| 180 | + | for _, part := range parts { | |
| 181 | + | item := strings.TrimSpace(part) | |
| 182 | + | if item == "" { | |
| 183 | + | continue | |
| 184 | + | } | |
| 185 | + | if !processNamePattern.MatchString(item) { | |
| 186 | + | *warnings = append(*warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)}) | |
| 187 | + | continue | |
| 188 | + | } | |
| 189 | + | if _, ok := seen[item]; ok { | |
| 190 | + | continue | |
| 191 | + | } | |
| 192 | + | seen[item] = struct{}{} | |
| 193 | + | out = append(out, item) | |
| 194 | + | } | |
| 195 | + | return out | |
| 196 | + | } | |
| 197 | + | ||
| 167 | 198 | func parseTheme(value string) (domain.ThemeMode, bool) { | |
| 168 | 199 | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 169 | 200 | case "auto", "": | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | "os" | 9 | "os" | ||
| 10 | + | "regexp" | |||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| @@ -16,6 +17,8 @@ import ( | |||||
| 16 | 17 | ||||
| 17 | const pollInterval = 2 * time.Second | 18 | const pollInterval = 2 * time.Second | ||
| 18 | 19 | ||||
| 20 | + | var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | |||
| 21 | + | ||||
| 19 | // Parse reads vev's flat action = value config format. Duplicate action keys | 22 | // Parse reads vev's flat action = value config format. Duplicate action keys | ||
| 20 | // are accepted with a warning; the last value wins while first-seen action order | 23 | // are accepted with a warning; the last value wins while first-seen action order | ||
| 21 | // is preserved for binding conflict resolution. | 24 | // is preserved for binding conflict resolution. | ||
| @@ -67,6 +70,9 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 67 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | 70 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | ||
| 68 | } | 71 | } | ||
| 69 | cfg.Codes[codeKey] = value | 72 | cfg.Codes[codeKey] = value | ||
| 73 | + | case key == "snapshot.restore_processes": | |||
| 74 | + | cfg.Snapshot.RestoreProcesses = parseProcessList(value, lineNo, &warnings) | |||
| 75 | + | cfg.Snapshot.RestoreProcessesSet = true | |||
| 70 | default: | 76 | default: | ||
| 71 | if seenBindingKeys[key] { | 77 | if seenBindingKeys[key] { | ||
| 72 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | 78 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | ||
| @@ -164,6 +170,31 @@ func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { | |||||
| 164 | } | 170 | } | ||
| 165 | } | 171 | } | ||
| 166 | 172 | ||||
| 173 | + | func parseProcessList(value string, lineNo int, warnings *[]domain.Warning) []string { | |||
| 174 | + | if strings.TrimSpace(value) == "" { | |||
| 175 | + | return nil | |||
| 176 | + | } | |||
| 177 | + | parts := strings.Split(value, ",") | |||
| 178 | + | out := make([]string, 0, len(parts)) | |||
| 179 | + | seen := make(map[string]struct{}, len(parts)) | |||
| 180 | + | for _, part := range parts { | |||
| 181 | + | item := strings.TrimSpace(part) | |||
| 182 | + | if item == "" { | |||
| 183 | + | continue | |||
| 184 | + | } | |||
| 185 | + | if !processNamePattern.MatchString(item) { | |||
| 186 | + | *warnings = append(*warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)}) | |||
| 187 | + | continue | |||
| 188 | + | } | |||
| 189 | + | if _, ok := seen[item]; ok { | |||
| 190 | + | continue | |||
| 191 | + | } | |||
| 192 | + | seen[item] = struct{}{} | |||
| 193 | + | out = append(out, item) | |||
| 194 | + | } | |||
| 195 | + | return out | |||
| 196 | + | } | |||
| 197 | + | ||||
| 167 | func parseTheme(value string) (domain.ThemeMode, bool) { | 198 | func parseTheme(value string) (domain.ThemeMode, bool) { | ||
| 168 | switch strings.ToLower(strings.TrimSpace(value)) { | 199 | switch strings.ToLower(strings.TrimSpace(value)) { | ||
| 169 | case "auto", "": | 200 | case "auto", "": | ||
M internal/adapters/config/config_test.go +46 -0
| @@ -97,6 +97,9 @@ func TestParse(t *testing.T) { | |||
| 97 | 97 | t.Run(tt.name, func(t *testing.T) { | |
| 98 | 98 | t.Parallel() | |
| 99 | 99 | ||
| 100 | + | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 101 | + | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses...) | |
| 102 | + | } | |
| 100 | 103 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 101 | 104 | if err != nil { | |
| 102 | 105 | t.Fatalf("Parse() error = %v", err) | |
| @@ -111,6 +114,49 @@ func TestParse(t *testing.T) { | |||
| 111 | 114 | } | |
| 112 | 115 | } | |
| 113 | 116 | ||
| 117 | + | func TestParseSnapshotRestoreProcesses(t *testing.T) { | |
| 118 | + | t.Parallel() | |
| 119 | + | ||
| 120 | + | cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes = claude, codex, pi, opencode, btop\n")) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | require.Empty(t, warnings) | |
| 123 | + | require.Equal(t, []string{"claude", "codex", "pi", "opencode", "btop"}, cfg.Snapshot.RestoreProcesses) | |
| 124 | + | require.True(t, cfg.Snapshot.RestoreProcessesSet) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestParseSnapshotRestoreProcessesEmptyDisables(t *testing.T) { | |
| 128 | + | t.Parallel() | |
| 129 | + | ||
| 130 | + | cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes =\n")) | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | require.Empty(t, warnings) | |
| 133 | + | require.Empty(t, cfg.Snapshot.RestoreProcesses) | |
| 134 | + | require.True(t, cfg.Snapshot.RestoreProcessesSet) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestParseSnapshotRestoreProcessesWarnsOnMalformedEntries(t *testing.T) { | |
| 138 | + | t.Parallel() | |
| 139 | + | ||
| 140 | + | cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n")) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | require.Equal(t, []domain.Warning{ | |
| 143 | + | {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""}, | |
| 144 | + | {Line: 1, Msg: "invalid snapshot restore process \":all:\""}, | |
| 145 | + | {Line: 1, Msg: "invalid snapshot restore process \"bad name\""}, | |
| 146 | + | }, warnings) | |
| 147 | + | require.Equal(t, []string{"claude", "custom-tool", "zsh"}, cfg.Snapshot.RestoreProcesses) | |
| 148 | + | require.True(t, cfg.Snapshot.RestoreProcessesSet) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |
| 152 | + | t.Parallel() | |
| 153 | + | ||
| 154 | + | first := domain.Defaults() | |
| 155 | + | first.Snapshot.RestoreProcesses[0] = "mutated" | |
| 156 | + | second := domain.Defaults() | |
| 157 | + | require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0]) | |
| 158 | + | } | |
| 159 | + | ||
| 114 | 160 | func TestParsePreservesBindingFileOrder(t *testing.T) { | |
| 115 | 161 | t.Parallel() | |
| 116 | 162 | ||
| @@ -97,6 +97,9 @@ func TestParse(t *testing.T) { | |||||
| 97 | t.Run(tt.name, func(t *testing.T) { | 97 | t.Run(tt.name, func(t *testing.T) { | ||
| 98 | t.Parallel() | 98 | t.Parallel() | ||
| 99 | 99 | ||||
| 100 | + | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |||
| 101 | + | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses...) | |||
| 102 | + | } | |||
| 100 | got, warnings, err := Parse(strings.NewReader(tt.input)) | 103 | got, warnings, err := Parse(strings.NewReader(tt.input)) | ||
| 101 | if err != nil { | 104 | if err != nil { | ||
| 102 | t.Fatalf("Parse() error = %v", err) | 105 | t.Fatalf("Parse() error = %v", err) | ||
| @@ -111,6 +114,49 @@ func TestParse(t *testing.T) { | |||||
| 111 | } | 114 | } | ||
| 112 | } | 115 | } | ||
| 113 | 116 | ||||
| 117 | + | func TestParseSnapshotRestoreProcesses(t *testing.T) { | |||
| 118 | + | t.Parallel() | |||
| 119 | + | ||||
| 120 | + | cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes = claude, codex, pi, opencode, btop\n")) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | require.Empty(t, warnings) | |||
| 123 | + | require.Equal(t, []string{"claude", "codex", "pi", "opencode", "btop"}, cfg.Snapshot.RestoreProcesses) | |||
| 124 | + | require.True(t, cfg.Snapshot.RestoreProcessesSet) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestParseSnapshotRestoreProcessesEmptyDisables(t *testing.T) { | |||
| 128 | + | t.Parallel() | |||
| 129 | + | ||||
| 130 | + | cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes =\n")) | |||
| 131 | + | require.NoError(t, err) | |||
| 132 | + | require.Empty(t, warnings) | |||
| 133 | + | require.Empty(t, cfg.Snapshot.RestoreProcesses) | |||
| 134 | + | require.True(t, cfg.Snapshot.RestoreProcessesSet) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestParseSnapshotRestoreProcessesWarnsOnMalformedEntries(t *testing.T) { | |||
| 138 | + | t.Parallel() | |||
| 139 | + | ||||
| 140 | + | cfg, warnings, err := Parse(strings.NewReader("snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n")) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | require.Equal(t, []domain.Warning{ | |||
| 143 | + | {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""}, | |||
| 144 | + | {Line: 1, Msg: "invalid snapshot restore process \":all:\""}, | |||
| 145 | + | {Line: 1, Msg: "invalid snapshot restore process \"bad name\""}, | |||
| 146 | + | }, warnings) | |||
| 147 | + | require.Equal(t, []string{"claude", "custom-tool", "zsh"}, cfg.Snapshot.RestoreProcesses) | |||
| 148 | + | require.True(t, cfg.Snapshot.RestoreProcessesSet) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |||
| 152 | + | t.Parallel() | |||
| 153 | + | ||||
| 154 | + | first := domain.Defaults() | |||
| 155 | + | first.Snapshot.RestoreProcesses[0] = "mutated" | |||
| 156 | + | second := domain.Defaults() | |||
| 157 | + | require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0]) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 114 | func TestParsePreservesBindingFileOrder(t *testing.T) { | 160 | func TestParsePreservesBindingFileOrder(t *testing.T) { | ||
| 115 | t.Parallel() | 161 | t.Parallel() | ||
| 116 | 162 | ||||
M internal/app/run.go +3 -0
| @@ -142,6 +142,9 @@ func parseArgs(args []string) (command, error) { | |||
| 142 | 142 | if len(args) > 2 { | |
| 143 | 143 | return command{}, usagef("`new` does not support command overrides") | |
| 144 | 144 | } | |
| 145 | + | if err := domain.ValidateSessionName(args[1]); err != nil { | |
| 146 | + | return command{}, err | |
| 147 | + | } | |
| 145 | 148 | return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil | |
| 146 | 149 | case "attach", "a": | |
| 147 | 150 | if len(args) < 2 || args[1] == "" { | |
| @@ -142,6 +142,9 @@ func parseArgs(args []string) (command, error) { | |||||
| 142 | if len(args) > 2 { | 142 | if len(args) > 2 { | ||
| 143 | return command{}, usagef("`new` does not support command overrides") | 143 | return command{}, usagef("`new` does not support command overrides") | ||
| 144 | } | 144 | } | ||
| 145 | + | if err := domain.ValidateSessionName(args[1]); err != nil { | |||
| 146 | + | return command{}, err | |||
| 147 | + | } | |||
| 145 | return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil | 148 | return command{kind: kindAttach, intent: ports.IntentNew, name: args[1]}, nil | ||
| 146 | case "attach", "a": | 149 | case "attach", "a": | ||
| 147 | if len(args) < 2 || args[1] == "" { | 150 | if len(args) < 2 || args[1] == "" { | ||
M internal/app/run_test.go +11 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/adapters/snapshot" | |
| 16 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/persist" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | "github.com/bnema/vev/internal/usecase/client" | |
| @@ -38,6 +39,7 @@ func TestParseArgs(t *testing.T) { | |||
| 38 | 39 | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | |
| 39 | 40 | {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true}, | |
| 40 | 41 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | |
| 42 | + | {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"}, | |
| 41 | 43 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | |
| 42 | 44 | {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | |
| 43 | 45 | {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | |
| @@ -47,6 +49,7 @@ func TestParseArgs(t *testing.T) { | |||
| 47 | 49 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | |
| 48 | 50 | {name: "list", args: []string{"list"}, wantKind: kindList}, | |
| 49 | 51 | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | |
| 52 | + | {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"}, | |
| 50 | 53 | {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"}, | |
| 51 | 54 | {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true}, | |
| 52 | 55 | {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true}, | |
| @@ -58,6 +61,7 @@ func TestParseArgs(t *testing.T) { | |||
| 58 | 61 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | |
| 59 | 62 | {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio}, | |
| 60 | 63 | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | |
| 64 | + | {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"}, | |
| 61 | 65 | {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true}, | |
| 62 | 66 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | |
| 63 | 67 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | |
| @@ -103,6 +107,13 @@ func TestParseArgs(t *testing.T) { | |||
| 103 | 107 | } | |
| 104 | 108 | } | |
| 105 | 109 | ||
| 110 | + | func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) { | |
| 111 | + | _, err := parseArgs([]string{"new", "my work"}) | |
| 112 | + | if !errors.Is(err, domain.ErrInvalidSessionName) { | |
| 113 | + | t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 106 | 117 | func TestPrintSessionsShowsStoppedState(t *testing.T) { | |
| 107 | 118 | var out bytes.Buffer | |
| 108 | 119 | printSessions(&out, []ports.SessionInfo{ | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | "github.com/bnema/vev/internal/adapters/snapshot" | 15 | "github.com/bnema/vev/internal/adapters/snapshot" | ||
| 16 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | "github.com/bnema/vev/internal/persist" | 17 | "github.com/bnema/vev/internal/persist" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 18 | "github.com/bnema/vev/internal/usecase/client" | 19 | "github.com/bnema/vev/internal/usecase/client" | ||
| @@ -38,6 +39,7 @@ func TestParseArgs(t *testing.T) { | |||||
| 38 | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | 39 | {name: "new empty name", args: []string{"new", ""}, wantErr: true}, | ||
| 39 | {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true}, | 40 | {name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true}, | ||
| 40 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | 41 | {name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | ||
| 42 | + | {name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"}, | |||
| 41 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | 43 | {name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"}, | ||
| 42 | {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | 44 | {name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | ||
| 43 | {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | 45 | {name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"}, | ||
| @@ -47,6 +49,7 @@ func TestParseArgs(t *testing.T) { | |||||
| 47 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | 49 | {name: "ls", args: []string{"ls"}, wantKind: kindList}, | ||
| 48 | {name: "list", args: []string{"list"}, wantKind: kindList}, | 50 | {name: "list", args: []string{"list"}, wantKind: kindList}, | ||
| 49 | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | 51 | {name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"}, | ||
| 52 | + | {name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"}, | |||
| 50 | {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"}, | 53 | {name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"}, | ||
| 51 | {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true}, | 54 | {name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true}, | ||
| 52 | {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true}, | 55 | {name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true}, | ||
| @@ -58,6 +61,7 @@ func TestParseArgs(t *testing.T) { | |||||
| 58 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | 61 | {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon}, | ||
| 59 | {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio}, | 62 | {name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio}, | ||
| 60 | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | 63 | {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"}, | ||
| 64 | + | {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"}, | |||
| 61 | {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true}, | 65 | {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true}, | ||
| 62 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | 66 | {name: "help", args: []string{"--help"}, wantKind: kindHelp}, | ||
| 63 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | 67 | {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp}, | ||
| @@ -103,6 +107,13 @@ func TestParseArgs(t *testing.T) { | |||||
| 103 | } | 107 | } | ||
| 104 | } | 108 | } | ||
| 105 | 109 | ||||
| 110 | + | func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) { | |||
| 111 | + | _, err := parseArgs([]string{"new", "my work"}) | |||
| 112 | + | if !errors.Is(err, domain.ErrInvalidSessionName) { | |||
| 113 | + | t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 106 | func TestPrintSessionsShowsStoppedState(t *testing.T) { | 117 | func TestPrintSessionsShowsStoppedState(t *testing.T) { | ||
| 107 | var out bytes.Buffer | 118 | var out bytes.Buffer | ||
| 108 | printSessions(&out, []ports.SessionInfo{ | 119 | printSessions(&out, []ports.SessionInfo{ | ||
M internal/domain/config.go +16 -0
| @@ -18,6 +18,18 @@ type ConfigEntry struct { | |||
| 18 | 18 | Value string | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | + | // SnapshotConfig contains user-configurable snapshot restore settings. | |
| 22 | + | type SnapshotConfig struct { | |
| 23 | + | RestoreProcesses []string | |
| 24 | + | RestoreProcessesSet bool | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // DefaultSnapshotRestoreProcesses is the default allowlist for process restore. | |
| 28 | + | var DefaultSnapshotRestoreProcesses = []string{ | |
| 29 | + | "vi", "vim", "nvim", "emacs", "man", "less", "more", "tail", "top", "htop", "btop", | |
| 30 | + | "claude", "codex", "pi", "opencode", | |
| 31 | + | } | |
| 32 | + | ||
| 21 | 33 | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 22 | 34 | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 23 | 35 | // layer can decide which actions it understands. | |
| @@ -25,6 +37,7 @@ type Config struct { | |||
| 25 | 37 | Theme ThemeMode | |
| 26 | 38 | BindingEntries []ConfigEntry | |
| 27 | 39 | Codes map[string]string | |
| 40 | + | Snapshot SnapshotConfig | |
| 28 | 41 | } | |
| 29 | 42 | ||
| 30 | 43 | // Warning describes a non-fatal config problem. Parsers and reloaders should | |
| @@ -40,5 +53,8 @@ func Defaults() Config { | |||
| 40 | 53 | Theme: ThemeAuto, | |
| 41 | 54 | BindingEntries: []ConfigEntry{}, | |
| 42 | 55 | Codes: map[string]string{}, | |
| 56 | + | Snapshot: SnapshotConfig{ | |
| 57 | + | RestoreProcesses: append([]string(nil), DefaultSnapshotRestoreProcesses...), | |
| 58 | + | }, | |
| 43 | 59 | } | |
| 44 | 60 | } | |
| @@ -18,6 +18,18 @@ type ConfigEntry struct { | |||||
| 18 | Value string | 18 | Value string | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | + | // SnapshotConfig contains user-configurable snapshot restore settings. | |||
| 22 | + | type SnapshotConfig struct { | |||
| 23 | + | RestoreProcesses []string | |||
| 24 | + | RestoreProcessesSet bool | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // DefaultSnapshotRestoreProcesses is the default allowlist for process restore. | |||
| 28 | + | var DefaultSnapshotRestoreProcesses = []string{ | |||
| 29 | + | "vi", "vim", "nvim", "emacs", "man", "less", "more", "tail", "top", "htop", "btop", | |||
| 30 | + | "claude", "codex", "pi", "opencode", | |||
| 31 | + | } | |||
| 32 | + | ||||
| 21 | // Config is the user-editable vev configuration after parsing. Unknown binding | 33 | // Config is the user-editable vev configuration after parsing. Unknown binding | ||
| 22 | // keys are preserved here (in BindingEntries, in file order) so the usecase | 34 | // keys are preserved here (in BindingEntries, in file order) so the usecase | ||
| 23 | // layer can decide which actions it understands. | 35 | // layer can decide which actions it understands. | ||
| @@ -25,6 +37,7 @@ type Config struct { | |||||
| 25 | Theme ThemeMode | 37 | Theme ThemeMode | ||
| 26 | BindingEntries []ConfigEntry | 38 | BindingEntries []ConfigEntry | ||
| 27 | Codes map[string]string | 39 | Codes map[string]string | ||
| 40 | + | Snapshot SnapshotConfig | |||
| 28 | } | 41 | } | ||
| 29 | 42 | ||||
| 30 | // Warning describes a non-fatal config problem. Parsers and reloaders should | 43 | // Warning describes a non-fatal config problem. Parsers and reloaders should | ||
| @@ -40,5 +53,8 @@ func Defaults() Config { | |||||
| 40 | Theme: ThemeAuto, | 53 | Theme: ThemeAuto, | ||
| 41 | BindingEntries: []ConfigEntry{}, | 54 | BindingEntries: []ConfigEntry{}, | ||
| 42 | Codes: map[string]string{}, | 55 | Codes: map[string]string{}, | ||
| 56 | + | Snapshot: SnapshotConfig{ | |||
| 57 | + | RestoreProcesses: append([]string(nil), DefaultSnapshotRestoreProcesses...), | |||
| 58 | + | }, | |||
| 43 | } | 59 | } | ||
| 44 | } | 60 | } | ||
A internal/domain/session_name.go +22 -0
| @@ -0,0 +1,22 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "regexp" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const sessionNamePattern = `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` | |
| 9 | + | ||
| 10 | + | var ( | |
| 11 | + | // ErrInvalidSessionName is returned when a new or renamed session name is unsafe. | |
| 12 | + | ErrInvalidSessionName = errors.New("invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}") | |
| 13 | + | sessionNameRE = regexp.MustCompile(`^` + sessionNamePattern + `$`) | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // ValidateSessionName rejects unsafe names for newly-created or renamed sessions. | |
| 17 | + | func ValidateSessionName(name string) error { | |
| 18 | + | if !sessionNameRE.MatchString(name) { | |
| 19 | + | return ErrInvalidSessionName | |
| 20 | + | } | |
| 21 | + | return nil | |
| 22 | + | } | |
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "regexp" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | const sessionNamePattern = `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` | |||
| 9 | + | ||||
| 10 | + | var ( | |||
| 11 | + | // ErrInvalidSessionName is returned when a new or renamed session name is unsafe. | |||
| 12 | + | ErrInvalidSessionName = errors.New("invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}") | |||
| 13 | + | sessionNameRE = regexp.MustCompile(`^` + sessionNamePattern + `$`) | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // ValidateSessionName rejects unsafe names for newly-created or renamed sessions. | |||
| 17 | + | func ValidateSessionName(name string) error { | |||
| 18 | + | if !sessionNameRE.MatchString(name) { | |||
| 19 | + | return ErrInvalidSessionName | |||
| 20 | + | } | |||
| 21 | + | return nil | |||
| 22 | + | } | |||
A internal/domain/session_name_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestValidateSessionName(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | wantErr bool | |
| 9 | + | }{ | |
| 10 | + | {name: "a"}, | |
| 11 | + | {name: "work"}, | |
| 12 | + | {name: "Work_1.2-3"}, | |
| 13 | + | {name: "a123456789012345678901234567890123456789012345678901234567890123"}, | |
| 14 | + | {name: "", wantErr: true}, | |
| 15 | + | {name: "my work", wantErr: true}, | |
| 16 | + | {name: "-work", wantErr: true}, | |
| 17 | + | {name: "_work", wantErr: true}, | |
| 18 | + | {name: ".work", wantErr: true}, | |
| 19 | + | {name: "work/one", wantErr: true}, | |
| 20 | + | {name: "a1234567890123456789012345678901234567890123456789012345678901234", wantErr: true}, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | for _, tt := range tests { | |
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |
| 25 | + | err := ValidateSessionName(tt.name) | |
| 26 | + | if tt.wantErr && err == nil { | |
| 27 | + | t.Fatalf("ValidateSessionName(%q) = nil, want error", tt.name) | |
| 28 | + | } | |
| 29 | + | if !tt.wantErr && err != nil { | |
| 30 | + | t.Fatalf("ValidateSessionName(%q) = %v, want nil", tt.name, err) | |
| 31 | + | } | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestValidateSessionName(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | wantErr bool | |||
| 9 | + | }{ | |||
| 10 | + | {name: "a"}, | |||
| 11 | + | {name: "work"}, | |||
| 12 | + | {name: "Work_1.2-3"}, | |||
| 13 | + | {name: "a123456789012345678901234567890123456789012345678901234567890123"}, | |||
| 14 | + | {name: "", wantErr: true}, | |||
| 15 | + | {name: "my work", wantErr: true}, | |||
| 16 | + | {name: "-work", wantErr: true}, | |||
| 17 | + | {name: "_work", wantErr: true}, | |||
| 18 | + | {name: ".work", wantErr: true}, | |||
| 19 | + | {name: "work/one", wantErr: true}, | |||
| 20 | + | {name: "a1234567890123456789012345678901234567890123456789012345678901234", wantErr: true}, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | err := ValidateSessionName(tt.name) | |||
| 26 | + | if tt.wantErr && err == nil { | |||
| 27 | + | t.Fatalf("ValidateSessionName(%q) = nil, want error", tt.name) | |||
| 28 | + | } | |||
| 29 | + | if !tt.wantErr && err != nil { | |||
| 30 | + | t.Fatalf("ValidateSessionName(%q) = %v, want nil", tt.name, err) | |||
| 31 | + | } | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M internal/usecase/daemon/daemon.go +4 -0
| @@ -609,6 +609,10 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 609 | 609 | d.mu.Unlock() | |
| 610 | 610 | return nil, nil, &protoErr{ports.ErrInternal, "empty session name"} | |
| 611 | 611 | } | |
| 612 | + | if err := domain.ValidateSessionName(h.Name); err != nil { | |
| 613 | + | d.mu.Unlock() | |
| 614 | + | return nil, nil, &protoErr{ports.ErrInternal, err.Error()} | |
| 615 | + | } | |
| 612 | 616 | if d.nameLiveOrStoppedLocked(h.Name) { | |
| 613 | 617 | d.mu.Unlock() | |
| 614 | 618 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | |
| @@ -609,6 +609,10 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 609 | d.mu.Unlock() | 609 | d.mu.Unlock() | ||
| 610 | return nil, nil, &protoErr{ports.ErrInternal, "empty session name"} | 610 | return nil, nil, &protoErr{ports.ErrInternal, "empty session name"} | ||
| 611 | } | 611 | } | ||
| 612 | + | if err := domain.ValidateSessionName(h.Name); err != nil { | |||
| 613 | + | d.mu.Unlock() | |||
| 614 | + | return nil, nil, &protoErr{ports.ErrInternal, err.Error()} | |||
| 615 | + | } | |||
| 612 | if d.nameLiveOrStoppedLocked(h.Name) { | 616 | if d.nameLiveOrStoppedLocked(h.Name) { | ||
| 613 | d.mu.Unlock() | 617 | d.mu.Unlock() | ||
| 614 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | 618 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | ||
M internal/usecase/daemon/session.go +6 -0
| @@ -177,6 +177,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 177 | 177 | if name == "" { | |
| 178 | 178 | return errors.New("name required") | |
| 179 | 179 | } | |
| 180 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 181 | + | return err | |
| 182 | + | } | |
| 180 | 183 | sz := ac.size | |
| 181 | 184 | d.mu.Lock() | |
| 182 | 185 | if d.closing { | |
| @@ -390,6 +393,9 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 390 | 393 | if name == "" { | |
| 391 | 394 | return errors.New("name required") | |
| 392 | 395 | } | |
| 396 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 397 | + | return err | |
| 398 | + | } | |
| 393 | 399 | d.mu.Lock() | |
| 394 | 400 | defer d.mu.Unlock() | |
| 395 | 401 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | |
| @@ -177,6 +177,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 177 | if name == "" { | 177 | if name == "" { | ||
| 178 | return errors.New("name required") | 178 | return errors.New("name required") | ||
| 179 | } | 179 | } | ||
| 180 | + | if err := domain.ValidateSessionName(name); err != nil { | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 180 | sz := ac.size | 183 | sz := ac.size | ||
| 181 | d.mu.Lock() | 184 | d.mu.Lock() | ||
| 182 | if d.closing { | 185 | if d.closing { | ||
| @@ -390,6 +393,9 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 390 | if name == "" { | 393 | if name == "" { | ||
| 391 | return errors.New("name required") | 394 | return errors.New("name required") | ||
| 392 | } | 395 | } | ||
| 396 | + | if err := domain.ValidateSessionName(name); err != nil { | |||
| 397 | + | return err | |||
| 398 | + | } | |||
| 393 | d.mu.Lock() | 399 | d.mu.Lock() | ||
| 394 | defer d.mu.Unlock() | 400 | defer d.mu.Unlock() | ||
| 395 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | 401 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | ||
M internal/usecase/daemon/session_test.go +22 -0
| @@ -900,6 +900,28 @@ func TestIntentNewStoppedNameRejected(t *testing.T) { | |||
| 900 | 900 | require.ErrorContains(t, err, "name already in use") | |
| 901 | 901 | } | |
| 902 | 902 | ||
| 903 | + | func TestIntentNewUnsafeNameRejected(t *testing.T) { | |
| 904 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 905 | + | tr := portsmocks.NewMockTransport(t) | |
| 906 | + | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |
| 907 | + | require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error()) | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) { | |
| 911 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 912 | + | require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName) | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | func TestRenameSessionUnsafeNameRejected(t *testing.T) { | |
| 916 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 917 | + | p, release := newBlockingPTY(t) | |
| 918 | + | defer release() | |
| 919 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 920 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |
| 921 | + | require.NoError(t, err) | |
| 922 | + | require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName) | |
| 923 | + | } | |
| 924 | + | ||
| 903 | 925 | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |
| 904 | 926 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 905 | 927 | p1, release1 := newBlockingPTY(t) | |
| @@ -900,6 +900,28 @@ func TestIntentNewStoppedNameRejected(t *testing.T) { | |||||
| 900 | require.ErrorContains(t, err, "name already in use") | 900 | require.ErrorContains(t, err, "name already in use") | ||
| 901 | } | 901 | } | ||
| 902 | 902 | ||||
| 903 | + | func TestIntentNewUnsafeNameRejected(t *testing.T) { | |||
| 904 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 905 | + | tr := portsmocks.NewMockTransport(t) | |||
| 906 | + | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |||
| 907 | + | require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error()) | |||
| 908 | + | } | |||
| 909 | + | ||||
| 910 | + | func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) { | |||
| 911 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 912 | + | require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName) | |||
| 913 | + | } | |||
| 914 | + | ||||
| 915 | + | func TestRenameSessionUnsafeNameRejected(t *testing.T) { | |||
| 916 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 917 | + | p, release := newBlockingPTY(t) | |||
| 918 | + | defer release() | |||
| 919 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 920 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |||
| 921 | + | require.NoError(t, err) | |||
| 922 | + | require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName) | |||
| 923 | + | } | |||
| 924 | + | ||||
| 903 | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | 925 | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | ||
| 904 | sz := domain.Size{Cols: 80, Rows: 24} | 926 | sz := domain.Size{Cols: 80, Rows: 24} | ||
| 905 | p1, release1 := newBlockingPTY(t) | 927 | p1, release1 := newBlockingPTY(t) | ||