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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 649 | 649 | TermEnv: termEnv, | |
| 650 | 650 | Cwd: cwd, | |
| 651 | 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 | 781 | defer func() { _ = transport.Close() }() | |
| 781 | 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 | 786 | var ( | |
| @@ -920,10 +921,7 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||
| 920 | 921 | return err | |
| 921 | 922 | } | |
| 922 | 923 | ||
| 923 | - | var dgramTr ports.Transport = dg | |
| 924 | - | if session != "" { | |
| 925 | - | dgramTr = preHelloNameTransport{Transport: dg, session: session} | |
| 926 | - | } | |
| 924 | + | dgramTr := newFirstHelloTransport(dg, session, os.Environ()) | |
| 927 | 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 | 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 | 1020 | ports.Transport | |
| 1021 | + | mu sync.Mutex | |
| 1022 | + | seen bool | |
| 1020 | 1023 | 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 | 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 | 1038 | 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 | 1048 | h, err := ports.UnmarshalHello(f.Payload) | |
| 1029 | 1049 | if err != nil { | |
| 1030 | 1050 | return f, nil | |
| 1031 | 1051 | } | |
| 1052 | + | h.Env = append([]string(nil), t.env...) | |
| 1032 | 1053 | if h.Name == "" { | |
| 1033 | 1054 | h.Name = t.session | |
| 1034 | - | f.Payload = ports.MarshalHello(h) | |
| 1035 | 1055 | } | |
| 1056 | + | f.Payload = ports.MarshalHello(h) | |
| 1036 | 1057 | return f, nil | |
| 1037 | 1058 | } | |
| 1038 | 1059 | ||
| @@ -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 err | 921 | return err | ||
| 921 | } | 922 | } | ||
| 922 | 923 | ||||
| 923 | - | var dgramTr ports.Transport = dg | 924 | + | 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.Transport | 1020 | ports.Transport | ||
| 1021 | + | mu sync.Mutex | |||
| 1022 | + | seen bool | |||
| 1020 | session string | 1023 | 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, err | 1038 | 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, nil | 1050 | 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.session | 1054 | h.Name = t.session | ||
| 1034 | - | f.Payload = ports.MarshalHello(h) | |||
| 1035 | } | 1055 | } | ||
| 1056 | + | f.Payload = ports.MarshalHello(h) | |||
| 1036 | return f, nil | 1057 | return f, nil | ||
| 1037 | } | 1058 | } | ||
| 1038 | 1059 | ||||
M internal/app/run_test.go +60 -0
| @@ -25,6 +25,65 @@ import ( | |||
| 25 | 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 | 87 | func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) { | |
| 29 | 88 | require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending) | |
| 30 | 89 | } | |
| @@ -452,6 +511,7 @@ func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) { | |||
| 452 | 511 | require.Equal(t, "xterm-direct", hello.TermEnv) | |
| 453 | 512 | require.Equal(t, "/tmp/work", hello.Cwd) | |
| 454 | 513 | require.True(t, hello.TrueColor) | |
| 514 | + | require.Equal(t, os.Environ(), hello.Env) | |
| 455 | 515 | } | |
| 456 | 516 | ||
| 457 | 517 | 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 @@ | |||
| 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 +68 -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,69 @@ 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 | + | }) | |
| 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 | 180 | func TestInputGoldenAndRoundTrip(t *testing.T) { | |
| 118 | 181 | tests := []struct { | |
| 119 | 182 | name string | |
| @@ -574,9 +637,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) { | |||
| 574 | 637 | for _, window := range []uint8{0, 1, 8} { | |
| 575 | 638 | t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) { | |
| 576 | 639 | 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) | |
| 580 | 643 | want[1] = 14 | |
| 581 | 644 | got := MarshalHello(hello) | |
| 582 | 645 | 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 string | 182 | 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] = 14 | 643 | 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 | |||
| 481 | 481 | Cwd: cwd, | |
| 482 | 482 | TrueColor: trueColor, | |
| 483 | 483 | MaxOutputInFlight: requestedOutputWindow(transport), | |
| 484 | + | Env: os.Environ(), | |
| 484 | 485 | } | |
| 485 | 486 | if closed, err := boundedPreWelcome(ctx, clk, transport, func() error { | |
| 486 | 487 | 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 ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "os" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "sync/atomic" | |
| 11 | 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 | 341 | func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) { | |
| 311 | 342 | var out bytes.Buffer | |
| 312 | 343 | 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.Buffer | 342 | var out bytes.Buffer | ||
| 312 | var restoreCount atomic.Int32 | 343 | var restoreCount atomic.Int32 | ||