detached e2b324b

fix(snapshot): address PR review findings

commit
e2b324b8e937d4371b86fef9c5273fdcd248fc07
author
brice · · 2026-07-06 10:32 UTC
parent
d5782db
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +425 -21
M internal/app/run.go +1 -1
@@ -284,7 +284,7 @@ func runDaemon() error {
284284 }
285285 logConfigWarnings(log, warnings)
286286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
287- daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.ProcessInspector{}), daemon.WithDirOrHome(platform.DirOrHome))
287+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
288288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
289289 storePath := persist.StorePath(platform.StateDir())
290290 if store, err := kv.Open(storePath); err != nil {
@@ -284,7 +284,7 @@ func runDaemon() error {
284 }284 }
285 logConfigWarnings(log, warnings)285 logConfigWarnings(log, warnings)
286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))286 daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
287- daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.ProcessInspector{}), daemon.WithDirOrHome(platform.DirOrHome))287+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))288 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
289 storePath := persist.StorePath(platform.StateDir())289 storePath := persist.StorePath(platform.StateDir())
290 if store, err := kv.Open(storePath); err != nil {290 if store, err := kv.Open(storePath); err != nil {
M internal/platform/process_linux.go +45 -6
@@ -8,16 +8,55 @@ import (
88 "sort"
99 "strconv"
1010 "strings"
11+ "sync"
12+ "time"
1113 )
1214
15+const processRecordCacheTTL = 250 * time.Millisecond
16+
1317 // ProcessInspector implements ports.ProcessInspector using Linux /proc.
14-type ProcessInspector struct{}
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 (*ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) }
32+func (*ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) }
33+func (*ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) }
34+func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
35+ recs, err := p.processRecords()
36+ if err != nil {
37+ return nil, err
38+ }
39+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
40+ if !ok {
41+ return nil, nil
42+ }
43+ return ProcessArgv(pid)
44+}
1545
16-func (ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) }
17-func (ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) }
18-func (ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) }
19-func (ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
20- return ProcessGroupArgv(pgid, shellPid)
46+func (p *ProcessInspector) processRecords() ([]processRecord, error) {
47+ p.mu.Lock()
48+ defer p.mu.Unlock()
49+ now := time.Now()
50+ if now.Before(p.cachedRecordsUntil) {
51+ return p.cachedRecords, nil
52+ }
53+ recs, err := readProcRecords(p.root)
54+ if err != nil {
55+ return nil, err
56+ }
57+ p.cachedRecords = recs
58+ p.cachedRecordsUntil = now.Add(processRecordCacheTTL)
59+ return recs, nil
2160 }
2261
2362 // ProcessArgv returns argv from /proc/<pid>/cmdline.
@@ -8,16 +8,55 @@ import (
8 "sort"8 "sort"
9 "strconv"9 "strconv"
10 "strings"10 "strings"
11+ "sync"
12+ "time"
11 )13 )
12 14
15+const processRecordCacheTTL = 250 * time.Millisecond
16+
13 // ProcessInspector implements ports.ProcessInspector using Linux /proc.17 // ProcessInspector implements ports.ProcessInspector using Linux /proc.
14-type ProcessInspector struct{}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 (*ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) }
32+func (*ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) }
33+func (*ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) }
34+func (p *ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
35+ recs, err := p.processRecords()
36+ if err != nil {
37+ return nil, err
38+ }
39+ pid, ok := selectProcessGroupPID(recs, pgid, shellPid)
40+ if !ok {
41+ return nil, nil
42+ }
43+ return ProcessArgv(pid)
44+}
15 45
16-func (ProcessInspector) Cwd(pid int) (string, error) { return ProcessCwd(pid) }46+func (p *ProcessInspector) processRecords() ([]processRecord, error) {
17-func (ProcessInspector) Comm(pid int) (string, error) { return ProcessComm(pid) }47+ p.mu.Lock()
18-func (ProcessInspector) Argv(pid int) ([]string, error) { return ProcessArgv(pid) }48+ defer p.mu.Unlock()
19-func (ProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {49+ now := time.Now()
20- return ProcessGroupArgv(pgid, shellPid)50+ if now.Before(p.cachedRecordsUntil) {
51+ return p.cachedRecords, nil
52+ }
53+ recs, err := readProcRecords(p.root)
54+ if err != nil {
55+ return nil, err
56+ }
57+ p.cachedRecords = recs
58+ p.cachedRecordsUntil = now.Add(processRecordCacheTTL)
59+ return recs, nil
21 }60 }
22 61
23 // ProcessArgv returns argv from /proc/<pid>/cmdline.62 // ProcessArgv returns argv from /proc/<pid>/cmdline.
M internal/platform/process_linux_test.go +33 -0
@@ -1,9 +1,11 @@
11 package platform
22
33 import (
4+ "fmt"
45 "os"
56 "path/filepath"
67 "reflect"
8+ "strconv"
79 "testing"
810 )
911
@@ -64,6 +66,37 @@ func TestSelectProcessGroupPID(t *testing.T) {
6466 }
6567 }
6668
69+func TestProcessInspectorCachesProcessRecordsBriefly(t *testing.T) {
70+ root := t.TempDir()
71+ writeProcStat(t, root, 10, 7)
72+ ins := newProcessInspector(root)
73+
74+ first, err := ins.processRecords()
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ writeProcStat(t, root, 12, 7)
79+ second, err := ins.processRecords()
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ if len(first) != 1 || len(second) != 1 {
84+ t.Fatalf("cached processRecords lengths = %d, %d; want 1, 1", len(first), len(second))
85+ }
86+}
87+
88+func writeProcStat(t *testing.T, root string, pid int, pgrp int) {
89+ t.Helper()
90+ dir := filepath.Join(root, strconv.Itoa(pid))
91+ if err := os.MkdirAll(dir, 0o700); err != nil {
92+ t.Fatal(err)
93+ }
94+ stat := fmt.Sprintf("%d (cmd) S 1 %d 1 0 0", pid, pgrp)
95+ if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o600); err != nil {
96+ t.Fatal(err)
97+ }
98+}
99+
67100 func TestParseStatPgrp(t *testing.T) {
68101 got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")
69102 if err != nil || got != 456 {
@@ -1,9 +1,11 @@
1 package platform1 package platform
2 2
3 import (3 import (
4+ "fmt"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "reflect"7 "reflect"
8+ "strconv"
7 "testing"9 "testing"
8 )10 )
9 11
@@ -64,6 +66,37 @@ func TestSelectProcessGroupPID(t *testing.T) {
64 }66 }
65 }67 }
66 68
69+func TestProcessInspectorCachesProcessRecordsBriefly(t *testing.T) {
70+ root := t.TempDir()
71+ writeProcStat(t, root, 10, 7)
72+ ins := newProcessInspector(root)
73+
74+ first, err := ins.processRecords()
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ writeProcStat(t, root, 12, 7)
79+ second, err := ins.processRecords()
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ if len(first) != 1 || len(second) != 1 {
84+ t.Fatalf("cached processRecords lengths = %d, %d; want 1, 1", len(first), len(second))
85+ }
86+}
87+
88+func writeProcStat(t *testing.T, root string, pid int, pgrp int) {
89+ t.Helper()
90+ dir := filepath.Join(root, strconv.Itoa(pid))
91+ if err := os.MkdirAll(dir, 0o700); err != nil {
92+ t.Fatal(err)
93+ }
94+ stat := fmt.Sprintf("%d (cmd) S 1 %d 1 0 0", pid, pgrp)
95+ if err := os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o600); err != nil {
96+ t.Fatal(err)
97+ }
98+}
99+
67 func TestParseStatPgrp(t *testing.T) {100 func TestParseStatPgrp(t *testing.T) {
68 got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")101 got, err := parseStatPgrp("123 (cmd with spaces) S 1 456 456 0")
69 if err != nil || got != 456 {102 if err != nil || got != 456 {
M internal/ports/mocks/mocks.go +277 -0
@@ -2068,6 +2068,283 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
20682068 return _c
20692069 }
20702070
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+
20712348 // 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.
20722349 // The first argument is typically a *testing.T value.
20732350 func NewMockClipboardReader(t interface {
@@ -2068,6 +2068,283 @@ func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Cal
2068 return _c2068 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/usecase/daemon/identity.go +28 -0
@@ -17,6 +17,18 @@ func newStableID(prefix string) (string, error) {
1717 return prefix + "_" + strings.ToLower(enc), nil
1818 }
1919
20+func (d *Daemon) newTabPaneStableIDs() (string, string, error) {
21+ tabStableID, err := newStableID("t")
22+ if err != nil {
23+ return "", "", fmt.Errorf("daemon: generating tab identity: %w", err)
24+ }
25+ paneStableID, err := newStableID("p")
26+ if err != nil {
27+ return "", "", fmt.Errorf("daemon: generating pane identity: %w", err)
28+ }
29+ return tabStableID, paneStableID, nil
30+}
31+
2032 func fallbackStableID(prefix string) string {
2133 id, err := newStableID(prefix)
2234 if err != nil {
@@ -24,3 +36,19 @@ func fallbackStableID(prefix string) string {
2436 }
2537 return id
2638 }
39+
40+func escapeVEVComponent(s string) string {
41+ var b strings.Builder
42+ for i := 0; i < len(s); i++ {
43+ c := s[i]
44+ if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' {
45+ b.WriteByte(c)
46+ continue
47+ }
48+ b.WriteByte('%')
49+ const hex = "0123456789ABCDEF"
50+ b.WriteByte(hex[c>>4])
51+ b.WriteByte(hex[c&0x0f])
52+ }
53+ return b.String()
54+}
@@ -17,6 +17,18 @@ func newStableID(prefix string) (string, error) {
17 return prefix + "_" + strings.ToLower(enc), nil17 return prefix + "_" + strings.ToLower(enc), nil
18 }18 }
19 19
20+func (d *Daemon) newTabPaneStableIDs() (string, string, error) {
21+ tabStableID, err := newStableID("t")
22+ if err != nil {
23+ return "", "", fmt.Errorf("daemon: generating tab identity: %w", err)
24+ }
25+ paneStableID, err := newStableID("p")
26+ if err != nil {
27+ return "", "", fmt.Errorf("daemon: generating pane identity: %w", err)
28+ }
29+ return tabStableID, paneStableID, nil
30+}
31+
20 func fallbackStableID(prefix string) string {32 func fallbackStableID(prefix string) string {
21 id, err := newStableID(prefix)33 id, err := newStableID(prefix)
22 if err != nil {34 if err != nil {
@@ -24,3 +36,19 @@ func fallbackStableID(prefix string) string {
24 }36 }
25 return id37 return id
26 }38 }
39+
40+func escapeVEVComponent(s string) string {
41+ var b strings.Builder
42+ for i := 0; i < len(s); i++ {
43+ c := s[i]
44+ if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-' {
45+ b.WriteByte(c)
46+ continue
47+ }
48+ b.WriteByte('%')
49+ const hex = "0123456789ABCDEF"
50+ b.WriteByte(hex[c>>4])
51+ b.WriteByte(hex[c&0x0f])
52+ }
53+ return b.String()
54+}
M internal/usecase/daemon/process_restore.go +8 -1
@@ -46,7 +46,7 @@ func extractAgentSessionID(strategy string, argv []string) string {
4646 continue
4747 }
4848 candidate := argv[i+1]
49- if candidate != "" && !strings.HasPrefix(candidate, "-") {
49+ if candidate != "" && !strings.HasPrefix(candidate, "-") && !containsControl(candidate) {
5050 return candidate
5151 }
5252 }
@@ -78,6 +78,9 @@ func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) proc
7878 }
7979
8080 id := proc.Opts.AgentSessionID
81+ if containsControl(id) {
82+ return processRestoreDecision{Reason: "invalid_agent_session_id"}
83+ }
8184 switch strategy {
8285 case processStrategyPi:
8386 if id != "" {
@@ -128,6 +131,10 @@ func processAllowlistKey(strategy string, argv []string) string {
128131 }
129132 }
130133
134+func containsControl(s string) bool {
135+ return strings.IndexFunc(s, unicode.IsControl) >= 0
136+}
137+
131138 func shellQuoteArgvMust(argv []string) string {
132139 command, _ := shellQuoteArgv(argv)
133140 return command
@@ -46,7 +46,7 @@ func extractAgentSessionID(strategy string, argv []string) string {
46 continue46 continue
47 }47 }
48 candidate := argv[i+1]48 candidate := argv[i+1]
49- if candidate != "" && !strings.HasPrefix(candidate, "-") {49+ if candidate != "" && !strings.HasPrefix(candidate, "-") && !containsControl(candidate) {
50 return candidate50 return candidate
51 }51 }
52 }52 }
@@ -78,6 +78,9 @@ func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) proc
78 }78 }
79 79
80 id := proc.Opts.AgentSessionID80 id := proc.Opts.AgentSessionID
81+ if containsControl(id) {
82+ return processRestoreDecision{Reason: "invalid_agent_session_id"}
83+ }
81 switch strategy {84 switch strategy {
82 case processStrategyPi:85 case processStrategyPi:
83 if id != "" {86 if id != "" {
@@ -128,6 +131,10 @@ func processAllowlistKey(strategy string, argv []string) string {
128 }131 }
129 }132 }
130 133
134+func containsControl(s string) bool {
135+ return strings.IndexFunc(s, unicode.IsControl) >= 0
136+}
137+
131 func shellQuoteArgvMust(argv []string) string {138 func shellQuoteArgvMust(argv []string) string {
132 command, _ := shellQuoteArgv(argv)139 command, _ := shellQuoteArgv(argv)
133 return command140 return command
M internal/usecase/daemon/process_restore_test.go +6 -0
@@ -81,6 +81,11 @@ func TestProcessRestorePlan(t *testing.T) {
8181 allow: map[string]struct{}{"less": {}},
8282 reason: "not_allowlisted",
8383 },
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+ },
8489 {
8590 name: "empty executable rejected",
8691 proc: &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric},
@@ -115,6 +120,7 @@ func TestExtractAgentSessionID(t *testing.T) {
115120 {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},
116121 {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},
117122 {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"}},
118124 }
119125
120126 for _, tt := range tests {
@@ -81,6 +81,11 @@ func TestProcessRestorePlan(t *testing.T) {
81 allow: map[string]struct{}{"less": {}},81 allow: map[string]struct{}{"less": {}},
82 reason: "not_allowlisted",82 reason: "not_allowlisted",
83 },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+ },
84 {89 {
85 name: "empty executable rejected",90 name: "empty executable rejected",
86 proc: &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric},91 proc: &snapcodec.Process{Argv: []string{""}, Strategy: processStrategyGeneric},
@@ -115,6 +120,7 @@ func TestExtractAgentSessionID(t *testing.T) {
115 {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},120 {name: "codex resume extraction", strategy: processStrategyCodex, argv: []string{"codex", "resume", "abc"}, want: "abc"},
116 {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},121 {name: "claude flag-looking ID rejected", strategy: processStrategyClaude, argv: []string{"claude", "--resume", "--continue"}},
117 {name: "pi flag-looking ID rejected", strategy: processStrategyPi, argv: []string{"pi", "-r", "--last"}},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"}},
118 }124 }
119 125
120 for _, tt := range tests {126 for _, tt := range tests {
M internal/usecase/daemon/session.go +5 -13
@@ -123,13 +123,9 @@ func (d *Daemon) touchMRU(sess *session) {
123123
124124 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
125125 tbSize := tabSize(sz)
126- tabStableID, err := newStableID("t")
126+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
127127 if err != nil {
128- return nil, fmt.Errorf("daemon: generating tab identity: %w", err)
129- }
130- paneStableID, err := newStableID("p")
131- if err != nil {
132- return nil, fmt.Errorf("daemon: generating pane identity: %w", err)
128+ return nil, err
133129 }
134130 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
135131 if err != nil {
@@ -243,13 +239,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
243239 cwd := sess.cwd
244240 client := sess.client
245241 sess.mu.Unlock()
246- tabStableID, err := newStableID("t")
242+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
247243 if err != nil {
248- return fmt.Errorf("daemon: generating tab identity: %w", err)
249- }
250- paneStableID, err := newStableID("p")
251- if err != nil {
252- return fmt.Errorf("daemon: generating pane identity: %w", err)
244+ return err
253245 }
254246 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
255247 if err != nil {
@@ -737,5 +729,5 @@ func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
737729 }
738730 out = append(out, e)
739731 }
740- return append(out, "TERM=xterm-direct", "VEV=session="+name+",tab="+tabStableID+",pane="+paneStableID)
732+ return append(out, "TERM=xterm-direct", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID)
741733 }
@@ -123,13 +123,9 @@ func (d *Daemon) touchMRU(sess *session) {
123 123
124 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) {
125 tbSize := tabSize(sz)125 tbSize := tabSize(sz)
126- tabStableID, err := newStableID("t")126+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
127 if err != nil {127 if err != nil {
128- return nil, fmt.Errorf("daemon: generating tab identity: %w", err)128+ return nil, err
129- }
130- paneStableID, err := newStableID("p")
131- if err != nil {
132- return nil, fmt.Errorf("daemon: generating pane identity: %w", err)
133 }129 }
134 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)130 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
135 if err != nil {131 if err != nil {
@@ -243,13 +239,9 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
243 cwd := sess.cwd239 cwd := sess.cwd
244 client := sess.client240 client := sess.client
245 sess.mu.Unlock()241 sess.mu.Unlock()
246- tabStableID, err := newStableID("t")242+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
247 if err != nil {243 if err != nil {
248- return fmt.Errorf("daemon: generating tab identity: %w", err)244+ return err
249- }
250- paneStableID, err := newStableID("p")
251- if err != nil {
252- return fmt.Errorf("daemon: generating pane identity: %w", err)
253 }245 }
254 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)246 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
255 if err != nil {247 if err != nil {
@@ -737,5 +729,5 @@ func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
737 }729 }
738 out = append(out, e)730 out = append(out, e)
739 }731 }
740- return append(out, "TERM=xterm-direct", "VEV=session="+name+",tab="+tabStableID+",pane="+paneStableID)732+ return append(out, "TERM=xterm-direct", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID)
741 }733 }
M internal/usecase/daemon/session_test.go +8 -0
@@ -893,6 +893,14 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
893893 require.False(t, ok)
894894 }
895895
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+
896904 func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
897905 sz := domain.Size{Cols: 80, Rows: 24}
898906 p, releasePTY := newBlockingPTY(t)
@@ -893,6 +893,14 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
893 require.False(t, ok)893 require.False(t, ok)
894 }894 }
895 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+
896 func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {904 func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
897 sz := domain.Size{Cols: 80, Rows: 24}905 sz := domain.Size{Cols: 80, Rows: 24}
898 p, releasePTY := newBlockingPTY(t)906 p, releasePTY := newBlockingPTY(t)
M internal/usecase/daemon/snapshot_restore_test.go +14 -0
@@ -190,6 +190,20 @@ func TestRestoreSnapshotsWritesAgentIDCommandAndWriteFailureIsNonFatal(t *testin
190190 require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent2", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))
191191 }
192192
193+func TestRestoreSnapshotsRejectsAgentIDWithControlBytes(t *testing.T) {
194+ proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}}
195+ store := processRestoreTestStore(t, "agent-control", proc)
196+ factory := &restorePTYFactory{}
197+ d := newTestDaemon(t, factory, stubClock{})
198+ WithSnapshotStore(store)(d)
199+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
200+
201+ d.restoreSnapshots(context.Background())
202+
203+ require.Len(t, factory.opens, 1)
204+ require.Empty(t, factory.opens[0].pty.writes)
205+}
206+
193207 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
194208 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
195209 Name: "stacked",
@@ -190,6 +190,20 @@ func TestRestoreSnapshotsWritesAgentIDCommandAndWriteFailureIsNonFatal(t *testin
190 require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent2", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))190 require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent2", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}}))
191 }191 }
192 192
193+func TestRestoreSnapshotsRejectsAgentIDWithControlBytes(t *testing.T) {
194+ proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc\nmalicious"}}
195+ store := processRestoreTestStore(t, "agent-control", proc)
196+ factory := &restorePTYFactory{}
197+ d := newTestDaemon(t, factory, stubClock{})
198+ WithSnapshotStore(store)(d)
199+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
200+
201+ d.restoreSnapshots(context.Background())
202+
203+ require.Len(t, factory.opens, 1)
204+ require.Empty(t, factory.opens[0].pty.writes)
205+}
206+
193 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {207 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
194 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{208 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
195 Name: "stacked",209 Name: "stacked",

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close