Merge pull request #29 from bnema/feat/snapshot-restore-v2
feat(snapshot): restore named sessions with process metadata
34 files · +1965 -84
M README.md +7 -2
| @@ -35,9 +35,9 @@ vev --help show help (aliases: -h, help) | |||
| 35 | 35 | vev --version show version (alias: version) | |
| 36 | 36 | ``` | |
| 37 | 37 | ||
| 38 | - | Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. The daemon starts on first use and exits when no sessions remain. | |
| 38 | + | Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes. | |
| 39 | 39 | ||
| 40 | - | Only one client is attached to a session at a time; attaching again displaces the old client. | |
| 40 | + | The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client. | |
| 41 | 41 | ||
| 42 | 42 | ## Configuration | |
| 43 | 43 | ||
| @@ -64,6 +64,11 @@ switch-tab-7 = alt+7 | |||
| 64 | 64 | switch-tab-8 = alt+8 | |
| 65 | 65 | switch-tab-9 = alt+9 | |
| 66 | 66 | ||
| 67 | + | # Named session snapshots restore layout, cwd, visible content, scrollback, | |
| 68 | + | # and foreground processes from this allowlist. Omit the key for these defaults; | |
| 69 | + | # set it empty to disable process relaunch. | |
| 70 | + | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | |
| 71 | + | ||
| 67 | 72 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | |
| 68 | 73 | code.new-tab = CNT | |
| 69 | 74 | code.new-session = CNS | |
| @@ -35,9 +35,9 @@ vev --help show help (aliases: -h, help) | |||||
| 35 | vev --version show version (alias: version) | 35 | vev --version show version (alias: version) | ||
| 36 | ``` | 36 | ``` | ||
| 37 | 37 | ||||
| 38 | - | Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. The daemon starts on first use and exits when no sessions remain. | 38 | + | Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes. |
| 39 | 39 | ||||
| 40 | - | Only one client is attached to a session at a time; attaching again displaces the old client. | 40 | + | The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client. |
| 41 | 41 | ||||
| 42 | ## Configuration | 42 | ## Configuration | ||
| 43 | 43 | ||||
| @@ -64,6 +64,11 @@ switch-tab-7 = alt+7 | |||||
| 64 | switch-tab-8 = alt+8 | 64 | switch-tab-8 = alt+8 | ||
| 65 | switch-tab-9 = alt+9 | 65 | switch-tab-9 = alt+9 | ||
| 66 | 66 | ||||
| 67 | + | # Named session snapshots restore layout, cwd, visible content, scrollback, | |||
| 68 | + | # and foreground processes from this allowlist. Omit the key for these defaults; | |||
| 69 | + | # set it empty to disable process relaunch. | |||
| 70 | + | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | |||
| 71 | + | ||||
| 67 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | 72 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | ||
| 68 | code.new-tab = CNT | 73 | code.new-tab = CNT | ||
| 69 | code.new-session = CNS | 74 | code.new-session = CNS | ||
M boundary_test.go +5 -0
| @@ -29,6 +29,11 @@ func TestImportBoundaries(t *testing.T) { | |||
| 29 | 29 | root: "internal/usecase", | |
| 30 | 30 | disallowedPrefix: "github.com/bnema/vev/internal/adapters", | |
| 31 | 31 | }, | |
| 32 | + | { | |
| 33 | + | name: "usecase must not import platform", | |
| 34 | + | root: "internal/usecase", | |
| 35 | + | disallowedPrefix: "github.com/bnema/vev/internal/platform", | |
| 36 | + | }, | |
| 32 | 37 | } | |
| 33 | 38 | ||
| 34 | 39 | for _, rule := range rules { | |
| @@ -29,6 +29,11 @@ func TestImportBoundaries(t *testing.T) { | |||||
| 29 | root: "internal/usecase", | 29 | root: "internal/usecase", | ||
| 30 | disallowedPrefix: "github.com/bnema/vev/internal/adapters", | 30 | disallowedPrefix: "github.com/bnema/vev/internal/adapters", | ||
| 31 | }, | 31 | }, | ||
| 32 | + | { | |||
| 33 | + | name: "usecase must not import platform", | |||
| 34 | + | root: "internal/usecase", | |||
| 35 | + | disallowedPrefix: "github.com/bnema/vev/internal/platform", | |||
| 36 | + | }, | |||
| 32 | } | 37 | } | ||
| 33 | 38 | ||||
| 34 | for _, rule := range rules { | 39 | for _, rule := range rules { | ||
M internal/adapters/config/config.go +34 -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,11 @@ 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 | + | var processWarnings []domain.Warning | |
| 75 | + | cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo) | |
| 76 | + | warnings = append(warnings, processWarnings...) | |
| 77 | + | cfg.Snapshot.RestoreProcessesSet = true | |
| 70 | 78 | default: | |
| 71 | 79 | if seenBindingKeys[key] { | |
| 72 | 80 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |
| @@ -164,6 +172,32 @@ func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { | |||
| 164 | 172 | } | |
| 165 | 173 | } | |
| 166 | 174 | ||
| 175 | + | func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) { | |
| 176 | + | if strings.TrimSpace(value) == "" { | |
| 177 | + | return nil, nil | |
| 178 | + | } | |
| 179 | + | parts := strings.Split(value, ",") | |
| 180 | + | out := make([]string, 0, len(parts)) | |
| 181 | + | warnings := make([]domain.Warning, 0) | |
| 182 | + | seen := make(map[string]struct{}, len(parts)) | |
| 183 | + | for _, part := range parts { | |
| 184 | + | item := strings.TrimSpace(part) | |
| 185 | + | if item == "" { | |
| 186 | + | continue | |
| 187 | + | } | |
| 188 | + | if !processNamePattern.MatchString(item) { | |
| 189 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)}) | |
| 190 | + | continue | |
| 191 | + | } | |
| 192 | + | if _, ok := seen[item]; ok { | |
| 193 | + | continue | |
| 194 | + | } | |
| 195 | + | seen[item] = struct{}{} | |
| 196 | + | out = append(out, item) | |
| 197 | + | } | |
| 198 | + | return out, warnings | |
| 199 | + | } | |
| 200 | + | ||
| 167 | 201 | func parseTheme(value string) (domain.ThemeMode, bool) { | |
| 168 | 202 | switch strings.ToLower(strings.TrimSpace(value)) { | |
| 169 | 203 | 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,11 @@ 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 | + | var processWarnings []domain.Warning | |||
| 75 | + | cfg.Snapshot.RestoreProcesses, processWarnings = parseProcessList(value, lineNo) | |||
| 76 | + | warnings = append(warnings, processWarnings...) | |||
| 77 | + | cfg.Snapshot.RestoreProcessesSet = true | |||
| 70 | default: | 78 | default: | ||
| 71 | if seenBindingKeys[key] { | 79 | if seenBindingKeys[key] { | ||
| 72 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | 80 | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | ||
| @@ -164,6 +172,32 @@ func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { | |||||
| 164 | } | 172 | } | ||
| 165 | } | 173 | } | ||
| 166 | 174 | ||||
| 175 | + | func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) { | |||
| 176 | + | if strings.TrimSpace(value) == "" { | |||
| 177 | + | return nil, nil | |||
| 178 | + | } | |||
| 179 | + | parts := strings.Split(value, ",") | |||
| 180 | + | out := make([]string, 0, len(parts)) | |||
| 181 | + | warnings := make([]domain.Warning, 0) | |||
| 182 | + | seen := make(map[string]struct{}, len(parts)) | |||
| 183 | + | for _, part := range parts { | |||
| 184 | + | item := strings.TrimSpace(part) | |||
| 185 | + | if item == "" { | |||
| 186 | + | continue | |||
| 187 | + | } | |||
| 188 | + | if !processNamePattern.MatchString(item) { | |||
| 189 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid snapshot restore process %q", item)}) | |||
| 190 | + | continue | |||
| 191 | + | } | |||
| 192 | + | if _, ok := seen[item]; ok { | |||
| 193 | + | continue | |||
| 194 | + | } | |||
| 195 | + | seen[item] = struct{}{} | |||
| 196 | + | out = append(out, item) | |||
| 197 | + | } | |||
| 198 | + | return out, warnings | |||
| 199 | + | } | |||
| 200 | + | ||||
| 167 | func parseTheme(value string) (domain.ThemeMode, bool) { | 201 | func parseTheme(value string) (domain.ThemeMode, bool) { | ||
| 168 | switch strings.ToLower(strings.TrimSpace(value)) { | 202 | switch strings.ToLower(strings.TrimSpace(value)) { | ||
| 169 | case "auto", "": | 203 | case "auto", "": | ||
M internal/adapters/config/config_test.go +55 -0
| @@ -91,12 +91,58 @@ func TestParse(t *testing.T) { | |||
| 91 | 91 | {Line: 3, Msg: "missing key"}, | |
| 92 | 92 | }, | |
| 93 | 93 | }, | |
| 94 | + | { | |
| 95 | + | name: "snapshot restore processes", | |
| 96 | + | input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n", | |
| 97 | + | want: domain.Config{ | |
| 98 | + | Theme: domain.ThemeAuto, | |
| 99 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 100 | + | Codes: map[string]string{}, | |
| 101 | + | Snapshot: domain.SnapshotConfig{ | |
| 102 | + | RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"}, | |
| 103 | + | RestoreProcessesSet: true, | |
| 104 | + | }, | |
| 105 | + | }, | |
| 106 | + | }, | |
| 107 | + | { | |
| 108 | + | name: "snapshot restore processes empty disables", | |
| 109 | + | input: "snapshot.restore_processes =\n", | |
| 110 | + | want: domain.Config{ | |
| 111 | + | Theme: domain.ThemeAuto, | |
| 112 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 113 | + | Codes: map[string]string{}, | |
| 114 | + | Snapshot: domain.SnapshotConfig{ | |
| 115 | + | RestoreProcessesSet: true, | |
| 116 | + | }, | |
| 117 | + | }, | |
| 118 | + | }, | |
| 119 | + | { | |
| 120 | + | name: "snapshot restore processes warns on malformed entries", | |
| 121 | + | input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n", | |
| 122 | + | want: domain.Config{ | |
| 123 | + | Theme: domain.ThemeAuto, | |
| 124 | + | BindingEntries: []domain.ConfigEntry{}, | |
| 125 | + | Codes: map[string]string{}, | |
| 126 | + | Snapshot: domain.SnapshotConfig{ | |
| 127 | + | RestoreProcesses: []string{"claude", "custom-tool", "zsh"}, | |
| 128 | + | RestoreProcessesSet: true, | |
| 129 | + | }, | |
| 130 | + | }, | |
| 131 | + | wantWarnings: []domain.Warning{ | |
| 132 | + | {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""}, | |
| 133 | + | {Line: 1, Msg: "invalid snapshot restore process \":all:\""}, | |
| 134 | + | {Line: 1, Msg: "invalid snapshot restore process \"bad name\""}, | |
| 135 | + | }, | |
| 136 | + | }, | |
| 94 | 137 | } | |
| 95 | 138 | ||
| 96 | 139 | for _, tt := range tests { | |
| 97 | 140 | t.Run(tt.name, func(t *testing.T) { | |
| 98 | 141 | t.Parallel() | |
| 99 | 142 | ||
| 143 | + | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 144 | + | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | |
| 145 | + | } | |
| 100 | 146 | got, warnings, err := Parse(strings.NewReader(tt.input)) | |
| 101 | 147 | if err != nil { | |
| 102 | 148 | t.Fatalf("Parse() error = %v", err) | |
| @@ -111,6 +157,15 @@ func TestParse(t *testing.T) { | |||
| 111 | 157 | } | |
| 112 | 158 | } | |
| 113 | 159 | ||
| 160 | + | func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |
| 161 | + | t.Parallel() | |
| 162 | + | ||
| 163 | + | first := domain.Defaults() | |
| 164 | + | first.Snapshot.RestoreProcesses[0] = "mutated" | |
| 165 | + | second := domain.Defaults() | |
| 166 | + | require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0]) | |
| 167 | + | } | |
| 168 | + | ||
| 114 | 169 | func TestParsePreservesBindingFileOrder(t *testing.T) { | |
| 115 | 170 | t.Parallel() | |
| 116 | 171 | ||
| @@ -91,12 +91,58 @@ func TestParse(t *testing.T) { | |||||
| 91 | {Line: 3, Msg: "missing key"}, | 91 | {Line: 3, Msg: "missing key"}, | ||
| 92 | }, | 92 | }, | ||
| 93 | }, | 93 | }, | ||
| 94 | + | { | |||
| 95 | + | name: "snapshot restore processes", | |||
| 96 | + | input: "snapshot.restore_processes = claude, codex, pi, opencode, btop\n", | |||
| 97 | + | want: domain.Config{ | |||
| 98 | + | Theme: domain.ThemeAuto, | |||
| 99 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 100 | + | Codes: map[string]string{}, | |||
| 101 | + | Snapshot: domain.SnapshotConfig{ | |||
| 102 | + | RestoreProcesses: []string{"claude", "codex", "pi", "opencode", "btop"}, | |||
| 103 | + | RestoreProcessesSet: true, | |||
| 104 | + | }, | |||
| 105 | + | }, | |||
| 106 | + | }, | |||
| 107 | + | { | |||
| 108 | + | name: "snapshot restore processes empty disables", | |||
| 109 | + | input: "snapshot.restore_processes =\n", | |||
| 110 | + | want: domain.Config{ | |||
| 111 | + | Theme: domain.ThemeAuto, | |||
| 112 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 113 | + | Codes: map[string]string{}, | |||
| 114 | + | Snapshot: domain.SnapshotConfig{ | |||
| 115 | + | RestoreProcessesSet: true, | |||
| 116 | + | }, | |||
| 117 | + | }, | |||
| 118 | + | }, | |||
| 119 | + | { | |||
| 120 | + | name: "snapshot restore processes warns on malformed entries", | |||
| 121 | + | input: "snapshot.restore_processes = claude, custom-tool, /bin/sh, :all:, bad name, claude, , zsh\n", | |||
| 122 | + | want: domain.Config{ | |||
| 123 | + | Theme: domain.ThemeAuto, | |||
| 124 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 125 | + | Codes: map[string]string{}, | |||
| 126 | + | Snapshot: domain.SnapshotConfig{ | |||
| 127 | + | RestoreProcesses: []string{"claude", "custom-tool", "zsh"}, | |||
| 128 | + | RestoreProcessesSet: true, | |||
| 129 | + | }, | |||
| 130 | + | }, | |||
| 131 | + | wantWarnings: []domain.Warning{ | |||
| 132 | + | {Line: 1, Msg: "invalid snapshot restore process \"/bin/sh\""}, | |||
| 133 | + | {Line: 1, Msg: "invalid snapshot restore process \":all:\""}, | |||
| 134 | + | {Line: 1, Msg: "invalid snapshot restore process \"bad name\""}, | |||
| 135 | + | }, | |||
| 136 | + | }, | |||
| 94 | } | 137 | } | ||
| 95 | 138 | ||||
| 96 | for _, tt := range tests { | 139 | for _, tt := range tests { | ||
| 97 | t.Run(tt.name, func(t *testing.T) { | 140 | t.Run(tt.name, func(t *testing.T) { | ||
| 98 | t.Parallel() | 141 | t.Parallel() | ||
| 99 | 142 | ||||
| 143 | + | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |||
| 144 | + | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | |||
| 145 | + | } | |||
| 100 | got, warnings, err := Parse(strings.NewReader(tt.input)) | 146 | got, warnings, err := Parse(strings.NewReader(tt.input)) | ||
| 101 | if err != nil { | 147 | if err != nil { | ||
| 102 | t.Fatalf("Parse() error = %v", err) | 148 | t.Fatalf("Parse() error = %v", err) | ||
| @@ -111,6 +157,15 @@ func TestParse(t *testing.T) { | |||||
| 111 | } | 157 | } | ||
| 112 | } | 158 | } | ||
| 113 | 159 | ||||
| 160 | + | func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |||
| 161 | + | t.Parallel() | |||
| 162 | + | ||||
| 163 | + | first := domain.Defaults() | |||
| 164 | + | first.Snapshot.RestoreProcesses[0] = "mutated" | |||
| 165 | + | second := domain.Defaults() | |||
| 166 | + | require.Equal(t, "vi", second.Snapshot.RestoreProcesses[0]) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 114 | func TestParsePreservesBindingFileOrder(t *testing.T) { | 169 | func TestParsePreservesBindingFileOrder(t *testing.T) { | ||
| 115 | t.Parallel() | 170 | t.Parallel() | ||
| 116 | 171 | ||||
M internal/app/run.go +4 -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] == "" { | |
| @@ -281,6 +284,7 @@ func runDaemon() error { | |||
| 281 | 284 | } | |
| 282 | 285 | logConfigWarnings(log, warnings) | |
| 283 | 286 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | |
| 287 | + | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |
| 284 | 288 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | |
| 285 | 289 | storePath := persist.StorePath(platform.StateDir()) | |
| 286 | 290 | if store, err := kv.Open(storePath); err != nil { | |
| @@ -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] == "" { | ||
| @@ -281,6 +284,7 @@ func runDaemon() error { | |||||
| 281 | } | 284 | } | ||
| 282 | logConfigWarnings(log, warnings) | 285 | logConfigWarnings(log, warnings) | ||
| 283 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | 286 | daemonOpts = append(daemonOpts, daemon.WithConfig(cfg)) | ||
| 287 | + | daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome)) | |||
| 284 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | 288 | daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir()))) | ||
| 285 | storePath := persist.StorePath(platform.StateDir()) | 289 | storePath := persist.StorePath(platform.StateDir()) | ||
| 286 | if store, err := kv.Open(storePath); err != nil { | 290 | if store, err := kv.Open(storePath); err != nil { | ||
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 +21 -0
| @@ -18,6 +18,23 @@ 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 | + | ||
| 33 | + | // DefaultSnapshotRestoreProcesses returns a copy of the default process restore allowlist. | |
| 34 | + | func DefaultSnapshotRestoreProcesses() []string { | |
| 35 | + | return append([]string(nil), defaultSnapshotRestoreProcesses...) | |
| 36 | + | } | |
| 37 | + | ||
| 21 | 38 | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 22 | 39 | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 23 | 40 | // layer can decide which actions it understands. | |
| @@ -25,6 +42,7 @@ type Config struct { | |||
| 25 | 42 | Theme ThemeMode | |
| 26 | 43 | BindingEntries []ConfigEntry | |
| 27 | 44 | Codes map[string]string | |
| 45 | + | Snapshot SnapshotConfig | |
| 28 | 46 | } | |
| 29 | 47 | ||
| 30 | 48 | // Warning describes a non-fatal config problem. Parsers and reloaders should | |
| @@ -40,5 +58,8 @@ func Defaults() Config { | |||
| 40 | 58 | Theme: ThemeAuto, | |
| 41 | 59 | BindingEntries: []ConfigEntry{}, | |
| 42 | 60 | Codes: map[string]string{}, | |
| 61 | + | Snapshot: SnapshotConfig{ | |
| 62 | + | RestoreProcesses: DefaultSnapshotRestoreProcesses(), | |
| 63 | + | }, | |
| 43 | 64 | } | |
| 44 | 65 | } | |
| @@ -18,6 +18,23 @@ 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 | + | ||||
| 33 | + | // DefaultSnapshotRestoreProcesses returns a copy of the default process restore allowlist. | |||
| 34 | + | func DefaultSnapshotRestoreProcesses() []string { | |||
| 35 | + | return append([]string(nil), defaultSnapshotRestoreProcesses...) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 21 | // Config is the user-editable vev configuration after parsing. Unknown binding | 38 | // Config is the user-editable vev configuration after parsing. Unknown binding | ||
| 22 | // keys are preserved here (in BindingEntries, in file order) so the usecase | 39 | // keys are preserved here (in BindingEntries, in file order) so the usecase | ||
| 23 | // layer can decide which actions it understands. | 40 | // layer can decide which actions it understands. | ||
| @@ -25,6 +42,7 @@ type Config struct { | |||||
| 25 | Theme ThemeMode | 42 | Theme ThemeMode | ||
| 26 | BindingEntries []ConfigEntry | 43 | BindingEntries []ConfigEntry | ||
| 27 | Codes map[string]string | 44 | Codes map[string]string | ||
| 45 | + | Snapshot SnapshotConfig | |||
| 28 | } | 46 | } | ||
| 29 | 47 | ||||
| 30 | // Warning describes a non-fatal config problem. Parsers and reloaders should | 48 | // Warning describes a non-fatal config problem. Parsers and reloaders should | ||
| @@ -40,5 +58,8 @@ func Defaults() Config { | |||||
| 40 | Theme: ThemeAuto, | 58 | Theme: ThemeAuto, | ||
| 41 | BindingEntries: []ConfigEntry{}, | 59 | BindingEntries: []ConfigEntry{}, | ||
| 42 | Codes: map[string]string{}, | 60 | Codes: map[string]string{}, | ||
| 61 | + | Snapshot: SnapshotConfig{ | |||
| 62 | + | RestoreProcesses: DefaultSnapshotRestoreProcesses(), | |||
| 63 | + | }, | |||
| 43 | } | 64 | } | ||
| 44 | } | 65 | } | ||
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/platform/proc_linux.go +13 -2
| @@ -8,15 +8,26 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | // ProcessCwd returns the current working directory for a Linux process. | |
| 10 | 10 | func ProcessCwd(pid int) (string, error) { | |
| 11 | - | return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) | |
| 11 | + | return processCwd("/proc", pid) | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func processCwd(root string, pid int) (string, error) { | |
| 15 | + | if pid <= 0 { | |
| 16 | + | return "", fmt.Errorf("process cwd: invalid pid %d", pid) | |
| 17 | + | } | |
| 18 | + | return os.Readlink(fmt.Sprintf("%s/%d/cwd", root, pid)) | |
| 12 | 19 | } | |
| 13 | 20 | ||
| 14 | 21 | // ProcessComm returns the command name from /proc/<pid>/comm. | |
| 15 | 22 | func ProcessComm(pid int) (string, error) { | |
| 23 | + | return processComm("/proc", pid) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func processComm(root string, pid int) (string, error) { | |
| 16 | 27 | if pid <= 0 { | |
| 17 | 28 | return "", fmt.Errorf("process comm: invalid pid %d", pid) | |
| 18 | 29 | } | |
| 19 | - | data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) | |
| 30 | + | data, err := os.ReadFile(fmt.Sprintf("%s/%d/comm", root, pid)) | |
| 20 | 31 | if err != nil { | |
| 21 | 32 | return "", fmt.Errorf("process comm: read pid %d: %w", pid, err) | |
| 22 | 33 | } | |
| @@ -8,15 +8,26 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | // ProcessCwd returns the current working directory for a Linux process. | 9 | // ProcessCwd returns the current working directory for a Linux process. | ||
| 10 | func ProcessCwd(pid int) (string, error) { | 10 | func ProcessCwd(pid int) (string, error) { | ||
| 11 | - | return os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) | 11 | + | return processCwd("/proc", pid) |
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func processCwd(root string, pid int) (string, error) { | |||
| 15 | + | if pid <= 0 { | |||
| 16 | + | return "", fmt.Errorf("process cwd: invalid pid %d", pid) | |||
| 17 | + | } | |||
| 18 | + | return os.Readlink(fmt.Sprintf("%s/%d/cwd", root, pid)) | |||
| 12 | } | 19 | } | ||
| 13 | 20 | ||||
| 14 | // ProcessComm returns the command name from /proc/<pid>/comm. | 21 | // ProcessComm returns the command name from /proc/<pid>/comm. | ||
| 15 | func ProcessComm(pid int) (string, error) { | 22 | func ProcessComm(pid int) (string, error) { | ||
| 23 | + | return processComm("/proc", pid) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func processComm(root string, pid int) (string, error) { | |||
| 16 | if pid <= 0 { | 27 | if pid <= 0 { | ||
| 17 | return "", fmt.Errorf("process comm: invalid pid %d", pid) | 28 | return "", fmt.Errorf("process comm: invalid pid %d", pid) | ||
| 18 | } | 29 | } | ||
| 19 | - | data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) | 30 | + | data, err := os.ReadFile(fmt.Sprintf("%s/%d/comm", root, pid)) |
| 20 | if err != nil { | 31 | if err != nil { | ||
| 21 | return "", fmt.Errorf("process comm: read pid %d: %w", pid, err) | 32 | return "", fmt.Errorf("process comm: read pid %d: %w", pid, err) | ||
| 22 | } | 33 | } | ||
A internal/platform/process_linux.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | package platform | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "sort" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const processRecordCacheTTL = 250 * time.Millisecond | |
| 16 | + | ||
| 17 | + | // ProcessInspector implements ports.ProcessInspector using Linux /proc. | |
| 18 | + | type ProcessInspector struct { | |
| 19 | + | root string | |
| 20 | + | ||
| 21 | + | mu sync.Mutex | |
| 22 | + | cachedRecords []processRecord | |
| 23 | + | cachedRecordsUntil time.Time | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // NewProcessInspector returns a Linux /proc-backed process inspector. | |
| 27 | + | func NewProcessInspector() *ProcessInspector { return newProcessInspector("/proc") } | |
| 28 | + | ||
| 29 | + | func newProcessInspector(root string) *ProcessInspector { return &ProcessInspector{root: root} } | |
| 30 | + | ||
| 31 | + | func (p *ProcessInspector) Cwd(pid int) (string, error) { | |
| 32 | + | return processCwd(p.root, pid) | |
| 33 | + | } | |
| 34 | + | func (p *ProcessInspector) Comm(pid int) (string, error) { | |
| 35 | + | return processComm(p.root, pid) | |
| 36 | + | } | |
| 37 | + | func (p *ProcessInspector) Argv(pid int) ([]string, error) { | |
| 38 | + | return processArgv(filepath.Join(p.root, strconv.Itoa(pid), "cmdline"), pid) | |
| 39 | + | } | |
| 40 | + | func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) { | |
| 41 | + | recs, err := p.processRecords() | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, err | |
| 44 | + | } | |
| 45 | + | pid, ok := selectProcessGroupPID(recs, pgid, shellPid) | |
| 46 | + | if !ok { | |
| 47 | + | return nil, nil | |
| 48 | + | } | |
| 49 | + | return p.Argv(pid) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (p *ProcessInspector) processRecords() ([]processRecord, error) { | |
| 53 | + | p.mu.Lock() | |
| 54 | + | defer p.mu.Unlock() | |
| 55 | + | now := time.Now() | |
| 56 | + | if now.Before(p.cachedRecordsUntil) { | |
| 57 | + | return p.cachedRecords, nil | |
| 58 | + | } | |
| 59 | + | recs, err := readProcRecords(p.root) | |
| 60 | + | if err != nil { | |
| 61 | + | return nil, err | |
| 62 | + | } | |
| 63 | + | p.cachedRecords = recs | |
| 64 | + | p.cachedRecordsUntil = now.Add(processRecordCacheTTL) | |
| 65 | + | return recs, nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // ProcessArgv returns argv from /proc/<pid>/cmdline. | |
| 69 | + | func ProcessArgv(pid int) ([]string, error) { | |
| 70 | + | return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func processArgv(path string, pid int) ([]string, error) { | |
| 74 | + | data, err := os.ReadFile(path) | |
| 75 | + | if err != nil { | |
| 76 | + | return nil, err | |
| 77 | + | } | |
| 78 | + | data = bytes.TrimRight(data, "\x00") | |
| 79 | + | if len(data) == 0 { | |
| 80 | + | return nil, fmt.Errorf("process argv: empty argv for pid %d", pid) | |
| 81 | + | } | |
| 82 | + | parts := bytes.Split(data, []byte{0}) | |
| 83 | + | argv := make([]string, len(parts)) | |
| 84 | + | for i, part := range parts { | |
| 85 | + | argv[i] = string(part) | |
| 86 | + | } | |
| 87 | + | return argv, nil | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | type processRecord struct{ pid, pgrp int } | |
| 91 | + | ||
| 92 | + | // ProcessGroupArgv returns argv for a restorable foreground process in pgid. | |
| 93 | + | // A bare pane shell (the only process in its foreground group) returns nil argv. | |
| 94 | + | func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) { | |
| 95 | + | recs, err := readProcRecords("/proc") | |
| 96 | + | if err != nil { | |
| 97 | + | return nil, err | |
| 98 | + | } | |
| 99 | + | pid, ok := selectProcessGroupPID(recs, pgid, shellPid) | |
| 100 | + | if !ok { | |
| 101 | + | return nil, nil | |
| 102 | + | } | |
| 103 | + | return ProcessArgv(pid) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) { | |
| 107 | + | if pgid <= 0 { | |
| 108 | + | return 0, false | |
| 109 | + | } | |
| 110 | + | matches := make([]int, 0) | |
| 111 | + | for _, r := range recs { | |
| 112 | + | if r.pgrp == pgid && r.pid != shellPid { | |
| 113 | + | matches = append(matches, r.pid) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | if len(matches) == 0 { | |
| 117 | + | return 0, false | |
| 118 | + | } | |
| 119 | + | sort.Ints(matches) | |
| 120 | + | return matches[0], true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func readProcRecords(root string) ([]processRecord, error) { | |
| 124 | + | ents, err := os.ReadDir(root) | |
| 125 | + | if err != nil { | |
| 126 | + | return nil, err | |
| 127 | + | } | |
| 128 | + | var recs []processRecord | |
| 129 | + | for _, ent := range ents { | |
| 130 | + | pid, err := strconv.Atoi(ent.Name()) | |
| 131 | + | if err != nil { | |
| 132 | + | continue | |
| 133 | + | } | |
| 134 | + | data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat")) | |
| 135 | + | if err != nil { | |
| 136 | + | continue | |
| 137 | + | } | |
| 138 | + | pgrp, err := parseStatPgrp(string(data)) | |
| 139 | + | if err != nil { | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | recs = append(recs, processRecord{pid: pid, pgrp: pgrp}) | |
| 143 | + | } | |
| 144 | + | return recs, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func parseStatPgrp(stat string) (int, error) { | |
| 148 | + | end := strings.LastIndex(stat, ")") | |
| 149 | + | if end < 0 || end+2 >= len(stat) { | |
| 150 | + | return 0, fmt.Errorf("invalid stat") | |
| 151 | + | } | |
| 152 | + | fields := strings.Fields(stat[end+2:]) | |
| 153 | + | if len(fields) < 3 { | |
| 154 | + | return 0, fmt.Errorf("invalid stat") | |
| 155 | + | } | |
| 156 | + | return strconv.Atoi(fields[2]) | |
| 157 | + | } | |
| @@ -0,0 +1,157 @@ | |||||
| 1 | + | package platform | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "sort" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "time" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const processRecordCacheTTL = 250 * time.Millisecond | |||
| 16 | + | ||||
| 17 | + | // ProcessInspector implements ports.ProcessInspector using Linux /proc. | |||
| 18 | + | type ProcessInspector struct { | |||
| 19 | + | root string | |||
| 20 | + | ||||
| 21 | + | mu sync.Mutex | |||
| 22 | + | cachedRecords []processRecord | |||
| 23 | + | cachedRecordsUntil time.Time | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // NewProcessInspector returns a Linux /proc-backed process inspector. | |||
| 27 | + | func NewProcessInspector() *ProcessInspector { return newProcessInspector("/proc") } | |||
| 28 | + | ||||
| 29 | + | func newProcessInspector(root string) *ProcessInspector { return &ProcessInspector{root: root} } | |||
| 30 | + | ||||
| 31 | + | func (p *ProcessInspector) Cwd(pid int) (string, error) { | |||
| 32 | + | return processCwd(p.root, pid) | |||
| 33 | + | } | |||
| 34 | + | func (p *ProcessInspector) Comm(pid int) (string, error) { | |||
| 35 | + | return processComm(p.root, pid) | |||
| 36 | + | } | |||
| 37 | + | func (p *ProcessInspector) Argv(pid int) ([]string, error) { | |||
| 38 | + | return processArgv(filepath.Join(p.root, strconv.Itoa(pid), "cmdline"), pid) | |||
| 39 | + | } | |||
| 40 | + | func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) { | |||
| 41 | + | recs, err := p.processRecords() | |||
| 42 | + | if err != nil { | |||
| 43 | + | return nil, err | |||
| 44 | + | } | |||
| 45 | + | pid, ok := selectProcessGroupPID(recs, pgid, shellPid) | |||
| 46 | + | if !ok { | |||
| 47 | + | return nil, nil | |||
| 48 | + | } | |||
| 49 | + | return p.Argv(pid) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (p *ProcessInspector) processRecords() ([]processRecord, error) { | |||
| 53 | + | p.mu.Lock() | |||
| 54 | + | defer p.mu.Unlock() | |||
| 55 | + | now := time.Now() | |||
| 56 | + | if now.Before(p.cachedRecordsUntil) { | |||
| 57 | + | return p.cachedRecords, nil | |||
| 58 | + | } | |||
| 59 | + | recs, err := readProcRecords(p.root) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return nil, err | |||
| 62 | + | } | |||
| 63 | + | p.cachedRecords = recs | |||
| 64 | + | p.cachedRecordsUntil = now.Add(processRecordCacheTTL) | |||
| 65 | + | return recs, nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // ProcessArgv returns argv from /proc/<pid>/cmdline. | |||
| 69 | + | func ProcessArgv(pid int) ([]string, error) { | |||
| 70 | + | return processArgv(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"), pid) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func processArgv(path string, pid int) ([]string, error) { | |||
| 74 | + | data, err := os.ReadFile(path) | |||
| 75 | + | if err != nil { | |||
| 76 | + | return nil, err | |||
| 77 | + | } | |||
| 78 | + | data = bytes.TrimRight(data, "\x00") | |||
| 79 | + | if len(data) == 0 { | |||
| 80 | + | return nil, fmt.Errorf("process argv: empty argv for pid %d", pid) | |||
| 81 | + | } | |||
| 82 | + | parts := bytes.Split(data, []byte{0}) | |||
| 83 | + | argv := make([]string, len(parts)) | |||
| 84 | + | for i, part := range parts { | |||
| 85 | + | argv[i] = string(part) | |||
| 86 | + | } | |||
| 87 | + | return argv, nil | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | type processRecord struct{ pid, pgrp int } | |||
| 91 | + | ||||
| 92 | + | // ProcessGroupArgv returns argv for a restorable foreground process in pgid. | |||
| 93 | + | // A bare pane shell (the only process in its foreground group) returns nil argv. | |||
| 94 | + | func ProcessGroupArgv(pgid int, shellPid int) ([]string, error) { | |||
| 95 | + | recs, err := readProcRecords("/proc") | |||
| 96 | + | if err != nil { | |||
| 97 | + | return nil, err | |||
| 98 | + | } | |||
| 99 | + | pid, ok := selectProcessGroupPID(recs, pgid, shellPid) | |||
| 100 | + | if !ok { | |||
| 101 | + | return nil, nil | |||
| 102 | + | } | |||
| 103 | + | return ProcessArgv(pid) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func selectProcessGroupPID(recs []processRecord, pgid int, shellPid int) (int, bool) { | |||
| 107 | + | if pgid <= 0 { | |||
| 108 | + | return 0, false | |||
| 109 | + | } | |||
| 110 | + | matches := make([]int, 0) | |||
| 111 | + | for _, r := range recs { | |||
| 112 | + | if r.pgrp == pgid && r.pid != shellPid { | |||
| 113 | + | matches = append(matches, r.pid) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | if len(matches) == 0 { | |||
| 117 | + | return 0, false | |||
| 118 | + | } | |||
| 119 | + | sort.Ints(matches) | |||
| 120 | + | return matches[0], true | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func readProcRecords(root string) ([]processRecord, error) { | |||
| 124 | + | ents, err := os.ReadDir(root) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return nil, err | |||
| 127 | + | } | |||
| 128 | + | var recs []processRecord | |||
| 129 | + | for _, ent := range ents { | |||
| 130 | + | pid, err := strconv.Atoi(ent.Name()) | |||
| 131 | + | if err != nil { | |||
| 132 | + | continue | |||
| 133 | + | } | |||
| 134 | + | data, err := os.ReadFile(filepath.Join(root, ent.Name(), "stat")) | |||
| 135 | + | if err != nil { | |||
| 136 | + | continue | |||
| 137 | + | } | |||
| 138 | + | pgrp, err := parseStatPgrp(string(data)) | |||
| 139 | + | if err != nil { | |||
| 140 | + | continue | |||
| 141 | + | } | |||
| 142 | + | recs = append(recs, processRecord{pid: pid, pgrp: pgrp}) | |||
| 143 | + | } | |||
| 144 | + | return recs, nil | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func parseStatPgrp(stat string) (int, error) { | |||
| 148 | + | end := strings.LastIndex(stat, ")") | |||
| 149 | + | if end < 0 || end+2 >= len(stat) { | |||
| 150 | + | return 0, fmt.Errorf("invalid stat") | |||
| 151 | + | } | |||
| 152 | + | fields := strings.Fields(stat[end+2:]) | |||
| 153 | + | if len(fields) < 3 { | |||
| 154 | + | return 0, fmt.Errorf("invalid stat") | |||
| 155 | + | } | |||
| 156 | + | return strconv.Atoi(fields[2]) | |||
| 157 | + | } | |||
A internal/platform/process_linux_test.go +176 -0
| @@ -0,0 +1,176 @@ | |||
| 1 | + | package platform | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "reflect" | |
| 8 | + | "strconv" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestProcessCwdRejectsInvalidPID(t *testing.T) { | |
| 13 | + | if _, err := processCwd("/proc", 0); err == nil { | |
| 14 | + | t.Fatal("processCwd accepted invalid pid") | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestProcessArgv(t *testing.T) { | |
| 19 | + | argv, err := ProcessArgv(os.Getpid()) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatalf("ProcessArgv current process: %v", err) | |
| 22 | + | } | |
| 23 | + | if len(argv) == 0 { | |
| 24 | + | t.Fatal("ProcessArgv returned empty argv") | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestProcessArgvRejectsEmptyCmdline(t *testing.T) { | |
| 29 | + | path := filepath.Join(t.TempDir(), "cmdline") | |
| 30 | + | if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil { | |
| 31 | + | t.Fatal(err) | |
| 32 | + | } | |
| 33 | + | if argv, err := processArgv(path, 123); err == nil || argv != nil { | |
| 34 | + | t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestSelectProcessGroupPID(t *testing.T) { | |
| 39 | + | tests := []struct { | |
| 40 | + | name string | |
| 41 | + | recs []processRecord | |
| 42 | + | pgid int | |
| 43 | + | shell int | |
| 44 | + | wantPID int | |
| 45 | + | wantOK bool | |
| 46 | + | }{ | |
| 47 | + | { | |
| 48 | + | name: "selects other member", | |
| 49 | + | recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}}, | |
| 50 | + | pgid: 7, | |
| 51 | + | shell: 10, | |
| 52 | + | wantPID: 12, | |
| 53 | + | wantOK: true, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "bare shell has no candidate", | |
| 57 | + | recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}}, | |
| 58 | + | pgid: 7, | |
| 59 | + | shell: 10, | |
| 60 | + | wantPID: 0, | |
| 61 | + | wantOK: false, | |
| 62 | + | }, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | for _, tt := range tests { | |
| 66 | + | t.Run(tt.name, func(t *testing.T) { | |
| 67 | + | pid, ok := selectProcessGroupPID(tt.recs, tt.pgid, tt.shell) | |
| 68 | + | if ok != tt.wantOK || pid != tt.wantPID { | |
| 69 | + | t.Fatalf("selectProcessGroupPID = %d, %v; want %d, %v", pid, ok, tt.wantPID, tt.wantOK) | |
| 70 | + | } | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestProcessInspectorUsesConfiguredRoot(t *testing.T) { | |
| 76 | + | root := t.TempDir() | |
| 77 | + | writeProcStat(t, root, 10, 7) | |
| 78 | + | pidDir := filepath.Join(root, "10") | |
| 79 | + | cwdTarget := t.TempDir() | |
| 80 | + | if err := os.Symlink(cwdTarget, filepath.Join(pidDir, "cwd")); err != nil { | |
| 81 | + | t.Fatal(err) | |
| 82 | + | } | |
| 83 | + | if err := os.WriteFile(filepath.Join(pidDir, "comm"), []byte("custom\n"), 0o600); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | if err := os.WriteFile(filepath.Join(pidDir, "cmdline"), []byte("custom\x00arg\x00"), 0o600); err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | ins := newProcessInspector(root) | |
| 90 | + | ||
| 91 | + | cwd, err := ins.Cwd(10) | |
| 92 | + | if err != nil || cwd != cwdTarget { | |
| 93 | + | t.Fatalf("Cwd = %q, %v; want %q, nil", cwd, err, cwdTarget) | |
| 94 | + | } | |
| 95 | + | comm, err := ins.Comm(10) | |
| 96 | + | if err != nil || comm != "custom" { | |
| 97 | + | t.Fatalf("Comm = %q, %v; want custom, nil", comm, err) | |
| 98 | + | } | |
| 99 | + | argv, err := ins.Argv(10) | |
| 100 | + | if err != nil || !reflect.DeepEqual(argv, []string{"custom", "arg"}) { | |
| 101 | + | t.Fatalf("Argv = %#v, %v; want custom argv, nil", argv, err) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestProcessInspectorCachesProcessRecordsBriefly(t *testing.T) { | |
| 106 | + | root := t.TempDir() | |
| 107 | + | writeProcStat(t, root, 10, 7) | |
| 108 | + | ins := newProcessInspector(root) | |
| 109 | + | ||
| 110 | + | first, err := ins.processRecords() | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | writeProcStat(t, root, 12, 7) | |
| 115 | + | second, err := ins.processRecords() | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | if len(first) != 1 || len(second) != 1 { | |
| 120 | + | t.Fatalf("cached processRecords lengths = %d, %d; want 1, 1", len(first), len(second)) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func writeProcStat(t *testing.T, root string, pid int, pgrp int) { | |
| 125 | + | t.Helper() | |
| 126 | + | dir := filepath.Join(root, strconv.Itoa(pid)) | |
| 127 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | stat := fmt.Sprintf("%d (cmd) S 1 %d 1 0 0", pid, pgrp) | |
| 131 | + | if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o600); err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestParseStatPgrp(t *testing.T) { | |
| 137 | + | got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0") | |
| 138 | + | if err != nil || got != 456 { | |
| 139 | + | t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestProcessArgvFileParsing(t *testing.T) { | |
| 144 | + | tests := []struct { | |
| 145 | + | name string | |
| 146 | + | data []byte | |
| 147 | + | want []string | |
| 148 | + | }{ | |
| 149 | + | { | |
| 150 | + | name: "splits nul-delimited argv", | |
| 151 | + | data: []byte("cmd\x00two words\x00"), | |
| 152 | + | want: []string{"cmd", "two words"}, | |
| 153 | + | }, | |
| 154 | + | { | |
| 155 | + | name: "preserves empty argument", | |
| 156 | + | data: []byte("cmd\x00\x00arg\x00"), | |
| 157 | + | want: []string{"cmd", "", "arg"}, | |
| 158 | + | }, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | for _, tt := range tests { | |
| 162 | + | t.Run(tt.name, func(t *testing.T) { | |
| 163 | + | path := filepath.Join(t.TempDir(), "cmdline") | |
| 164 | + | if err := os.WriteFile(path, tt.data, 0o600); err != nil { | |
| 165 | + | t.Fatal(err) | |
| 166 | + | } | |
| 167 | + | got, err := processArgv(path, 123) | |
| 168 | + | if err != nil { | |
| 169 | + | t.Fatal(err) | |
| 170 | + | } | |
| 171 | + | if !reflect.DeepEqual(got, tt.want) { | |
| 172 | + | t.Fatalf("processArgv = %#v; want %#v", got, tt.want) | |
| 173 | + | } | |
| 174 | + | }) | |
| 175 | + | } | |
| 176 | + | } | |
| @@ -0,0 +1,176 @@ | |||||
| 1 | + | package platform | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "reflect" | |||
| 8 | + | "strconv" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestProcessCwdRejectsInvalidPID(t *testing.T) { | |||
| 13 | + | if _, err := processCwd("/proc", 0); err == nil { | |||
| 14 | + | t.Fatal("processCwd accepted invalid pid") | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestProcessArgv(t *testing.T) { | |||
| 19 | + | argv, err := ProcessArgv(os.Getpid()) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("ProcessArgv current process: %v", err) | |||
| 22 | + | } | |||
| 23 | + | if len(argv) == 0 { | |||
| 24 | + | t.Fatal("ProcessArgv returned empty argv") | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestProcessArgvRejectsEmptyCmdline(t *testing.T) { | |||
| 29 | + | path := filepath.Join(t.TempDir(), "cmdline") | |||
| 30 | + | if err := os.WriteFile(path, []byte("\x00\x00"), 0o600); err != nil { | |||
| 31 | + | t.Fatal(err) | |||
| 32 | + | } | |||
| 33 | + | if argv, err := processArgv(path, 123); err == nil || argv != nil { | |||
| 34 | + | t.Fatalf("processArgv empty = %#v, %v; want nil error", argv, err) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestSelectProcessGroupPID(t *testing.T) { | |||
| 39 | + | tests := []struct { | |||
| 40 | + | name string | |||
| 41 | + | recs []processRecord | |||
| 42 | + | pgid int | |||
| 43 | + | shell int | |||
| 44 | + | wantPID int | |||
| 45 | + | wantOK bool | |||
| 46 | + | }{ | |||
| 47 | + | { | |||
| 48 | + | name: "selects other member", | |||
| 49 | + | recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 12, pgrp: 7}, {pid: 20, pgrp: 9}}, | |||
| 50 | + | pgid: 7, | |||
| 51 | + | shell: 10, | |||
| 52 | + | wantPID: 12, | |||
| 53 | + | wantOK: true, | |||
| 54 | + | }, | |||
| 55 | + | { | |||
| 56 | + | name: "bare shell has no candidate", | |||
| 57 | + | recs: []processRecord{{pid: 10, pgrp: 7}, {pid: 20, pgrp: 9}}, | |||
| 58 | + | pgid: 7, | |||
| 59 | + | shell: 10, | |||
| 60 | + | wantPID: 0, | |||
| 61 | + | wantOK: false, | |||
| 62 | + | }, | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | for _, tt := range tests { | |||
| 66 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 67 | + | pid, ok := selectProcessGroupPID(tt.recs, tt.pgid, tt.shell) | |||
| 68 | + | if ok != tt.wantOK || pid != tt.wantPID { | |||
| 69 | + | t.Fatalf("selectProcessGroupPID = %d, %v; want %d, %v", pid, ok, tt.wantPID, tt.wantOK) | |||
| 70 | + | } | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestProcessInspectorUsesConfiguredRoot(t *testing.T) { | |||
| 76 | + | root := t.TempDir() | |||
| 77 | + | writeProcStat(t, root, 10, 7) | |||
| 78 | + | pidDir := filepath.Join(root, "10") | |||
| 79 | + | cwdTarget := t.TempDir() | |||
| 80 | + | if err := os.Symlink(cwdTarget, filepath.Join(pidDir, "cwd")); err != nil { | |||
| 81 | + | t.Fatal(err) | |||
| 82 | + | } | |||
| 83 | + | if err := os.WriteFile(filepath.Join(pidDir, "comm"), []byte("custom\n"), 0o600); err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | if err := os.WriteFile(filepath.Join(pidDir, "cmdline"), []byte("custom\x00arg\x00"), 0o600); err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | ins := newProcessInspector(root) | |||
| 90 | + | ||||
| 91 | + | cwd, err := ins.Cwd(10) | |||
| 92 | + | if err != nil || cwd != cwdTarget { | |||
| 93 | + | t.Fatalf("Cwd = %q, %v; want %q, nil", cwd, err, cwdTarget) | |||
| 94 | + | } | |||
| 95 | + | comm, err := ins.Comm(10) | |||
| 96 | + | if err != nil || comm != "custom" { | |||
| 97 | + | t.Fatalf("Comm = %q, %v; want custom, nil", comm, err) | |||
| 98 | + | } | |||
| 99 | + | argv, err := ins.Argv(10) | |||
| 100 | + | if err != nil || !reflect.DeepEqual(argv, []string{"custom", "arg"}) { | |||
| 101 | + | t.Fatalf("Argv = %#v, %v; want custom argv, nil", argv, err) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestProcessInspectorCachesProcessRecordsBriefly(t *testing.T) { | |||
| 106 | + | root := t.TempDir() | |||
| 107 | + | writeProcStat(t, root, 10, 7) | |||
| 108 | + | ins := newProcessInspector(root) | |||
| 109 | + | ||||
| 110 | + | first, err := ins.processRecords() | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | writeProcStat(t, root, 12, 7) | |||
| 115 | + | second, err := ins.processRecords() | |||
| 116 | + | if err != nil { | |||
| 117 | + | t.Fatal(err) | |||
| 118 | + | } | |||
| 119 | + | if len(first) != 1 || len(second) != 1 { | |||
| 120 | + | t.Fatalf("cached processRecords lengths = %d, %d; want 1, 1", len(first), len(second)) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func writeProcStat(t *testing.T, root string, pid int, pgrp int) { | |||
| 125 | + | t.Helper() | |||
| 126 | + | dir := filepath.Join(root, strconv.Itoa(pid)) | |||
| 127 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | stat := fmt.Sprintf("%d (cmd) S 1 %d 1 0 0", pid, pgrp) | |||
| 131 | + | if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o600); err != nil { | |||
| 132 | + | t.Fatal(err) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestParseStatPgrp(t *testing.T) { | |||
| 137 | + | got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0") | |||
| 138 | + | if err != nil || got != 456 { | |||
| 139 | + | t.Fatalf("parseStatPgrp = %d, %v; want 456, nil", got, err) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestProcessArgvFileParsing(t *testing.T) { | |||
| 144 | + | tests := []struct { | |||
| 145 | + | name string | |||
| 146 | + | data []byte | |||
| 147 | + | want []string | |||
| 148 | + | }{ | |||
| 149 | + | { | |||
| 150 | + | name: "splits nul-delimited argv", | |||
| 151 | + | data: []byte("cmd\x00two words\x00"), | |||
| 152 | + | want: []string{"cmd", "two words"}, | |||
| 153 | + | }, | |||
| 154 | + | { | |||
| 155 | + | name: "preserves empty argument", | |||
| 156 | + | data: []byte("cmd\x00\x00arg\x00"), | |||
| 157 | + | want: []string{"cmd", "", "arg"}, | |||
| 158 | + | }, | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | for _, tt := range tests { | |||
| 162 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 163 | + | path := filepath.Join(t.TempDir(), "cmdline") | |||
| 164 | + | if err := os.WriteFile(path, tt.data, 0o600); err != nil { | |||
| 165 | + | t.Fatal(err) | |||
| 166 | + | } | |||
| 167 | + | got, err := processArgv(path, 123) | |||
| 168 | + | if err != nil { | |||
| 169 | + | t.Fatal(err) | |||
| 170 | + | } | |||
| 171 | + | if !reflect.DeepEqual(got, tt.want) { | |||
| 172 | + | t.Fatalf("processArgv = %#v; want %#v", got, tt.want) | |||
| 173 | + | } | |||
| 174 | + | }) | |||
| 175 | + | } | |||
| 176 | + | } | |||
M internal/ports/mocks/mocks.go +277 -0
| @@ -2068,6 +2068,283 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||
| 2068 | 2068 | return _c | |
| 2069 | 2069 | } | |
| 2070 | 2070 | ||
| 2071 | + | // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2072 | + | // The first argument is typically a *testing.T value. | |
| 2073 | + | func NewMockProcessInspector(t interface { | |
| 2074 | + | mock.TestingT | |
| 2075 | + | Cleanup(func()) | |
| 2076 | + | }) *MockProcessInspector { | |
| 2077 | + | mock := &MockProcessInspector{} | |
| 2078 | + | mock.Mock.Test(t) | |
| 2079 | + | ||
| 2080 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2081 | + | ||
| 2082 | + | return mock | |
| 2083 | + | } | |
| 2084 | + | ||
| 2085 | + | // MockProcessInspector is an autogenerated mock type for the ProcessInspector type | |
| 2086 | + | type MockProcessInspector struct { | |
| 2087 | + | mock.Mock | |
| 2088 | + | } | |
| 2089 | + | ||
| 2090 | + | type MockProcessInspector_Expecter struct { | |
| 2091 | + | mock *mock.Mock | |
| 2092 | + | } | |
| 2093 | + | ||
| 2094 | + | func (_m *MockProcessInspector) EXPECT() *MockProcessInspector_Expecter { | |
| 2095 | + | return &MockProcessInspector_Expecter{mock: &_m.Mock} | |
| 2096 | + | } | |
| 2097 | + | ||
| 2098 | + | // Argv provides a mock function for the type MockProcessInspector | |
| 2099 | + | func (_mock *MockProcessInspector) Argv(pid int) ([]string, error) { | |
| 2100 | + | ret := _mock.Called(pid) | |
| 2101 | + | ||
| 2102 | + | if len(ret) == 0 { | |
| 2103 | + | panic("no return value specified for Argv") | |
| 2104 | + | } | |
| 2105 | + | ||
| 2106 | + | var r0 []string | |
| 2107 | + | var r1 error | |
| 2108 | + | if returnFunc, ok := ret.Get(0).(func(int) ([]string, error)); ok { | |
| 2109 | + | return returnFunc(pid) | |
| 2110 | + | } | |
| 2111 | + | if returnFunc, ok := ret.Get(0).(func(int) []string); ok { | |
| 2112 | + | r0 = returnFunc(pid) | |
| 2113 | + | } else { | |
| 2114 | + | if ret.Get(0) != nil { | |
| 2115 | + | r0 = ret.Get(0).([]string) | |
| 2116 | + | } | |
| 2117 | + | } | |
| 2118 | + | if returnFunc, ok := ret.Get(1).(func(int) error); ok { | |
| 2119 | + | r1 = returnFunc(pid) | |
| 2120 | + | } else { | |
| 2121 | + | r1 = ret.Error(1) | |
| 2122 | + | } | |
| 2123 | + | return r0, r1 | |
| 2124 | + | } | |
| 2125 | + | ||
| 2126 | + | // MockProcessInspector_Argv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Argv' | |
| 2127 | + | type MockProcessInspector_Argv_Call struct { | |
| 2128 | + | *mock.Call | |
| 2129 | + | } | |
| 2130 | + | ||
| 2131 | + | // Argv is a helper method to define mock.On call | |
| 2132 | + | // - pid int | |
| 2133 | + | func (_e *MockProcessInspector_Expecter) Argv(pid any) *MockProcessInspector_Argv_Call { | |
| 2134 | + | return &MockProcessInspector_Argv_Call{Call: _e.mock.On("Argv", pid)} | |
| 2135 | + | } | |
| 2136 | + | ||
| 2137 | + | func (_c *MockProcessInspector_Argv_Call) Run(run func(pid int)) *MockProcessInspector_Argv_Call { | |
| 2138 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2139 | + | var arg0 int | |
| 2140 | + | if args[0] != nil { | |
| 2141 | + | arg0 = args[0].(int) | |
| 2142 | + | } | |
| 2143 | + | run( | |
| 2144 | + | arg0, | |
| 2145 | + | ) | |
| 2146 | + | }) | |
| 2147 | + | return _c | |
| 2148 | + | } | |
| 2149 | + | ||
| 2150 | + | func (_c *MockProcessInspector_Argv_Call) Return(strings []string, err error) *MockProcessInspector_Argv_Call { | |
| 2151 | + | _c.Call.Return(strings, err) | |
| 2152 | + | return _c | |
| 2153 | + | } | |
| 2154 | + | ||
| 2155 | + | func (_c *MockProcessInspector_Argv_Call) RunAndReturn(run func(pid int) ([]string, error)) *MockProcessInspector_Argv_Call { | |
| 2156 | + | _c.Call.Return(run) | |
| 2157 | + | return _c | |
| 2158 | + | } | |
| 2159 | + | ||
| 2160 | + | // Comm provides a mock function for the type MockProcessInspector | |
| 2161 | + | func (_mock *MockProcessInspector) Comm(pid int) (string, error) { | |
| 2162 | + | ret := _mock.Called(pid) | |
| 2163 | + | ||
| 2164 | + | if len(ret) == 0 { | |
| 2165 | + | panic("no return value specified for Comm") | |
| 2166 | + | } | |
| 2167 | + | ||
| 2168 | + | var r0 string | |
| 2169 | + | var r1 error | |
| 2170 | + | if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok { | |
| 2171 | + | return returnFunc(pid) | |
| 2172 | + | } | |
| 2173 | + | if returnFunc, ok := ret.Get(0).(func(int) string); ok { | |
| 2174 | + | r0 = returnFunc(pid) | |
| 2175 | + | } else { | |
| 2176 | + | r0 = ret.Get(0).(string) | |
| 2177 | + | } | |
| 2178 | + | if returnFunc, ok := ret.Get(1).(func(int) error); ok { | |
| 2179 | + | r1 = returnFunc(pid) | |
| 2180 | + | } else { | |
| 2181 | + | r1 = ret.Error(1) | |
| 2182 | + | } | |
| 2183 | + | return r0, r1 | |
| 2184 | + | } | |
| 2185 | + | ||
| 2186 | + | // MockProcessInspector_Comm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comm' | |
| 2187 | + | type MockProcessInspector_Comm_Call struct { | |
| 2188 | + | *mock.Call | |
| 2189 | + | } | |
| 2190 | + | ||
| 2191 | + | // Comm is a helper method to define mock.On call | |
| 2192 | + | // - pid int | |
| 2193 | + | func (_e *MockProcessInspector_Expecter) Comm(pid any) *MockProcessInspector_Comm_Call { | |
| 2194 | + | return &MockProcessInspector_Comm_Call{Call: _e.mock.On("Comm", pid)} | |
| 2195 | + | } | |
| 2196 | + | ||
| 2197 | + | func (_c *MockProcessInspector_Comm_Call) Run(run func(pid int)) *MockProcessInspector_Comm_Call { | |
| 2198 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2199 | + | var arg0 int | |
| 2200 | + | if args[0] != nil { | |
| 2201 | + | arg0 = args[0].(int) | |
| 2202 | + | } | |
| 2203 | + | run( | |
| 2204 | + | arg0, | |
| 2205 | + | ) | |
| 2206 | + | }) | |
| 2207 | + | return _c | |
| 2208 | + | } | |
| 2209 | + | ||
| 2210 | + | func (_c *MockProcessInspector_Comm_Call) Return(s string, err error) *MockProcessInspector_Comm_Call { | |
| 2211 | + | _c.Call.Return(s, err) | |
| 2212 | + | return _c | |
| 2213 | + | } | |
| 2214 | + | ||
| 2215 | + | func (_c *MockProcessInspector_Comm_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Comm_Call { | |
| 2216 | + | _c.Call.Return(run) | |
| 2217 | + | return _c | |
| 2218 | + | } | |
| 2219 | + | ||
| 2220 | + | // Cwd provides a mock function for the type MockProcessInspector | |
| 2221 | + | func (_mock *MockProcessInspector) Cwd(pid int) (string, error) { | |
| 2222 | + | ret := _mock.Called(pid) | |
| 2223 | + | ||
| 2224 | + | if len(ret) == 0 { | |
| 2225 | + | panic("no return value specified for Cwd") | |
| 2226 | + | } | |
| 2227 | + | ||
| 2228 | + | var r0 string | |
| 2229 | + | var r1 error | |
| 2230 | + | if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok { | |
| 2231 | + | return returnFunc(pid) | |
| 2232 | + | } | |
| 2233 | + | if returnFunc, ok := ret.Get(0).(func(int) string); ok { | |
| 2234 | + | r0 = returnFunc(pid) | |
| 2235 | + | } else { | |
| 2236 | + | r0 = ret.Get(0).(string) | |
| 2237 | + | } | |
| 2238 | + | if returnFunc, ok := ret.Get(1).(func(int) error); ok { | |
| 2239 | + | r1 = returnFunc(pid) | |
| 2240 | + | } else { | |
| 2241 | + | r1 = ret.Error(1) | |
| 2242 | + | } | |
| 2243 | + | return r0, r1 | |
| 2244 | + | } | |
| 2245 | + | ||
| 2246 | + | // MockProcessInspector_Cwd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cwd' | |
| 2247 | + | type MockProcessInspector_Cwd_Call struct { | |
| 2248 | + | *mock.Call | |
| 2249 | + | } | |
| 2250 | + | ||
| 2251 | + | // Cwd is a helper method to define mock.On call | |
| 2252 | + | // - pid int | |
| 2253 | + | func (_e *MockProcessInspector_Expecter) Cwd(pid any) *MockProcessInspector_Cwd_Call { | |
| 2254 | + | return &MockProcessInspector_Cwd_Call{Call: _e.mock.On("Cwd", pid)} | |
| 2255 | + | } | |
| 2256 | + | ||
| 2257 | + | func (_c *MockProcessInspector_Cwd_Call) Run(run func(pid int)) *MockProcessInspector_Cwd_Call { | |
| 2258 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2259 | + | var arg0 int | |
| 2260 | + | if args[0] != nil { | |
| 2261 | + | arg0 = args[0].(int) | |
| 2262 | + | } | |
| 2263 | + | run( | |
| 2264 | + | arg0, | |
| 2265 | + | ) | |
| 2266 | + | }) | |
| 2267 | + | return _c | |
| 2268 | + | } | |
| 2269 | + | ||
| 2270 | + | func (_c *MockProcessInspector_Cwd_Call) Return(s string, err error) *MockProcessInspector_Cwd_Call { | |
| 2271 | + | _c.Call.Return(s, err) | |
| 2272 | + | return _c | |
| 2273 | + | } | |
| 2274 | + | ||
| 2275 | + | func (_c *MockProcessInspector_Cwd_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Cwd_Call { | |
| 2276 | + | _c.Call.Return(run) | |
| 2277 | + | return _c | |
| 2278 | + | } | |
| 2279 | + | ||
| 2280 | + | // GroupArgv provides a mock function for the type MockProcessInspector | |
| 2281 | + | func (_mock *MockProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) { | |
| 2282 | + | ret := _mock.Called(pgid, shellPid) | |
| 2283 | + | ||
| 2284 | + | if len(ret) == 0 { | |
| 2285 | + | panic("no return value specified for GroupArgv") | |
| 2286 | + | } | |
| 2287 | + | ||
| 2288 | + | var r0 []string | |
| 2289 | + | var r1 error | |
| 2290 | + | if returnFunc, ok := ret.Get(0).(func(int, int) ([]string, error)); ok { | |
| 2291 | + | return returnFunc(pgid, shellPid) | |
| 2292 | + | } | |
| 2293 | + | if returnFunc, ok := ret.Get(0).(func(int, int) []string); ok { | |
| 2294 | + | r0 = returnFunc(pgid, shellPid) | |
| 2295 | + | } else { | |
| 2296 | + | if ret.Get(0) != nil { | |
| 2297 | + | r0 = ret.Get(0).([]string) | |
| 2298 | + | } | |
| 2299 | + | } | |
| 2300 | + | if returnFunc, ok := ret.Get(1).(func(int, int) error); ok { | |
| 2301 | + | r1 = returnFunc(pgid, shellPid) | |
| 2302 | + | } else { | |
| 2303 | + | r1 = ret.Error(1) | |
| 2304 | + | } | |
| 2305 | + | return r0, r1 | |
| 2306 | + | } | |
| 2307 | + | ||
| 2308 | + | // MockProcessInspector_GroupArgv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GroupArgv' | |
| 2309 | + | type MockProcessInspector_GroupArgv_Call struct { | |
| 2310 | + | *mock.Call | |
| 2311 | + | } | |
| 2312 | + | ||
| 2313 | + | // GroupArgv is a helper method to define mock.On call | |
| 2314 | + | // - pgid int | |
| 2315 | + | // - shellPid int | |
| 2316 | + | func (_e *MockProcessInspector_Expecter) GroupArgv(pgid any, shellPid any) *MockProcessInspector_GroupArgv_Call { | |
| 2317 | + | return &MockProcessInspector_GroupArgv_Call{Call: _e.mock.On("GroupArgv", pgid, shellPid)} | |
| 2318 | + | } | |
| 2319 | + | ||
| 2320 | + | func (_c *MockProcessInspector_GroupArgv_Call) Run(run func(pgid int, shellPid int)) *MockProcessInspector_GroupArgv_Call { | |
| 2321 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2322 | + | var arg0 int | |
| 2323 | + | if args[0] != nil { | |
| 2324 | + | arg0 = args[0].(int) | |
| 2325 | + | } | |
| 2326 | + | var arg1 int | |
| 2327 | + | if args[1] != nil { | |
| 2328 | + | arg1 = args[1].(int) | |
| 2329 | + | } | |
| 2330 | + | run( | |
| 2331 | + | arg0, | |
| 2332 | + | arg1, | |
| 2333 | + | ) | |
| 2334 | + | }) | |
| 2335 | + | return _c | |
| 2336 | + | } | |
| 2337 | + | ||
| 2338 | + | func (_c *MockProcessInspector_GroupArgv_Call) Return(strings []string, err error) *MockProcessInspector_GroupArgv_Call { | |
| 2339 | + | _c.Call.Return(strings, err) | |
| 2340 | + | return _c | |
| 2341 | + | } | |
| 2342 | + | ||
| 2343 | + | func (_c *MockProcessInspector_GroupArgv_Call) RunAndReturn(run func(pgid int, shellPid int) ([]string, error)) *MockProcessInspector_GroupArgv_Call { | |
| 2344 | + | _c.Call.Return(run) | |
| 2345 | + | return _c | |
| 2346 | + | } | |
| 2347 | + | ||
| 2071 | 2348 | // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2072 | 2349 | // The first argument is typically a *testing.T value. | |
| 2073 | 2350 | func NewMockClipboardReader(t interface { | |
| @@ -2068,6 +2068,283 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal | |||||
| 2068 | return _c | 2068 | return _c | ||
| 2069 | } | 2069 | } | ||
| 2070 | 2070 | ||||
| 2071 | + | // NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 2072 | + | // The first argument is typically a *testing.T value. | |||
| 2073 | + | func NewMockProcessInspector(t interface { | |||
| 2074 | + | mock.TestingT | |||
| 2075 | + | Cleanup(func()) | |||
| 2076 | + | }) *MockProcessInspector { | |||
| 2077 | + | mock := &MockProcessInspector{} | |||
| 2078 | + | mock.Mock.Test(t) | |||
| 2079 | + | ||||
| 2080 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 2081 | + | ||||
| 2082 | + | return mock | |||
| 2083 | + | } | |||
| 2084 | + | ||||
| 2085 | + | // MockProcessInspector is an autogenerated mock type for the ProcessInspector type | |||
| 2086 | + | type MockProcessInspector struct { | |||
| 2087 | + | mock.Mock | |||
| 2088 | + | } | |||
| 2089 | + | ||||
| 2090 | + | type MockProcessInspector_Expecter struct { | |||
| 2091 | + | mock *mock.Mock | |||
| 2092 | + | } | |||
| 2093 | + | ||||
| 2094 | + | func (_m *MockProcessInspector) EXPECT() *MockProcessInspector_Expecter { | |||
| 2095 | + | return &MockProcessInspector_Expecter{mock: &_m.Mock} | |||
| 2096 | + | } | |||
| 2097 | + | ||||
| 2098 | + | // Argv provides a mock function for the type MockProcessInspector | |||
| 2099 | + | func (_mock *MockProcessInspector) Argv(pid int) ([]string, error) { | |||
| 2100 | + | ret := _mock.Called(pid) | |||
| 2101 | + | ||||
| 2102 | + | if len(ret) == 0 { | |||
| 2103 | + | panic("no return value specified for Argv") | |||
| 2104 | + | } | |||
| 2105 | + | ||||
| 2106 | + | var r0 []string | |||
| 2107 | + | var r1 error | |||
| 2108 | + | if returnFunc, ok := ret.Get(0).(func(int) ([]string, error)); ok { | |||
| 2109 | + | return returnFunc(pid) | |||
| 2110 | + | } | |||
| 2111 | + | if returnFunc, ok := ret.Get(0).(func(int) []string); ok { | |||
| 2112 | + | r0 = returnFunc(pid) | |||
| 2113 | + | } else { | |||
| 2114 | + | if ret.Get(0) != nil { | |||
| 2115 | + | r0 = ret.Get(0).([]string) | |||
| 2116 | + | } | |||
| 2117 | + | } | |||
| 2118 | + | if returnFunc, ok := ret.Get(1).(func(int) error); ok { | |||
| 2119 | + | r1 = returnFunc(pid) | |||
| 2120 | + | } else { | |||
| 2121 | + | r1 = ret.Error(1) | |||
| 2122 | + | } | |||
| 2123 | + | return r0, r1 | |||
| 2124 | + | } | |||
| 2125 | + | ||||
| 2126 | + | // MockProcessInspector_Argv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Argv' | |||
| 2127 | + | type MockProcessInspector_Argv_Call struct { | |||
| 2128 | + | *mock.Call | |||
| 2129 | + | } | |||
| 2130 | + | ||||
| 2131 | + | // Argv is a helper method to define mock.On call | |||
| 2132 | + | // - pid int | |||
| 2133 | + | func (_e *MockProcessInspector_Expecter) Argv(pid any) *MockProcessInspector_Argv_Call { | |||
| 2134 | + | return &MockProcessInspector_Argv_Call{Call: _e.mock.On("Argv", pid)} | |||
| 2135 | + | } | |||
| 2136 | + | ||||
| 2137 | + | func (_c *MockProcessInspector_Argv_Call) Run(run func(pid int)) *MockProcessInspector_Argv_Call { | |||
| 2138 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2139 | + | var arg0 int | |||
| 2140 | + | if args[0] != nil { | |||
| 2141 | + | arg0 = args[0].(int) | |||
| 2142 | + | } | |||
| 2143 | + | run( | |||
| 2144 | + | arg0, | |||
| 2145 | + | ) | |||
| 2146 | + | }) | |||
| 2147 | + | return _c | |||
| 2148 | + | } | |||
| 2149 | + | ||||
| 2150 | + | func (_c *MockProcessInspector_Argv_Call) Return(strings []string, err error) *MockProcessInspector_Argv_Call { | |||
| 2151 | + | _c.Call.Return(strings, err) | |||
| 2152 | + | return _c | |||
| 2153 | + | } | |||
| 2154 | + | ||||
| 2155 | + | func (_c *MockProcessInspector_Argv_Call) RunAndReturn(run func(pid int) ([]string, error)) *MockProcessInspector_Argv_Call { | |||
| 2156 | + | _c.Call.Return(run) | |||
| 2157 | + | return _c | |||
| 2158 | + | } | |||
| 2159 | + | ||||
| 2160 | + | // Comm provides a mock function for the type MockProcessInspector | |||
| 2161 | + | func (_mock *MockProcessInspector) Comm(pid int) (string, error) { | |||
| 2162 | + | ret := _mock.Called(pid) | |||
| 2163 | + | ||||
| 2164 | + | if len(ret) == 0 { | |||
| 2165 | + | panic("no return value specified for Comm") | |||
| 2166 | + | } | |||
| 2167 | + | ||||
| 2168 | + | var r0 string | |||
| 2169 | + | var r1 error | |||
| 2170 | + | if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok { | |||
| 2171 | + | return returnFunc(pid) | |||
| 2172 | + | } | |||
| 2173 | + | if returnFunc, ok := ret.Get(0).(func(int) string); ok { | |||
| 2174 | + | r0 = returnFunc(pid) | |||
| 2175 | + | } else { | |||
| 2176 | + | r0 = ret.Get(0).(string) | |||
| 2177 | + | } | |||
| 2178 | + | if returnFunc, ok := ret.Get(1).(func(int) error); ok { | |||
| 2179 | + | r1 = returnFunc(pid) | |||
| 2180 | + | } else { | |||
| 2181 | + | r1 = ret.Error(1) | |||
| 2182 | + | } | |||
| 2183 | + | return r0, r1 | |||
| 2184 | + | } | |||
| 2185 | + | ||||
| 2186 | + | // MockProcessInspector_Comm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comm' | |||
| 2187 | + | type MockProcessInspector_Comm_Call struct { | |||
| 2188 | + | *mock.Call | |||
| 2189 | + | } | |||
| 2190 | + | ||||
| 2191 | + | // Comm is a helper method to define mock.On call | |||
| 2192 | + | // - pid int | |||
| 2193 | + | func (_e *MockProcessInspector_Expecter) Comm(pid any) *MockProcessInspector_Comm_Call { | |||
| 2194 | + | return &MockProcessInspector_Comm_Call{Call: _e.mock.On("Comm", pid)} | |||
| 2195 | + | } | |||
| 2196 | + | ||||
| 2197 | + | func (_c *MockProcessInspector_Comm_Call) Run(run func(pid int)) *MockProcessInspector_Comm_Call { | |||
| 2198 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2199 | + | var arg0 int | |||
| 2200 | + | if args[0] != nil { | |||
| 2201 | + | arg0 = args[0].(int) | |||
| 2202 | + | } | |||
| 2203 | + | run( | |||
| 2204 | + | arg0, | |||
| 2205 | + | ) | |||
| 2206 | + | }) | |||
| 2207 | + | return _c | |||
| 2208 | + | } | |||
| 2209 | + | ||||
| 2210 | + | func (_c *MockProcessInspector_Comm_Call) Return(s string, err error) *MockProcessInspector_Comm_Call { | |||
| 2211 | + | _c.Call.Return(s, err) | |||
| 2212 | + | return _c | |||
| 2213 | + | } | |||
| 2214 | + | ||||
| 2215 | + | func (_c *MockProcessInspector_Comm_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Comm_Call { | |||
| 2216 | + | _c.Call.Return(run) | |||
| 2217 | + | return _c | |||
| 2218 | + | } | |||
| 2219 | + | ||||
| 2220 | + | // Cwd provides a mock function for the type MockProcessInspector | |||
| 2221 | + | func (_mock *MockProcessInspector) Cwd(pid int) (string, error) { | |||
| 2222 | + | ret := _mock.Called(pid) | |||
| 2223 | + | ||||
| 2224 | + | if len(ret) == 0 { | |||
| 2225 | + | panic("no return value specified for Cwd") | |||
| 2226 | + | } | |||
| 2227 | + | ||||
| 2228 | + | var r0 string | |||
| 2229 | + | var r1 error | |||
| 2230 | + | if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok { | |||
| 2231 | + | return returnFunc(pid) | |||
| 2232 | + | } | |||
| 2233 | + | if returnFunc, ok := ret.Get(0).(func(int) string); ok { | |||
| 2234 | + | r0 = returnFunc(pid) | |||
| 2235 | + | } else { | |||
| 2236 | + | r0 = ret.Get(0).(string) | |||
| 2237 | + | } | |||
| 2238 | + | if returnFunc, ok := ret.Get(1).(func(int) error); ok { | |||
| 2239 | + | r1 = returnFunc(pid) | |||
| 2240 | + | } else { | |||
| 2241 | + | r1 = ret.Error(1) | |||
| 2242 | + | } | |||
| 2243 | + | return r0, r1 | |||
| 2244 | + | } | |||
| 2245 | + | ||||
| 2246 | + | // MockProcessInspector_Cwd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cwd' | |||
| 2247 | + | type MockProcessInspector_Cwd_Call struct { | |||
| 2248 | + | *mock.Call | |||
| 2249 | + | } | |||
| 2250 | + | ||||
| 2251 | + | // Cwd is a helper method to define mock.On call | |||
| 2252 | + | // - pid int | |||
| 2253 | + | func (_e *MockProcessInspector_Expecter) Cwd(pid any) *MockProcessInspector_Cwd_Call { | |||
| 2254 | + | return &MockProcessInspector_Cwd_Call{Call: _e.mock.On("Cwd", pid)} | |||
| 2255 | + | } | |||
| 2256 | + | ||||
| 2257 | + | func (_c *MockProcessInspector_Cwd_Call) Run(run func(pid int)) *MockProcessInspector_Cwd_Call { | |||
| 2258 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2259 | + | var arg0 int | |||
| 2260 | + | if args[0] != nil { | |||
| 2261 | + | arg0 = args[0].(int) | |||
| 2262 | + | } | |||
| 2263 | + | run( | |||
| 2264 | + | arg0, | |||
| 2265 | + | ) | |||
| 2266 | + | }) | |||
| 2267 | + | return _c | |||
| 2268 | + | } | |||
| 2269 | + | ||||
| 2270 | + | func (_c *MockProcessInspector_Cwd_Call) Return(s string, err error) *MockProcessInspector_Cwd_Call { | |||
| 2271 | + | _c.Call.Return(s, err) | |||
| 2272 | + | return _c | |||
| 2273 | + | } | |||
| 2274 | + | ||||
| 2275 | + | func (_c *MockProcessInspector_Cwd_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Cwd_Call { | |||
| 2276 | + | _c.Call.Return(run) | |||
| 2277 | + | return _c | |||
| 2278 | + | } | |||
| 2279 | + | ||||
| 2280 | + | // GroupArgv provides a mock function for the type MockProcessInspector | |||
| 2281 | + | func (_mock *MockProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) { | |||
| 2282 | + | ret := _mock.Called(pgid, shellPid) | |||
| 2283 | + | ||||
| 2284 | + | if len(ret) == 0 { | |||
| 2285 | + | panic("no return value specified for GroupArgv") | |||
| 2286 | + | } | |||
| 2287 | + | ||||
| 2288 | + | var r0 []string | |||
| 2289 | + | var r1 error | |||
| 2290 | + | if returnFunc, ok := ret.Get(0).(func(int, int) ([]string, error)); ok { | |||
| 2291 | + | return returnFunc(pgid, shellPid) | |||
| 2292 | + | } | |||
| 2293 | + | if returnFunc, ok := ret.Get(0).(func(int, int) []string); ok { | |||
| 2294 | + | r0 = returnFunc(pgid, shellPid) | |||
| 2295 | + | } else { | |||
| 2296 | + | if ret.Get(0) != nil { | |||
| 2297 | + | r0 = ret.Get(0).([]string) | |||
| 2298 | + | } | |||
| 2299 | + | } | |||
| 2300 | + | if returnFunc, ok := ret.Get(1).(func(int, int) error); ok { | |||
| 2301 | + | r1 = returnFunc(pgid, shellPid) | |||
| 2302 | + | } else { | |||
| 2303 | + | r1 = ret.Error(1) | |||
| 2304 | + | } | |||
| 2305 | + | return r0, r1 | |||
| 2306 | + | } | |||
| 2307 | + | ||||
| 2308 | + | // MockProcessInspector_GroupArgv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GroupArgv' | |||
| 2309 | + | type MockProcessInspector_GroupArgv_Call struct { | |||
| 2310 | + | *mock.Call | |||
| 2311 | + | } | |||
| 2312 | + | ||||
| 2313 | + | // GroupArgv is a helper method to define mock.On call | |||
| 2314 | + | // - pgid int | |||
| 2315 | + | // - shellPid int | |||
| 2316 | + | func (_e *MockProcessInspector_Expecter) GroupArgv(pgid any, shellPid any) *MockProcessInspector_GroupArgv_Call { | |||
| 2317 | + | return &MockProcessInspector_GroupArgv_Call{Call: _e.mock.On("GroupArgv", pgid, shellPid)} | |||
| 2318 | + | } | |||
| 2319 | + | ||||
| 2320 | + | func (_c *MockProcessInspector_GroupArgv_Call) Run(run func(pgid int, shellPid int)) *MockProcessInspector_GroupArgv_Call { | |||
| 2321 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2322 | + | var arg0 int | |||
| 2323 | + | if args[0] != nil { | |||
| 2324 | + | arg0 = args[0].(int) | |||
| 2325 | + | } | |||
| 2326 | + | var arg1 int | |||
| 2327 | + | if args[1] != nil { | |||
| 2328 | + | arg1 = args[1].(int) | |||
| 2329 | + | } | |||
| 2330 | + | run( | |||
| 2331 | + | arg0, | |||
| 2332 | + | arg1, | |||
| 2333 | + | ) | |||
| 2334 | + | }) | |||
| 2335 | + | return _c | |||
| 2336 | + | } | |||
| 2337 | + | ||||
| 2338 | + | func (_c *MockProcessInspector_GroupArgv_Call) Return(strings []string, err error) *MockProcessInspector_GroupArgv_Call { | |||
| 2339 | + | _c.Call.Return(strings, err) | |||
| 2340 | + | return _c | |||
| 2341 | + | } | |||
| 2342 | + | ||||
| 2343 | + | func (_c *MockProcessInspector_GroupArgv_Call) RunAndReturn(run func(pgid int, shellPid int) ([]string, error)) *MockProcessInspector_GroupArgv_Call { | |||
| 2344 | + | _c.Call.Return(run) | |||
| 2345 | + | return _c | |||
| 2346 | + | } | |||
| 2347 | + | ||||
| 2071 | // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 2348 | // NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 2072 | // The first argument is typically a *testing.T value. | 2349 | // The first argument is typically a *testing.T value. | ||
| 2073 | func NewMockClipboardReader(t interface { | 2350 | func NewMockClipboardReader(t interface { | ||
M internal/ports/ports.go +9 -0
| @@ -96,6 +96,15 @@ type Timer interface { | |||
| 96 | 96 | Stop() bool | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | |
| 100 | + | // Implementations may live in platform packages; usecases depend on this port. | |
| 101 | + | type ProcessInspector interface { | |
| 102 | + | Cwd(pid int) (string, error) | |
| 103 | + | Comm(pid int) (string, error) | |
| 104 | + | Argv(pid int) ([]string, error) | |
| 105 | + | GroupArgv(pgid int, shellPid int) ([]string, error) | |
| 106 | + | } | |
| 107 | + | ||
| 99 | 108 | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | |
| 100 | 109 | // forwarding to a remote session's focused pane. Implementations live in | |
| 101 | 110 | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | |
| @@ -96,6 +96,15 @@ type Timer interface { | |||||
| 96 | Stop() bool | 96 | Stop() bool | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | // ProcessInspector reads process metadata for daemon snapshot/persistence use. | |||
| 100 | + | // Implementations may live in platform packages; usecases depend on this port. | |||
| 101 | + | type ProcessInspector interface { | |||
| 102 | + | Cwd(pid int) (string, error) | |||
| 103 | + | Comm(pid int) (string, error) | |||
| 104 | + | Argv(pid int) ([]string, error) | |||
| 105 | + | GroupArgv(pgid int, shellPid int) ([]string, error) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 99 | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | 108 | // ClipboardReader reads an image off the CLIENT-side system clipboard, for | ||
| 100 | // forwarding to a remote session's focused pane. Implementations live in | 109 | // forwarding to a remote session's focused pane. Implementations live in | ||
| 101 | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | 110 | // internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only | ||
M internal/ports/wire.go +6 -5
| @@ -40,11 +40,12 @@ const ( | |||
| 40 | 40 | ||
| 41 | 41 | // ErrorMsg codes. | |
| 42 | 42 | const ( | |
| 43 | - | ErrVersionMismatch uint16 = 1 | |
| 44 | - | ErrNoSuchSession uint16 = 2 | |
| 45 | - | ErrNameTaken uint16 = 3 | |
| 46 | - | ErrServerShutdown uint16 = 4 | |
| 47 | - | ErrInternal uint16 = 255 | |
| 43 | + | ErrVersionMismatch uint16 = 1 | |
| 44 | + | ErrNoSuchSession uint16 = 2 | |
| 45 | + | ErrNameTaken uint16 = 3 | |
| 46 | + | ErrServerShutdown uint16 = 4 | |
| 47 | + | ErrInvalidSessionName uint16 = 5 | |
| 48 | + | ErrInternal uint16 = 255 | |
| 48 | 49 | ) | |
| 49 | 50 | ||
| 50 | 51 | // Detached reasons. | |
| @@ -40,11 +40,12 @@ const ( | |||||
| 40 | 40 | ||||
| 41 | // ErrorMsg codes. | 41 | // ErrorMsg codes. | ||
| 42 | const ( | 42 | const ( | ||
| 43 | - | ErrVersionMismatch uint16 = 1 | 43 | + | ErrVersionMismatch uint16 = 1 |
| 44 | - | ErrNoSuchSession uint16 = 2 | 44 | + | ErrNoSuchSession uint16 = 2 |
| 45 | - | ErrNameTaken uint16 = 3 | 45 | + | ErrNameTaken uint16 = 3 |
| 46 | - | ErrServerShutdown uint16 = 4 | 46 | + | ErrServerShutdown uint16 = 4 |
| 47 | - | ErrInternal uint16 = 255 | 47 | + | ErrInvalidSessionName uint16 = 5 |
| 48 | + | ErrInternal uint16 = 255 | |||
| 48 | ) | 49 | ) | ||
| 49 | 50 | ||||
| 50 | // Detached reasons. | 51 | // Detached reasons. | ||
M internal/ports/wire_test.go +8 -6
| @@ -542,13 +542,15 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||
| 542 | 542 | ||
| 543 | 543 | t.Run("error codes", func(t *testing.T) { | |
| 544 | 544 | vals := map[uint16]string{ | |
| 545 | - | ErrVersionMismatch: "ErrVersionMismatch", | |
| 546 | - | ErrNoSuchSession: "ErrNoSuchSession", | |
| 547 | - | ErrNameTaken: "ErrNameTaken", | |
| 548 | - | ErrInternal: "ErrInternal", | |
| 545 | + | ErrVersionMismatch: "ErrVersionMismatch", | |
| 546 | + | ErrNoSuchSession: "ErrNoSuchSession", | |
| 547 | + | ErrNameTaken: "ErrNameTaken", | |
| 548 | + | ErrServerShutdown: "ErrServerShutdown", | |
| 549 | + | ErrInvalidSessionName: "ErrInvalidSessionName", | |
| 550 | + | ErrInternal: "ErrInternal", | |
| 549 | 551 | } | |
| 550 | - | if len(vals) != 4 { | |
| 551 | - | t.Fatalf("expected 4 distinct error codes, got %d", len(vals)) | |
| 552 | + | if len(vals) != 6 { | |
| 553 | + | t.Fatalf("expected 6 distinct error codes, got %d", len(vals)) | |
| 552 | 554 | } | |
| 553 | 555 | }) | |
| 554 | 556 | ||
| @@ -542,13 +542,15 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||||
| 542 | 542 | ||||
| 543 | t.Run("error codes", func(t *testing.T) { | 543 | t.Run("error codes", func(t *testing.T) { | ||
| 544 | vals := map[uint16]string{ | 544 | vals := map[uint16]string{ | ||
| 545 | - | ErrVersionMismatch: "ErrVersionMismatch", | 545 | + | ErrVersionMismatch: "ErrVersionMismatch", |
| 546 | - | ErrNoSuchSession: "ErrNoSuchSession", | 546 | + | ErrNoSuchSession: "ErrNoSuchSession", |
| 547 | - | ErrNameTaken: "ErrNameTaken", | 547 | + | ErrNameTaken: "ErrNameTaken", |
| 548 | - | ErrInternal: "ErrInternal", | 548 | + | ErrServerShutdown: "ErrServerShutdown", |
| 549 | + | ErrInvalidSessionName: "ErrInvalidSessionName", | |||
| 550 | + | ErrInternal: "ErrInternal", | |||
| 549 | } | 551 | } | ||
| 550 | - | if len(vals) != 4 { | 552 | + | if len(vals) != 6 { |
| 551 | - | t.Fatalf("expected 4 distinct error codes, got %d", len(vals)) | 553 | + | t.Fatalf("expected 6 distinct error codes, got %d", len(vals)) |
| 552 | } | 554 | } | ||
| 553 | }) | 555 | }) | ||
| 554 | 556 | ||||
M internal/usecase/daemon/config.go +34 -3
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "maps" | |
| 5 | 6 | "regexp" | |
| 6 | 7 | "sort" | |
| 7 | 8 | "strings" | |
| @@ -21,8 +22,10 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 21 | 22 | d.logConfigWarning(warning) | |
| 22 | 23 | } | |
| 23 | 24 | overrides := d.buildCodeOverrides(cfg.Codes) | |
| 25 | + | allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot) | |
| 24 | 26 | d.bindings.Store(bindings) | |
| 25 | 27 | d.codeOverrides.Store(&overrides) | |
| 28 | + | d.restoreProcessAllowlist.Store(&allowlist) | |
| 26 | 29 | d.themeMode.Store(uint32(cfg.Theme)) | |
| 27 | 30 | d.reapplyThemeAllSessions() | |
| 28 | 31 | d.repaintAllAttachedClients() | |
| @@ -56,9 +59,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||
| 56 | 59 | // *different* slug's override — that must be re-checked regardless of | |
| 57 | 60 | // where the two slugs fall in sort order. | |
| 58 | 61 | accepted := make(map[string]string, len(desired)) | |
| 59 | - | for slug, code := range desired { | |
| 60 | - | accepted[slug] = code | |
| 61 | - | } | |
| 62 | + | maps.Copy(accepted, desired) | |
| 62 | 63 | effectiveCode := func(slug string) string { | |
| 63 | 64 | if code, ok := accepted[slug]; ok { | |
| 64 | 65 | return code | |
| @@ -122,6 +123,36 @@ func sortedKeys(values map[string]string) []string { | |||
| 122 | 123 | return keys | |
| 123 | 124 | } | |
| 124 | 125 | ||
| 126 | + | func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} { | |
| 127 | + | if cfg.RestoreProcessesSet { | |
| 128 | + | return buildRestoreProcessAllowlist(cfg.RestoreProcesses) | |
| 129 | + | } | |
| 130 | + | if len(cfg.RestoreProcesses) > 0 { | |
| 131 | + | return buildRestoreProcessAllowlist(cfg.RestoreProcesses) | |
| 132 | + | } | |
| 133 | + | return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func buildRestoreProcessAllowlist(values []string) map[string]struct{} { | |
| 137 | + | allow := make(map[string]struct{}, len(values)) | |
| 138 | + | for _, value := range values { | |
| 139 | + | value = strings.TrimSpace(value) | |
| 140 | + | if value == "" { | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | allow[value] = struct{}{} | |
| 144 | + | } | |
| 145 | + | return allow | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} { | |
| 149 | + | allow := d.restoreProcessAllowlist.Load() | |
| 150 | + | if allow == nil { | |
| 151 | + | return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()) | |
| 152 | + | } | |
| 153 | + | return *allow | |
| 154 | + | } | |
| 155 | + | ||
| 125 | 156 | func (d *Daemon) logConfigWarning(w domain.Warning) { | |
| 126 | 157 | if w.Line > 0 { | |
| 127 | 158 | d.log.Warn("config warning", "line", w.Line, "msg", w.Msg) | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "maps" | |||
| 5 | "regexp" | 6 | "regexp" | ||
| 6 | "sort" | 7 | "sort" | ||
| 7 | "strings" | 8 | "strings" | ||
| @@ -21,8 +22,10 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 21 | d.logConfigWarning(warning) | 22 | d.logConfigWarning(warning) | ||
| 22 | } | 23 | } | ||
| 23 | overrides := d.buildCodeOverrides(cfg.Codes) | 24 | overrides := d.buildCodeOverrides(cfg.Codes) | ||
| 25 | + | allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot) | |||
| 24 | d.bindings.Store(bindings) | 26 | d.bindings.Store(bindings) | ||
| 25 | d.codeOverrides.Store(&overrides) | 27 | d.codeOverrides.Store(&overrides) | ||
| 28 | + | d.restoreProcessAllowlist.Store(&allowlist) | |||
| 26 | d.themeMode.Store(uint32(cfg.Theme)) | 29 | d.themeMode.Store(uint32(cfg.Theme)) | ||
| 27 | d.reapplyThemeAllSessions() | 30 | d.reapplyThemeAllSessions() | ||
| 28 | d.repaintAllAttachedClients() | 31 | d.repaintAllAttachedClients() | ||
| @@ -56,9 +59,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||||
| 56 | // *different* slug's override — that must be re-checked regardless of | 59 | // *different* slug's override — that must be re-checked regardless of | ||
| 57 | // where the two slugs fall in sort order. | 60 | // where the two slugs fall in sort order. | ||
| 58 | accepted := make(map[string]string, len(desired)) | 61 | accepted := make(map[string]string, len(desired)) | ||
| 59 | - | for slug, code := range desired { | 62 | + | maps.Copy(accepted, desired) |
| 60 | - | accepted[slug] = code | |||
| 61 | - | } | |||
| 62 | effectiveCode := func(slug string) string { | 63 | effectiveCode := func(slug string) string { | ||
| 63 | if code, ok := accepted[slug]; ok { | 64 | if code, ok := accepted[slug]; ok { | ||
| 64 | return code | 65 | return code | ||
| @@ -122,6 +123,36 @@ func sortedKeys(values map[string]string) []string { | |||||
| 122 | return keys | 123 | return keys | ||
| 123 | } | 124 | } | ||
| 124 | 125 | ||||
| 126 | + | func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} { | |||
| 127 | + | if cfg.RestoreProcessesSet { | |||
| 128 | + | return buildRestoreProcessAllowlist(cfg.RestoreProcesses) | |||
| 129 | + | } | |||
| 130 | + | if len(cfg.RestoreProcesses) > 0 { | |||
| 131 | + | return buildRestoreProcessAllowlist(cfg.RestoreProcesses) | |||
| 132 | + | } | |||
| 133 | + | return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func buildRestoreProcessAllowlist(values []string) map[string]struct{} { | |||
| 137 | + | allow := make(map[string]struct{}, len(values)) | |||
| 138 | + | for _, value := range values { | |||
| 139 | + | value = strings.TrimSpace(value) | |||
| 140 | + | if value == "" { | |||
| 141 | + | continue | |||
| 142 | + | } | |||
| 143 | + | allow[value] = struct{}{} | |||
| 144 | + | } | |||
| 145 | + | return allow | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} { | |||
| 149 | + | allow := d.restoreProcessAllowlist.Load() | |||
| 150 | + | if allow == nil { | |||
| 151 | + | return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()) | |||
| 152 | + | } | |||
| 153 | + | return *allow | |||
| 154 | + | } | |||
| 155 | + | ||||
| 125 | func (d *Daemon) logConfigWarning(w domain.Warning) { | 156 | func (d *Daemon) logConfigWarning(w domain.Warning) { | ||
| 126 | if w.Line > 0 { | 157 | if w.Line > 0 { | ||
| 127 | d.log.Warn("config warning", "line", w.Line, "msg", w.Msg) | 158 | d.log.Warn("config warning", "line", w.Line, "msg", w.Msg) | ||
M internal/usecase/daemon/config_test.go +47 -0
| @@ -49,6 +49,53 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 49 | 49 | require.Equal(t, "NT", cmd.Code) | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) { | |
| 53 | + | tests := []struct { | |
| 54 | + | name string | |
| 55 | + | cfg domain.Config | |
| 56 | + | check func(t *testing.T, allow map[string]struct{}) | |
| 57 | + | }{ | |
| 58 | + | { | |
| 59 | + | name: "default allowlist", | |
| 60 | + | check: func(t *testing.T, allow map[string]struct{}) { | |
| 61 | + | t.Helper() | |
| 62 | + | require.Contains(t, allow, "claude") | |
| 63 | + | }, | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "explicit empty disables restore", | |
| 67 | + | cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}}, | |
| 68 | + | check: func(t *testing.T, allow map[string]struct{}) { | |
| 69 | + | t.Helper() | |
| 70 | + | require.Empty(t, allow) | |
| 71 | + | }, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "explicit values are trimmed", | |
| 75 | + | cfg: domain.Config{Snapshot: domain.SnapshotConfig{ | |
| 76 | + | RestoreProcessesSet: true, | |
| 77 | + | RestoreProcesses: []string{"less", "", " pi "}, | |
| 78 | + | }}, | |
| 79 | + | check: func(t *testing.T, allow map[string]struct{}) { | |
| 80 | + | t.Helper() | |
| 81 | + | require.Contains(t, allow, "less") | |
| 82 | + | require.Contains(t, allow, "pi") | |
| 83 | + | require.NotContains(t, allow, "") | |
| 84 | + | }, | |
| 85 | + | }, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | for _, tt := range tests { | |
| 89 | + | t.Run(tt.name, func(t *testing.T) { | |
| 90 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 91 | + | if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 { | |
| 92 | + | d.ApplyConfig(tt.cfg) | |
| 93 | + | } | |
| 94 | + | tt.check(t, d.restoreProcessAllowlistSnapshot()) | |
| 95 | + | }) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 52 | 99 | func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | |
| 53 | 100 | d := newTestDaemon(t, nil, stubClock{}) | |
| 54 | 101 | d.ApplyConfig(domain.Config{ | |
| @@ -49,6 +49,53 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||||
| 49 | require.Equal(t, "NT", cmd.Code) | 49 | require.Equal(t, "NT", cmd.Code) | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) { | |||
| 53 | + | tests := []struct { | |||
| 54 | + | name string | |||
| 55 | + | cfg domain.Config | |||
| 56 | + | check func(t *testing.T, allow map[string]struct{}) | |||
| 57 | + | }{ | |||
| 58 | + | { | |||
| 59 | + | name: "default allowlist", | |||
| 60 | + | check: func(t *testing.T, allow map[string]struct{}) { | |||
| 61 | + | t.Helper() | |||
| 62 | + | require.Contains(t, allow, "claude") | |||
| 63 | + | }, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "explicit empty disables restore", | |||
| 67 | + | cfg: domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}}, | |||
| 68 | + | check: func(t *testing.T, allow map[string]struct{}) { | |||
| 69 | + | t.Helper() | |||
| 70 | + | require.Empty(t, allow) | |||
| 71 | + | }, | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | name: "explicit values are trimmed", | |||
| 75 | + | cfg: domain.Config{Snapshot: domain.SnapshotConfig{ | |||
| 76 | + | RestoreProcessesSet: true, | |||
| 77 | + | RestoreProcesses: []string{"less", "", " pi "}, | |||
| 78 | + | }}, | |||
| 79 | + | check: func(t *testing.T, allow map[string]struct{}) { | |||
| 80 | + | t.Helper() | |||
| 81 | + | require.Contains(t, allow, "less") | |||
| 82 | + | require.Contains(t, allow, "pi") | |||
| 83 | + | require.NotContains(t, allow, "") | |||
| 84 | + | }, | |||
| 85 | + | }, | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | for _, tt := range tests { | |||
| 89 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 90 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 91 | + | if tt.cfg.Snapshot.RestoreProcessesSet || len(tt.cfg.Snapshot.RestoreProcesses) > 0 { | |||
| 92 | + | d.ApplyConfig(tt.cfg) | |||
| 93 | + | } | |||
| 94 | + | tt.check(t, d.restoreProcessAllowlistSnapshot()) | |||
| 95 | + | }) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 52 | func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | 99 | func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | ||
| 53 | d := newTestDaemon(t, nil, stubClock{}) | 100 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 54 | d.ApplyConfig(domain.Config{ | 101 | d.ApplyConfig(domain.Config{ | ||
M internal/usecase/daemon/daemon.go +57 -28
| @@ -36,7 +36,6 @@ import ( | |||
| 36 | 36 | ||
| 37 | 37 | "github.com/bnema/vev/internal/domain" | |
| 38 | 38 | "github.com/bnema/vev/internal/persist" | |
| 39 | - | "github.com/bnema/vev/internal/platform" | |
| 40 | 39 | "github.com/bnema/vev/internal/ports" | |
| 41 | 40 | "github.com/bnema/vev/internal/usecase/keys" | |
| 42 | 41 | ) | |
| @@ -96,23 +95,27 @@ type Daemon struct { | |||
| 96 | 95 | paletteRecentMu sync.Mutex | |
| 97 | 96 | paletteRecent []string | |
| 98 | 97 | ||
| 99 | - | ptys ports.PTYFactory | |
| 100 | - | clock ports.Clock | |
| 101 | - | log *slog.Logger | |
| 102 | - | baseEnv []string | |
| 103 | - | shell string | |
| 104 | - | shellArgs []string | |
| 105 | - | persist *persist.Persister | |
| 106 | - | persistEnabled bool | |
| 107 | - | snaps ports.SnapshotStore | |
| 108 | - | snapsEnabled bool | |
| 109 | - | restoreDone chan struct{} | |
| 110 | - | restoreOnce sync.Once | |
| 111 | - | procCwd func(int) (string, error) | |
| 112 | - | procComm func(int) (string, error) | |
| 113 | - | bindings atomic.Pointer[keys.Bindings] | |
| 114 | - | codeOverrides atomic.Pointer[map[string]string] | |
| 115 | - | themeMode atomic.Uint32 | |
| 98 | + | ptys ports.PTYFactory | |
| 99 | + | clock ports.Clock | |
| 100 | + | log *slog.Logger | |
| 101 | + | baseEnv []string | |
| 102 | + | shell string | |
| 103 | + | shellArgs []string | |
| 104 | + | persist *persist.Persister | |
| 105 | + | persistEnabled bool | |
| 106 | + | snaps ports.SnapshotStore | |
| 107 | + | snapsEnabled bool | |
| 108 | + | restoreDone chan struct{} | |
| 109 | + | restoreOnce sync.Once | |
| 110 | + | procCwd func(int) (string, error) | |
| 111 | + | procComm func(int) (string, error) | |
| 112 | + | procArgv func(int) ([]string, error) | |
| 113 | + | procGroupArgv func(int, int) ([]string, error) | |
| 114 | + | dirOrHome func(string) string | |
| 115 | + | bindings atomic.Pointer[keys.Bindings] | |
| 116 | + | codeOverrides atomic.Pointer[map[string]string] | |
| 117 | + | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 118 | + | themeMode atomic.Uint32 | |
| 116 | 119 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 117 | 120 | // (see clipboard.go); empty means use os.TempDir(). | |
| 118 | 121 | tempDir string | |
| @@ -190,6 +193,28 @@ func WithCwdReader(fn func(int) (string, error)) Option { | |||
| 190 | 193 | } | |
| 191 | 194 | } | |
| 192 | 195 | ||
| 196 | + | // WithProcessInspector installs the platform process-inspection implementation. | |
| 197 | + | func WithProcessInspector(ins ports.ProcessInspector) Option { | |
| 198 | + | return func(d *Daemon) { | |
| 199 | + | if ins == nil { | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | d.procCwd = ins.Cwd | |
| 203 | + | d.procComm = ins.Comm | |
| 204 | + | d.procArgv = ins.Argv | |
| 205 | + | d.procGroupArgv = ins.GroupArgv | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // WithDirOrHome installs path fallback behavior from the application layer. | |
| 210 | + | func WithDirOrHome(fn func(string) string) Option { | |
| 211 | + | return func(d *Daemon) { | |
| 212 | + | if fn != nil { | |
| 213 | + | d.dirOrHome = fn | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 193 | 218 | // WithTempDir overrides the directory clipboard-image-transfer writes temp | |
| 194 | 219 | // files into (production default: os.TempDir()); tests use this with | |
| 195 | 220 | // t.TempDir() so writes are isolated and auto-cleaned. | |
| @@ -226,8 +251,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 226 | 251 | baseEnv: os.Environ(), | |
| 227 | 252 | shell: shell, | |
| 228 | 253 | persist: persist.New(nil), | |
| 229 | - | procCwd: platform.ProcessCwd, | |
| 230 | - | procComm: platform.ProcessComm, | |
| 254 | + | dirOrHome: dirOrHome, | |
| 231 | 255 | done: make(chan struct{}), | |
| 232 | 256 | restoreDone: make(chan struct{}), | |
| 233 | 257 | animWake: make(chan struct{}, 1), | |
| @@ -238,11 +262,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 238 | 262 | if d.persist == nil { | |
| 239 | 263 | d.persist = persist.New(nil) | |
| 240 | 264 | } | |
| 241 | - | if d.procCwd == nil { | |
| 242 | - | d.procCwd = platform.ProcessCwd | |
| 243 | - | } | |
| 244 | - | if d.procComm == nil { | |
| 245 | - | d.procComm = platform.ProcessComm | |
| 265 | + | if d.dirOrHome == nil { | |
| 266 | + | d.dirOrHome = dirOrHome | |
| 246 | 267 | } | |
| 247 | 268 | if d.bindings.Load() == nil { | |
| 248 | 269 | d.bindings.Store(keys.DefaultBindings()) | |
| @@ -251,6 +272,10 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 251 | 272 | empty := map[string]string{} | |
| 252 | 273 | d.codeOverrides.Store(&empty) | |
| 253 | 274 | } | |
| 275 | + | if d.restoreProcessAllowlist.Load() == nil { | |
| 276 | + | allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()) | |
| 277 | + | d.restoreProcessAllowlist.Store(&allow) | |
| 278 | + | } | |
| 254 | 279 | if records, err := d.persist.LoadAll(); err != nil { | |
| 255 | 280 | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 256 | 281 | } else { | |
| @@ -577,7 +602,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 577 | 602 | d.mu.Unlock() | |
| 578 | 603 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |
| 579 | 604 | } | |
| 580 | - | cwd := platform.DirOrHome(stopped.cwd) | |
| 605 | + | cwd := d.dirOrHome(stopped.cwd) | |
| 581 | 606 | var err error | |
| 582 | 607 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |
| 583 | 608 | if err != nil { | |
| @@ -607,7 +632,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 607 | 632 | case ports.IntentNew: | |
| 608 | 633 | if h.Name == "" { | |
| 609 | 634 | d.mu.Unlock() | |
| 610 | - | return nil, nil, &protoErr{ports.ErrInternal, "empty session name"} | |
| 635 | + | return nil, nil, &protoErr{ports.ErrInvalidSessionName, "empty session name"} | |
| 636 | + | } | |
| 637 | + | if err := domain.ValidateSessionName(h.Name); err != nil { | |
| 638 | + | d.mu.Unlock() | |
| 639 | + | return nil, nil, &protoErr{ports.ErrInvalidSessionName, err.Error()} | |
| 611 | 640 | } | |
| 612 | 641 | if d.nameLiveOrStoppedLocked(h.Name) { | |
| 613 | 642 | d.mu.Unlock() | |
| @@ -632,7 +661,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 632 | 661 | d.mu.Unlock() | |
| 633 | 662 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |
| 634 | 663 | } | |
| 635 | - | cwd := platform.DirOrHome(stopped.cwd) | |
| 664 | + | cwd := d.dirOrHome(stopped.cwd) | |
| 636 | 665 | var err error | |
| 637 | 666 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |
| 638 | 667 | if err != nil { | |
| @@ -36,7 +36,6 @@ import ( | |||||
| 36 | 36 | ||||
| 37 | "github.com/bnema/vev/internal/domain" | 37 | "github.com/bnema/vev/internal/domain" | ||
| 38 | "github.com/bnema/vev/internal/persist" | 38 | "github.com/bnema/vev/internal/persist" | ||
| 39 | - | "github.com/bnema/vev/internal/platform" | |||
| 40 | "github.com/bnema/vev/internal/ports" | 39 | "github.com/bnema/vev/internal/ports" | ||
| 41 | "github.com/bnema/vev/internal/usecase/keys" | 40 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 42 | ) | 41 | ) | ||
| @@ -96,23 +95,27 @@ type Daemon struct { | |||||
| 96 | paletteRecentMu sync.Mutex | 95 | paletteRecentMu sync.Mutex | ||
| 97 | paletteRecent []string | 96 | paletteRecent []string | ||
| 98 | 97 | ||||
| 99 | - | ptys ports.PTYFactory | 98 | + | ptys ports.PTYFactory |
| 100 | - | clock ports.Clock | 99 | + | clock ports.Clock |
| 101 | - | log *slog.Logger | 100 | + | log *slog.Logger |
| 102 | - | baseEnv []string | 101 | + | baseEnv []string |
| 103 | - | shell string | 102 | + | shell string |
| 104 | - | shellArgs []string | 103 | + | shellArgs []string |
| 105 | - | persist *persist.Persister | 104 | + | persist *persist.Persister |
| 106 | - | persistEnabled bool | 105 | + | persistEnabled bool |
| 107 | - | snaps ports.SnapshotStore | 106 | + | snaps ports.SnapshotStore |
| 108 | - | snapsEnabled bool | 107 | + | snapsEnabled bool |
| 109 | - | restoreDone chan struct{} | 108 | + | restoreDone chan struct{} |
| 110 | - | restoreOnce sync.Once | 109 | + | restoreOnce sync.Once |
| 111 | - | procCwd func(int) (string, error) | 110 | + | procCwd func(int) (string, error) |
| 112 | - | procComm func(int) (string, error) | 111 | + | procComm func(int) (string, error) |
| 113 | - | bindings atomic.Pointer[keys.Bindings] | 112 | + | procArgv func(int) ([]string, error) |
| 114 | - | codeOverrides atomic.Pointer[map[string]string] | 113 | + | procGroupArgv func(int, int) ([]string, error) |
| 115 | - | themeMode atomic.Uint32 | 114 | + | dirOrHome func(string) string |
| 115 | + | bindings atomic.Pointer[keys.Bindings] | |||
| 116 | + | codeOverrides atomic.Pointer[map[string]string] | |||
| 117 | + | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |||
| 118 | + | themeMode atomic.Uint32 | |||
| 116 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 119 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 117 | // (see clipboard.go); empty means use os.TempDir(). | 120 | // (see clipboard.go); empty means use os.TempDir(). | ||
| 118 | tempDir string | 121 | tempDir string | ||
| @@ -190,6 +193,28 @@ func WithCwdReader(fn func(int) (string, error)) Option { | |||||
| 190 | } | 193 | } | ||
| 191 | } | 194 | } | ||
| 192 | 195 | ||||
| 196 | + | // WithProcessInspector installs the platform process-inspection implementation. | |||
| 197 | + | func WithProcessInspector(ins ports.ProcessInspector) Option { | |||
| 198 | + | return func(d *Daemon) { | |||
| 199 | + | if ins == nil { | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | d.procCwd = ins.Cwd | |||
| 203 | + | d.procComm = ins.Comm | |||
| 204 | + | d.procArgv = ins.Argv | |||
| 205 | + | d.procGroupArgv = ins.GroupArgv | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // WithDirOrHome installs path fallback behavior from the application layer. | |||
| 210 | + | func WithDirOrHome(fn func(string) string) Option { | |||
| 211 | + | return func(d *Daemon) { | |||
| 212 | + | if fn != nil { | |||
| 213 | + | d.dirOrHome = fn | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 193 | // WithTempDir overrides the directory clipboard-image-transfer writes temp | 218 | // WithTempDir overrides the directory clipboard-image-transfer writes temp | ||
| 194 | // files into (production default: os.TempDir()); tests use this with | 219 | // files into (production default: os.TempDir()); tests use this with | ||
| 195 | // t.TempDir() so writes are isolated and auto-cleaned. | 220 | // t.TempDir() so writes are isolated and auto-cleaned. | ||
| @@ -226,8 +251,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 226 | baseEnv: os.Environ(), | 251 | baseEnv: os.Environ(), | ||
| 227 | shell: shell, | 252 | shell: shell, | ||
| 228 | persist: persist.New(nil), | 253 | persist: persist.New(nil), | ||
| 229 | - | procCwd: platform.ProcessCwd, | 254 | + | dirOrHome: dirOrHome, |
| 230 | - | procComm: platform.ProcessComm, | |||
| 231 | done: make(chan struct{}), | 255 | done: make(chan struct{}), | ||
| 232 | restoreDone: make(chan struct{}), | 256 | restoreDone: make(chan struct{}), | ||
| 233 | animWake: make(chan struct{}, 1), | 257 | animWake: make(chan struct{}, 1), | ||
| @@ -238,11 +262,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 238 | if d.persist == nil { | 262 | if d.persist == nil { | ||
| 239 | d.persist = persist.New(nil) | 263 | d.persist = persist.New(nil) | ||
| 240 | } | 264 | } | ||
| 241 | - | if d.procCwd == nil { | 265 | + | if d.dirOrHome == nil { |
| 242 | - | d.procCwd = platform.ProcessCwd | 266 | + | d.dirOrHome = dirOrHome |
| 243 | - | } | |||
| 244 | - | if d.procComm == nil { | |||
| 245 | - | d.procComm = platform.ProcessComm | |||
| 246 | } | 267 | } | ||
| 247 | if d.bindings.Load() == nil { | 268 | if d.bindings.Load() == nil { | ||
| 248 | d.bindings.Store(keys.DefaultBindings()) | 269 | d.bindings.Store(keys.DefaultBindings()) | ||
| @@ -251,6 +272,10 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 251 | empty := map[string]string{} | 272 | empty := map[string]string{} | ||
| 252 | d.codeOverrides.Store(&empty) | 273 | d.codeOverrides.Store(&empty) | ||
| 253 | } | 274 | } | ||
| 275 | + | if d.restoreProcessAllowlist.Load() == nil { | |||
| 276 | + | allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()) | |||
| 277 | + | d.restoreProcessAllowlist.Store(&allow) | |||
| 278 | + | } | |||
| 254 | if records, err := d.persist.LoadAll(); err != nil { | 279 | if records, err := d.persist.LoadAll(); err != nil { | ||
| 255 | d.log.Warn("loading persisted sessions failed", "err", err) | 280 | d.log.Warn("loading persisted sessions failed", "err", err) | ||
| 256 | } else { | 281 | } else { | ||
| @@ -577,7 +602,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 577 | d.mu.Unlock() | 602 | d.mu.Unlock() | ||
| 578 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | 603 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | ||
| 579 | } | 604 | } | ||
| 580 | - | cwd := platform.DirOrHome(stopped.cwd) | 605 | + | cwd := d.dirOrHome(stopped.cwd) |
| 581 | var err error | 606 | var err error | ||
| 582 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | 607 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | ||
| 583 | if err != nil { | 608 | if err != nil { | ||
| @@ -607,7 +632,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 607 | case ports.IntentNew: | 632 | case ports.IntentNew: | ||
| 608 | if h.Name == "" { | 633 | if h.Name == "" { | ||
| 609 | d.mu.Unlock() | 634 | d.mu.Unlock() | ||
| 610 | - | return nil, nil, &protoErr{ports.ErrInternal, "empty session name"} | 635 | + | return nil, nil, &protoErr{ports.ErrInvalidSessionName, "empty session name"} |
| 636 | + | } | |||
| 637 | + | if err := domain.ValidateSessionName(h.Name); err != nil { | |||
| 638 | + | d.mu.Unlock() | |||
| 639 | + | return nil, nil, &protoErr{ports.ErrInvalidSessionName, err.Error()} | |||
| 611 | } | 640 | } | ||
| 612 | if d.nameLiveOrStoppedLocked(h.Name) { | 641 | if d.nameLiveOrStoppedLocked(h.Name) { | ||
| 613 | d.mu.Unlock() | 642 | d.mu.Unlock() | ||
| @@ -632,7 +661,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 632 | d.mu.Unlock() | 661 | d.mu.Unlock() | ||
| 633 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | 662 | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | ||
| 634 | } | 663 | } | ||
| 635 | - | cwd := platform.DirOrHome(stopped.cwd) | 664 | + | cwd := d.dirOrHome(stopped.cwd) |
| 636 | var err error | 665 | var err error | ||
| 637 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | 666 | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | ||
| 638 | if err != nil { | 667 | if err != nil { | ||
A internal/usecase/daemon/dir_fallback.go +15 -0
| @@ -0,0 +1,15 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "os" | |
| 4 | + | ||
| 5 | + | func dirOrHome(dir string) string { | |
| 6 | + | if dir != "" { | |
| 7 | + | if st, err := os.Stat(dir); err == nil && st.IsDir() { | |
| 8 | + | return dir | |
| 9 | + | } | |
| 10 | + | } | |
| 11 | + | if home, err := os.UserHomeDir(); err == nil && home != "" { | |
| 12 | + | return home | |
| 13 | + | } | |
| 14 | + | return "/" | |
| 15 | + | } | |
| @@ -0,0 +1,15 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "os" | |||
| 4 | + | ||||
| 5 | + | func dirOrHome(dir string) string { | |||
| 6 | + | if dir != "" { | |||
| 7 | + | if st, err := os.Stat(dir); err == nil && st.IsDir() { | |||
| 8 | + | return dir | |||
| 9 | + | } | |||
| 10 | + | } | |||
| 11 | + | if home, err := os.UserHomeDir(); err == nil && home != "" { | |||
| 12 | + | return home | |||
| 13 | + | } | |||
| 14 | + | return "/" | |||
| 15 | + | } | |||
A internal/usecase/daemon/identity.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "encoding/base32" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "strings" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | var fallbackStableIDCounter atomic.Uint64 | |
| 14 | + | ||
| 15 | + | func newStableID(prefix string) (string, error) { | |
| 16 | + | var b [16]byte | |
| 17 | + | if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { | |
| 18 | + | return "", fmt.Errorf("generate stable id: %w", err) | |
| 19 | + | } | |
| 20 | + | enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:]) | |
| 21 | + | return prefix + "_" + strings.ToLower(enc), nil | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (d *Daemon) newTabPaneStableIDs() (string, string, error) { | |
| 25 | + | tabStableID, err := newStableID("t") | |
| 26 | + | if err != nil { | |
| 27 | + | return "", "", fmt.Errorf("daemon: generating tab identity: %w", err) | |
| 28 | + | } | |
| 29 | + | paneStableID, err := newStableID("p") | |
| 30 | + | if err != nil { | |
| 31 | + | return "", "", fmt.Errorf("daemon: generating pane identity: %w", err) | |
| 32 | + | } | |
| 33 | + | return tabStableID, paneStableID, nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func fallbackStableID(prefix string) string { | |
| 37 | + | id, err := newStableID(prefix) | |
| 38 | + | if err != nil { | |
| 39 | + | return fmt.Sprintf("%s_fallback_%x_%x", prefix, time.Now().UnixNano(), fallbackStableIDCounter.Add(1)) | |
| 40 | + | } | |
| 41 | + | return id | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func escapeVEVComponent(s string) string { | |
| 45 | + | var b strings.Builder | |
| 46 | + | for i := 0; i < len(s); i++ { | |
| 47 | + | c := s[i] | |
| 48 | + | if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' { | |
| 49 | + | b.WriteByte(c) | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | b.WriteByte('%') | |
| 53 | + | const hex = "0123456789ABCDEF" | |
| 54 | + | b.WriteByte(hex[c>>4]) | |
| 55 | + | b.WriteByte(hex[c&0x0f]) | |
| 56 | + | } | |
| 57 | + | return b.String() | |
| 58 | + | } | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "encoding/base32" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync/atomic" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | var fallbackStableIDCounter atomic.Uint64 | |||
| 14 | + | ||||
| 15 | + | func newStableID(prefix string) (string, error) { | |||
| 16 | + | var b [16]byte | |||
| 17 | + | if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { | |||
| 18 | + | return "", fmt.Errorf("generate stable id: %w", err) | |||
| 19 | + | } | |||
| 20 | + | enc := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b[:]) | |||
| 21 | + | return prefix + "_" + strings.ToLower(enc), nil | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (d *Daemon) newTabPaneStableIDs() (string, string, error) { | |||
| 25 | + | tabStableID, err := newStableID("t") | |||
| 26 | + | if err != nil { | |||
| 27 | + | return "", "", fmt.Errorf("daemon: generating tab identity: %w", err) | |||
| 28 | + | } | |||
| 29 | + | paneStableID, err := newStableID("p") | |||
| 30 | + | if err != nil { | |||
| 31 | + | return "", "", fmt.Errorf("daemon: generating pane identity: %w", err) | |||
| 32 | + | } | |||
| 33 | + | return tabStableID, paneStableID, nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func fallbackStableID(prefix string) string { | |||
| 37 | + | id, err := newStableID(prefix) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return fmt.Sprintf("%s_fallback_%x_%x", prefix, time.Now().UnixNano(), fallbackStableIDCounter.Add(1)) | |||
| 40 | + | } | |||
| 41 | + | return id | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func escapeVEVComponent(s string) string { | |||
| 45 | + | var b strings.Builder | |||
| 46 | + | for i := 0; i < len(s); i++ { | |||
| 47 | + | c := s[i] | |||
| 48 | + | if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' { | |||
| 49 | + | b.WriteByte(c) | |||
| 50 | + | continue | |||
| 51 | + | } | |||
| 52 | + | b.WriteByte('%') | |||
| 53 | + | const hex = "0123456789ABCDEF" | |||
| 54 | + | b.WriteByte(hex[c>>4]) | |||
| 55 | + | b.WriteByte(hex[c&0x0f]) | |||
| 56 | + | } | |||
| 57 | + | return b.String() | |||
| 58 | + | } | |||
M internal/usecase/daemon/pane.go +6 -0
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | |
| 19 | 19 | type pane struct { | |
| 20 | 20 | id layout.PaneID | |
| 21 | + | stableID string | |
| 21 | 22 | pty ports.PTY | |
| 22 | 23 | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | |
| 23 | 24 | screen *vt.Screen | |
| @@ -34,11 +35,16 @@ type pane struct { | |||
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | |
| 38 | + | return newPaneWithStableID(id, fallbackStableID("p"), pty, sz) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | |
| 37 | 42 | sb := scopy.NewScrollback(defaultScrollbackRows) | |
| 38 | 43 | screen := vt.NewScreen(sz.Cols, sz.Rows) | |
| 39 | 44 | screen.OnLineEvicted = sb.Append | |
| 40 | 45 | return &pane{ | |
| 41 | 46 | id: id, | |
| 47 | + | stableID: stableID, | |
| 42 | 48 | pty: pty, | |
| 43 | 49 | screen: screen, | |
| 44 | 50 | scrollback: sb, | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | 18 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | ||
| 19 | type pane struct { | 19 | type pane struct { | ||
| 20 | id layout.PaneID | 20 | id layout.PaneID | ||
| 21 | + | stableID string | |||
| 21 | pty ports.PTY | 22 | pty ports.PTY | ||
| 22 | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | 23 | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | ||
| 23 | screen *vt.Screen | 24 | screen *vt.Screen | ||
| @@ -34,11 +35,16 @@ type pane struct { | |||||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | 37 | func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | ||
| 38 | + | return newPaneWithStableID(id, fallbackStableID("p"), pty, sz) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane { | |||
| 37 | sb := scopy.NewScrollback(defaultScrollbackRows) | 42 | sb := scopy.NewScrollback(defaultScrollbackRows) | ||
| 38 | screen := vt.NewScreen(sz.Cols, sz.Rows) | 43 | screen := vt.NewScreen(sz.Cols, sz.Rows) | ||
| 39 | screen.OnLineEvicted = sb.Append | 44 | screen.OnLineEvicted = sb.Append | ||
| 40 | return &pane{ | 45 | return &pane{ | ||
| 41 | id: id, | 46 | id: id, | ||
| 47 | + | stableID: stableID, | |||
| 42 | pty: pty, | 48 | pty: pty, | ||
| 43 | screen: screen, | 49 | screen: screen, | ||
| 44 | scrollback: sb, | 50 | scrollback: sb, | ||
M internal/usecase/daemon/pane_actions.go +11 -2
| @@ -57,9 +57,18 @@ func (d *Daemon) spawnPaneOp( | |||
| 57 | 57 | return layout.ErrTooSmall | |
| 58 | 58 | } | |
| 59 | 59 | newRect := placementContent(placements, newID) | |
| 60 | + | tabStableID := tb.stableID | |
| 60 | 61 | tb.mu.Unlock() | |
| 61 | 62 | ||
| 62 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect)) | |
| 63 | + | paneStableID, err := newStableID("p") | |
| 64 | + | if err != nil { | |
| 65 | + | tb.mu.Lock() | |
| 66 | + | _ = tb.tree.Close(newID) | |
| 67 | + | tb.tree.Focus = oldFocus | |
| 68 | + | tb.mu.Unlock() | |
| 69 | + | return fmt.Errorf("daemon: generating pane identity: %w", err) | |
| 70 | + | } | |
| 71 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, rectSize(newRect)) | |
| 63 | 72 | if err != nil { | |
| 64 | 73 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 65 | 74 | tb.mu.Lock() | |
| @@ -70,7 +79,7 @@ func (d *Daemon) spawnPaneOp( | |||
| 70 | 79 | } | |
| 71 | 80 | ||
| 72 | 81 | pctx, cancel := context.WithCancel(tb.ctx) | |
| 73 | - | p := newPane(newID, pty, rectSize(newRect)) | |
| 82 | + | p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect)) | |
| 74 | 83 | p.ctx, p.cancel = pctx, cancel | |
| 75 | 84 | p.rect = newRect | |
| 76 | 85 | ||
| @@ -57,9 +57,18 @@ func (d *Daemon) spawnPaneOp( | |||||
| 57 | return layout.ErrTooSmall | 57 | return layout.ErrTooSmall | ||
| 58 | } | 58 | } | ||
| 59 | newRect := placementContent(placements, newID) | 59 | newRect := placementContent(placements, newID) | ||
| 60 | + | tabStableID := tb.stableID | |||
| 60 | tb.mu.Unlock() | 61 | tb.mu.Unlock() | ||
| 61 | 62 | ||||
| 62 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, rectSize(newRect)) | 63 | + | paneStableID, err := newStableID("p") |
| 64 | + | if err != nil { | |||
| 65 | + | tb.mu.Lock() | |||
| 66 | + | _ = tb.tree.Close(newID) | |||
| 67 | + | tb.tree.Focus = oldFocus | |||
| 68 | + | tb.mu.Unlock() | |||
| 69 | + | return fmt.Errorf("daemon: generating pane identity: %w", err) | |||
| 70 | + | } | |||
| 71 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, rectSize(newRect)) | |||
| 63 | if err != nil { | 72 | if err != nil { | ||
| 64 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | 73 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | ||
| 65 | tb.mu.Lock() | 74 | tb.mu.Lock() | ||
| @@ -70,7 +79,7 @@ func (d *Daemon) spawnPaneOp( | |||||
| 70 | } | 79 | } | ||
| 71 | 80 | ||||
| 72 | pctx, cancel := context.WithCancel(tb.ctx) | 81 | pctx, cancel := context.WithCancel(tb.ctx) | ||
| 73 | - | p := newPane(newID, pty, rectSize(newRect)) | 82 | + | p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect)) |
| 74 | p.ctx, p.cancel = pctx, cancel | 83 | p.ctx, p.cancel = pctx, cancel | ||
| 75 | p.rect = newRect | 84 | p.rect = newRect | ||
| 76 | 85 | ||||
M internal/usecase/daemon/picker.go +1 -2
| @@ -29,7 +29,6 @@ import ( | |||
| 29 | 29 | "strconv" | |
| 30 | 30 | ||
| 31 | 31 | "github.com/bnema/vev/internal/domain" | |
| 32 | - | "github.com/bnema/vev/internal/platform" | |
| 33 | 32 | "github.com/bnema/vev/internal/ports" | |
| 34 | 33 | "github.com/bnema/vev/internal/usecase/keys" | |
| 35 | 34 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -421,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 421 | 420 | d.mu.Unlock() | |
| 422 | 421 | return | |
| 423 | 422 | } | |
| 424 | - | cwd := platform.DirOrHome(stopped.cwd) | |
| 423 | + | cwd := d.dirOrHome(stopped.cwd) | |
| 425 | 424 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | |
| 426 | 425 | if err != nil { | |
| 427 | 426 | from.mu.Unlock() | |
| @@ -29,7 +29,6 @@ import ( | |||||
| 29 | "strconv" | 29 | "strconv" | ||
| 30 | 30 | ||||
| 31 | "github.com/bnema/vev/internal/domain" | 31 | "github.com/bnema/vev/internal/domain" | ||
| 32 | - | "github.com/bnema/vev/internal/platform" | |||
| 33 | "github.com/bnema/vev/internal/ports" | 32 | "github.com/bnema/vev/internal/ports" | ||
| 34 | "github.com/bnema/vev/internal/usecase/keys" | 33 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 35 | "github.com/bnema/vev/internal/usecase/layout" | 34 | "github.com/bnema/vev/internal/usecase/layout" | ||
| @@ -421,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 421 | d.mu.Unlock() | 420 | d.mu.Unlock() | ||
| 422 | return | 421 | return | ||
| 423 | } | 422 | } | ||
| 424 | - | cwd := platform.DirOrHome(stopped.cwd) | 423 | + | cwd := d.dirOrHome(stopped.cwd) |
| 425 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | 424 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size) | ||
| 426 | if err != nil { | 425 | if err != nil { | ||
| 427 | from.mu.Unlock() | 426 | from.mu.Unlock() | ||
A internal/usecase/daemon/process_restore.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "strings" | |
| 6 | + | "unicode" | |
| 7 | + | ||
| 8 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | processStrategyGeneric = "generic" | |
| 13 | + | processStrategyClaude = "claude" | |
| 14 | + | processStrategyCodex = "codex" | |
| 15 | + | processStrategyOpenCode = "opencode" | |
| 16 | + | processStrategyPi = "pi" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type processRestoreDecision struct { | |
| 20 | + | Command string | |
| 21 | + | Restore bool | |
| 22 | + | Reason string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func detectProcessStrategy(argv []string) string { | |
| 26 | + | if len(argv) == 0 { | |
| 27 | + | return processStrategyGeneric | |
| 28 | + | } | |
| 29 | + | switch filepath.Base(argv[0]) { | |
| 30 | + | case "claude": | |
| 31 | + | return processStrategyClaude | |
| 32 | + | case "codex": | |
| 33 | + | return processStrategyCodex | |
| 34 | + | case "opencode": | |
| 35 | + | return processStrategyOpenCode | |
| 36 | + | case "pi": | |
| 37 | + | return processStrategyPi | |
| 38 | + | default: | |
| 39 | + | return processStrategyGeneric | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func extractAgentSessionID(strategy string, argv []string) string { | |
| 44 | + | for i, arg := range argv { | |
| 45 | + | if !agentSessionFlag(strategy, arg) || i+1 >= len(argv) { | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | candidate := argv[i+1] | |
| 49 | + | if candidate != "" && !strings.HasPrefix(candidate, "-") && !containsControl(candidate) { | |
| 50 | + | return candidate | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | return "" | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func agentSessionFlag(strategy, arg string) bool { | |
| 57 | + | switch strategy { | |
| 58 | + | case processStrategyClaude: | |
| 59 | + | return arg == "--session-id" || arg == "--resume" || arg == "-r" | |
| 60 | + | case processStrategyOpenCode: | |
| 61 | + | return arg == "-s" || arg == "--session" | |
| 62 | + | case processStrategyCodex: | |
| 63 | + | return arg == "resume" | |
| 64 | + | case processStrategyPi: | |
| 65 | + | return arg == "--resume" || arg == "-r" | |
| 66 | + | default: | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) processRestoreDecision { | |
| 72 | + | if proc == nil || len(proc.Argv) == 0 || proc.Argv[0] == "" { | |
| 73 | + | return processRestoreDecision{Reason: "missing_process"} | |
| 74 | + | } | |
| 75 | + | strategy := normalizeProcessStrategy(proc.Strategy, proc.Argv) | |
| 76 | + | if _, ok := allow[processAllowlistKey(strategy, proc.Argv)]; !ok { | |
| 77 | + | return processRestoreDecision{Reason: "not_allowlisted"} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | id := proc.Opts.AgentSessionID | |
| 81 | + | if containsControl(id) { | |
| 82 | + | return processRestoreDecision{Reason: "invalid_agent_session_id"} | |
| 83 | + | } | |
| 84 | + | switch strategy { | |
| 85 | + | case processStrategyPi: | |
| 86 | + | if id != "" { | |
| 87 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true} | |
| 88 | + | } | |
| 89 | + | return processRestoreDecision{Command: "pi --continue", Restore: true} | |
| 90 | + | case processStrategyClaude: | |
| 91 | + | if id != "" { | |
| 92 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"claude", "--resume", id}), Restore: true} | |
| 93 | + | } | |
| 94 | + | return processRestoreDecision{Command: "claude --continue", Restore: true} | |
| 95 | + | case processStrategyOpenCode: | |
| 96 | + | if id != "" { | |
| 97 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"opencode", "--session", id}), Restore: true} | |
| 98 | + | } | |
| 99 | + | return processRestoreDecision{Command: "opencode --continue", Restore: true} | |
| 100 | + | case processStrategyCodex: | |
| 101 | + | if id != "" { | |
| 102 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"codex", "resume", id}), Restore: true} | |
| 103 | + | } | |
| 104 | + | return processRestoreDecision{Command: "codex resume --last", Restore: true} | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | command, ok := shellQuoteArgv(proc.Argv) | |
| 108 | + | if !ok { | |
| 109 | + | return processRestoreDecision{Reason: "empty_command"} | |
| 110 | + | } | |
| 111 | + | return processRestoreDecision{Command: command, Restore: true} | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func normalizeProcessStrategy(strategy string, argv []string) string { | |
| 115 | + | switch strategy { | |
| 116 | + | case processStrategyClaude, processStrategyCodex, processStrategyOpenCode, processStrategyPi: | |
| 117 | + | return strategy | |
| 118 | + | case "", processStrategyGeneric: | |
| 119 | + | return detectProcessStrategy(argv) | |
| 120 | + | default: | |
| 121 | + | return processStrategyGeneric | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func processAllowlistKey(strategy string, argv []string) string { | |
| 126 | + | switch strategy { | |
| 127 | + | case processStrategyClaude, processStrategyCodex, processStrategyOpenCode, processStrategyPi: | |
| 128 | + | return strategy | |
| 129 | + | default: | |
| 130 | + | return filepath.Base(argv[0]) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func containsControl(s string) bool { | |
| 135 | + | return strings.IndexFunc(s, unicode.IsControl) >= 0 | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func shellQuoteArgvMust(argv []string) string { | |
| 139 | + | command, _ := shellQuoteArgv(argv) | |
| 140 | + | return command | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func shellQuoteArgv(argv []string) (string, bool) { | |
| 144 | + | if len(argv) == 0 { | |
| 145 | + | return "", false | |
| 146 | + | } | |
| 147 | + | quoted := make([]string, 0, len(argv)) | |
| 148 | + | for _, arg := range argv { | |
| 149 | + | quoted = append(quoted, shellQuote(arg)) | |
| 150 | + | } | |
| 151 | + | return strings.Join(quoted, " "), true | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func shellQuote(s string) string { | |
| 155 | + | if s == "" { | |
| 156 | + | return "''" | |
| 157 | + | } | |
| 158 | + | if strings.IndexFunc(s, func(r rune) bool { | |
| 159 | + | return r != '-' && r != '_' && r != '.' && r != '/' && r != ':' && r != '+' && r != '=' && !unicode.IsLetter(r) && !unicode.IsDigit(r) | |
| 160 | + | }) == -1 { | |
| 161 | + | return s | |
| 162 | + | } | |
| 163 | + | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |
| 164 | + | } | |
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "strings" | |||
| 6 | + | "unicode" | |||
| 7 | + | ||||
| 8 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | processStrategyGeneric = "generic" | |||
| 13 | + | processStrategyClaude = "claude" | |||
| 14 | + | processStrategyCodex = "codex" | |||
| 15 | + | processStrategyOpenCode = "opencode" | |||
| 16 | + | processStrategyPi = "pi" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | type processRestoreDecision struct { | |||
| 20 | + | Command string | |||
| 21 | + | Restore bool | |||
| 22 | + | Reason string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func detectProcessStrategy(argv []string) string { | |||
| 26 | + | if len(argv) == 0 { | |||
| 27 | + | return processStrategyGeneric | |||
| 28 | + | } | |||
| 29 | + | switch filepath.Base(argv[0]) { | |||
| 30 | + | case "claude": | |||
| 31 | + | return processStrategyClaude | |||
| 32 | + | case "codex": | |||
| 33 | + | return processStrategyCodex | |||
| 34 | + | case "opencode": | |||
| 35 | + | return processStrategyOpenCode | |||
| 36 | + | case "pi": | |||
| 37 | + | return processStrategyPi | |||
| 38 | + | default: | |||
| 39 | + | return processStrategyGeneric | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func extractAgentSessionID(strategy string, argv []string) string { | |||
| 44 | + | for i, arg := range argv { | |||
| 45 | + | if !agentSessionFlag(strategy, arg) || i+1 >= len(argv) { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | candidate := argv[i+1] | |||
| 49 | + | if candidate != "" && !strings.HasPrefix(candidate, "-") && !containsControl(candidate) { | |||
| 50 | + | return candidate | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | return "" | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func agentSessionFlag(strategy, arg string) bool { | |||
| 57 | + | switch strategy { | |||
| 58 | + | case processStrategyClaude: | |||
| 59 | + | return arg == "--session-id" || arg == "--resume" || arg == "-r" | |||
| 60 | + | case processStrategyOpenCode: | |||
| 61 | + | return arg == "-s" || arg == "--session" | |||
| 62 | + | case processStrategyCodex: | |||
| 63 | + | return arg == "resume" | |||
| 64 | + | case processStrategyPi: | |||
| 65 | + | return arg == "--resume" || arg == "-r" | |||
| 66 | + | default: | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) processRestoreDecision { | |||
| 72 | + | if proc == nil || len(proc.Argv) == 0 || proc.Argv[0] == "" { | |||
| 73 | + | return processRestoreDecision{Reason: "missing_process"} | |||
| 74 | + | } | |||
| 75 | + | strategy := normalizeProcessStrategy(proc.Strategy, proc.Argv) | |||
| 76 | + | if _, ok := allow[processAllowlistKey(strategy, proc.Argv)]; !ok { | |||
| 77 | + | return processRestoreDecision{Reason: "not_allowlisted"} | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | id := proc.Opts.AgentSessionID | |||
| 81 | + | if containsControl(id) { | |||
| 82 | + | return processRestoreDecision{Reason: "invalid_agent_session_id"} | |||
| 83 | + | } | |||
| 84 | + | switch strategy { | |||
| 85 | + | case processStrategyPi: | |||
| 86 | + | if id != "" { | |||
| 87 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true} | |||
| 88 | + | } | |||
| 89 | + | return processRestoreDecision{Command: "pi --continue", Restore: true} | |||
| 90 | + | case processStrategyClaude: | |||
| 91 | + | if id != "" { | |||
| 92 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"claude", "--resume", id}), Restore: true} | |||
| 93 | + | } | |||
| 94 | + | return processRestoreDecision{Command: "claude --continue", Restore: true} | |||
| 95 | + | case processStrategyOpenCode: | |||
| 96 | + | if id != "" { | |||
| 97 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"opencode", "--session", id}), Restore: true} | |||
| 98 | + | } | |||
| 99 | + | return processRestoreDecision{Command: "opencode --continue", Restore: true} | |||
| 100 | + | case processStrategyCodex: | |||
| 101 | + | if id != "" { | |||
| 102 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"codex", "resume", id}), Restore: true} | |||
| 103 | + | } | |||
| 104 | + | return processRestoreDecision{Command: "codex resume --last", Restore: true} | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | command, ok := shellQuoteArgv(proc.Argv) | |||
| 108 | + | if !ok { | |||
| 109 | + | return processRestoreDecision{Reason: "empty_command"} | |||
| 110 | + | } | |||
| 111 | + | return processRestoreDecision{Command: command, Restore: true} | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func normalizeProcessStrategy(strategy string, argv []string) string { | |||
| 115 | + | switch strategy { | |||
| 116 | + | case processStrategyClaude, processStrategyCodex, processStrategyOpenCode, processStrategyPi: | |||
| 117 | + | return strategy | |||
| 118 | + | case "", processStrategyGeneric: | |||
| 119 | + | return detectProcessStrategy(argv) | |||
| 120 | + | default: | |||
| 121 | + | return processStrategyGeneric | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func processAllowlistKey(strategy string, argv []string) string { | |||
| 126 | + | switch strategy { | |||
| 127 | + | case processStrategyClaude, processStrategyCodex, processStrategyOpenCode, processStrategyPi: | |||
| 128 | + | return strategy | |||
| 129 | + | default: | |||
| 130 | + | return filepath.Base(argv[0]) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func containsControl(s string) bool { | |||
| 135 | + | return strings.IndexFunc(s, unicode.IsControl) >= 0 | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func shellQuoteArgvMust(argv []string) string { | |||
| 139 | + | command, _ := shellQuoteArgv(argv) | |||
| 140 | + | return command | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func shellQuoteArgv(argv []string) (string, bool) { | |||
| 144 | + | if len(argv) == 0 { | |||
| 145 | + | return "", false | |||
| 146 | + | } | |||
| 147 | + | quoted := make([]string, 0, len(argv)) | |||
| 148 | + | for _, arg := range argv { | |||
| 149 | + | quoted = append(quoted, shellQuote(arg)) | |||
| 150 | + | } | |||
| 151 | + | return strings.Join(quoted, " "), true | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func shellQuote(s string) string { | |||
| 155 | + | if s == "" { | |||
| 156 | + | return "''" | |||
| 157 | + | } | |||
| 158 | + | if strings.IndexFunc(s, func(r rune) bool { | |||
| 159 | + | return r != '-' && r != '_' && r != '.' && r != '/' && r != ':' && r != '+' && r != '=' && !unicode.IsLetter(r) && !unicode.IsDigit(r) | |||
| 160 | + | }) == -1 { | |||
| 161 | + | return s | |||
| 162 | + | } | |||
| 163 | + | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |||
| 164 | + | } | |||
A internal/usecase/daemon/process_restore_test.go +156 -0
| @@ -0,0 +1,156 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestProcessRestorePlan(t *testing.T) { | |
| 10 | + | defaultAllow := map[string]struct{}{ | |
| 11 | + | "less": {}, | |
| 12 | + | "pi": {}, | |
| 13 | + | "claude": {}, | |
| 14 | + | "opencode": {}, | |
| 15 | + | "codex": {}, | |
| 16 | + | } | |
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | proc *snapcodec.Process | |
| 20 | + | allow map[string]struct{} | |
| 21 | + | restore bool | |
| 22 | + | command string | |
| 23 | + | reason string | |
| 24 | + | }{ | |
| 25 | + | { | |
| 26 | + | name: "generic less allowed", | |
| 27 | + | proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric}, | |
| 28 | + | restore: true, | |
| 29 | + | command: "/usr/bin/less README.md", | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "bash denied", | |
| 33 | + | proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric}, | |
| 34 | + | reason: "not_allowlisted", | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | name: "pi with ID resumes", | |
| 38 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}}, | |
| 39 | + | restore: true, | |
| 40 | + | command: "pi --resume session-123", | |
| 41 | + | }, | |
| 42 | + | { | |
| 43 | + | name: "pi no ID continues", | |
| 44 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi}, | |
| 45 | + | restore: true, | |
| 46 | + | command: "pi --continue", | |
| 47 | + | }, | |
| 48 | + | { | |
| 49 | + | name: "claude no ID continues", | |
| 50 | + | proc: &snapcodec.Process{Argv: []string{"claude"}, Strategy: processStrategyClaude}, | |
| 51 | + | restore: true, | |
| 52 | + | command: "claude --continue", | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "opencode session command", | |
| 56 | + | proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |
| 57 | + | restore: true, | |
| 58 | + | command: "opencode --session abc", | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | name: "opencode no ID continues", | |
| 62 | + | proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode}, | |
| 63 | + | restore: true, | |
| 64 | + | command: "opencode --continue", | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | name: "codex resume command", | |
| 68 | + | proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |
| 69 | + | restore: true, | |
| 70 | + | command: "codex resume abc", | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | name: "codex no ID resumes last", | |
| 74 | + | proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex}, | |
| 75 | + | restore: true, | |
| 76 | + | command: "codex resume --last", | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "agent strategy matches allowlist by strategy not argv basename", | |
| 80 | + | proc: &snapcodec.Process{Argv: []string{"less"}, Strategy: processStrategyClaude, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |
| 81 | + | allow: map[string]struct{}{"less": {}}, | |
| 82 | + | reason: "not_allowlisted", | |
| 83 | + | }, | |
| 84 | + | { | |
| 85 | + | name: "agent ID with control byte rejected", | |
| 86 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session\rmalicious"}}, | |
| 87 | + | reason: "invalid_agent_session_id", | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | name: "empty executable rejected", | |
| 91 | + | proc: &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric}, | |
| 92 | + | reason: "missing_process", | |
| 93 | + | }, | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | for _, tt := range tests { | |
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |
| 98 | + | allow := tt.allow | |
| 99 | + | if allow == nil { | |
| 100 | + | allow = defaultAllow | |
| 101 | + | } | |
| 102 | + | got := planProcessRestore(tt.proc, allow) | |
| 103 | + | if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason { | |
| 104 | + | t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason) | |
| 105 | + | } | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestExtractAgentSessionID(t *testing.T) { | |
| 111 | + | tests := []struct { | |
| 112 | + | name string | |
| 113 | + | strategy string | |
| 114 | + | argv []string | |
| 115 | + | want string | |
| 116 | + | }{ | |
| 117 | + | {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"}, | |
| 118 | + | {name: "claude short resume extraction", strategy: processStrategyClaude, argv: []string{"claude", "-r", "uuid-456"}, want: "uuid-456"}, | |
| 119 | + | {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"}, | |
| 120 | + | {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"}, | |
| 121 | + | {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}}, | |
| 122 | + | {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}}, | |
| 123 | + | {name: "control byte ID rejected", strategy: processStrategyPi, argv: []string{"pi", "--resume", "abc\nmalicious"}}, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | for _, tt := range tests { | |
| 127 | + | t.Run(tt.name, func(t *testing.T) { | |
| 128 | + | if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want { | |
| 129 | + | t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want) | |
| 130 | + | } | |
| 131 | + | }) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestShellQuoteArgv(t *testing.T) { | |
| 136 | + | tests := []struct { | |
| 137 | + | name string | |
| 138 | + | argv []string | |
| 139 | + | want string | |
| 140 | + | ok bool | |
| 141 | + | }{ | |
| 142 | + | {name: "empty argv", ok: false}, | |
| 143 | + | {name: "spaces and single quote", argv: []string{"cmd", "two words", "it's"}, want: "cmd 'two words' 'it'\\''s'", ok: true}, | |
| 144 | + | {name: "shell metacharacters", argv: []string{"cmd", "$(touch /tmp/pwned)", "a;b", "line\nbreak"}, want: "cmd '$(touch /tmp/pwned)' 'a;b' 'line\nbreak'", ok: true}, | |
| 145 | + | {name: "empty argument", argv: []string{"cmd", ""}, want: "cmd ''", ok: true}, | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | for _, tt := range tests { | |
| 149 | + | t.Run(tt.name, func(t *testing.T) { | |
| 150 | + | got, ok := shellQuoteArgv(tt.argv) | |
| 151 | + | if ok != tt.ok || got != tt.want { | |
| 152 | + | t.Fatalf("shellQuoteArgv() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok) | |
| 153 | + | } | |
| 154 | + | }) | |
| 155 | + | } | |
| 156 | + | } | |
| @@ -0,0 +1,156 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestProcessRestorePlan(t *testing.T) { | |||
| 10 | + | defaultAllow := map[string]struct{}{ | |||
| 11 | + | "less": {}, | |||
| 12 | + | "pi": {}, | |||
| 13 | + | "claude": {}, | |||
| 14 | + | "opencode": {}, | |||
| 15 | + | "codex": {}, | |||
| 16 | + | } | |||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | proc *snapcodec.Process | |||
| 20 | + | allow map[string]struct{} | |||
| 21 | + | restore bool | |||
| 22 | + | command string | |||
| 23 | + | reason string | |||
| 24 | + | }{ | |||
| 25 | + | { | |||
| 26 | + | name: "generic less allowed", | |||
| 27 | + | proc: &snapcodec.Process{Argv: []string{"/usr/bin/less", "README.md"}, Strategy: processStrategyGeneric}, | |||
| 28 | + | restore: true, | |||
| 29 | + | command: "/usr/bin/less README.md", | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "bash denied", | |||
| 33 | + | proc: &snapcodec.Process{Argv: []string{"bash", "-lc", "rm -rf /"}, Strategy: processStrategyGeneric}, | |||
| 34 | + | reason: "not_allowlisted", | |||
| 35 | + | }, | |||
| 36 | + | { | |||
| 37 | + | name: "pi with ID resumes", | |||
| 38 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}}, | |||
| 39 | + | restore: true, | |||
| 40 | + | command: "pi --resume session-123", | |||
| 41 | + | }, | |||
| 42 | + | { | |||
| 43 | + | name: "pi no ID continues", | |||
| 44 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi}, | |||
| 45 | + | restore: true, | |||
| 46 | + | command: "pi --continue", | |||
| 47 | + | }, | |||
| 48 | + | { | |||
| 49 | + | name: "claude no ID continues", | |||
| 50 | + | proc: &snapcodec.Process{Argv: []string{"claude"}, Strategy: processStrategyClaude}, | |||
| 51 | + | restore: true, | |||
| 52 | + | command: "claude --continue", | |||
| 53 | + | }, | |||
| 54 | + | { | |||
| 55 | + | name: "opencode session command", | |||
| 56 | + | proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |||
| 57 | + | restore: true, | |||
| 58 | + | command: "opencode --session abc", | |||
| 59 | + | }, | |||
| 60 | + | { | |||
| 61 | + | name: "opencode no ID continues", | |||
| 62 | + | proc: &snapcodec.Process{Argv: []string{"opencode"}, Strategy: processStrategyOpenCode}, | |||
| 63 | + | restore: true, | |||
| 64 | + | command: "opencode --continue", | |||
| 65 | + | }, | |||
| 66 | + | { | |||
| 67 | + | name: "codex resume command", | |||
| 68 | + | proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |||
| 69 | + | restore: true, | |||
| 70 | + | command: "codex resume abc", | |||
| 71 | + | }, | |||
| 72 | + | { | |||
| 73 | + | name: "codex no ID resumes last", | |||
| 74 | + | proc: &snapcodec.Process{Argv: []string{"codex"}, Strategy: processStrategyCodex}, | |||
| 75 | + | restore: true, | |||
| 76 | + | command: "codex resume --last", | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | name: "agent strategy matches allowlist by strategy not argv basename", | |||
| 80 | + | proc: &snapcodec.Process{Argv: []string{"less"}, Strategy: processStrategyClaude, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc"}}, | |||
| 81 | + | allow: map[string]struct{}{"less": {}}, | |||
| 82 | + | reason: "not_allowlisted", | |||
| 83 | + | }, | |||
| 84 | + | { | |||
| 85 | + | name: "agent ID with control byte rejected", | |||
| 86 | + | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session\rmalicious"}}, | |||
| 87 | + | reason: "invalid_agent_session_id", | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "empty executable rejected", | |||
| 91 | + | proc: &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric}, | |||
| 92 | + | reason: "missing_process", | |||
| 93 | + | }, | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | for _, tt := range tests { | |||
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 98 | + | allow := tt.allow | |||
| 99 | + | if allow == nil { | |||
| 100 | + | allow = defaultAllow | |||
| 101 | + | } | |||
| 102 | + | got := planProcessRestore(tt.proc, allow) | |||
| 103 | + | if got.Restore != tt.restore || got.Command != tt.command || got.Reason != tt.reason { | |||
| 104 | + | t.Fatalf("planProcessRestore() = %+v, want restore=%v command=%q reason=%q", got, tt.restore, tt.command, tt.reason) | |||
| 105 | + | } | |||
| 106 | + | }) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestExtractAgentSessionID(t *testing.T) { | |||
| 111 | + | tests := []struct { | |||
| 112 | + | name string | |||
| 113 | + | strategy string | |||
| 114 | + | argv []string | |||
| 115 | + | want string | |||
| 116 | + | }{ | |||
| 117 | + | {name: "claude session flag extraction", strategy: processStrategyClaude, argv: []string{"claude", "--session-id", "uuid-123"}, want: "uuid-123"}, | |||
| 118 | + | {name: "claude short resume extraction", strategy: processStrategyClaude, argv: []string{"claude", "-r", "uuid-456"}, want: "uuid-456"}, | |||
| 119 | + | {name: "opencode short session flag extraction", strategy: processStrategyOpenCode, argv: []string{"opencode", "-s", "abc"}, want: "abc"}, | |||
| 120 | + | {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"}, | |||
| 121 | + | {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}}, | |||
| 122 | + | {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}}, | |||
| 123 | + | {name: "control byte ID rejected", strategy: processStrategyPi, argv: []string{"pi", "--resume", "abc\nmalicious"}}, | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | for _, tt := range tests { | |||
| 127 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 128 | + | if got := extractAgentSessionID(tt.strategy, tt.argv); got != tt.want { | |||
| 129 | + | t.Fatalf("extractAgentSessionID() = %q, want %q", got, tt.want) | |||
| 130 | + | } | |||
| 131 | + | }) | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestShellQuoteArgv(t *testing.T) { | |||
| 136 | + | tests := []struct { | |||
| 137 | + | name string | |||
| 138 | + | argv []string | |||
| 139 | + | want string | |||
| 140 | + | ok bool | |||
| 141 | + | }{ | |||
| 142 | + | {name: "empty argv", ok: false}, | |||
| 143 | + | {name: "spaces and single quote", argv: []string{"cmd", "two words", "it's"}, want: "cmd 'two words' 'it'\\''s'", ok: true}, | |||
| 144 | + | {name: "shell metacharacters", argv: []string{"cmd", "$(touch /tmp/pwned)", "a;b", "line\nbreak"}, want: "cmd '$(touch /tmp/pwned)' 'a;b' 'line\nbreak'", ok: true}, | |||
| 145 | + | {name: "empty argument", argv: []string{"cmd", ""}, want: "cmd ''", ok: true}, | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | for _, tt := range tests { | |||
| 149 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 150 | + | got, ok := shellQuoteArgv(tt.argv) | |||
| 151 | + | if ok != tt.ok || got != tt.want { | |||
| 152 | + | t.Fatalf("shellQuoteArgv() = %q, %v; want %q, %v", got, ok, tt.want, tt.ok) | |||
| 153 | + | } | |||
| 154 | + | }) | |||
| 155 | + | } | |||
| 156 | + | } | |||
M internal/usecase/daemon/session.go +27 -7
| @@ -70,6 +70,7 @@ type session struct { | |||
| 70 | 70 | type tab struct { | |
| 71 | 71 | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | |
| 72 | 72 | ||
| 73 | + | stableID string | |
| 73 | 74 | tree *layout.Tree | |
| 74 | 75 | panes map[layout.PaneID]*pane | |
| 75 | 76 | nextPaneID int | |
| @@ -122,7 +123,11 @@ func (d *Daemon) touchMRU(sess *session) { | |||
| 122 | 123 | ||
| 123 | 124 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) { | |
| 124 | 125 | tbSize := tabSize(sz) | |
| 125 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 126 | + | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 127 | + | if err != nil { | |
| 128 | + | return nil, err | |
| 129 | + | } | |
| 130 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize) | |
| 126 | 131 | if err != nil { | |
| 127 | 132 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | |
| 128 | 133 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | |
| @@ -132,7 +137,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 132 | 137 | d.nextID++ | |
| 133 | 138 | createdAt := time.Now().UnixNano() | |
| 134 | 139 | ||
| 135 | - | tb := newTab(pty, tbSize) | |
| 140 | + | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | |
| 136 | 141 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 137 | 142 | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 138 | 143 | lastUsedSeq := uint64(0) | |
| @@ -177,6 +182,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 177 | 182 | if name == "" { | |
| 178 | 183 | return errors.New("name required") | |
| 179 | 184 | } | |
| 185 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 186 | + | return err | |
| 187 | + | } | |
| 180 | 188 | sz := ac.size | |
| 181 | 189 | d.mu.Lock() | |
| 182 | 190 | if d.closing { | |
| @@ -231,12 +239,16 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 231 | 239 | cwd := sess.cwd | |
| 232 | 240 | client := sess.client | |
| 233 | 241 | sess.mu.Unlock() | |
| 234 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 242 | + | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 243 | + | if err != nil { | |
| 244 | + | return err | |
| 245 | + | } | |
| 246 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize) | |
| 235 | 247 | if err != nil { | |
| 236 | 248 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 237 | 249 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | |
| 238 | 250 | } | |
| 239 | - | tb := newTab(pty, tbSize) | |
| 251 | + | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | |
| 240 | 252 | if client != nil { | |
| 241 | 253 | t := d.effectiveTheme(client.getClientTheme()) | |
| 242 | 254 | tb.mu.Lock() | |
| @@ -271,9 +283,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 271 | 283 | } | |
| 272 | 284 | ||
| 273 | 285 | func newTab(pty ports.PTY, sz domain.Size) *tab { | |
| 286 | + | return newTabWithStableID(fallbackStableID("t"), fallbackStableID("p"), pty, sz) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab { | |
| 274 | 290 | id := layout.PaneID("pane-1") | |
| 275 | - | p := newPane(id, pty, sz) | |
| 291 | + | p := newPaneWithStableID(id, paneStableID, pty, sz) | |
| 276 | 292 | return &tab{ | |
| 293 | + | stableID: tabStableID, | |
| 277 | 294 | tree: layout.NewTree(id), | |
| 278 | 295 | panes: map[layout.PaneID]*pane{id: p}, | |
| 279 | 296 | nextPaneID: 2, | |
| @@ -390,6 +407,9 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 390 | 407 | if name == "" { | |
| 391 | 408 | return errors.New("name required") | |
| 392 | 409 | } | |
| 410 | + | if err := domain.ValidateSessionName(name); err != nil { | |
| 411 | + | return err | |
| 412 | + | } | |
| 393 | 413 | d.mu.Lock() | |
| 394 | 414 | defer d.mu.Unlock() | |
| 395 | 415 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | |
| @@ -701,7 +721,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 701 | 721 | ||
| 702 | 722 | // childEnv builds the session child's environment: the daemon's own, with TERM | |
| 703 | 723 | // and VEV forced to well-known values. | |
| 704 | - | func (d *Daemon) childEnv(name string) []string { | |
| 724 | + | func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string { | |
| 705 | 725 | out := make([]string, 0, len(d.baseEnv)+2) | |
| 706 | 726 | for _, e := range d.baseEnv { | |
| 707 | 727 | if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") { | |
| @@ -709,5 +729,5 @@ func (d *Daemon) childEnv(name string) []string { | |||
| 709 | 729 | } | |
| 710 | 730 | out = append(out, e) | |
| 711 | 731 | } | |
| 712 | - | return append(out, "TERM=xterm-256color", "VEV="+name) | |
| 732 | + | return append(out, "TERM=xterm-direct", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID) | |
| 713 | 733 | } | |
| @@ -70,6 +70,7 @@ type session struct { | |||||
| 70 | type tab struct { | 70 | type tab struct { | ||
| 71 | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | 71 | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | ||
| 72 | 72 | ||||
| 73 | + | stableID string | |||
| 73 | tree *layout.Tree | 74 | tree *layout.Tree | ||
| 74 | panes map[layout.PaneID]*pane | 75 | panes map[layout.PaneID]*pane | ||
| 75 | nextPaneID int | 76 | nextPaneID int | ||
| @@ -122,7 +123,11 @@ func (d *Daemon) touchMRU(sess *session) { | |||||
| 122 | 123 | ||||
| 123 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) { | 124 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) { | ||
| 124 | tbSize := tabSize(sz) | 125 | tbSize := tabSize(sz) | ||
| 125 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | 126 | + | tabStableID, paneStableID, err := d.newTabPaneStableIDs() |
| 127 | + | if err != nil { | |||
| 128 | + | return nil, err | |||
| 129 | + | } | |||
| 130 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize) | |||
| 126 | if err != nil { | 131 | if err != nil { | ||
| 127 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | 132 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | ||
| 128 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | 133 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | ||
| @@ -132,7 +137,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 132 | d.nextID++ | 137 | d.nextID++ | ||
| 133 | createdAt := time.Now().UnixNano() | 138 | createdAt := time.Now().UnixNano() | ||
| 134 | 139 | ||||
| 135 | - | tb := newTab(pty, tbSize) | 140 | + | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) |
| 136 | sctx, cancel := context.WithCancel(d.serveCtx) | 141 | sctx, cancel := context.WithCancel(d.serveCtx) | ||
| 137 | tb.ctx, tb.cancel = context.WithCancel(sctx) | 142 | tb.ctx, tb.cancel = context.WithCancel(sctx) | ||
| 138 | lastUsedSeq := uint64(0) | 143 | lastUsedSeq := uint64(0) | ||
| @@ -177,6 +182,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 177 | if name == "" { | 182 | if name == "" { | ||
| 178 | return errors.New("name required") | 183 | return errors.New("name required") | ||
| 179 | } | 184 | } | ||
| 185 | + | if err := domain.ValidateSessionName(name); err != nil { | |||
| 186 | + | return err | |||
| 187 | + | } | |||
| 180 | sz := ac.size | 188 | sz := ac.size | ||
| 181 | d.mu.Lock() | 189 | d.mu.Lock() | ||
| 182 | if d.closing { | 190 | if d.closing { | ||
| @@ -231,12 +239,16 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 231 | cwd := sess.cwd | 239 | cwd := sess.cwd | ||
| 232 | client := sess.client | 240 | client := sess.client | ||
| 233 | sess.mu.Unlock() | 241 | sess.mu.Unlock() | ||
| 234 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | 242 | + | tabStableID, paneStableID, err := d.newTabPaneStableIDs() |
| 243 | + | if err != nil { | |||
| 244 | + | return err | |||
| 245 | + | } | |||
| 246 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize) | |||
| 235 | if err != nil { | 247 | if err != nil { | ||
| 236 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | 248 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | ||
| 237 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | 249 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | ||
| 238 | } | 250 | } | ||
| 239 | - | tb := newTab(pty, tbSize) | 251 | + | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) |
| 240 | if client != nil { | 252 | if client != nil { | ||
| 241 | t := d.effectiveTheme(client.getClientTheme()) | 253 | t := d.effectiveTheme(client.getClientTheme()) | ||
| 242 | tb.mu.Lock() | 254 | tb.mu.Lock() | ||
| @@ -271,9 +283,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 271 | } | 283 | } | ||
| 272 | 284 | ||||
| 273 | func newTab(pty ports.PTY, sz domain.Size) *tab { | 285 | func newTab(pty ports.PTY, sz domain.Size) *tab { | ||
| 286 | + | return newTabWithStableID(fallbackStableID("t"), fallbackStableID("p"), pty, sz) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab { | |||
| 274 | id := layout.PaneID("pane-1") | 290 | id := layout.PaneID("pane-1") | ||
| 275 | - | p := newPane(id, pty, sz) | 291 | + | p := newPaneWithStableID(id, paneStableID, pty, sz) |
| 276 | return &tab{ | 292 | return &tab{ | ||
| 293 | + | stableID: tabStableID, | |||
| 277 | tree: layout.NewTree(id), | 294 | tree: layout.NewTree(id), | ||
| 278 | panes: map[layout.PaneID]*pane{id: p}, | 295 | panes: map[layout.PaneID]*pane{id: p}, | ||
| 279 | nextPaneID: 2, | 296 | nextPaneID: 2, | ||
| @@ -390,6 +407,9 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 390 | if name == "" { | 407 | if name == "" { | ||
| 391 | return errors.New("name required") | 408 | return errors.New("name required") | ||
| 392 | } | 409 | } | ||
| 410 | + | if err := domain.ValidateSessionName(name); err != nil { | |||
| 411 | + | return err | |||
| 412 | + | } | |||
| 393 | d.mu.Lock() | 413 | d.mu.Lock() | ||
| 394 | defer d.mu.Unlock() | 414 | defer d.mu.Unlock() | ||
| 395 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | 415 | if taken := d.findByNameLocked(name); taken != nil && taken != sess { | ||
| @@ -701,7 +721,7 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||||
| 701 | 721 | ||||
| 702 | // childEnv builds the session child's environment: the daemon's own, with TERM | 722 | // childEnv builds the session child's environment: the daemon's own, with TERM | ||
| 703 | // and VEV forced to well-known values. | 723 | // and VEV forced to well-known values. | ||
| 704 | - | func (d *Daemon) childEnv(name string) []string { | 724 | + | func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string { |
| 705 | out := make([]string, 0, len(d.baseEnv)+2) | 725 | out := make([]string, 0, len(d.baseEnv)+2) | ||
| 706 | for _, e := range d.baseEnv { | 726 | for _, e := range d.baseEnv { | ||
| 707 | if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") { | 727 | if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") { | ||
| @@ -709,5 +729,5 @@ func (d *Daemon) childEnv(name string) []string { | |||||
| 709 | } | 729 | } | ||
| 710 | out = append(out, e) | 730 | out = append(out, e) | ||
| 711 | } | 731 | } | ||
| 712 | - | return append(out, "TERM=xterm-256color", "VEV="+name) | 732 | + | return append(out, "TERM=xterm-direct", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID) |
| 713 | } | 733 | } | ||
M internal/usecase/daemon/session_test.go +74 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "io" | |
| 6 | 6 | "log/slog" | |
| 7 | + | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "sync/atomic" | |
| 9 | 10 | "testing" | |
| @@ -892,6 +893,57 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||
| 892 | 893 | require.False(t, ok) | |
| 893 | 894 | } | |
| 894 | 895 | ||
| 896 | + | func TestChildEnvEscapesLegacySessionName(t *testing.T) { | |
| 897 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 898 | + | ||
| 899 | + | got := d.childEnv("legacy,name=value", "t_alpha", "p_beta") | |
| 900 | + | ||
| 901 | + | require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta") | |
| 902 | + | } | |
| 903 | + | ||
| 904 | + | func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) { | |
| 905 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 906 | + | p, releasePTY := newBlockingPTY(t) | |
| 907 | + | defer releasePTY() | |
| 908 | + | ||
| 909 | + | var gotEnv []string | |
| 910 | + | f := portsmocks.NewMockPTYFactory(t) | |
| 911 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 912 | + | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 913 | + | gotEnv = append([]string(nil), env...) | |
| 914 | + | return p, nil | |
| 915 | + | }, | |
| 916 | + | ).Once() | |
| 917 | + | d := newTestDaemon(t, f, stubClock{}) | |
| 918 | + | d.baseEnv = []string{"KEEP=1", "TERM=old", "VEV=old"} | |
| 919 | + | ||
| 920 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |
| 921 | + | require.NoError(t, err) | |
| 922 | + | defer func() { | |
| 923 | + | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| 924 | + | releasePTY() | |
| 925 | + | d.sessWg.Wait() | |
| 926 | + | }() | |
| 927 | + | ||
| 928 | + | sess.mu.Lock() | |
| 929 | + | require.Len(t, sess.tabs, 1) | |
| 930 | + | tb := sess.tabs[0] | |
| 931 | + | sess.mu.Unlock() | |
| 932 | + | tb.mu.Lock() | |
| 933 | + | paneStableID := tb.panes["pane-1"].stableID | |
| 934 | + | tabStableID := tb.stableID | |
| 935 | + | tb.mu.Unlock() | |
| 936 | + | ||
| 937 | + | require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID) | |
| 938 | + | require.True(t, strings.HasPrefix(paneStableID, "p_"), paneStableID) | |
| 939 | + | require.NotEqual(t, tabStableID, paneStableID) | |
| 940 | + | require.Contains(t, gotEnv, "KEEP=1") | |
| 941 | + | require.NotContains(t, gotEnv, "TERM=old") | |
| 942 | + | require.NotContains(t, gotEnv, "VEV=old") | |
| 943 | + | require.Contains(t, gotEnv, "TERM=xterm-direct") | |
| 944 | + | require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID) | |
| 945 | + | } | |
| 946 | + | ||
| 895 | 947 | func TestIntentNewStoppedNameRejected(t *testing.T) { | |
| 896 | 948 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 897 | 949 | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | |
| @@ -900,6 +952,28 @@ func TestIntentNewStoppedNameRejected(t *testing.T) { | |||
| 900 | 952 | require.ErrorContains(t, err, "name already in use") | |
| 901 | 953 | } | |
| 902 | 954 | ||
| 955 | + | func TestIntentNewUnsafeNameRejected(t *testing.T) { | |
| 956 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 957 | + | tr := portsmocks.NewMockTransport(t) | |
| 958 | + | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |
| 959 | + | require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error()) | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) { | |
| 963 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 964 | + | require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName) | |
| 965 | + | } | |
| 966 | + | ||
| 967 | + | func TestRenameSessionUnsafeNameRejected(t *testing.T) { | |
| 968 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 969 | + | p, release := newBlockingPTY(t) | |
| 970 | + | defer release() | |
| 971 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 972 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |
| 973 | + | require.NoError(t, err) | |
| 974 | + | require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName) | |
| 975 | + | } | |
| 976 | + | ||
| 903 | 977 | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |
| 904 | 978 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 905 | 979 | p1, release1 := newBlockingPTY(t) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | "log/slog" | 6 | "log/slog" | ||
| 7 | + | "strings" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "sync/atomic" | 9 | "sync/atomic" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -892,6 +893,57 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||||
| 892 | require.False(t, ok) | 893 | require.False(t, ok) | ||
| 893 | } | 894 | } | ||
| 894 | 895 | ||||
| 896 | + | func TestChildEnvEscapesLegacySessionName(t *testing.T) { | |||
| 897 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 898 | + | ||||
| 899 | + | got := d.childEnv("legacy,name=value", "t_alpha", "p_beta") | |||
| 900 | + | ||||
| 901 | + | require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta") | |||
| 902 | + | } | |||
| 903 | + | ||||
| 904 | + | func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) { | |||
| 905 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 906 | + | p, releasePTY := newBlockingPTY(t) | |||
| 907 | + | defer releasePTY() | |||
| 908 | + | ||||
| 909 | + | var gotEnv []string | |||
| 910 | + | f := portsmocks.NewMockPTYFactory(t) | |||
| 911 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 912 | + | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |||
| 913 | + | gotEnv = append([]string(nil), env...) | |||
| 914 | + | return p, nil | |||
| 915 | + | }, | |||
| 916 | + | ).Once() | |||
| 917 | + | d := newTestDaemon(t, f, stubClock{}) | |||
| 918 | + | d.baseEnv = []string{"KEEP=1", "TERM=old", "VEV=old"} | |||
| 919 | + | ||||
| 920 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |||
| 921 | + | require.NoError(t, err) | |||
| 922 | + | defer func() { | |||
| 923 | + | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |||
| 924 | + | releasePTY() | |||
| 925 | + | d.sessWg.Wait() | |||
| 926 | + | }() | |||
| 927 | + | ||||
| 928 | + | sess.mu.Lock() | |||
| 929 | + | require.Len(t, sess.tabs, 1) | |||
| 930 | + | tb := sess.tabs[0] | |||
| 931 | + | sess.mu.Unlock() | |||
| 932 | + | tb.mu.Lock() | |||
| 933 | + | paneStableID := tb.panes["pane-1"].stableID | |||
| 934 | + | tabStableID := tb.stableID | |||
| 935 | + | tb.mu.Unlock() | |||
| 936 | + | ||||
| 937 | + | require.True(t, strings.HasPrefix(tabStableID, "t_"), tabStableID) | |||
| 938 | + | require.True(t, strings.HasPrefix(paneStableID, "p_"), paneStableID) | |||
| 939 | + | require.NotEqual(t, tabStableID, paneStableID) | |||
| 940 | + | require.Contains(t, gotEnv, "KEEP=1") | |||
| 941 | + | require.NotContains(t, gotEnv, "TERM=old") | |||
| 942 | + | require.NotContains(t, gotEnv, "VEV=old") | |||
| 943 | + | require.Contains(t, gotEnv, "TERM=xterm-direct") | |||
| 944 | + | require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID) | |||
| 945 | + | } | |||
| 946 | + | ||||
| 895 | func TestIntentNewStoppedNameRejected(t *testing.T) { | 947 | func TestIntentNewStoppedNameRejected(t *testing.T) { | ||
| 896 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 948 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 897 | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | 949 | d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1} | ||
| @@ -900,6 +952,28 @@ func TestIntentNewStoppedNameRejected(t *testing.T) { | |||||
| 900 | require.ErrorContains(t, err, "name already in use") | 952 | require.ErrorContains(t, err, "name already in use") | ||
| 901 | } | 953 | } | ||
| 902 | 954 | ||||
| 955 | + | func TestIntentNewUnsafeNameRejected(t *testing.T) { | |||
| 956 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 957 | + | tr := portsmocks.NewMockTransport(t) | |||
| 958 | + | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "my work", Size: domain.Size{Cols: 80, Rows: 24}}, tr) | |||
| 959 | + | require.ErrorContains(t, err, domain.ErrInvalidSessionName.Error()) | |||
| 960 | + | } | |||
| 961 | + | ||||
| 962 | + | func TestCreateSessionAndSwitchUnsafeNameRejected(t *testing.T) { | |||
| 963 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 964 | + | require.ErrorIs(t, d.createSessionAndSwitch(nil, nil, "my work"), domain.ErrInvalidSessionName) | |||
| 965 | + | } | |||
| 966 | + | ||||
| 967 | + | func TestRenameSessionUnsafeNameRejected(t *testing.T) { | |||
| 968 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 969 | + | p, release := newBlockingPTY(t) | |||
| 970 | + | defer release() | |||
| 971 | + | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |||
| 972 | + | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz) | |||
| 973 | + | require.NoError(t, err) | |||
| 974 | + | require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName) | |||
| 975 | + | } | |||
| 976 | + | ||||
| 903 | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | 977 | func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | ||
| 904 | sz := domain.Size{Cols: 80, Rows: 24} | 978 | sz := domain.Size{Cols: 80, Rows: 24} | ||
| 905 | p1, release1 := newBlockingPTY(t) | 979 | p1, release1 := newBlockingPTY(t) | ||
M internal/usecase/daemon/snapshot.go +59 -5
| @@ -73,6 +73,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 73 | 73 | } | |
| 74 | 74 | cancel() | |
| 75 | 75 | } | |
| 76 | + | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 76 | 77 | for _, tabSnap := range snap.Tabs { | |
| 77 | 78 | if err := ctx.Err(); err != nil { | |
| 78 | 79 | closeOpened() | |
| @@ -92,13 +93,26 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 92 | 93 | for _, pl := range placements { | |
| 93 | 94 | placementByPane[pl.ID] = pl.Content | |
| 94 | 95 | } | |
| 95 | - | tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 96 | + | tabStableID := tabSnap.StableID | |
| 97 | + | if tabStableID == "" { | |
| 98 | + | var err error | |
| 99 | + | tabStableID, err = newStableID("t") | |
| 100 | + | if err != nil { | |
| 101 | + | closeOpened() | |
| 102 | + | return fmt.Errorf("snapshot: generating tab identity: %w", err) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |
| 96 | 106 | if tb.nextPaneID <= 0 { | |
| 97 | 107 | tb.nextPaneID = 1 | |
| 98 | 108 | } | |
| 99 | 109 | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 100 | 110 | opened = append(opened, tb) | |
| 101 | 111 | for _, paneSnap := range tabSnap.Panes { | |
| 112 | + | restoreCommand := "" | |
| 113 | + | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |
| 114 | + | restoreCommand = decision.Command | |
| 115 | + | } | |
| 102 | 116 | if err := ctx.Err(); err != nil { | |
| 103 | 117 | closeOpened() | |
| 104 | 118 | return err | |
| @@ -109,15 +123,29 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 109 | 123 | return fmt.Errorf("snapshot: missing pane placement") | |
| 110 | 124 | } | |
| 111 | 125 | contentSize := restorePTYSize(contentRect, tbSize) | |
| 112 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize) | |
| 126 | + | paneStableID := paneSnap.StableID | |
| 127 | + | if paneStableID == "" { | |
| 128 | + | var err error | |
| 129 | + | paneStableID, err = newStableID("p") | |
| 130 | + | if err != nil { | |
| 131 | + | closeOpened() | |
| 132 | + | return fmt.Errorf("snapshot: generating pane identity: %w", err) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize) | |
| 113 | 136 | if err != nil { | |
| 114 | 137 | closeOpened() | |
| 115 | 138 | return err | |
| 116 | 139 | } | |
| 117 | - | p := newPane(paneSnap.ID, pty, contentSize) | |
| 140 | + | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) | |
| 118 | 141 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 119 | 142 | p.rect = contentRect | |
| 120 | 143 | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | |
| 144 | + | if restoreCommand != "" { | |
| 145 | + | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | |
| 146 | + | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |
| 147 | + | } | |
| 148 | + | } | |
| 121 | 149 | tb.panes[paneSnap.ID] = p | |
| 122 | 150 | } | |
| 123 | 151 | } | |
| @@ -235,6 +263,7 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 235 | 263 | for _, tb := range tabs { | |
| 236 | 264 | tb.mu.Lock() | |
| 237 | 265 | tabSnap := snapcodec.Tab{ | |
| 266 | + | StableID: tb.stableID, | |
| 238 | 267 | Cols: uint16(max(tb.size.Cols, 0)), | |
| 239 | 268 | Rows: uint16(max(tb.size.Rows, 0)), | |
| 240 | 269 | NextPaneID: uint64(max(tb.nextPaneID, 0)), | |
| @@ -254,11 +283,13 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 254 | 283 | for _, p := range panes { | |
| 255 | 284 | p.mu.Lock() | |
| 256 | 285 | pid := 0 | |
| 257 | - | if p.pty != nil { | |
| 258 | - | pid = p.pty.Pid() | |
| 286 | + | pty := p.pty | |
| 287 | + | if pty != nil { | |
| 288 | + | pid = pty.Pid() | |
| 259 | 289 | } | |
| 260 | 290 | paneSnap := snapcodec.Pane{ | |
| 261 | 291 | ID: p.id, | |
| 292 | + | StableID: p.stableID, | |
| 262 | 293 | Scrollback: cloneRows(p.scrollback.Snapshot()), | |
| 263 | 294 | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | |
| 264 | 295 | } | |
| @@ -269,6 +300,7 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 269 | 300 | paneSnap.Cwd = cwd | |
| 270 | 301 | } | |
| 271 | 302 | } | |
| 303 | + | paneSnap.Process = d.capturePaneProcess(pty, pid) | |
| 272 | 304 | paneIDs[paneSnap.ID] = struct{}{} | |
| 273 | 305 | paneSnaps = append(paneSnaps, paneSnap) | |
| 274 | 306 | } | |
| @@ -291,6 +323,28 @@ func (d *Daemon) captureSession(sess *session) bool { | |||
| 291 | 323 | return true | |
| 292 | 324 | } | |
| 293 | 325 | ||
| 326 | + | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |
| 327 | + | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |
| 328 | + | return nil | |
| 329 | + | } | |
| 330 | + | pgid, err := pty.ForegroundPgid() | |
| 331 | + | if err != nil || pgid <= 0 || pgid == shellPid { | |
| 332 | + | return nil | |
| 333 | + | } | |
| 334 | + | argv, err := d.procGroupArgv(pgid, shellPid) | |
| 335 | + | if err != nil || len(argv) == 0 || argv[0] == "" { | |
| 336 | + | return nil | |
| 337 | + | } | |
| 338 | + | strategy := detectProcessStrategy(argv) | |
| 339 | + | return &snapcodec.Process{ | |
| 340 | + | Argv: append([]string(nil), argv...), | |
| 341 | + | Strategy: strategy, | |
| 342 | + | Opts: snapcodec.ProcessOpts{ | |
| 343 | + | AgentSessionID: extractAgentSessionID(strategy, argv), | |
| 344 | + | }, | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 294 | 348 | func (d *Daemon) snapshotSaver(ctx context.Context) { | |
| 295 | 349 | t := d.clock.NewTimer(snapshotInterval) | |
| 296 | 350 | defer t.Stop() | |
| @@ -73,6 +73,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 73 | } | 73 | } | ||
| 74 | cancel() | 74 | cancel() | ||
| 75 | } | 75 | } | ||
| 76 | + | allowlist := d.restoreProcessAllowlistSnapshot() | |||
| 76 | for _, tabSnap := range snap.Tabs { | 77 | for _, tabSnap := range snap.Tabs { | ||
| 77 | if err := ctx.Err(); err != nil { | 78 | if err := ctx.Err(); err != nil { | ||
| 78 | closeOpened() | 79 | closeOpened() | ||
| @@ -92,13 +93,26 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 92 | for _, pl := range placements { | 93 | for _, pl := range placements { | ||
| 93 | placementByPane[pl.ID] = pl.Content | 94 | placementByPane[pl.ID] = pl.Content | ||
| 94 | } | 95 | } | ||
| 95 | - | tb := &tab{tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | 96 | + | tabStableID := tabSnap.StableID |
| 97 | + | if tabStableID == "" { | |||
| 98 | + | var err error | |||
| 99 | + | tabStableID, err = newStableID("t") | |||
| 100 | + | if err != nil { | |||
| 101 | + | closeOpened() | |||
| 102 | + | return fmt.Errorf("snapshot: generating tab identity: %w", err) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize} | |||
| 96 | if tb.nextPaneID <= 0 { | 106 | if tb.nextPaneID <= 0 { | ||
| 97 | tb.nextPaneID = 1 | 107 | tb.nextPaneID = 1 | ||
| 98 | } | 108 | } | ||
| 99 | tb.ctx, tb.cancel = context.WithCancel(sctx) | 109 | tb.ctx, tb.cancel = context.WithCancel(sctx) | ||
| 100 | opened = append(opened, tb) | 110 | opened = append(opened, tb) | ||
| 101 | for _, paneSnap := range tabSnap.Panes { | 111 | for _, paneSnap := range tabSnap.Panes { | ||
| 112 | + | restoreCommand := "" | |||
| 113 | + | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |||
| 114 | + | restoreCommand = decision.Command | |||
| 115 | + | } | |||
| 102 | if err := ctx.Err(); err != nil { | 116 | if err := ctx.Err(); err != nil { | ||
| 103 | closeOpened() | 117 | closeOpened() | ||
| 104 | return err | 118 | return err | ||
| @@ -109,15 +123,29 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 109 | return fmt.Errorf("snapshot: missing pane placement") | 123 | return fmt.Errorf("snapshot: missing pane placement") | ||
| 110 | } | 124 | } | ||
| 111 | contentSize := restorePTYSize(contentRect, tbSize) | 125 | contentSize := restorePTYSize(contentRect, tbSize) | ||
| 112 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name), paneSnap.Cwd, contentSize) | 126 | + | paneStableID := paneSnap.StableID |
| 127 | + | if paneStableID == "" { | |||
| 128 | + | var err error | |||
| 129 | + | paneStableID, err = newStableID("p") | |||
| 130 | + | if err != nil { | |||
| 131 | + | closeOpened() | |||
| 132 | + | return fmt.Errorf("snapshot: generating pane identity: %w", err) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize) | |||
| 113 | if err != nil { | 136 | if err != nil { | ||
| 114 | closeOpened() | 137 | closeOpened() | ||
| 115 | return err | 138 | return err | ||
| 116 | } | 139 | } | ||
| 117 | - | p := newPane(paneSnap.ID, pty, contentSize) | 140 | + | p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, contentSize) |
| 118 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | 141 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | ||
| 119 | p.rect = contentRect | 142 | p.rect = contentRect | ||
| 120 | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | 143 | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | ||
| 144 | + | if restoreCommand != "" { | |||
| 145 | + | if _, err := pty.Write([]byte(restoreCommand + "\n")); err != nil { | |||
| 146 | + | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 121 | tb.panes[paneSnap.ID] = p | 149 | tb.panes[paneSnap.ID] = p | ||
| 122 | } | 150 | } | ||
| 123 | } | 151 | } | ||
| @@ -235,6 +263,7 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 235 | for _, tb := range tabs { | 263 | for _, tb := range tabs { | ||
| 236 | tb.mu.Lock() | 264 | tb.mu.Lock() | ||
| 237 | tabSnap := snapcodec.Tab{ | 265 | tabSnap := snapcodec.Tab{ | ||
| 266 | + | StableID: tb.stableID, | |||
| 238 | Cols: uint16(max(tb.size.Cols, 0)), | 267 | Cols: uint16(max(tb.size.Cols, 0)), | ||
| 239 | Rows: uint16(max(tb.size.Rows, 0)), | 268 | Rows: uint16(max(tb.size.Rows, 0)), | ||
| 240 | NextPaneID: uint64(max(tb.nextPaneID, 0)), | 269 | NextPaneID: uint64(max(tb.nextPaneID, 0)), | ||
| @@ -254,11 +283,13 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 254 | for _, p := range panes { | 283 | for _, p := range panes { | ||
| 255 | p.mu.Lock() | 284 | p.mu.Lock() | ||
| 256 | pid := 0 | 285 | pid := 0 | ||
| 257 | - | if p.pty != nil { | 286 | + | pty := p.pty |
| 258 | - | pid = p.pty.Pid() | 287 | + | if pty != nil { |
| 288 | + | pid = pty.Pid() | |||
| 259 | } | 289 | } | ||
| 260 | paneSnap := snapcodec.Pane{ | 290 | paneSnap := snapcodec.Pane{ | ||
| 261 | ID: p.id, | 291 | ID: p.id, | ||
| 292 | + | StableID: p.stableID, | |||
| 262 | Scrollback: cloneRows(p.scrollback.Snapshot()), | 293 | Scrollback: cloneRows(p.scrollback.Snapshot()), | ||
| 263 | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | 294 | Visible: cloneRows(p.screen.PrimaryVisibleRows()), | ||
| 264 | } | 295 | } | ||
| @@ -269,6 +300,7 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 269 | paneSnap.Cwd = cwd | 300 | paneSnap.Cwd = cwd | ||
| 270 | } | 301 | } | ||
| 271 | } | 302 | } | ||
| 303 | + | paneSnap.Process = d.capturePaneProcess(pty, pid) | |||
| 272 | paneIDs[paneSnap.ID] = struct{}{} | 304 | paneIDs[paneSnap.ID] = struct{}{} | ||
| 273 | paneSnaps = append(paneSnaps, paneSnap) | 305 | paneSnaps = append(paneSnaps, paneSnap) | ||
| 274 | } | 306 | } | ||
| @@ -291,6 +323,28 @@ func (d *Daemon) captureSession(sess *session) bool { | |||||
| 291 | return true | 323 | return true | ||
| 292 | } | 324 | } | ||
| 293 | 325 | ||||
| 326 | + | func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error) }, shellPid int) *snapcodec.Process { | |||
| 327 | + | if d == nil || pty == nil || shellPid <= 0 || d.procGroupArgv == nil { | |||
| 328 | + | return nil | |||
| 329 | + | } | |||
| 330 | + | pgid, err := pty.ForegroundPgid() | |||
| 331 | + | if err != nil || pgid <= 0 || pgid == shellPid { | |||
| 332 | + | return nil | |||
| 333 | + | } | |||
| 334 | + | argv, err := d.procGroupArgv(pgid, shellPid) | |||
| 335 | + | if err != nil || len(argv) == 0 || argv[0] == "" { | |||
| 336 | + | return nil | |||
| 337 | + | } | |||
| 338 | + | strategy := detectProcessStrategy(argv) | |||
| 339 | + | return &snapcodec.Process{ | |||
| 340 | + | Argv: append([]string(nil), argv...), | |||
| 341 | + | Strategy: strategy, | |||
| 342 | + | Opts: snapcodec.ProcessOpts{ | |||
| 343 | + | AgentSessionID: extractAgentSessionID(strategy, argv), | |||
| 344 | + | }, | |||
| 345 | + | } | |||
| 346 | + | } | |||
| 347 | + | ||||
| 294 | func (d *Daemon) snapshotSaver(ctx context.Context) { | 348 | func (d *Daemon) snapshotSaver(ctx context.Context) { | ||
| 295 | t := d.clock.NewTimer(snapshotInterval) | 349 | t := d.clock.NewTimer(snapshotInterval) | ||
| 296 | defer t.Stop() | 350 | defer t.Stop() | ||
M internal/usecase/daemon/snapshot_restore_test.go +160 -10
| @@ -2,6 +2,8 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "errors" | |
| 5 | 7 | "io" | |
| 6 | 8 | "math" | |
| 7 | 9 | "sync" | |
| @@ -25,6 +27,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 25 | 27 | CreatedAt: 99, | |
| 26 | 28 | Active: 0, | |
| 27 | 29 | Tabs: []snapcodec.Tab{{ | |
| 30 | + | StableID: "t_saved", | |
| 28 | 31 | Cols: 80, | |
| 29 | 32 | Rows: 24, | |
| 30 | 33 | NextPaneID: 3, | |
| @@ -34,8 +37,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 34 | 37 | layout.NewLeaf("pane-2"), | |
| 35 | 38 | }}}, | |
| 36 | 39 | Panes: []snapcodec.Pane{ | |
| 37 | - | {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | |
| 38 | - | {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | |
| 40 | + | {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | |
| 41 | + | {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | |
| 39 | 42 | }, | |
| 40 | 43 | }}, | |
| 41 | 44 | })}} | |
| @@ -76,6 +79,134 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 76 | 79 | require.Equal(t, "old2", rowText(copySnap.Rows[0][:4])) | |
| 77 | 80 | require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4])) | |
| 78 | 81 | p.mu.Unlock() | |
| 82 | + | ||
| 83 | + | tb.mu.Lock() | |
| 84 | + | tabStableID := tb.stableID | |
| 85 | + | pane1StableID := tb.panes["pane-1"].stableID | |
| 86 | + | pane2StableID := tb.panes["pane-2"].stableID | |
| 87 | + | tb.mu.Unlock() | |
| 88 | + | require.Equal(t, "t_saved", tabStableID) | |
| 89 | + | require.Equal(t, "p_saved_1", pane1StableID) | |
| 90 | + | require.Equal(t, "p_saved_2", pane2StableID) | |
| 91 | + | require.Contains(t, factory.opens[0].env, "TERM=xterm-direct") | |
| 92 | + | require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID) | |
| 93 | + | require.Contains(t, factory.opens[1].env, "TERM=xterm-direct") | |
| 94 | + | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |
| 98 | + | t.Helper() | |
| 99 | + | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 100 | + | Name: name, | |
| 101 | + | Tabs: []snapcodec.Tab{{ | |
| 102 | + | Cols: 80, | |
| 103 | + | Rows: 24, | |
| 104 | + | Tree: layout.NewTree("pane-1"), | |
| 105 | + | Panes: []snapcodec.Pane{{ | |
| 106 | + | ID: "pane-1", | |
| 107 | + | Cwd: "/tmp", | |
| 108 | + | Process: proc, | |
| 109 | + | }}, | |
| 110 | + | }}, | |
| 111 | + | })}}} | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) { | |
| 115 | + | lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric} | |
| 116 | + | agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 117 | + | controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}} | |
| 118 | + | ||
| 119 | + | tests := []struct { | |
| 120 | + | name string | |
| 121 | + | store *restoreSnapshotStore | |
| 122 | + | allow []string | |
| 123 | + | wantWrites [][]string | |
| 124 | + | }{ | |
| 125 | + | { | |
| 126 | + | name: "allowlisted generic command", | |
| 127 | + | store: processRestoreTestStore(t, "allowlisted", lessProc), | |
| 128 | + | allow: []string{"less"}, | |
| 129 | + | wantWrites: [][]string{{"less README.md\n"}}, | |
| 130 | + | }, | |
| 131 | + | { | |
| 132 | + | name: "denied generic command", | |
| 133 | + | store: processRestoreTestStore(t, "denied", lessProc), | |
| 134 | + | allow: []string{"vim"}, | |
| 135 | + | wantWrites: [][]string{{}}, | |
| 136 | + | }, | |
| 137 | + | { | |
| 138 | + | name: "pane scoped command", | |
| 139 | + | store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 140 | + | Name: "scoped", | |
| 141 | + | Tabs: []snapcodec.Tab{ | |
| 142 | + | { | |
| 143 | + | Cols: 80, | |
| 144 | + | Rows: 24, | |
| 145 | + | Tree: layout.NewTree("pane-1"), | |
| 146 | + | Panes: []snapcodec.Pane{{ | |
| 147 | + | ID: "pane-1", | |
| 148 | + | Cwd: "/one", | |
| 149 | + | Process: lessProc, | |
| 150 | + | }}, | |
| 151 | + | }, | |
| 152 | + | { | |
| 153 | + | Cols: 80, | |
| 154 | + | Rows: 24, | |
| 155 | + | Tree: layout.NewTree("pane-1"), | |
| 156 | + | Panes: []snapcodec.Pane{{ | |
| 157 | + | ID: "pane-1", | |
| 158 | + | Cwd: "/two", | |
| 159 | + | }}, | |
| 160 | + | }, | |
| 161 | + | }, | |
| 162 | + | })}}}, | |
| 163 | + | allow: []string{"less"}, | |
| 164 | + | wantWrites: [][]string{{"less README.md\n"}, {}}, | |
| 165 | + | }, | |
| 166 | + | { | |
| 167 | + | name: "agent ID command", | |
| 168 | + | store: processRestoreTestStore(t, "agent", agentProc), | |
| 169 | + | allow: []string{"pi"}, | |
| 170 | + | wantWrites: [][]string{{"pi --resume abc123\n"}}, | |
| 171 | + | }, | |
| 172 | + | { | |
| 173 | + | name: "agent ID with control byte rejected", | |
| 174 | + | store: processRestoreTestStore(t, "agent-control", controlAgentProc), | |
| 175 | + | allow: []string{"pi"}, | |
| 176 | + | wantWrites: [][]string{{}}, | |
| 177 | + | }, | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | for _, tt := range tests { | |
| 181 | + | t.Run(tt.name, func(t *testing.T) { | |
| 182 | + | factory := &restorePTYFactory{} | |
| 183 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 184 | + | WithSnapshotStore(tt.store)(d) | |
| 185 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}}) | |
| 186 | + | ||
| 187 | + | d.restoreSnapshots(context.Background()) | |
| 188 | + | ||
| 189 | + | require.Len(t, factory.opens, len(tt.wantWrites)) | |
| 190 | + | for i, want := range tt.wantWrites { | |
| 191 | + | if len(want) == 0 { | |
| 192 | + | require.Empty(t, factory.opens[i].pty.writes) | |
| 193 | + | continue | |
| 194 | + | } | |
| 195 | + | require.Equal(t, want, factory.opens[i].pty.writes) | |
| 196 | + | } | |
| 197 | + | }) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |
| 202 | + | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 203 | + | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |
| 204 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 205 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |
| 206 | + | ||
| 207 | + | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}})) | |
| 208 | + | require.Len(t, factory.opens, 1) | |
| 209 | + | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |
| 79 | 210 | } | |
| 80 | 211 | ||
| 81 | 212 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |
| @@ -118,6 +249,8 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) | |||
| 118 | 249 | ||
| 119 | 250 | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | |
| 120 | 251 | valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}}) | |
| 252 | + | badVersion := append([]byte(nil), valid...) | |
| 253 | + | binary.BigEndian.PutUint16(badVersion[4:6], 1) | |
| 121 | 254 | tests := []struct { | |
| 122 | 255 | name string | |
| 123 | 256 | blobs []ports.SnapshotBlob | |
| @@ -126,6 +259,7 @@ func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | |||
| 126 | 259 | }{ | |
| 127 | 260 | {name: "empty store"}, | |
| 128 | 261 | {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}}, | |
| 262 | + | {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}}, | |
| 129 | 263 | {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"}, | |
| 130 | 264 | } | |
| 131 | 265 | for _, tt := range tests { | |
| @@ -256,25 +390,33 @@ func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |||
| 256 | 390 | } | |
| 257 | 391 | ||
| 258 | 392 | type restorePTYFactory struct { | |
| 259 | - | mu sync.Mutex | |
| 260 | - | opens []restorePTYOpen | |
| 393 | + | mu sync.Mutex | |
| 394 | + | opens []restorePTYOpen | |
| 395 | + | writeErr error | |
| 261 | 396 | } | |
| 262 | 397 | ||
| 263 | 398 | type restorePTYOpen struct { | |
| 264 | 399 | dir string | |
| 265 | 400 | size domain.Size | |
| 401 | + | env []string | |
| 402 | + | pty *restorePTY | |
| 266 | 403 | } | |
| 267 | 404 | ||
| 268 | - | func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 405 | + | func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 269 | 406 | f.mu.Lock() | |
| 270 | 407 | defer f.mu.Unlock() | |
| 271 | - | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz}) | |
| 272 | - | return newRestorePTY(), nil | |
| 408 | + | pty := newRestorePTY() | |
| 409 | + | pty.writeErr = f.writeErr | |
| 410 | + | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | |
| 411 | + | return pty, nil | |
| 273 | 412 | } | |
| 274 | 413 | ||
| 275 | 414 | type restorePTY struct { | |
| 276 | - | done chan struct{} | |
| 277 | - | once sync.Once | |
| 415 | + | mu sync.Mutex | |
| 416 | + | writes []string | |
| 417 | + | writeErr error | |
| 418 | + | done chan struct{} | |
| 419 | + | once sync.Once | |
| 278 | 420 | } | |
| 279 | 421 | ||
| 280 | 422 | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | |
| @@ -282,7 +424,15 @@ func (p *restorePTY) Read([]byte) (int, error) { | |||
| 282 | 424 | <-p.done | |
| 283 | 425 | return 0, io.EOF | |
| 284 | 426 | } | |
| 285 | - | func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 427 | + | func (p *restorePTY) Write(b []byte) (int, error) { | |
| 428 | + | p.mu.Lock() | |
| 429 | + | defer p.mu.Unlock() | |
| 430 | + | p.writes = append(p.writes, string(b)) | |
| 431 | + | if p.writeErr != nil { | |
| 432 | + | return 0, p.writeErr | |
| 433 | + | } | |
| 434 | + | return len(b), nil | |
| 435 | + | } | |
| 286 | 436 | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | |
| 287 | 437 | func (p *restorePTY) Resize(domain.Size) error { return nil } | |
| 288 | 438 | func (p *restorePTY) Pid() int { return 0 } | |
| @@ -2,6 +2,8 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/binary" | |||
| 6 | + | "errors" | |||
| 5 | "io" | 7 | "io" | ||
| 6 | "math" | 8 | "math" | ||
| 7 | "sync" | 9 | "sync" | ||
| @@ -25,6 +27,7 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 25 | CreatedAt: 99, | 27 | CreatedAt: 99, | ||
| 26 | Active: 0, | 28 | Active: 0, | ||
| 27 | Tabs: []snapcodec.Tab{{ | 29 | Tabs: []snapcodec.Tab{{ | ||
| 30 | + | StableID: "t_saved", | |||
| 28 | Cols: 80, | 31 | Cols: 80, | ||
| 29 | Rows: 24, | 32 | Rows: 24, | ||
| 30 | NextPaneID: 3, | 33 | NextPaneID: 3, | ||
| @@ -34,8 +37,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 34 | layout.NewLeaf("pane-2"), | 37 | layout.NewLeaf("pane-2"), | ||
| 35 | }}}, | 38 | }}}, | ||
| 36 | Panes: []snapcodec.Pane{ | 39 | Panes: []snapcodec.Pane{ | ||
| 37 | - | {ID: "pane-1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, | 40 | + | {ID: "pane-1", StableID: "p_saved_1", Cwd: "/one", Scrollback: [][]renderer.Cell{cells("old1")}, Visible: [][]renderer.Cell{cells("vis1")}}, |
| 38 | - | {ID: "pane-2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, | 41 | + | {ID: "pane-2", StableID: "p_saved_2", Cwd: "/two", Scrollback: [][]renderer.Cell{cells("old2")}, Visible: [][]renderer.Cell{cells("vis2")}}, |
| 39 | }, | 42 | }, | ||
| 40 | }}, | 43 | }}, | ||
| 41 | })}} | 44 | })}} | ||
| @@ -76,6 +79,134 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 76 | require.Equal(t, "old2", rowText(copySnap.Rows[0][:4])) | 79 | require.Equal(t, "old2", rowText(copySnap.Rows[0][:4])) | ||
| 77 | require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4])) | 80 | require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4])) | ||
| 78 | p.mu.Unlock() | 81 | p.mu.Unlock() | ||
| 82 | + | ||||
| 83 | + | tb.mu.Lock() | |||
| 84 | + | tabStableID := tb.stableID | |||
| 85 | + | pane1StableID := tb.panes["pane-1"].stableID | |||
| 86 | + | pane2StableID := tb.panes["pane-2"].stableID | |||
| 87 | + | tb.mu.Unlock() | |||
| 88 | + | require.Equal(t, "t_saved", tabStableID) | |||
| 89 | + | require.Equal(t, "p_saved_1", pane1StableID) | |||
| 90 | + | require.Equal(t, "p_saved_2", pane2StableID) | |||
| 91 | + | require.Contains(t, factory.opens[0].env, "TERM=xterm-direct") | |||
| 92 | + | require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID) | |||
| 93 | + | require.Contains(t, factory.opens[1].env, "TERM=xterm-direct") | |||
| 94 | + | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |||
| 98 | + | t.Helper() | |||
| 99 | + | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 100 | + | Name: name, | |||
| 101 | + | Tabs: []snapcodec.Tab{{ | |||
| 102 | + | Cols: 80, | |||
| 103 | + | Rows: 24, | |||
| 104 | + | Tree: layout.NewTree("pane-1"), | |||
| 105 | + | Panes: []snapcodec.Pane{{ | |||
| 106 | + | ID: "pane-1", | |||
| 107 | + | Cwd: "/tmp", | |||
| 108 | + | Process: proc, | |||
| 109 | + | }}, | |||
| 110 | + | }}, | |||
| 111 | + | })}}} | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestRestoreSnapshotsProcessRestoreCommands(t *testing.T) { | |||
| 115 | + | lessProc := &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric} | |||
| 116 | + | agentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |||
| 117 | + | controlAgentProc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}} | |||
| 118 | + | ||||
| 119 | + | tests := []struct { | |||
| 120 | + | name string | |||
| 121 | + | store *restoreSnapshotStore | |||
| 122 | + | allow []string | |||
| 123 | + | wantWrites [][]string | |||
| 124 | + | }{ | |||
| 125 | + | { | |||
| 126 | + | name: "allowlisted generic command", | |||
| 127 | + | store: processRestoreTestStore(t, "allowlisted", lessProc), | |||
| 128 | + | allow: []string{"less"}, | |||
| 129 | + | wantWrites: [][]string{{"less README.md\n"}}, | |||
| 130 | + | }, | |||
| 131 | + | { | |||
| 132 | + | name: "denied generic command", | |||
| 133 | + | store: processRestoreTestStore(t, "denied", lessProc), | |||
| 134 | + | allow: []string{"vim"}, | |||
| 135 | + | wantWrites: [][]string{{}}, | |||
| 136 | + | }, | |||
| 137 | + | { | |||
| 138 | + | name: "pane scoped command", | |||
| 139 | + | store: &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "scoped", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 140 | + | Name: "scoped", | |||
| 141 | + | Tabs: []snapcodec.Tab{ | |||
| 142 | + | { | |||
| 143 | + | Cols: 80, | |||
| 144 | + | Rows: 24, | |||
| 145 | + | Tree: layout.NewTree("pane-1"), | |||
| 146 | + | Panes: []snapcodec.Pane{{ | |||
| 147 | + | ID: "pane-1", | |||
| 148 | + | Cwd: "/one", | |||
| 149 | + | Process: lessProc, | |||
| 150 | + | }}, | |||
| 151 | + | }, | |||
| 152 | + | { | |||
| 153 | + | Cols: 80, | |||
| 154 | + | Rows: 24, | |||
| 155 | + | Tree: layout.NewTree("pane-1"), | |||
| 156 | + | Panes: []snapcodec.Pane{{ | |||
| 157 | + | ID: "pane-1", | |||
| 158 | + | Cwd: "/two", | |||
| 159 | + | }}, | |||
| 160 | + | }, | |||
| 161 | + | }, | |||
| 162 | + | })}}}, | |||
| 163 | + | allow: []string{"less"}, | |||
| 164 | + | wantWrites: [][]string{{"less README.md\n"}, {}}, | |||
| 165 | + | }, | |||
| 166 | + | { | |||
| 167 | + | name: "agent ID command", | |||
| 168 | + | store: processRestoreTestStore(t, "agent", agentProc), | |||
| 169 | + | allow: []string{"pi"}, | |||
| 170 | + | wantWrites: [][]string{{"pi --resume abc123\n"}}, | |||
| 171 | + | }, | |||
| 172 | + | { | |||
| 173 | + | name: "agent ID with control byte rejected", | |||
| 174 | + | store: processRestoreTestStore(t, "agent-control", controlAgentProc), | |||
| 175 | + | allow: []string{"pi"}, | |||
| 176 | + | wantWrites: [][]string{{}}, | |||
| 177 | + | }, | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | for _, tt := range tests { | |||
| 181 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 182 | + | factory := &restorePTYFactory{} | |||
| 183 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 184 | + | WithSnapshotStore(tt.store)(d) | |||
| 185 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: tt.allow}}) | |||
| 186 | + | ||||
| 187 | + | d.restoreSnapshots(context.Background()) | |||
| 188 | + | ||||
| 189 | + | require.Len(t, factory.opens, len(tt.wantWrites)) | |||
| 190 | + | for i, want := range tt.wantWrites { | |||
| 191 | + | if len(want) == 0 { | |||
| 192 | + | require.Empty(t, factory.opens[i].pty.writes) | |||
| 193 | + | continue | |||
| 194 | + | } | |||
| 195 | + | require.Equal(t, want, factory.opens[i].pty.writes) | |||
| 196 | + | } | |||
| 197 | + | }) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestRestoreSnapshotsProcessCommandWriteFailureIsNonFatal(t *testing.T) { | |||
| 202 | + | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |||
| 203 | + | factory := &restorePTYFactory{writeErr: errors.New("boom")} | |||
| 204 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 205 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |||
| 206 | + | ||||
| 207 | + | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}})) | |||
| 208 | + | require.Len(t, factory.opens, 1) | |||
| 209 | + | require.Equal(t, []string{"pi --resume abc123\n"}, factory.opens[0].pty.writes) | |||
| 79 | } | 210 | } | ||
| 80 | 211 | ||||
| 81 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | 212 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | ||
| @@ -118,6 +249,8 @@ func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) | |||||
| 118 | 249 | ||||
| 119 | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | 250 | func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | ||
| 120 | valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}}) | 251 | valid := mustSnapshotBytes(t, snapcodec.Session{Name: "live", CreatedAt: 1, Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/ok"}}}}}) | ||
| 252 | + | badVersion := append([]byte(nil), valid...) | |||
| 253 | + | binary.BigEndian.PutUint16(badVersion[4:6], 1) | |||
| 121 | tests := []struct { | 254 | tests := []struct { | ||
| 122 | name string | 255 | name string | ||
| 123 | blobs []ports.SnapshotBlob | 256 | blobs []ports.SnapshotBlob | ||
| @@ -126,6 +259,7 @@ func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) { | |||||
| 126 | }{ | 259 | }{ | ||
| 127 | {name: "empty store"}, | 260 | {name: "empty store"}, | ||
| 128 | {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}}, | 261 | {name: "corrupt blob", blobs: []ports.SnapshotBlob{{Name: "bad", Data: []byte("not a snapshot")}}}, | ||
| 262 | + | {name: "old v1 blob", blobs: []ports.SnapshotBlob{{Name: "old", Data: badVersion}}}, | |||
| 129 | {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"}, | 263 | {name: "live name", blobs: []ports.SnapshotBlob{{Name: "live", Data: valid}}, liveName: "live"}, | ||
| 130 | } | 264 | } | ||
| 131 | for _, tt := range tests { | 265 | for _, tt := range tests { | ||
| @@ -256,25 +390,33 @@ func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |||||
| 256 | } | 390 | } | ||
| 257 | 391 | ||||
| 258 | type restorePTYFactory struct { | 392 | type restorePTYFactory struct { | ||
| 259 | - | mu sync.Mutex | 393 | + | mu sync.Mutex |
| 260 | - | opens []restorePTYOpen | 394 | + | opens []restorePTYOpen |
| 395 | + | writeErr error | |||
| 261 | } | 396 | } | ||
| 262 | 397 | ||||
| 263 | type restorePTYOpen struct { | 398 | type restorePTYOpen struct { | ||
| 264 | dir string | 399 | dir string | ||
| 265 | size domain.Size | 400 | size domain.Size | ||
| 401 | + | env []string | |||
| 402 | + | pty *restorePTY | |||
| 266 | } | 403 | } | ||
| 267 | 404 | ||||
| 268 | - | func (f *restorePTYFactory) Open(_ string, _ []string, _ []string, dir string, sz domain.Size) (ports.PTY, error) { | 405 | + | func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { |
| 269 | f.mu.Lock() | 406 | f.mu.Lock() | ||
| 270 | defer f.mu.Unlock() | 407 | defer f.mu.Unlock() | ||
| 271 | - | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz}) | 408 | + | pty := newRestorePTY() |
| 272 | - | return newRestorePTY(), nil | 409 | + | pty.writeErr = f.writeErr |
| 410 | + | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | |||
| 411 | + | return pty, nil | |||
| 273 | } | 412 | } | ||
| 274 | 413 | ||||
| 275 | type restorePTY struct { | 414 | type restorePTY struct { | ||
| 276 | - | done chan struct{} | 415 | + | mu sync.Mutex |
| 277 | - | once sync.Once | 416 | + | writes []string |
| 417 | + | writeErr error | |||
| 418 | + | done chan struct{} | |||
| 419 | + | once sync.Once | |||
| 278 | } | 420 | } | ||
| 279 | 421 | ||||
| 280 | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | 422 | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | ||
| @@ -282,7 +424,15 @@ func (p *restorePTY) Read([]byte) (int, error) { | |||||
| 282 | <-p.done | 424 | <-p.done | ||
| 283 | return 0, io.EOF | 425 | return 0, io.EOF | ||
| 284 | } | 426 | } | ||
| 285 | - | func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil } | 427 | + | func (p *restorePTY) Write(b []byte) (int, error) { |
| 428 | + | p.mu.Lock() | |||
| 429 | + | defer p.mu.Unlock() | |||
| 430 | + | p.writes = append(p.writes, string(b)) | |||
| 431 | + | if p.writeErr != nil { | |||
| 432 | + | return 0, p.writeErr | |||
| 433 | + | } | |||
| 434 | + | return len(b), nil | |||
| 435 | + | } | |||
| 286 | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | 436 | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | ||
| 287 | func (p *restorePTY) Resize(domain.Size) error { return nil } | 437 | func (p *restorePTY) Resize(domain.Size) error { return nil } | ||
| 288 | func (p *restorePTY) Pid() int { return 0 } | 438 | func (p *restorePTY) Pid() int { return 0 } | ||
M internal/usecase/daemon/snapshot_test.go +82 -0
| @@ -63,6 +63,77 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | + | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 69 | + | WithSnapshotStore(store)(d) | |
| 70 | + | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | |
| 71 | + | require.Equal(t, 4321, pgid) | |
| 72 | + | require.Equal(t, 1234, shellPid) | |
| 73 | + | return []string{"claude", "--session-id", "agent-123"}, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 77 | + | pty := snapshotProcessPTY(t, 4321) | |
| 78 | + | sess.tabs[0].panes["pane-1"].pty = pty | |
| 79 | + | ||
| 80 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 81 | + | snap, err := snapcodec.Unmarshal(data) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | proc := snap.Tabs[0].Panes[0].Process | |
| 84 | + | require.NotNil(t, proc) | |
| 85 | + | require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv) | |
| 86 | + | require.Equal(t, processStrategyClaude, proc.Strategy) | |
| 87 | + | require.Equal(t, "agent-123", proc.Opts.AgentSessionID) | |
| 88 | + | return nil | |
| 89 | + | }).Once() | |
| 90 | + | ||
| 91 | + | require.True(t, d.captureSession(sess)) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | |
| 95 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 96 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 97 | + | WithSnapshotStore(store)(d) | |
| 98 | + | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | |
| 99 | + | ||
| 100 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 101 | + | pty := snapshotProcessPTY(t, 4321) | |
| 102 | + | sess.tabs[0].panes["pane-1"].pty = pty | |
| 103 | + | ||
| 104 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 105 | + | snap, err := snapcodec.Unmarshal(data) | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |
| 108 | + | return nil | |
| 109 | + | }).Once() | |
| 110 | + | ||
| 111 | + | require.True(t, d.captureSession(sess)) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | |
| 115 | + | store := portsmocks.NewMockSnapshotStore(t) | |
| 116 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 117 | + | WithSnapshotStore(store)(d) | |
| 118 | + | d.procGroupArgv = func(int, int) ([]string, error) { | |
| 119 | + | t.Fatal("bare shell should not inspect argv") | |
| 120 | + | return nil, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |
| 124 | + | pty := snapshotProcessPTY(t, 1234) | |
| 125 | + | sess.tabs[0].panes["pane-1"].pty = pty | |
| 126 | + | ||
| 127 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |
| 128 | + | snap, err := snapcodec.Unmarshal(data) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |
| 131 | + | return nil | |
| 132 | + | }).Once() | |
| 133 | + | ||
| 134 | + | require.True(t, d.captureSession(sess)) | |
| 135 | + | } | |
| 136 | + | ||
| 66 | 137 | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | |
| 67 | 138 | store := portsmocks.NewMockSnapshotStore(t) | |
| 68 | 139 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| @@ -293,6 +364,17 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |||
| 293 | 364 | } | |
| 294 | 365 | } | |
| 295 | 366 | ||
| 367 | + | func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY { | |
| 368 | + | t.Helper() | |
| 369 | + | pty := portsmocks.NewMockPTY(t) | |
| 370 | + | pty.EXPECT().Pid().Return(1234).Maybe() | |
| 371 | + | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 372 | + | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |
| 373 | + | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |
| 374 | + | pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once() | |
| 375 | + | return pty | |
| 376 | + | } | |
| 377 | + | ||
| 296 | 378 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | |
| 297 | 379 | t.Helper() | |
| 298 | 380 | pty := portsmocks.NewMockPTY(t) | |
| @@ -63,6 +63,77 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | + | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | |||
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 69 | + | WithSnapshotStore(store)(d) | |||
| 70 | + | d.procGroupArgv = func(pgid int, shellPid int) ([]string, error) { | |||
| 71 | + | require.Equal(t, 4321, pgid) | |||
| 72 | + | require.Equal(t, 1234, shellPid) | |||
| 73 | + | return []string{"claude", "--session-id", "agent-123"}, nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 77 | + | pty := snapshotProcessPTY(t, 4321) | |||
| 78 | + | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 79 | + | ||||
| 80 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 81 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | proc := snap.Tabs[0].Panes[0].Process | |||
| 84 | + | require.NotNil(t, proc) | |||
| 85 | + | require.Equal(t, []string{"claude", "--session-id", "agent-123"}, proc.Argv) | |||
| 86 | + | require.Equal(t, processStrategyClaude, proc.Strategy) | |||
| 87 | + | require.Equal(t, "agent-123", proc.Opts.AgentSessionID) | |||
| 88 | + | return nil | |||
| 89 | + | }).Once() | |||
| 90 | + | ||||
| 91 | + | require.True(t, d.captureSession(sess)) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestSnapshotCaptureProcessFailureDoesNotBlockSnapshot(t *testing.T) { | |||
| 95 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 96 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 97 | + | WithSnapshotStore(store)(d) | |||
| 98 | + | d.procGroupArgv = func(int, int) ([]string, error) { return nil, errors.New("inspect failed") } | |||
| 99 | + | ||||
| 100 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 101 | + | pty := snapshotProcessPTY(t, 4321) | |||
| 102 | + | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 103 | + | ||||
| 104 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 105 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |||
| 108 | + | return nil | |||
| 109 | + | }).Once() | |||
| 110 | + | ||||
| 111 | + | require.True(t, d.captureSession(sess)) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestSnapshotCaptureSkipsBareShellProcess(t *testing.T) { | |||
| 115 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 116 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 117 | + | WithSnapshotStore(store)(d) | |||
| 118 | + | d.procGroupArgv = func(int, int) ([]string, error) { | |||
| 119 | + | t.Fatal("bare shell should not inspect argv") | |||
| 120 | + | return nil, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 124 | + | pty := snapshotProcessPTY(t, 1234) | |||
| 125 | + | sess.tabs[0].panes["pane-1"].pty = pty | |||
| 126 | + | ||||
| 127 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 128 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | require.Nil(t, snap.Tabs[0].Panes[0].Process) | |||
| 131 | + | return nil | |||
| 132 | + | }).Once() | |||
| 133 | + | ||||
| 134 | + | require.True(t, d.captureSession(sess)) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 66 | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | 137 | func TestKillSessionSnapshotsNamedSessionBeforeClosingPanes(t *testing.T) { | ||
| 67 | store := portsmocks.NewMockSnapshotStore(t) | 138 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 68 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 139 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| @@ -293,6 +364,17 @@ func TestSnapshotSaverCapturesLayoutOnlyDirtySave(t *testing.T) { | |||||
| 293 | } | 364 | } | ||
| 294 | } | 365 | } | ||
| 295 | 366 | ||||
| 367 | + | func snapshotProcessPTY(t *testing.T, pgid int) *portsmocks.MockPTY { | |||
| 368 | + | t.Helper() | |||
| 369 | + | pty := portsmocks.NewMockPTY(t) | |||
| 370 | + | pty.EXPECT().Pid().Return(1234).Maybe() | |||
| 371 | + | pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 372 | + | pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe() | |||
| 373 | + | pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe() | |||
| 374 | + | pty.EXPECT().ForegroundPgid().Return(pgid, nil).Once() | |||
| 375 | + | return pty | |||
| 376 | + | } | |||
| 377 | + | ||||
| 296 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | 378 | func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session { | ||
| 297 | t.Helper() | 379 | t.Helper() | ||
| 298 | pty := portsmocks.NewMockPTY(t) | 380 | pty := portsmocks.NewMockPTY(t) | ||
M internal/usecase/snapshot/codec.go +101 -6
| @@ -27,7 +27,7 @@ var ( | |||
| 27 | 27 | ||
| 28 | 28 | const ( | |
| 29 | 29 | magic = "VEVS" | |
| 30 | - | version = uint16(1) | |
| 30 | + | version = uint16(2) | |
| 31 | 31 | flagFlate = uint16(1 << 0) | |
| 32 | 32 | flateCutoff = 4 << 10 | |
| 33 | 33 | maxDecodedBodySize = 256 << 20 | |
| @@ -36,7 +36,7 @@ const ( | |||
| 36 | 36 | maxDecodedCells = 16 << 20 | |
| 37 | 37 | ) | |
| 38 | 38 | ||
| 39 | - | // Marshal encodes a v1 durable snapshot. | |
| 39 | + | // Marshal encodes a v2 durable snapshot. | |
| 40 | 40 | func Marshal(s Session) ([]byte, error) { | |
| 41 | 41 | var w payloadWriter | |
| 42 | 42 | if err := writeSession(&w, s); err != nil { | |
| @@ -73,7 +73,7 @@ func Marshal(s Session) ([]byte, error) { | |||
| 73 | 73 | return out, nil | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | - | // Unmarshal decodes a strict v1 durable snapshot. | |
| 76 | + | // Unmarshal decodes a strict v2 durable snapshot. | |
| 77 | 77 | func Unmarshal(b []byte) (Session, error) { | |
| 78 | 78 | if len(b) < 16 { | |
| 79 | 79 | return Session{}, ErrShortPayload | |
| @@ -166,6 +166,18 @@ func (w *payloadWriter) putString(s string) error { | |||
| 166 | 166 | w.b = append(w.b, s...) | |
| 167 | 167 | return nil | |
| 168 | 168 | } | |
| 169 | + | func (w *payloadWriter) putStrings(ss []string) error { | |
| 170 | + | if len(ss) > math.MaxUint16 { | |
| 171 | + | return fmt.Errorf("%w: too many strings", ErrInvalidData) | |
| 172 | + | } | |
| 173 | + | w.putUint16(uint16(len(ss))) | |
| 174 | + | for _, s := range ss { | |
| 175 | + | if err := w.putString(s); err != nil { | |
| 176 | + | return err | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | return nil | |
| 180 | + | } | |
| 169 | 181 | ||
| 170 | 182 | type payloadReader struct{ b []byte } | |
| 171 | 183 | ||
| @@ -213,6 +225,21 @@ func (r *payloadReader) getString() (string, error) { | |||
| 213 | 225 | r.b = r.b[n:] | |
| 214 | 226 | return s, nil | |
| 215 | 227 | } | |
| 228 | + | func (r *payloadReader) getStrings() ([]string, error) { | |
| 229 | + | n, err := r.getUint16() | |
| 230 | + | if err != nil { | |
| 231 | + | return nil, err | |
| 232 | + | } | |
| 233 | + | out := make([]string, n) | |
| 234 | + | for i := range out { | |
| 235 | + | s, err := r.getString() | |
| 236 | + | if err != nil { | |
| 237 | + | return nil, err | |
| 238 | + | } | |
| 239 | + | out[i] = s | |
| 240 | + | } | |
| 241 | + | return out, nil | |
| 242 | + | } | |
| 216 | 243 | func (r *payloadReader) done() error { | |
| 217 | 244 | if len(r.b) != 0 { | |
| 218 | 245 | return ErrTrailingBytes | |
| @@ -267,6 +294,9 @@ func readSession(r *payloadReader) (Session, error) { | |||
| 267 | 294 | } | |
| 268 | 295 | ||
| 269 | 296 | func writeTab(w *payloadWriter, t Tab) error { | |
| 297 | + | if err := w.putString(t.StableID); err != nil { | |
| 298 | + | return err | |
| 299 | + | } | |
| 270 | 300 | w.putUint16(t.Cols) | |
| 271 | 301 | w.putUint16(t.Rows) | |
| 272 | 302 | w.putUint64(t.NextPaneID) | |
| @@ -293,6 +323,10 @@ func writeTab(w *payloadWriter, t Tab) error { | |||
| 293 | 323 | } | |
| 294 | 324 | ||
| 295 | 325 | func readTab(r *payloadReader) (Tab, error) { | |
| 326 | + | stableID, err := r.getString() | |
| 327 | + | if err != nil { | |
| 328 | + | return Tab{}, err | |
| 329 | + | } | |
| 296 | 330 | cols, err := r.getUint16() | |
| 297 | 331 | if err != nil { | |
| 298 | 332 | return Tab{}, err | |
| @@ -317,7 +351,7 @@ func readTab(r *payloadReader) (Tab, error) { | |||
| 317 | 351 | if err != nil { | |
| 318 | 352 | return Tab{}, err | |
| 319 | 353 | } | |
| 320 | - | t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)} | |
| 354 | + | t := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)} | |
| 321 | 355 | for i := range t.Panes { | |
| 322 | 356 | p, err := readPane(r) | |
| 323 | 357 | if err != nil { | |
| @@ -424,6 +458,9 @@ func writePane(w *payloadWriter, p Pane) error { | |||
| 424 | 458 | if err := w.putString(string(p.ID)); err != nil { | |
| 425 | 459 | return err | |
| 426 | 460 | } | |
| 461 | + | if err := w.putString(p.StableID); err != nil { | |
| 462 | + | return err | |
| 463 | + | } | |
| 427 | 464 | if err := w.putString(p.Cwd); err != nil { | |
| 428 | 465 | return err | |
| 429 | 466 | } | |
| @@ -442,7 +479,10 @@ func writePane(w *payloadWriter, p Pane) error { | |||
| 442 | 479 | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | |
| 443 | 480 | return err | |
| 444 | 481 | } | |
| 445 | - | return writeRows(w, p.Visible, idx, true) | |
| 482 | + | if err := writeRows(w, p.Visible, idx, true); err != nil { | |
| 483 | + | return err | |
| 484 | + | } | |
| 485 | + | return writeProcess(w, p.Process) | |
| 446 | 486 | } | |
| 447 | 487 | ||
| 448 | 488 | func readPane(r *payloadReader) (Pane, error) { | |
| @@ -450,6 +490,10 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 450 | 490 | if err != nil { | |
| 451 | 491 | return Pane{}, err | |
| 452 | 492 | } | |
| 493 | + | stableID, err := r.getString() | |
| 494 | + | if err != nil { | |
| 495 | + | return Pane{}, err | |
| 496 | + | } | |
| 453 | 497 | cwd, err := r.getString() | |
| 454 | 498 | if err != nil { | |
| 455 | 499 | return Pane{}, err | |
| @@ -475,7 +519,58 @@ func readPane(r *payloadReader) (Pane, error) { | |||
| 475 | 519 | if err != nil { | |
| 476 | 520 | return Pane{}, err | |
| 477 | 521 | } | |
| 478 | - | return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil | |
| 522 | + | proc, err := readProcess(r) | |
| 523 | + | if err != nil { | |
| 524 | + | return Pane{}, err | |
| 525 | + | } | |
| 526 | + | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func writeProcess(w *payloadWriter, p *Process) error { | |
| 530 | + | if p == nil { | |
| 531 | + | w.putUint8(0) | |
| 532 | + | return nil | |
| 533 | + | } | |
| 534 | + | if len(p.Argv) == 0 { | |
| 535 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 536 | + | } | |
| 537 | + | w.putUint8(1) | |
| 538 | + | if err := w.putStrings(p.Argv); err != nil { | |
| 539 | + | return err | |
| 540 | + | } | |
| 541 | + | if err := w.putString(p.Strategy); err != nil { | |
| 542 | + | return err | |
| 543 | + | } | |
| 544 | + | return w.putString(p.Opts.AgentSessionID) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func readProcess(r *payloadReader) (*Process, error) { | |
| 548 | + | present, err := r.getUint8() | |
| 549 | + | if err != nil { | |
| 550 | + | return nil, err | |
| 551 | + | } | |
| 552 | + | if present == 0 { | |
| 553 | + | return nil, nil | |
| 554 | + | } | |
| 555 | + | if present != 1 { | |
| 556 | + | return nil, fmt.Errorf("%w: process presence", ErrInvalidData) | |
| 557 | + | } | |
| 558 | + | argv, err := r.getStrings() | |
| 559 | + | if err != nil { | |
| 560 | + | return nil, err | |
| 561 | + | } | |
| 562 | + | if len(argv) == 0 { | |
| 563 | + | return nil, fmt.Errorf("%w: process argv empty", ErrInvalidData) | |
| 564 | + | } | |
| 565 | + | strategy, err := r.getString() | |
| 566 | + | if err != nil { | |
| 567 | + | return nil, err | |
| 568 | + | } | |
| 569 | + | agentSessionID, err := r.getString() | |
| 570 | + | if err != nil { | |
| 571 | + | return nil, err | |
| 572 | + | } | |
| 573 | + | return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil | |
| 479 | 574 | } | |
| 480 | 575 | ||
| 481 | 576 | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | |
| @@ -27,7 +27,7 @@ var ( | |||||
| 27 | 27 | ||||
| 28 | const ( | 28 | const ( | ||
| 29 | magic = "VEVS" | 29 | magic = "VEVS" | ||
| 30 | - | version = uint16(1) | 30 | + | version = uint16(2) |
| 31 | flagFlate = uint16(1 << 0) | 31 | flagFlate = uint16(1 << 0) | ||
| 32 | flateCutoff = 4 << 10 | 32 | flateCutoff = 4 << 10 | ||
| 33 | maxDecodedBodySize = 256 << 20 | 33 | maxDecodedBodySize = 256 << 20 | ||
| @@ -36,7 +36,7 @@ const ( | |||||
| 36 | maxDecodedCells = 16 << 20 | 36 | maxDecodedCells = 16 << 20 | ||
| 37 | ) | 37 | ) | ||
| 38 | 38 | ||||
| 39 | - | // Marshal encodes a v1 durable snapshot. | 39 | + | // Marshal encodes a v2 durable snapshot. |
| 40 | func Marshal(s Session) ([]byte, error) { | 40 | func Marshal(s Session) ([]byte, error) { | ||
| 41 | var w payloadWriter | 41 | var w payloadWriter | ||
| 42 | if err := writeSession(&w, s); err != nil { | 42 | if err := writeSession(&w, s); err != nil { | ||
| @@ -73,7 +73,7 @@ func Marshal(s Session) ([]byte, error) { | |||||
| 73 | return out, nil | 73 | return out, nil | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | - | // Unmarshal decodes a strict v1 durable snapshot. | 76 | + | // Unmarshal decodes a strict v2 durable snapshot. |
| 77 | func Unmarshal(b []byte) (Session, error) { | 77 | func Unmarshal(b []byte) (Session, error) { | ||
| 78 | if len(b) < 16 { | 78 | if len(b) < 16 { | ||
| 79 | return Session{}, ErrShortPayload | 79 | return Session{}, ErrShortPayload | ||
| @@ -166,6 +166,18 @@ func (w *payloadWriter) putString(s string) error { | |||||
| 166 | w.b = append(w.b, s...) | 166 | w.b = append(w.b, s...) | ||
| 167 | return nil | 167 | return nil | ||
| 168 | } | 168 | } | ||
| 169 | + | func (w *payloadWriter) putStrings(ss []string) error { | |||
| 170 | + | if len(ss) > math.MaxUint16 { | |||
| 171 | + | return fmt.Errorf("%w: too many strings", ErrInvalidData) | |||
| 172 | + | } | |||
| 173 | + | w.putUint16(uint16(len(ss))) | |||
| 174 | + | for _, s := range ss { | |||
| 175 | + | if err := w.putString(s); err != nil { | |||
| 176 | + | return err | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | return nil | |||
| 180 | + | } | |||
| 169 | 181 | ||||
| 170 | type payloadReader struct{ b []byte } | 182 | type payloadReader struct{ b []byte } | ||
| 171 | 183 | ||||
| @@ -213,6 +225,21 @@ func (r *payloadReader) getString() (string, error) { | |||||
| 213 | r.b = r.b[n:] | 225 | r.b = r.b[n:] | ||
| 214 | return s, nil | 226 | return s, nil | ||
| 215 | } | 227 | } | ||
| 228 | + | func (r *payloadReader) getStrings() ([]string, error) { | |||
| 229 | + | n, err := r.getUint16() | |||
| 230 | + | if err != nil { | |||
| 231 | + | return nil, err | |||
| 232 | + | } | |||
| 233 | + | out := make([]string, n) | |||
| 234 | + | for i := range out { | |||
| 235 | + | s, err := r.getString() | |||
| 236 | + | if err != nil { | |||
| 237 | + | return nil, err | |||
| 238 | + | } | |||
| 239 | + | out[i] = s | |||
| 240 | + | } | |||
| 241 | + | return out, nil | |||
| 242 | + | } | |||
| 216 | func (r *payloadReader) done() error { | 243 | func (r *payloadReader) done() error { | ||
| 217 | if len(r.b) != 0 { | 244 | if len(r.b) != 0 { | ||
| 218 | return ErrTrailingBytes | 245 | return ErrTrailingBytes | ||
| @@ -267,6 +294,9 @@ func readSession(r *payloadReader) (Session, error) { | |||||
| 267 | } | 294 | } | ||
| 268 | 295 | ||||
| 269 | func writeTab(w *payloadWriter, t Tab) error { | 296 | func writeTab(w *payloadWriter, t Tab) error { | ||
| 297 | + | if err := w.putString(t.StableID); err != nil { | |||
| 298 | + | return err | |||
| 299 | + | } | |||
| 270 | w.putUint16(t.Cols) | 300 | w.putUint16(t.Cols) | ||
| 271 | w.putUint16(t.Rows) | 301 | w.putUint16(t.Rows) | ||
| 272 | w.putUint64(t.NextPaneID) | 302 | w.putUint64(t.NextPaneID) | ||
| @@ -293,6 +323,10 @@ func writeTab(w *payloadWriter, t Tab) error { | |||||
| 293 | } | 323 | } | ||
| 294 | 324 | ||||
| 295 | func readTab(r *payloadReader) (Tab, error) { | 325 | func readTab(r *payloadReader) (Tab, error) { | ||
| 326 | + | stableID, err := r.getString() | |||
| 327 | + | if err != nil { | |||
| 328 | + | return Tab{}, err | |||
| 329 | + | } | |||
| 296 | cols, err := r.getUint16() | 330 | cols, err := r.getUint16() | ||
| 297 | if err != nil { | 331 | if err != nil { | ||
| 298 | return Tab{}, err | 332 | return Tab{}, err | ||
| @@ -317,7 +351,7 @@ func readTab(r *payloadReader) (Tab, error) { | |||||
| 317 | if err != nil { | 351 | if err != nil { | ||
| 318 | return Tab{}, err | 352 | return Tab{}, err | ||
| 319 | } | 353 | } | ||
| 320 | - | t := Tab{Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)} | 354 | + | t := Tab{StableID: stableID, Cols: cols, Rows: rows, NextPaneID: next, Focus: layout.PaneID(focus), Tree: &layout.Tree{Root: root, Focus: layout.PaneID(focus)}, Panes: make([]Pane, np)} |
| 321 | for i := range t.Panes { | 355 | for i := range t.Panes { | ||
| 322 | p, err := readPane(r) | 356 | p, err := readPane(r) | ||
| 323 | if err != nil { | 357 | if err != nil { | ||
| @@ -424,6 +458,9 @@ func writePane(w *payloadWriter, p Pane) error { | |||||
| 424 | if err := w.putString(string(p.ID)); err != nil { | 458 | if err := w.putString(string(p.ID)); err != nil { | ||
| 425 | return err | 459 | return err | ||
| 426 | } | 460 | } | ||
| 461 | + | if err := w.putString(p.StableID); err != nil { | |||
| 462 | + | return err | |||
| 463 | + | } | |||
| 427 | if err := w.putString(p.Cwd); err != nil { | 464 | if err := w.putString(p.Cwd); err != nil { | ||
| 428 | return err | 465 | return err | ||
| 429 | } | 466 | } | ||
| @@ -442,7 +479,10 @@ func writePane(w *payloadWriter, p Pane) error { | |||||
| 442 | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | 479 | if err := writeRows(w, p.Scrollback, idx, false); err != nil { | ||
| 443 | return err | 480 | return err | ||
| 444 | } | 481 | } | ||
| 445 | - | return writeRows(w, p.Visible, idx, true) | 482 | + | if err := writeRows(w, p.Visible, idx, true); err != nil { |
| 483 | + | return err | |||
| 484 | + | } | |||
| 485 | + | return writeProcess(w, p.Process) | |||
| 446 | } | 486 | } | ||
| 447 | 487 | ||||
| 448 | func readPane(r *payloadReader) (Pane, error) { | 488 | func readPane(r *payloadReader) (Pane, error) { | ||
| @@ -450,6 +490,10 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 450 | if err != nil { | 490 | if err != nil { | ||
| 451 | return Pane{}, err | 491 | return Pane{}, err | ||
| 452 | } | 492 | } | ||
| 493 | + | stableID, err := r.getString() | |||
| 494 | + | if err != nil { | |||
| 495 | + | return Pane{}, err | |||
| 496 | + | } | |||
| 453 | cwd, err := r.getString() | 497 | cwd, err := r.getString() | ||
| 454 | if err != nil { | 498 | if err != nil { | ||
| 455 | return Pane{}, err | 499 | return Pane{}, err | ||
| @@ -475,7 +519,58 @@ func readPane(r *payloadReader) (Pane, error) { | |||||
| 475 | if err != nil { | 519 | if err != nil { | ||
| 476 | return Pane{}, err | 520 | return Pane{}, err | ||
| 477 | } | 521 | } | ||
| 478 | - | return Pane{ID: layout.PaneID(id), Cwd: cwd, Scrollback: sb, Visible: vis}, nil | 522 | + | proc, err := readProcess(r) |
| 523 | + | if err != nil { | |||
| 524 | + | return Pane{}, err | |||
| 525 | + | } | |||
| 526 | + | return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, Scrollback: sb, Visible: vis, Process: proc}, nil | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func writeProcess(w *payloadWriter, p *Process) error { | |||
| 530 | + | if p == nil { | |||
| 531 | + | w.putUint8(0) | |||
| 532 | + | return nil | |||
| 533 | + | } | |||
| 534 | + | if len(p.Argv) == 0 { | |||
| 535 | + | return fmt.Errorf("%w: process argv empty", ErrInvalidData) | |||
| 536 | + | } | |||
| 537 | + | w.putUint8(1) | |||
| 538 | + | if err := w.putStrings(p.Argv); err != nil { | |||
| 539 | + | return err | |||
| 540 | + | } | |||
| 541 | + | if err := w.putString(p.Strategy); err != nil { | |||
| 542 | + | return err | |||
| 543 | + | } | |||
| 544 | + | return w.putString(p.Opts.AgentSessionID) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | func readProcess(r *payloadReader) (*Process, error) { | |||
| 548 | + | present, err := r.getUint8() | |||
| 549 | + | if err != nil { | |||
| 550 | + | return nil, err | |||
| 551 | + | } | |||
| 552 | + | if present == 0 { | |||
| 553 | + | return nil, nil | |||
| 554 | + | } | |||
| 555 | + | if present != 1 { | |||
| 556 | + | return nil, fmt.Errorf("%w: process presence", ErrInvalidData) | |||
| 557 | + | } | |||
| 558 | + | argv, err := r.getStrings() | |||
| 559 | + | if err != nil { | |||
| 560 | + | return nil, err | |||
| 561 | + | } | |||
| 562 | + | if len(argv) == 0 { | |||
| 563 | + | return nil, fmt.Errorf("%w: process argv empty", ErrInvalidData) | |||
| 564 | + | } | |||
| 565 | + | strategy, err := r.getString() | |||
| 566 | + | if err != nil { | |||
| 567 | + | return nil, err | |||
| 568 | + | } | |||
| 569 | + | agentSessionID, err := r.getString() | |||
| 570 | + | if err != nil { | |||
| 571 | + | return nil, err | |||
| 572 | + | } | |||
| 573 | + | return &Process{Argv: argv, Strategy: strategy, Opts: ProcessOpts{AgentSessionID: agentSessionID}}, nil | |||
| 479 | } | 574 | } | ||
| 480 | 575 | ||||
| 481 | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | 576 | func writeRows(w *payloadWriter, rows [][]renderer.Cell, idx map[renderer.Style]uint16, trimBlankRows bool) error { | ||
M internal/usecase/snapshot/codec_test.go +59 -6
| @@ -18,7 +18,7 @@ func TestMarshalMinimalGolden(t *testing.T) { | |||
| 18 | 18 | t.Fatalf("Marshal() error = %v", err) | |
| 19 | 19 | } | |
| 20 | 20 | want := []byte{ | |
| 21 | - | 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, | |
| 21 | + | 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, | |
| 22 | 22 | 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, | |
| 23 | 23 | } | |
| 24 | 24 | if !bytes.Equal(got, want) { | |
| @@ -76,7 +76,7 @@ func TestRoundTripSessions(t *testing.T) { | |||
| 76 | 76 | {Rune: 'x', Style: indexed}, | |
| 77 | 77 | }} | |
| 78 | 78 | multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | |
| 79 | - | {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}}, | |
| 79 | + | {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}}, | |
| 80 | 80 | {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}}, | |
| 81 | 81 | }} | |
| 82 | 82 | empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}} | |
| @@ -116,7 +116,7 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | |||
| 116 | 116 | data []byte | |
| 117 | 117 | }{ | |
| 118 | 118 | {"bad magic", append([]byte("NOPE"), good[4:]...)}, | |
| 119 | - | {"bad version", replaceU16(good, 4, 2)}, | |
| 119 | + | {"bad version", replaceU16(good, 4, 1)}, | |
| 120 | 120 | {"bad crc", append([]byte(nil), good[:len(good)-1]...)}, | |
| 121 | 121 | {"trailing", append(append([]byte(nil), good...), 0)}, | |
| 122 | 122 | {"body len overrun", replaceU32(good, 8, 999999)}, | |
| @@ -143,6 +143,42 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | |||
| 143 | 143 | } | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | + | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | |
| 147 | + | _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}}) | |
| 148 | + | if !errors.Is(err, ErrInvalidData) { | |
| 149 | + | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |
| 154 | + | var w payloadWriter | |
| 155 | + | _ = w.putString("x") | |
| 156 | + | w.putUint64(0) | |
| 157 | + | w.putUint16(0) | |
| 158 | + | w.putUint16(1) | |
| 159 | + | _ = w.putString("t") | |
| 160 | + | w.putUint16(80) | |
| 161 | + | w.putUint16(24) | |
| 162 | + | w.putUint64(1) | |
| 163 | + | _ = w.putString("p") | |
| 164 | + | _ = writeNode(&w, layout.NewLeaf("p")) | |
| 165 | + | w.putUint16(1) | |
| 166 | + | _ = w.putString("p") | |
| 167 | + | _ = w.putString("p_stable") | |
| 168 | + | _ = w.putString("") | |
| 169 | + | w.putUint16(0) | |
| 170 | + | w.putUint32(0) | |
| 171 | + | w.putUint32(0) | |
| 172 | + | w.putUint8(1) | |
| 173 | + | w.putUint16(0) | |
| 174 | + | _ = w.putString("generic") | |
| 175 | + | _ = w.putString("") | |
| 176 | + | _, err := Unmarshal(reheader(w.b, 0)) | |
| 177 | + | if !errors.Is(err, ErrInvalidData) { | |
| 178 | + | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 146 | 182 | func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) { | |
| 147 | 183 | body := []byte{0} | |
| 148 | 184 | oversizedRaw := reheader(body, 0) | |
| @@ -248,11 +284,26 @@ func equalSession(a, b Session) bool { | |||
| 248 | 284 | } | |
| 249 | 285 | ||
| 250 | 286 | func equalTab(a, b Tab) bool { | |
| 251 | - | if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) { | |
| 287 | + | if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) { | |
| 252 | 288 | return false | |
| 253 | 289 | } | |
| 254 | 290 | for i := range a.Panes { | |
| 255 | - | if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) { | |
| 291 | + | if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) { | |
| 292 | + | return false | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | return true | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func equalProcess(a, b *Process) bool { | |
| 299 | + | if a == nil || b == nil { | |
| 300 | + | return a == b | |
| 301 | + | } | |
| 302 | + | if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) { | |
| 303 | + | return false | |
| 304 | + | } | |
| 305 | + | for i := range a.Argv { | |
| 306 | + | if a.Argv[i] != b.Argv[i] { | |
| 256 | 307 | return false | |
| 257 | 308 | } | |
| 258 | 309 | } | |
| @@ -311,7 +362,7 @@ func trimSession(s Session) Session { | |||
| 311 | 362 | func reheader(body []byte, flags uint16) []byte { | |
| 312 | 363 | out := make([]byte, 16+len(body)) | |
| 313 | 364 | copy(out[:4], []byte("VEVS")) | |
| 314 | - | binary.BigEndian.PutUint16(out[4:6], 1) | |
| 365 | + | binary.BigEndian.PutUint16(out[4:6], version) | |
| 315 | 366 | binary.BigEndian.PutUint16(out[6:8], flags) | |
| 316 | 367 | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | |
| 317 | 368 | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | |
| @@ -336,6 +387,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | |||
| 336 | 387 | w.putUint64(0) | |
| 337 | 388 | w.putUint16(0) | |
| 338 | 389 | w.putUint16(1) | |
| 390 | + | _ = w.putString("") | |
| 339 | 391 | w.putUint16(0) | |
| 340 | 392 | w.putUint16(0) | |
| 341 | 393 | w.putUint64(0) | |
| @@ -344,6 +396,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | |||
| 344 | 396 | w.putUint16(1) | |
| 345 | 397 | _ = w.putString("p") | |
| 346 | 398 | _ = w.putString("") | |
| 399 | + | _ = w.putString("") | |
| 347 | 400 | w.putUint16(0) | |
| 348 | 401 | w.putUint32(rowCount) | |
| 349 | 402 | if rowCount > 0 { | |
| @@ -18,7 +18,7 @@ func TestMarshalMinimalGolden(t *testing.T) { | |||||
| 18 | t.Fatalf("Marshal() error = %v", err) | 18 | t.Fatalf("Marshal() error = %v", err) | ||
| 19 | } | 19 | } | ||
| 20 | want := []byte{ | 20 | want := []byte{ | ||
| 21 | - | 'V', 'E', 'V', 'S', 0, 1, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, | 21 | + | 'V', 'E', 'V', 'S', 0, 2, 0, 0, 0, 0, 0, 15, 0xff, 0xc2, 0x2e, 0xcb, |
| 22 | 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, | 22 | 0, 1, 's', 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, | ||
| 23 | } | 23 | } | ||
| 24 | if !bytes.Equal(got, want) { | 24 | if !bytes.Equal(got, want) { | ||
| @@ -76,7 +76,7 @@ func TestRoundTripSessions(t *testing.T) { | |||||
| 76 | {Rune: 'x', Style: indexed}, | 76 | {Rune: 'x', Style: indexed}, | ||
| 77 | }} | 77 | }} | ||
| 78 | multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | 78 | multi := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{ | ||
| 79 | - | {Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v")}, {ID: "2", Cwd: "/b", Visible: rows("focus")}, {ID: "3", Cwd: "/c"}}}, | 79 | + | {StableID: "t_stable", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2", Tree: deepTree, Panes: []Pane{{ID: "1", StableID: "p_one", Cwd: "/a", Scrollback: rows("abc"), Visible: rows("v"), Process: &Process{Argv: []string{"claude", "--resume"}, Strategy: "claude", Opts: ProcessOpts{AgentSessionID: "agent-123"}}}, {ID: "2", StableID: "p_two", Cwd: "/b", Visible: rows("focus")}, {ID: "3", StableID: "p_three", Cwd: "/c"}}}, |
| 80 | {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}}, | 80 | {Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", Visible: cjkVisible}}}, | ||
| 81 | }} | 81 | }} | ||
| 82 | empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}} | 82 | empty := Session{Name: "blank", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Cwd: "/", Scrollback: nil, Visible: rows(" ", "")}}}}} | ||
| @@ -116,7 +116,7 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | |||||
| 116 | data []byte | 116 | data []byte | ||
| 117 | }{ | 117 | }{ | ||
| 118 | {"bad magic", append([]byte("NOPE"), good[4:]...)}, | 118 | {"bad magic", append([]byte("NOPE"), good[4:]...)}, | ||
| 119 | - | {"bad version", replaceU16(good, 4, 2)}, | 119 | + | {"bad version", replaceU16(good, 4, 1)}, |
| 120 | {"bad crc", append([]byte(nil), good[:len(good)-1]...)}, | 120 | {"bad crc", append([]byte(nil), good[:len(good)-1]...)}, | ||
| 121 | {"trailing", append(append([]byte(nil), good...), 0)}, | 121 | {"trailing", append(append([]byte(nil), good...), 0)}, | ||
| 122 | {"body len overrun", replaceU32(good, 8, 999999)}, | 122 | {"body len overrun", replaceU32(good, 8, 999999)}, | ||
| @@ -143,6 +143,42 @@ func TestUnmarshalRejectsMalformedWithoutPanic(t *testing.T) { | |||||
| 143 | } | 143 | } | ||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | + | func TestMarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 147 | + | _, err := Marshal(Session{Name: "s", Tabs: []Tab{{Tree: layout.NewTree("p"), Focus: "p", Panes: []Pane{{ID: "p", Process: &Process{Strategy: "generic"}}}}}}) | |||
| 148 | + | if !errors.Is(err, ErrInvalidData) { | |||
| 149 | + | t.Fatalf("Marshal() error = %v, want %v", err, ErrInvalidData) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) { | |||
| 154 | + | var w payloadWriter | |||
| 155 | + | _ = w.putString("x") | |||
| 156 | + | w.putUint64(0) | |||
| 157 | + | w.putUint16(0) | |||
| 158 | + | w.putUint16(1) | |||
| 159 | + | _ = w.putString("t") | |||
| 160 | + | w.putUint16(80) | |||
| 161 | + | w.putUint16(24) | |||
| 162 | + | w.putUint64(1) | |||
| 163 | + | _ = w.putString("p") | |||
| 164 | + | _ = writeNode(&w, layout.NewLeaf("p")) | |||
| 165 | + | w.putUint16(1) | |||
| 166 | + | _ = w.putString("p") | |||
| 167 | + | _ = w.putString("p_stable") | |||
| 168 | + | _ = w.putString("") | |||
| 169 | + | w.putUint16(0) | |||
| 170 | + | w.putUint32(0) | |||
| 171 | + | w.putUint32(0) | |||
| 172 | + | w.putUint8(1) | |||
| 173 | + | w.putUint16(0) | |||
| 174 | + | _ = w.putString("generic") | |||
| 175 | + | _ = w.putString("") | |||
| 176 | + | _, err := Unmarshal(reheader(w.b, 0)) | |||
| 177 | + | if !errors.Is(err, ErrInvalidData) { | |||
| 178 | + | t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData) | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 146 | func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) { | 182 | func TestUnmarshalRejectsOversizedBodiesBeforeAllocation(t *testing.T) { | ||
| 147 | body := []byte{0} | 183 | body := []byte{0} | ||
| 148 | oversizedRaw := reheader(body, 0) | 184 | oversizedRaw := reheader(body, 0) | ||
| @@ -248,11 +284,26 @@ func equalSession(a, b Session) bool { | |||||
| 248 | } | 284 | } | ||
| 249 | 285 | ||||
| 250 | func equalTab(a, b Tab) bool { | 286 | func equalTab(a, b Tab) bool { | ||
| 251 | - | if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) { | 287 | + | if a.StableID != b.StableID || a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) { |
| 252 | return false | 288 | return false | ||
| 253 | } | 289 | } | ||
| 254 | for i := range a.Panes { | 290 | for i := range a.Panes { | ||
| 255 | - | if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) { | 291 | + | if a.Panes[i].ID != b.Panes[i].ID || a.Panes[i].StableID != b.Panes[i].StableID || a.Panes[i].Cwd != b.Panes[i].Cwd || !equalProcess(a.Panes[i].Process, b.Panes[i].Process) || !equalRows(a.Panes[i].Scrollback, b.Panes[i].Scrollback) || !equalRows(a.Panes[i].Visible, b.Panes[i].Visible) { |
| 292 | + | return false | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | return true | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func equalProcess(a, b *Process) bool { | |||
| 299 | + | if a == nil || b == nil { | |||
| 300 | + | return a == b | |||
| 301 | + | } | |||
| 302 | + | if a.Strategy != b.Strategy || a.Opts.AgentSessionID != b.Opts.AgentSessionID || len(a.Argv) != len(b.Argv) { | |||
| 303 | + | return false | |||
| 304 | + | } | |||
| 305 | + | for i := range a.Argv { | |||
| 306 | + | if a.Argv[i] != b.Argv[i] { | |||
| 256 | return false | 307 | return false | ||
| 257 | } | 308 | } | ||
| 258 | } | 309 | } | ||
| @@ -311,7 +362,7 @@ func trimSession(s Session) Session { | |||||
| 311 | func reheader(body []byte, flags uint16) []byte { | 362 | func reheader(body []byte, flags uint16) []byte { | ||
| 312 | out := make([]byte, 16+len(body)) | 363 | out := make([]byte, 16+len(body)) | ||
| 313 | copy(out[:4], []byte("VEVS")) | 364 | copy(out[:4], []byte("VEVS")) | ||
| 314 | - | binary.BigEndian.PutUint16(out[4:6], 1) | 365 | + | binary.BigEndian.PutUint16(out[4:6], version) |
| 315 | binary.BigEndian.PutUint16(out[6:8], flags) | 366 | binary.BigEndian.PutUint16(out[6:8], flags) | ||
| 316 | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | 367 | binary.BigEndian.PutUint32(out[8:12], uint32(len(body))) | ||
| 317 | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | 368 | binary.BigEndian.PutUint32(out[12:16], crc32.ChecksumIEEE(body)) | ||
| @@ -336,6 +387,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | |||||
| 336 | w.putUint64(0) | 387 | w.putUint64(0) | ||
| 337 | w.putUint16(0) | 388 | w.putUint16(0) | ||
| 338 | w.putUint16(1) | 389 | w.putUint16(1) | ||
| 390 | + | _ = w.putString("") | |||
| 339 | w.putUint16(0) | 391 | w.putUint16(0) | ||
| 340 | w.putUint16(0) | 392 | w.putUint16(0) | ||
| 341 | w.putUint64(0) | 393 | w.putUint64(0) | ||
| @@ -344,6 +396,7 @@ func malformedRowsSnapshot(rowCount uint32, runCount uint16) []byte { | |||||
| 344 | w.putUint16(1) | 396 | w.putUint16(1) | ||
| 345 | _ = w.putString("p") | 397 | _ = w.putString("p") | ||
| 346 | _ = w.putString("") | 398 | _ = w.putString("") | ||
| 399 | + | _ = w.putString("") | |||
| 347 | w.putUint16(0) | 400 | w.putUint16(0) | ||
| 348 | w.putUint32(rowCount) | 401 | w.putUint32(rowCount) | ||
| 349 | if rowCount > 0 { | 402 | if rowCount > 0 { | ||
M internal/usecase/snapshot/snapshot.go +15 -0
| @@ -15,6 +15,7 @@ type Session struct { | |||
| 15 | 15 | ||
| 16 | 16 | // Tab captures one tab's layout, dimensions, and pane snapshots. | |
| 17 | 17 | type Tab struct { | |
| 18 | + | StableID string | |
| 18 | 19 | Cols uint16 | |
| 19 | 20 | Rows uint16 | |
| 20 | 21 | NextPaneID uint64 | |
| @@ -27,7 +28,21 @@ type Tab struct { | |||
| 27 | 28 | // with its scrollback and final visible primary screen. | |
| 28 | 29 | type Pane struct { | |
| 29 | 30 | ID layout.PaneID | |
| 31 | + | StableID string | |
| 30 | 32 | Cwd string | |
| 31 | 33 | Scrollback [][]renderer.Cell | |
| 32 | 34 | Visible [][]renderer.Cell | |
| 35 | + | Process *Process | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Process captures restorable foreground process metadata. | |
| 39 | + | type Process struct { | |
| 40 | + | Argv []string | |
| 41 | + | Strategy string | |
| 42 | + | Opts ProcessOpts | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ProcessOpts captures strategy-specific process restore options. | |
| 46 | + | type ProcessOpts struct { | |
| 47 | + | AgentSessionID string | |
| 33 | 48 | } | |
| @@ -15,6 +15,7 @@ type Session struct { | |||||
| 15 | 15 | ||||
| 16 | // Tab captures one tab's layout, dimensions, and pane snapshots. | 16 | // Tab captures one tab's layout, dimensions, and pane snapshots. | ||
| 17 | type Tab struct { | 17 | type Tab struct { | ||
| 18 | + | StableID string | |||
| 18 | Cols uint16 | 19 | Cols uint16 | ||
| 19 | Rows uint16 | 20 | Rows uint16 | ||
| 20 | NextPaneID uint64 | 21 | NextPaneID uint64 | ||
| @@ -27,7 +28,21 @@ type Tab struct { | |||||
| 27 | // with its scrollback and final visible primary screen. | 28 | // with its scrollback and final visible primary screen. | ||
| 28 | type Pane struct { | 29 | type Pane struct { | ||
| 29 | ID layout.PaneID | 30 | ID layout.PaneID | ||
| 31 | + | StableID string | |||
| 30 | Cwd string | 32 | Cwd string | ||
| 31 | Scrollback [][]renderer.Cell | 33 | Scrollback [][]renderer.Cell | ||
| 32 | Visible [][]renderer.Cell | 34 | Visible [][]renderer.Cell | ||
| 35 | + | Process *Process | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Process captures restorable foreground process metadata. | |||
| 39 | + | type Process struct { | |||
| 40 | + | Argv []string | |||
| 41 | + | Strategy string | |||
| 42 | + | Opts ProcessOpts | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // ProcessOpts captures strategy-specific process restore options. | |||
| 46 | + | type ProcessOpts struct { | |||
| 47 | + | AgentSessionID string | |||
| 33 | } | 48 | } | ||