fix(navigation): restore hybrid local selection and route history (#257)
* fix(navigation): hand off local tabs from transient home pickers * test(navigation): harden sandbox prerequisite diagnostics * test(navigation): keep visual sandbox tooling out of the fix * style(wire): consolidate tab handoff test imports
- commit
- 823056118810cbfedca99840e0b2b88f37d60ec6
- author
- bnema · · 2026-09-05 23:06 UTC
- committer
- GitHub · · 2026-09-05 23:06 UTC
- parent
- bc06929
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
17 files · +205 -36
M docs/architecture.md +1 -1
| @@ -48,4 +48,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||
| 48 | 48 | - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`. | |
| 49 | 49 | - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`. | |
| 50 | 50 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | |
| 51 | - | - Keep protocol version `37` unless a negotiated wire layout changes. | |
| 51 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `40`). | |
| @@ -48,4 +48,4 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values. | |||||
| 48 | - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`. | 48 | - Add remote catalogue schema fields and validation to `internal/protocol/catalogue`. | ||
| 49 | - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`. | 49 | - Add message IDs, binary layouts, strict decoding, compression, or raw carriage contracts to `internal/protocol/wire`. | ||
| 50 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | 50 | - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`. | ||
| 51 | - | - Keep protocol version `37` unless a negotiated wire layout changes. | 51 | + | - Bump `internal/protocol.Version` for negotiated wire layout changes (currently `40`). |
M docs/remote-resilience.md +6 -1
| @@ -82,7 +82,12 @@ session order, on either UDP or SSH stdio. Its status bar keeps the source | |||
| 82 | 82 | session label (for example, `misc@igor`), not the local session hosting the | |
| 83 | 83 | picker. Local backing-session tabs and bar-script output are hidden while | |
| 84 | 84 | this temporary attachment renders the picker. Selecting a destination commits | |
| 85 | - | that route; Back returns to the source. | |
| 85 | + | that route; Back returns to the source. Local tab selections also hand off to | |
| 86 | + | the client, including selections in the backing session itself: the temporary | |
| 87 | + | picker connection never becomes the destination attachment. Protocol version | |
| 88 | + | 40 carries the explicit local tab in `AttachTarget.PreferredTabID`; it overrides | |
| 89 | + | remembered tab state. If that tab disappears before attach, the existing Hello | |
| 90 | + | default-tab fallback applies. Upgrade the client and both daemons together. | |
| 86 | 91 | ||
| 87 | 92 | ## Durable record compatibility | |
| 88 | 93 | ||
| @@ -82,7 +82,12 @@ session order, on either UDP or SSH stdio. Its status bar keeps the source | |||||
| 82 | session label (for example, `misc@igor`), not the local session hosting the | 82 | session label (for example, `misc@igor`), not the local session hosting the | ||
| 83 | picker. Local backing-session tabs and bar-script output are hidden while | 83 | picker. Local backing-session tabs and bar-script output are hidden while | ||
| 84 | this temporary attachment renders the picker. Selecting a destination commits | 84 | this temporary attachment renders the picker. Selecting a destination commits | ||
| 85 | - | that route; Back returns to the source. | 85 | + | that route; Back returns to the source. Local tab selections also hand off to |
| 86 | + | the client, including selections in the backing session itself: the temporary | |||
| 87 | + | picker connection never becomes the destination attachment. Protocol version | |||
| 88 | + | 40 carries the explicit local tab in `AttachTarget.PreferredTabID`; it overrides | |||
| 89 | + | remembered tab state. If that tab disappears before attach, the existing Hello | |||
| 90 | + | default-tab fallback applies. Upgrade the client and both daemons together. | |||
| 86 | 91 | ||||
| 87 | ## Durable record compatibility | 92 | ## Durable record compatibility | ||
| 88 | 93 | ||||
M internal/protocol/session.go +8 -1
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // Version is the negotiated vev session protocol version. | |
| 16 | - | const Version uint16 = 39 | |
| 16 | + | const Version uint16 = 40 | |
| 17 | 17 | ||
| 18 | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 19 | 19 | // first committed publication. | |
| @@ -205,6 +205,10 @@ type AttachTarget struct { | |||
| 205 | 205 | ExactTarget *ExactSessionTarget | |
| 206 | 206 | EnvironmentPolicy EnvironmentPolicy | |
| 207 | 207 | SamePeer bool | |
| 208 | + | // PreferredTabID carries an explicit local tab selection across a handoff. | |
| 209 | + | // Empty retains the client's route memory; a disappeared tab uses Hello's | |
| 210 | + | // existing default-tab fallback. | |
| 211 | + | PreferredTabID domain.TabStableID | |
| 208 | 212 | } | |
| 209 | 213 | ||
| 210 | 214 | type Detached struct{ Reason uint8 } | |
| @@ -395,6 +399,9 @@ func ValidateAttachTarget(m AttachTarget) error { | |||
| 395 | 399 | if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) { | |
| 396 | 400 | return ErrInvalidAttachTarget | |
| 397 | 401 | } | |
| 402 | + | if m.PreferredTabID != "" && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) { | |
| 403 | + | return ErrInvalidAttachTarget | |
| 404 | + | } | |
| 398 | 405 | if m.RemoteTarget == nil { | |
| 399 | 406 | return nil | |
| 400 | 407 | } | |
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // Version is the negotiated vev session protocol version. | 15 | // Version is the negotiated vev session protocol version. | ||
| 16 | - | const Version uint16 = 39 | 16 | + | const Version uint16 = 40 |
| 17 | 17 | ||||
| 18 | // HandshakeTimeout bounds every transport handshake from connect through the | 18 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 19 | // first committed publication. | 19 | // first committed publication. | ||
| @@ -205,6 +205,10 @@ type AttachTarget struct { | |||||
| 205 | ExactTarget *ExactSessionTarget | 205 | ExactTarget *ExactSessionTarget | ||
| 206 | EnvironmentPolicy EnvironmentPolicy | 206 | EnvironmentPolicy EnvironmentPolicy | ||
| 207 | SamePeer bool | 207 | SamePeer bool | ||
| 208 | + | // PreferredTabID carries an explicit local tab selection across a handoff. | |||
| 209 | + | // Empty retains the client's route memory; a disappeared tab uses Hello's | |||
| 210 | + | // existing default-tab fallback. | |||
| 211 | + | PreferredTabID domain.TabStableID | |||
| 208 | } | 212 | } | ||
| 209 | 213 | ||||
| 210 | type Detached struct{ Reason uint8 } | 214 | type Detached struct{ Reason uint8 } | ||
| @@ -395,6 +399,9 @@ func ValidateAttachTarget(m AttachTarget) error { | |||||
| 395 | if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) { | 399 | if m.SamePeer && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil) { | ||
| 396 | return ErrInvalidAttachTarget | 400 | return ErrInvalidAttachTarget | ||
| 397 | } | 401 | } | ||
| 402 | + | if m.PreferredTabID != "" && (m.Endpoint != "" || m.RemoteTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) { | |||
| 403 | + | return ErrInvalidAttachTarget | |||
| 404 | + | } | |||
| 398 | if m.RemoteTarget == nil { | 405 | if m.RemoteTarget == nil { | ||
| 399 | return nil | 406 | return nil | ||
| 400 | } | 407 | } | ||
M internal/protocol/session_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(39), Version) | |
| 13 | + | require.Equal(t, uint16(40), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-55, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | ||
| 13 | - | require.Equal(t, uint16(39), Version) | 13 | + | require.Equal(t, uint16(40), Version) |
| 14 | require.Equal(t, (16<<20)-55, MaxOutputDataLen) | 14 | require.Equal(t, (16<<20)-55, MaxOutputDataLen) | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
A internal/protocol/wire/attach_target_tab_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package wire | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/protocol" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestAttachTargetPreferredTabStrict(t *testing.T) { | |
| 13 | + | valid := protocol.AttachTarget{ | |
| 14 | + | Session: "work", Intent: protocol.IntentAttach, | |
| 15 | + | ExactTarget: &protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}, | |
| 16 | + | PreferredTabID: "tab", | |
| 17 | + | } | |
| 18 | + | payload := MarshalAttachTarget(valid) | |
| 19 | + | want := append(make([]byte, 8), 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1) | |
| 20 | + | want = append(want, make([]byte, 15)...) | |
| 21 | + | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 0, 0, 3, 't', 'a', 'b') | |
| 22 | + | require.Equal(t, want, payload) | |
| 23 | + | decoded, err := UnmarshalAttachTarget(payload) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | require.Equal(t, valid, decoded) | |
| 26 | + | assertAllPrefixesFail(t, payload, UnmarshalAttachTarget) | |
| 27 | + | assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget) | |
| 28 | + | malformed := append([]byte(nil), payload...) | |
| 29 | + | malformed[len(malformed)-1] = '\n' | |
| 30 | + | _, err = UnmarshalAttachTarget(malformed) | |
| 31 | + | require.ErrorIs(t, err, protocol.ErrInvalidAttachTarget) | |
| 32 | + | ||
| 33 | + | for _, tc := range []struct { | |
| 34 | + | name string | |
| 35 | + | change func(*protocol.AttachTarget) | |
| 36 | + | }{ | |
| 37 | + | {name: "no exact lifecycle", change: func(m *protocol.AttachTarget) { m.ExactTarget = nil }}, | |
| 38 | + | {name: "remote endpoint", change: func(m *protocol.AttachTarget) { m.Endpoint = "host" }}, | |
| 39 | + | {name: "remote target", change: func(m *protocol.AttachTarget) { m.RemoteTarget = &domain.RemoteSessionTarget{} }}, | |
| 40 | + | {name: "creation", change: func(m *protocol.AttachTarget) { m.Intent = protocol.IntentNew }}, | |
| 41 | + | {name: "ephemeral creation", change: func(m *protocol.AttachTarget) { m.Intent = protocol.IntentEphemeral }}, | |
| 42 | + | {name: "control byte", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "tab\n" }}, | |
| 43 | + | {name: "whitespace", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "tab name" }}, | |
| 44 | + | {name: "invalid UTF8", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "\xff" }}, | |
| 45 | + | {name: "oversize", change: func(m *protocol.AttachTarget) { m.PreferredTabID = domain.TabStableID(strings.Repeat("x", 129)) }}, | |
| 46 | + | } { | |
| 47 | + | t.Run(tc.name, func(t *testing.T) { | |
| 48 | + | message := valid | |
| 49 | + | tc.change(&message) | |
| 50 | + | require.ErrorIs(t, protocol.ValidateAttachTarget(message), protocol.ErrInvalidAttachTarget) | |
| 51 | + | require.Nil(t, MarshalAttachTarget(message)) | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package wire | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/protocol" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestAttachTargetPreferredTabStrict(t *testing.T) { | |||
| 13 | + | valid := protocol.AttachTarget{ | |||
| 14 | + | Session: "work", Intent: protocol.IntentAttach, | |||
| 15 | + | ExactTarget: &protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work"}, | |||
| 16 | + | PreferredTabID: "tab", | |||
| 17 | + | } | |||
| 18 | + | payload := MarshalAttachTarget(valid) | |||
| 19 | + | want := append(make([]byte, 8), 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1) | |||
| 20 | + | want = append(want, make([]byte, 15)...) | |||
| 21 | + | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 0, 0, 3, 't', 'a', 'b') | |||
| 22 | + | require.Equal(t, want, payload) | |||
| 23 | + | decoded, err := UnmarshalAttachTarget(payload) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | require.Equal(t, valid, decoded) | |||
| 26 | + | assertAllPrefixesFail(t, payload, UnmarshalAttachTarget) | |||
| 27 | + | assertTrailingGarbageFails(t, payload, UnmarshalAttachTarget) | |||
| 28 | + | malformed := append([]byte(nil), payload...) | |||
| 29 | + | malformed[len(malformed)-1] = '\n' | |||
| 30 | + | _, err = UnmarshalAttachTarget(malformed) | |||
| 31 | + | require.ErrorIs(t, err, protocol.ErrInvalidAttachTarget) | |||
| 32 | + | ||||
| 33 | + | for _, tc := range []struct { | |||
| 34 | + | name string | |||
| 35 | + | change func(*protocol.AttachTarget) | |||
| 36 | + | }{ | |||
| 37 | + | {name: "no exact lifecycle", change: func(m *protocol.AttachTarget) { m.ExactTarget = nil }}, | |||
| 38 | + | {name: "remote endpoint", change: func(m *protocol.AttachTarget) { m.Endpoint = "host" }}, | |||
| 39 | + | {name: "remote target", change: func(m *protocol.AttachTarget) { m.RemoteTarget = &domain.RemoteSessionTarget{} }}, | |||
| 40 | + | {name: "creation", change: func(m *protocol.AttachTarget) { m.Intent = protocol.IntentNew }}, | |||
| 41 | + | {name: "ephemeral creation", change: func(m *protocol.AttachTarget) { m.Intent = protocol.IntentEphemeral }}, | |||
| 42 | + | {name: "control byte", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "tab\n" }}, | |||
| 43 | + | {name: "whitespace", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "tab name" }}, | |||
| 44 | + | {name: "invalid UTF8", change: func(m *protocol.AttachTarget) { m.PreferredTabID = "\xff" }}, | |||
| 45 | + | {name: "oversize", change: func(m *protocol.AttachTarget) { m.PreferredTabID = domain.TabStableID(strings.Repeat("x", 129)) }}, | |||
| 46 | + | } { | |||
| 47 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 48 | + | message := valid | |||
| 49 | + | tc.change(&message) | |||
| 50 | + | require.ErrorIs(t, protocol.ValidateAttachTarget(message), protocol.ErrInvalidAttachTarget) | |||
| 51 | + | require.Nil(t, MarshalAttachTarget(message)) | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
M internal/protocol/wire/frame_test.go +2 -2
| @@ -7,8 +7,8 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func TestProtocolVersion(t *testing.T) { | |
| 10 | - | if protocol.Version != 39 { | |
| 11 | - | t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version) | |
| 10 | + | if protocol.Version != 40 { | |
| 11 | + | t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version) | |
| 12 | 12 | } | |
| 13 | 13 | } | |
| 14 | 14 | ||
| @@ -7,8 +7,8 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | func TestProtocolVersion(t *testing.T) { | 9 | func TestProtocolVersion(t *testing.T) { | ||
| 10 | - | if protocol.Version != 39 { | 10 | + | if protocol.Version != 40 { |
| 11 | - | t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version) | 11 | + | t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version) |
| 12 | } | 12 | } | ||
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
M internal/protocol/wire/navigation_test.go +1 -1
| @@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||
| 153 | 153 | {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | |
| 154 | 154 | {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 155 | 155 | {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | |
| 156 | - | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002702" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 156 | + | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002802" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | |
| 157 | 157 | {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, | |
| 158 | 158 | {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | |
| 159 | 159 | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | |
| @@ -153,7 +153,7 @@ func TestHelloNavigationValidationTable(t *testing.T) { | |||||
| 153 | {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | 153 | {name: "client-owned home picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: false}, | ||
| 154 | {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | 154 | {name: "daemon-owned back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 155 | {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | 155 | {name: "remote-target back picker rejected", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}, valid: false}, | ||
| 156 | - | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002702" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, | 156 | + | {name: "back with startup picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: true, capability: protocol.NavigationCapabilityBack, overlay: protocol.StartupOverlaySessionPicker, wantPayload: "002802" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "00000000" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "02010000"}, |
| 157 | {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, | 157 | {name: "new accepts retained home picker", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityHomePicker}, valid: true, capability: protocol.NavigationCapabilityHomePicker}, | ||
| 158 | {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | 158 | {name: "new rejects back navigation", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: protocol.NavigationCapabilityBack, StartupOverlay: protocol.StartupOverlaySessionPicker}, valid: false}, | ||
| 159 | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | 159 | {name: "unknown capability", hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false}, | ||
M internal/protocol/wire/remote_preview_hardening_test.go +1 -1
| @@ -203,7 +203,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||
| 203 | 203 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |
| 204 | 204 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | |
| 205 | 205 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | |
| 206 | - | 0x00, 0x00, | |
| 206 | + | 0x00, 0x00, 0x00, 0x00, | |
| 207 | 207 | } | |
| 208 | 208 | if !bytes.Equal(got, want) { | |
| 209 | 209 | t.Fatalf("rich target bytes = %x, want %x", got, want) | |
| @@ -203,7 +203,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) { | |||||
| 203 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | 203 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | ||
| 204 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | 204 | 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, | ||
| 205 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | 205 | 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01, | ||
| 206 | - | 0x00, 0x00, | 206 | + | 0x00, 0x00, 0x00, 0x00, |
| 207 | } | 207 | } | ||
| 208 | if !bytes.Equal(got, want) { | 208 | if !bytes.Equal(got, want) { | ||
| 209 | t.Fatalf("rich target bytes = %x, want %x", got, want) | 209 | t.Fatalf("rich target bytes = %x, want %x", got, want) | ||
M internal/protocol/wire/remote_target_wire_test.go +2 -2
| @@ -140,7 +140,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||
| 140 | 140 | }) | |
| 141 | 141 | }, | |
| 142 | 142 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | |
| 143 | - | policyOffset: 3, | |
| 143 | + | policyOffset: 5, | |
| 144 | 144 | wantErr: protocol.ErrInvalidAttachTarget, | |
| 145 | 145 | }, | |
| 146 | 146 | } | |
| @@ -196,7 +196,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | 198 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | |
| 199 | - | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | |
| 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0} | |
| 200 | 200 | got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | |
| 201 | 201 | if !bytes.Equal(got, want) { | |
| 202 | 202 | t.Fatalf("target bytes = %x, want %x", got, want) | |
| @@ -140,7 +140,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) { | |||||
| 140 | }) | 140 | }) | ||
| 141 | }, | 141 | }, | ||
| 142 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | 142 | unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err }, | ||
| 143 | - | policyOffset: 3, | 143 | + | policyOffset: 5, |
| 144 | wantErr: protocol.ErrInvalidAttachTarget, | 144 | wantErr: protocol.ErrInvalidAttachTarget, | ||
| 145 | }, | 145 | }, | ||
| 146 | } | 146 | } | ||
| @@ -196,7 +196,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) { | |||||
| 196 | } | 196 | } | ||
| 197 | 197 | ||||
| 198 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | 198 | func TestTargetWireIncludesRequiredV27Section(t *testing.T) { | ||
| 199 | - | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0} |
| 200 | got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | 200 | got := MarshalAttachTarget(protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach}) | ||
| 201 | if !bytes.Equal(got, want) { | 201 | if !bytes.Equal(got, want) { | ||
| 202 | t.Fatalf("target bytes = %x, want %x", got, want) | 202 | t.Fatalf("target bytes = %x, want %x", got, want) | ||
M internal/protocol/wire/wire.go +9 -0
| @@ -1207,6 +1207,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte { | |||
| 1207 | 1207 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | |
| 1208 | 1208 | marshalExactTargetSection(&w, m.ExactTarget) | |
| 1209 | 1209 | w.putBool(m.SamePeer) | |
| 1210 | + | w.putString(string(m.PreferredTabID)) | |
| 1210 | 1211 | return w.b | |
| 1211 | 1212 | } | |
| 1212 | 1213 | ||
| @@ -1240,6 +1241,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |||
| 1240 | 1241 | if _, err := probe.getBool(); err != nil { | |
| 1241 | 1242 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 1242 | 1243 | } | |
| 1244 | + | if err := probe.skipString(); err != nil { | |
| 1245 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 1246 | + | } | |
| 1243 | 1247 | if err := probe.done(); err != nil { | |
| 1244 | 1248 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |
| 1245 | 1249 | } | |
| @@ -1267,6 +1271,11 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |||
| 1267 | 1271 | if m.SamePeer, err = r.getBool(); err != nil { | |
| 1268 | 1272 | return protocol.AttachTarget{}, err | |
| 1269 | 1273 | } | |
| 1274 | + | preferredTabID, err := r.getString() | |
| 1275 | + | if err != nil { | |
| 1276 | + | return protocol.AttachTarget{}, err | |
| 1277 | + | } | |
| 1278 | + | m.PreferredTabID = domain.TabStableID(preferredTabID) | |
| 1270 | 1279 | if err := r.done(); err != nil { | |
| 1271 | 1280 | return protocol.AttachTarget{}, err | |
| 1272 | 1281 | } | |
| @@ -1207,6 +1207,7 @@ func MarshalAttachTarget(m protocol.AttachTarget) []byte { | |||||
| 1207 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | 1207 | marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy) | ||
| 1208 | marshalExactTargetSection(&w, m.ExactTarget) | 1208 | marshalExactTargetSection(&w, m.ExactTarget) | ||
| 1209 | w.putBool(m.SamePeer) | 1209 | w.putBool(m.SamePeer) | ||
| 1210 | + | w.putString(string(m.PreferredTabID)) | |||
| 1210 | return w.b | 1211 | return w.b | ||
| 1211 | } | 1212 | } | ||
| 1212 | 1213 | ||||
| @@ -1240,6 +1241,9 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |||||
| 1240 | if _, err := probe.getBool(); err != nil { | 1241 | if _, err := probe.getBool(); err != nil { | ||
| 1241 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | 1242 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | ||
| 1242 | } | 1243 | } | ||
| 1244 | + | if err := probe.skipString(); err != nil { | |||
| 1245 | + | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | |||
| 1246 | + | } | |||
| 1243 | if err := probe.done(); err != nil { | 1247 | if err := probe.done(); err != nil { | ||
| 1244 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | 1248 | return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget | ||
| 1245 | } | 1249 | } | ||
| @@ -1267,6 +1271,11 @@ func UnmarshalAttachTarget(b []byte) (protocol.AttachTarget, error) { | |||||
| 1267 | if m.SamePeer, err = r.getBool(); err != nil { | 1271 | if m.SamePeer, err = r.getBool(); err != nil { | ||
| 1268 | return protocol.AttachTarget{}, err | 1272 | return protocol.AttachTarget{}, err | ||
| 1269 | } | 1273 | } | ||
| 1274 | + | preferredTabID, err := r.getString() | |||
| 1275 | + | if err != nil { | |||
| 1276 | + | return protocol.AttachTarget{}, err | |||
| 1277 | + | } | |||
| 1278 | + | m.PreferredTabID = domain.TabStableID(preferredTabID) | |||
| 1270 | if err := r.done(); err != nil { | 1279 | if err := r.done(); err != nil { | ||
| 1271 | return protocol.AttachTarget{}, err | 1280 | return protocol.AttachTarget{}, err | ||
| 1272 | } | 1281 | } | ||
M internal/protocol/wire/wire_final_test.go +6 -6
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |
| 15 | - | if protocol.Version != 39 { | |
| 16 | - | t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version) | |
| 15 | + | if protocol.Version != 40 { | |
| 16 | + | t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version) | |
| 17 | 17 | } | |
| 18 | 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 19 | 19 | if len(payload) < 2 { | |
| @@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 27 | 27 | ||
| 28 | 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | |
| 29 | 29 | msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |
| 30 | - | want := append([]byte{0, 39, 2}, make([]byte, 16+8)...) | |
| 30 | + | want := append([]byte{0, 40, 2}, make([]byte, 16+8)...) | |
| 31 | 31 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |
| 32 | 32 | want = append(want, 0, 0) | |
| 33 | 33 | got := MarshalHello(msg) | |
| @@ -196,7 +196,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||
| 196 | 196 | ||
| 197 | 197 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |
| 198 | 198 | msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach} | |
| 199 | - | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0} | |
| 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0, 0, 0} | |
| 200 | 200 | got := MarshalAttachTarget(msg) | |
| 201 | 201 | if !bytes.Equal(got, want) { | |
| 202 | 202 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | |
| @@ -212,7 +212,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 212 | 212 | if localPayload == nil { | |
| 213 | 213 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | |
| 214 | 214 | } | |
| 215 | - | wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | |
| 215 | + | wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0} | |
| 216 | 216 | if !bytes.Equal(localPayload, wantLocal) { | |
| 217 | 217 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | |
| 218 | 218 | } | |
| @@ -248,7 +248,7 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) { | |||
| 248 | 248 | payload := MarshalAttachTarget(target) | |
| 249 | 249 | want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...) | |
| 250 | 250 | want = append(want, make([]byte, 15)...) | |
| 251 | - | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1) | |
| 251 | + | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1, 0, 0) | |
| 252 | 252 | if !bytes.Equal(payload, want) { | |
| 253 | 253 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | |
| 254 | 254 | } | |
| @@ -12,8 +12,8 @@ import ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | 14 | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | ||
| 15 | - | if protocol.Version != 39 { | 15 | + | if protocol.Version != 40 { |
| 16 | - | t.Fatalf("ProtocolVersion = %d, want 39", protocol.Version) | 16 | + | t.Fatalf("ProtocolVersion = %d, want 40", protocol.Version) |
| 17 | } | 17 | } | ||
| 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | 18 | payload := MarshalHello(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | ||
| 19 | if len(payload) < 2 { | 19 | if len(payload) < 2 { | ||
| @@ -27,7 +27,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||||
| 27 | 27 | ||||
| 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | 28 | func TestFinalHelloGoldenStrict(t *testing.T) { | ||
| 29 | msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | 29 | msg := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | ||
| 30 | - | want := append([]byte{0, 39, 2}, make([]byte, 16+8)...) | 30 | + | want := append([]byte{0, 40, 2}, make([]byte, 16+8)...) |
| 31 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | 31 | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | ||
| 32 | want = append(want, 0, 0) | 32 | want = append(want, 0, 0) | ||
| 33 | got := MarshalHello(msg) | 33 | got := MarshalHello(msg) | ||
| @@ -196,7 +196,7 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||||
| 196 | 196 | ||||
| 197 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | 197 | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | ||
| 198 | msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach} | 198 | msg := protocol.AttachTarget{Endpoint: "host", Session: "work", Intent: protocol.IntentAttach} | ||
| 199 | - | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0} | 199 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0, 0, 0, 0, 0} |
| 200 | got := MarshalAttachTarget(msg) | 200 | got := MarshalAttachTarget(msg) | ||
| 201 | if !bytes.Equal(got, want) { | 201 | if !bytes.Equal(got, want) { | ||
| 202 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | 202 | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | ||
| @@ -212,7 +212,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||||
| 212 | if localPayload == nil { | 212 | if localPayload == nil { | ||
| 213 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | 213 | t.Fatal("MarshalAttachTarget rejected same-peer route handoff") | ||
| 214 | } | 214 | } | ||
| 215 | - | wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0} | 215 | + | wantLocal := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 0, 0, 0, 0} |
| 216 | if !bytes.Equal(localPayload, wantLocal) { | 216 | if !bytes.Equal(localPayload, wantLocal) { | ||
| 217 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | 217 | t.Fatalf("same-peer bytes = %x, want %x", localPayload, wantLocal) | ||
| 218 | } | 218 | } | ||
| @@ -248,7 +248,7 @@ func TestAttachTargetExactTargetWireStrict(t *testing.T) { | |||||
| 248 | payload := MarshalAttachTarget(target) | 248 | payload := MarshalAttachTarget(target) | ||
| 249 | want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...) | 249 | want := append(make([]byte, 8), []byte{0, 0, 0, 4, 'w', 'o', 'r', 'k', protocol.IntentAttach, 0, 0, 1, 1}...) | ||
| 250 | want = append(want, make([]byte, 15)...) | 250 | want = append(want, make([]byte, 15)...) | ||
| 251 | - | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1) | 251 | + | want = append(want, 0, 4, 'w', 'o', 'r', 'k', 1, 0, 0) |
| 252 | if !bytes.Equal(payload, want) { | 252 | if !bytes.Equal(payload, want) { | ||
| 253 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | 253 | t.Fatalf("exact target bytes = %x, want %x", payload, want) | ||
| 254 | } | 254 | } | ||
M internal/protocol/wire/wire_test.go +6 -6
| @@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 136 | 136 | } | |
| 137 | 137 | got := MarshalHello(msg) | |
| 138 | 138 | want := []byte{ | |
| 139 | - | 0x00, 0x27, 0x00, // version, intent, first client ID byte | |
| 139 | + | 0x00, 0x28, 0x00, // version, intent, first client ID byte | |
| 140 | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 141 | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 142 | 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 367 | 367 | } | |
| 368 | 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 369 | 369 | require.Equal(t, want, MarshalTheme(cleared)) | |
| 370 | - | require.Equal(t, uint16(39), protocol.Version) | |
| 370 | + | require.Equal(t, uint16(40), protocol.Version) | |
| 371 | 371 | } | |
| 372 | 372 | ||
| 373 | 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 528 | 528 | name: "minimal", | |
| 529 | 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | |
| 530 | 530 | want: []byte{ | |
| 531 | - | 0x00, 0x27, | |
| 531 | + | 0x00, 0x28, | |
| 532 | 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 533 | 533 | 0x00, // attached | |
| 534 | 534 | 0x00, // self | |
| @@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 551 | 551 | JSON: true, | |
| 552 | 552 | }, | |
| 553 | 553 | want: []byte{ | |
| 554 | - | 0x00, 0x27, | |
| 554 | + | 0x00, 0x28, | |
| 555 | 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 556 | 556 | 0x00, // attached | |
| 557 | 557 | 0x01, // self | |
| @@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 989 | 989 | name: "attached", | |
| 990 | 990 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 991 | 991 | want: []byte{ | |
| 992 | - | 0x00, 0x27, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 992 | + | 0x00, 0x28, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | |
| 993 | 993 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 994 | 994 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 995 | 995 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -999,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 999 | 999 | name: "control", | |
| 1000 | 1000 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | |
| 1001 | 1001 | want: []byte{ | |
| 1002 | - | 0x00, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1002 | + | 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1003 | 1003 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 1004 | 1004 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 1005 | 1005 | }, | |
| @@ -136,7 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 136 | } | 136 | } | ||
| 137 | got := MarshalHello(msg) | 137 | got := MarshalHello(msg) | ||
| 138 | want := []byte{ | 138 | want := []byte{ | ||
| 139 | - | 0x00, 0x27, 0x00, // version, intent, first client ID byte | 139 | + | 0x00, 0x28, 0x00, // version, intent, first client ID byte |
| 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 140 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | 141 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 142 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| @@ -367,7 +367,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 367 | } | 367 | } | ||
| 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 368 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 369 | require.Equal(t, want, MarshalTheme(cleared)) | 369 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 370 | - | require.Equal(t, uint16(39), protocol.Version) | 370 | + | require.Equal(t, uint16(40), protocol.Version) |
| 371 | } | 371 | } | ||
| 372 | 372 | ||||
| 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 373 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -528,7 +528,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 528 | name: "minimal", | 528 | name: "minimal", | ||
| 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | 529 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "split-right"}, | ||
| 530 | want: []byte{ | 530 | want: []byte{ | ||
| 531 | - | 0x00, 0x27, | 531 | + | 0x00, 0x28, |
| 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 532 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 533 | 0x00, // attached | 533 | 0x00, // attached | ||
| 534 | 0x00, // self | 534 | 0x00, // self | ||
| @@ -551,7 +551,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 551 | JSON: true, | 551 | JSON: true, | ||
| 552 | }, | 552 | }, | ||
| 553 | want: []byte{ | 553 | want: []byte{ | ||
| 554 | - | 0x00, 0x27, | 554 | + | 0x00, 0x28, |
| 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 555 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 556 | 0x00, // attached | 556 | 0x00, // attached | ||
| 557 | 0x01, // self | 557 | 0x01, // self | ||
| @@ -989,7 +989,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 989 | name: "attached", | 989 | name: "attached", | ||
| 990 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 990 | msg: protocol.CommandRequest{Version: protocol.Version, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 991 | want: []byte{ | 991 | want: []byte{ | ||
| 992 | - | 0x00, 0x27, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 992 | + | 0x00, 0x28, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 993 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 993 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 994 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 994 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 995 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 995 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -999,7 +999,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 999 | name: "control", | 999 | name: "control", | ||
| 1000 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | 1000 | msg: protocol.CommandRequest{Version: protocol.Version, Slug: "ls"}, | ||
| 1001 | want: []byte{ | 1001 | want: []byte{ | ||
| 1002 | - | 0x00, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 1002 | + | 0x00, 0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 1003 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 1003 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 1004 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 1004 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 1005 | }, | 1005 | }, | ||
M internal/usecase/client/home_picker_context_test.go +3 -1
| @@ -24,6 +24,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) { | |||
| 24 | 24 | {name: "UDP backing session renamed", udp: true, rename: true}, | |
| 25 | 25 | {name: "stdio backing session renamed", rename: true}, | |
| 26 | 26 | {name: "stdio select new local destination", selectLocal: true}, | |
| 27 | + | {name: "UDP select new local destination", udp: true, selectLocal: true}, | |
| 27 | 28 | } { | |
| 28 | 29 | t.Run(tc.name, func(t *testing.T) { | |
| 29 | 30 | term := newRunTerminal() | |
| @@ -69,7 +70,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) { | |||
| 69 | 70 | selected := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "destination"} | |
| 70 | 71 | localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | |
| 71 | 72 | Session: selected.SessionName, Intent: protocol.IntentAttach, ExactTarget: &selected, | |
| 72 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, | |
| 73 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "chosen-tab", | |
| 73 | 74 | }))}) | |
| 74 | 75 | localDestination = &recordingTransport{recvs: []recvItem{ | |
| 75 | 76 | {f: hybridWelcomeFrame(selected.SessionName, selected.LifecycleID)}, | |
| @@ -114,6 +115,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) { | |||
| 114 | 115 | if localDestination != nil { | |
| 115 | 116 | hello := helloFromSend(t, localDestination) | |
| 116 | 117 | require.Equal(t, protocol.StartupOverlayNone, hello.StartupOverlay) | |
| 118 | + | require.Equal(t, domain.TabStableID("chosen-tab"), hello.PreferredTabID) | |
| 117 | 119 | require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityBack) | |
| 118 | 120 | var selected protocol.RecentRouteSnapshot | |
| 119 | 121 | for _, sent := range localDestination.Sends() { | |
| @@ -24,6 +24,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) { | |||||
| 24 | {name: "UDP backing session renamed", udp: true, rename: true}, | 24 | {name: "UDP backing session renamed", udp: true, rename: true}, | ||
| 25 | {name: "stdio backing session renamed", rename: true}, | 25 | {name: "stdio backing session renamed", rename: true}, | ||
| 26 | {name: "stdio select new local destination", selectLocal: true}, | 26 | {name: "stdio select new local destination", selectLocal: true}, | ||
| 27 | + | {name: "UDP select new local destination", udp: true, selectLocal: true}, | |||
| 27 | } { | 28 | } { | ||
| 28 | t.Run(tc.name, func(t *testing.T) { | 29 | t.Run(tc.name, func(t *testing.T) { | ||
| 29 | term := newRunTerminal() | 30 | term := newRunTerminal() | ||
| @@ -69,7 +70,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) { | |||||
| 69 | selected := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "destination"} | 70 | selected := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "destination"} | ||
| 70 | localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | 71 | localPicker.recvs = append(localPicker.recvs, recvItem{f: frameOf(wire.MsgAttachTarget, wire.MarshalAttachTarget(protocol.AttachTarget{ | ||
| 71 | Session: selected.SessionName, Intent: protocol.IntentAttach, ExactTarget: &selected, | 72 | Session: selected.SessionName, Intent: protocol.IntentAttach, ExactTarget: &selected, | ||
| 72 | - | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, SamePeer: true, | 73 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, PreferredTabID: "chosen-tab", |
| 73 | }))}) | 74 | }))}) | ||
| 74 | localDestination = &recordingTransport{recvs: []recvItem{ | 75 | localDestination = &recordingTransport{recvs: []recvItem{ | ||
| 75 | {f: hybridWelcomeFrame(selected.SessionName, selected.LifecycleID)}, | 76 | {f: hybridWelcomeFrame(selected.SessionName, selected.LifecycleID)}, | ||
| @@ -114,6 +115,7 @@ func TestHomePickerPreservesActiveRouteSnapshot(t *testing.T) { | |||||
| 114 | if localDestination != nil { | 115 | if localDestination != nil { | ||
| 115 | hello := helloFromSend(t, localDestination) | 116 | hello := helloFromSend(t, localDestination) | ||
| 116 | require.Equal(t, protocol.StartupOverlayNone, hello.StartupOverlay) | 117 | require.Equal(t, protocol.StartupOverlayNone, hello.StartupOverlay) | ||
| 118 | + | require.Equal(t, domain.TabStableID("chosen-tab"), hello.PreferredTabID) | |||
| 117 | require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityBack) | 119 | require.Zero(t, hello.NavigationCapabilities&protocol.NavigationCapabilityBack) | ||
| 118 | var selected protocol.RecentRouteSnapshot | 120 | var selected protocol.RecentRouteSnapshot | ||
| 119 | for _, sent := range localDestination.Sends() { | 121 | for _, sent := range localDestination.Sends() { | ||
M internal/usecase/client/routes.go +6 -5
| @@ -551,8 +551,9 @@ func (l *routeLedger) snapshot() protocol.RecentRouteSnapshot { | |||
| 551 | 551 | } | |
| 552 | 552 | ||
| 553 | 553 | // samePeerHandoff restores a previously committed route on the daemon already | |
| 554 | - | // connected to by the client. The daemon deliberately sends no tab state: the | |
| 555 | - | // ledger is the sole owner of per-route view memory. | |
| 554 | + | // connected to by the client. An explicit tab selection overrides route memory; | |
| 555 | + | // a session-only selection restores the client's remembered view only when | |
| 556 | + | // the exact lifecycle matches. Without an exact target, tab memory is cleared. | |
| 556 | 557 | func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.AttachTarget) AttachRequest { | |
| 557 | 558 | origin := normalizeRouteOrigin(active.Origin, active.Remote) | |
| 558 | 559 | originKey := normalizeRouteOriginKey(active.OriginKey, origin) | |
| @@ -582,8 +583,8 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta | |||
| 582 | 583 | } | |
| 583 | 584 | request.EnvironmentPolicy = environmentPolicy | |
| 584 | 585 | request.ExactTarget = target.ExactTarget | |
| 585 | - | if target.ExactTarget == nil { | |
| 586 | - | request.PreferredTabID = "" | |
| 586 | + | if target.ExactTarget == nil || target.PreferredTabID != "" { | |
| 587 | + | request.PreferredTabID = target.PreferredTabID | |
| 587 | 588 | } | |
| 588 | 589 | return request | |
| 589 | 590 | } | |
| @@ -592,7 +593,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta | |||
| 592 | 593 | request.Intent = target.Intent | |
| 593 | 594 | request.SessionName = target.Session | |
| 594 | 595 | request.ExactTarget = target.ExactTarget | |
| 595 | - | request.PreferredTabID = "" | |
| 596 | + | request.PreferredTabID = target.PreferredTabID | |
| 596 | 597 | request.RemoteTarget = nil | |
| 597 | 598 | if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned { | |
| 598 | 599 | request.NavigationCapabilities = 0 | |
| @@ -551,8 +551,9 @@ func (l *routeLedger) snapshot() protocol.RecentRouteSnapshot { | |||||
| 551 | } | 551 | } | ||
| 552 | 552 | ||||
| 553 | // samePeerHandoff restores a previously committed route on the daemon already | 553 | // samePeerHandoff restores a previously committed route on the daemon already | ||
| 554 | - | // connected to by the client. The daemon deliberately sends no tab state: the | 554 | + | // connected to by the client. An explicit tab selection overrides route memory; |
| 555 | - | // ledger is the sole owner of per-route view memory. | 555 | + | // a session-only selection restores the client's remembered view only when |
| 556 | + | // the exact lifecycle matches. Without an exact target, tab memory is cleared. | |||
| 556 | func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.AttachTarget) AttachRequest { | 557 | func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.AttachTarget) AttachRequest { | ||
| 557 | origin := normalizeRouteOrigin(active.Origin, active.Remote) | 558 | origin := normalizeRouteOrigin(active.Origin, active.Remote) | ||
| 558 | originKey := normalizeRouteOriginKey(active.OriginKey, origin) | 559 | originKey := normalizeRouteOriginKey(active.OriginKey, origin) | ||
| @@ -582,8 +583,8 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta | |||||
| 582 | } | 583 | } | ||
| 583 | request.EnvironmentPolicy = environmentPolicy | 584 | request.EnvironmentPolicy = environmentPolicy | ||
| 584 | request.ExactTarget = target.ExactTarget | 585 | request.ExactTarget = target.ExactTarget | ||
| 585 | - | if target.ExactTarget == nil { | 586 | + | if target.ExactTarget == nil || target.PreferredTabID != "" { |
| 586 | - | request.PreferredTabID = "" | 587 | + | request.PreferredTabID = target.PreferredTabID |
| 587 | } | 588 | } | ||
| 588 | return request | 589 | return request | ||
| 589 | } | 590 | } | ||
| @@ -592,7 +593,7 @@ func (l *routeLedger) samePeerHandoff(active AttachRequest, target protocol.Atta | |||||
| 592 | request.Intent = target.Intent | 593 | request.Intent = target.Intent | ||
| 593 | request.SessionName = target.Session | 594 | request.SessionName = target.Session | ||
| 594 | request.ExactTarget = target.ExactTarget | 595 | request.ExactTarget = target.ExactTarget | ||
| 595 | - | request.PreferredTabID = "" | 596 | + | request.PreferredTabID = target.PreferredTabID |
| 596 | request.RemoteTarget = nil | 597 | request.RemoteTarget = nil | ||
| 597 | if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned { | 598 | if request.EnvironmentPolicy != environmentPolicy && environmentPolicy == protocol.EnvironmentPolicyClientOwned { | ||
| 598 | request.NavigationCapabilities = 0 | 599 | request.NavigationCapabilities = 0 | ||
M internal/usecase/client/routes_test.go +4 -0
| @@ -190,16 +190,20 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) { | |||
| 190 | 190 | name string | |
| 191 | 191 | target protocol.ExactSessionTarget | |
| 192 | 192 | wantExactTarget *protocol.ExactSessionTarget | |
| 193 | + | explicitTab domain.TabStableID | |
| 193 | 194 | wantTab domain.TabStableID | |
| 194 | 195 | }{ | |
| 195 | 196 | {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"}, | |
| 196 | 197 | {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated}, | |
| 198 | + | {name: "explicit tab overrides memory", target: first.target, wantExactTarget: &first.target, explicitTab: "chosen-tab", wantTab: "chosen-tab"}, | |
| 199 | + | {name: "new lifecycle retains explicit tab", target: recreated, wantExactTarget: &recreated, explicitTab: "chosen-tab", wantTab: "chosen-tab"}, | |
| 197 | 200 | } { | |
| 198 | 201 | t.Run(tt.name, func(t *testing.T) { | |
| 199 | 202 | request := ledger.samePeerHandoff(second.request, protocol.AttachTarget{ | |
| 200 | 203 | Session: first.target.SessionName, | |
| 201 | 204 | Intent: protocol.IntentAttach, | |
| 202 | 205 | ExactTarget: &tt.target, | |
| 206 | + | PreferredTabID: tt.explicitTab, | |
| 203 | 207 | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 204 | 208 | }) | |
| 205 | 209 | require.Equal(t, first.target.SessionName, request.SessionName) | |
| @@ -190,16 +190,20 @@ func TestRouteLedgerSamePeerHandoffRestoresRouteTab(t *testing.T) { | |||||
| 190 | name string | 190 | name string | ||
| 191 | target protocol.ExactSessionTarget | 191 | target protocol.ExactSessionTarget | ||
| 192 | wantExactTarget *protocol.ExactSessionTarget | 192 | wantExactTarget *protocol.ExactSessionTarget | ||
| 193 | + | explicitTab domain.TabStableID | |||
| 193 | wantTab domain.TabStableID | 194 | wantTab domain.TabStableID | ||
| 194 | }{ | 195 | }{ | ||
| 195 | {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"}, | 196 | {name: "matching lifecycle restores remembered tab", target: first.target, wantExactTarget: &first.target, wantTab: "tab-first"}, | ||
| 196 | {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated}, | 197 | {name: "recreated session does not reuse remembered tab", target: recreated, wantExactTarget: &recreated}, | ||
| 198 | + | {name: "explicit tab overrides memory", target: first.target, wantExactTarget: &first.target, explicitTab: "chosen-tab", wantTab: "chosen-tab"}, | |||
| 199 | + | {name: "new lifecycle retains explicit tab", target: recreated, wantExactTarget: &recreated, explicitTab: "chosen-tab", wantTab: "chosen-tab"}, | |||
| 197 | } { | 200 | } { | ||
| 198 | t.Run(tt.name, func(t *testing.T) { | 201 | t.Run(tt.name, func(t *testing.T) { | ||
| 199 | request := ledger.samePeerHandoff(second.request, protocol.AttachTarget{ | 202 | request := ledger.samePeerHandoff(second.request, protocol.AttachTarget{ | ||
| 200 | Session: first.target.SessionName, | 203 | Session: first.target.SessionName, | ||
| 201 | Intent: protocol.IntentAttach, | 204 | Intent: protocol.IntentAttach, | ||
| 202 | ExactTarget: &tt.target, | 205 | ExactTarget: &tt.target, | ||
| 206 | + | PreferredTabID: tt.explicitTab, | |||
| 203 | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | 207 | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | ||
| 204 | }) | 208 | }) | ||
| 205 | require.Equal(t, first.target.SessionName, request.SessionName) | 209 | require.Equal(t, first.target.SessionName, request.SessionName) | ||
A internal/usecase/daemon/home_picker_navigation_test.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/protocol" | |
| 10 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestHomePickerTabSelectionHandsOffToClient(t *testing.T) { | |
| 14 | + | for _, tc := range []struct { | |
| 15 | + | name string | |
| 16 | + | sameSession bool | |
| 17 | + | homePicker bool | |
| 18 | + | }{ | |
| 19 | + | {name: "different local session", homePicker: true}, | |
| 20 | + | {name: "backing local session", homePicker: true, sameSession: true}, | |
| 21 | + | {name: "ordinary local session switch"}, | |
| 22 | + | {name: "ordinary local tab switch", sameSession: true}, | |
| 23 | + | } { | |
| 24 | + | t.Run(tc.name, func(t *testing.T) { | |
| 25 | + | d, source, ac, sends, releases := newManualTabSession(t, 2) | |
| 26 | + | defer releaseAll(releases) | |
| 27 | + | source.name = "home" | |
| 28 | + | source.incarnation = domain.SessionLifecycleID{1} | |
| 29 | + | target := source | |
| 30 | + | if !tc.sameSession { | |
| 31 | + | target = &session{ | |
| 32 | + | sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.SessionLifecycleID{2}, attachments: make(map[*attachedClient]struct{})}, | |
| 33 | + | ctx: source.ctx, cancel: func() {}, | |
| 34 | + | tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23}), newTab(nil, domain.Size{Cols: 80, Rows: 23})}, | |
| 35 | + | } | |
| 36 | + | for _, tab := range target.tabs { | |
| 37 | + | publishTiledPaneOwners(target, tab) | |
| 38 | + | } | |
| 39 | + | d.sessions[target.id] = target | |
| 40 | + | } | |
| 41 | + | target.tabs[1].name = "chosen-tab" | |
| 42 | + | if tc.homePicker { | |
| 43 | + | ac.startupOverlay = protocol.StartupOverlaySessionPicker | |
| 44 | + | ac.navigationCapabilities = protocol.NavigationCapabilityBack | |
| 45 | + | } | |
| 46 | + | ref := protocol.RouteRef{Key: 7, Generation: 1} | |
| 47 | + | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{ | |
| 48 | + | Generation: 1, Active: ref, | |
| 49 | + | ActiveEntry: protocol.RecentRouteEntry{Key: ref.Key, Generation: ref.Generation, | |
| 50 | + | Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "remote"}, | |
| 51 | + | Name: "remote", HostLabel: "remote-host", Kind: protocol.RouteKindRemote}, | |
| 52 | + | }) | |
| 53 | + | effect := beginRecentRoutePaletteEffect(t, d, source, ac) | |
| 54 | + | d.enterPicker(source, ac) | |
| 55 | + | // Exercise the real picker model and input dispatch, not a fabricated | |
| 56 | + | // AttachTarget: navigation selects tab rows, including search hits. | |
| 57 | + | d.handleInputForAttachment(effect, []byte("/chosen-tab")) | |
| 58 | + | ac.overlays.pickerMu.Lock() | |
| 59 | + | selected, ok := ac.overlays.picker.Selected() | |
| 60 | + | ac.overlays.pickerMu.Unlock() | |
| 61 | + | require.True(t, ok) | |
| 62 | + | require.Equal(t, domain.TabStableID(target.tabs[1].stableID), selected.TabID) | |
| 63 | + | d.handleInputForAttachment(effect, []byte("\r")) | |
| 64 | + | ||
| 65 | + | if !tc.homePicker { | |
| 66 | + | require.Same(t, target, ac.currentAttachmentSession()) | |
| 67 | + | require.Equal(t, selected.TabID, ac.viewSnapshot().tabID) | |
| 68 | + | return | |
| 69 | + | } | |
| 70 | + | require.True(t, source == ac.currentAttachmentSession(), "a temporary picker must not become the destination attachment") | |
| 71 | + | var handoff *protocol.AttachTarget | |
| 72 | + | for len(sends) != 0 { | |
| 73 | + | frame := <-sends | |
| 74 | + | if frame.Type == wire.MsgAttachTarget { | |
| 75 | + | decoded, err := wire.UnmarshalAttachTarget(frame.Payload) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | handoff = &decoded | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | require.NotNil(t, handoff, "selection must return to the client that owns the parked remote route") | |
| 81 | + | require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, handoff.ExactTarget) | |
| 82 | + | require.Equal(t, selected.TabID, handoff.PreferredTabID) | |
| 83 | + | require.False(t, handoff.SamePeer, "the temporary connection must end, not be promoted in place") | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/protocol" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestHomePickerTabSelectionHandsOffToClient(t *testing.T) { | |||
| 14 | + | for _, tc := range []struct { | |||
| 15 | + | name string | |||
| 16 | + | sameSession bool | |||
| 17 | + | homePicker bool | |||
| 18 | + | }{ | |||
| 19 | + | {name: "different local session", homePicker: true}, | |||
| 20 | + | {name: "backing local session", homePicker: true, sameSession: true}, | |||
| 21 | + | {name: "ordinary local session switch"}, | |||
| 22 | + | {name: "ordinary local tab switch", sameSession: true}, | |||
| 23 | + | } { | |||
| 24 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 25 | + | d, source, ac, sends, releases := newManualTabSession(t, 2) | |||
| 26 | + | defer releaseAll(releases) | |||
| 27 | + | source.name = "home" | |||
| 28 | + | source.incarnation = domain.SessionLifecycleID{1} | |||
| 29 | + | target := source | |||
| 30 | + | if !tc.sameSession { | |||
| 31 | + | target = &session{ | |||
| 32 | + | sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.SessionLifecycleID{2}, attachments: make(map[*attachedClient]struct{})}, | |||
| 33 | + | ctx: source.ctx, cancel: func() {}, | |||
| 34 | + | tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23}), newTab(nil, domain.Size{Cols: 80, Rows: 23})}, | |||
| 35 | + | } | |||
| 36 | + | for _, tab := range target.tabs { | |||
| 37 | + | publishTiledPaneOwners(target, tab) | |||
| 38 | + | } | |||
| 39 | + | d.sessions[target.id] = target | |||
| 40 | + | } | |||
| 41 | + | target.tabs[1].name = "chosen-tab" | |||
| 42 | + | if tc.homePicker { | |||
| 43 | + | ac.startupOverlay = protocol.StartupOverlaySessionPicker | |||
| 44 | + | ac.navigationCapabilities = protocol.NavigationCapabilityBack | |||
| 45 | + | } | |||
| 46 | + | ref := protocol.RouteRef{Key: 7, Generation: 1} | |||
| 47 | + | ac.setRouteSnapshot(protocol.RecentRouteSnapshot{ | |||
| 48 | + | Generation: 1, Active: ref, | |||
| 49 | + | ActiveEntry: protocol.RecentRouteEntry{Key: ref.Key, Generation: ref.Generation, | |||
| 50 | + | Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{3}, SessionName: "remote"}, | |||
| 51 | + | Name: "remote", HostLabel: "remote-host", Kind: protocol.RouteKindRemote}, | |||
| 52 | + | }) | |||
| 53 | + | effect := beginRecentRoutePaletteEffect(t, d, source, ac) | |||
| 54 | + | d.enterPicker(source, ac) | |||
| 55 | + | // Exercise the real picker model and input dispatch, not a fabricated | |||
| 56 | + | // AttachTarget: navigation selects tab rows, including search hits. | |||
| 57 | + | d.handleInputForAttachment(effect, []byte("/chosen-tab")) | |||
| 58 | + | ac.overlays.pickerMu.Lock() | |||
| 59 | + | selected, ok := ac.overlays.picker.Selected() | |||
| 60 | + | ac.overlays.pickerMu.Unlock() | |||
| 61 | + | require.True(t, ok) | |||
| 62 | + | require.Equal(t, domain.TabStableID(target.tabs[1].stableID), selected.TabID) | |||
| 63 | + | d.handleInputForAttachment(effect, []byte("\r")) | |||
| 64 | + | ||||
| 65 | + | if !tc.homePicker { | |||
| 66 | + | require.Same(t, target, ac.currentAttachmentSession()) | |||
| 67 | + | require.Equal(t, selected.TabID, ac.viewSnapshot().tabID) | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 70 | + | require.True(t, source == ac.currentAttachmentSession(), "a temporary picker must not become the destination attachment") | |||
| 71 | + | var handoff *protocol.AttachTarget | |||
| 72 | + | for len(sends) != 0 { | |||
| 73 | + | frame := <-sends | |||
| 74 | + | if frame.Type == wire.MsgAttachTarget { | |||
| 75 | + | decoded, err := wire.UnmarshalAttachTarget(frame.Payload) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | handoff = &decoded | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | require.NotNil(t, handoff, "selection must return to the client that owns the parked remote route") | |||
| 81 | + | require.Equal(t, &protocol.ExactSessionTarget{LifecycleID: target.incarnation, SessionName: target.name}, handoff.ExactTarget) | |||
| 82 | + | require.Equal(t, selected.TabID, handoff.PreferredTabID) | |||
| 83 | + | require.False(t, handoff.SamePeer, "the temporary connection must end, not be promoted in place") | |||
| 84 | + | }) | |||
| 85 | + | } | |||
| 86 | + | } | |||
M internal/usecase/daemon/picker.go +9 -8
| @@ -1017,17 +1017,17 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||
| 1017 | 1017 | if !matches || !effect.current() { | |
| 1018 | 1018 | return errAttachmentTransition | |
| 1019 | 1019 | } | |
| 1020 | - | if targetSess != nil && target.TabID != "" { | |
| 1021 | - | // An explicit tab row is a direct user choice, not route memory; retain | |
| 1022 | - | // the established daemon-side transition so it opens exactly that tab. | |
| 1020 | + | homePicker := effect.ac.startupOverlay == protocol.StartupOverlaySessionPicker | |
| 1021 | + | if !homePicker && targetSess != nil && target.TabID != "" { | |
| 1022 | + | // Ordinary local tab navigation keeps the attachment. A home picker | |
| 1023 | + | // only borrows it: selecting even its backing session must hand off | |
| 1024 | + | // to the client so the remote route and temporary attachment end. | |
| 1023 | 1025 | return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action) | |
| 1024 | 1026 | } | |
| 1025 | 1027 | ||
| 1026 | - | // An active session row with no explicit non-default tab is on the | |
| 1027 | - | // authenticated daemon and can use the client-confirmed same-peer path. | |
| 1028 | - | // Explicit tab rows already take the direct transition path above, while | |
| 1029 | - | // stopped sessions have no active target session and retain the fallback. | |
| 1030 | - | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |
| 1028 | + | // Ordinary live session rows can use the client-confirmed same-peer | |
| 1029 | + | // path. Temporary home pickers and stopped targets require a handoff. | |
| 1030 | + | samePeerEligible := !homePicker && guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |
| 1031 | 1031 | if exactTarget == nil { | |
| 1032 | 1032 | return errAttachmentTransition | |
| 1033 | 1033 | } | |
| @@ -1044,6 +1044,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||
| 1044 | 1044 | Session: sessionName, | |
| 1045 | 1045 | Intent: protocol.IntentAttach, | |
| 1046 | 1046 | ExactTarget: exactTarget, | |
| 1047 | + | PreferredTabID: target.TabID, | |
| 1047 | 1048 | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 1048 | 1049 | } | |
| 1049 | 1050 | if protocol.ValidateAttachTarget(handoff) != nil { | |
| @@ -1017,17 +1017,17 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||||
| 1017 | if !matches || !effect.current() { | 1017 | if !matches || !effect.current() { | ||
| 1018 | return errAttachmentTransition | 1018 | return errAttachmentTransition | ||
| 1019 | } | 1019 | } | ||
| 1020 | - | if targetSess != nil && target.TabID != "" { | 1020 | + | homePicker := effect.ac.startupOverlay == protocol.StartupOverlaySessionPicker |
| 1021 | - | // An explicit tab row is a direct user choice, not route memory; retain | 1021 | + | if !homePicker && targetSess != nil && target.TabID != "" { |
| 1022 | - | // the established daemon-side transition so it opens exactly that tab. | 1022 | + | // Ordinary local tab navigation keeps the attachment. A home picker |
| 1023 | + | // only borrows it: selecting even its backing session must hand off | |||
| 1024 | + | // to the client so the remote route and temporary attachment end. | |||
| 1023 | return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action) | 1025 | return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action) | ||
| 1024 | } | 1026 | } | ||
| 1025 | 1027 | ||||
| 1026 | - | // An active session row with no explicit non-default tab is on the | 1028 | + | // Ordinary live session rows can use the client-confirmed same-peer |
| 1027 | - | // authenticated daemon and can use the client-confirmed same-peer path. | 1029 | + | // path. Temporary home pickers and stopped targets require a handoff. |
| 1028 | - | // Explicit tab rows already take the direct transition path above, while | 1030 | + | samePeerEligible := !homePicker && guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 |
| 1029 | - | // stopped sessions have no active target session and retain the fallback. | |||
| 1030 | - | samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0 | |||
| 1031 | if exactTarget == nil { | 1031 | if exactTarget == nil { | ||
| 1032 | return errAttachmentTransition | 1032 | return errAttachmentTransition | ||
| 1033 | } | 1033 | } | ||
| @@ -1044,6 +1044,7 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, ta | |||||
| 1044 | Session: sessionName, | 1044 | Session: sessionName, | ||
| 1045 | Intent: protocol.IntentAttach, | 1045 | Intent: protocol.IntentAttach, | ||
| 1046 | ExactTarget: exactTarget, | 1046 | ExactTarget: exactTarget, | ||
| 1047 | + | PreferredTabID: target.TabID, | |||
| 1047 | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | 1048 | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | ||
| 1048 | } | 1049 | } | ||
| 1049 | if protocol.ValidateAttachTarget(handoff) != nil { | 1050 | if protocol.ValidateAttachTarget(handoff) != nil { | ||