detached fe8474f

fix(routes): preserve remote route identity

commit
fe8474f964a8ea7e979c15cd50bb3b0677782fcf
author
brice · · 2026-08-10 04:38 UTC
parent
217c05a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +116 -37
M internal/app/run.go +9 -0
@@ -855,6 +855,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
855855 factory := deps.remoteDialerFactory
856856 var remoteSelection *domain.RemoteSessionTarget
857857 var remoteEnvironmentPolicy ports.EnvironmentPolicy
858+ remoteDisplayOrigin := remoteTarget
858859 routeOrigin := ports.RouteOriginLocal
859860 routeOriginKey := "local"
860861 if remoteTarget != "" {
@@ -894,6 +895,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
894895 return nil, client.AttachRequest{}, err
895896 }
896897 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
898+ displayOrigin := target.Endpoint
899+ if selection != nil {
900+ displayOrigin = selection.DisplayOrigin
901+ }
897902 return dialer, client.AttachRequest{
898903 Intent: target.Intent,
899904 SessionName: target.Session,
@@ -901,6 +906,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
901906 Origin: ports.RouteOriginDiscovery,
902907 OriginKey: target.Endpoint,
903908 RemoteTarget: selection,
909+ RemoteOrigin: displayOrigin,
904910 EnvironmentPolicy: policy,
905911 }, nil
906912 }
@@ -934,6 +940,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
934940 Origin: routeOrigin,
935941 OriginKey: routeOriginKey,
936942 RemoteTarget: remoteSelection,
943+ RemoteOrigin: remoteDisplayOrigin,
937944 EnvironmentPolicy: remoteEnvironmentPolicy,
938945 })
939946 } else {
@@ -964,6 +971,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
964971 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
965972 }
966973 remoteTarget = handoffErr.Target.Endpoint
974+ remoteDisplayOrigin = remoteTarget
967975 routeOrigin = ports.RouteOriginDiscovery
968976 routeOriginKey = remoteTarget
969977 name = handoffErr.Target.Session
@@ -972,6 +980,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
972980 if handoffErr.Target.RemoteTarget != nil {
973981 copyTarget := *handoffErr.Target.RemoteTarget
974982 remoteSelection = &copyTarget
983+ remoteDisplayOrigin = copyTarget.DisplayOrigin
975984 }
976985 remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy)
977986 if modeErr != nil {
@@ -855,6 +855,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
855 factory := deps.remoteDialerFactory855 factory := deps.remoteDialerFactory
856 var remoteSelection *domain.RemoteSessionTarget856 var remoteSelection *domain.RemoteSessionTarget
857 var remoteEnvironmentPolicy ports.EnvironmentPolicy857 var remoteEnvironmentPolicy ports.EnvironmentPolicy
858+ remoteDisplayOrigin := remoteTarget
858 routeOrigin := ports.RouteOriginLocal859 routeOrigin := ports.RouteOriginLocal
859 routeOriginKey := "local"860 routeOriginKey := "local"
860 if remoteTarget != "" {861 if remoteTarget != "" {
@@ -894,6 +895,10 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
894 return nil, client.AttachRequest{}, err895 return nil, client.AttachRequest{}, err
895 }896 }
896 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)897 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
898+ displayOrigin := target.Endpoint
899+ if selection != nil {
900+ displayOrigin = selection.DisplayOrigin
901+ }
897 return dialer, client.AttachRequest{902 return dialer, client.AttachRequest{
898 Intent: target.Intent,903 Intent: target.Intent,
899 SessionName: target.Session,904 SessionName: target.Session,
@@ -901,6 +906,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
901 Origin: ports.RouteOriginDiscovery,906 Origin: ports.RouteOriginDiscovery,
902 OriginKey: target.Endpoint,907 OriginKey: target.Endpoint,
903 RemoteTarget: selection,908 RemoteTarget: selection,
909+ RemoteOrigin: displayOrigin,
904 EnvironmentPolicy: policy,910 EnvironmentPolicy: policy,
905 }, nil911 }, nil
906 }912 }
@@ -934,6 +940,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
934 Origin: routeOrigin,940 Origin: routeOrigin,
935 OriginKey: routeOriginKey,941 OriginKey: routeOriginKey,
936 RemoteTarget: remoteSelection,942 RemoteTarget: remoteSelection,
943+ RemoteOrigin: remoteDisplayOrigin,
937 EnvironmentPolicy: remoteEnvironmentPolicy,944 EnvironmentPolicy: remoteEnvironmentPolicy,
938 })945 })
939 } else {946 } else {
@@ -964,6 +971,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
964 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)971 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
965 }972 }
966 remoteTarget = handoffErr.Target.Endpoint973 remoteTarget = handoffErr.Target.Endpoint
974+ remoteDisplayOrigin = remoteTarget
967 routeOrigin = ports.RouteOriginDiscovery975 routeOrigin = ports.RouteOriginDiscovery
968 routeOriginKey = remoteTarget976 routeOriginKey = remoteTarget
969 name = handoffErr.Target.Session977 name = handoffErr.Target.Session
@@ -972,6 +980,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
972 if handoffErr.Target.RemoteTarget != nil {980 if handoffErr.Target.RemoteTarget != nil {
973 copyTarget := *handoffErr.Target.RemoteTarget981 copyTarget := *handoffErr.Target.RemoteTarget
974 remoteSelection = &copyTarget982 remoteSelection = &copyTarget
983+ remoteDisplayOrigin = copyTarget.DisplayOrigin
975 }984 }
976 remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy)985 remoteEnvironmentPolicy = pickerEnvironmentPolicy(remoteSelection, handoffErr.Target.EnvironmentPolicy)
977 if modeErr != nil {986 if modeErr != nil {
M internal/app/run_test.go +1 -1
@@ -946,7 +946,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
946946 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
947947 if deps.Remote {
948948 remoteCalls++
949- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
949+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
950950 return nil
951951 }
952952 localCalls++
@@ -946,7 +946,7 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
946 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {946 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
947 if deps.Remote {947 if deps.Remote {
948 remoteCalls++948 remoteCalls++
949- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)949+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
950 return nil950 return nil
951 }951 }
952 localCalls++952 localCalls++
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 28
15+const ProtocolVersion uint16 = 29
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 2815+const ProtocolVersion uint16 = 29
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 28 {
7- t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion)
6+ if ProtocolVersion != 29 {
7+ t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 28 {6+ if ProtocolVersion != 29 {
7- t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/navigation_test.go +1 -1
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
6262 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
6363 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
6464 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001c02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},
65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001d02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
6666 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
6767 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
6868 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
@@ -62,7 +62,7 @@ func TestHelloNavigationValidationTable(t *testing.T) {
62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},62 {name: "client-owned home picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityHomePicker}, valid: false},
63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},63 {name: "daemon-owned back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},64 {name: "remote-target back picker rejected", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: remoteTarget, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}, valid: false},
65- {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001c02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0201"},65+ {name: "back with startup picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001d02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "0000" + "0000" + "0201"},
66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},66 {name: "new rejects navigation", hello: Hello{Version: ProtocolVersion, Intent: IntentNew, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: false},
67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},67 {name: "unknown capability", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: 4}, valid: false},
68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},68 {name: "back without picker", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack}, valid: false},
M internal/ports/remote_target_wire_test.go +1 -1
@@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
115115 })
116116 },
117117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
118- policyOffset: 6,
118+ policyOffset: 8,
119119 wantErr: ErrInvalidHello,
120120 },
121121 {
@@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
115 })115 })
116 },116 },
117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
118- policyOffset: 6,118+ policyOffset: 8,
119 wantErr: ErrInvalidHello,119 wantErr: ErrInvalidHello,
120 },120 },
121 {121 {
M internal/ports/wire.go +13 -0
@@ -169,6 +169,7 @@ type Hello struct {
169169 // PreferredTabID is a client-owned route cursor. The daemon treats it as an
170170 // attachment-local hint and falls back when the stable tab no longer exists.
171171 PreferredTabID domain.TabStableID
172+ RemoteOrigin string
172173 EnvironmentPolicy EnvironmentPolicy
173174 NavigationCapabilities NavigationCapabilities
174175 StartupOverlay StartupOverlay
@@ -728,6 +729,11 @@ func ValidateHello(h Hello) error {
728729 return ErrInvalidHello
729730 }
730731 }
732+ if h.RemoteOrigin != "" {
733+ if err := domain.ValidateRemoteDisplayOrigin(h.RemoteOrigin); err != nil {
734+ return fmt.Errorf("%w: remote origin: %v", ErrInvalidHello, err)
735+ }
736+ }
731737 if h.PreferredTabID != "" {
732738 if h.Intent != IntentAttach && h.Intent != IntentResume {
733739 return ErrInvalidHello
@@ -864,6 +870,7 @@ func MarshalHello(h Hello) []byte {
864870 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
865871 marshalExactTargetSection(&w, h.ExactTarget)
866872 w.putString(string(h.PreferredTabID))
873+ w.putString(h.RemoteOrigin)
867874 w.putUint8(uint8(h.NavigationCapabilities))
868875 w.putUint8(uint8(h.StartupOverlay))
869876 return w.b
@@ -930,6 +937,9 @@ func preflightHello(b []byte) error {
930937 if err := r.skipString(); err != nil {
931938 return err
932939 }
940+ if err := r.skipString(); err != nil {
941+ return err
942+ }
933943 if _, err := r.getUint8(); err != nil {
934944 return err
935945 }
@@ -1019,6 +1029,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
10191029 return Hello{}, err
10201030 }
10211031 h.PreferredTabID = domain.TabStableID(preferredTabID)
1032+ if h.RemoteOrigin, err = r.getString(); err != nil {
1033+ return Hello{}, err
1034+ }
10221035 capabilities, err := r.getUint8()
10231036 if err != nil {
10241037 return Hello{}, err
@@ -169,6 +169,7 @@ type Hello struct {
169 // PreferredTabID is a client-owned route cursor. The daemon treats it as an169 // PreferredTabID is a client-owned route cursor. The daemon treats it as an
170 // attachment-local hint and falls back when the stable tab no longer exists.170 // attachment-local hint and falls back when the stable tab no longer exists.
171 PreferredTabID domain.TabStableID171 PreferredTabID domain.TabStableID
172+ RemoteOrigin string
172 EnvironmentPolicy EnvironmentPolicy173 EnvironmentPolicy EnvironmentPolicy
173 NavigationCapabilities NavigationCapabilities174 NavigationCapabilities NavigationCapabilities
174 StartupOverlay StartupOverlay175 StartupOverlay StartupOverlay
@@ -728,6 +729,11 @@ func ValidateHello(h Hello) error {
728 return ErrInvalidHello729 return ErrInvalidHello
729 }730 }
730 }731 }
732+ if h.RemoteOrigin != "" {
733+ if err := domain.ValidateRemoteDisplayOrigin(h.RemoteOrigin); err != nil {
734+ return fmt.Errorf("%w: remote origin: %v", ErrInvalidHello, err)
735+ }
736+ }
731 if h.PreferredTabID != "" {737 if h.PreferredTabID != "" {
732 if h.Intent != IntentAttach && h.Intent != IntentResume {738 if h.Intent != IntentAttach && h.Intent != IntentResume {
733 return ErrInvalidHello739 return ErrInvalidHello
@@ -864,6 +870,7 @@ func MarshalHello(h Hello) []byte {
864 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)870 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
865 marshalExactTargetSection(&w, h.ExactTarget)871 marshalExactTargetSection(&w, h.ExactTarget)
866 w.putString(string(h.PreferredTabID))872 w.putString(string(h.PreferredTabID))
873+ w.putString(h.RemoteOrigin)
867 w.putUint8(uint8(h.NavigationCapabilities))874 w.putUint8(uint8(h.NavigationCapabilities))
868 w.putUint8(uint8(h.StartupOverlay))875 w.putUint8(uint8(h.StartupOverlay))
869 return w.b876 return w.b
@@ -930,6 +937,9 @@ func preflightHello(b []byte) error {
930 if err := r.skipString(); err != nil {937 if err := r.skipString(); err != nil {
931 return err938 return err
932 }939 }
940+ if err := r.skipString(); err != nil {
941+ return err
942+ }
933 if _, err := r.getUint8(); err != nil {943 if _, err := r.getUint8(); err != nil {
934 return err944 return err
935 }945 }
@@ -1019,6 +1029,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
1019 return Hello{}, err1029 return Hello{}, err
1020 }1030 }
1021 h.PreferredTabID = domain.TabStableID(preferredTabID)1031 h.PreferredTabID = domain.TabStableID(preferredTabID)
1032+ if h.RemoteOrigin, err = r.getString(); err != nil {
1033+ return Hello{}, err
1034+ }
1022 capabilities, err := r.getUint8()1035 capabilities, err := r.getUint8()
1023 if err != nil {1036 if err != nil {
1024 return Hello{}, err1037 return Hello{}, err
M internal/ports/wire_final_test.go +4 -4
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 28 {
15- t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion)
14+ if ProtocolVersion != 29 {
15+ t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 28, 2}, make([]byte, 16+8)...)
30- 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)
29+ want := append([]byte{0, 29, 2}, make([]byte, 16+8)...)
30+ 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)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
3333 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 28 {14+ if ProtocolVersion != 29 {
15- t.Fatalf("ProtocolVersion = %d, want 28", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 29", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 28, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 29, 2}, make([]byte, 16+8)...)
30- 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)30+ 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)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
33 t.Fatalf("Hello bytes = %x, want %x", got, want)33 t.Fatalf("Hello bytes = %x, want %x", got, want)
M internal/ports/wire_test.go +9 -9
@@ -54,9 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
5454 }
5555
5656 func appendNoNavigationTail(payload []byte) []byte {
57- // exact-target absent, preferred-tab absent, navigation capabilities none,
58- // startup overlay none.
59- return append(payload, 0, 0, 0, 0, 0)
57+ // exact-target absent, preferred-tab absent, remote-origin absent,
58+ // navigation capabilities none, startup overlay none.
59+ return append(payload, 0, 0, 0, 0, 0, 0, 0)
6060 }
6161
6262 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131131 }
132132 got := MarshalHello(msg)
133133 want := []byte{
134- 0x00, 0x1c, 0x00, // version, intent, first client ID byte
134+ 0x00, 0x1d, 0x00, // version, intent, first client ID byte
135135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -350,7 +350,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
350350 }
351351 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
352352 require.Equal(t, want, MarshalTheme(cleared))
353- require.Equal(t, uint16(28), ProtocolVersion)
353+ require.Equal(t, uint16(29), ProtocolVersion)
354354 }
355355
356356 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -511,7 +511,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
511511 name: "minimal",
512512 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
513513 want: []byte{
514- 0x00, 0x1c,
514+ 0x00, 0x1d,
515515 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
516516 0x00, // attached
517517 0x00, // self
@@ -534,7 +534,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
534534 JSON: true,
535535 },
536536 want: []byte{
537- 0x00, 0x1c,
537+ 0x00, 0x1d,
538538 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
539539 0x00, // attached
540540 0x01, // self
@@ -963,7 +963,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
963963 name: "attached",
964964 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
965965 want: []byte{
966- 0x00, 0x1c, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
966+ 0x00, 0x1d, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
967967 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
968968 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
969969 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -973,7 +973,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
973973 name: "control",
974974 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
975975 want: []byte{
976- 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
976+ 0x00, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
977977 0x00, 0x02, 'l', 's', 0x00, 0x00,
978978 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
979979 },
@@ -54,9 +54,9 @@ func assertTrailingGarbageFails[T any](t *testing.T, full []byte, unmarshal func
54 }54 }
55 55
56 func appendNoNavigationTail(payload []byte) []byte {56 func appendNoNavigationTail(payload []byte) []byte {
57- // exact-target absent, preferred-tab absent, navigation capabilities none,57+ // exact-target absent, preferred-tab absent, remote-origin absent,
58- // startup overlay none.58+ // navigation capabilities none, startup overlay none.
59- return append(payload, 0, 0, 0, 0, 0)59+ return append(payload, 0, 0, 0, 0, 0, 0, 0)
60 }60 }
61 61
62 func TestHelloGoldenAndRoundTrip(t *testing.T) {62 func TestHelloGoldenAndRoundTrip(t *testing.T) {
@@ -131,7 +131,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
131 }131 }
132 got := MarshalHello(msg)132 got := MarshalHello(msg)
133 want := []byte{133 want := []byte{
134- 0x00, 0x1c, 0x00, // version, intent, first client ID byte134+ 0x00, 0x1d, 0x00, // version, intent, first client ID byte
135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,135 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID136 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token137 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -350,7 +350,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
350 }350 }
351 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)351 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
352 require.Equal(t, want, MarshalTheme(cleared))352 require.Equal(t, want, MarshalTheme(cleared))
353- require.Equal(t, uint16(28), ProtocolVersion)353+ require.Equal(t, uint16(29), ProtocolVersion)
354 }354 }
355 355
356 func TestResizeGoldenAndRoundTrip(t *testing.T) {356 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -511,7 +511,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
511 name: "minimal",511 name: "minimal",
512 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},512 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
513 want: []byte{513 want: []byte{
514- 0x00, 0x1c,514+ 0x00, 0x1d,
515 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID515 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
516 0x00, // attached516 0x00, // attached
517 0x00, // self517 0x00, // self
@@ -534,7 +534,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
534 JSON: true,534 JSON: true,
535 },535 },
536 want: []byte{536 want: []byte{
537- 0x00, 0x1c,537+ 0x00, 0x1d,
538 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID538 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
539 0x00, // attached539 0x00, // attached
540 0x01, // self540 0x01, // self
@@ -963,7 +963,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
963 name: "attached",963 name: "attached",
964 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},964 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
965 want: []byte{965 want: []byte{
966- 0x00, 0x1c, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,966+ 0x00, 0x1d, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
967 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',967 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
968 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',968 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
969 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,969 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -973,7 +973,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
973 name: "control",973 name: "control",
974 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},974 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
975 want: []byte{975 want: []byte{
976- 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,976+ 0x00, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
977 0x00, 0x02, 'l', 's', 0x00, 0x00,977 0x00, 0x02, 'l', 's', 0x00, 0x00,
978 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,978 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
979 },979 },
M internal/usecase/client/client.go +16 -17
@@ -181,6 +181,7 @@ type AttachRequest struct {
181181 RemoteTarget *domain.RemoteSessionTarget
182182 ExactTarget *ports.ExactSessionTarget
183183 PreferredTabID domain.TabStableID
184+ RemoteOrigin string
184185 EnvironmentPolicy ports.EnvironmentPolicy
185186 NavigationCapabilities ports.NavigationCapabilities
186187 StartupOverlay ports.StartupOverlay
@@ -616,7 +617,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
616617 enterHomePicker()
617618 continue
618619 case ports.NavigationBack:
619- if attemptRequest.StartupOverlay != ports.StartupOverlaySessionPicker || returnRoute == nil || attemptRequest.NavigationCapabilities&ports.NavigationCapabilityBack == 0 {
620+ // As with Home, the daemon accepted Back in Hello before sending
621+ // this action. The retained concrete route is the durable client
622+ // prerequisite; request metadata may have been rebased meanwhile.
623+ if returnRoute == nil {
620624 return errors.New("vev: stale return navigation action")
621625 }
622626 route := *returnRoute
@@ -691,14 +695,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
691695 restoreReturnRoute(route)
692696 continue
693697 }
694- if routeNavigationPending && routeNavigationResumeFallback {
695- var protocolErr *ProtocolError
696- if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {
697- attemptRequest.Intent = ports.IntentAttach
698- resumeToken = 0
699- routeNavigationResumeFallback = false
700- continue
701- }
698+ if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {
699+ attemptRequest.Intent = ports.IntentAttach
700+ resumeToken = 0
701+ routeNavigationResumeFallback = false
702+ continue
702703 }
703704 if routeNavigationPending && routeNavigationFallback != nil {
704705 if routeNavigationAction != nil {
@@ -715,14 +716,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
715716 restoreReturnRoute(route)
716717 continue
717718 }
718- if returnResumeFallback {
719- var protocolErr *ProtocolError
720- if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {
721- attemptRequest.Intent = ports.IntentAttach
722- resumeToken = 0
723- returnResumeFallback = false
724- continue
725- }
719+ if returnResumeFallback && resumeNeedsExactAttach(result.err) {
720+ attemptRequest.Intent = ports.IntentAttach
721+ resumeToken = 0
722+ returnResumeFallback = false
723+ continue
726724 }
727725 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
728726 returnRoute = nil
@@ -1040,6 +1038,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10401038 RemoteTarget: request.RemoteTarget,
10411039 ExactTarget: exactTarget,
10421040 PreferredTabID: request.PreferredTabID,
1041+ RemoteOrigin: request.RemoteOrigin,
10431042 EnvironmentPolicy: request.EnvironmentPolicy,
10441043 NavigationCapabilities: request.NavigationCapabilities,
10451044 StartupOverlay: request.StartupOverlay,
@@ -181,6 +181,7 @@ type AttachRequest struct {
181 RemoteTarget *domain.RemoteSessionTarget181 RemoteTarget *domain.RemoteSessionTarget
182 ExactTarget *ports.ExactSessionTarget182 ExactTarget *ports.ExactSessionTarget
183 PreferredTabID domain.TabStableID183 PreferredTabID domain.TabStableID
184+ RemoteOrigin string
184 EnvironmentPolicy ports.EnvironmentPolicy185 EnvironmentPolicy ports.EnvironmentPolicy
185 NavigationCapabilities ports.NavigationCapabilities186 NavigationCapabilities ports.NavigationCapabilities
186 StartupOverlay ports.StartupOverlay187 StartupOverlay ports.StartupOverlay
@@ -616,7 +617,10 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
616 enterHomePicker()617 enterHomePicker()
617 continue618 continue
618 case ports.NavigationBack:619 case ports.NavigationBack:
619- if attemptRequest.StartupOverlay != ports.StartupOverlaySessionPicker || returnRoute == nil || attemptRequest.NavigationCapabilities&ports.NavigationCapabilityBack == 0 {620+ // As with Home, the daemon accepted Back in Hello before sending
621+ // this action. The retained concrete route is the durable client
622+ // prerequisite; request metadata may have been rebased meanwhile.
623+ if returnRoute == nil {
620 return errors.New("vev: stale return navigation action")624 return errors.New("vev: stale return navigation action")
621 }625 }
622 route := *returnRoute626 route := *returnRoute
@@ -691,14 +695,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
691 restoreReturnRoute(route)695 restoreReturnRoute(route)
692 continue696 continue
693 }697 }
694- if routeNavigationPending && routeNavigationResumeFallback {698+ if routeNavigationPending && routeNavigationResumeFallback && resumeNeedsExactAttach(result.err) {
695- var protocolErr *ProtocolError699+ attemptRequest.Intent = ports.IntentAttach
696- if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {700+ resumeToken = 0
697- attemptRequest.Intent = ports.IntentAttach701+ routeNavigationResumeFallback = false
698- resumeToken = 0702+ continue
699- routeNavigationResumeFallback = false
700- continue
701- }
702 }703 }
703 if routeNavigationPending && routeNavigationFallback != nil {704 if routeNavigationPending && routeNavigationFallback != nil {
704 if routeNavigationAction != nil {705 if routeNavigationAction != nil {
@@ -715,14 +716,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
715 restoreReturnRoute(route)716 restoreReturnRoute(route)
716 continue717 continue
717 }718 }
718- if returnResumeFallback {719+ if returnResumeFallback && resumeNeedsExactAttach(result.err) {
719- var protocolErr *ProtocolError720+ attemptRequest.Intent = ports.IntentAttach
720- if errors.As(result.err, &protocolErr) && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget) {721+ resumeToken = 0
721- attemptRequest.Intent = ports.IntentAttach722+ returnResumeFallback = false
722- resumeToken = 0723+ continue
723- returnResumeFallback = false
724- continue
725- }
726 }724 }
727 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {725 if returnNavigationPending && !returnResumeFallback && homeRoute != nil {
728 returnRoute = nil726 returnRoute = nil
@@ -1040,6 +1038,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1040 RemoteTarget: request.RemoteTarget,1038 RemoteTarget: request.RemoteTarget,
1041 ExactTarget: exactTarget,1039 ExactTarget: exactTarget,
1042 PreferredTabID: request.PreferredTabID,1040 PreferredTabID: request.PreferredTabID,
1041+ RemoteOrigin: request.RemoteOrigin,
1043 EnvironmentPolicy: request.EnvironmentPolicy,1042 EnvironmentPolicy: request.EnvironmentPolicy,
1044 NavigationCapabilities: request.NavigationCapabilities,1043 NavigationCapabilities: request.NavigationCapabilities,
1045 StartupOverlay: request.StartupOverlay,1044 StartupOverlay: request.StartupOverlay,
M internal/usecase/client/reconnect.go +8 -0
@@ -49,6 +49,14 @@ func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
4949 return cur
5050 }
5151
52+func resumeNeedsExactAttach(err error) bool {
53+ if errors.Is(err, errRouteTargetChanged) {
54+ return true
55+ }
56+ protocolErr, ok := errors.AsType[*ProtocolError](err)
57+ return ok && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget)
58+}
59+
5260 func shouldReconnect(err error) bool {
5361 if err == nil {
5462 return false
@@ -49,6 +49,14 @@ func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
49 return cur49 return cur
50 }50 }
51 51
52+func resumeNeedsExactAttach(err error) bool {
53+ if errors.Is(err, errRouteTargetChanged) {
54+ return true
55+ }
56+ protocolErr, ok := errors.AsType[*ProtocolError](err)
57+ return ok && (protocolErr.Code == ports.ErrNoSuchSession || protocolErr.Code == ports.ErrNoSuchTarget)
58+}
59+
52 func shouldReconnect(err error) bool {60 func shouldReconnect(err error) bool {
53 if err == nil {61 if err == nil {
54 return false62 return false
M internal/usecase/client/reconnect_toast_test.go +18 -0
@@ -354,6 +354,24 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c
354354 }
355355 }
356356
357+func TestResumeNeedsExactAttach(t *testing.T) {
358+ for _, test := range []struct {
359+ name string
360+ err error
361+ want bool
362+ }{
363+ {name: "moved resume token", err: errRouteTargetChanged, want: true},
364+ {name: "missing target", err: &ProtocolError{Code: ports.ErrNoSuchTarget}, want: true},
365+ {name: "missing session", err: &ProtocolError{Code: ports.ErrNoSuchSession}, want: true},
366+ {name: "internal", err: &ProtocolError{Code: ports.ErrInternal}},
367+ {name: "transport", err: io.EOF},
368+ } {
369+ t.Run(test.name, func(t *testing.T) {
370+ require.Equal(t, test.want, resumeNeedsExactAttach(test.err))
371+ })
372+ }
373+}
374+
357375 func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
358376 out := newReconnectToastOutputRecorder()
359377 var flushes atomic.Int32
@@ -354,6 +354,24 @@ func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, c
354 }354 }
355 }355 }
356 356
357+func TestResumeNeedsExactAttach(t *testing.T) {
358+ for _, test := range []struct {
359+ name string
360+ err error
361+ want bool
362+ }{
363+ {name: "moved resume token", err: errRouteTargetChanged, want: true},
364+ {name: "missing target", err: &ProtocolError{Code: ports.ErrNoSuchTarget}, want: true},
365+ {name: "missing session", err: &ProtocolError{Code: ports.ErrNoSuchSession}, want: true},
366+ {name: "internal", err: &ProtocolError{Code: ports.ErrInternal}},
367+ {name: "transport", err: io.EOF},
368+ } {
369+ t.Run(test.name, func(t *testing.T) {
370+ require.Equal(t, test.want, resumeNeedsExactAttach(test.err))
371+ })
372+ }
373+}
374+
357 func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {375 func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
358 out := newReconnectToastOutputRecorder()376 out := newReconnectToastOutputRecorder()
359 var flushes atomic.Int32377 var flushes atomic.Int32
M internal/usecase/client/routes.go +1 -0
@@ -132,6 +132,7 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
132132 request.OriginKey = originKey
133133 request.SessionName = identity.Target.SessionName
134134 request.ExactTarget = &identity.Target
135+ request.RemoteOrigin = hostLabel
135136 // Discovery targets are point-in-time attach authority, including a live
136137 // tab selector. Once Welcome commits an exact route, mutable tab memory is
137138 // carried independently by PreferredTabID.
@@ -132,6 +132,7 @@ func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRout
132 request.OriginKey = originKey132 request.OriginKey = originKey
133 request.SessionName = identity.Target.SessionName133 request.SessionName = identity.Target.SessionName
134 request.ExactTarget = &identity.Target134 request.ExactTarget = &identity.Target
135+ request.RemoteOrigin = hostLabel
135 // Discovery targets are point-in-time attach authority, including a live136 // Discovery targets are point-in-time attach authority, including a live
136 // tab selector. Once Welcome commits an exact route, mutable tab memory is137 // tab selector. Once Welcome commits an exact route, mutable tab memory is
137 // carried independently by PreferredTabID.138 // carried independently by PreferredTabID.
M internal/usecase/client/routes_test.go +12 -0
@@ -33,6 +33,18 @@ func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
3333 }
3434 }
3535
36+func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
37+ target := routeTestTarget(1)
38+ candidate := routeCandidateForAttach(AttachRequest{
39+ Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true,
40+ Origin: ports.RouteOriginRemote, OriginKey: "remote",
41+ }, ports.CommittedRouteIdentity{Target: target}, nil, 0)
42+
43+ require.Nil(t, candidate.request.RemoteTarget)
44+ require.Equal(t, "remote", candidate.request.RemoteOrigin)
45+ require.Equal(t, "remote", candidate.presentation.hostLabel)
46+}
47+
3648 func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
3749 ledger := newRouteLedger()
3850 var keys []routeKey
@@ -33,6 +33,18 @@ func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
33 }33 }
34 }34 }
35 35
36+func TestRouteCandidateRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
37+ target := routeTestTarget(1)
38+ candidate := routeCandidateForAttach(AttachRequest{
39+ Intent: ports.IntentAttach, SessionName: target.SessionName, Remote: true,
40+ Origin: ports.RouteOriginRemote, OriginKey: "remote",
41+ }, ports.CommittedRouteIdentity{Target: target}, nil, 0)
42+
43+ require.Nil(t, candidate.request.RemoteTarget)
44+ require.Equal(t, "remote", candidate.request.RemoteOrigin)
45+ require.Equal(t, "remote", candidate.presentation.hostLabel)
46+}
47+
36 func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {48 func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
37 ledger := newRouteLedger()49 ledger := newRouteLedger()
38 var keys []routeKey50 var keys []routeKey
M internal/usecase/daemon/daemon.go +2 -1
@@ -1288,8 +1288,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12881288 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
12891289 navigationCapabilities: h.NavigationCapabilities,
12901290 startupOverlay: h.StartupOverlay,
1291+ remoteOrigin: h.RemoteOrigin,
12911292 }
1292- if h.RemoteTarget != nil {
1293+ if opts.remoteOrigin == "" && h.RemoteTarget != nil {
12931294 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
12941295 }
12951296 ac := d.prepareAttachedClientLocked(tr, sz, opts)
@@ -1288,8 +1288,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1288 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1288 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1289 navigationCapabilities: h.NavigationCapabilities,1289 navigationCapabilities: h.NavigationCapabilities,
1290 startupOverlay: h.StartupOverlay,1290 startupOverlay: h.StartupOverlay,
1291+ remoteOrigin: h.RemoteOrigin,
1291 }1292 }
1292- if h.RemoteTarget != nil {1293+ if opts.remoteOrigin == "" && h.RemoteTarget != nil {
1293 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin1294 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
1294 }1295 }
1295 ac := d.prepareAttachedClientLocked(tr, sz, opts)1296 ac := d.prepareAttachedClientLocked(tr, sz, opts)
M internal/usecase/daemon/route_position_test.go +18 -0
@@ -10,6 +10,24 @@ import (
1010 "github.com/bnema/vev/internal/ports"
1111 )
1212
13+func TestRouteRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
14+ d := newTestDaemon(t, nil, stubClock{})
15+ sess := addControlSession(d, "work", "tab-1", "pane-1")
16+ sess.incarnation = domain.SessionLifecycleID{1}
17+ tr := &closeTrackingTransport{}
18+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
19+ hello := ports.Hello{
20+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
21+ Size: defaultSize, ExactTarget: &target, RemoteOrigin: "remote",
22+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
23+ }
24+
25+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
26+ require.NoError(t, err)
27+ require.Equal(t, "remote", ac.remoteOrigin)
28+ d.clientGone(sess, ac, tr, false)
29+}
30+
1331 func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {
1432 for _, test := range []struct {
1533 name string
@@ -10,6 +10,24 @@ import (
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 )11 )
12 12
13+func TestRouteRetainsRemoteOriginWithoutDiscoveryTarget(t *testing.T) {
14+ d := newTestDaemon(t, nil, stubClock{})
15+ sess := addControlSession(d, "work", "tab-1", "pane-1")
16+ sess.incarnation = domain.SessionLifecycleID{1}
17+ tr := &closeTrackingTransport{}
18+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
19+ hello := ports.Hello{
20+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: sess.name,
21+ Size: defaultSize, ExactTarget: &target, RemoteOrigin: "remote",
22+ EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
23+ }
24+
25+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
26+ require.NoError(t, err)
27+ require.Equal(t, "remote", ac.remoteOrigin)
28+ d.clientGone(sess, ac, tr, false)
29+}
30+
13 func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {31 func TestRouteAppliesPreferredTabPerAttachment(t *testing.T) {
14 for _, test := range []struct {32 for _, test := range []struct {
15 name string33 name string

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close