detached 106fbb7

feat(protocol): attach authoritative environment

commit
106fbb70c72aa34b14cc1f9c644401a735dab020
author
brice · · 2026-07-15 05:14 UTC
committer
brice · · 2026-07-15 05:21 UTC
parent
201f7b0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +237 -17
M internal/app/run.go +30 -9
@@ -649,6 +649,7 @@ func detachedLocalHello(name, cwd string) ports.Hello {
649649 TermEnv: termEnv,
650650 Cwd: cwd,
651651 TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),
652+ Env: os.Environ(),
652653 }
653654 }
654655
@@ -779,7 +780,7 @@ func runStdio(ctx context.Context) (retErr error) {
779780 }
780781 defer func() { _ = transport.Close() }()
781782 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
782- return proxyTransports(ctx, stdio, transport, log)
783+ return proxyTransports(ctx, newFirstHelloTransport(stdio, "", os.Environ()), transport, log)
783784 }
784785
785786 var (
@@ -920,10 +921,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
920921 return err
921922 }
922923
923- var dgramTr ports.Transport = dg
924- if session != "" {
925- dgramTr = preHelloNameTransport{Transport: dg, session: session}
926- }
924+ dgramTr := newFirstHelloTransport(dg, session, os.Environ())
927925 return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
928926 }
929927
@@ -1015,24 +1013,47 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
10151013 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
10161014 }
10171015
1018-type preHelloNameTransport struct {
1016+// firstHelloTransport applies proxy-owned values to exactly the first Hello
1017+// crossing a remote boundary. It intentionally leaves all later frames and
1018+// malformed Hellos untouched so proxying preserves the client stream.
1019+type firstHelloTransport struct {
10191020 ports.Transport
1021+ mu sync.Mutex
1022+ seen bool
10201023 session string
1024+ env []string
1025+}
1026+
1027+func newFirstHelloTransport(transport ports.Transport, session string, env []string) *firstHelloTransport {
1028+ return &firstHelloTransport{
1029+ Transport: transport,
1030+ session: session,
1031+ env: append([]string(nil), env...),
1032+ }
10211033 }
10221034
1023-func (t preHelloNameTransport) Recv() (ports.Frame, error) {
1035+func (t *firstHelloTransport) Recv() (ports.Frame, error) {
10241036 f, err := t.Transport.Recv()
1025- if err != nil || f.Type != ports.MsgHello || t.session == "" {
1037+ if err != nil || f.Type != ports.MsgHello {
10261038 return f, err
10271039 }
1040+
1041+ t.mu.Lock()
1042+ defer t.mu.Unlock()
1043+ if t.seen {
1044+ return f, nil
1045+ }
1046+ t.seen = true
1047+
10281048 h, err := ports.UnmarshalHello(f.Payload)
10291049 if err != nil {
10301050 return f, nil
10311051 }
1052+ h.Env = append([]string(nil), t.env...)
10321053 if h.Name == "" {
10331054 h.Name = t.session
1034- f.Payload = ports.MarshalHello(h)
10351055 }
1056+ f.Payload = ports.MarshalHello(h)
10361057 return f, nil
10371058 }
10381059
@@ -649,6 +649,7 @@ func detachedLocalHello(name, cwd string) ports.Hello {
649 TermEnv: termEnv,649 TermEnv: termEnv,
650 Cwd: cwd,650 Cwd: cwd,
651 TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),651 TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),
652+ Env: os.Environ(),
652 }653 }
653 }654 }
654 655
@@ -779,7 +780,7 @@ func runStdio(ctx context.Context) (retErr error) {
779 }780 }
780 defer func() { _ = transport.Close() }()781 defer func() { _ = transport.Close() }()
781 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))782 stdio := sshstdio.NewTransport(os.Stdin, os.Stdout, nil, sshstdio.WithRuntimeObserver(observer))
782- return proxyTransports(ctx, stdio, transport, log)783+ return proxyTransports(ctx, newFirstHelloTransport(stdio, "", os.Environ()), transport, log)
783 }784 }
784 785
785 var (786 var (
@@ -920,10 +921,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e
920 return err921 return err
921 }922 }
922 923
923- var dgramTr ports.Transport = dg924+ dgramTr := newFirstHelloTransport(dg, session, os.Environ())
924- if session != "" {
925- dgramTr = preHelloNameTransport{Transport: dg, session: session}
926- }
927 return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)925 return dgram.ProxyRuntime{Client: dgramTr, Daemon: daemonTr, Log: log, IdleTTL: udpProxyIdleTTL}.Run(ctx)
928 }926 }
929 927
@@ -1015,24 +1013,47 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
1015 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)1013 return nil, fmt.Errorf("no free UDP port in range %d-%d: %w", r.start, r.end, lastErr)
1016 }1014 }
1017 1015
1018-type preHelloNameTransport struct {1016+// firstHelloTransport applies proxy-owned values to exactly the first Hello
1017+// crossing a remote boundary. It intentionally leaves all later frames and
1018+// malformed Hellos untouched so proxying preserves the client stream.
1019+type firstHelloTransport struct {
1019 ports.Transport1020 ports.Transport
1021+ mu sync.Mutex
1022+ seen bool
1020 session string1023 session string
1024+ env []string
1025+}
1026+
1027+func newFirstHelloTransport(transport ports.Transport, session string, env []string) *firstHelloTransport {
1028+ return &firstHelloTransport{
1029+ Transport: transport,
1030+ session: session,
1031+ env: append([]string(nil), env...),
1032+ }
1021 }1033 }
1022 1034
1023-func (t preHelloNameTransport) Recv() (ports.Frame, error) {1035+func (t *firstHelloTransport) Recv() (ports.Frame, error) {
1024 f, err := t.Transport.Recv()1036 f, err := t.Transport.Recv()
1025- if err != nil || f.Type != ports.MsgHello || t.session == "" {1037+ if err != nil || f.Type != ports.MsgHello {
1026 return f, err1038 return f, err
1027 }1039 }
1040+
1041+ t.mu.Lock()
1042+ defer t.mu.Unlock()
1043+ if t.seen {
1044+ return f, nil
1045+ }
1046+ t.seen = true
1047+
1028 h, err := ports.UnmarshalHello(f.Payload)1048 h, err := ports.UnmarshalHello(f.Payload)
1029 if err != nil {1049 if err != nil {
1030 return f, nil1050 return f, nil
1031 }1051 }
1052+ h.Env = append([]string(nil), t.env...)
1032 if h.Name == "" {1053 if h.Name == "" {
1033 h.Name = t.session1054 h.Name = t.session
1034- f.Payload = ports.MarshalHello(h)
1035 }1055 }
1056+ f.Payload = ports.MarshalHello(h)
1036 return f, nil1057 return f, nil
1037 }1058 }
1038 1059
M internal/app/run_test.go +60 -0
@@ -25,6 +25,65 @@ import (
2525 "github.com/stretchr/testify/require"
2626 )
2727
28+type firstHelloTestTransport struct {
29+ frames []ports.Frame
30+}
31+
32+func (t *firstHelloTestTransport) Send(ports.Frame) error { return nil }
33+func (t *firstHelloTestTransport) Recv() (ports.Frame, error) {
34+ if len(t.frames) == 0 {
35+ return ports.Frame{}, io.EOF
36+ }
37+ frame := t.frames[0]
38+ t.frames = t.frames[1:]
39+ return frame, nil
40+}
41+func (t *firstHelloTestTransport) Close() error { return nil }
42+
43+func TestFirstHelloTransportReplacesRemoteEnvironment(t *testing.T) {
44+ for _, tt := range []struct {
45+ name string
46+ session string
47+ wantName string
48+ }{
49+ {name: "stdio replaces environment", wantName: ""},
50+ {name: "udp proxy replaces environment and retains session rewrite", session: "work", wantName: "work"},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ original := ports.Hello{
54+ Version: ports.ProtocolVersion,
55+ Intent: ports.IntentAttach,
56+ ClientID: [16]byte{1},
57+ ResumeToken: 2,
58+ Size: domain.Size{Cols: 80, Rows: 24},
59+ TermEnv: "client-term",
60+ Cwd: "/client",
61+ TrueColor: true,
62+ MaxOutputInFlight: 8,
63+ Env: []string{"CLIENT=value"},
64+ }
65+ nonHello := ports.Frame{Type: ports.MsgInput, Payload: []byte("unchanged")}
66+ transport := &firstHelloTestTransport{frames: []ports.Frame{
67+ {Type: ports.MsgHello, Payload: ports.MarshalHello(original)},
68+ nonHello,
69+ }}
70+ wrapped := newFirstHelloTransport(transport, tt.session, []string{"REMOTE=one", "TOKEN=a=b=c"})
71+
72+ frame, err := wrapped.Recv()
73+ require.NoError(t, err)
74+ got, err := ports.UnmarshalHello(frame.Payload)
75+ require.NoError(t, err)
76+ original.Name = tt.wantName
77+ original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
78+ require.Equal(t, original, got)
79+
80+ frame, err = wrapped.Recv()
81+ require.NoError(t, err)
82+ require.Equal(t, nonHello, frame)
83+ })
84+ }
85+}
86+
2887 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
2988 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
3089 }
@@ -452,6 +511,7 @@ func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
452511 require.Equal(t, "xterm-direct", hello.TermEnv)
453512 require.Equal(t, "/tmp/work", hello.Cwd)
454513 require.True(t, hello.TrueColor)
514+ require.Equal(t, os.Environ(), hello.Env)
455515 }
456516
457517 type namedDialer struct{ name string }
@@ -25,6 +25,65 @@ import (
25 "github.com/stretchr/testify/require"25 "github.com/stretchr/testify/require"
26 )26 )
27 27
28+type firstHelloTestTransport struct {
29+ frames []ports.Frame
30+}
31+
32+func (t *firstHelloTestTransport) Send(ports.Frame) error { return nil }
33+func (t *firstHelloTestTransport) Recv() (ports.Frame, error) {
34+ if len(t.frames) == 0 {
35+ return ports.Frame{}, io.EOF
36+ }
37+ frame := t.frames[0]
38+ t.frames = t.frames[1:]
39+ return frame, nil
40+}
41+func (t *firstHelloTestTransport) Close() error { return nil }
42+
43+func TestFirstHelloTransportReplacesRemoteEnvironment(t *testing.T) {
44+ for _, tt := range []struct {
45+ name string
46+ session string
47+ wantName string
48+ }{
49+ {name: "stdio replaces environment", wantName: ""},
50+ {name: "udp proxy replaces environment and retains session rewrite", session: "work", wantName: "work"},
51+ } {
52+ t.Run(tt.name, func(t *testing.T) {
53+ original := ports.Hello{
54+ Version: ports.ProtocolVersion,
55+ Intent: ports.IntentAttach,
56+ ClientID: [16]byte{1},
57+ ResumeToken: 2,
58+ Size: domain.Size{Cols: 80, Rows: 24},
59+ TermEnv: "client-term",
60+ Cwd: "/client",
61+ TrueColor: true,
62+ MaxOutputInFlight: 8,
63+ Env: []string{"CLIENT=value"},
64+ }
65+ nonHello := ports.Frame{Type: ports.MsgInput, Payload: []byte("unchanged")}
66+ transport := &firstHelloTestTransport{frames: []ports.Frame{
67+ {Type: ports.MsgHello, Payload: ports.MarshalHello(original)},
68+ nonHello,
69+ }}
70+ wrapped := newFirstHelloTransport(transport, tt.session, []string{"REMOTE=one", "TOKEN=a=b=c"})
71+
72+ frame, err := wrapped.Recv()
73+ require.NoError(t, err)
74+ got, err := ports.UnmarshalHello(frame.Payload)
75+ require.NoError(t, err)
76+ original.Name = tt.wantName
77+ original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
78+ require.Equal(t, original, got)
79+
80+ frame, err = wrapped.Recv()
81+ require.NoError(t, err)
82+ require.Equal(t, nonHello, frame)
83+ })
84+ }
85+}
86+
28 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {87 func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
29 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)88 require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
30 }89 }
@@ -452,6 +511,7 @@ func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
452 require.Equal(t, "xterm-direct", hello.TermEnv)511 require.Equal(t, "xterm-direct", hello.TermEnv)
453 require.Equal(t, "/tmp/work", hello.Cwd)512 require.Equal(t, "/tmp/work", hello.Cwd)
454 require.True(t, hello.TrueColor)513 require.True(t, hello.TrueColor)
514+ require.Equal(t, os.Environ(), hello.Env)
455 }515 }
456 516
457 type namedDialer struct{ name string }517 type namedDialer struct{ name string }
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 14
4+const ProtocolVersion uint16 = 15
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 144+const ProtocolVersion uint16 = 15
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 14 {
7- t.Fatalf("ProtocolVersion = %d, want 14", ProtocolVersion)
6+ if ProtocolVersion != 15 {
7+ t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 14 {6+ if ProtocolVersion != 15 {
7- t.Fatalf("ProtocolVersion = %d, want 14", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/wire.go +44 -0
@@ -69,6 +69,9 @@ type Hello struct {
6969 // MaxOutputInFlight is the requested maximum number of unacknowledged
7070 // state-bearing output frames.
7171 MaxOutputInFlight uint8
72+ // Env is the complete exported client environment for future PTY children.
73+ // Entries are opaque strings so their ordering and contents are preserved.
74+ Env []string
7275 }
7376
7477 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -207,6 +210,11 @@ func (w *payloadWriter) putString(s string) {
207210 w.b = append(w.b, s...)
208211 }
209212
213+func (w *payloadWriter) putLongString(s string) {
214+ w.putUint32(uint32(len(s)))
215+ w.b = append(w.b, s...)
216+}
217+
210218 // payloadReader consumes a message payload field by field in wire order,
211219 // erroring instead of panicking on any short read.
212220 type payloadReader struct {
@@ -271,6 +279,19 @@ func (r *payloadReader) getString() (string, error) {
271279 return s, nil
272280 }
273281
282+func (r *payloadReader) getLongString() (string, error) {
283+ n, err := r.getUint32()
284+ if err != nil {
285+ return "", err
286+ }
287+ if uint64(n) > uint64(len(r.b)) {
288+ return "", errShortPayload
289+ }
290+ s := string(r.b[:int(n)])
291+ r.b = r.b[int(n):]
292+ return s, nil
293+}
294+
274295 // rest consumes and returns all remaining bytes, copied so the result is
275296 // independent of the reader's backing array.
276297 func (r *payloadReader) rest() []byte {
@@ -313,6 +334,10 @@ func MarshalHello(h Hello) []byte {
313334 w.putUint8(0)
314335 }
315336 w.putUint8(h.MaxOutputInFlight)
337+ w.putUint32(uint32(len(h.Env)))
338+ for _, entry := range h.Env {
339+ w.putLongString(entry)
340+ }
316341 return w.b
317342 }
318343
@@ -362,6 +387,25 @@ func UnmarshalHello(b []byte) (Hello, error) {
362387 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {
363388 return Hello{}, err
364389 }
390+ envCount, err := r.getUint32()
391+ if err != nil {
392+ return Hello{}, err
393+ }
394+ // Each entry has at least its uint32 byte length. Check that before
395+ // allocating so a malformed count cannot force an excessive allocation.
396+ if uint64(envCount) > uint64(len(r.b)/4) {
397+ return Hello{}, errShortPayload
398+ }
399+ if envCount != 0 {
400+ h.Env = make([]string, 0, int(envCount))
401+ for range int(envCount) {
402+ entry, err := r.getLongString()
403+ if err != nil {
404+ return Hello{}, err
405+ }
406+ h.Env = append(h.Env, entry)
407+ }
408+ }
365409 if err := r.done(); err != nil {
366410 return Hello{}, err
367411 }
@@ -69,6 +69,9 @@ type Hello struct {
69 // MaxOutputInFlight is the requested maximum number of unacknowledged69 // MaxOutputInFlight is the requested maximum number of unacknowledged
70 // state-bearing output frames.70 // state-bearing output frames.
71 MaxOutputInFlight uint871 MaxOutputInFlight uint8
72+ // Env is the complete exported client environment for future PTY children.
73+ // Entries are opaque strings so their ordering and contents are preserved.
74+ Env []string
72 }75 }
73 76
74 // Input carries raw bytes typed/pasted by the client, destined for the PTY.77 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -207,6 +210,11 @@ func (w *payloadWriter) putString(s string) {
207 w.b = append(w.b, s...)210 w.b = append(w.b, s...)
208 }211 }
209 212
213+func (w *payloadWriter) putLongString(s string) {
214+ w.putUint32(uint32(len(s)))
215+ w.b = append(w.b, s...)
216+}
217+
210 // payloadReader consumes a message payload field by field in wire order,218 // payloadReader consumes a message payload field by field in wire order,
211 // erroring instead of panicking on any short read.219 // erroring instead of panicking on any short read.
212 type payloadReader struct {220 type payloadReader struct {
@@ -271,6 +279,19 @@ func (r *payloadReader) getString() (string, error) {
271 return s, nil279 return s, nil
272 }280 }
273 281
282+func (r *payloadReader) getLongString() (string, error) {
283+ n, err := r.getUint32()
284+ if err != nil {
285+ return "", err
286+ }
287+ if uint64(n) > uint64(len(r.b)) {
288+ return "", errShortPayload
289+ }
290+ s := string(r.b[:int(n)])
291+ r.b = r.b[int(n):]
292+ return s, nil
293+}
294+
274 // rest consumes and returns all remaining bytes, copied so the result is295 // rest consumes and returns all remaining bytes, copied so the result is
275 // independent of the reader's backing array.296 // independent of the reader's backing array.
276 func (r *payloadReader) rest() []byte {297 func (r *payloadReader) rest() []byte {
@@ -313,6 +334,10 @@ func MarshalHello(h Hello) []byte {
313 w.putUint8(0)334 w.putUint8(0)
314 }335 }
315 w.putUint8(h.MaxOutputInFlight)336 w.putUint8(h.MaxOutputInFlight)
337+ w.putUint32(uint32(len(h.Env)))
338+ for _, entry := range h.Env {
339+ w.putLongString(entry)
340+ }
316 return w.b341 return w.b
317 }342 }
318 343
@@ -362,6 +387,25 @@ func UnmarshalHello(b []byte) (Hello, error) {
362 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {387 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {
363 return Hello{}, err388 return Hello{}, err
364 }389 }
390+ envCount, err := r.getUint32()
391+ if err != nil {
392+ return Hello{}, err
393+ }
394+ // Each entry has at least its uint32 byte length. Check that before
395+ // allocating so a malformed count cannot force an excessive allocation.
396+ if uint64(envCount) > uint64(len(r.b)/4) {
397+ return Hello{}, errShortPayload
398+ }
399+ if envCount != 0 {
400+ h.Env = make([]string, 0, int(envCount))
401+ for range int(envCount) {
402+ entry, err := r.getLongString()
403+ if err != nil {
404+ return Hello{}, err
405+ }
406+ h.Env = append(h.Env, entry)
407+ }
408+ }
365 if err := r.done(); err != nil {409 if err := r.done(); err != nil {
366 return Hello{}, err410 return Hello{}, err
367 }411 }
M internal/ports/wire_test.go +68 -5
@@ -72,7 +72,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7272 TrueColor: true,
7373 MaxOutputInFlight: 8,
7474 },
75- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08},
75+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00},
7676 },
7777 {
7878 name: "empty strings",
@@ -85,7 +85,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8585 Cwd: "",
8686 TrueColor: false,
8787 },
88- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
88+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8989 },
9090 }
9191
@@ -114,6 +114,69 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
114114 assertTrailingGarbageFails(t, full, UnmarshalHello)
115115 }
116116
117+func TestHelloEnvironmentCodec(t *testing.T) {
118+ t.Run("literal wire layout", func(t *testing.T) {
119+ got := MarshalHello(Hello{
120+ Version: ProtocolVersion,
121+ Env: []string{"A=B", "XY=123"},
122+ })
123+ want := []byte{
124+ 0x00, 0x0f, 0x00, // version, intent
125+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
128+ 0x00, 0x00, // name
129+ 0x00, 0x00, 0x00, 0x00, // size
130+ 0x00, 0x00, // TERM
131+ 0x00, 0x00, // cwd
132+ 0x00, 0x00, // true color, max output in flight
133+ 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
134+ 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
135+ 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
136+ }
137+ require.Equal(t, want, got)
138+ })
139+
140+ t.Run("lossless order and values", func(t *testing.T) {
141+ entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
142+ want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
143+ payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want})
144+ got, err := UnmarshalHello(payload)
145+ require.NoError(t, err)
146+ require.Equal(t, want, got.Env)
147+ })
148+
149+ t.Run("empty", func(t *testing.T) {
150+ payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}})
151+ got, err := UnmarshalHello(payload)
152+ require.NoError(t, err)
153+ require.Empty(t, got.Env)
154+ })
155+
156+ base := MarshalHello(Hello{Version: ProtocolVersion})
157+ withCount := func(count byte) []byte {
158+ payload := append([]byte(nil), base...)
159+ payload[len(payload)-1] = count
160+ return payload
161+ }
162+ tests := []struct {
163+ name string
164+ payload []byte
165+ }{
166+ {name: "truncated count", payload: base[:len(base)-1]},
167+ {name: "truncated entry length", payload: append(withCount(1), 0, 0)},
168+ {name: "truncated entry body", payload: append(withCount(1), 0, 0, 0, 3, 'x')},
169+ {name: "impossible count", payload: withCount(2)},
170+ {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
171+ }
172+ for _, tt := range tests {
173+ t.Run(tt.name, func(t *testing.T) {
174+ _, err := UnmarshalHello(tt.payload)
175+ require.Error(t, err)
176+ })
177+ }
178+}
179+
117180 func TestInputGoldenAndRoundTrip(t *testing.T) {
118181 tests := []struct {
119182 name string
@@ -574,9 +637,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
574637 for _, window := range []uint8{0, 1, 8} {
575638 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
576639 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)
640+ // The empty Hello has 38 zero bytes before the negotiated output window,
641+ // followed by a uint32 zero environment-entry count.
642+ want := append(make([]byte, 38), window, 0, 0, 0, 0)
580643 want[1] = 14
581644 got := MarshalHello(hello)
582645 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,69 @@ 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+ })
139+
140+ t.Run("lossless order and values", func(t *testing.T) {
141+ entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
142+ want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
143+ payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want})
144+ got, err := UnmarshalHello(payload)
145+ require.NoError(t, err)
146+ require.Equal(t, want, got.Env)
147+ })
148+
149+ t.Run("empty", func(t *testing.T) {
150+ payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}})
151+ got, err := UnmarshalHello(payload)
152+ require.NoError(t, err)
153+ require.Empty(t, got.Env)
154+ })
155+
156+ base := MarshalHello(Hello{Version: ProtocolVersion})
157+ withCount := func(count byte) []byte {
158+ payload := append([]byte(nil), base...)
159+ payload[len(payload)-1] = count
160+ return payload
161+ }
162+ tests := []struct {
163+ name string
164+ payload []byte
165+ }{
166+ {name: "truncated count", payload: base[:len(base)-1]},
167+ {name: "truncated entry length", payload: append(withCount(1), 0, 0)},
168+ {name: "truncated entry body", payload: append(withCount(1), 0, 0, 0, 3, 'x')},
169+ {name: "impossible count", payload: withCount(2)},
170+ {name: "trailing garbage", payload: append(append([]byte(nil), base...), 0xff)},
171+ }
172+ for _, tt := range tests {
173+ t.Run(tt.name, func(t *testing.T) {
174+ _, err := UnmarshalHello(tt.payload)
175+ require.Error(t, err)
176+ })
177+ }
178+}
179+
117 func TestInputGoldenAndRoundTrip(t *testing.T) {180 func TestInputGoldenAndRoundTrip(t *testing.T) {
118 tests := []struct {181 tests := []struct {
119 name string182 name string
@@ -574,9 +637,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
574 for _, window := range []uint8{0, 1, 8} {637 for _, window := range []uint8{0, 1, 8} {
575 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {638 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
576 hello := Hello{Version: 14, MaxOutputInFlight: window}639 hello := Hello{Version: 14, MaxOutputInFlight: window}
577- // Every preceding field in this deliberately empty Hello has a fixed,640+ // The empty Hello has 38 zero bytes before the negotiated output window,
578- // byte-exact zero encoding; the final byte is the negotiated window.641+ // followed by a uint32 zero environment-entry count.
579- want := append(make([]byte, 38), window)642+ want := append(make([]byte, 38), window, 0, 0, 0, 0)
580 want[1] = 14643 want[1] = 14
581 got := MarshalHello(hello)644 got := MarshalHello(hello)
582 requireBytesEqual(t, want, got)645 requireBytesEqual(t, want, got)
M internal/usecase/client/client.go +1 -0
@@ -481,6 +481,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
481481 Cwd: cwd,
482482 TrueColor: trueColor,
483483 MaxOutputInFlight: requestedOutputWindow(transport),
484+ Env: os.Environ(),
484485 }
485486 if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
486487 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
@@ -481,6 +481,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
481 Cwd: cwd,481 Cwd: cwd,
482 TrueColor: trueColor,482 TrueColor: trueColor,
483 MaxOutputInFlight: requestedOutputWindow(transport),483 MaxOutputInFlight: requestedOutputWindow(transport),
484+ Env: os.Environ(),
484 }485 }
485 if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {486 if closed, err := boundedPreWelcome(ctx, clk, transport, func() error {
486 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})487 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
M internal/usecase/client/client_test.go +31 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "log/slog"
9+ "os"
910 "sync"
1011 "sync/atomic"
1112 "testing"
@@ -307,6 +308,36 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
307308 }
308309 }
309310
311+func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
312+ t.Setenv("VEV_CLIENT_ENV_TEST", "TOKEN=a=b=c")
313+
314+ var out bytes.Buffer
315+ var restoreCount atomic.Int32
316+ resizeCh := make(chan domain.Size)
317+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
318+ defer in.unblock()
319+
320+ gotHello := make(chan ports.Hello, 1)
321+ tr := portsmocks.NewMockTransport(t)
322+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
323+ hello, err := ports.UnmarshalHello(f.Payload)
324+ if err != nil {
325+ return err
326+ }
327+ gotHello <- hello
328+ return nil
329+ }).Once()
330+ unblock := scriptRecv(tr,
331+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
332+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
333+ )
334+ defer unblock()
335+ tr.EXPECT().Close().Return(nil).Once()
336+
337+ require.NoError(t, client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, ""))
338+ require.Equal(t, os.Environ(), (<-gotHello).Env)
339+}
340+
310341 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
311342 var out bytes.Buffer
312343 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"
@@ -307,6 +308,36 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
307 }308 }
308 }309 }
309 310
311+func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
312+ t.Setenv("VEV_CLIENT_ENV_TEST", "TOKEN=a=b=c")
313+
314+ var out bytes.Buffer
315+ var restoreCount atomic.Int32
316+ resizeCh := make(chan domain.Size)
317+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
318+ defer in.unblock()
319+
320+ gotHello := make(chan ports.Hello, 1)
321+ tr := portsmocks.NewMockTransport(t)
322+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
323+ hello, err := ports.UnmarshalHello(f.Payload)
324+ if err != nil {
325+ return err
326+ }
327+ gotHello <- hello
328+ return nil
329+ }).Once()
330+ unblock := scriptRecv(tr,
331+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
332+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
333+ )
334+ defer unblock()
335+ tr.EXPECT().Close().Return(nil).Once()
336+
337+ require.NoError(t, client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, ""))
338+ require.Equal(t, os.Environ(), (<-gotHello).Env)
339+}
340+
310 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {341 func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
311 var out bytes.Buffer342 var out bytes.Buffer
312 var restoreCount atomic.Int32343 var restoreCount atomic.Int32

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close