detached 464c9e0

Merge pull request #93 from bnema/feature/attach-environment

feat(session): refresh child environment on attach
commit
464c9e0e399b516494ab23a8f927690712e8a7a3
author
bnema · · 2026-07-15 07:03 UTC
committer
GitHub · · 2026-07-15 07:03 UTC
merge of
f8d0bc7 + bc9ddfa · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
24 files · +725 -114
M docs/terminal.md +6 -0
@@ -4,6 +4,12 @@
44
55 When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot start conservative until a client attaches.
66
7+## Environment
8+
9+On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.
10+
11+For remote attaches, the remote proxy substitutes the remote host's exported environment, so remote PTYs do not inherit the local client's environment.
12+
713 ## VT compatibility
814
915 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.
@@ -4,6 +4,12 @@
4 4
5 When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot start conservative until a client attaches.5 When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot start conservative until a client attaches.
6 6
7+## Environment
8+
9+On each attach, the attaching environment becomes the session's source of truth for future PTY children. vev preserves it except that it replaces `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`; `SHELL` selects the command used to start each PTY. Already-running processes keep their original environment.
10+
11+For remote attaches, the remote proxy substitutes the remote host's exported environment, so remote PTYs do not inherit the local client's environment.
12+
7 ## VT compatibility13 ## VT compatibility
8 14
9 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.15 vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, cursor and mode-query reports, alternate screen, bracketed paste, mouse tracking, synchronized updates, OSC 9/777 notifications, OSC 52 clipboard, and double-width cells. Resizes repair double-width cells so CJK and emoji halves are never left behind.
M internal/app/daemon_integration_test.go +65 -1
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "io"
99 "log/slog"
10+ "os"
1011 "path/filepath"
1112 "strings"
1213 "testing"
@@ -73,10 +74,15 @@ func recvPump(tr ports.Transport) *pump {
7374
7475 // attach dials, handshakes, and returns the transport plus its frame pump.
7576 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {
77+ t.Helper()
78+ return attachWithEnvironment(t, dir, intent, name, sz, nil)
79+}
80+
81+func attachWithEnvironment(t *testing.T, dir string, intent uint8, name string, sz domain.Size, env []string) (ports.Transport, *pump) {
7682 t.Helper()
7783 tr, err := ipc.DialContext(context.Background(), dir)
7884 require.NoError(t, err)
79- hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"}
85+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", TrueColor: true, Env: env}
8086 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}))
8187 p := recvPump(tr)
8288 select {
@@ -189,6 +195,64 @@ func screenText(s *vt.Screen) string {
189195 return b.String()
190196 }
191197
198+func shellFixture(t *testing.T, label string) string {
199+ t.Helper()
200+ path := filepath.Join(t.TempDir(), "shell")
201+ require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nprintf 'SHELL_COMMAND="+label+"\\n'\nexec /bin/sh\n"), 0o700))
202+ return path
203+}
204+
205+func assertChildEnvironment(t *testing.T, tr ports.Transport, p *pump, sz domain.Size, wantTestEnv, wantShell, wantRuntimeDir, wantWayland string) {
206+ t.Helper()
207+ command := "printf '\\033[2J\\033[H'; printf 'VEV_TEST_ENV=%s SHELL=%s XDG_RUNTIME_DIR=%s WAYLAND_DISPLAY=%s TERM=%s COLORTERM=%s TERM_PROGRAM=%s VEV_PREFIX=%.24s\\n' \"$VEV_TEST_ENV\" \"${SHELL##*/}\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$TERM\" \"$COLORTERM\" \"$TERM_PROGRAM\" \"$VEV\"\n"
208+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command)})}))
209+ text := awaitScreenText(t, p, sz, "TERM_PROGRAM=vev")
210+ for _, want := range []string{
211+ "VEV_TEST_ENV=" + wantTestEnv,
212+ "SHELL=" + filepath.Base(wantShell),
213+ "XDG_RUNTIME_DIR=" + wantRuntimeDir,
214+ "WAYLAND_DISPLAY=" + wantWayland,
215+ "TERM=xterm-direct",
216+ "COLORTERM=truecolor",
217+ "TERM_PROGRAM=vev",
218+ "VEV_PREFIX=session=environment,tab=",
219+ } {
220+ require.Contains(t, text, want)
221+ }
222+}
223+
224+func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) {
225+ sz := domain.Size{Cols: 80, Rows: 24}
226+ dir, _ := startDaemon(t)
227+ firstShell := shellFixture(t, "first")
228+ secondShell := shellFixture(t, "second")
229+ firstEnv := []string{
230+ "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
231+ "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
232+ }
233+
234+ tr1, p1 := attachWithEnvironment(t, dir, ports.IntentNew, "environment", sz, firstEnv)
235+ defer func() { _ = tr1.Close() }()
236+ awaitText(t, p1, sz, "SHELL_COMMAND=first")
237+ assertChildEnvironment(t, tr1, p1, sz, "first", firstShell, "/run/first", "wayland-first")
238+
239+ secondEnv := []string{
240+ "VEV_TEST_ENV=second", "SHELL=" + secondShell, "XDG_RUNTIME_DIR=/run/second", "WAYLAND_DISPLAY=wayland-second",
241+ "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
242+ }
243+ tr2, p2 := attachWithEnvironment(t, dir, ports.IntentAttach, "environment", sz, secondEnv)
244+ defer func() { _ = tr2.Close() }()
245+
246+ // The first shell was already running, so it retains its original environment.
247+ assertChildEnvironment(t, tr2, p2, sz, "first", firstShell, "/run/first", "wayland-first")
248+
249+ require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b ")})}))
250+ awaitText(t, p2, sz, "Commands")
251+ require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))
252+ awaitText(t, p2, sz, "SHELL_COMMAND=second")
253+ assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second")
254+}
255+
192256 func TestIntegration_AttachFirstOutput(t *testing.T) {
193257 sz := domain.Size{Cols: 80, Rows: 24}
194258 dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"}))
@@ -7,6 +7,7 @@ import (
7 "errors"7 "errors"
8 "io"8 "io"
9 "log/slog"9 "log/slog"
10+ "os"
10 "path/filepath"11 "path/filepath"
11 "strings"12 "strings"
12 "testing"13 "testing"
@@ -73,10 +74,15 @@ func recvPump(tr ports.Transport) *pump {
73 74
74 // attach dials, handshakes, and returns the transport plus its frame pump.75 // attach dials, handshakes, and returns the transport plus its frame pump.
75 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {76 func attach(t *testing.T, dir string, intent uint8, name string, sz domain.Size) (ports.Transport, *pump) {
77+ t.Helper()
78+ return attachWithEnvironment(t, dir, intent, name, sz, nil)
79+}
80+
81+func attachWithEnvironment(t *testing.T, dir string, intent uint8, name string, sz domain.Size, env []string) (ports.Transport, *pump) {
76 t.Helper()82 t.Helper()
77 tr, err := ipc.DialContext(context.Background(), dir)83 tr, err := ipc.DialContext(context.Background(), dir)
78 require.NoError(t, err)84 require.NoError(t, err)
79- hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color"}85+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", TrueColor: true, Env: env}
80 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}))86 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}))
81 p := recvPump(tr)87 p := recvPump(tr)
82 select {88 select {
@@ -189,6 +195,64 @@ func screenText(s *vt.Screen) string {
189 return b.String()195 return b.String()
190 }196 }
191 197
198+func shellFixture(t *testing.T, label string) string {
199+ t.Helper()
200+ path := filepath.Join(t.TempDir(), "shell")
201+ require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nprintf 'SHELL_COMMAND="+label+"\\n'\nexec /bin/sh\n"), 0o700))
202+ return path
203+}
204+
205+func assertChildEnvironment(t *testing.T, tr ports.Transport, p *pump, sz domain.Size, wantTestEnv, wantShell, wantRuntimeDir, wantWayland string) {
206+ t.Helper()
207+ command := "printf '\\033[2J\\033[H'; printf 'VEV_TEST_ENV=%s SHELL=%s XDG_RUNTIME_DIR=%s WAYLAND_DISPLAY=%s TERM=%s COLORTERM=%s TERM_PROGRAM=%s VEV_PREFIX=%.24s\\n' \"$VEV_TEST_ENV\" \"${SHELL##*/}\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$TERM\" \"$COLORTERM\" \"$TERM_PROGRAM\" \"$VEV\"\n"
208+ require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command)})}))
209+ text := awaitScreenText(t, p, sz, "TERM_PROGRAM=vev")
210+ for _, want := range []string{
211+ "VEV_TEST_ENV=" + wantTestEnv,
212+ "SHELL=" + filepath.Base(wantShell),
213+ "XDG_RUNTIME_DIR=" + wantRuntimeDir,
214+ "WAYLAND_DISPLAY=" + wantWayland,
215+ "TERM=xterm-direct",
216+ "COLORTERM=truecolor",
217+ "TERM_PROGRAM=vev",
218+ "VEV_PREFIX=session=environment,tab=",
219+ } {
220+ require.Contains(t, text, want)
221+ }
222+}
223+
224+func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) {
225+ sz := domain.Size{Cols: 80, Rows: 24}
226+ dir, _ := startDaemon(t)
227+ firstShell := shellFixture(t, "first")
228+ secondShell := shellFixture(t, "second")
229+ firstEnv := []string{
230+ "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
231+ "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
232+ }
233+
234+ tr1, p1 := attachWithEnvironment(t, dir, ports.IntentNew, "environment", sz, firstEnv)
235+ defer func() { _ = tr1.Close() }()
236+ awaitText(t, p1, sz, "SHELL_COMMAND=first")
237+ assertChildEnvironment(t, tr1, p1, sz, "first", firstShell, "/run/first", "wayland-first")
238+
239+ secondEnv := []string{
240+ "VEV_TEST_ENV=second", "SHELL=" + secondShell, "XDG_RUNTIME_DIR=/run/second", "WAYLAND_DISPLAY=wayland-second",
241+ "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
242+ }
243+ tr2, p2 := attachWithEnvironment(t, dir, ports.IntentAttach, "environment", sz, secondEnv)
244+ defer func() { _ = tr2.Close() }()
245+
246+ // The first shell was already running, so it retains its original environment.
247+ assertChildEnvironment(t, tr2, p2, sz, "first", firstShell, "/run/first", "wayland-first")
248+
249+ require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("\x1b ")})}))
250+ awaitText(t, p2, sz, "Commands")
251+ require.NoError(t, tr2.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})}))
252+ awaitText(t, p2, sz, "SHELL_COMMAND=second")
253+ assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second")
254+}
255+
192 func TestIntegration_AttachFirstOutput(t *testing.T) {256 func TestIntegration_AttachFirstOutput(t *testing.T) {
193 sz := domain.Size{Cols: 80, Rows: 24}257 sz := domain.Size{Cols: 80, Rows: 24}
194 dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"}))258 dir, _ := startDaemon(t, daemon.WithShell("/bin/sh", []string{"-c", "printf HELLO; sleep 30"}))
M internal/app/run.go +48 -10
@@ -663,6 +663,7 @@ func detachedLocalHello(name, cwd string) ports.Hello {
663663 TermEnv: termEnv,
664664 Cwd: cwd,
665665 TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),
666+ Env: os.Environ(),
666667 }
667668 }
668669
@@ -793,7 +794,7 @@ func runStdio(ctx context.Context) (retErr error) {
793794 }
794795 defer func() { _ = transport.Close() }()
795796 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
796- return proxyTransports(ctx, stdio, transport, log)
797+ return runStdioProxy(ctx, stdio, transport, os.Environ(), log)
797798 }
798799
799800 var (
@@ -934,11 +935,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
934935 return err
935936 }
936937
937- var dgramTr ports.Transport = dg
938- if session != "" {
939- dgramTr = preHelloNameTransport{Transport: dg, session: session}
940- }
941- return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
938+ return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
942939 }
943940
944941 const udpProxyIdleTTL = 15 * time.Minute
@@ -1029,27 +1026,68 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
10291026 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
10301027 }
10311028
1032-type preHelloNameTransport struct {
1029+// firstHelloTransport applies proxy-owned values to exactly the first Hello
1030+// crossing a remote boundary. It intentionally leaves all later frames and
1031+// malformed Hellos untouched so proxying preserves the client stream.
1032+type firstHelloTransport struct {
10331033 ports.Transport
1034+ mu sync.Mutex
1035+ seen bool
10341036 session string
1037+ env []string
10351038 }
10361039
1037-func (t preHelloNameTransport) Recv() (ports.Frame, error) {
1040+func newFirstHelloTransport(transport ports.Transport, session string, env []string) *firstHelloTransport {
1041+ return &firstHelloTransport{
1042+ Transport: transport,
1043+ session: session,
1044+ env: append([]string(nil), env...),
1045+ }
1046+}
1047+
1048+func (t *firstHelloTransport) Recv() (ports.Frame, error) {
10381049 f, err := t.Transport.Recv()
1039- if err != nil || f.Type != ports.MsgHello || t.session == "" {
1050+ if err != nil || f.Type != ports.MsgHello {
10401051 return f, err
10411052 }
1053+
1054+ t.mu.Lock()
1055+ defer t.mu.Unlock()
1056+ if t.seen {
1057+ return f, nil
1058+ }
1059+ t.seen = true
1060+
10421061 h, err := ports.UnmarshalHello(f.Payload)
10431062 if err != nil {
10441063 return f, nil
10451064 }
1065+ h.Env = append([]string(nil), t.env...)
10461066 if h.Name == "" {
10471067 h.Name = t.session
1048- f.Payload = ports.MarshalHello(h)
10491068 }
1069+ f.Payload = ports.MarshalHello(h)
10501070 return f, nil
10511071 }
10521072
1073+// runStdioProxy applies the remote host environment before the stdio stream
1074+// reaches the daemon. Its explicit environment input keeps this host boundary
1075+// independently testable.
1076+func runStdioProxy(ctx context.Context, client, daemon ports.Transport, env []string, log *slog.Logger) error {
1077+ return proxyTransports(ctx, newFirstHelloTransport(client, "", env), daemon, log)
1078+}
1079+
1080+// runUDPProxyRuntime applies the remote host environment and selected session
1081+// before the datagram stream reaches the daemon.
1082+func runUDPProxyRuntime(ctx context.Context, session string, client, daemon ports.Transport, env []string, log *slog.Logger) error {
1083+ return dgram.ProxyRuntime{
1084+ Client: newFirstHelloTransport(client, session, env),
1085+ Daemon: daemon,
1086+ Log: log,
1087+ IdleTTL: udpProxyIdleTTL,
1088+ }.Run(ctx)
1089+}
1090+
10531091 func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
10541092 return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx)
10551093 }
@@ -663,6 +663,7 @@ func detachedLocalHello(name, cwd string) ports.Hello {
663 TermEnv: termEnv,663 TermEnv: termEnv,
664 Cwd: cwd,664 Cwd: cwd,
665 TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),665 TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),
666+ Env: os.Environ(),
666 }667 }
667 }668 }
668 669
@@ -793,7 +794,7 @@ func runStdio(ctx context.Context) (retErr error) {
793 }794 }
794 defer func() { _ = transport.Close() }()795 defer func() { _ = transport.Close() }()
795 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))796 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
796- return proxyTransports(ctx, stdio, transport, log)797+ return runStdioProxy(ctx, stdio, transport, os.Environ(), log)
797 }798 }
798 799
799 var (800 var (
@@ -934,11 +935,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
934 return err935 return err
935 }936 }
936 937
937- var dgramTr ports.Transport = dg938+ return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log)
938- if session != "" {
939- dgramTr = preHelloNameTransport{Transport: dg, session: session}
940- }
941- return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
942 }939 }
943 940
944 const udpProxyIdleTTL = 15 * time.Minute941 const udpProxyIdleTTL = 15 * time.Minute
@@ -1029,27 +1026,68 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
1029 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)1026 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
1030 }1027 }
1031 1028
1032-type preHelloNameTransport struct {1029+// firstHelloTransport applies proxy-owned values to exactly the first Hello
1030+// crossing a remote boundary. It intentionally leaves all later frames and
1031+// malformed Hellos untouched so proxying preserves the client stream.
1032+type firstHelloTransport struct {
1033 ports.Transport1033 ports.Transport
1034+ mu sync.Mutex
1035+ seen bool
1034 session string1036 session string
1037+ env []string
1035 }1038 }
1036 1039
1037-func (t preHelloNameTransport) Recv() (ports.Frame, error) {1040+func newFirstHelloTransport(transport ports.Transport, session string, env []string) *firstHelloTransport {
1041+ return &firstHelloTransport{
1042+ Transport: transport,
1043+ session: session,
1044+ env: append([]string(nil), env...),
1045+ }
1046+}
1047+
1048+func (t *firstHelloTransport) Recv() (ports.Frame, error) {
1038 f, err := t.Transport.Recv()1049 f, err := t.Transport.Recv()
1039- if err != nil || f.Type != ports.MsgHello || t.session == "" {1050+ if err != nil || f.Type != ports.MsgHello {
1040 return f, err1051 return f, err
1041 }1052 }
1053+
1054+ t.mu.Lock()
1055+ defer t.mu.Unlock()
1056+ if t.seen {
1057+ return f, nil
1058+ }
1059+ t.seen = true
1060+
1042 h, err := ports.UnmarshalHello(f.Payload)1061 h, err := ports.UnmarshalHello(f.Payload)
1043 if err != nil {1062 if err != nil {
1044 return f, nil1063 return f, nil
1045 }1064 }
1065+ h.Env = append([]string(nil), t.env...)
1046 if h.Name == "" {1066 if h.Name == "" {
1047 h.Name = t.session1067 h.Name = t.session
1048- f.Payload = ports.MarshalHello(h)
1049 }1068 }
1069+ f.Payload = ports.MarshalHello(h)
1050 return f, nil1070 return f, nil
1051 }1071 }
1052 1072
1073+// runStdioProxy applies the remote host environment before the stdio stream
1074+// reaches the daemon. Its explicit environment input keeps this host boundary
1075+// independently testable.
1076+func runStdioProxy(ctx context.Context, client, daemon ports.Transport, env []string, log *slog.Logger) error {
1077+ return proxyTransports(ctx, newFirstHelloTransport(client, "", env), daemon, log)
1078+}
1079+
1080+// runUDPProxyRuntime applies the remote host environment and selected session
1081+// before the datagram stream reaches the daemon.
1082+func runUDPProxyRuntime(ctx context.Context, session string, client, daemon ports.Transport, env []string, log *slog.Logger) error {
1083+ return dgram.ProxyRuntime{
1084+ Client: newFirstHelloTransport(client, session, env),
1085+ Daemon: daemon,
1086+ Log: log,
1087+ IdleTTL: udpProxyIdleTTL,
1088+ }.Run(ctx)
1089+}
1090+
1053 func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {1091 func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error {
1054 return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx)1092 return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx)
1055 }1093 }
M internal/app/run_test.go +137 -0
@@ -24,6 +24,142 @@ import (
2424 "github.com/stretchr/testify/require"
2525 )
2626
27+// scriptRecv makes a transport yield frames in order, then wait for done
28+// before returning EOF. The shared done channel coordinates proxy readers
29+// without relying on scheduling or sleeps.
30+func scriptRecv(tr *portsmocks.MockTransport, done <-chan struct{}, frames ...ports.Frame) *portsmocks.MockTransport_Recv_Call {
31+ script := make(chan ports.Frame, len(frames))
32+ for _, frame := range frames {
33+ script <- frame
34+ }
35+ return tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
36+ select {
37+ case frame := <-script:
38+ return frame, nil
39+ case <-done:
40+ return ports.Frame{}, io.EOF
41+ }
42+ })
43+}
44+
45+func TestFirstHelloTransportReplacesRemoteEnvironment(t *testing.T) {
46+ for _, tt := range []struct {
47+ name string
48+ session string
49+ wantName string
50+ }{
51+ {name: "stdio replaces environment", wantName: ""},
52+ {name: "udp proxy replaces environment and retains session rewrite", session: "work", wantName: "work"},
53+ } {
54+ t.Run(tt.name, func(t *testing.T) {
55+ original := ports.Hello{
56+ Version: ports.ProtocolVersion,
57+ Intent: ports.IntentAttach,
58+ ClientID: [16]byte{1},
59+ ResumeToken: 2,
60+ Size: domain.Size{Cols: 80, Rows: 24},
61+ TermEnv: "client-term",
62+ Cwd: "/client",
63+ TrueColor: true,
64+ MaxOutputInFlight: 8,
65+ Env: []string{"CLIENT=value"},
66+ }
67+ nonHello := ports.Frame{Type: ports.MsgInput, Payload: []byte("unchanged")}
68+ transport := portsmocks.NewMockTransport(t)
69+ done := make(chan struct{})
70+ scriptRecv(transport, done,
71+ ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(original)},
72+ nonHello,
73+ ).Times(2)
74+ wrapped := newFirstHelloTransport(transport, tt.session, []string{"REMOTE=one", "TOKEN=a=b=c"})
75+
76+ frame, err := wrapped.Recv()
77+ require.NoError(t, err)
78+ got, err := ports.UnmarshalHello(frame.Payload)
79+ require.NoError(t, err)
80+ original.Name = tt.wantName
81+ original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
82+ require.Equal(t, original, got)
83+
84+ frame, err = wrapped.Recv()
85+ require.NoError(t, err)
86+ require.Equal(t, nonHello, frame)
87+ })
88+ }
89+}
90+
91+func TestRunStdioProxyReplacesHelloEnvironmentAtDaemonBoundary(t *testing.T) {
92+ original := remoteProxyHello()
93+ client, daemon, sent := newRemoteProxyTransports(t, original)
94+
95+ err := runStdioProxy(context.Background(), client, daemon, []string{"REMOTE=one", "TOKEN=a=b=c"}, nil)
96+ require.NoError(t, err)
97+
98+ got := firstHello(t, sent)
99+ original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
100+ require.Equal(t, original, got)
101+}
102+
103+func TestRunUDPProxyRuntimeReplacesHelloEnvironmentAndSessionAtDaemonBoundary(t *testing.T) {
104+ original := remoteProxyHello()
105+ client, daemon, sent := newRemoteProxyTransports(t, original)
106+
107+ err := runUDPProxyRuntime(context.Background(), "remote-work", client, daemon, []string{"REMOTE=udp", "TOKEN=a=b=c"}, nil)
108+ require.NoError(t, err)
109+
110+ got := firstHello(t, sent)
111+ original.Name = "remote-work"
112+ original.Env = []string{"REMOTE=udp", "TOKEN=a=b=c"}
113+ require.Equal(t, original, got)
114+}
115+
116+func remoteProxyHello() ports.Hello {
117+ return ports.Hello{
118+ Version: ports.ProtocolVersion,
119+ Intent: ports.IntentAttach,
120+ ClientID: [16]byte{1},
121+ ResumeToken: 2,
122+ Size: domain.Size{Cols: 80, Rows: 24},
123+ TermEnv: "client-term",
124+ Cwd: "/client",
125+ TrueColor: true,
126+ MaxOutputInFlight: 1,
127+ Env: []string{"CLIENT=value"},
128+ }
129+}
130+
131+func newRemoteProxyTransports(t *testing.T, hello ports.Hello) (*portsmocks.MockTransport, *portsmocks.MockTransport, <-chan ports.Frame) {
132+ t.Helper()
133+ client := portsmocks.NewMockTransport(t)
134+ daemon := portsmocks.NewMockTransport(t)
135+ clientDone := make(chan struct{})
136+ daemonDone := make(chan struct{})
137+ sent := make(chan ports.Frame, 1)
138+
139+ scriptRecv(client, clientDone, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}).Maybe()
140+ client.EXPECT().Close().RunAndReturn(func() error {
141+ close(clientDone)
142+ return nil
143+ }).Once()
144+ daemon.EXPECT().Send(mock.Anything).RunAndReturn(func(frame ports.Frame) error {
145+ sent <- frame
146+ close(daemonDone)
147+ return nil
148+ }).Once()
149+ scriptRecv(daemon, daemonDone).Once()
150+ daemon.EXPECT().Close().Return(nil).Once()
151+
152+ return client, daemon, sent
153+}
154+
155+func firstHello(t *testing.T, sent <-chan ports.Frame) ports.Hello {
156+ t.Helper()
157+ frame := <-sent
158+ hello, err := ports.UnmarshalHello(frame.Payload)
159+ require.NoError(t, err)
160+ return hello
161+}
162+
27163 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
28164 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
29165 }
@@ -443,6 +579,7 @@ func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
443579 require.Equal(t, "xterm-direct", hello.TermEnv)
444580 require.Equal(t, "/tmp/work", hello.Cwd)
445581 require.True(t, hello.TrueColor)
582+ require.Equal(t, os.Environ(), hello.Env)
446583 }
447584
448585 type namedDialer struct{ name string }
@@ -24,6 +24,142 @@ import (
24 "github.com/stretchr/testify/require"24 "github.com/stretchr/testify/require"
25 )25 )
26 26
27+// scriptRecv makes a transport yield frames in order, then wait for done
28+// before returning EOF. The shared done channel coordinates proxy readers
29+// without relying on scheduling or sleeps.
30+func scriptRecv(tr *portsmocks.MockTransport, done <-chan struct{}, frames ...ports.Frame) *portsmocks.MockTransport_Recv_Call {
31+ script := make(chan ports.Frame, len(frames))
32+ for _, frame := range frames {
33+ script <- frame
34+ }
35+ return tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
36+ select {
37+ case frame := <-script:
38+ return frame, nil
39+ case <-done:
40+ return ports.Frame{}, io.EOF
41+ }
42+ })
43+}
44+
45+func TestFirstHelloTransportReplacesRemoteEnvironment(t *testing.T) {
46+ for _, tt := range []struct {
47+ name string
48+ session string
49+ wantName string
50+ }{
51+ {name: "stdio replaces environment", wantName: ""},
52+ {name: "udp proxy replaces environment and retains session rewrite", session: "work", wantName: "work"},
53+ } {
54+ t.Run(tt.name, func(t *testing.T) {
55+ original := ports.Hello{
56+ Version: ports.ProtocolVersion,
57+ Intent: ports.IntentAttach,
58+ ClientID: [16]byte{1},
59+ ResumeToken: 2,
60+ Size: domain.Size{Cols: 80, Rows: 24},
61+ TermEnv: "client-term",
62+ Cwd: "/client",
63+ TrueColor: true,
64+ MaxOutputInFlight: 8,
65+ Env: []string{"CLIENT=value"},
66+ }
67+ nonHello := ports.Frame{Type: ports.MsgInput, Payload: []byte("unchanged")}
68+ transport := portsmocks.NewMockTransport(t)
69+ done := make(chan struct{})
70+ scriptRecv(transport, done,
71+ ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(original)},
72+ nonHello,
73+ ).Times(2)
74+ wrapped := newFirstHelloTransport(transport, tt.session, []string{"REMOTE=one", "TOKEN=a=b=c"})
75+
76+ frame, err := wrapped.Recv()
77+ require.NoError(t, err)
78+ got, err := ports.UnmarshalHello(frame.Payload)
79+ require.NoError(t, err)
80+ original.Name = tt.wantName
81+ original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
82+ require.Equal(t, original, got)
83+
84+ frame, err = wrapped.Recv()
85+ require.NoError(t, err)
86+ require.Equal(t, nonHello, frame)
87+ })
88+ }
89+}
90+
91+func TestRunStdioProxyReplacesHelloEnvironmentAtDaemonBoundary(t *testing.T) {
92+ original := remoteProxyHello()
93+ client, daemon, sent := newRemoteProxyTransports(t, original)
94+
95+ err := runStdioProxy(context.Background(), client, daemon, []string{"REMOTE=one", "TOKEN=a=b=c"}, nil)
96+ require.NoError(t, err)
97+
98+ got := firstHello(t, sent)
99+ original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
100+ require.Equal(t, original, got)
101+}
102+
103+func TestRunUDPProxyRuntimeReplacesHelloEnvironmentAndSessionAtDaemonBoundary(t *testing.T) {
104+ original := remoteProxyHello()
105+ client, daemon, sent := newRemoteProxyTransports(t, original)
106+
107+ err := runUDPProxyRuntime(context.Background(), "remote-work", client, daemon, []string{"REMOTE=udp", "TOKEN=a=b=c"}, nil)
108+ require.NoError(t, err)
109+
110+ got := firstHello(t, sent)
111+ original.Name = "remote-work"
112+ original.Env = []string{"REMOTE=udp", "TOKEN=a=b=c"}
113+ require.Equal(t, original, got)
114+}
115+
116+func remoteProxyHello() ports.Hello {
117+ return ports.Hello{
118+ Version: ports.ProtocolVersion,
119+ Intent: ports.IntentAttach,
120+ ClientID: [16]byte{1},
121+ ResumeToken: 2,
122+ Size: domain.Size{Cols: 80, Rows: 24},
123+ TermEnv: "client-term",
124+ Cwd: "/client",
125+ TrueColor: true,
126+ MaxOutputInFlight: 1,
127+ Env: []string{"CLIENT=value"},
128+ }
129+}
130+
131+func newRemoteProxyTransports(t *testing.T, hello ports.Hello) (*portsmocks.MockTransport, *portsmocks.MockTransport, <-chan ports.Frame) {
132+ t.Helper()
133+ client := portsmocks.NewMockTransport(t)
134+ daemon := portsmocks.NewMockTransport(t)
135+ clientDone := make(chan struct{})
136+ daemonDone := make(chan struct{})
137+ sent := make(chan ports.Frame, 1)
138+
139+ scriptRecv(client, clientDone, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}).Maybe()
140+ client.EXPECT().Close().RunAndReturn(func() error {
141+ close(clientDone)
142+ return nil
143+ }).Once()
144+ daemon.EXPECT().Send(mock.Anything).RunAndReturn(func(frame ports.Frame) error {
145+ sent <- frame
146+ close(daemonDone)
147+ return nil
148+ }).Once()
149+ scriptRecv(daemon, daemonDone).Once()
150+ daemon.EXPECT().Close().Return(nil).Once()
151+
152+ return client, daemon, sent
153+}
154+
155+func firstHello(t *testing.T, sent <-chan ports.Frame) ports.Hello {
156+ t.Helper()
157+ frame := <-sent
158+ hello, err := ports.UnmarshalHello(frame.Payload)
159+ require.NoError(t, err)
160+ return hello
161+}
162+
27 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {163 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
28 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)164 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
29 }165 }
@@ -443,6 +579,7 @@ func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
443 require.Equal(t, "xterm-direct", hello.TermEnv)579 require.Equal(t, "xterm-direct", hello.TermEnv)
444 require.Equal(t, "/tmp/work", hello.Cwd)580 require.Equal(t, "/tmp/work", hello.Cwd)
445 require.True(t, hello.TrueColor)581 require.True(t, hello.TrueColor)
582+ require.Equal(t, os.Environ(), hello.Env)
446 }583 }
447 584
448 type namedDialer struct{ name string }585 type namedDialer struct{ name string }
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 14
4+const ProtocolVersion uint16 = 15
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 144+const ProtocolVersion uint16 = 15
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 14 {
7- t.Fatalf("ProtocolVersion = %d, want 14", ProtocolVersion)
6+ if ProtocolVersion != 15 {
7+ t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 14 {6+ if ProtocolVersion != 15 {
7- t.Fatalf("ProtocolVersion = %d, want 14", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/wire.go +44 -0
@@ -69,6 +69,9 @@ type Hello struct {
6969 // MaxOutputInFlight is the requested maximum number of unacknowledged
7070 // state-bearing output frames.
7171 MaxOutputInFlight uint8
72+ // Env is the complete exported client environment for future PTY children.
73+ // Entries are opaque strings so their ordering and contents are preserved.
74+ Env []string
7275 }
7376
7477 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -207,6 +210,11 @@ func (w *payloadWriter) putString(s string) {
207210 w.b = append(w.b, s...)
208211 }
209212
213+func (w *payloadWriter) putLongString(s string) {
214+ w.putUint32(uint32(len(s)))
215+ w.b = append(w.b, s...)
216+}
217+
210218 // payloadReader consumes a message payload field by field in wire order,
211219 // erroring instead of panicking on any short read.
212220 type payloadReader struct {
@@ -271,6 +279,19 @@ func (r *payloadReader) getString() (string, error) {
271279 return s, nil
272280 }
273281
282+func (r *payloadReader) getLongString() (string, error) {
283+ n, err := r.getUint32()
284+ if err != nil {
285+ return "", err
286+ }
287+ if uint64(n) > uint64(len(r.b)) {
288+ return "", errShortPayload
289+ }
290+ s := string(r.b[:int(n)])
291+ r.b = r.b[int(n):]
292+ return s, nil
293+}
294+
274295 // rest consumes and returns all remaining bytes, copied so the result is
275296 // independent of the reader's backing array.
276297 func (r *payloadReader) rest() []byte {
@@ -313,6 +334,10 @@ func MarshalHello(h Hello) []byte {
313334 w.putUint8(0)
314335 }
315336 w.putUint8(h.MaxOutputInFlight)
337+ w.putUint32(uint32(len(h.Env)))
338+ for _, entry := range h.Env {
339+ w.putLongString(entry)
340+ }
316341 return w.b
317342 }
318343
@@ -362,6 +387,25 @@ func UnmarshalHello(b []byte) (Hello, error) {
362387 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {
363388 return Hello{}, err
364389 }
390+ envCount, err := r.getUint32()
391+ if err != nil {
392+ return Hello{}, err
393+ }
394+ // Each entry has at least its uint32 byte length. Check that before
395+ // allocating so a malformed count cannot force an excessive allocation.
396+ if uint64(envCount) > uint64(len(r.b)/4) {
397+ return Hello{}, errShortPayload
398+ }
399+ if envCount != 0 {
400+ h.Env = make([]string, 0, int(envCount))
401+ for range int(envCount) {
402+ entry, err := r.getLongString()
403+ if err != nil {
404+ return Hello{}, err
405+ }
406+ h.Env = append(h.Env, entry)
407+ }
408+ }
365409 if err := r.done(); err != nil {
366410 return Hello{}, err
367411 }
@@ -69,6 +69,9 @@ type Hello struct {
69 // MaxOutputInFlight is the requested maximum number of unacknowledged69 // MaxOutputInFlight is the requested maximum number of unacknowledged
70 // state-bearing output frames.70 // state-bearing output frames.
71 MaxOutputInFlight uint871 MaxOutputInFlight uint8
72+ // Env is the complete exported client environment for future PTY children.
73+ // Entries are opaque strings so their ordering and contents are preserved.
74+ Env []string
72 }75 }
73 76
74 // Input carries raw bytes typed/pasted by the client, destined for the PTY.77 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -207,6 +210,11 @@ func (w *payloadWriter) putString(s string) {
207 w.b = append(w.b, s...)210 w.b = append(w.b, s...)
208 }211 }
209 212
213+func (w *payloadWriter) putLongString(s string) {
214+ w.putUint32(uint32(len(s)))
215+ w.b = append(w.b, s...)
216+}
217+
210 // payloadReader consumes a message payload field by field in wire order,218 // payloadReader consumes a message payload field by field in wire order,
211 // erroring instead of panicking on any short read.219 // erroring instead of panicking on any short read.
212 type payloadReader struct {220 type payloadReader struct {
@@ -271,6 +279,19 @@ func (r *payloadReader) getString() (string, error) {
271 return s, nil279 return s, nil
272 }280 }
273 281
282+func (r *payloadReader) getLongString() (string, error) {
283+ n, err := r.getUint32()
284+ if err != nil {
285+ return "", err
286+ }
287+ if uint64(n) > uint64(len(r.b)) {
288+ return "", errShortPayload
289+ }
290+ s := string(r.b[:int(n)])
291+ r.b = r.b[int(n):]
292+ return s, nil
293+}
294+
274 // rest consumes and returns all remaining bytes, copied so the result is295 // rest consumes and returns all remaining bytes, copied so the result is
275 // independent of the reader's backing array.296 // independent of the reader's backing array.
276 func (r *payloadReader) rest() []byte {297 func (r *payloadReader) rest() []byte {
@@ -313,6 +334,10 @@ func MarshalHello(h Hello) []byte {
313 w.putUint8(0)334 w.putUint8(0)
314 }335 }
315 w.putUint8(h.MaxOutputInFlight)336 w.putUint8(h.MaxOutputInFlight)
337+ w.putUint32(uint32(len(h.Env)))
338+ for _, entry := range h.Env {
339+ w.putLongString(entry)
340+ }
316 return w.b341 return w.b
317 }342 }
318 343
@@ -362,6 +387,25 @@ func UnmarshalHello(b []byte) (Hello, error) {
362 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {387 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {
363 return Hello{}, err388 return Hello{}, err
364 }389 }
390+ envCount, err := r.getUint32()
391+ if err != nil {
392+ return Hello{}, err
393+ }
394+ // Each entry has at least its uint32 byte length. Check that before
395+ // allocating so a malformed count cannot force an excessive allocation.
396+ if uint64(envCount) > uint64(len(r.b)/4) {
397+ return Hello{}, errShortPayload
398+ }
399+ if envCount != 0 {
400+ h.Env = make([]string, 0, int(envCount))
401+ for range int(envCount) {
402+ entry, err := r.getLongString()
403+ if err != nil {
404+ return Hello{}, err
405+ }
406+ h.Env = append(h.Env, entry)
407+ }
408+ }
365 if err := r.done(); err != nil {409 if err := r.done(); err != nil {
366 return Hello{}, err410 return Hello{}, err
367 }411 }
M internal/ports/wire_test.go +70 -5
@@ -72,7 +72,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7272 TrueColor: true,
7373 MaxOutputInFlight: 8,
7474 },
75- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08},
75+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00},
7676 },
7777 {
7878 name: "empty strings",
@@ -85,7 +85,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8585 Cwd: "",
8686 TrueColor: false,
8787 },
88- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
88+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8989 },
9090 }
9191
@@ -114,6 +114,71 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
114114 assertTrailingGarbageFails(t, full, UnmarshalHello)
115115 }
116116
117+func TestHelloEnvironmentCodec(t *testing.T) {
118+ t.Run("literal wire layout", func(t *testing.T) {
119+ got := MarshalHello(Hello{
120+ Version: ProtocolVersion,
121+ Env: []string{"A=B", "XY=123"},
122+ })
123+ want := []byte{
124+ 0x00, 0x0f, 0x00, // version, intent
125+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
128+ 0x00, 0x00, // name
129+ 0x00, 0x00, 0x00, 0x00, // size
130+ 0x00, 0x00, // TERM
131+ 0x00, 0x00, // cwd
132+ 0x00, 0x00, // true color, max output in flight
133+ 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
134+ 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
135+ 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
136+ }
137+ require.Equal(t, want, got)
138+ assertAllPrefixesFail(t, got, UnmarshalHello)
139+ assertTrailingGarbageFails(t, got, UnmarshalHello)
140+ })
141+
142+ t.Run("lossless order and values", func(t *testing.T) {
143+ entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
144+ want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
145+ payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want})
146+ got, err := UnmarshalHello(payload)
147+ require.NoError(t, err)
148+ require.Equal(t, want, got.Env)
149+ })
150+
151+ t.Run("empty", func(t *testing.T) {
152+ payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}})
153+ got, err := UnmarshalHello(payload)
154+ require.NoError(t, err)
155+ require.Empty(t, got.Env)
156+ })
157+
158+ base := MarshalHello(Hello{Version: ProtocolVersion})
159+ withCount := func(count byte) []byte {
160+ payload := append([]byte(nil), base...)
161+ payload[len(payload)-1] = count
162+ return payload
163+ }
164+ tests := []struct {
165+ name string
166+ payload []byte
167+ }{
168+ {name: "truncated count", payload: base[:len(base)-1]},
169+ {name: "truncated entry length", payload: append(withCount(1), 0, 0)},
170+ {name: "truncated entry body", payload: append(withCount(1), 0, 0, 0, 3, 'x')},
171+ {name: "impossible count", payload: withCount(2)},
172+ {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
173+ }
174+ for _, tt := range tests {
175+ t.Run(tt.name, func(t *testing.T) {
176+ _, err := UnmarshalHello(tt.payload)
177+ require.Error(t, err)
178+ })
179+ }
180+}
181+
117182 func TestInputGoldenAndRoundTrip(t *testing.T) {
118183 tests := []struct {
119184 name string
@@ -574,9 +639,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
574639 for _, window := range []uint8{0, 1, 8} {
575640 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
576641 hello := Hello{Version: 14, MaxOutputInFlight: window}
577- // Every preceding field in this deliberately empty Hello has a fixed,
578- // byte-exact zero encoding; the final byte is the negotiated window.
579- want := append(make([]byte, 38), window)
642+ // The empty Hello has 38 zero bytes before the negotiated output window,
643+ // followed by a uint32 zero environment-entry count.
644+ want := append(make([]byte, 38), window, 0, 0, 0, 0)
580645 want[1] = 14
581646 got := MarshalHello(hello)
582647 requireBytesEqual(t, want, got)
@@ -72,7 +72,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
72 TrueColor: true,72 TrueColor: true,
73 MaxOutputInFlight: 8,73 MaxOutputInFlight: 8,
74 },74 },
75- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08},75+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00},
76 },76 },
77 {77 {
78 name: "empty strings",78 name: "empty strings",
@@ -85,7 +85,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
85 Cwd: "",85 Cwd: "",
86 TrueColor: false,86 TrueColor: false,
87 },87 },
88- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},88+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
89 },89 },
90 }90 }
91 91
@@ -114,6 +114,71 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
114 assertTrailingGarbageFails(t, full, UnmarshalHello)114 assertTrailingGarbageFails(t, full, UnmarshalHello)
115 }115 }
116 116
117+func TestHelloEnvironmentCodec(t *testing.T) {
118+ t.Run("literal wire layout", func(t *testing.T) {
119+ got := MarshalHello(Hello{
120+ Version: ProtocolVersion,
121+ Env: []string{"A=B", "XY=123"},
122+ })
123+ want := []byte{
124+ 0x00, 0x0f, 0x00, // version, intent
125+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
128+ 0x00, 0x00, // name
129+ 0x00, 0x00, 0x00, 0x00, // size
130+ 0x00, 0x00, // TERM
131+ 0x00, 0x00, // cwd
132+ 0x00, 0x00, // true color, max output in flight
133+ 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
134+ 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
135+ 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
136+ }
137+ require.Equal(t, want, got)
138+ assertAllPrefixesFail(t, got, UnmarshalHello)
139+ assertTrailingGarbageFails(t, got, UnmarshalHello)
140+ })
141+
142+ t.Run("lossless order and values", func(t *testing.T) {
143+ entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
144+ want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
145+ payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want})
146+ got, err := UnmarshalHello(payload)
147+ require.NoError(t, err)
148+ require.Equal(t, want, got.Env)
149+ })
150+
151+ t.Run("empty", func(t *testing.T) {
152+ payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}})
153+ got, err := UnmarshalHello(payload)
154+ require.NoError(t, err)
155+ require.Empty(t, got.Env)
156+ })
157+
158+ base := MarshalHello(Hello{Version: ProtocolVersion})
159+ withCount := func(count byte) []byte {
160+ payload := append([]byte(nil), base...)
161+ payload[len(payload)-1] = count
162+ return payload
163+ }
164+ tests := []struct {
165+ name string
166+ payload []byte
167+ }{
168+ {name: "truncated count", payload: base[:len(base)-1]},
169+ {name: "truncated entry length", payload: append(withCount(1), 0, 0)},
170+ {name: "truncated entry body", payload: append(withCount(1), 0, 0, 0, 3, 'x')},
171+ {name: "impossible count", payload: withCount(2)},
172+ {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
173+ }
174+ for _, tt := range tests {
175+ t.Run(tt.name, func(t *testing.T) {
176+ _, err := UnmarshalHello(tt.payload)
177+ require.Error(t, err)
178+ })
179+ }
180+}
181+
117 func TestInputGoldenAndRoundTrip(t *testing.T) {182 func TestInputGoldenAndRoundTrip(t *testing.T) {
118 tests := []struct {183 tests := []struct {
119 name string184 name string
@@ -574,9 +639,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
574 for _, window := range []uint8{0, 1, 8} {639 for _, window := range []uint8{0, 1, 8} {
575 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {640 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
576 hello := Hello{Version: 14, MaxOutputInFlight: window}641 hello := Hello{Version: 14, MaxOutputInFlight: window}
577- // Every preceding field in this deliberately empty Hello has a fixed,642+ // The empty Hello has 38 zero bytes before the negotiated output window,
578- // byte-exact zero encoding; the final byte is the negotiated window.643+ // followed by a uint32 zero environment-entry count.
579- want := append(make([]byte, 38), window)644+ want := append(make([]byte, 38), window, 0, 0, 0, 0)
580 want[1] = 14645 want[1] = 14
581 got := MarshalHello(hello)646 got := MarshalHello(hello)
582 requireBytesEqual(t, want, got)647 requireBytesEqual(t, want, got)
M internal/usecase/client/client.go +1 -0
@@ -525,6 +525,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
525525 Cwd: cwd,
526526 TrueColor: trueColor,
527527 MaxOutputInFlight: requestedOutputWindow(transport),
528+ Env: os.Environ(),
528529 }
529530 if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
530531 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
@@ -525,6 +525,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
525 Cwd: cwd,525 Cwd: cwd,
526 TrueColor: trueColor,526 TrueColor: trueColor,
527 MaxOutputInFlight: requestedOutputWindow(transport),527 MaxOutputInFlight: requestedOutputWindow(transport),
528+ Env: os.Environ(),
528 }529 }
529 if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {530 if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
530 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})531 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
M internal/usecase/client/client_test.go +31 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "log/slog"
9+ "os"
910 "sync"
1011 "sync/atomic"
1112 "testing"
@@ -333,6 +334,36 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
333334 }
334335 }
335336
337+func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
338+ t.Setenv("VEV_CLIENT_ENV_TEST", "TOKEN=a=b=c")
339+
340+ var out bytes.Buffer
341+ var restoreCount atomic.Int32
342+ resizeCh := make(chan domain.Size)
343+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
344+ defer in.unblock()
345+
346+ gotHello := make(chan ports.Hello, 1)
347+ tr := portsmocks.NewMockTransport(t)
348+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
349+ hello, err := ports.UnmarshalHello(f.Payload)
350+ if err != nil {
351+ return err
352+ }
353+ gotHello <- hello
354+ return nil
355+ }).Once()
356+ unblock := scriptRecv(tr,
357+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
358+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
359+ )
360+ defer unblock()
361+ tr.EXPECT().Close().Return(nil).Once()
362+
363+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
364+ require.Equal(t, os.Environ(), (<-gotHello).Env)
365+}
366+
336367 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
337368 var out bytes.Buffer
338369 var restoreCount atomic.Int32
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9+ "os"
9 "sync"10 "sync"
10 "sync/atomic"11 "sync/atomic"
11 "testing"12 "testing"
@@ -333,6 +334,36 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
333 }334 }
334 }335 }
335 336
337+func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
338+ t.Setenv("VEV_CLIENT_ENV_TEST", "TOKEN=a=b=c")
339+
340+ var out bytes.Buffer
341+ var restoreCount atomic.Int32
342+ resizeCh := make(chan domain.Size)
343+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
344+ defer in.unblock()
345+
346+ gotHello := make(chan ports.Hello, 1)
347+ tr := portsmocks.NewMockTransport(t)
348+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
349+ hello, err := ports.UnmarshalHello(f.Payload)
350+ if err != nil {
351+ return err
352+ }
353+ gotHello <- hello
354+ return nil
355+ }).Once()
356+ unblock := scriptRecv(tr,
357+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
358+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
359+ )
360+ defer unblock()
361+ tr.EXPECT().Close().Return(nil).Once()
362+
363+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
364+ require.Equal(t, os.Environ(), (<-gotHello).Env)
365+}
366+
336 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {367 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
337 var out bytes.Buffer368 var out bytes.Buffer
338 var restoreCount atomic.Int32369 var restoreCount atomic.Int32
M internal/usecase/daemon/daemon.go +12 -7
@@ -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
@@ -670,14 +672,17 @@ func (e *protoErr) Error() string { return e.text }
670672 // publishes the terminal state before releasing d.mu, then queues replacement
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 {
675+ // Session state is the sole source for future PTY children. Update it before
676+ // publishing the attachment; existing PTYs keep their original environment.
677+ sess.mu.Lock()
678+ sess.env = copyEnvironment(h.Env)
679+ sess.terminal = term
680+ sess.mu.Unlock()
673681 ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
674682 clientID: h.ClientID,
675683 resumeCapable: true,
676684 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
677685 })
678- sess.mu.Lock()
679- sess.terminal = term
680- sess.mu.Unlock()
681686 d.mu.Unlock()
682687 d.retireReplacedClient(old, cleanup)
683688 return ac
@@ -726,7 +731,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
726731 }
727732 cwd := d.dirOrHome(stopped.cwd)
728733 var err error
729- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
734+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
730735 if err != nil {
731736 d.mu.Unlock()
732737 return nil, nil, err
@@ -737,7 +742,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
737742
738743 case ports.IntentEphemeral:
739744 name := d.allocEphemeralNameLocked()
740- sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term)
745+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term, h.Env)
741746 if err != nil {
742747 d.mu.Unlock()
743748 return nil, nil, err
@@ -758,7 +763,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
758763 d.mu.Unlock()
759764 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
760765 }
761- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term)
766+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term, h.Env)
762767 if err != nil {
763768 d.mu.Unlock()
764769 return nil, nil, err
@@ -776,7 +781,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
776781 }
777782 cwd := d.dirOrHome(stopped.cwd)
778783 var err error
779- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
784+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
780785 if err != nil {
781786 d.mu.Unlock()
782787 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
@@ -670,14 +672,17 @@ func (e *protoErr) Error() string { return e.text }
670 // publishes the terminal state before releasing d.mu, then queues replacement672 // publishes the terminal state before releasing d.mu, then queues replacement
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 {
675+ // Session state is the sole source for future PTY children. Update it before
676+ // publishing the attachment; existing PTYs keep their original environment.
677+ sess.mu.Lock()
678+ sess.env = copyEnvironment(h.Env)
679+ sess.terminal = term
680+ sess.mu.Unlock()
673 ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{681 ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{
674 clientID: h.ClientID,682 clientID: h.ClientID,
675 resumeCapable: true,683 resumeCapable: true,
676 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),684 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
677 })685 })
678- sess.mu.Lock()
679- sess.terminal = term
680- sess.mu.Unlock()
681 d.mu.Unlock()686 d.mu.Unlock()
682 d.retireReplacedClient(old, cleanup)687 d.retireReplacedClient(old, cleanup)
683 return ac688 return ac
@@ -726,7 +731,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
726 }731 }
727 cwd := d.dirOrHome(stopped.cwd)732 cwd := d.dirOrHome(stopped.cwd)
728 var err error733 var err error
729- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)734+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
730 if err != nil {735 if err != nil {
731 d.mu.Unlock()736 d.mu.Unlock()
732 return nil, nil, err737 return nil, nil, err
@@ -737,7 +742,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
737 742
738 case ports.IntentEphemeral:743 case ports.IntentEphemeral:
739 name := d.allocEphemeralNameLocked()744 name := d.allocEphemeralNameLocked()
740- sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term)745+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term, h.Env)
741 if err != nil {746 if err != nil {
742 d.mu.Unlock()747 d.mu.Unlock()
743 return nil, nil, err748 return nil, nil, err
@@ -758,7 +763,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
758 d.mu.Unlock()763 d.mu.Unlock()
759 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}764 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
760 }765 }
761- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term)766+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term, h.Env)
762 if err != nil {767 if err != nil {
763 d.mu.Unlock()768 d.mu.Unlock()
764 return nil, nil, err769 return nil, nil, err
@@ -776,7 +781,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
776 }781 }
777 cwd := d.dirOrHome(stopped.cwd)782 cwd := d.dirOrHome(stopped.cwd)
778 var err error783 var err error
779- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)784+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
780 if err != nil {785 if err != nil {
781 d.mu.Unlock()786 d.mu.Unlock()
782 return nil, nil, err787 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 +5 -4
@@ -24,6 +24,7 @@ import (
2424 type gatedFloatingOpen struct {
2525 command string
2626 args []string
27+ env []string
2728 dir string
2829 size domain.Size
2930 }
@@ -37,8 +38,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
3738 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
3839 t.Cleanup(releaseOpen)
3940 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40- func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
41- opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}
41+ func(_ context.Context, command string, args, env []string, dir string, size domain.Size) (ports.PTY, error) {
42+ opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: size}
4243 <-release
4344 return result, openErr
4445 }).Once()
@@ -143,11 +144,10 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
143144 pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
144145 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
145146 d := newTestDaemon(t, factory, stubClock{})
146- d.shell = "/bin/custom-shell"
147147 cwd := t.TempDir()
148148 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
149149 tb.ctx, tb.cancel = context.WithCancel(t.Context())
150- sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()}
150+ sess := &session{name: "work", cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
151151 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
152152 d.ensureFloatingWarm(sess, tb)
153153 // Open has started while this goroutine owns tab.mu: an external factory
@@ -171,6 +171,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
171171 floating := tb.floating.pane
172172 tb.mu.Unlock()
173173 require.NotNil(t, floating)
174+ require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
174175 assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
175176 assertPaneColorScheme(t, floating, false)
176177 require.Equal(t, "/bin/custom-shell", openCall.command)
@@ -24,6 +24,7 @@ import (
24 type gatedFloatingOpen struct {24 type gatedFloatingOpen struct {
25 command string25 command string
26 args []string26 args []string
27+ env []string
27 dir string28 dir string
28 size domain.Size29 size domain.Size
29 }30 }
@@ -37,8 +38,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm
37 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }38 releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
38 t.Cleanup(releaseOpen)39 t.Cleanup(releaseOpen)
39 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(40 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40- func(_ context.Context, command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {41+ func(_ context.Context, command string, args, env []string, dir string, size domain.Size) (ports.PTY, error) {
41- opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}42+ opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), env: append([]string(nil), env...), dir: dir, size: size}
42 <-release43 <-release
43 return result, openErr44 return result, openErr
44 }).Once()45 }).Once()
@@ -143,11 +144,10 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
143 pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()144 pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
144 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)145 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
145 d := newTestDaemon(t, factory, stubClock{})146 d := newTestDaemon(t, factory, stubClock{})
146- d.shell = "/bin/custom-shell"
147 cwd := t.TempDir()147 cwd := t.TempDir()
148 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})148 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
149 tb.ctx, tb.cancel = context.WithCancel(t.Context())149 tb.ctx, tb.cancel = context.WithCancel(t.Context())
150- sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()}150+ sess := &session{name: "work", cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
151 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})151 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
152 d.ensureFloatingWarm(sess, tb)152 d.ensureFloatingWarm(sess, tb)
153 // Open has started while this goroutine owns tab.mu: an external factory153 // Open has started while this goroutine owns tab.mu: an external factory
@@ -171,6 +171,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
171 floating := tb.floating.pane171 floating := tb.floating.pane
172 tb.mu.Unlock()172 tb.mu.Unlock()
173 require.NotNil(t, floating)173 require.NotNil(t, floating)
174+ require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
174 assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)175 assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
175 assertPaneColorScheme(t, floating, false)176 assertPaneColorScheme(t, floating, false)
176 require.Equal(t, "/bin/custom-shell", openCall.command)177 require.Equal(t, "/bin/custom-shell", openCall.command)
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/pane_actions_test.go +24 -24
@@ -331,48 +331,48 @@ func blockingRead(t *testing.T) func([]byte) (int, error) {
331331 }
332332 }
333333
334-func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
335- tests := []struct {
336- name string
337- term terminalEnv
338- wantContain []string
339- wantNotContain []string
334+func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
335+ for _, tt := range []struct {
336+ name string
337+ term terminalEnv
338+ want []string
340339 }{
341340 {
342- name: "truecolor",
343- term: terminalEnv{TrueColor: true},
344- wantContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
341+ name: "truecolor",
342+ term: terminalEnv{TrueColor: true},
343+ want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
345344 },
346345 {
347- name: "no truecolor",
348- term: terminalEnv{},
349- wantContain: []string{"TERM=xterm-256color", "TERM_PROGRAM=vev"},
350- wantNotContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor"},
346+ name: "no truecolor",
347+ want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "TERM_PROGRAM=vev"},
351348 },
352- }
353-
354- for _, tt := range tests {
349+ } {
355350 t.Run(tt.name, func(t *testing.T) {
356351 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
352+ d.shellOverride = false
357353 sess.terminal = tt.term
354+ sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
358355 newPTY := portsmocks.NewMockPTY(t)
359356 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
360357 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
358+ var command string
361359 var gotEnv []string
362- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
363- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
360+ factory.EXPECT().Open(mock.Anything, mock.Anything, []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
361+ func(_ context.Context, gotCommand string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
362+ command = gotCommand
364363 gotEnv = append([]string(nil), env...)
365364 return newPTY, nil
366365 },
367366 ).Once()
368367
369368 require.NoError(t, d.splitPane(sess, nil, layout.Right))
370- for _, want := range tt.wantContain {
371- require.Contains(t, gotEnv, want)
372- }
373- for _, notWant := range tt.wantNotContain {
374- require.NotContains(t, gotEnv, notWant)
375- }
369+ tb := sess.activeTab()
370+ tb.mu.Lock()
371+ paneID := tb.panes["pane-2"].stableID
372+ tabID := tb.stableID
373+ tb.mu.Unlock()
374+ require.Equal(t, "/usr/bin/fish", command)
375+ require.Equal(t, append(tt.want, "VEV=session=s,tab="+tabID+",pane="+paneID), gotEnv)
376376 })
377377 }
378378 }
@@ -331,48 +331,48 @@ func blockingRead(t *testing.T) func([]byte) (int, error) {
331 }331 }
332 }332 }
333 333
334-func TestSplitPaneInheritsTerminalEnv(t *testing.T) {334+func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
335- tests := []struct {335+ for _, tt := range []struct {
336- name string336+ name string
337- term terminalEnv337+ term terminalEnv
338- wantContain []string338+ want []string
339- wantNotContain []string
340 }{339 }{
341 {340 {
342- name: "truecolor",341+ name: "truecolor",
343- term: terminalEnv{TrueColor: true},342+ term: terminalEnv{TrueColor: true},
344- wantContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},343+ want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
345 },344 },
346 {345 {
347- name: "no truecolor",346+ name: "no truecolor",
348- term: terminalEnv{},347+ want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "TERM_PROGRAM=vev"},
349- wantContain: []string{"TERM=xterm-256color", "TERM_PROGRAM=vev"},
350- wantNotContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor"},
351 },348 },
352- }349+ } {
353-
354- for _, tt := range tests {
355 t.Run(tt.name, func(t *testing.T) {350 t.Run(tt.name, func(t *testing.T) {
356 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})351 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
352+ d.shellOverride = false
357 sess.terminal = tt.term353 sess.terminal = tt.term
354+ sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
358 newPTY := portsmocks.NewMockPTY(t)355 newPTY := portsmocks.NewMockPTY(t)
359 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()356 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
360 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()357 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
358+ var command string
361 var gotEnv []string359 var gotEnv []string
362- factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(360+ factory.EXPECT().Open(mock.Anything, mock.Anything, []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
363- func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {361+ func(_ context.Context, gotCommand string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
362+ command = gotCommand
364 gotEnv = append([]string(nil), env...)363 gotEnv = append([]string(nil), env...)
365 return newPTY, nil364 return newPTY, nil
366 },365 },
367 ).Once()366 ).Once()
368 367
369 require.NoError(t, d.splitPane(sess, nil, layout.Right))368 require.NoError(t, d.splitPane(sess, nil, layout.Right))
370- for _, want := range tt.wantContain {369+ tb := sess.activeTab()
371- require.Contains(t, gotEnv, want)370+ tb.mu.Lock()
372- }371+ paneID := tb.panes["pane-2"].stableID
373- for _, notWant := range tt.wantNotContain {372+ tabID := tb.stableID
374- require.NotContains(t, gotEnv, notWant)373+ tb.mu.Unlock()
375- }374+ require.Equal(t, "/usr/bin/fish", command)
375+ require.Equal(t, append(tt.want, "VEV=session=s,tab="+tabID+",pane="+paneID), gotEnv)
376 })376 })
377 }377 }
378 }378 }
M internal/usecase/daemon/picker.go +4 -1
@@ -427,6 +427,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
427427 return nil
428428 }
429429 term := from.terminal
430+ env := copyEnvironment(from.env)
430431 from.client = nil
431432 ac.setSession(nil)
432433 from.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 := copyEnvironment(from.env)
471474 cwd := d.dirOrHome(stopped.cwd)
472- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, stopped.tabNames)
475+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
473476 if err != nil {
474477 from.mu.Unlock()
475478 d.mu.Unlock()
@@ -427,6 +427,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
427 return nil427 return nil
428 }428 }
429 term := from.terminal429 term := from.terminal
430+ env := copyEnvironment(from.env)
430 from.client = nil431 from.client = nil
431 ac.setSession(nil)432 ac.setSession(nil)
432 from.mu.Unlock()433 from.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 := copyEnvironment(from.env)
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.createSessionLocked(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 +12 -6
@@ -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 },
@@ -464,8 +466,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
464466 sctx2, cancel2 := context.WithCancel(d.serveCtx)
465467 defer cancel1()
466468 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{}}
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}}
469+ sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}}
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}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}}
469471 ac1.setSession(sess1)
470472 ac2.setSession(sess2)
471473 d.sessions[sess1.id] = sess1
@@ -476,9 +478,13 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
476478 require.Same(t, ac2, old)
477479 require.False(t, sess2.terminal.TrueColor)
478480 require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))
479- require.Contains(t, openedEnv, "TERM=xterm-256color")
480- require.NotContains(t, openedEnv, "COLORTERM=truecolor")
481- require.NotContains(t, openedEnv, "TERM=xterm-direct")
481+ sess2.mu.Lock()
482+ futureTabID := sess2.tabs[1].stableID
483+ futurePaneID := sess2.tabs[1].focusedPane().stableID
484+ sess2.mu.Unlock()
485+ require.Equal(t, "/usr/bin/fish", openedCommand)
486+ require.Equal(t, []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=beta,tab=" + futureTabID + ",pane=" + futurePaneID}, openedEnv)
487+ require.NotContains(t, openedEnv, "STALE=destination-only")
482488 }
483489
484490 func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {
@@ -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 },
@@ -464,8 +466,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
464 sctx2, cancel2 := context.WithCancel(d.serveCtx)466 sctx2, cancel2 := context.WithCancel(d.serveCtx)
465 defer cancel1()467 defer cancel1()
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{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}}
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}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}}
469 ac1.setSession(sess1)471 ac1.setSession(sess1)
470 ac2.setSession(sess2)472 ac2.setSession(sess2)
471 d.sessions[sess1.id] = sess1473 d.sessions[sess1.id] = sess1
@@ -476,9 +478,13 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
476 require.Same(t, ac2, old)478 require.Same(t, ac2, old)
477 require.False(t, sess2.terminal.TrueColor)479 require.False(t, sess2.terminal.TrueColor)
478 require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))480 require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))
479- require.Contains(t, openedEnv, "TERM=xterm-256color")481+ sess2.mu.Lock()
480- require.NotContains(t, openedEnv, "COLORTERM=truecolor")482+ futureTabID := sess2.tabs[1].stableID
481- require.NotContains(t, openedEnv, "TERM=xterm-direct")483+ futurePaneID := sess2.tabs[1].focusedPane().stableID
484+ sess2.mu.Unlock()
485+ require.Equal(t, "/usr/bin/fish", openedCommand)
486+ require.Equal(t, []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=beta,tab=" + futureTabID + ",pane=" + futurePaneID}, openedEnv)
487+ require.NotContains(t, openedEnv, "STALE=destination-only")
482 }488 }
483 489
484 func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {490 func TestPickerPreviewGenerationRejectsStaleSubscriptionReplacement(t *testing.T) {
M internal/usecase/daemon/resume.go +4 -1
@@ -184,9 +184,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
184184 ac.resumeToken = d.nextResumeTokenLocked()
185185 ac.parked = false
186186 ac.setSession(sess)
187+ // The resumed session's snapshot is the sole source for future PTY children.
188+ // Existing PTYs retain the environment they were started with.
187189 sess.mu.Lock()
188- sess.client = ac
190+ sess.env = copyEnvironment(h.Env)
189191 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
192+ sess.client = ac
190193 sess.mu.Unlock()
191194 d.attachCoordinator(sess, nil, ac, false)
192195 d.touchMRU(sess)
@@ -184,9 +184,12 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
184 ac.resumeToken = d.nextResumeTokenLocked()184 ac.resumeToken = d.nextResumeTokenLocked()
185 ac.parked = false185 ac.parked = false
186 ac.setSession(sess)186 ac.setSession(sess)
187+ // The resumed session's snapshot is the sole source for future PTY children.
188+ // Existing PTYs retain the environment they were started with.
187 sess.mu.Lock()189 sess.mu.Lock()
188- sess.client = ac190+ sess.env = copyEnvironment(h.Env)
189 sess.terminal = terminalEnv{TrueColor: h.TrueColor}191 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
192+ sess.client = ac
190 sess.mu.Unlock()193 sess.mu.Unlock()
191 d.attachCoordinator(sess, nil, ac, false)194 d.attachCoordinator(sess, nil, ac, false)
192 d.touchMRU(sess)195 d.touchMRU(sess)
M internal/usecase/daemon/resume_test.go +35 -6
@@ -1,16 +1,19 @@
11 package daemon
22
33 import (
4+ "context"
45 "errors"
56 "strings"
67 "sync"
78 "testing"
89 "time"
910
11+ "github.com/stretchr/testify/mock"
1012 "github.com/stretchr/testify/require"
1113
1214 "github.com/bnema/vev/internal/domain"
1315 "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1417 "github.com/bnema/vev/internal/usecase/layout"
1518 "github.com/bnema/vev/pkg/vt"
1619 )
@@ -678,13 +681,31 @@ func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) {
678681 require.Zero(t, ac.echoAck.Load())
679682 }
680683
681-func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
682- pty, release := newBlockingPTY(t)
683- defer release()
684- d := newTestDaemon(t, newFactory(t, pty), stubClock{})
684+func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
685+ initialPTY, releaseInitial := newBlockingPTY(t)
686+ futurePTY, releaseFuture := newBlockingPTY(t)
687+ defer releaseInitial()
688+ defer releaseFuture()
689+ var commands []string
690+ var envs [][]string
691+ factory := portsmocks.NewMockPTYFactory(t)
692+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
693+ func(_ context.Context, command string, _ []string, env []string, _ string, size domain.Size) (ports.PTY, error) {
694+ if size != (domain.Size{Cols: 80, Rows: 22}) {
695+ return newQuietPTY(), nil
696+ }
697+ commands = append(commands, command)
698+ envs = append(envs, append([]string(nil), env...))
699+ if len(commands) == 1 {
700+ return initialPTY, nil
701+ }
702+ return futurePTY, nil
703+ },
704+ ).Maybe()
705+ d := newTestDaemon(t, factory, stubClock{})
685706 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
686707 hello := helloResumeCapable(ports.IntentNew, "work", 0)
687- hello.TrueColor = false
708+ hello.Env = []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old"}
688709 sess, ac, err := d.route(hello, tr)
689710 require.NoError(t, err)
690711 token := ac.resumeToken
@@ -692,13 +713,21 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
692713 require.True(t, d.parkAttachment(sess, ac))
693714
694715 resumeHello := helloResumeCapable(ports.IntentResume, "work", token)
716+ resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
695717 resumeHello.TrueColor = true
696718 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
697719 require.NoError(t, err)
698720 require.True(t, ok)
721+ require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
722+
699723 sess.mu.Lock()
700- defer sess.mu.Unlock()
724+ future := sess.tabs[1].focusedPane()
725+ futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID
701726 require.True(t, sess.terminal.TrueColor)
727+ require.Equal(t, resumeHello.Env, sess.env)
728+ sess.mu.Unlock()
729+ require.Equal(t, []string{"/bin/sh", "/usr/bin/fish"}, commands)
730+ require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
702731 }
703732
704733 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
@@ -1,16 +1,19 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "strings"6 "strings"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 "time"9 "time"
9 10
11+ "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
11 13
12 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
14 "github.com/bnema/vev/internal/usecase/layout"17 "github.com/bnema/vev/internal/usecase/layout"
15 "github.com/bnema/vev/pkg/vt"18 "github.com/bnema/vev/pkg/vt"
16 )19 )
@@ -678,13 +681,31 @@ func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) {
678 require.Zero(t, ac.echoAck.Load())681 require.Zero(t, ac.echoAck.Load())
679 }682 }
680 683
681-func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {684+func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
682- pty, release := newBlockingPTY(t)685+ initialPTY, releaseInitial := newBlockingPTY(t)
683- defer release()686+ futurePTY, releaseFuture := newBlockingPTY(t)
684- d := newTestDaemon(t, newFactory(t, pty), stubClock{})687+ defer releaseInitial()
688+ defer releaseFuture()
689+ var commands []string
690+ var envs [][]string
691+ factory := portsmocks.NewMockPTYFactory(t)
692+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
693+ func(_ context.Context, command string, _ []string, env []string, _ string, size domain.Size) (ports.PTY, error) {
694+ if size != (domain.Size{Cols: 80, Rows: 22}) {
695+ return newQuietPTY(), nil
696+ }
697+ commands = append(commands, command)
698+ envs = append(envs, append([]string(nil), env...))
699+ if len(commands) == 1 {
700+ return initialPTY, nil
701+ }
702+ return futurePTY, nil
703+ },
704+ ).Maybe()
705+ d := newTestDaemon(t, factory, stubClock{})
685 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))706 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
686 hello := helloResumeCapable(ports.IntentNew, "work", 0)707 hello := helloResumeCapable(ports.IntentNew, "work", 0)
687- hello.TrueColor = false708+ hello.Env = []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old"}
688 sess, ac, err := d.route(hello, tr)709 sess, ac, err := d.route(hello, tr)
689 require.NoError(t, err)710 require.NoError(t, err)
690 token := ac.resumeToken711 token := ac.resumeToken
@@ -692,13 +713,21 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
692 require.True(t, d.parkAttachment(sess, ac))713 require.True(t, d.parkAttachment(sess, ac))
693 714
694 resumeHello := helloResumeCapable(ports.IntentResume, "work", token)715 resumeHello := helloResumeCapable(ports.IntentResume, "work", token)
716+ resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
695 resumeHello.TrueColor = true717 resumeHello.TrueColor = true
696 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})718 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
697 require.NoError(t, err)719 require.NoError(t, err)
698 require.True(t, ok)720 require.True(t, ok)
721+ require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24}))
722+
699 sess.mu.Lock()723 sess.mu.Lock()
700- defer sess.mu.Unlock()724+ future := sess.tabs[1].focusedPane()
725+ futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID
701 require.True(t, sess.terminal.TrueColor)726 require.True(t, sess.terminal.TrueColor)
727+ require.Equal(t, resumeHello.Env, sess.env)
728+ sess.mu.Unlock()
729+ require.Equal(t, []string{"/bin/sh", "/usr/bin/fish"}, commands)
730+ require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
702 }731 }
703 732
704 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {733 func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) {
M internal/usecase/daemon/session.go +62 -17
@@ -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.
@@ -126,7 +129,8 @@ func (d *Daemon) touchMRU(sess *session) {
126129 }
127130 }
128131
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, env []string, restoredTabNames ...[]string) (*session, error) {
133+ env = copyEnvironment(env)
130134 tbSize := tabSize(sz)
131135 var names []string
132136 if len(restoredTabNames) > 0 {
@@ -139,7 +143,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
139143 closeTabs(tabs)
140144 return nil, err
141145 }
142- pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
146+ command, args := d.ptyCommand(env)
147+ pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
143148 if err != nil {
144149 closeTabs(tabs)
145150 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -175,6 +180,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
175180 tabs: tabs,
176181 cwd: cwd,
177182 terminal: term,
183+ env: env,
178184 createdAt: createdAt,
179185 }
180186 if lastUsedSeq > 0 {
@@ -229,6 +235,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
229235 from.mu.Lock()
230236 cwd := from.cwd
231237 term := from.terminal
238+ env := copyEnvironment(from.env)
232239 if from.client != ac {
233240 from.mu.Unlock()
234241 d.mu.Unlock()
@@ -236,7 +243,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
236243 }
237244 from.mu.Unlock()
238245
239- newSess, err := d.createSessionLocked(name, false, cwd, sz, term)
246+ newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
240247 if err != nil {
241248 d.mu.Unlock()
242249 return err
@@ -276,12 +283,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
276283 cwd := sess.cwd
277284 client := sess.client
278285 term := sess.terminal
286+ env := copyEnvironment(sess.env)
279287 sess.mu.Unlock()
280288 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
281289 if err != nil {
282290 return err
283291 }
284- pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
292+ command, args := d.ptyCommand(env)
293+ pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
285294 if err != nil {
286295 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
287296 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -899,18 +908,54 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
899908 d.mu.Unlock()
900909 }
901910
902-// childEnv builds the session child's environment: the daemon's own, with terminal
903-// and VEV values forced to well-known values.
911+// childEnv retains the daemon-environment helper for daemon-local legacy callers.
912+// Interactive PTY launch paths use childEnvFrom with their session snapshot.
904913 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=") {
914+ return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID, term)
915+}
916+
917+func copyEnvironment(env []string) []string {
918+ return append([]string(nil), env...)
919+}
920+
921+func environmentEntry(entry string) (name, value string, ok bool) {
922+ i := strings.IndexByte(entry, '=')
923+ if i < 0 {
924+ return "", "", false
925+ }
926+ return entry[:i], entry[i+1:], true
927+}
928+
929+func (d *Daemon) ptyCommand(env []string) (string, []string) {
930+ if d.shellOverride {
931+ return d.shell, append([]string(nil), d.shellArgs...)
932+ }
933+ return shellFromEnvironment(env), nil
934+}
935+
936+func shellFromEnvironment(env []string) string {
937+ shell := ""
938+ for _, entry := range env {
939+ if name, value, ok := environmentEntry(entry); ok && name == "SHELL" {
940+ shell = value
941+ }
942+ }
943+ if shell == "" {
944+ return "/bin/sh"
945+ }
946+ return shell
947+}
948+
949+// childEnvFrom preserves the supplied environment byte-for-byte and in order,
950+// except for exact reserved variable names which vev owns.
951+func childEnvFrom(env []string, name, tabStableID, paneStableID string, term terminalEnv) []string {
952+ out := make([]string, 0, len(env)+4)
953+ for _, entry := range env {
954+ key, _, ok := environmentEntry(entry)
955+ if ok && (key == "TERM" || key == "COLORTERM" || key == "TERM_PROGRAM" || key == "VEV") {
911956 continue
912957 }
913- out = append(out, e)
958+ out = append(out, entry)
914959 }
915960 if term.TrueColor {
916961 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.
@@ -126,7 +129,8 @@ func (d *Daemon) touchMRU(sess *session) {
126 }129 }
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, env []string, restoredTabNames ...[]string) (*session, error) {
133+ env = copyEnvironment(env)
130 tbSize := tabSize(sz)134 tbSize := tabSize(sz)
131 var names []string135 var names []string
132 if len(restoredTabNames) > 0 {136 if len(restoredTabNames) > 0 {
@@ -139,7 +143,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
139 closeTabs(tabs)143 closeTabs(tabs)
140 return nil, err144 return nil, err
141 }145 }
142- pty, err := d.ptys.Open(d.serveCtx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)146+ command, args := d.ptyCommand(env)
147+ pty, err := d.ptys.Open(d.serveCtx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
143 if err != nil {148 if err != nil {
144 closeTabs(tabs)149 closeTabs(tabs)
145 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")150 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
@@ -175,6 +180,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
175 tabs: tabs,180 tabs: tabs,
176 cwd: cwd,181 cwd: cwd,
177 terminal: term,182 terminal: term,
183+ env: env,
178 createdAt: createdAt,184 createdAt: createdAt,
179 }185 }
180 if lastUsedSeq > 0 {186 if lastUsedSeq > 0 {
@@ -229,6 +235,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
229 from.mu.Lock()235 from.mu.Lock()
230 cwd := from.cwd236 cwd := from.cwd
231 term := from.terminal237 term := from.terminal
238+ env := copyEnvironment(from.env)
232 if from.client != ac {239 if from.client != ac {
233 from.mu.Unlock()240 from.mu.Unlock()
234 d.mu.Unlock()241 d.mu.Unlock()
@@ -236,7 +243,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
236 }243 }
237 from.mu.Unlock()244 from.mu.Unlock()
238 245
239- newSess, err := d.createSessionLocked(name, false, cwd, sz, term)246+ newSess, err := d.createSessionLocked(name, false, cwd, sz, term, env)
240 if err != nil {247 if err != nil {
241 d.mu.Unlock()248 d.mu.Unlock()
242 return err249 return err
@@ -276,12 +283,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
276 cwd := sess.cwd283 cwd := sess.cwd
277 client := sess.client284 client := sess.client
278 term := sess.terminal285 term := sess.terminal
286+ env := copyEnvironment(sess.env)
279 sess.mu.Unlock()287 sess.mu.Unlock()
280 tabStableID, paneStableID, err := d.newTabPaneStableIDs()288 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
281 if err != nil {289 if err != nil {
282 return err290 return err
283 }291 }
284- pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)292+ command, args := d.ptyCommand(env)
293+ pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
285 if err != nil {294 if err != nil {
286 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")295 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)296 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -899,18 +908,54 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
899 d.mu.Unlock()908 d.mu.Unlock()
900 }909 }
901 910
902-// childEnv builds the session child's environment: the daemon's own, with terminal911+// childEnv retains the daemon-environment helper for daemon-local legacy callers.
903-// and VEV values forced to well-known values.912+// Interactive PTY launch paths use childEnvFrom with their session snapshot.
904 func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {913 func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {
905- out := make([]string, 0, len(d.baseEnv)+4)914+ return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID, term)
906- for _, e := range d.baseEnv {915+}
907- if strings.HasPrefix(e, "TERM=") ||916+
908- strings.HasPrefix(e, "COLORTERM=") ||917+func copyEnvironment(env []string) []string {
909- strings.HasPrefix(e, "TERM_PROGRAM=") ||918+ return append([]string(nil), env...)
910- strings.HasPrefix(e, "VEV=") {919+}
920+
921+func environmentEntry(entry string) (name, value string, ok bool) {
922+ i := strings.IndexByte(entry, '=')
923+ if i < 0 {
924+ return "", "", false
925+ }
926+ return entry[:i], entry[i+1:], true
927+}
928+
929+func (d *Daemon) ptyCommand(env []string) (string, []string) {
930+ if d.shellOverride {
931+ return d.shell, append([]string(nil), d.shellArgs...)
932+ }
933+ return shellFromEnvironment(env), nil
934+}
935+
936+func shellFromEnvironment(env []string) string {
937+ shell := ""
938+ for _, entry := range env {
939+ if name, value, ok := environmentEntry(entry); ok && name == "SHELL" {
940+ shell = value
941+ }
942+ }
943+ if shell == "" {
944+ return "/bin/sh"
945+ }
946+ return shell
947+}
948+
949+// childEnvFrom preserves the supplied environment byte-for-byte and in order,
950+// except for exact reserved variable names which vev owns.
951+func childEnvFrom(env []string, name, tabStableID, paneStableID string, term terminalEnv) []string {
952+ out := make([]string, 0, len(env)+4)
953+ for _, entry := range env {
954+ key, _, ok := environmentEntry(entry)
955+ if ok && (key == "TERM" || key == "COLORTERM" || key == "TERM_PROGRAM" || key == "VEV") {
911 continue956 continue
912 }957 }
913- out = append(out, e)958+ out = append(out, entry)
914 }959 }
915 if term.TrueColor {960 if term.TrueColor {
916 out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")961 out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")
M internal/usecase/daemon/session_test.go +87 -11
@@ -853,7 +853,7 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
853853 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
854854 d.mruSeq.Store(42)
855855
856- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
856+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
857857 require.NoError(t, err)
858858 require.Equal(t, uint64(42), sess.mruAt.Load())
859859 require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
@@ -869,7 +869,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
869869 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
870870 WithStore(store)(d)
871871
872- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
872+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
873873 require.NoError(t, err)
874874 require.True(t, state.has("work"))
875875 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
@@ -893,7 +893,7 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
893893 d := newTestDaemon(t, newFactory(t, p), stubClock{})
894894 WithStore(store)(d)
895895
896- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
896+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
897897 require.NoError(t, err)
898898 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
899899
@@ -930,7 +930,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
930930 WithStore(store)(d)
931931 sz := domain.Size{Cols: 80, Rows: 24}
932932
933- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
933+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
934934 require.NoError(t, err)
935935 require.NoError(t, d.createTab(sess, sz))
936936 require.NoError(t, d.createTab(sess, sz))
@@ -978,7 +978,7 @@ func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
978978 d := newTestDaemon(t, newFactory(t, p), stubClock{})
979979 WithStore(store)(d)
980980
981- sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{})
981+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
982982 require.NoError(t, err)
983983 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
984984 require.Equal(t, "shell", sess.tabs[0].name)
@@ -1021,7 +1021,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
10211021 WithStore(store)(d)
10221022 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
10231023
1024- sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, terminalEnv{})
1024+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv)
10251025 require.NoError(t, err)
10261026 require.False(t, state.has("0"))
10271027 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
@@ -1039,7 +1039,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
10391039 cwd := "/tmp/work"
10401040 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
10411041
1042- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
1042+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
10431043 require.NoError(t, err)
10441044 state.mu.Lock()
10451045 setsAfterCreate := state.sets
@@ -1136,7 +1136,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
11361136 d := newTestDaemon(t, f, stubClock{})
11371137 d.baseEnv = []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
11381138
1139- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{TrueColor: true})
1139+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{TrueColor: true}, d.baseEnv)
11401140 require.NoError(t, err)
11411141 defer func() {
11421142 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1192,7 +1192,7 @@ func TestRenameSessionUnsafeNameRejected(t *testing.T) {
11921192 p, release := newBlockingPTY(t)
11931193 defer release()
11941194 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1195- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
1195+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
11961196 require.NoError(t, err)
11971197 require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
11981198 }
@@ -1208,9 +1208,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
12081208 WithStore(store)(d)
12091209 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
12101210
1211- natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, terminalEnv{})
1211+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, terminalEnv{}, d.baseEnv)
12121212 require.NoError(t, err)
1213- other, err := d.createSessionLocked("other", false, "/tmp/other", sz, terminalEnv{})
1213+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz, terminalEnv{}, d.baseEnv)
12141214 require.NoError(t, err)
12151215 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
12161216 require.True(t, state.has("natural"))
@@ -1422,3 +1422,79 @@ 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+ tests := []struct {
1486+ name string
1487+ env []string
1488+ want string
1489+ }{
1490+ {name: "absent", want: "/bin/sh"},
1491+ {name: "empty", env: []string{"SHELL="}, want: "/bin/sh"},
1492+ {name: "set", env: []string{"SHELL=/usr/bin/fish"}, want: "/usr/bin/fish"},
1493+ }
1494+
1495+ for _, tt := range tests {
1496+ t.Run(tt.name, func(t *testing.T) {
1497+ require.Equal(t, tt.want, shellFromEnvironment(tt.env))
1498+ })
1499+ }
1500+}
@@ -853,7 +853,7 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
853 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}853 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
854 d.mruSeq.Store(42)854 d.mruSeq.Store(42)
855 855
856- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})856+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
857 require.NoError(t, err)857 require.NoError(t, err)
858 require.Equal(t, uint64(42), sess.mruAt.Load())858 require.Equal(t, uint64(42), sess.mruAt.Load())
859 require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)859 require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
@@ -869,7 +869,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
869 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})869 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
870 WithStore(store)(d)870 WithStore(store)(d)
871 871
872- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})872+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
873 require.NoError(t, err)873 require.NoError(t, err)
874 require.True(t, state.has("work"))874 require.True(t, state.has("work"))
875 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))875 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
@@ -893,7 +893,7 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
893 d := newTestDaemon(t, newFactory(t, p), stubClock{})893 d := newTestDaemon(t, newFactory(t, p), stubClock{})
894 WithStore(store)(d)894 WithStore(store)(d)
895 895
896- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})896+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
897 require.NoError(t, err)897 require.NoError(t, err)
898 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))898 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
899 899
@@ -930,7 +930,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
930 WithStore(store)(d)930 WithStore(store)(d)
931 sz := domain.Size{Cols: 80, Rows: 24}931 sz := domain.Size{Cols: 80, Rows: 24}
932 932
933- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})933+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
934 require.NoError(t, err)934 require.NoError(t, err)
935 require.NoError(t, d.createTab(sess, sz))935 require.NoError(t, d.createTab(sess, sz))
936 require.NoError(t, d.createTab(sess, sz))936 require.NoError(t, d.createTab(sess, sz))
@@ -978,7 +978,7 @@ func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
978 d := newTestDaemon(t, newFactory(t, p), stubClock{})978 d := newTestDaemon(t, newFactory(t, p), stubClock{})
979 WithStore(store)(d)979 WithStore(store)(d)
980 980
981- sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{})981+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
982 require.NoError(t, err)982 require.NoError(t, err)
983 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))983 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
984 require.Equal(t, "shell", sess.tabs[0].name)984 require.Equal(t, "shell", sess.tabs[0].name)
@@ -1021,7 +1021,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
1021 WithStore(store)(d)1021 WithStore(store)(d)
1022 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}1022 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
1023 1023
1024- sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, terminalEnv{})1024+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, terminalEnv{}, d.baseEnv)
1025 require.NoError(t, err)1025 require.NoError(t, err)
1026 require.False(t, state.has("0"))1026 require.False(t, state.has("0"))
1027 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")1027 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
@@ -1039,7 +1039,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
1039 cwd := "/tmp/work"1039 cwd := "/tmp/work"
1040 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)1040 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
1041 1041
1042- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})1042+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1043 require.NoError(t, err)1043 require.NoError(t, err)
1044 state.mu.Lock()1044 state.mu.Lock()
1045 setsAfterCreate := state.sets1045 setsAfterCreate := state.sets
@@ -1136,7 +1136,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
1136 d := newTestDaemon(t, f, stubClock{})1136 d := newTestDaemon(t, f, stubClock{})
1137 d.baseEnv = []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}1137 d.baseEnv = []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
1138 1138
1139- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{TrueColor: true})1139+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{TrueColor: true}, d.baseEnv)
1140 require.NoError(t, err)1140 require.NoError(t, err)
1141 defer func() {1141 defer func() {
1142 _ = d.killSession(sess, ports.ReasonServerShutdown, false)1142 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -1192,7 +1192,7 @@ func TestRenameSessionUnsafeNameRejected(t *testing.T) {
1192 p, release := newBlockingPTY(t)1192 p, release := newBlockingPTY(t)
1193 defer release()1193 defer release()
1194 d := newTestDaemon(t, newFactory(t, p), stubClock{})1194 d := newTestDaemon(t, newFactory(t, p), stubClock{})
1195- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})1195+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
1196 require.NoError(t, err)1196 require.NoError(t, err)
1197 require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)1197 require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
1198 }1198 }
@@ -1208,9 +1208,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
1208 WithStore(store)(d)1208 WithStore(store)(d)
1209 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)1209 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
1210 1210
1211- natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, terminalEnv{})1211+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, terminalEnv{}, d.baseEnv)
1212 require.NoError(t, err)1212 require.NoError(t, err)
1213- other, err := d.createSessionLocked("other", false, "/tmp/other", sz, terminalEnv{})1213+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz, terminalEnv{}, d.baseEnv)
1214 require.NoError(t, err)1214 require.NoError(t, err)
1215 _ = d.killSession(natural, ports.ReasonSessionKilled, false)1215 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
1216 require.True(t, state.has("natural"))1216 require.True(t, state.has("natural"))
@@ -1422,3 +1422,79 @@ 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+ tests := []struct {
1486+ name string
1487+ env []string
1488+ want string
1489+ }{
1490+ {name: "absent", want: "/bin/sh"},
1491+ {name: "empty", env: []string{"SHELL="}, want: "/bin/sh"},
1492+ {name: "set", env: []string{"SHELL=/usr/bin/fish"}, want: "/usr/bin/fish"},
1493+ }
1494+
1495+ for _, tt := range tests {
1496+ t.Run(tt.name, func(t *testing.T) {
1497+ require.Equal(t, tt.want, shellFromEnvironment(tt.env))
1498+ })
1499+ }
1500+}
M internal/usecase/daemon/snapshot.go +7 -5
@@ -114,10 +114,11 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
114114
115115 sctx, cancel := context.WithCancel(d.serveCtx)
116116 opened := make([]*tab, 0, len(snap.Tabs))
117- // Snapshot restore runs before any client Hello is available, so restored
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.
117+ // Snapshot restore runs before any client Hello is available. Start panes
118+ // from the daemon environment captured at startup; the next attach replaces
119+ // this snapshot for future panes from the client's capability.
120120 restoreTerm := terminalEnv{}
121+ restoreEnv := copyEnvironment(d.baseEnv)
121122 closeOpened := func() {
122123 for _, tb := range opened {
123124 tb.closeAllPanes()
@@ -189,7 +190,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
189190 return fmt.Errorf("snapshot: generating pane identity: %w", err)
190191 }
191192 }
192- pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
193+ command, args := d.ptyCommand(restoreEnv)
194+ pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
193195 if err != nil {
194196 closeOpened()
195197 return err
@@ -217,7 +219,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
217219 return nil
218220 }
219221 createdAt := int64(snap.CreatedAt)
220- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}
222+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, env: restoreEnv, createdAt: createdAt}
221223 sess.snapEligible.Store(true)
222224 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
223225 sess.cwd = snap.Tabs[0].Panes[0].Cwd
@@ -114,10 +114,11 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
114 114
115 sctx, cancel := context.WithCancel(d.serveCtx)115 sctx, cancel := context.WithCancel(d.serveCtx)
116 opened := make([]*tab, 0, len(snap.Tabs))116 opened := make([]*tab, 0, len(snap.Tabs))
117- // Snapshot restore runs before any client Hello is available, so restored117+ // Snapshot restore runs before any client Hello is available. Start panes
118- // panes start with the conservative terminal environment. The next attach or118+ // from the daemon environment captured at startup; the next attach replaces
119- // resume updates sess.terminal for future panes from the client's capability.119+ // this snapshot for future panes from the client's capability.
120 restoreTerm := terminalEnv{}120 restoreTerm := terminalEnv{}
121+ restoreEnv := copyEnvironment(d.baseEnv)
121 closeOpened := func() {122 closeOpened := func() {
122 for _, tb := range opened {123 for _, tb := range opened {
123 tb.closeAllPanes()124 tb.closeAllPanes()
@@ -189,7 +190,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
189 return fmt.Errorf("snapshot: generating pane identity: %w", err)190 return fmt.Errorf("snapshot: generating pane identity: %w", err)
190 }191 }
191 }192 }
192- pty, err := d.ptys.Open(ctx, d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)193+ command, args := d.ptyCommand(restoreEnv)
194+ pty, err := d.ptys.Open(ctx, command, args, childEnvFrom(restoreEnv, snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
193 if err != nil {195 if err != nil {
194 closeOpened()196 closeOpened()
195 return err197 return err
@@ -217,7 +219,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
217 return nil219 return nil
218 }220 }
219 createdAt := int64(snap.CreatedAt)221 createdAt := int64(snap.CreatedAt)
220- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}222+ 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)223 sess.snapEligible.Store(true)
222 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {224 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
223 sess.cwd = snap.Tabs[0].Panes[0].Cwd225 sess.cwd = snap.Tabs[0].Panes[0].Cwd
M internal/usecase/daemon/snapshot_restore_test.go +59 -7
@@ -86,6 +86,57 @@ func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
8686 require.Equal(t, 1, factory.opens[0].pty.closeCount())
8787 }
8888
89+func TestRestoreSessionUsesDaemonFallbackEnvironmentAndShellBeforeAttach(t *testing.T) {
90+ factory := &restorePTYFactory{}
91+ d := newTestDaemon(t, factory, stubClock{})
92+ d.baseEnv = []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}
93+
94+ require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{
95+ StableID: "tab", Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{terminalPane(t, "pane-1", "pane", "/tmp", nil, nil)},
96+ }}}))
97+ require.Len(t, factory.opens, 1)
98+ require.Equal(t, "/usr/local/bin/daemon-shell", factory.opens[0].command)
99+ require.Equal(t, []string{
100+ "ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "RAW",
101+ "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
102+ }, factory.opens[0].env)
103+
104+ d.baseEnv[0] = "ORDINARY=changed"
105+ d.mu.Lock()
106+ restored := d.findByNameLocked("work")
107+ d.mu.Unlock()
108+ require.NotNil(t, restored)
109+ restored.mu.Lock()
110+ require.Equal(t, []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}, restored.env)
111+ restored.mu.Unlock()
112+
113+ tr, _ := newCapturingTransport(t)
114+ _, ac, err := d.route(ports.Hello{
115+ Version: ports.ProtocolVersion,
116+ Intent: ports.IntentAttach,
117+ Name: "work",
118+ Size: domain.Size{Cols: 80, Rows: 24},
119+ Env: []string{"ORDINARY=attached", "SHELL=/bin/attached-shell"},
120+ }, tr)
121+ require.NoError(t, err)
122+ require.NoError(t, d.createTab(restored, ac.size))
123+ require.Len(t, factory.opens, 2)
124+ require.Equal(t, "/bin/attached-shell", factory.opens[1].command)
125+
126+ restored.mu.Lock()
127+ attachedTab := restored.tabs[1]
128+ restored.mu.Unlock()
129+ attachedTab.mu.Lock()
130+ attachedPane := attachedTab.panes["pane-1"]
131+ attachedTab.mu.Unlock()
132+ require.Equal(t, []string{
133+ "ORDINARY=attached", "SHELL=/bin/attached-shell",
134+ "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + attachedTab.stableID + ",pane=" + attachedPane.stableID,
135+ }, factory.opens[1].env)
136+ require.NoError(t, d.killSession(restored, ports.ReasonSessionKilled, false))
137+ d.sessWg.Wait()
138+}
139+
89140 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
90141 store := &restoreSnapshotStore{}
91142 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -541,19 +592,20 @@ type restorePTYFactory struct {
541592 }
542593
543594 type restorePTYOpen struct {
544- ctx context.Context
545- dir string
546- size domain.Size
547- env []string
548- pty *restorePTY
595+ ctx context.Context
596+ command string
597+ dir string
598+ size domain.Size
599+ env []string
600+ pty *restorePTY
549601 }
550602
551-func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
603+func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
552604 f.mu.Lock()
553605 defer f.mu.Unlock()
554606 pty := newRestorePTY()
555607 pty.writeErr = f.writeErr
556- f.opens = append(f.opens, restorePTYOpen{ctx: ctx, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
608+ f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
557609 return pty, nil
558610 }
559611
@@ -86,6 +86,57 @@ func TestRestoreSessionClosesOpenedPTYWhenTerminalRestoreFails(t *testing.T) {
86 require.Equal(t, 1, factory.opens[0].pty.closeCount())86 require.Equal(t, 1, factory.opens[0].pty.closeCount())
87 }87 }
88 88
89+func TestRestoreSessionUsesDaemonFallbackEnvironmentAndShellBeforeAttach(t *testing.T) {
90+ factory := &restorePTYFactory{}
91+ d := newTestDaemon(t, factory, stubClock{})
92+ d.baseEnv = []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}
93+
94+ require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "work", Tabs: []snapcodec.Tab{{
95+ StableID: "tab", Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{terminalPane(t, "pane-1", "pane", "/tmp", nil, nil)},
96+ }}}))
97+ require.Len(t, factory.opens, 1)
98+ require.Equal(t, "/usr/local/bin/daemon-shell", factory.opens[0].command)
99+ require.Equal(t, []string{
100+ "ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "RAW",
101+ "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
102+ }, factory.opens[0].env)
103+
104+ d.baseEnv[0] = "ORDINARY=changed"
105+ d.mu.Lock()
106+ restored := d.findByNameLocked("work")
107+ d.mu.Unlock()
108+ require.NotNil(t, restored)
109+ restored.mu.Lock()
110+ require.Equal(t, []string{"ORDINARY=preserved", "SHELL=/usr/local/bin/daemon-shell", "TERM=stale", "RAW"}, restored.env)
111+ restored.mu.Unlock()
112+
113+ tr, _ := newCapturingTransport(t)
114+ _, ac, err := d.route(ports.Hello{
115+ Version: ports.ProtocolVersion,
116+ Intent: ports.IntentAttach,
117+ Name: "work",
118+ Size: domain.Size{Cols: 80, Rows: 24},
119+ Env: []string{"ORDINARY=attached", "SHELL=/bin/attached-shell"},
120+ }, tr)
121+ require.NoError(t, err)
122+ require.NoError(t, d.createTab(restored, ac.size))
123+ require.Len(t, factory.opens, 2)
124+ require.Equal(t, "/bin/attached-shell", factory.opens[1].command)
125+
126+ restored.mu.Lock()
127+ attachedTab := restored.tabs[1]
128+ restored.mu.Unlock()
129+ attachedTab.mu.Lock()
130+ attachedPane := attachedTab.panes["pane-1"]
131+ attachedTab.mu.Unlock()
132+ require.Equal(t, []string{
133+ "ORDINARY=attached", "SHELL=/bin/attached-shell",
134+ "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + attachedTab.stableID + ",pane=" + attachedPane.stableID,
135+ }, factory.opens[1].env)
136+ require.NoError(t, d.killSession(restored, ports.ReasonSessionKilled, false))
137+ d.sessWg.Wait()
138+}
139+
89 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {140 func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
90 store := &restoreSnapshotStore{}141 store := &restoreSnapshotStore{}
91 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{142 store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -541,19 +592,20 @@ type restorePTYFactory struct {
541 }592 }
542 593
543 type restorePTYOpen struct {594 type restorePTYOpen struct {
544- ctx context.Context595+ ctx context.Context
545- dir string596+ command string
546- size domain.Size597+ dir string
547- env []string598+ size domain.Size
548- pty *restorePTY599+ env []string
600+ pty *restorePTY
549 }601 }
550 602
551-func (f *restorePTYFactory) Open(ctx context.Context, _ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {603+func (f *restorePTYFactory) Open(ctx context.Context, command string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
552 f.mu.Lock()604 f.mu.Lock()
553 defer f.mu.Unlock()605 defer f.mu.Unlock()
554 pty := newRestorePTY()606 pty := newRestorePTY()
555 pty.writeErr = f.writeErr607 pty.writeErr = f.writeErr
556- f.opens = append(f.opens, restorePTYOpen{ctx: ctx, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})608+ f.opens = append(f.opens, restorePTYOpen{ctx: ctx, command: command, dir: dir, size: sz, env: append([]string(nil), env...), pty: pty})
557 return pty, nil609 return pty, nil
558 }610 }
559 611
M internal/usecase/daemon/status_test.go +2 -2
@@ -1047,9 +1047,9 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
10471047 cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
10481048 d.sessions[cur.id] = cur
10491049
1050- _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz, terminalEnv{})
1050+ _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
10511051 require.NoError(t, err)
1052- _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz, terminalEnv{})
1052+ _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv)
10531053 require.NoError(t, err)
10541054
10551055 state := d.barStateFor(cur, "")
@@ -1047,9 +1047,9 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
1047 cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}1047 cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
1048 d.sessions[cur.id] = cur1048 d.sessions[cur.id] = cur
1049 1049
1050- _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz, terminalEnv{})1050+ _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
1051 require.NoError(t, err)1051 require.NoError(t, err)
1052- _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz, terminalEnv{})1052+ _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz, terminalEnv{}, d.baseEnv)
1053 require.NoError(t, err)1053 require.NoError(t, err)
1054 1054
1055 state := d.barStateFor(cur, "")1055 state := d.barStateFor(cur, "")

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