detached db0ee28

feat(daemon): use attached environment for PTYs

commit
db0ee28fcab39d825c00e5723b15b285a647e542
author
brice · · 2026-07-15 05:30 UTC
parent
106fbb7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +198 -41
M internal/usecase/daemon/client.go +25 -3
@@ -55,6 +55,8 @@ type attachedClient struct {
5555 // previousSession is guarded independently. It is retained through temporary
5656 // setSession(nil) hand-offs and cleared only on terminal teardown.
5757 previousSession Guarded[*session]
58+ envMu sync.Mutex // guards the immutable attach environment snapshot
59+ env []string
5860 linkMu sync.Mutex
5961 sendMu sync.Mutex
6062 }
@@ -107,6 +109,18 @@ func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
107109
108110 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
109111
112+func (ac *attachedClient) environment() []string {
113+ ac.envMu.Lock()
114+ defer ac.envMu.Unlock()
115+ return copyEnvironment(ac.env)
116+}
117+
118+func (ac *attachedClient) setEnvironment(env []string) {
119+ ac.envMu.Lock()
120+ ac.env = copyEnvironment(env)
121+ ac.envMu.Unlock()
122+}
123+
110124 func (ac *attachedClient) clearPreviousSession() {
111125 if ac != nil {
112126 ac.previousSession.Set(nil)
@@ -392,9 +406,12 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
392406 }
393407
394408 type attachClientOptions struct {
395- clientID [16]byte
396- resumeCapable bool
397- maxOutputInFlight uint8
409+ clientID [16]byte
410+ resumeCapable bool
411+ maxOutputInFlight uint8
412+ environment []string
413+ terminal terminalEnv
414+ refreshEnvironment bool
398415 }
399416
400417 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
@@ -415,6 +432,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
415432 clientID: opts.clientID,
416433 resumeCapable: opts.resumeCapable,
417434 resumeToken: resumeToken,
435+ env: copyEnvironment(opts.environment),
418436 }
419437 ac.initOverlays()
420438 ac.setSession(sess)
@@ -425,6 +443,10 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
425443 sess.mu.Lock()
426444 old := sess.client
427445 name := sess.name
446+ if opts.refreshEnvironment {
447+ sess.env = copyEnvironment(opts.environment)
448+ sess.terminal = opts.terminal
449+ }
428450 sess.mu.Unlock()
429451 _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false)
430452 sess.mu.Lock()
@@ -55,6 +55,8 @@ type attachedClient struct {
55 // previousSession is guarded independently. It is retained through temporary55 // previousSession is guarded independently. It is retained through temporary
56 // setSession(nil) hand-offs and cleared only on terminal teardown.56 // setSession(nil) hand-offs and cleared only on terminal teardown.
57 previousSession Guarded[*session]57 previousSession Guarded[*session]
58+ envMu sync.Mutex // guards the immutable attach environment snapshot
59+ env []string
58 linkMu sync.Mutex60 linkMu sync.Mutex
59 sendMu sync.Mutex61 sendMu sync.Mutex
60 }62 }
@@ -107,6 +109,18 @@ func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
107 109
108 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }110 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
109 111
112+func (ac *attachedClient) environment() []string {
113+ ac.envMu.Lock()
114+ defer ac.envMu.Unlock()
115+ return copyEnvironment(ac.env)
116+}
117+
118+func (ac *attachedClient) setEnvironment(env []string) {
119+ ac.envMu.Lock()
120+ ac.env = copyEnvironment(env)
121+ ac.envMu.Unlock()
122+}
123+
110 func (ac *attachedClient) clearPreviousSession() {124 func (ac *attachedClient) clearPreviousSession() {
111 if ac != nil {125 if ac != nil {
112 ac.previousSession.Set(nil)126 ac.previousSession.Set(nil)
@@ -392,9 +406,12 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
392 }406 }
393 407
394 type attachClientOptions struct {408 type attachClientOptions struct {
395- clientID [16]byte409+ clientID [16]byte
396- resumeCapable bool410+ resumeCapable bool
397- maxOutputInFlight uint8411+ maxOutputInFlight uint8
412+ environment []string
413+ terminal terminalEnv
414+ refreshEnvironment bool
398 }415 }
399 416
400 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {417 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
@@ -415,6 +432,7 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
415 clientID: opts.clientID,432 clientID: opts.clientID,
416 resumeCapable: opts.resumeCapable,433 resumeCapable: opts.resumeCapable,
417 resumeToken: resumeToken,434 resumeToken: resumeToken,
435+ env: copyEnvironment(opts.environment),
418 }436 }
419 ac.initOverlays()437 ac.initOverlays()
420 ac.setSession(sess)438 ac.setSession(sess)
@@ -425,6 +443,10 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma
425 sess.mu.Lock()443 sess.mu.Lock()
426 old := sess.client444 old := sess.client
427 name := sess.name445 name := sess.name
446+ if opts.refreshEnvironment {
447+ sess.env = copyEnvironment(opts.environment)
448+ sess.terminal = opts.terminal
449+ }
428 sess.mu.Unlock()450 sess.mu.Unlock()
429 _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false)451 _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false)
430 sess.mu.Lock()452 sess.mu.Lock()
M internal/usecase/daemon/daemon.go +12 -10
@@ -98,6 +98,7 @@ type Daemon struct {
9898 baseEnv []string
9999 shell string
100100 shellArgs []string
101+ shellOverride bool
101102 persist *persist.Persister
102103 persistEnabled bool
103104 snaps ports.SnapshotStore
@@ -204,6 +205,7 @@ func WithShell(cmd string, args []string) Option {
204205 return func(d *Daemon) {
205206 d.shell = cmd
206207 d.shellArgs = args
208+ d.shellOverride = true
207209 }
208210 }
209211
@@ -671,13 +673,13 @@ func (e *protoErr) Error() string { return e.text }
671673 // teardown so obsolete worker joins never delay the new handshake.
672674 func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {
673675 ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
674- clientID: h.ClientID,
675- resumeCapable: true,
676- maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
676+ clientID: h.ClientID,
677+ resumeCapable: true,
678+ maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
679+ environment: h.Env,
680+ terminal: term,
681+ refreshEnvironment: true,
677682 })
678- sess.mu.Lock()
679- sess.terminal = term
680- sess.mu.Unlock()
681683 d.mu.Unlock()
682684 d.retireReplacedClient(old, cleanup)
683685 return ac
@@ -726,7 +728,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
726728 }
727729 cwd := d.dirOrHome(stopped.cwd)
728730 var err error
729- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
731+ sess, err = d.createSessionWithEnvLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
730732 if err != nil {
731733 d.mu.Unlock()
732734 return nil, nil, err
@@ -737,7 +739,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
737739
738740 case ports.IntentEphemeral:
739741 name := d.allocEphemeralNameLocked()
740- sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term)
742+ sess, err := d.createSessionWithEnvLocked(name, true, h.Cwd, sz, term, h.Env)
741743 if err != nil {
742744 d.mu.Unlock()
743745 return nil, nil, err
@@ -758,7 +760,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
758760 d.mu.Unlock()
759761 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
760762 }
761- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term)
763+ sess, err := d.createSessionWithEnvLocked(h.Name, false, h.Cwd, sz, term, h.Env)
762764 if err != nil {
763765 d.mu.Unlock()
764766 return nil, nil, err
@@ -776,7 +778,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
776778 }
777779 cwd := d.dirOrHome(stopped.cwd)
778780 var err error
779- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
781+ sess, err = d.createSessionWithEnvLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
780782 if err != nil {
781783 d.mu.Unlock()
782784 return nil, nil, err
@@ -98,6 +98,7 @@ type Daemon struct {
98 baseEnv []string98 baseEnv []string
99 shell string99 shell string
100 shellArgs []string100 shellArgs []string
101+ shellOverride bool
101 persist *persist.Persister102 persist *persist.Persister
102 persistEnabled bool103 persistEnabled bool
103 snaps ports.SnapshotStore104 snaps ports.SnapshotStore
@@ -204,6 +205,7 @@ func WithShell(cmd string, args []string) Option {
204 return func(d *Daemon) {205 return func(d *Daemon) {
205 d.shell = cmd206 d.shell = cmd
206 d.shellArgs = args207 d.shellArgs = args
208+ d.shellOverride = true
207 }209 }
208 }210 }
209 211
@@ -671,13 +673,13 @@ func (e *protoErr) Error() string { return e.text }
671 // teardown so obsolete worker joins never delay the new handshake.673 // teardown so obsolete worker joins never delay the new handshake.
672 func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {674 func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient {
673 ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{675 ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
674- clientID: h.ClientID,676+ clientID: h.ClientID,
675- resumeCapable: true,677+ resumeCapable: true,
676- maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),678+ maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
679+ environment: h.Env,
680+ terminal: term,
681+ refreshEnvironment: true,
677 })682 })
678- sess.mu.Lock()
679- sess.terminal = term
680- sess.mu.Unlock()
681 d.mu.Unlock()683 d.mu.Unlock()
682 d.retireReplacedClient(old, cleanup)684 d.retireReplacedClient(old, cleanup)
683 return ac685 return ac
@@ -726,7 +728,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
726 }728 }
727 cwd := d.dirOrHome(stopped.cwd)729 cwd := d.dirOrHome(stopped.cwd)
728 var err error730 var err error
729- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)731+ sess, err = d.createSessionWithEnvLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
730 if err != nil {732 if err != nil {
731 d.mu.Unlock()733 d.mu.Unlock()
732 return nil, nil, err734 return nil, nil, err
@@ -737,7 +739,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
737 739
738 case ports.IntentEphemeral:740 case ports.IntentEphemeral:
739 name := d.allocEphemeralNameLocked()741 name := d.allocEphemeralNameLocked()
740- sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term)742+ sess, err := d.createSessionWithEnvLocked(name, true, h.Cwd, sz, term, h.Env)
741 if err != nil {743 if err != nil {
742 d.mu.Unlock()744 d.mu.Unlock()
743 return nil, nil, err745 return nil, nil, err
@@ -758,7 +760,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
758 d.mu.Unlock()760 d.mu.Unlock()
759 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}761 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
760 }762 }
761- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term)763+ sess, err := d.createSessionWithEnvLocked(h.Name, false, h.Cwd, sz, term, h.Env)
762 if err != nil {764 if err != nil {
763 d.mu.Unlock()765 d.mu.Unlock()
764 return nil, nil, err766 return nil, nil, err
@@ -776,7 +778,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
776 }778 }
777 cwd := d.dirOrHome(stopped.cwd)779 cwd := d.dirOrHome(stopped.cwd)
778 var err error780 var err error
779- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)781+ sess, err = d.createSessionWithEnvLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
780 if err != nil {782 if err != nil {
781 d.mu.Unlock()783 d.mu.Unlock()
782 return nil, nil, err784 return nil, nil, err
M internal/usecase/daemon/floating.go +4 -3
@@ -313,6 +313,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
313313 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
314314 sess.mu.Lock()
315315 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
316+ env := copyEnvironment(sess.env)
316317 sess.mu.Unlock()
317318 tb.mu.Lock()
318319 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)
@@ -342,7 +343,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
342343 if tabCtx == nil {
343344 tabCtx = context.Background()
344345 }
345- command, args := d.shell, append([]string(nil), d.shellArgs...)
346+ command, args := d.ptyCommand(env)
346347 if cfg.Command != "" {
347348 args = []string{"-lc", cfg.Command}
348349 }
@@ -352,10 +353,10 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
352353 size: size,
353354 geometry: geometry,
354355 paneStableID: paneStableID,
355- env: d.childEnv(name, tabStableID, paneStableID, term),
356+ env: childEnvFrom(env, name, tabStableID, paneStableID, term),
356357 command: command,
357358 args: args,
358- fallback: floatingCommandFallback(cfg.Command, d.shell),
359+ fallback: floatingCommandFallback(cfg.Command, command),
359360 parentCtx: tabCtx,
360361 userOpen: userOpen,
361362 }, nil
@@ -313,6 +313,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
313 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {313 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
314 sess.mu.Lock()314 sess.mu.Lock()
315 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx315 name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
316+ env := copyEnvironment(sess.env)
316 sess.mu.Unlock()317 sess.mu.Unlock()
317 tb.mu.Lock()318 tb.mu.Lock()
318 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)319 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)
@@ -342,7 +343,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
342 if tabCtx == nil {343 if tabCtx == nil {
343 tabCtx = context.Background()344 tabCtx = context.Background()
344 }345 }
345- command, args := d.shell, append([]string(nil), d.shellArgs...)346+ command, args := d.ptyCommand(env)
346 if cfg.Command != "" {347 if cfg.Command != "" {
347 args = []string{"-lc", cfg.Command}348 args = []string{"-lc", cfg.Command}
348 }349 }
@@ -352,10 +353,10 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
352 size: size,353 size: size,
353 geometry: geometry,354 geometry: geometry,
354 paneStableID: paneStableID,355 paneStableID: paneStableID,
355- env: d.childEnv(name, tabStableID, paneStableID, term),356+ env: childEnvFrom(env, name, tabStableID, paneStableID, term),
356 command: command,357 command: command,
357 args: args,358 args: args,
358- fallback: floatingCommandFallback(cfg.Command, d.shell),359+ fallback: floatingCommandFallback(cfg.Command, command),
359 parentCtx: tabCtx,360 parentCtx: tabCtx,
360 userOpen: userOpen,361 userOpen: userOpen,
361 }, nil362 }, nil
M internal/usecase/daemon/floating_test.go +1 -2
@@ -143,11 +143,10 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
143143 pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
144144 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
145145 d := newTestDaemon(t, factory, stubClock{})
146- d.shell = "/bin/custom-shell"
147146 cwd := t.TempDir()
148147 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
149148 tb.ctx, tb.cancel = context.WithCancel(t.Context())
150- sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()}
149+ sess := &session{name: "work", cwd: cwd, env: []string{"SHELL=/bin/custom-shell"}, tabs: []*tab{tb}, ctx: t.Context()}
151150 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
152151 d.ensureFloatingWarm(sess, tb)
153152 // Open has started while this goroutine owns tab.mu: an external factory
@@ -143,11 +143,10 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
143 pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()143 pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
144 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)144 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
145 d := newTestDaemon(t, factory, stubClock{})145 d := newTestDaemon(t, factory, stubClock{})
146- d.shell = "/bin/custom-shell"
147 cwd := t.TempDir()146 cwd := t.TempDir()
148 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})147 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
149 tb.ctx, tb.cancel = context.WithCancel(t.Context())148 tb.ctx, tb.cancel = context.WithCancel(t.Context())
150- sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()}149+ sess := &session{name: "work", cwd: cwd, env: []string{"SHELL=/bin/custom-shell"}, tabs: []*tab{tb}, ctx: t.Context()}
151 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})150 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
152 d.ensureFloatingWarm(sess, tb)151 d.ensureFloatingWarm(sess, tb)
153 // Open has started while this goroutine owns tab.mu: an external factory152 // Open has started while this goroutine owns tab.mu: an external factory
M internal/usecase/daemon/pane_actions.go +3 -1
@@ -35,6 +35,7 @@ func (d *Daemon) spawnPaneOp(
3535 sess.mu.Lock()
3636 name, cwd := sess.name, sess.cwd
3737 term := sess.terminal
38+ env := copyEnvironment(sess.env)
3839 sess.mu.Unlock()
3940
4041 tb.mu.Lock()
@@ -69,7 +70,8 @@ func (d *Daemon) spawnPaneOp(
6970 tb.mu.Unlock()
7071 return fmt.Errorf("daemon: generating pane identity: %w", err)
7172 }
72- pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
73+ command, args := d.ptyCommand(env)
74+ pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
7375 if err != nil {
7476 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
7577 tb.mu.Lock()
@@ -35,6 +35,7 @@ func (d *Daemon) spawnPaneOp(
35 sess.mu.Lock()35 sess.mu.Lock()
36 name, cwd := sess.name, sess.cwd36 name, cwd := sess.name, sess.cwd
37 term := sess.terminal37 term := sess.terminal
38+ env := copyEnvironment(sess.env)
38 sess.mu.Unlock()39 sess.mu.Unlock()
39 40
40 tb.mu.Lock()41 tb.mu.Lock()
@@ -69,7 +70,8 @@ func (d *Daemon) spawnPaneOp(
69 tb.mu.Unlock()70 tb.mu.Unlock()
70 return fmt.Errorf("daemon: generating pane identity: %w", err)71 return fmt.Errorf("daemon: generating pane identity: %w", err)
71 }72 }
72- pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))73+ command, args := d.ptyCommand(env)
74+ pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
73 if err != nil {75 if err != nil {
74 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")76 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
75 tb.mu.Lock()77 tb.mu.Lock()
M internal/usecase/daemon/picker.go +4 -1
@@ -415,6 +415,7 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
415415 }
416416
417417 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
418+ env := ac.environment()
418419 d.mu.Lock()
419420 if d.sessions[target.Session] != targetSess {
420421 d.mu.Unlock()
@@ -434,6 +435,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
434435 targetSess.mu.Lock()
435436 old := targetSess.client
436437 targetSess.terminal = term
438+ targetSess.env = copyEnvironment(env)
437439 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
438440 targetSess.active = target.TabIndex
439441 }
@@ -468,8 +470,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
468470 return false
469471 }
470472 term := from.terminal
473+ env := ac.environment()
471474 cwd := d.dirOrHome(stopped.cwd)
472- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, stopped.tabNames)
475+ targetSess, err := d.createSessionWithEnvLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
473476 if err != nil {
474477 from.mu.Unlock()
475478 d.mu.Unlock()
@@ -415,6 +415,7 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
415 }415 }
416 416
417 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {417 func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient {
418+ env := ac.environment()
418 d.mu.Lock()419 d.mu.Lock()
419 if d.sessions[target.Session] != targetSess {420 if d.sessions[target.Session] != targetSess {
420 d.mu.Unlock()421 d.mu.Unlock()
@@ -434,6 +435,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
434 targetSess.mu.Lock()435 targetSess.mu.Lock()
435 old := targetSess.client436 old := targetSess.client
436 targetSess.terminal = term437 targetSess.terminal = term
438+ targetSess.env = copyEnvironment(env)
437 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {439 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
438 targetSess.active = target.TabIndex440 targetSess.active = target.TabIndex
439 }441 }
@@ -468,8 +470,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
468 return false470 return false
469 }471 }
470 term := from.terminal472 term := from.terminal
473+ env := ac.environment()
471 cwd := d.dirOrHome(stopped.cwd)474 cwd := d.dirOrHome(stopped.cwd)
472- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, stopped.tabNames)475+ targetSess, err := d.createSessionWithEnvLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
473 if err != nil {476 if err != nil {
474 from.mu.Unlock()477 from.mu.Unlock()
475 d.mu.Unlock()478 d.mu.Unlock()
M internal/usecase/daemon/picker_test.go +6 -1
@@ -445,12 +445,14 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
445445 defer releasePTY2()
446446 defer releasePTY3()
447447 var openedEnv []string
448+ var openedCommand string
448449 f := portsmocks.NewMockPTYFactory(t)
449450 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
450- func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
451+ func(_ context.Context, command string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
451452 if sz != (domain.Size{Cols: 80, Rows: 22}) {
452453 return newQuietPTY(), nil
453454 }
455+ openedCommand = command
454456 openedEnv = append([]string(nil), env...)
455457 return p3, nil
456458 },
@@ -466,6 +468,7 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
466468 defer cancel2()
467469 sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}}
468470 sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}}
471+ ac1.setEnvironment([]string{"SECRET=from-active-client", "SHELL=/usr/bin/fish"})
469472 ac1.setSession(sess1)
470473 ac2.setSession(sess2)
471474 d.sessions[sess1.id] = sess1
@@ -476,6 +479,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
476479 require.Same(t, ac2, old)
477480 require.False(t, sess2.terminal.TrueColor)
478481 require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))
482+ require.Equal(t, "/usr/bin/fish", openedCommand)
483+ require.Contains(t, openedEnv, "SECRET=from-active-client")
479484 require.Contains(t, openedEnv, "TERM=xterm-256color")
480485 require.NotContains(t, openedEnv, "COLORTERM=truecolor")
481486 require.NotContains(t, openedEnv, "TERM=xterm-direct")
@@ -445,12 +445,14 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
445 defer releasePTY2()445 defer releasePTY2()
446 defer releasePTY3()446 defer releasePTY3()
447 var openedEnv []string447 var openedEnv []string
448+ var openedCommand string
448 f := portsmocks.NewMockPTYFactory(t)449 f := portsmocks.NewMockPTYFactory(t)
449 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(450 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
450- func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {451+ func(_ context.Context, command string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
451 if sz != (domain.Size{Cols: 80, Rows: 22}) {452 if sz != (domain.Size{Cols: 80, Rows: 22}) {
452 return newQuietPTY(), nil453 return newQuietPTY(), nil
453 }454 }
455+ openedCommand = command
454 openedEnv = append([]string(nil), env...)456 openedEnv = append([]string(nil), env...)
455 return p3, nil457 return p3, nil
456 },458 },
@@ -466,6 +468,7 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
466 defer cancel2()468 defer cancel2()
467 sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}}469 sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}}
468 sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}}470 sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}}
471+ ac1.setEnvironment([]string{"SECRET=from-active-client", "SHELL=/usr/bin/fish"})
469 ac1.setSession(sess1)472 ac1.setSession(sess1)
470 ac2.setSession(sess2)473 ac2.setSession(sess2)
471 d.sessions[sess1.id] = sess1474 d.sessions[sess1.id] = sess1
@@ -476,6 +479,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
476 require.Same(t, ac2, old)479 require.Same(t, ac2, old)
477 require.False(t, sess2.terminal.TrueColor)480 require.False(t, sess2.terminal.TrueColor)
478 require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))481 require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))
482+ require.Equal(t, "/usr/bin/fish", openedCommand)
483+ require.Contains(t, openedEnv, "SECRET=from-active-client")
479 require.Contains(t, openedEnv, "TERM=xterm-256color")484 require.Contains(t, openedEnv, "TERM=xterm-256color")
480 require.NotContains(t, openedEnv, "COLORTERM=truecolor")485 require.NotContains(t, openedEnv, "COLORTERM=truecolor")
481 require.NotContains(t, openedEnv, "TERM=xterm-direct")486 require.NotContains(t, openedEnv, "TERM=xterm-direct")
M internal/usecase/daemon/resume.go +3 -1
@@ -179,14 +179,16 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
179179 // mandatory first paint cannot be blocked by ACKs that died with the link.
180180 ac.output.rebase()
181181 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
182+ ac.setEnvironment(h.Env)
182183 ac.replaceTransport(tr)
183184 ac.size = sz
184185 ac.resumeToken = d.nextResumeTokenLocked()
185186 ac.parked = false
186187 ac.setSession(sess)
187188 sess.mu.Lock()
188- sess.client = ac
189+ sess.env = copyEnvironment(h.Env)
189190 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
191+ sess.client = ac
190192 sess.mu.Unlock()
191193 d.attachCoordinator(sess, nil, ac, false)
192194 d.touchMRU(sess)
@@ -179,14 +179,16 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
179 // mandatory first paint cannot be blocked by ACKs that died with the link.179 // mandatory first paint cannot be blocked by ACKs that died with the link.
180 ac.output.rebase()180 ac.output.rebase()
181 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))181 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
182+ ac.setEnvironment(h.Env)
182 ac.replaceTransport(tr)183 ac.replaceTransport(tr)
183 ac.size = sz184 ac.size = sz
184 ac.resumeToken = d.nextResumeTokenLocked()185 ac.resumeToken = d.nextResumeTokenLocked()
185 ac.parked = false186 ac.parked = false
186 ac.setSession(sess)187 ac.setSession(sess)
187 sess.mu.Lock()188 sess.mu.Lock()
188- sess.client = ac189+ sess.env = copyEnvironment(h.Env)
189 sess.terminal = terminalEnv{TrueColor: h.TrueColor}190 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
191+ sess.client = ac
190 sess.mu.Unlock()192 sess.mu.Unlock()
191 d.attachCoordinator(sess, nil, ac, false)193 d.attachCoordinator(sess, nil, ac, false)
192 d.touchMRU(sess)194 d.touchMRU(sess)
M internal/usecase/daemon/resume_test.go +5 -1
@@ -684,6 +684,7 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
684684 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
685685 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
686686 hello := helloResumeCapable(ports.IntentNew, "work", 0)
687+ hello.Env = []string{"SECRET=before", "SHELL=/bin/sh"}
687688 hello.TrueColor = false
688689 sess, ac, err := d.route(hello, tr)
689690 require.NoError(t, err)
@@ -692,13 +693,16 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
692693 require.True(t, d.parkAttachment(sess, ac))
693694
694695 resumeHello := helloResumeCapable(ports.IntentResume, "work", token)
696+ resumeHello.Env = []string{"SECRET=after", "SHELL=/usr/bin/fish"}
695697 resumeHello.TrueColor = true
696- _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
698+ _, resumed, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
697699 require.NoError(t, err)
698700 require.True(t, ok)
699701 sess.mu.Lock()
700702 defer sess.mu.Unlock()
701703 require.True(t, sess.terminal.TrueColor)
704+ require.Equal(t, resumeHello.Env, sess.env)
705+ require.Equal(t, resumeHello.Env, resumed.environment())
702706 }
703707
704708 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
@@ -684,6 +684,7 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
684 d := newTestDaemon(t, newFactory(t, pty), stubClock{})684 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
685 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))685 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
686 hello := helloResumeCapable(ports.IntentNew, "work", 0)686 hello := helloResumeCapable(ports.IntentNew, "work", 0)
687+ hello.Env = []string{"SECRET=before", "SHELL=/bin/sh"}
687 hello.TrueColor = false688 hello.TrueColor = false
688 sess, ac, err := d.route(hello, tr)689 sess, ac, err := d.route(hello, tr)
689 require.NoError(t, err)690 require.NoError(t, err)
@@ -692,13 +693,16 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
692 require.True(t, d.parkAttachment(sess, ac))693 require.True(t, d.parkAttachment(sess, ac))
693 694
694 resumeHello := helloResumeCapable(ports.IntentResume, "work", token)695 resumeHello := helloResumeCapable(ports.IntentResume, "work", token)
696+ resumeHello.Env = []string{"SECRET=after", "SHELL=/usr/bin/fish"}
695 resumeHello.TrueColor = true697 resumeHello.TrueColor = true
696- _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})698+ _, resumed, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
697 require.NoError(t, err)699 require.NoError(t, err)
698 require.True(t, ok)700 require.True(t, ok)
699 sess.mu.Lock()701 sess.mu.Lock()
700 defer sess.mu.Unlock()702 defer sess.mu.Unlock()
701 require.True(t, sess.terminal.TrueColor)703 require.True(t, sess.terminal.TrueColor)
704+ require.Equal(t, resumeHello.Env, sess.env)
705+ require.Equal(t, resumeHello.Env, resumed.environment())
702 }706 }
703 707
704 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {708 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
M internal/usecase/daemon/session.go +65 -16
@@ -40,10 +40,13 @@ type session struct {
4040 clipboardWorkerRunning bool
4141 cwd string
4242 terminal terminalEnv
43- createdAt int64
44- mruAt atomic.Uint64
45- snapDirty atomic.Bool
46- snapEligible atomic.Bool
43+ // env is the authoritative immutable environment snapshot for future PTY children.
44+ // It is guarded by mu and is always copied on ingress and egress.
45+ env []string
46+ createdAt int64
47+ mruAt atomic.Uint64
48+ snapDirty atomic.Bool
49+ snapEligible atomic.Bool
4750 // snapshotMu serializes the dirty generation with worker completion. It is
4851 // intentionally independent from mu: persistence never holds session state
4952 // locks while encoding or writing.
@@ -127,6 +130,11 @@ func (d *Daemon) touchMRU(sess *session) {
127130 }
128131
129132 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {
133+ return d.createSessionWithEnvLocked(name, ephemeral, cwd, sz, term, d.baseEnv, restoredTabNames...)
134+}
135+
136+func (d *Daemon) createSessionWithEnvLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
137+ env = copyEnvironment(env)
130138 tbSize := tabSize(sz)
131139 var names []string
132140 if len(restoredTabNames) > 0 {
@@ -139,7 +147,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
139147 closeTabs(tabs)
140148 return nil, err
141149 }
142- pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
150+ command, args := d.ptyCommand(env)
151+ pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
143152 if err != nil {
144153 closeTabs(tabs)
145154 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -175,6 +184,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
175184 tabs: tabs,
176185 cwd: cwd,
177186 terminal: term,
187+ env: env,
178188 createdAt: createdAt,
179189 }
180190 if lastUsedSeq > 0 {
@@ -217,6 +227,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
217227 return err
218228 }
219229 sz := ac.size
230+ env := ac.environment()
220231 d.mu.Lock()
221232 if d.closing {
222233 d.mu.Unlock()
@@ -236,7 +247,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
236247 }
237248 from.mu.Unlock()
238249
239- newSess, err := d.createSessionLocked(name, false, cwd, sz, term)
250+ newSess, err := d.createSessionWithEnvLocked(name, false, cwd, sz, term, env)
240251 if err != nil {
241252 d.mu.Unlock()
242253 return err
@@ -276,12 +287,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
276287 cwd := sess.cwd
277288 client := sess.client
278289 term := sess.terminal
290+ env := copyEnvironment(sess.env)
279291 sess.mu.Unlock()
280292 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
281293 if err != nil {
282294 return err
283295 }
284- pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
296+ command, args := d.ptyCommand(env)
297+ pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
285298 if err != nil {
286299 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
287300 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -899,18 +912,54 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
899912 d.mu.Unlock()
900913 }
901914
902-// childEnv builds the session child's environment: the daemon's own, with terminal
903-// and VEV values forced to well-known values.
915+// childEnv retains the daemon-environment helper for daemon-local legacy callers.
916+// Interactive PTY launch paths use childEnvFrom with their session snapshot.
904917 func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {
905- out := make([]string, 0, len(d.baseEnv)+4)
906- for _, e := range d.baseEnv {
907- if strings.HasPrefix(e, "TERM=") ||
908- strings.HasPrefix(e, "COLORTERM=") ||
909- strings.HasPrefix(e, "TERM_PROGRAM=") ||
910- strings.HasPrefix(e, "VEV=") {
918+ return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID, term)
919+}
920+
921+func copyEnvironment(env []string) []string {
922+ return append([]string(nil), env...)
923+}
924+
925+func environmentEntry(entry string) (name, value string, ok bool) {
926+ i := strings.IndexByte(entry, '=')
927+ if i < 0 {
928+ return "", "", false
929+ }
930+ return entry[:i], entry[i+1:], true
931+}
932+
933+func (d *Daemon) ptyCommand(env []string) (string, []string) {
934+ if d.shellOverride {
935+ return d.shell, append([]string(nil), d.shellArgs...)
936+ }
937+ return shellFromEnvironment(env), nil
938+}
939+
940+func shellFromEnvironment(env []string) string {
941+ shell := ""
942+ for _, entry := range env {
943+ if name, value, ok := environmentEntry(entry); ok && name == "SHELL" {
944+ shell = value
945+ }
946+ }
947+ if shell == "" {
948+ return "/bin/sh"
949+ }
950+ return shell
951+}
952+
953+// childEnvFrom preserves the supplied environment byte-for-byte and in order,
954+// except for exact reserved variable names which vev owns.
955+func childEnvFrom(env []string, name, tabStableID, paneStableID string, term terminalEnv) []string {
956+ out := make([]string, 0, len(env)+4)
957+ for _, entry := range env {
958+ key, _, ok := environmentEntry(entry)
959+ if ok && (key == "TERM" || key == "COLORTERM" || key == "TERM_PROGRAM" || key == "VEV") {
911960 continue
912961 }
913- out = append(out, e)
962+ out = append(out, entry)
914963 }
915964 if term.TrueColor {
916965 out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")
@@ -40,10 +40,13 @@ type session struct {
40 clipboardWorkerRunning bool40 clipboardWorkerRunning bool
41 cwd string41 cwd string
42 terminal terminalEnv42 terminal terminalEnv
43- createdAt int6443+ // env is the authoritative immutable environment snapshot for future PTY children.
44- mruAt atomic.Uint6444+ // It is guarded by mu and is always copied on ingress and egress.
45- snapDirty atomic.Bool45+ env []string
46- snapEligible atomic.Bool46+ createdAt int64
47+ mruAt atomic.Uint64
48+ snapDirty atomic.Bool
49+ snapEligible atomic.Bool
47 // snapshotMu serializes the dirty generation with worker completion. It is50 // snapshotMu serializes the dirty generation with worker completion. It is
48 // intentionally independent from mu: persistence never holds session state51 // intentionally independent from mu: persistence never holds session state
49 // locks while encoding or writing.52 // locks while encoding or writing.
@@ -127,6 +130,11 @@ func (d *Daemon) touchMRU(sess *session) {
127 }130 }
128 131
129 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {132 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {
133+ return d.createSessionWithEnvLocked(name, ephemeral, cwd, sz, term, d.baseEnv, restoredTabNames...)
134+}
135+
136+func (d *Daemon) createSessionWithEnvLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
137+ env = copyEnvironment(env)
130 tbSize := tabSize(sz)138 tbSize := tabSize(sz)
131 var names []string139 var names []string
132 if len(restoredTabNames) > 0 {140 if len(restoredTabNames) > 0 {
@@ -139,7 +147,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
139 closeTabs(tabs)147 closeTabs(tabs)
140 return nil, err148 return nil, err
141 }149 }
142- pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)150+ command, args := d.ptyCommand(env)
151+ pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
143 if err != nil {152 if err != nil {
144 closeTabs(tabs)153 closeTabs(tabs)
145 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")154 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -175,6 +184,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
175 tabs: tabs,184 tabs: tabs,
176 cwd: cwd,185 cwd: cwd,
177 terminal: term,186 terminal: term,
187+ env: env,
178 createdAt: createdAt,188 createdAt: createdAt,
179 }189 }
180 if lastUsedSeq > 0 {190 if lastUsedSeq > 0 {
@@ -217,6 +227,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
217 return err227 return err
218 }228 }
219 sz := ac.size229 sz := ac.size
230+ env := ac.environment()
220 d.mu.Lock()231 d.mu.Lock()
221 if d.closing {232 if d.closing {
222 d.mu.Unlock()233 d.mu.Unlock()
@@ -236,7 +247,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
236 }247 }
237 from.mu.Unlock()248 from.mu.Unlock()
238 249
239- newSess, err := d.createSessionLocked(name, false, cwd, sz, term)250+ newSess, err := d.createSessionWithEnvLocked(name, false, cwd, sz, term, env)
240 if err != nil {251 if err != nil {
241 d.mu.Unlock()252 d.mu.Unlock()
242 return err253 return err
@@ -276,12 +287,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
276 cwd := sess.cwd287 cwd := sess.cwd
277 client := sess.client288 client := sess.client
278 term := sess.terminal289 term := sess.terminal
290+ env := copyEnvironment(sess.env)
279 sess.mu.Unlock()291 sess.mu.Unlock()
280 tabStableID, paneStableID, err := d.newTabPaneStableIDs()292 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
281 if err != nil {293 if err != nil {
282 return err294 return err
283 }295 }
284- pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)296+ command, args := d.ptyCommand(env)
297+ pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
285 if err != nil {298 if err != nil {
286 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")299 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
287 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)300 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -899,18 +912,54 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
899 d.mu.Unlock()912 d.mu.Unlock()
900 }913 }
901 914
902-// childEnv builds the session child's environment: the daemon's own, with terminal915+// childEnv retains the daemon-environment helper for daemon-local legacy callers.
903-// and VEV values forced to well-known values.916+// Interactive PTY launch paths use childEnvFrom with their session snapshot.
904 func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {917 func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {
905- out := make([]string, 0, len(d.baseEnv)+4)918+ return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID, term)
906- for _, e := range d.baseEnv {919+}
907- if strings.HasPrefix(e, "TERM=") ||920+
908- strings.HasPrefix(e, "COLORTERM=") ||921+func copyEnvironment(env []string) []string {
909- strings.HasPrefix(e, "TERM_PROGRAM=") ||922+ return append([]string(nil), env...)
910- strings.HasPrefix(e, "VEV=") {923+}
924+
925+func environmentEntry(entry string) (name, value string, ok bool) {
926+ i := strings.IndexByte(entry, '=')
927+ if i < 0 {
928+ return "", "", false
929+ }
930+ return entry[:i], entry[i+1:], true
931+}
932+
933+func (d *Daemon) ptyCommand(env []string) (string, []string) {
934+ if d.shellOverride {
935+ return d.shell, append([]string(nil), d.shellArgs...)
936+ }
937+ return shellFromEnvironment(env), nil
938+}
939+
940+func shellFromEnvironment(env []string) string {
941+ shell := ""
942+ for _, entry := range env {
943+ if name, value, ok := environmentEntry(entry); ok && name == "SHELL" {
944+ shell = value
945+ }
946+ }
947+ if shell == "" {
948+ return "/bin/sh"
949+ }
950+ return shell
951+}
952+
953+// childEnvFrom preserves the supplied environment byte-for-byte and in order,
954+// except for exact reserved variable names which vev owns.
955+func childEnvFrom(env []string, name, tabStableID, paneStableID string, term terminalEnv) []string {
956+ out := make([]string, 0, len(env)+4)
957+ for _, entry := range env {
958+ key, _, ok := environmentEntry(entry)
959+ if ok && (key == "TERM" || key == "COLORTERM" || key == "TERM_PROGRAM" || key == "VEV") {
911 continue960 continue
912 }961 }
913- out = append(out, e)962+ out = append(out, entry)
914 }963 }
915 if term.TrueColor {964 if term.TrueColor {
916 out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")965 out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")
M internal/usecase/daemon/session_test.go +64 -0
@@ -1422,3 +1422,67 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
14221422 release2()
14231423 d.sessWg.Wait()
14241424 }
1425+
1426+func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
1427+ sz := domain.Size{Cols: 80, Rows: 24}
1428+ p1, release1 := newBlockingPTY(t)
1429+ defer release1()
1430+ p2, release2 := newBlockingPTY(t)
1431+ defer release2()
1432+
1433+ var commands []string
1434+ var envs [][]string
1435+ f := portsmocks.NewMockPTYFactory(t)
1436+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1437+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1438+ func(_ context.Context, command string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1439+ commands = append(commands, command)
1440+ envs = append(envs, append([]string(nil), env...))
1441+ if len(envs) == 1 {
1442+ return p1, nil
1443+ }
1444+ return p2, nil
1445+ },
1446+ ).Twice()
1447+ floating := newQuietPTY()
1448+ expectFloatingPrewarmOpen(f, normalSize, floating)
1449+ d := newTestDaemon(t, f, stubClock{})
1450+ tr1 := portsmocks.NewMockTransport(t)
1451+ tr1.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1452+ tr1.EXPECT().Close().Return(nil).Maybe()
1453+ tr2 := portsmocks.NewMockTransport(t)
1454+ tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1455+ tr2.EXPECT().Close().Return(nil).Maybe()
1456+
1457+ first := []string{"SECRET=first", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b"}
1458+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, Env: first}, tr1)
1459+ require.NoError(t, err)
1460+ second := []string{"SECRET=second", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b"}
1461+ _, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz, Env: second}, tr2)
1462+ require.NoError(t, err)
1463+ defer func() {
1464+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
1465+ d.sessWg.Wait()
1466+ }()
1467+
1468+ require.Equal(t, "/usr/bin/fish", commands[0])
1469+ require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1470+ require.NoError(t, d.createTab(sess, ac.size))
1471+ require.Equal(t, "/bin/bash", commands[1])
1472+ require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1473+}
1474+
1475+func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
1476+ env := []string{"SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "EMPTY="}
1477+ got := childEnvFrom(env, "work", "tab", "pane", terminalEnv{})
1478+ require.Equal(t, []string{
1479+ "SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "EMPTY=",
1480+ "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
1481+ }, got)
1482+}
1483+
1484+func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) {
1485+ require.Equal(t, "/bin/sh", shellFromEnvironment(nil))
1486+ require.Equal(t, "/bin/sh", shellFromEnvironment([]string{"SHELL="}))
1487+ require.Equal(t, "/usr/bin/fish", shellFromEnvironment([]string{"SHELL=/usr/bin/fish"}))
1488+}
@@ -1422,3 +1422,67 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1422 release2()1422 release2()
1423 d.sessWg.Wait()1423 d.sessWg.Wait()
1424 }1424 }
1425+
1426+func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
1427+ sz := domain.Size{Cols: 80, Rows: 24}
1428+ p1, release1 := newBlockingPTY(t)
1429+ defer release1()
1430+ p2, release2 := newBlockingPTY(t)
1431+ defer release2()
1432+
1433+ var commands []string
1434+ var envs [][]string
1435+ f := portsmocks.NewMockPTYFactory(t)
1436+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1437+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1438+ func(_ context.Context, command string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1439+ commands = append(commands, command)
1440+ envs = append(envs, append([]string(nil), env...))
1441+ if len(envs) == 1 {
1442+ return p1, nil
1443+ }
1444+ return p2, nil
1445+ },
1446+ ).Twice()
1447+ floating := newQuietPTY()
1448+ expectFloatingPrewarmOpen(f, normalSize, floating)
1449+ d := newTestDaemon(t, f, stubClock{})
1450+ tr1 := portsmocks.NewMockTransport(t)
1451+ tr1.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1452+ tr1.EXPECT().Close().Return(nil).Maybe()
1453+ tr2 := portsmocks.NewMockTransport(t)
1454+ tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1455+ tr2.EXPECT().Close().Return(nil).Maybe()
1456+
1457+ first := []string{"SECRET=first", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b"}
1458+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, Env: first}, tr1)
1459+ require.NoError(t, err)
1460+ second := []string{"SECRET=second", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b"}
1461+ _, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz, Env: second}, tr2)
1462+ require.NoError(t, err)
1463+ defer func() {
1464+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
1465+ d.sessWg.Wait()
1466+ }()
1467+
1468+ require.Equal(t, "/usr/bin/fish", commands[0])
1469+ require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1470+ require.NoError(t, d.createTab(sess, ac.size))
1471+ require.Equal(t, "/bin/bash", commands[1])
1472+ require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1473+}
1474+
1475+func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
1476+ env := []string{"SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "EMPTY="}
1477+ got := childEnvFrom(env, "work", "tab", "pane", terminalEnv{})
1478+ require.Equal(t, []string{
1479+ "SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "EMPTY=",
1480+ "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
1481+ }, got)
1482+}
1483+
1484+func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) {
1485+ require.Equal(t, "/bin/sh", shellFromEnvironment(nil))
1486+ require.Equal(t, "/bin/sh", shellFromEnvironment([]string{"SHELL="}))
1487+ require.Equal(t, "/usr/bin/fish", shellFromEnvironment([]string{"SHELL=/usr/bin/fish"}))
1488+}
M internal/usecase/daemon/snapshot.go +6 -2
@@ -118,6 +118,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
118118 // panes start with the conservative terminal environment. The next attach or
119119 // resume updates sess.terminal for future panes from the client's capability.
120120 restoreTerm := terminalEnv{}
121+ // No attach exists during restore, so no client environment is available.
122+ // Future attaches replace this empty snapshot before publishing a client.
123+ restoreEnv := []string(nil)
121124 closeOpened := func() {
122125 for _, tb := range opened {
123126 tb.closeAllPanes()
@@ -189,7 +192,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
189192 return fmt.Errorf("snapshot: generating pane identity: %w", err)
190193 }
191194 }
192- pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
195+ command, args := d.ptyCommand(restoreEnv)
196+ pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
193197 if err != nil {
194198 closeOpened()
195199 return err
@@ -217,7 +221,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
217221 return nil
218222 }
219223 createdAt := int64(snap.CreatedAt)
220- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}
224+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt}
221225 sess.snapEligible.Store(true)
222226 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
223227 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -118,6 +118,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
118 // panes start with the conservative terminal environment. The next attach or118 // panes start with the conservative terminal environment. The next attach or
119 // resume updates sess.terminal for future panes from the client's capability.119 // resume updates sess.terminal for future panes from the client's capability.
120 restoreTerm := terminalEnv{}120 restoreTerm := terminalEnv{}
121+ // No attach exists during restore, so no client environment is available.
122+ // Future attaches replace this empty snapshot before publishing a client.
123+ restoreEnv := []string(nil)
121 closeOpened := func() {124 closeOpened := func() {
122 for _, tb := range opened {125 for _, tb := range opened {
123 tb.closeAllPanes()126 tb.closeAllPanes()
@@ -189,7 +192,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
189 return fmt.Errorf("snapshot: generating pane identity: %w", err)192 return fmt.Errorf("snapshot: generating pane identity: %w", err)
190 }193 }
191 }194 }
192- pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)195+ command, args := d.ptyCommand(restoreEnv)
196+ pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
193 if err != nil {197 if err != nil {
194 closeOpened()198 closeOpened()
195 return err199 return err
@@ -217,7 +221,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
217 return nil221 return nil
218 }222 }
219 createdAt := int64(snap.CreatedAt)223 createdAt := int64(snap.CreatedAt)
220- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}224+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt}
221 sess.snapEligible.Store(true)225 sess.snapEligible.Store(true)
222 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {226 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
223 sess.cwd = snap.Tabs[0].Panes[0].Cwd227 sess.cwd = snap.Tabs[0].Panes[0].Cwd

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 12

Tags 11

↑↓ moveenter openesc close