feat(daemon): use attached environment for PTYs
- commit
- db0ee28fcab39d825c00e5723b15b285a647e542
- author
- brice · · 2026-07-15 05:30 UTC
- parent
- 106fbb7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 55 | 55 | // previousSession is guarded independently. It is retained through temporary | |
| 56 | 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 57 | 57 | previousSession Guarded[*session] | |
| 58 | + | envMu sync.Mutex // guards the immutable attach environment snapshot | |
| 59 | + | env []string | |
| 58 | 60 | linkMu sync.Mutex | |
| 59 | 61 | sendMu sync.Mutex | |
| 60 | 62 | } | |
| @@ -107,6 +109,18 @@ func (ac *attachedClient) currentSession() *session { return ac.sess.Get() } | |||
| 107 | 109 | ||
| 108 | 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 | 124 | func (ac *attachedClient) clearPreviousSession() { | |
| 111 | 125 | if ac != nil { | |
| 112 | 126 | ac.previousSession.Set(nil) | |
| @@ -392,9 +406,12 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} { | |||
| 392 | 406 | } | |
| 393 | 407 | ||
| 394 | 408 | 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 | |
| 398 | 415 | } | |
| 399 | 416 | ||
| 400 | 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 | 432 | clientID: opts.clientID, | |
| 416 | 433 | resumeCapable: opts.resumeCapable, | |
| 417 | 434 | resumeToken: resumeToken, | |
| 435 | + | env: copyEnvironment(opts.environment), | |
| 418 | 436 | } | |
| 419 | 437 | ac.initOverlays() | |
| 420 | 438 | ac.setSession(sess) | |
| @@ -425,6 +443,10 @@ func (d *Daemon) attachClientDeferred(sess *session, tr ports.Transport, sz doma | |||
| 425 | 443 | sess.mu.Lock() | |
| 426 | 444 | old := sess.client | |
| 427 | 445 | name := sess.name | |
| 446 | + | if opts.refreshEnvironment { | |
| 447 | + | sess.env = copyEnvironment(opts.environment) | |
| 448 | + | sess.terminal = opts.terminal | |
| 449 | + | } | |
| 428 | 450 | sess.mu.Unlock() | |
| 429 | 451 | _, cleanup := d.attachCoordinatorDeferred(sess, old, ac, false) | |
| 430 | 452 | sess.mu.Lock() | |
| @@ -55,6 +55,8 @@ type attachedClient struct { | |||||
| 55 | // previousSession is guarded independently. It is retained through temporary | 55 | // 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.Mutex | 60 | linkMu sync.Mutex | ||
| 59 | sendMu sync.Mutex | 61 | 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]byte | 409 | + | clientID [16]byte |
| 396 | - | resumeCapable bool | 410 | + | resumeCapable bool |
| 397 | - | maxOutputInFlight uint8 | 411 | + | 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.client | 444 | old := sess.client | ||
| 427 | name := sess.name | 445 | 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 { | |||
| 98 | 98 | baseEnv []string | |
| 99 | 99 | shell string | |
| 100 | 100 | shellArgs []string | |
| 101 | + | shellOverride bool | |
| 101 | 102 | persist *persist.Persister | |
| 102 | 103 | persistEnabled bool | |
| 103 | 104 | snaps ports.SnapshotStore | |
| @@ -204,6 +205,7 @@ func WithShell(cmd string, args []string) Option { | |||
| 204 | 205 | return func(d *Daemon) { | |
| 205 | 206 | d.shell = cmd | |
| 206 | 207 | 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 | 673 | // teardown so obsolete worker joins never delay the new handshake. | |
| 672 | 674 | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient { | |
| 673 | 675 | 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, | |
| 677 | 682 | }) | |
| 678 | - | sess.mu.Lock() | |
| 679 | - | sess.terminal = term | |
| 680 | - | sess.mu.Unlock() | |
| 681 | 683 | d.mu.Unlock() | |
| 682 | 684 | d.retireReplacedClient(old, cleanup) | |
| 683 | 685 | return ac | |
| @@ -726,7 +728,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 726 | 728 | } | |
| 727 | 729 | cwd := d.dirOrHome(stopped.cwd) | |
| 728 | 730 | 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 | 732 | if err != nil { | |
| 731 | 733 | d.mu.Unlock() | |
| 732 | 734 | return nil, nil, err | |
| @@ -737,7 +739,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 737 | 739 | ||
| 738 | 740 | case ports.IntentEphemeral: | |
| 739 | 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 | 743 | if err != nil { | |
| 742 | 744 | d.mu.Unlock() | |
| 743 | 745 | return nil, nil, err | |
| @@ -758,7 +760,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 758 | 760 | d.mu.Unlock() | |
| 759 | 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 | 764 | if err != nil { | |
| 763 | 765 | d.mu.Unlock() | |
| 764 | 766 | return nil, nil, err | |
| @@ -776,7 +778,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 776 | 778 | } | |
| 777 | 779 | cwd := d.dirOrHome(stopped.cwd) | |
| 778 | 780 | 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 | 782 | if err != nil { | |
| 781 | 783 | d.mu.Unlock() | |
| 782 | 784 | return nil, nil, err | |
| @@ -98,6 +98,7 @@ type Daemon struct { | |||||
| 98 | baseEnv []string | 98 | baseEnv []string | ||
| 99 | shell string | 99 | shell string | ||
| 100 | shellArgs []string | 100 | shellArgs []string | ||
| 101 | + | shellOverride bool | |||
| 101 | persist *persist.Persister | 102 | persist *persist.Persister | ||
| 102 | persistEnabled bool | 103 | persistEnabled bool | ||
| 103 | snaps ports.SnapshotStore | 104 | 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 = cmd | 206 | d.shell = cmd | ||
| 206 | d.shellArgs = args | 207 | 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 ac | 685 | 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 error | 730 | 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, err | 734 | 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, err | 745 | 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, err | 766 | 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 error | 780 | 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, err | 784 | 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 | |||
| 313 | 313 | func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) { | |
| 314 | 314 | sess.mu.Lock() | |
| 315 | 315 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | |
| 316 | + | env := copyEnvironment(sess.env) | |
| 316 | 317 | sess.mu.Unlock() | |
| 317 | 318 | tb.mu.Lock() | |
| 318 | 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 | 343 | if tabCtx == nil { | |
| 343 | 344 | tabCtx = context.Background() | |
| 344 | 345 | } | |
| 345 | - | command, args := d.shell, append([]string(nil), d.shellArgs...) | |
| 346 | + | command, args := d.ptyCommand(env) | |
| 346 | 347 | if cfg.Command != "" { | |
| 347 | 348 | args = []string{"-lc", cfg.Command} | |
| 348 | 349 | } | |
| @@ -352,10 +353,10 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 352 | 353 | size: size, | |
| 353 | 354 | geometry: geometry, | |
| 354 | 355 | paneStableID: paneStableID, | |
| 355 | - | env: d.childEnv(name, tabStableID, paneStableID, term), | |
| 356 | + | env: childEnvFrom(env, name, tabStableID, paneStableID, term), | |
| 356 | 357 | command: command, | |
| 357 | 358 | args: args, | |
| 358 | - | fallback: floatingCommandFallback(cfg.Command, d.shell), | |
| 359 | + | fallback: floatingCommandFallback(cfg.Command, command), | |
| 359 | 360 | parentCtx: tabCtx, | |
| 360 | 361 | userOpen: userOpen, | |
| 361 | 362 | }, 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.ctx | 315 | 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 | }, nil | 362 | }, nil | ||
M internal/usecase/daemon/floating_test.go +1 -2
| @@ -143,11 +143,10 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||
| 143 | 143 | pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once() | |
| 144 | 144 | factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil) | |
| 145 | 145 | d := newTestDaemon(t, factory, stubClock{}) | |
| 146 | - | d.shell = "/bin/custom-shell" | |
| 147 | 146 | cwd := t.TempDir() | |
| 148 | 147 | tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40}) | |
| 149 | 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 | 150 | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}}) | |
| 152 | 151 | d.ensureFloatingWarm(sess, tb) | |
| 153 | 152 | // 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 factory | 152 | // 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( | |||
| 35 | 35 | sess.mu.Lock() | |
| 36 | 36 | name, cwd := sess.name, sess.cwd | |
| 37 | 37 | term := sess.terminal | |
| 38 | + | env := copyEnvironment(sess.env) | |
| 38 | 39 | sess.mu.Unlock() | |
| 39 | 40 | ||
| 40 | 41 | tb.mu.Lock() | |
| @@ -69,7 +70,8 @@ func (d *Daemon) spawnPaneOp( | |||
| 69 | 70 | tb.mu.Unlock() | |
| 70 | 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 | 75 | if err != nil { | |
| 74 | 76 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 75 | 77 | 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.cwd | 36 | name, cwd := sess.name, sess.cwd | ||
| 37 | term := sess.terminal | 37 | 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 | |||
| 415 | 415 | } | |
| 416 | 416 | ||
| 417 | 417 | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 418 | + | env := ac.environment() | |
| 418 | 419 | d.mu.Lock() | |
| 419 | 420 | if d.sessions[target.Session] != targetSess { | |
| 420 | 421 | d.mu.Unlock() | |
| @@ -434,6 +435,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 434 | 435 | targetSess.mu.Lock() | |
| 435 | 436 | old := targetSess.client | |
| 436 | 437 | targetSess.terminal = term | |
| 438 | + | targetSess.env = copyEnvironment(env) | |
| 437 | 439 | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | |
| 438 | 440 | targetSess.active = target.TabIndex | |
| 439 | 441 | } | |
| @@ -468,8 +470,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 468 | 470 | return false | |
| 469 | 471 | } | |
| 470 | 472 | term := from.terminal | |
| 473 | + | env := ac.environment() | |
| 471 | 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 | 476 | if err != nil { | |
| 474 | 477 | from.mu.Unlock() | |
| 475 | 478 | 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.client | 436 | old := targetSess.client | ||
| 436 | targetSess.terminal = term | 437 | 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.TabIndex | 440 | targetSess.active = target.TabIndex | ||
| 439 | } | 441 | } | ||
| @@ -468,8 +470,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 468 | return false | 470 | return false | ||
| 469 | } | 471 | } | ||
| 470 | term := from.terminal | 472 | 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) { | |||
| 445 | 445 | defer releasePTY2() | |
| 446 | 446 | defer releasePTY3() | |
| 447 | 447 | var openedEnv []string | |
| 448 | + | var openedCommand string | |
| 448 | 449 | f := portsmocks.NewMockPTYFactory(t) | |
| 449 | 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 | 452 | if sz != (domain.Size{Cols: 80, Rows: 22}) { | |
| 452 | 453 | return newQuietPTY(), nil | |
| 453 | 454 | } | |
| 455 | + | openedCommand = command | |
| 454 | 456 | openedEnv = append([]string(nil), env...) | |
| 455 | 457 | return p3, nil | |
| 456 | 458 | }, | |
| @@ -466,6 +468,7 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 466 | 468 | defer cancel2() | |
| 467 | 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 | 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 | 472 | ac1.setSession(sess1) | |
| 470 | 473 | ac2.setSession(sess2) | |
| 471 | 474 | d.sessions[sess1.id] = sess1 | |
| @@ -476,6 +479,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 476 | 479 | require.Same(t, ac2, old) | |
| 477 | 480 | require.False(t, sess2.terminal.TrueColor) | |
| 478 | 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 | 484 | require.Contains(t, openedEnv, "TERM=xterm-256color") | |
| 480 | 485 | require.NotContains(t, openedEnv, "COLORTERM=truecolor") | |
| 481 | 486 | 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 []string | 447 | 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(), nil | 453 | return newQuietPTY(), nil | ||
| 453 | } | 454 | } | ||
| 455 | + | openedCommand = command | |||
| 454 | openedEnv = append([]string(nil), env...) | 456 | openedEnv = append([]string(nil), env...) | ||
| 455 | return p3, nil | 457 | 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] = sess1 | 474 | 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 | |||
| 179 | 179 | // mandatory first paint cannot be blocked by ACKs that died with the link. | |
| 180 | 180 | ac.output.rebase() | |
| 181 | 181 | ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight)) | |
| 182 | + | ac.setEnvironment(h.Env) | |
| 182 | 183 | ac.replaceTransport(tr) | |
| 183 | 184 | ac.size = sz | |
| 184 | 185 | ac.resumeToken = d.nextResumeTokenLocked() | |
| 185 | 186 | ac.parked = false | |
| 186 | 187 | ac.setSession(sess) | |
| 187 | 188 | sess.mu.Lock() | |
| 188 | - | sess.client = ac | |
| 189 | + | sess.env = copyEnvironment(h.Env) | |
| 189 | 190 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | |
| 191 | + | sess.client = ac | |
| 190 | 192 | sess.mu.Unlock() | |
| 191 | 193 | d.attachCoordinator(sess, nil, ac, false) | |
| 192 | 194 | 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 = sz | 184 | ac.size = sz | ||
| 184 | ac.resumeToken = d.nextResumeTokenLocked() | 185 | ac.resumeToken = d.nextResumeTokenLocked() | ||
| 185 | ac.parked = false | 186 | ac.parked = false | ||
| 186 | ac.setSession(sess) | 187 | ac.setSession(sess) | ||
| 187 | sess.mu.Lock() | 188 | sess.mu.Lock() | ||
| 188 | - | sess.client = ac | 189 | + | 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) { | |||
| 684 | 684 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 685 | 685 | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 686 | 686 | hello := helloResumeCapable(ports.IntentNew, "work", 0) | |
| 687 | + | hello.Env = []string{"SECRET=before", "SHELL=/bin/sh"} | |
| 687 | 688 | hello.TrueColor = false | |
| 688 | 689 | sess, ac, err := d.route(hello, tr) | |
| 689 | 690 | require.NoError(t, err) | |
| @@ -692,13 +693,16 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) { | |||
| 692 | 693 | require.True(t, d.parkAttachment(sess, ac)) | |
| 693 | 694 | ||
| 694 | 695 | resumeHello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 696 | + | resumeHello.Env = []string{"SECRET=after", "SHELL=/usr/bin/fish"} | |
| 695 | 697 | 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 | 699 | require.NoError(t, err) | |
| 698 | 700 | require.True(t, ok) | |
| 699 | 701 | sess.mu.Lock() | |
| 700 | 702 | defer sess.mu.Unlock() | |
| 701 | 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 | 708 | 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 = false | 688 | 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 = true | 697 | 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 { | |||
| 40 | 40 | clipboardWorkerRunning bool | |
| 41 | 41 | cwd string | |
| 42 | 42 | 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 | |
| 47 | 50 | // snapshotMu serializes the dirty generation with worker completion. It is | |
| 48 | 51 | // intentionally independent from mu: persistence never holds session state | |
| 49 | 52 | // locks while encoding or writing. | |
| @@ -127,6 +130,11 @@ func (d *Daemon) touchMRU(sess *session) { | |||
| 127 | 130 | } | |
| 128 | 131 | ||
| 129 | 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 | 138 | tbSize := tabSize(sz) | |
| 131 | 139 | var names []string | |
| 132 | 140 | if len(restoredTabNames) > 0 { | |
| @@ -139,7 +147,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 139 | 147 | closeTabs(tabs) | |
| 140 | 148 | 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 | 152 | if err != nil { | |
| 144 | 153 | closeTabs(tabs) | |
| 145 | 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 | 184 | tabs: tabs, | |
| 176 | 185 | cwd: cwd, | |
| 177 | 186 | terminal: term, | |
| 187 | + | env: env, | |
| 178 | 188 | createdAt: createdAt, | |
| 179 | 189 | } | |
| 180 | 190 | if lastUsedSeq > 0 { | |
| @@ -217,6 +227,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 217 | 227 | return err | |
| 218 | 228 | } | |
| 219 | 229 | sz := ac.size | |
| 230 | + | env := ac.environment() | |
| 220 | 231 | d.mu.Lock() | |
| 221 | 232 | if d.closing { | |
| 222 | 233 | d.mu.Unlock() | |
| @@ -236,7 +247,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 236 | 247 | } | |
| 237 | 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 | 251 | if err != nil { | |
| 241 | 252 | d.mu.Unlock() | |
| 242 | 253 | return err | |
| @@ -276,12 +287,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 276 | 287 | cwd := sess.cwd | |
| 277 | 288 | client := sess.client | |
| 278 | 289 | term := sess.terminal | |
| 290 | + | env := copyEnvironment(sess.env) | |
| 279 | 291 | sess.mu.Unlock() | |
| 280 | 292 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 281 | 293 | if err != nil { | |
| 282 | 294 | 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 | 298 | if err != nil { | |
| 286 | 299 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 287 | 300 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | |
| @@ -899,18 +912,54 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 899 | 912 | d.mu.Unlock() | |
| 900 | 913 | } | |
| 901 | 914 | ||
| 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. | |
| 904 | 917 | 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") { | |
| 911 | 960 | continue | |
| 912 | 961 | } | |
| 913 | - | out = append(out, e) | |
| 962 | + | out = append(out, entry) | |
| 914 | 963 | } | |
| 915 | 964 | if term.TrueColor { | |
| 916 | 965 | out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor") | |
| @@ -40,10 +40,13 @@ type session struct { | |||||
| 40 | clipboardWorkerRunning bool | 40 | clipboardWorkerRunning bool | ||
| 41 | cwd string | 41 | cwd string | ||
| 42 | terminal terminalEnv | 42 | terminal terminalEnv | ||
| 43 | - | createdAt int64 | 43 | + | // env is the authoritative immutable environment snapshot for future PTY children. |
| 44 | - | mruAt atomic.Uint64 | 44 | + | // It is guarded by mu and is always copied on ingress and egress. |
| 45 | - | snapDirty atomic.Bool | 45 | + | env []string |
| 46 | - | snapEligible atomic.Bool | 46 | + | createdAt int64 |
| 47 | + | mruAt atomic.Uint64 | |||
| 48 | + | snapDirty atomic.Bool | |||
| 49 | + | snapEligible atomic.Bool | |||
| 47 | // snapshotMu serializes the dirty generation with worker completion. It is | 50 | // snapshotMu serializes the dirty generation with worker completion. It is | ||
| 48 | // intentionally independent from mu: persistence never holds session state | 51 | // 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 []string | 139 | 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, err | 148 | 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 err | 227 | return err | ||
| 218 | } | 228 | } | ||
| 219 | sz := ac.size | 229 | 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 err | 253 | return err | ||
| @@ -276,12 +287,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 276 | cwd := sess.cwd | 287 | cwd := sess.cwd | ||
| 277 | client := sess.client | 288 | client := sess.client | ||
| 278 | term := sess.terminal | 289 | 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 err | 294 | 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 terminal | 915 | + | // 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 | continue | 960 | 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) { | |||
| 1422 | 1422 | release2() | |
| 1423 | 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 | + | } | |
| @@ -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 | |||
| 118 | 118 | // panes start with the conservative terminal environment. The next attach or | |
| 119 | 119 | // resume updates sess.terminal for future panes from the client's capability. | |
| 120 | 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 | 124 | closeOpened := func() { | |
| 122 | 125 | for _, tb := range opened { | |
| 123 | 126 | tb.closeAllPanes() | |
| @@ -189,7 +192,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 189 | 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 | 197 | if err != nil { | |
| 194 | 198 | closeOpened() | |
| 195 | 199 | return err | |
| @@ -217,7 +221,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 217 | 221 | return nil | |
| 218 | 222 | } | |
| 219 | 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 | 225 | sess.snapEligible.Store(true) | |
| 222 | 226 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 223 | 227 | 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 or | 118 | // 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 err | 199 | return err | ||
| @@ -217,7 +221,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 217 | return nil | 221 | 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].Cwd | 227 | sess.cwd = snap.Tabs[0].Panes[0].Cwd | ||