Merge pull request #93 from bnema/feature/attach-environment
feat(session): refresh child environment on attach
24 files · +725 -114
M docs/terminal.md +6 -0
| @@ -4,6 +4,12 @@ | |||
| 4 | 4 | ||
| 5 | 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 | 13 | ## VT compatibility | |
| 8 | 14 | ||
| 9 | 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. | |
| @@ -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 compatibility | 13 | ## 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 ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "io" | |
| 9 | 9 | "log/slog" | |
| 10 | + | "os" | |
| 10 | 11 | "path/filepath" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "testing" | |
| @@ -73,10 +74,15 @@ func recvPump(tr ports.Transport) *pump { | |||
| 73 | 74 | ||
| 74 | 75 | // attach dials, handshakes, and returns the transport plus its frame pump. | |
| 75 | 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 | 82 | t.Helper() | |
| 77 | 83 | tr, err := ipc.DialContext(context.Background(), dir) | |
| 78 | 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 | 86 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})) | |
| 81 | 87 | p := recvPump(tr) | |
| 82 | 88 | select { | |
| @@ -189,6 +195,64 @@ func screenText(s *vt.Screen) string { | |||
| 189 | 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 | 256 | func TestIntegration_AttachFirstOutput(t *testing.T) { | |
| 193 | 257 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 194 | 258 | 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 { | |||
| 663 | 663 | TermEnv: termEnv, | |
| 664 | 664 | Cwd: cwd, | |
| 665 | 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 | 795 | defer func() { _ = transport.Close() }() | |
| 795 | 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 | 800 | var ( | |
| @@ -934,11 +935,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||
| 934 | 935 | return err | |
| 935 | 936 | } | |
| 936 | 937 | ||
| 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) | |
| 942 | 939 | } | |
| 943 | 940 | ||
| 944 | 941 | const udpProxyIdleTTL = 15 * time.Minute | |
| @@ -1029,27 +1026,68 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro | |||
| 1029 | 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 | 1033 | ports.Transport | |
| 1034 | + | mu sync.Mutex | |
| 1035 | + | seen bool | |
| 1034 | 1036 | 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 | 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 | 1051 | 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 | 1061 | h, err := ports.UnmarshalHello(f.Payload) | |
| 1043 | 1062 | if err != nil { | |
| 1044 | 1063 | return f, nil | |
| 1045 | 1064 | } | |
| 1065 | + | h.Env = append([]string(nil), t.env...) | |
| 1046 | 1066 | if h.Name == "" { | |
| 1047 | 1067 | h.Name = t.session | |
| 1048 | - | f.Payload = ports.MarshalHello(h) | |
| 1049 | 1068 | } | |
| 1069 | + | f.Payload = ports.MarshalHello(h) | |
| 1050 | 1070 | 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 | 1091 | func proxyTransports(ctx context.Context, a, b ports.Transport, log *slog.Logger) error { | |
| 1054 | 1092 | return dgram.ProxyRuntime{Client: a, Daemon: b, Log: log}.Run(ctx) | |
| 1055 | 1093 | } | |
| @@ -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 err | 935 | return err | ||
| 935 | } | 936 | } | ||
| 936 | 937 | ||||
| 937 | - | var dgramTr ports.Transport = dg | 938 | + | 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.Minute | 941 | 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.Transport | 1033 | ports.Transport | ||
| 1034 | + | mu sync.Mutex | |||
| 1035 | + | seen bool | |||
| 1034 | session string | 1036 | 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, err | 1051 | 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, nil | 1063 | 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.session | 1067 | h.Name = t.session | ||
| 1048 | - | f.Payload = ports.MarshalHello(h) | |||
| 1049 | } | 1068 | } | ||
| 1069 | + | f.Payload = ports.MarshalHello(h) | |||
| 1050 | return f, nil | 1070 | 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 ( | |||
| 24 | 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 | 163 | func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) { | |
| 28 | 164 | require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending) | |
| 29 | 165 | } | |
| @@ -443,6 +579,7 @@ func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) { | |||
| 443 | 579 | require.Equal(t, "xterm-direct", hello.TermEnv) | |
| 444 | 580 | require.Equal(t, "/tmp/work", hello.Cwd) | |
| 445 | 581 | require.True(t, hello.TrueColor) | |
| 582 | + | require.Equal(t, os.Environ(), hello.Env) | |
| 446 | 583 | } | |
| 447 | 584 | ||
| 448 | 585 | 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 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 14 | |
| 4 | + | const ProtocolVersion uint16 = 15 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | 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 = 14 | 4 | + | 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 uint8 | 7 | type MsgType uint8 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | 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) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -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 { | |||
| 69 | 69 | // MaxOutputInFlight is the requested maximum number of unacknowledged | |
| 70 | 70 | // state-bearing output frames. | |
| 71 | 71 | 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 | 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 | 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 | 218 | // payloadReader consumes a message payload field by field in wire order, | |
| 211 | 219 | // erroring instead of panicking on any short read. | |
| 212 | 220 | type payloadReader struct { | |
| @@ -271,6 +279,19 @@ func (r *payloadReader) getString() (string, error) { | |||
| 271 | 279 | 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 | 295 | // rest consumes and returns all remaining bytes, copied so the result is | |
| 275 | 296 | // independent of the reader's backing array. | |
| 276 | 297 | func (r *payloadReader) rest() []byte { | |
| @@ -313,6 +334,10 @@ func MarshalHello(h Hello) []byte { | |||
| 313 | 334 | w.putUint8(0) | |
| 314 | 335 | } | |
| 315 | 336 | w.putUint8(h.MaxOutputInFlight) | |
| 337 | + | w.putUint32(uint32(len(h.Env))) | |
| 338 | + | for _, entry := range h.Env { | |
| 339 | + | w.putLongString(entry) | |
| 340 | + | } | |
| 316 | 341 | return w.b | |
| 317 | 342 | } | |
| 318 | 343 | ||
| @@ -362,6 +387,25 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 362 | 387 | if h.MaxOutputInFlight, err = r.getUint8(); err != nil { | |
| 363 | 388 | 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 | 409 | if err := r.done(); err != nil { | |
| 366 | 410 | return Hello{}, err | |
| 367 | 411 | } | |
| @@ -69,6 +69,9 @@ type Hello struct { | |||||
| 69 | // MaxOutputInFlight is the requested maximum number of unacknowledged | 69 | // MaxOutputInFlight is the requested maximum number of unacknowledged | ||
| 70 | // state-bearing output frames. | 70 | // state-bearing output frames. | ||
| 71 | MaxOutputInFlight uint8 | 71 | 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, nil | 279 | 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 is | 295 | // 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.b | 341 | 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{}, err | 388 | 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{}, err | 410 | return Hello{}, err | ||
| 367 | } | 411 | } | ||
M internal/ports/wire_test.go +70 -5
| @@ -72,7 +72,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 72 | 72 | TrueColor: true, | |
| 73 | 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 | 78 | name: "empty strings", | |
| @@ -85,7 +85,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 85 | 85 | Cwd: "", | |
| 86 | 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 | 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 | 182 | func TestInputGoldenAndRoundTrip(t *testing.T) { | |
| 118 | 183 | tests := []struct { | |
| 119 | 184 | name string | |
| @@ -574,9 +639,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) { | |||
| 574 | 639 | for _, window := range []uint8{0, 1, 8} { | |
| 575 | 640 | t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) { | |
| 576 | 641 | 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) | |
| 580 | 645 | want[1] = 14 | |
| 581 | 646 | got := MarshalHello(hello) | |
| 582 | 647 | 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 string | 184 | 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] = 14 | 645 | 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 { | |||
| 525 | 525 | Cwd: cwd, | |
| 526 | 526 | TrueColor: trueColor, | |
| 527 | 527 | MaxOutputInFlight: requestedOutputWindow(transport), | |
| 528 | + | Env: os.Environ(), | |
| 528 | 529 | } | |
| 529 | 530 | if closed, err := boundedPreWelcome(ctx, clk, transport, func() error { | |
| 530 | 531 | 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 ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "os" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "sync/atomic" | |
| 11 | 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 | 367 | func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) { | |
| 337 | 368 | var out bytes.Buffer | |
| 338 | 369 | 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.Buffer | 368 | var out bytes.Buffer | ||
| 338 | var restoreCount atomic.Int32 | 369 | var restoreCount atomic.Int32 | ||
M internal/usecase/daemon/daemon.go +12 -7
| @@ -98,6 +98,7 @@ type Daemon struct { | |||
| 98 | 98 | baseEnv []string | |
| 99 | 99 | shell string | |
| 100 | 100 | shellArgs []string | |
| 101 | + | shellOverride bool | |
| 101 | 102 | persist *persist.Persister | |
| 102 | 103 | persistEnabled bool | |
| 103 | 104 | snaps ports.SnapshotStore | |
| @@ -204,6 +205,7 @@ func WithShell(cmd string, args []string) Option { | |||
| 204 | 205 | return func(d *Daemon) { | |
| 205 | 206 | d.shell = cmd | |
| 206 | 207 | d.shellArgs = args | |
| 208 | + | d.shellOverride = true | |
| 207 | 209 | } | |
| 208 | 210 | } | |
| 209 | 211 | ||
| @@ -670,14 +672,17 @@ func (e *protoErr) Error() string { return e.text } | |||
| 670 | 672 | // publishes the terminal state before releasing d.mu, then queues replacement | |
| 671 | 673 | // teardown so obsolete worker joins never delay the new handshake. | |
| 672 | 674 | func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) *attachedClient { | |
| 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 | 681 | ac, old, cleanup := d.attachClientDeferred(sess, tr, sz, attachClientOptions{ | |
| 674 | 682 | clientID: h.ClientID, | |
| 675 | 683 | resumeCapable: true, | |
| 676 | 684 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 677 | 685 | }) | |
| 678 | - | sess.mu.Lock() | |
| 679 | - | sess.terminal = term | |
| 680 | - | sess.mu.Unlock() | |
| 681 | 686 | d.mu.Unlock() | |
| 682 | 687 | d.retireReplacedClient(old, cleanup) | |
| 683 | 688 | return ac | |
| @@ -726,7 +731,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 726 | 731 | } | |
| 727 | 732 | cwd := d.dirOrHome(stopped.cwd) | |
| 728 | 733 | 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 | 735 | if err != nil { | |
| 731 | 736 | d.mu.Unlock() | |
| 732 | 737 | return nil, nil, err | |
| @@ -737,7 +742,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 737 | 742 | ||
| 738 | 743 | case ports.IntentEphemeral: | |
| 739 | 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 | 746 | if err != nil { | |
| 742 | 747 | d.mu.Unlock() | |
| 743 | 748 | return nil, nil, err | |
| @@ -758,7 +763,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 758 | 763 | d.mu.Unlock() | |
| 759 | 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 | 767 | if err != nil { | |
| 763 | 768 | d.mu.Unlock() | |
| 764 | 769 | return nil, nil, err | |
| @@ -776,7 +781,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 776 | 781 | } | |
| 777 | 782 | cwd := d.dirOrHome(stopped.cwd) | |
| 778 | 783 | 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 | 785 | if err != nil { | |
| 781 | 786 | d.mu.Unlock() | |
| 782 | 787 | return nil, nil, err | |
| @@ -98,6 +98,7 @@ type Daemon struct { | |||||
| 98 | baseEnv []string | 98 | baseEnv []string | ||
| 99 | shell string | 99 | shell string | ||
| 100 | shellArgs []string | 100 | shellArgs []string | ||
| 101 | + | shellOverride bool | |||
| 101 | persist *persist.Persister | 102 | persist *persist.Persister | ||
| 102 | persistEnabled bool | 103 | persistEnabled bool | ||
| 103 | snaps ports.SnapshotStore | 104 | snaps ports.SnapshotStore | ||
| @@ -204,6 +205,7 @@ func WithShell(cmd string, args []string) Option { | |||||
| 204 | return func(d *Daemon) { | 205 | return func(d *Daemon) { | ||
| 205 | d.shell = cmd | 206 | d.shell = cmd | ||
| 206 | d.shellArgs = args | 207 | d.shellArgs = args | ||
| 208 | + | d.shellOverride = true | |||
| 207 | } | 209 | } | ||
| 208 | } | 210 | } | ||
| 209 | 211 | ||||
| @@ -670,14 +672,17 @@ func (e *protoErr) Error() string { return e.text } | |||||
| 670 | // publishes the terminal state before releasing d.mu, then queues replacement | 672 | // 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 ac | 688 | 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 error | 733 | 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, err | 737 | 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, err | 748 | 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, err | 769 | 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 error | 783 | 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, err | 787 | return nil, nil, err | ||
M internal/usecase/daemon/floating.go +4 -3
| @@ -313,6 +313,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi | |||
| 313 | 313 | func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) { | |
| 314 | 314 | sess.mu.Lock() | |
| 315 | 315 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | |
| 316 | + | env := copyEnvironment(sess.env) | |
| 316 | 317 | sess.mu.Unlock() | |
| 317 | 318 | tb.mu.Lock() | |
| 318 | 319 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | |
| @@ -342,7 +343,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 342 | 343 | if tabCtx == nil { | |
| 343 | 344 | tabCtx = context.Background() | |
| 344 | 345 | } | |
| 345 | - | command, args := d.shell, append([]string(nil), d.shellArgs...) | |
| 346 | + | command, args := d.ptyCommand(env) | |
| 346 | 347 | if cfg.Command != "" { | |
| 347 | 348 | args = []string{"-lc", cfg.Command} | |
| 348 | 349 | } | |
| @@ -352,10 +353,10 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 352 | 353 | size: size, | |
| 353 | 354 | geometry: geometry, | |
| 354 | 355 | paneStableID: paneStableID, | |
| 355 | - | env: d.childEnv(name, tabStableID, paneStableID, term), | |
| 356 | + | env: childEnvFrom(env, name, tabStableID, paneStableID, term), | |
| 356 | 357 | command: command, | |
| 357 | 358 | args: args, | |
| 358 | - | fallback: floatingCommandFallback(cfg.Command, d.shell), | |
| 359 | + | fallback: floatingCommandFallback(cfg.Command, command), | |
| 359 | 360 | parentCtx: tabCtx, | |
| 360 | 361 | userOpen: userOpen, | |
| 361 | 362 | }, nil | |
| @@ -313,6 +313,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi | |||||
| 313 | func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) { | 313 | func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) { | ||
| 314 | sess.mu.Lock() | 314 | sess.mu.Lock() | ||
| 315 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | 315 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | ||
| 316 | + | env := copyEnvironment(sess.env) | |||
| 316 | sess.mu.Unlock() | 317 | sess.mu.Unlock() | ||
| 317 | tb.mu.Lock() | 318 | tb.mu.Lock() | ||
| 318 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | 319 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | ||
| @@ -342,7 +343,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 342 | if tabCtx == nil { | 343 | if tabCtx == nil { | ||
| 343 | tabCtx = context.Background() | 344 | tabCtx = context.Background() | ||
| 344 | } | 345 | } | ||
| 345 | - | command, args := d.shell, append([]string(nil), d.shellArgs...) | 346 | + | command, args := d.ptyCommand(env) |
| 346 | if cfg.Command != "" { | 347 | if cfg.Command != "" { | ||
| 347 | args = []string{"-lc", cfg.Command} | 348 | args = []string{"-lc", cfg.Command} | ||
| 348 | } | 349 | } | ||
| @@ -352,10 +353,10 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 352 | size: size, | 353 | size: size, | ||
| 353 | geometry: geometry, | 354 | geometry: geometry, | ||
| 354 | paneStableID: paneStableID, | 355 | paneStableID: paneStableID, | ||
| 355 | - | env: d.childEnv(name, tabStableID, paneStableID, term), | 356 | + | env: childEnvFrom(env, name, tabStableID, paneStableID, term), |
| 356 | command: command, | 357 | command: command, | ||
| 357 | args: args, | 358 | args: args, | ||
| 358 | - | fallback: floatingCommandFallback(cfg.Command, d.shell), | 359 | + | fallback: floatingCommandFallback(cfg.Command, command), |
| 359 | parentCtx: tabCtx, | 360 | parentCtx: tabCtx, | ||
| 360 | userOpen: userOpen, | 361 | userOpen: userOpen, | ||
| 361 | }, nil | 362 | }, nil | ||
M internal/usecase/daemon/floating_test.go +5 -4
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | type gatedFloatingOpen struct { | |
| 25 | 25 | command string | |
| 26 | 26 | args []string | |
| 27 | + | env []string | |
| 27 | 28 | dir string | |
| 28 | 29 | size domain.Size | |
| 29 | 30 | } | |
| @@ -37,8 +38,8 @@ func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsm | |||
| 37 | 38 | releaseOpen := func() { releaseOnce.Do(func() { close(release) }) } | |
| 38 | 39 | t.Cleanup(releaseOpen) | |
| 39 | 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 | - | 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} | |
| 42 | 43 | <-release | |
| 43 | 44 | return result, openErr | |
| 44 | 45 | }).Once() | |
| @@ -143,11 +144,10 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||
| 143 | 144 | pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once() | |
| 144 | 145 | factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil) | |
| 145 | 146 | d := newTestDaemon(t, factory, stubClock{}) | |
| 146 | - | d.shell = "/bin/custom-shell" | |
| 147 | 147 | cwd := t.TempDir() | |
| 148 | 148 | tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40}) | |
| 149 | 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 | 151 | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}}) | |
| 152 | 152 | d.ensureFloatingWarm(sess, tb) | |
| 153 | 153 | // Open has started while this goroutine owns tab.mu: an external factory | |
| @@ -171,6 +171,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||
| 171 | 171 | floating := tb.floating.pane | |
| 172 | 172 | tb.mu.Unlock() | |
| 173 | 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 | 175 | assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background) | |
| 175 | 176 | assertPaneColorScheme(t, floating, false) | |
| 176 | 177 | require.Equal(t, "/bin/custom-shell", openCall.command) | |
| @@ -24,6 +24,7 @@ import ( | |||||
| 24 | type gatedFloatingOpen struct { | 24 | type gatedFloatingOpen struct { | ||
| 25 | command string | 25 | command string | ||
| 26 | args []string | 26 | args []string | ||
| 27 | + | env []string | |||
| 27 | dir string | 28 | dir string | ||
| 28 | size domain.Size | 29 | 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 | <-release | 43 | <-release | ||
| 43 | return result, openErr | 44 | 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 factory | 153 | // Open has started while this goroutine owns tab.mu: an external factory | ||
| @@ -171,6 +171,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||||
| 171 | floating := tb.floating.pane | 171 | 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( | |||
| 35 | 35 | sess.mu.Lock() | |
| 36 | 36 | name, cwd := sess.name, sess.cwd | |
| 37 | 37 | term := sess.terminal | |
| 38 | + | env := copyEnvironment(sess.env) | |
| 38 | 39 | sess.mu.Unlock() | |
| 39 | 40 | ||
| 40 | 41 | tb.mu.Lock() | |
| @@ -69,7 +70,8 @@ func (d *Daemon) spawnPaneOp( | |||
| 69 | 70 | tb.mu.Unlock() | |
| 70 | 71 | return fmt.Errorf("daemon: generating pane identity: %w", err) | |
| 71 | 72 | } | |
| 72 | - | pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | |
| 73 | + | command, args := d.ptyCommand(env) | |
| 74 | + | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | |
| 73 | 75 | if err != nil { | |
| 74 | 76 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 75 | 77 | tb.mu.Lock() | |
| @@ -35,6 +35,7 @@ func (d *Daemon) spawnPaneOp( | |||||
| 35 | sess.mu.Lock() | 35 | sess.mu.Lock() | ||
| 36 | name, cwd := sess.name, sess.cwd | 36 | name, cwd := sess.name, sess.cwd | ||
| 37 | term := sess.terminal | 37 | term := sess.terminal | ||
| 38 | + | env := copyEnvironment(sess.env) | |||
| 38 | sess.mu.Unlock() | 39 | sess.mu.Unlock() | ||
| 39 | 40 | ||||
| 40 | tb.mu.Lock() | 41 | tb.mu.Lock() | ||
| @@ -69,7 +70,8 @@ func (d *Daemon) spawnPaneOp( | |||||
| 69 | tb.mu.Unlock() | 70 | tb.mu.Unlock() | ||
| 70 | return fmt.Errorf("daemon: generating pane identity: %w", err) | 71 | return fmt.Errorf("daemon: generating pane identity: %w", err) | ||
| 71 | } | 72 | } | ||
| 72 | - | pty, err := d.ptys.Open(sess.ctx, d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | 73 | + | command, args := d.ptyCommand(env) |
| 74 | + | pty, err := d.ptys.Open(sess.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect)) | |||
| 73 | if err != nil { | 75 | if err != nil { | ||
| 74 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | 76 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | ||
| 75 | tb.mu.Lock() | 77 | tb.mu.Lock() | ||
M internal/usecase/daemon/pane_actions_test.go +24 -24
| @@ -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) { | |
| 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 | |
| 340 | 339 | }{ | |
| 341 | 340 | { | |
| 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"}, | |
| 345 | 344 | }, | |
| 346 | 345 | { | |
| 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"}, | |
| 351 | 348 | }, | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | for _, tt := range tests { | |
| 349 | + | } { | |
| 355 | 350 | t.Run(tt.name, func(t *testing.T) { | |
| 356 | 351 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10}) | |
| 352 | + | d.shellOverride = false | |
| 357 | 353 | 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 | 355 | newPTY := portsmocks.NewMockPTY(t) | |
| 359 | 356 | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once() | |
| 360 | 357 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 358 | + | var command string | |
| 361 | 359 | 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 | |
| 364 | 363 | gotEnv = append([]string(nil), env...) | |
| 365 | 364 | return newPTY, nil | |
| 366 | 365 | }, | |
| 367 | 366 | ).Once() | |
| 368 | 367 | ||
| 369 | 368 | 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) | |
| 376 | 376 | }) | |
| 377 | 377 | } | |
| 378 | 378 | } | |
| @@ -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 string | 336 | + | name string |
| 337 | - | term terminalEnv | 337 | + | term terminalEnv |
| 338 | - | wantContain []string | 338 | + | 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.term | 353 | 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 []string | 359 | 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, nil | 364 | 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 | |||
| 427 | 427 | return nil | |
| 428 | 428 | } | |
| 429 | 429 | term := from.terminal | |
| 430 | + | env := copyEnvironment(from.env) | |
| 430 | 431 | from.client = nil | |
| 431 | 432 | ac.setSession(nil) | |
| 432 | 433 | from.mu.Unlock() | |
| @@ -434,6 +435,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 434 | 435 | targetSess.mu.Lock() | |
| 435 | 436 | old := targetSess.client | |
| 436 | 437 | targetSess.terminal = term | |
| 438 | + | targetSess.env = copyEnvironment(env) | |
| 437 | 439 | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | |
| 438 | 440 | targetSess.active = target.TabIndex | |
| 439 | 441 | } | |
| @@ -468,8 +470,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 468 | 470 | return false | |
| 469 | 471 | } | |
| 470 | 472 | term := from.terminal | |
| 473 | + | env := copyEnvironment(from.env) | |
| 471 | 474 | cwd := d.dirOrHome(stopped.cwd) | |
| 472 | - | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, stopped.tabNames) | |
| 475 | + | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | |
| 473 | 476 | if err != nil { | |
| 474 | 477 | from.mu.Unlock() | |
| 475 | 478 | d.mu.Unlock() | |
| @@ -427,6 +427,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 427 | return nil | 427 | return nil | ||
| 428 | } | 428 | } | ||
| 429 | term := from.terminal | 429 | term := from.terminal | ||
| 430 | + | env := copyEnvironment(from.env) | |||
| 430 | from.client = nil | 431 | 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.client | 436 | old := targetSess.client | ||
| 436 | targetSess.terminal = term | 437 | targetSess.terminal = term | ||
| 438 | + | targetSess.env = copyEnvironment(env) | |||
| 437 | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | 439 | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | ||
| 438 | targetSess.active = target.TabIndex | 440 | targetSess.active = target.TabIndex | ||
| 439 | } | 441 | } | ||
| @@ -468,8 +470,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 468 | return false | 470 | return false | ||
| 469 | } | 471 | } | ||
| 470 | term := from.terminal | 472 | term := from.terminal | ||
| 473 | + | env := 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) { | |||
| 445 | 445 | defer releasePTY2() | |
| 446 | 446 | defer releasePTY3() | |
| 447 | 447 | var openedEnv []string | |
| 448 | + | var openedCommand string | |
| 448 | 449 | f := portsmocks.NewMockPTYFactory(t) | |
| 449 | 450 | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 450 | - | func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 451 | + | func(_ context.Context, command string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { | |
| 451 | 452 | if sz != (domain.Size{Cols: 80, Rows: 22}) { | |
| 452 | 453 | return newQuietPTY(), nil | |
| 453 | 454 | } | |
| 455 | + | openedCommand = command | |
| 454 | 456 | openedEnv = append([]string(nil), env...) | |
| 455 | 457 | return p3, nil | |
| 456 | 458 | }, | |
| @@ -464,8 +466,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 464 | 466 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 465 | 467 | defer cancel1() | |
| 466 | 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{}} | |
| 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"}} | |
| 469 | 471 | ac1.setSession(sess1) | |
| 470 | 472 | ac2.setSession(sess2) | |
| 471 | 473 | d.sessions[sess1.id] = sess1 | |
| @@ -476,9 +478,13 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 476 | 478 | require.Same(t, ac2, old) | |
| 477 | 479 | require.False(t, sess2.terminal.TrueColor) | |
| 478 | 480 | 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") | |
| 482 | 488 | } | |
| 483 | 489 | ||
| 484 | 490 | 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 []string | 447 | var openedEnv []string | ||
| 448 | + | var openedCommand string | |||
| 448 | f := portsmocks.NewMockPTYFactory(t) | 449 | f := portsmocks.NewMockPTYFactory(t) | ||
| 449 | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 450 | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | ||
| 450 | - | func(_ context.Context, _ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { | 451 | + | func(_ context.Context, command string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { |
| 451 | if sz != (domain.Size{Cols: 80, Rows: 22}) { | 452 | if sz != (domain.Size{Cols: 80, Rows: 22}) { | ||
| 452 | return newQuietPTY(), nil | 453 | return newQuietPTY(), nil | ||
| 453 | } | 454 | } | ||
| 455 | + | openedCommand = command | |||
| 454 | openedEnv = append([]string(nil), env...) | 456 | openedEnv = append([]string(nil), env...) | ||
| 455 | return p3, nil | 457 | return p3, nil | ||
| 456 | }, | 458 | }, | ||
| @@ -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] = sess1 | 473 | 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 | |||
| 184 | 184 | ac.resumeToken = d.nextResumeTokenLocked() | |
| 185 | 185 | ac.parked = false | |
| 186 | 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 | 189 | sess.mu.Lock() | |
| 188 | - | sess.client = ac | |
| 190 | + | sess.env = copyEnvironment(h.Env) | |
| 189 | 191 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | |
| 192 | + | sess.client = ac | |
| 190 | 193 | sess.mu.Unlock() | |
| 191 | 194 | d.attachCoordinator(sess, nil, ac, false) | |
| 192 | 195 | 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 = false | 185 | 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 = ac | 190 | + | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| 11 | + | "github.com/stretchr/testify/mock" | |
| 10 | 12 | "github.com/stretchr/testify/require" | |
| 11 | 13 | ||
| 12 | 14 | "github.com/bnema/vev/internal/domain" | |
| 13 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | 17 | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 18 | "github.com/bnema/vev/pkg/vt" | |
| 16 | 19 | ) | |
| @@ -678,13 +681,31 @@ func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) { | |||
| 678 | 681 | require.Zero(t, ac.echoAck.Load()) | |
| 679 | 682 | } | |
| 680 | 683 | ||
| 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{}) | |
| 685 | 706 | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 686 | 707 | hello := helloResumeCapable(ports.IntentNew, "work", 0) | |
| 687 | - | hello.TrueColor = false | |
| 708 | + | hello.Env = []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old"} | |
| 688 | 709 | sess, ac, err := d.route(hello, tr) | |
| 689 | 710 | require.NoError(t, err) | |
| 690 | 711 | token := ac.resumeToken | |
| @@ -692,13 +713,21 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) { | |||
| 692 | 713 | require.True(t, d.parkAttachment(sess, ac)) | |
| 693 | 714 | ||
| 694 | 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 | 717 | resumeHello.TrueColor = true | |
| 696 | 718 | _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 697 | 719 | require.NoError(t, err) | |
| 698 | 720 | require.True(t, ok) | |
| 721 | + | require.NoError(t, d.createTab(sess, domain.Size{Cols: 80, Rows: 24})) | |
| 722 | + | ||
| 699 | 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 | 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 | 733 | func TestResumeRenegotiatesOutputWindowOnReusedStream(t *testing.T) { | |
| @@ -1,16 +1,19 @@ | |||||
| 1 | package daemon | 1 | 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 = false | 708 | + | 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.resumeToken | 711 | 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 = true | 717 | 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 { | |||
| 40 | 40 | clipboardWorkerRunning bool | |
| 41 | 41 | cwd string | |
| 42 | 42 | terminal terminalEnv | |
| 43 | - | createdAt int64 | |
| 44 | - | mruAt atomic.Uint64 | |
| 45 | - | snapDirty atomic.Bool | |
| 46 | - | snapEligible atomic.Bool | |
| 43 | + | // env is the authoritative immutable environment snapshot for future PTY children. | |
| 44 | + | // It is guarded by mu and is always copied on ingress and egress. | |
| 45 | + | env []string | |
| 46 | + | createdAt int64 | |
| 47 | + | mruAt atomic.Uint64 | |
| 48 | + | snapDirty atomic.Bool | |
| 49 | + | snapEligible atomic.Bool | |
| 47 | 50 | // snapshotMu serializes the dirty generation with worker completion. It is | |
| 48 | 51 | // intentionally independent from mu: persistence never holds session state | |
| 49 | 52 | // locks while encoding or writing. | |
| @@ -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 | 134 | tbSize := tabSize(sz) | |
| 131 | 135 | var names []string | |
| 132 | 136 | if len(restoredTabNames) > 0 { | |
| @@ -139,7 +143,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 139 | 143 | closeTabs(tabs) | |
| 140 | 144 | 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 | 148 | if err != nil { | |
| 144 | 149 | closeTabs(tabs) | |
| 145 | 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 | 180 | tabs: tabs, | |
| 176 | 181 | cwd: cwd, | |
| 177 | 182 | terminal: term, | |
| 183 | + | env: env, | |
| 178 | 184 | createdAt: createdAt, | |
| 179 | 185 | } | |
| 180 | 186 | if lastUsedSeq > 0 { | |
| @@ -229,6 +235,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 229 | 235 | from.mu.Lock() | |
| 230 | 236 | cwd := from.cwd | |
| 231 | 237 | term := from.terminal | |
| 238 | + | env := copyEnvironment(from.env) | |
| 232 | 239 | if from.client != ac { | |
| 233 | 240 | from.mu.Unlock() | |
| 234 | 241 | d.mu.Unlock() | |
| @@ -236,7 +243,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 236 | 243 | } | |
| 237 | 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 | 247 | if err != nil { | |
| 241 | 248 | d.mu.Unlock() | |
| 242 | 249 | return err | |
| @@ -276,12 +283,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 276 | 283 | cwd := sess.cwd | |
| 277 | 284 | client := sess.client | |
| 278 | 285 | term := sess.terminal | |
| 286 | + | env := copyEnvironment(sess.env) | |
| 279 | 287 | sess.mu.Unlock() | |
| 280 | 288 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 281 | 289 | if err != nil { | |
| 282 | 290 | 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 | 294 | if err != nil { | |
| 286 | 295 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 287 | 296 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | |
| @@ -899,18 +908,54 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 899 | 908 | d.mu.Unlock() | |
| 900 | 909 | } | |
| 901 | 910 | ||
| 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. | |
| 904 | 913 | 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") { | |
| 911 | 956 | continue | |
| 912 | 957 | } | |
| 913 | - | out = append(out, e) | |
| 958 | + | out = append(out, entry) | |
| 914 | 959 | } | |
| 915 | 960 | if term.TrueColor { | |
| 916 | 961 | out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor") | |
| @@ -40,10 +40,13 @@ type session struct { | |||||
| 40 | clipboardWorkerRunning bool | 40 | clipboardWorkerRunning bool | ||
| 41 | cwd string | 41 | cwd string | ||
| 42 | terminal terminalEnv | 42 | terminal terminalEnv | ||
| 43 | - | createdAt int64 | 43 | + | // env is the authoritative immutable environment snapshot for future PTY children. |
| 44 | - | mruAt atomic.Uint64 | 44 | + | // It is guarded by mu and is always copied on ingress and egress. |
| 45 | - | snapDirty atomic.Bool | 45 | + | env []string |
| 46 | - | snapEligible atomic.Bool | 46 | + | createdAt int64 |
| 47 | + | mruAt atomic.Uint64 | |||
| 48 | + | snapDirty atomic.Bool | |||
| 49 | + | snapEligible atomic.Bool | |||
| 47 | // snapshotMu serializes the dirty generation with worker completion. It is | 50 | // snapshotMu serializes the dirty generation with worker completion. It is | ||
| 48 | // intentionally independent from mu: persistence never holds session state | 51 | // intentionally independent from mu: persistence never holds session state | ||
| 49 | // locks while encoding or writing. | 52 | // locks while encoding or writing. | ||
| @@ -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 []string | 135 | 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, err | 144 | 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.cwd | 236 | cwd := from.cwd | ||
| 231 | term := from.terminal | 237 | 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 err | 249 | return err | ||
| @@ -276,12 +283,14 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 276 | cwd := sess.cwd | 283 | cwd := sess.cwd | ||
| 277 | client := sess.client | 284 | client := sess.client | ||
| 278 | term := sess.terminal | 285 | 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 err | 290 | 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 terminal | 911 | + | // 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 | continue | 956 | 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) { | |||
| 853 | 853 | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42} | |
| 854 | 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 | 857 | require.NoError(t, err) | |
| 858 | 858 | require.Equal(t, uint64(42), sess.mruAt.Load()) | |
| 859 | 859 | require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq) | |
| @@ -869,7 +869,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||
| 869 | 869 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 870 | 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 | 873 | require.NoError(t, err) | |
| 874 | 874 | require.True(t, state.has("work")) | |
| 875 | 875 | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |
| @@ -893,7 +893,7 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) { | |||
| 893 | 893 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 894 | 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 | 897 | require.NoError(t, err) | |
| 898 | 898 | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |
| 899 | 899 | ||
| @@ -930,7 +930,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) { | |||
| 930 | 930 | WithStore(store)(d) | |
| 931 | 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 | 934 | require.NoError(t, err) | |
| 935 | 935 | require.NoError(t, d.createTab(sess, sz)) | |
| 936 | 936 | require.NoError(t, d.createTab(sess, sz)) | |
| @@ -978,7 +978,7 @@ func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) { | |||
| 978 | 978 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 979 | 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 | 982 | require.NoError(t, err) | |
| 983 | 983 | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |
| 984 | 984 | require.Equal(t, "shell", sess.tabs[0].name) | |
| @@ -1021,7 +1021,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |||
| 1021 | 1021 | WithStore(store)(d) | |
| 1022 | 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 | 1025 | require.NoError(t, err) | |
| 1026 | 1026 | require.False(t, state.has("0")) | |
| 1027 | 1027 | require.EqualError(t, d.renameSession(sess, "taken"), "name already in use") | |
| @@ -1039,7 +1039,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | |||
| 1039 | 1039 | cwd := "/tmp/work" | |
| 1040 | 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 | 1043 | require.NoError(t, err) | |
| 1044 | 1044 | state.mu.Lock() | |
| 1045 | 1045 | setsAfterCreate := state.sets | |
| @@ -1136,7 +1136,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) { | |||
| 1136 | 1136 | d := newTestDaemon(t, f, stubClock{}) | |
| 1137 | 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 | 1140 | require.NoError(t, err) | |
| 1141 | 1141 | defer func() { | |
| 1142 | 1142 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| @@ -1192,7 +1192,7 @@ func TestRenameSessionUnsafeNameRejected(t *testing.T) { | |||
| 1192 | 1192 | p, release := newBlockingPTY(t) | |
| 1193 | 1193 | defer release() | |
| 1194 | 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 | 1196 | require.NoError(t, err) | |
| 1197 | 1197 | require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName) | |
| 1198 | 1198 | } | |
| @@ -1208,9 +1208,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |||
| 1208 | 1208 | WithStore(store)(d) | |
| 1209 | 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 | 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 | 1214 | require.NoError(t, err) | |
| 1215 | 1215 | _ = d.killSession(natural, ports.ReasonSessionKilled, false) | |
| 1216 | 1216 | require.True(t, state.has("natural")) | |
| @@ -1422,3 +1422,79 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1422 | 1422 | release2() | |
| 1423 | 1423 | d.sessWg.Wait() | |
| 1424 | 1424 | } | |
| 1425 | + | ||
| 1426 | + | func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) { | |
| 1427 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 1428 | + | p1, release1 := newBlockingPTY(t) | |
| 1429 | + | defer release1() | |
| 1430 | + | p2, release2 := newBlockingPTY(t) | |
| 1431 | + | defer release2() | |
| 1432 | + | ||
| 1433 | + | var commands []string | |
| 1434 | + | var envs [][]string | |
| 1435 | + | f := portsmocks.NewMockPTYFactory(t) | |
| 1436 | + | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} | |
| 1437 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |
| 1438 | + | func(_ context.Context, command string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | |
| 1439 | + | commands = append(commands, command) | |
| 1440 | + | envs = append(envs, append([]string(nil), env...)) | |
| 1441 | + | if len(envs) == 1 { | |
| 1442 | + | return p1, nil | |
| 1443 | + | } | |
| 1444 | + | return p2, nil | |
| 1445 | + | }, | |
| 1446 | + | ).Twice() | |
| 1447 | + | floating := newQuietPTY() | |
| 1448 | + | expectFloatingPrewarmOpen(f, normalSize, floating) | |
| 1449 | + | d := newTestDaemon(t, f, stubClock{}) | |
| 1450 | + | tr1 := portsmocks.NewMockTransport(t) | |
| 1451 | + | tr1.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1452 | + | tr1.EXPECT().Close().Return(nil).Maybe() | |
| 1453 | + | tr2 := portsmocks.NewMockTransport(t) | |
| 1454 | + | tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1455 | + | tr2.EXPECT().Close().Return(nil).Maybe() | |
| 1456 | + | ||
| 1457 | + | first := []string{"SECRET=first", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b"} | |
| 1458 | + | sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, Env: first}, tr1) | |
| 1459 | + | require.NoError(t, err) | |
| 1460 | + | second := []string{"SECRET=second", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b"} | |
| 1461 | + | _, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz, Env: second}, tr2) | |
| 1462 | + | require.NoError(t, err) | |
| 1463 | + | defer func() { | |
| 1464 | + | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| 1465 | + | d.sessWg.Wait() | |
| 1466 | + | }() | |
| 1467 | + | ||
| 1468 | + | require.Equal(t, "/usr/bin/fish", commands[0]) | |
| 1469 | + | require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0]) | |
| 1470 | + | require.NoError(t, d.createTab(sess, ac.size)) | |
| 1471 | + | require.Equal(t, "/bin/bash", commands[1]) | |
| 1472 | + | require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1]) | |
| 1473 | + | } | |
| 1474 | + | ||
| 1475 | + | func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) { | |
| 1476 | + | env := []string{"SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "EMPTY="} | |
| 1477 | + | got := childEnvFrom(env, "work", "tab", "pane", terminalEnv{}) | |
| 1478 | + | require.Equal(t, []string{ | |
| 1479 | + | "SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "EMPTY=", | |
| 1480 | + | "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane", | |
| 1481 | + | }, got) | |
| 1482 | + | } | |
| 1483 | + | ||
| 1484 | + | func TestShellFromEnvironmentFallsBackOnlyWhenAbsentOrEmpty(t *testing.T) { | |
| 1485 | + | 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.sets | 1045 | 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 | |||
| 114 | 114 | ||
| 115 | 115 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 116 | 116 | 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. | |
| 120 | 120 | restoreTerm := terminalEnv{} | |
| 121 | + | restoreEnv := copyEnvironment(d.baseEnv) | |
| 121 | 122 | closeOpened := func() { | |
| 122 | 123 | for _, tb := range opened { | |
| 123 | 124 | tb.closeAllPanes() | |
| @@ -189,7 +190,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 189 | 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 | 195 | if err != nil { | |
| 194 | 196 | closeOpened() | |
| 195 | 197 | return err | |
| @@ -217,7 +219,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 217 | 219 | return nil | |
| 218 | 220 | } | |
| 219 | 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 | 223 | sess.snapEligible.Store(true) | |
| 222 | 224 | if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 { | |
| 223 | 225 | 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 restored | 117 | + | // Snapshot restore runs before any client Hello is available. Start panes |
| 118 | - | // panes start with the conservative terminal environment. The next attach or | 118 | + | // 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 err | 197 | return err | ||
| @@ -217,7 +219,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 217 | return nil | 219 | 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].Cwd | 225 | 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) { | |||
| 86 | 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 | 140 | func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |
| 90 | 141 | store := &restoreSnapshotStore{} | |
| 91 | 142 | store.blobs = []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| @@ -541,19 +592,20 @@ type restorePTYFactory struct { | |||
| 541 | 592 | } | |
| 542 | 593 | ||
| 543 | 594 | 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 | |
| 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 | 604 | f.mu.Lock() | |
| 553 | 605 | defer f.mu.Unlock() | |
| 554 | 606 | pty := newRestorePTY() | |
| 555 | 607 | 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 | 609 | return pty, nil | |
| 558 | 610 | } | |
| 559 | 611 | ||
| @@ -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.Context | 595 | + | ctx context.Context |
| 545 | - | dir string | 596 | + | command string |
| 546 | - | size domain.Size | 597 | + | dir string |
| 547 | - | env []string | 598 | + | size domain.Size |
| 548 | - | pty *restorePTY | 599 | + | 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.writeErr | 607 | 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, nil | 609 | 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) { | |||
| 1047 | 1047 | cur := &session{id: "cur", name: "current", tabs: []*tab{{}}} | |
| 1048 | 1048 | 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 | 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 | 1053 | require.NoError(t, err) | |
| 1054 | 1054 | ||
| 1055 | 1055 | 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] = cur | 1048 | 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, "") | ||