detached 07ec9d9

feat(snapshot): restore allowlisted foreground processes

commit
07ec9d9d3d1fd9ed16fe7474a1479a4a8a3e4f38
author
brice · · 2026-07-06 08:06 UTC
parent
2e3ea8b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +176 -34
M README.md +7 -2
@@ -35,9 +35,9 @@ vev --help show help (aliases: -h, help)
3535 vev --version show version (alias: version)
3636 ```
3737
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.
3939
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.
4141
4242 ## Configuration
4343
@@ -64,6 +64,11 @@ switch-tab-7 = alt+7
6464 switch-tab-8 = alt+8
6565 switch-tab-9 = alt+9
6666
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+
6772 # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits.
6873 code.new-tab = CNT
6974 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 ## Configuration42 ## Configuration
43 43
@@ -64,6 +64,11 @@ switch-tab-7 = alt+7
64 switch-tab-8 = alt+864 switch-tab-8 = alt+8
65 switch-tab-9 = alt+965 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 = CNT73 code.new-tab = CNT
69 code.new-session = CNS74 code.new-session = CNS
M internal/usecase/daemon/config.go +34 -3
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "fmt"
5+ "maps"
56 "regexp"
67 "sort"
78 "strings"
@@ -21,8 +22,10 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
2122 d.logConfigWarning(warning)
2223 }
2324 overrides := d.buildCodeOverrides(cfg.Codes)
25+ allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
2426 d.bindings.Store(bindings)
2527 d.codeOverrides.Store(&overrides)
28+ d.restoreProcessAllowlist.Store(&allowlist)
2629 d.themeMode.Store(uint32(cfg.Theme))
2730 d.reapplyThemeAllSessions()
2831 d.repaintAllAttachedClients()
@@ -56,9 +59,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str
5659 // *different* slug's override — that must be re-checked regardless of
5760 // where the two slugs fall in sort order.
5861 accepted := make(map[string]string, len(desired))
59- for slug, code := range desired {
60- accepted[slug] = code
61- }
62+ maps.Copy(accepted, desired)
6263 effectiveCode := func(slug string) string {
6364 if code, ok := accepted[slug]; ok {
6465 return code
@@ -122,6 +123,36 @@ func sortedKeys(values map[string]string) []string {
122123 return keys
123124 }
124125
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+
125156 func (d *Daemon) logConfigWarning(w domain.Warning) {
126157 if w.Line > 0 {
127158 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 of59 // *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 code65 return code
@@ -122,6 +123,36 @@ func sortedKeys(values map[string]string) []string {
122 return keys123 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 +16 -0
@@ -49,6 +49,22 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
4949 require.Equal(t, "NT", cmd.Code)
5050 }
5151
52+func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
53+ d := newTestDaemon(t, nil, stubClock{})
54+
55+ _, ok := d.restoreProcessAllowlistSnapshot()["claude"]
56+ require.True(t, ok)
57+
58+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}})
59+ require.Empty(t, d.restoreProcessAllowlistSnapshot())
60+
61+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"less", "", " pi "}}})
62+ allow := d.restoreProcessAllowlistSnapshot()
63+ require.Contains(t, allow, "less")
64+ require.Contains(t, allow, "pi")
65+ require.NotContains(t, allow, "")
66+}
67+
5268 func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
5369 d := newTestDaemon(t, nil, stubClock{})
5470 d.ApplyConfig(domain.Config{
@@ -49,6 +49,22 @@ 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+ d := newTestDaemon(t, nil, stubClock{})
54+
55+ _, ok := d.restoreProcessAllowlistSnapshot()["claude"]
56+ require.True(t, ok)
57+
58+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}})
59+ require.Empty(t, d.restoreProcessAllowlistSnapshot())
60+
61+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"less", "", " pi "}}})
62+ allow := d.restoreProcessAllowlistSnapshot()
63+ require.Contains(t, allow, "less")
64+ require.Contains(t, allow, "pi")
65+ require.NotContains(t, allow, "")
66+}
67+
52 func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {68 func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) {
53 d := newTestDaemon(t, nil, stubClock{})69 d := newTestDaemon(t, nil, stubClock{})
54 d.ApplyConfig(domain.Config{70 d.ApplyConfig(domain.Config{
M internal/usecase/daemon/daemon.go +25 -20
@@ -95,26 +95,27 @@ type Daemon struct {
9595 paletteRecentMu sync.Mutex
9696 paletteRecent []string
9797
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- 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
118119 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
119120 // (see clipboard.go); empty means use os.TempDir().
120121 tempDir string
@@ -271,6 +272,10 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
271272 empty := map[string]string{}
272273 d.codeOverrides.Store(&empty)
273274 }
275+ if d.restoreProcessAllowlist.Load() == nil {
276+ allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses)
277+ d.restoreProcessAllowlist.Store(&allow)
278+ }
274279 if records, err := d.persist.LoadAll(); err != nil {
275280 d.log.Warn("loading persisted sessions failed", "err", err)
276281 } else {
@@ -95,26 +95,27 @@ type Daemon struct {
95 paletteRecentMu sync.Mutex95 paletteRecentMu sync.Mutex
96 paletteRecent []string96 paletteRecent []string
97 97
98- ptys ports.PTYFactory98+ ptys ports.PTYFactory
99- clock ports.Clock99+ clock ports.Clock
100- log *slog.Logger100+ log *slog.Logger
101- baseEnv []string101+ baseEnv []string
102- shell string102+ shell string
103- shellArgs []string103+ shellArgs []string
104- persist *persist.Persister104+ persist *persist.Persister
105- persistEnabled bool105+ persistEnabled bool
106- snaps ports.SnapshotStore106+ snaps ports.SnapshotStore
107- snapsEnabled bool107+ snapsEnabled bool
108- restoreDone chan struct{}108+ restoreDone chan struct{}
109- restoreOnce sync.Once109+ restoreOnce sync.Once
110- procCwd func(int) (string, error)110+ procCwd func(int) (string, error)
111- procComm func(int) (string, error)111+ procComm func(int) (string, error)
112- procArgv func(int) ([]string, error)112+ procArgv func(int) ([]string, error)
113- procGroupArgv func(int, int) ([]string, error)113+ procGroupArgv func(int, int) ([]string, error)
114- dirOrHome func(string) string114+ dirOrHome func(string) string
115- bindings atomic.Pointer[keys.Bindings]115+ bindings atomic.Pointer[keys.Bindings]
116- codeOverrides atomic.Pointer[map[string]string]116+ codeOverrides atomic.Pointer[map[string]string]
117- themeMode atomic.Uint32117+ restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
118+ themeMode atomic.Uint32
118 // tempDir overrides os.TempDir() for clipboard-image-transfer writes119 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
119 // (see clipboard.go); empty means use os.TempDir().120 // (see clipboard.go); empty means use os.TempDir().
120 tempDir string121 tempDir string
@@ -271,6 +272,10 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
271 empty := map[string]string{}272 empty := map[string]string{}
272 d.codeOverrides.Store(&empty)273 d.codeOverrides.Store(&empty)
273 }274 }
275+ if d.restoreProcessAllowlist.Load() == nil {
276+ allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses)
277+ d.restoreProcessAllowlist.Store(&allow)
278+ }
274 if records, err := d.persist.LoadAll(); err != nil {279 if records, err := d.persist.LoadAll(); err != nil {
275 d.log.Warn("loading persisted sessions failed", "err", err)280 d.log.Warn("loading persisted sessions failed", "err", err)
276 } else {281 } else {
M internal/usecase/daemon/process_restore.go +1 -1
@@ -81,7 +81,7 @@ func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) proc
8181 switch strategy {
8282 case processStrategyPi:
8383 if id != "" {
84- return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true}
84+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--session-id", id}), Restore: true}
8585 }
8686 return processRestoreDecision{Command: "pi --continue", Restore: true}
8787 case processStrategyClaude:
@@ -81,7 +81,7 @@ func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) proc
81 switch strategy {81 switch strategy {
82 case processStrategyPi:82 case processStrategyPi:
83 if id != "" {83 if id != "" {
84- return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true}84+ return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--session-id", id}), Restore: true}
85 }85 }
86 return processRestoreDecision{Command: "pi --continue", Restore: true}86 return processRestoreDecision{Command: "pi --continue", Restore: true}
87 case processStrategyClaude:87 case processStrategyClaude:
M internal/usecase/daemon/process_restore_test.go +1 -1
@@ -37,7 +37,7 @@ func TestProcessRestorePlan(t *testing.T) {
3737 name: "pi with ID resumes",
3838 proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},
3939 restore: true,
40- command: "pi --resume session-123",
40+ command: "pi --session-id session-123",
4141 },
4242 {
4343 name: "pi no ID continues",
@@ -37,7 +37,7 @@ func TestProcessRestorePlan(t *testing.T) {
37 name: "pi with ID resumes",37 name: "pi with ID resumes",
38 proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},38 proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}},
39 restore: true,39 restore: true,
40- command: "pi --resume session-123",40+ command: "pi --session-id session-123",
41 },41 },
42 {42 {
43 name: "pi no ID continues",43 name: "pi no ID continues",
M internal/usecase/daemon/snapshot.go +10 -0
@@ -73,6 +73,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
7373 }
7474 cancel()
7575 }
76+ restoreCommands := make(map[layout.PaneID]string)
77+ allowlist := d.restoreProcessAllowlistSnapshot()
7678 for _, tabSnap := range snap.Tabs {
7779 if err := ctx.Err(); err != nil {
7880 closeOpened()
@@ -108,6 +110,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
108110 tb.ctx, tb.cancel = context.WithCancel(sctx)
109111 opened = append(opened, tb)
110112 for _, paneSnap := range tabSnap.Panes {
113+ if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
114+ restoreCommands[paneSnap.ID] = decision.Command
115+ }
111116 if err := ctx.Err(); err != nil {
112117 closeOpened()
113118 return err
@@ -136,6 +141,11 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
136141 p.ctx, p.cancel = context.WithCancel(tb.ctx)
137142 p.rect = contentRect
138143 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
144+ if command := restoreCommands[paneSnap.ID]; command != "" {
145+ if _, err := pty.Write([]byte(command + "\n")); err != nil {
146+ d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
147+ }
148+ }
139149 tb.panes[paneSnap.ID] = p
140150 }
141151 }
@@ -73,6 +73,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
73 }73 }
74 cancel()74 cancel()
75 }75 }
76+ restoreCommands := make(map[layout.PaneID]string)
77+ allowlist := d.restoreProcessAllowlistSnapshot()
76 for _, tabSnap := range snap.Tabs {78 for _, tabSnap := range snap.Tabs {
77 if err := ctx.Err(); err != nil {79 if err := ctx.Err(); err != nil {
78 closeOpened()80 closeOpened()
@@ -108,6 +110,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
108 tb.ctx, tb.cancel = context.WithCancel(sctx)110 tb.ctx, tb.cancel = context.WithCancel(sctx)
109 opened = append(opened, tb)111 opened = append(opened, tb)
110 for _, paneSnap := range tabSnap.Panes {112 for _, paneSnap := range tabSnap.Panes {
113+ if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore {
114+ restoreCommands[paneSnap.ID] = decision.Command
115+ }
111 if err := ctx.Err(); err != nil {116 if err := ctx.Err(); err != nil {
112 closeOpened()117 closeOpened()
113 return err118 return err
@@ -136,6 +141,11 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
136 p.ctx, p.cancel = context.WithCancel(tb.ctx)141 p.ctx, p.cancel = context.WithCancel(tb.ctx)
137 p.rect = contentRect142 p.rect = contentRect
138 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)143 seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible)
144+ if command := restoreCommands[paneSnap.ID]; command != "" {
145+ if _, err := pty.Write([]byte(command + "\n")); err != nil {
146+ d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID)
147+ }
148+ }
139 tb.panes[paneSnap.ID] = p149 tb.panes[paneSnap.ID] = p
140 }150 }
141 }151 }
M internal/usecase/daemon/snapshot_restore_test.go +82 -7
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "encoding/binary"
6+ "errors"
67 "io"
78 "math"
89 "sync"
@@ -93,6 +94,65 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
9394 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
9495 }
9596
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 TestRestoreSnapshotsWritesAllowlistedProcessCommands(t *testing.T) {
115+ store := processRestoreTestStore(t, "proc", &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric})
116+ factory := &restorePTYFactory{}
117+ d := newTestDaemon(t, factory, stubClock{})
118+ WithSnapshotStore(store)(d)
119+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"less"}}})
120+
121+ d.restoreSnapshots(context.Background())
122+
123+ require.Len(t, factory.opens, 1)
124+ require.Equal(t, []string{"less README.md\n"}, factory.opens[0].pty.writes)
125+}
126+
127+func TestRestoreSnapshotsSkipsDeniedProcessCommands(t *testing.T) {
128+ store := processRestoreTestStore(t, "proc", &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric})
129+ factory := &restorePTYFactory{}
130+ d := newTestDaemon(t, factory, stubClock{})
131+ WithSnapshotStore(store)(d)
132+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"vim"}}})
133+
134+ d.restoreSnapshots(context.Background())
135+
136+ require.Len(t, factory.opens, 1)
137+ require.Empty(t, factory.opens[0].pty.writes)
138+}
139+
140+func TestRestoreSnapshotsWritesAgentIDCommandAndWriteFailureIsNonFatal(t *testing.T) {
141+ proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
142+ store := processRestoreTestStore(t, "agent", proc)
143+ factory := &restorePTYFactory{}
144+ d := newTestDaemon(t, factory, stubClock{})
145+ WithSnapshotStore(store)(d)
146+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
147+
148+ d.restoreSnapshots(context.Background())
149+
150+ require.Len(t, factory.opens, 1)
151+ require.Equal(t, []string{"pi --session-id abc123\n"}, factory.opens[0].pty.writes)
152+ factory.writeErr = errors.New("boom")
153+ 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}}}}}))
154+}
155+
96156 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
97157 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
98158 Name: "stacked",
@@ -274,26 +334,33 @@ func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
274334 }
275335
276336 type restorePTYFactory struct {
277- mu sync.Mutex
278- opens []restorePTYOpen
337+ mu sync.Mutex
338+ opens []restorePTYOpen
339+ writeErr error
279340 }
280341
281342 type restorePTYOpen struct {
282343 dir string
283344 size domain.Size
284345 env []string
346+ pty *restorePTY
285347 }
286348
287349 func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
288350 f.mu.Lock()
289351 defer f.mu.Unlock()
290- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...)})
291- return newRestorePTY(), nil
352+ pty := newRestorePTY()
353+ pty.writeErr = f.writeErr
354+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
355+ return pty, nil
292356 }
293357
294358 type restorePTY struct {
295- done chan struct{}
296- once sync.Once
359+ mu sync.Mutex
360+ writes []string
361+ writeErr error
362+ done chan struct{}
363+ once sync.Once
297364 }
298365
299366 func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
@@ -301,7 +368,15 @@ func (p *restorePTY) Read([]byte) (int, error) {
301368 <-p.done
302369 return 0, io.EOF
303370 }
304-func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil }
371+func (p *restorePTY) Write(b []byte) (int, error) {
372+ p.mu.Lock()
373+ defer p.mu.Unlock()
374+ p.writes = append(p.writes, string(b))
375+ if p.writeErr != nil {
376+ return 0, p.writeErr
377+ }
378+ return len(b), nil
379+}
305380 func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
306381 func (p *restorePTY) Resize(domain.Size) error { return nil }
307382 func (p *restorePTY) Pid() int { return 0 }
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "encoding/binary"5 "encoding/binary"
6+ "errors"
6 "io"7 "io"
7 "math"8 "math"
8 "sync"9 "sync"
@@ -93,6 +94,65 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
93 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)94 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
94 }95 }
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 TestRestoreSnapshotsWritesAllowlistedProcessCommands(t *testing.T) {
115+ store := processRestoreTestStore(t, "proc", &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric})
116+ factory := &restorePTYFactory{}
117+ d := newTestDaemon(t, factory, stubClock{})
118+ WithSnapshotStore(store)(d)
119+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"less"}}})
120+
121+ d.restoreSnapshots(context.Background())
122+
123+ require.Len(t, factory.opens, 1)
124+ require.Equal(t, []string{"less README.md\n"}, factory.opens[0].pty.writes)
125+}
126+
127+func TestRestoreSnapshotsSkipsDeniedProcessCommands(t *testing.T) {
128+ store := processRestoreTestStore(t, "proc", &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric})
129+ factory := &restorePTYFactory{}
130+ d := newTestDaemon(t, factory, stubClock{})
131+ WithSnapshotStore(store)(d)
132+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"vim"}}})
133+
134+ d.restoreSnapshots(context.Background())
135+
136+ require.Len(t, factory.opens, 1)
137+ require.Empty(t, factory.opens[0].pty.writes)
138+}
139+
140+func TestRestoreSnapshotsWritesAgentIDCommandAndWriteFailureIsNonFatal(t *testing.T) {
141+ proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}}
142+ store := processRestoreTestStore(t, "agent", proc)
143+ factory := &restorePTYFactory{}
144+ d := newTestDaemon(t, factory, stubClock{})
145+ WithSnapshotStore(store)(d)
146+ d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}})
147+
148+ d.restoreSnapshots(context.Background())
149+
150+ require.Len(t, factory.opens, 1)
151+ require.Equal(t, []string{"pi --session-id abc123\n"}, factory.opens[0].pty.writes)
152+ factory.writeErr = errors.New("boom")
153+ 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}}}}}))
154+}
155+
96 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {156 func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) {
97 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{157 store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{
98 Name: "stacked",158 Name: "stacked",
@@ -274,26 +334,33 @@ func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
274 }334 }
275 335
276 type restorePTYFactory struct {336 type restorePTYFactory struct {
277- mu sync.Mutex337+ mu sync.Mutex
278- opens []restorePTYOpen338+ opens []restorePTYOpen
339+ writeErr error
279 }340 }
280 341
281 type restorePTYOpen struct {342 type restorePTYOpen struct {
282 dir string343 dir string
283 size domain.Size344 size domain.Size
284 env []string345 env []string
346+ pty *restorePTY
285 }347 }
286 348
287 func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {349 func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
288 f.mu.Lock()350 f.mu.Lock()
289 defer f.mu.Unlock()351 defer f.mu.Unlock()
290- f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...)})352+ pty := newRestorePTY()
291- return newRestorePTY(), nil353+ pty.writeErr = f.writeErr
354+ f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
355+ return pty, nil
292 }356 }
293 357
294 type restorePTY struct {358 type restorePTY struct {
295- done chan struct{}359+ mu sync.Mutex
296- once sync.Once360+ writes []string
361+ writeErr error
362+ done chan struct{}
363+ once sync.Once
297 }364 }
298 365
299 func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }366 func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} }
@@ -301,7 +368,15 @@ func (p *restorePTY) Read([]byte) (int, error) {
301 <-p.done368 <-p.done
302 return 0, io.EOF369 return 0, io.EOF
303 }370 }
304-func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil }371+func (p *restorePTY) Write(b []byte) (int, error) {
372+ p.mu.Lock()
373+ defer p.mu.Unlock()
374+ p.writes = append(p.writes, string(b))
375+ if p.writeErr != nil {
376+ return 0, p.writeErr
377+ }
378+ return len(b), nil
379+}
305 func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }380 func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil }
306 func (p *restorePTY) Resize(domain.Size) error { return nil }381 func (p *restorePTY) Resize(domain.Size) error { return nil }
307 func (p *restorePTY) Pid() int { return 0 }382 func (p *restorePTY) Pid() int { return 0 }

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