detached 4dbec77

feat(client): add route ledger protocol plumbing

commit
4dbec77c1436c57bb2cb661b4d7600601e9ffeee
author
brice · · 2026-08-09 11:51 UTC
parent
a27f761
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +1760 -59
A .agent-reviews/redteam.md +38 -0
@@ -0,0 +1,38 @@
1+# Stack 2 route ledger/protocol red-team
2+
3+## Builder decision
4+
5+Add Stack 2 as daemon-neutral plumbing on top of the completed presentation branch:
6+
7+- model route origins, exact lifecycle/name targets, committed identity, bounded complete snapshots, opaque key/generation navigation actions, and closed failure codes in `internal/ports`;
8+- bump the protocol version and append an optional exact target section to `Hello` while preserving the version peeker's first field;
9+- wire explicit local, remote, and discovery origins at `internal/app`/client composition boundaries;
10+- keep the client ledger private, bounded, lock-protected, and separate from display values and transport capabilities;
11+- validate exact local targets in the daemon and preserve existing remote-target authority;
12+- leave JRS/BSK command authority and the atomic global cutover for Stack 3.
13+
14+## Critic pass
15+
16+The read-only reviewer worker did not return a retrievable result in this harness.
17+The builder completed a local high-impact critic pass below and recorded each
18+objection with its resolution and acceptance check.
19+
20+## Evidence so far
21+
22+- PR #188 dependency branch already has strict Hello peeking and remote lifecycle/tab target validation.
23+- `internal/ports/wire.go` requires `Hello.Version` first; the new exact-target section is appended after the existing remote/policy section.
24+- Existing daemon route authority remains in `routeWithContext`; exact local validation is an additional lifecycle check before ordinary attach routing.
25+- Focused ports, client, app, and daemon tests pass after the current incremental changes.
26+
27+## Accepted risks
28+
29+- Stack 2 introduces codec/model seams and private transition primitives but does not yet publish snapshots or replace command authority; those remain explicit Stack 3 work.
30+- The first reviewer-worker attempts did not produce a retrievable result in this harness, so the builder performed the required critic pass locally before final validation.
31+
32+## Builder critic pass and resolutions
33+
34+- **Welcome identity mismatch:** a malicious or buggy daemon could return a committed lifecycle/name different from the Welcome metadata. `UnmarshalWelcome` now rejects mismatched session name or ephemeral state.
35+- **Stale exact target side effects:** exact local validation waits for an existing restore barrier before checking lifecycle identity; it does not create or mutate a session and rejects lifecycle replacement before normal attach.
36+- **Snapshot leakage:** `RecentRouteEntry` contains only opaque key/generation and display fields. Dialers, requests, resume credentials, and exact targets remain in private `routeRecord` values and are copied out only through navigation lookup.
37+- **Transactional navigation:** connector failure, stale key/generation, or committed-target mismatch returns before ledger commit; origin is restored from the selected private record rather than connector input.
38+- **Bounds/strictness:** snapshot entry count, labels, refs, closed enums, bool encodings, truncation, trailing bytes, and protocol-version peeking are covered by tests; focused race tests pass.
@@ -0,0 +1,38 @@
1+# Stack 2 route ledger/protocol red-team
2+
3+## Builder decision
4+
5+Add Stack 2 as daemon-neutral plumbing on top of the completed presentation branch:
6+
7+- model route origins, exact lifecycle/name targets, committed identity, bounded complete snapshots, opaque key/generation navigation actions, and closed failure codes in `internal/ports`;
8+- bump the protocol version and append an optional exact target section to `Hello` while preserving the version peeker's first field;
9+- wire explicit local, remote, and discovery origins at `internal/app`/client composition boundaries;
10+- keep the client ledger private, bounded, lock-protected, and separate from display values and transport capabilities;
11+- validate exact local targets in the daemon and preserve existing remote-target authority;
12+- leave JRS/BSK command authority and the atomic global cutover for Stack 3.
13+
14+## Critic pass
15+
16+The read-only reviewer worker did not return a retrievable result in this harness.
17+The builder completed a local high-impact critic pass below and recorded each
18+objection with its resolution and acceptance check.
19+
20+## Evidence so far
21+
22+- PR #188 dependency branch already has strict Hello peeking and remote lifecycle/tab target validation.
23+- `internal/ports/wire.go` requires `Hello.Version` first; the new exact-target section is appended after the existing remote/policy section.
24+- Existing daemon route authority remains in `routeWithContext`; exact local validation is an additional lifecycle check before ordinary attach routing.
25+- Focused ports, client, app, and daemon tests pass after the current incremental changes.
26+
27+## Accepted risks
28+
29+- Stack 2 introduces codec/model seams and private transition primitives but does not yet publish snapshots or replace command authority; those remain explicit Stack 3 work.
30+- The first reviewer-worker attempts did not produce a retrievable result in this harness, so the builder performed the required critic pass locally before final validation.
31+
32+## Builder critic pass and resolutions
33+
34+- **Welcome identity mismatch:** a malicious or buggy daemon could return a committed lifecycle/name different from the Welcome metadata. `UnmarshalWelcome` now rejects mismatched session name or ephemeral state.
35+- **Stale exact target side effects:** exact local validation waits for an existing restore barrier before checking lifecycle identity; it does not create or mutate a session and rejects lifecycle replacement before normal attach.
36+- **Snapshot leakage:** `RecentRouteEntry` contains only opaque key/generation and display fields. Dialers, requests, resume credentials, and exact targets remain in private `routeRecord` values and are copied out only through navigation lookup.
37+- **Transactional navigation:** connector failure, stale key/generation, or committed-target mismatch returns before ledger commit; origin is restored from the selected private record rather than connector input.
38+- **Bounds/strictness:** snapshot entry count, labels, refs, closed enums, bool encodings, truncation, trailing bytes, and protocol-version peeking are covered by tests; focused race tests pass.
M docs/remote-resilience.md +4 -1
@@ -32,7 +32,10 @@ is a temporary direct handoff; the client keeps one input pump and remembers a
3232 bounded home route. A remote daemon may request the home picker, and cancelling
3333 that temporary local picker returns to the parked remote route, first attempting
3434 resume and then falling back to a fresh attach when the resume token is stale.
35-These navigation actions and the strict handshake layout use protocol v26.
35+These navigation actions and the strict handshake layout use protocol v27.
36+Attach handshakes may carry an exact lifecycle/name target, and successful
37+Welcomes can return the daemon's committed identity; the client keeps bounded
38+route identity and display snapshots separate from transport capabilities.
3639
3740 ## Durable record compatibility
3841
@@ -32,7 +32,10 @@ is a temporary direct handoff; the client keeps one input pump and remembers a
32 bounded home route. A remote daemon may request the home picker, and cancelling32 bounded home route. A remote daemon may request the home picker, and cancelling
33 that temporary local picker returns to the parked remote route, first attempting33 that temporary local picker returns to the parked remote route, first attempting
34 resume and then falling back to a fresh attach when the resume token is stale.34 resume and then falling back to a fresh attach when the resume token is stale.
35-These navigation actions and the strict handshake layout use protocol v26.35+These navigation actions and the strict handshake layout use protocol v27.
36+Attach handshakes may carry an exact lifecycle/name target, and successful
37+Welcomes can return the daemon's committed identity; the client keeps bounded
38+route identity and display snapshots separate from transport capabilities.
36 39
37 ## Durable record compatibility40 ## Durable record compatibility
38 41
M internal/app/run.go +15 -2
@@ -866,6 +866,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
866866 factory := deps.remoteDialerFactory
867867 var remoteSelection *domain.RemoteSessionTarget
868868 var remoteEnvironmentPolicy ports.EnvironmentPolicy
869+ routeOrigin := ports.RouteOriginLocal
869870 seenHandoffs := make(map[attachHandoffKey]struct{})
870871 handoffHops := 0
871872 admitHandoff := func(next attachHandoffKey) error {
@@ -880,6 +881,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
880881 return nil
881882 }
882883 if remoteTarget != "" {
884+ routeOrigin = ports.RouteOriginRemote
883885 if modeErr != nil {
884886 return modeErr
885887 }
@@ -922,7 +924,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
922924 return nil, client.AttachRequest{}, err
923925 }
924926 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
925- return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: policy}, nil
927+ return dialer, client.AttachRequest{
928+ Intent: target.Intent,
929+ SessionName: target.Session,
930+ Remote: true,
931+ Origin: ports.RouteOriginDiscovery,
932+ RemoteTarget: selection,
933+ EnvironmentPolicy: policy,
934+ }, nil
926935 }
927936
928937 for {
@@ -945,10 +954,12 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
945954 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
946955 AttachHandoff: handoff,
947956 Remote: true,
957+ Origin: ports.RouteOriginRemote,
948958 }, client.AttachRequest{
949959 Intent: intent,
950960 SessionName: name,
951961 Remote: true,
962+ Origin: routeOrigin,
952963 RemoteTarget: remoteSelection,
953964 EnvironmentPolicy: remoteEnvironmentPolicy,
954965 })
@@ -964,7 +975,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
964975 RuntimeObserver: deps.runtimeObserver,
965976 AttachHandoff: handoff,
966977 Remote: false,
967- }, client.AttachRequest{Intent: intent, SessionName: name})
978+ Origin: ports.RouteOriginLocal,
979+ }, client.AttachRequest{Intent: intent, SessionName: name, Origin: ports.RouteOriginLocal})
968980 }
969981
970982 var handoffErr *client.AttachTargetError
@@ -987,6 +999,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
987999 return err
9881000 }
9891001 remoteTarget = handoffErr.Target.Endpoint
1002+ routeOrigin = ports.RouteOriginDiscovery
9901003 name = handoffErr.Target.Session
9911004 intent = handoffErr.Target.Intent
9921005 remoteSelection = nil
@@ -866,6 +866,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
866 factory := deps.remoteDialerFactory866 factory := deps.remoteDialerFactory
867 var remoteSelection *domain.RemoteSessionTarget867 var remoteSelection *domain.RemoteSessionTarget
868 var remoteEnvironmentPolicy ports.EnvironmentPolicy868 var remoteEnvironmentPolicy ports.EnvironmentPolicy
869+ routeOrigin := ports.RouteOriginLocal
869 seenHandoffs := make(map[attachHandoffKey]struct{})870 seenHandoffs := make(map[attachHandoffKey]struct{})
870 handoffHops := 0871 handoffHops := 0
871 admitHandoff := func(next attachHandoffKey) error {872 admitHandoff := func(next attachHandoffKey) error {
@@ -880,6 +881,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
880 return nil881 return nil
881 }882 }
882 if remoteTarget != "" {883 if remoteTarget != "" {
884+ routeOrigin = ports.RouteOriginRemote
883 if modeErr != nil {885 if modeErr != nil {
884 return modeErr886 return modeErr
885 }887 }
@@ -922,7 +924,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
922 return nil, client.AttachRequest{}, err924 return nil, client.AttachRequest{}, err
923 }925 }
924 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)926 policy := pickerEnvironmentPolicy(selection, target.EnvironmentPolicy)
925- return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: policy}, nil927+ return dialer, client.AttachRequest{
928+ Intent: target.Intent,
929+ SessionName: target.Session,
930+ Remote: true,
931+ Origin: ports.RouteOriginDiscovery,
932+ RemoteTarget: selection,
933+ EnvironmentPolicy: policy,
934+ }, nil
926 }935 }
927 936
928 for {937 for {
@@ -945,10 +954,12 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
945 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),954 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
946 AttachHandoff: handoff,955 AttachHandoff: handoff,
947 Remote: true,956 Remote: true,
957+ Origin: ports.RouteOriginRemote,
948 }, client.AttachRequest{958 }, client.AttachRequest{
949 Intent: intent,959 Intent: intent,
950 SessionName: name,960 SessionName: name,
951 Remote: true,961 Remote: true,
962+ Origin: routeOrigin,
952 RemoteTarget: remoteSelection,963 RemoteTarget: remoteSelection,
953 EnvironmentPolicy: remoteEnvironmentPolicy,964 EnvironmentPolicy: remoteEnvironmentPolicy,
954 })965 })
@@ -964,7 +975,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
964 RuntimeObserver: deps.runtimeObserver,975 RuntimeObserver: deps.runtimeObserver,
965 AttachHandoff: handoff,976 AttachHandoff: handoff,
966 Remote: false,977 Remote: false,
967- }, client.AttachRequest{Intent: intent, SessionName: name})978+ Origin: ports.RouteOriginLocal,
979+ }, client.AttachRequest{Intent: intent, SessionName: name, Origin: ports.RouteOriginLocal})
968 }980 }
969 981
970 var handoffErr *client.AttachTargetError982 var handoffErr *client.AttachTargetError
@@ -987,6 +999,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
987 return err999 return err
988 }1000 }
989 remoteTarget = handoffErr.Target.Endpoint1001 remoteTarget = handoffErr.Target.Endpoint
1002+ routeOrigin = ports.RouteOriginDiscovery
990 name = handoffErr.Target.Session1003 name = handoffErr.Target.Session
991 intent = handoffErr.Target.Intent1004 intent = handoffErr.Target.Intent
992 remoteSelection = nil1005 remoteSelection = nil
M internal/app/run_test.go +2 -2
@@ -959,11 +959,11 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
959959 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
960960 if deps.Remote {
961961 remoteCalls++
962- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
962+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
963963 return nil
964964 }
965965 localCalls++
966- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, request)
966+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal}, request)
967967 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
968968 },
969969 })
@@ -959,11 +959,11 @@ func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T)
959 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {959 runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
960 if deps.Remote {960 if deps.Remote {
961 remoteCalls++961 remoteCalls++
962- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)962+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
963 return nil963 return nil
964 }964 }
965 localCalls++965 localCalls++
966- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, request)966+ require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal}, request)
967 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}967 return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
968 },968 },
969 })969 })
M internal/ports/frame.go +18 -13
@@ -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 = 26
15+const ProtocolVersion uint16 = 27
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
@@ -22,8 +22,9 @@ const MaxFrameLen = 16 << 20
2222 type MsgType uint8
2323
2424 // Frame message types. Client-originated messages occupy 1–13 and 15;
25-// server-originated messages occupy 16–23 and 25–26. Values 14 and 24 remain
26-// reserved for future extensions.
25+// server-originated messages occupy 16–23 and 25–27. Values 14 and 24 remain
26+// reserved for future extensions. Route snapshot/navigation messages use the
27+// next available values 28–30 and remain daemon-neutral until the cutover.
2728 const (
2829 MsgHello MsgType = 1
2930 MsgInput MsgType = 2
@@ -40,16 +41,20 @@ const (
4041 MsgOutputResetRequest MsgType = 13
4142 MsgRemotePreviewRequest MsgType = 15
4243
43- MsgWelcome MsgType = 16
44- MsgError MsgType = 17
45- MsgOutput MsgType = 18
46- MsgDetached MsgType = 19
47- MsgPong MsgType = 20
48- MsgSessions MsgType = 21
49- MsgCommandResult MsgType = 22
50- MsgAttachTarget MsgType = 25
51- MsgRemotePreviewResponse MsgType = 26
52- MsgNavigationAction MsgType = 23
44+ MsgWelcome MsgType = 16
45+ MsgError MsgType = 17
46+ MsgOutput MsgType = 18
47+ MsgDetached MsgType = 19
48+ MsgPong MsgType = 20
49+ MsgSessions MsgType = 21
50+ MsgCommandResult MsgType = 22
51+ MsgAttachTarget MsgType = 25
52+ MsgRemotePreviewResponse MsgType = 26
53+ MsgNavigationAction MsgType = 23
54+ MsgCommittedRouteIdentity MsgType = 27
55+ MsgRecentRouteSnapshot MsgType = 28
56+ MsgNavigateRecentRoute MsgType = 29
57+ MsgRouteNavigationFailure MsgType = 30
5358 )
5459
5560 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -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 = 2615+const ProtocolVersion uint16 = 27
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.
@@ -22,8 +22,9 @@ const MaxFrameLen = 16 << 20
22 type MsgType uint822 type MsgType uint8
23 23
24 // Frame message types. Client-originated messages occupy 1–13 and 15;24 // Frame message types. Client-originated messages occupy 1–13 and 15;
25-// server-originated messages occupy 16–23 and 25–26. Values 14 and 24 remain25+// server-originated messages occupy 16–23 and 25–27. Values 14 and 24 remain
26-// reserved for future extensions.26+// reserved for future extensions. Route snapshot/navigation messages use the
27+// next available values 28–30 and remain daemon-neutral until the cutover.
27 const (28 const (
28 MsgHello MsgType = 129 MsgHello MsgType = 1
29 MsgInput MsgType = 230 MsgInput MsgType = 2
@@ -40,16 +41,20 @@ const (
40 MsgOutputResetRequest MsgType = 1341 MsgOutputResetRequest MsgType = 13
41 MsgRemotePreviewRequest MsgType = 1542 MsgRemotePreviewRequest MsgType = 15
42 43
43- MsgWelcome MsgType = 1644+ MsgWelcome MsgType = 16
44- MsgError MsgType = 1745+ MsgError MsgType = 17
45- MsgOutput MsgType = 1846+ MsgOutput MsgType = 18
46- MsgDetached MsgType = 1947+ MsgDetached MsgType = 19
47- MsgPong MsgType = 2048+ MsgPong MsgType = 20
48- MsgSessions MsgType = 2149+ MsgSessions MsgType = 21
49- MsgCommandResult MsgType = 2250+ MsgCommandResult MsgType = 22
50- MsgAttachTarget MsgType = 2551+ MsgAttachTarget MsgType = 25
51- MsgRemotePreviewResponse MsgType = 2652+ MsgRemotePreviewResponse MsgType = 26
52- MsgNavigationAction MsgType = 2353+ MsgNavigationAction MsgType = 23
54+ MsgCommittedRouteIdentity MsgType = 27
55+ MsgRecentRouteSnapshot MsgType = 28
56+ MsgNavigateRecentRoute MsgType = 29
57+ MsgRouteNavigationFailure MsgType = 30
53 )58 )
54 59
55 // Frame is the unit of exchange over a Transport: a typed, length-delimited60 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +4 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 26 {
7- t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion)
6+ if ProtocolVersion != 27 {
7+ t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion)
88 }
99 }
1010
@@ -37,6 +37,8 @@ func TestMsgTypeUnique(t *testing.T) {
3737 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
3838 {"MsgCommandResult", MsgCommandResult},
3939 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
40+ {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
41+ {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
4042 }
4143
4244 seen := make(map[MsgType]string, len(tests))
@@ -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 != 26 {6+ if ProtocolVersion != 27 {
7- t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 27", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -37,6 +37,8 @@ func TestMsgTypeUnique(t *testing.T) {
37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},37 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
38 {"MsgCommandResult", MsgCommandResult},38 {"MsgCommandResult", MsgCommandResult},
39 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},39 {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget}, {"MsgNavigationAction", MsgNavigationAction},
40+ {"MsgCommittedRouteIdentity", MsgCommittedRouteIdentity}, {"MsgRecentRouteSnapshot", MsgRecentRouteSnapshot},
41+ {"MsgNavigateRecentRoute", MsgNavigateRecentRoute}, {"MsgRouteNavigationFailure", MsgRouteNavigationFailure},
40 }42 }
41 43
42 seen := make(map[MsgType]string, len(tests))44 seen := make(map[MsgType]string, len(tests))
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: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "0201"},
65+ {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "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: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001a02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "0201"},65+ {name: "startup picker requires back", hello: Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, NavigationCapabilities: NavigationCapabilityBack, StartupOverlay: StartupOverlaySessionPicker}, valid: true, capability: NavigationCapabilityBack, overlay: StartupOverlaySessionPicker, wantPayload: "001b02" + "00000000000000000000000000000000" + "0000000000000000" + "0000" + "00500018" + "0000" + "0000" + "00" + "00" + "00000000" + "00" + "00" + "00" + "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 +2 -2
@@ -115,7 +115,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
115115 })
116116 },
117117 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
118- policyOffset: 3,
118+ policyOffset: 4,
119119 wantErr: ErrInvalidHello,
120120 },
121121 {
@@ -194,7 +194,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
194194 }
195195 }
196196
197-func TestTargetWireIncludesRequiredV26Section(t *testing.T) {
197+func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
198198 want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
199199 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
200200 if !bytes.Equal(got, want) {
@@ -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: 3,118+ policyOffset: 4,
119 wantErr: ErrInvalidHello,119 wantErr: ErrInvalidHello,
120 },120 },
121 {121 {
@@ -194,7 +194,7 @@ func TestAttachTargetRejectsResumeIntent(t *testing.T) {
194 }194 }
195 }195 }
196 196
197-func TestTargetWireIncludesRequiredV26Section(t *testing.T) {197+func TestTargetWireIncludesRequiredV27Section(t *testing.T) {
198 want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}198 want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
199 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})199 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
200 if !bytes.Equal(got, want) {200 if !bytes.Equal(got, want) {
A internal/ports/routes.go +201 -0
@@ -0,0 +1,201 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+// RouteOrigin identifies how a client reached a daemon. The origin is client
11+// composition metadata, not an authority granted to the daemon.
12+type RouteOrigin uint8
13+
14+const (
15+ RouteOriginLocal RouteOrigin = iota + 1
16+ RouteOriginRemote
17+ RouteOriginDiscovery
18+)
19+
20+func (o RouteOrigin) valid() bool {
21+ switch o {
22+ case RouteOriginLocal, RouteOriginRemote, RouteOriginDiscovery:
23+ return true
24+ default:
25+ return false
26+ }
27+}
28+
29+func (o RouteOrigin) Validate() error {
30+ if !o.valid() {
31+ return errors.New("invalid route origin")
32+ }
33+ return nil
34+}
35+
36+// RouteKind identifies the kind of target represented by one display entry.
37+type RouteKind uint8
38+
39+const (
40+ RouteKindLocal RouteKind = iota + 1
41+ RouteKindRemote
42+)
43+
44+func (k RouteKind) valid() bool {
45+ switch k {
46+ case RouteKindLocal, RouteKindRemote:
47+ return true
48+ default:
49+ return false
50+ }
51+}
52+
53+func (k RouteKind) Validate() error {
54+ if !k.valid() {
55+ return errors.New("invalid route kind")
56+ }
57+ return nil
58+}
59+
60+// RouteReachability is a closed display hint. It is never used as proof that
61+// a route can be attached; the exact target and daemon still decide that.
62+type RouteReachability uint8
63+
64+const (
65+ RouteReachabilityUnknown RouteReachability = iota
66+ RouteReachabilityReachable
67+ RouteReachabilityUnavailable
68+)
69+
70+func (r RouteReachability) valid() bool {
71+ switch r {
72+ case RouteReachabilityUnknown, RouteReachabilityReachable, RouteReachabilityUnavailable:
73+ return true
74+ default:
75+ return false
76+ }
77+}
78+
79+func (r RouteReachability) Validate() error {
80+ if !r.valid() {
81+ return errors.New("invalid route reachability")
82+ }
83+ return nil
84+}
85+
86+// ExactSessionTarget is the daemon-neutral identity required to attach to a
87+// specific session lifecycle. The transport endpoint is deliberately absent:
88+// the selected route's dialer owns that boundary.
89+type ExactSessionTarget struct {
90+ LifecycleID domain.SessionLifecycleID
91+ SessionName string
92+}
93+
94+func (t ExactSessionTarget) Validate() error {
95+ if t.LifecycleID == (domain.SessionLifecycleID{}) {
96+ return errors.New("missing session lifecycle")
97+ }
98+ if err := domain.ValidateSessionName(t.SessionName); err != nil {
99+ return fmt.Errorf("invalid session name: %w", err)
100+ }
101+ return nil
102+}
103+
104+// CommittedRouteIdentity is the identity a daemon commits after accepting an
105+// attach. It is separate from display data so a stale presentation cannot
106+// become an attach authority.
107+type CommittedRouteIdentity struct {
108+ Target ExactSessionTarget
109+ Ephemeral bool
110+}
111+
112+func (i CommittedRouteIdentity) Validate() error {
113+ if err := i.Target.Validate(); err != nil {
114+ return err
115+ }
116+ return nil
117+}
118+
119+// RouteRef is an opaque client-ledger reference. It is intentionally not a
120+// lifecycle identifier and is meaningful only with the matching generation.
121+type RouteRef struct {
122+ Key uint64
123+ Generation uint64
124+}
125+
126+func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 }
127+
128+func (r RouteRef) Validate() error {
129+ if r.empty() {
130+ return nil
131+ }
132+ if r.Key == 0 || r.Generation == 0 {
133+ return errors.New("route reference must contain key and generation")
134+ }
135+ return nil
136+}
137+
138+// RecentRouteEntry is the complete daemon-neutral display view of one route.
139+// It contains no dialer, credential, endpoint, live session pointer, or attach
140+// target. The client ledger keeps those private beside this value.
141+type RecentRouteEntry struct {
142+ Key uint64
143+ Generation uint64
144+ Name string
145+ HostLabel string
146+ Kind RouteKind
147+ Ephemeral bool
148+ Attention bool
149+ Reachability RouteReachability
150+}
151+
152+// RecentRouteSnapshot is an immutable, bounded publication of the client
153+// route ledger. Entries are ordered from most recent to least recent.
154+type RecentRouteSnapshot struct {
155+ Generation uint64
156+ Active RouteRef
157+ Previous RouteRef
158+ Home RouteRef
159+ Entries []RecentRouteEntry
160+}
161+
162+// RouteNavigationAction asks the client to resolve one captured route key and
163+// generation. It carries no session name, endpoint, or credential.
164+type RouteNavigationAction struct {
165+ Key uint64
166+ Generation uint64
167+}
168+
169+// RouteNavigationFailure is a bounded taxonomy for a rejected or stale route
170+// action. User-facing text remains local to the receiving client.
171+type RouteNavigationFailure struct {
172+ Key uint64
173+ Generation uint64
174+ Code RouteFailureCode
175+}
176+
177+type RouteFailureCode uint8
178+
179+const (
180+ RouteFailureStaleSelection RouteFailureCode = iota + 1
181+ RouteFailureNoSuchRoute
182+ RouteFailureUnavailable
183+ RouteFailureTargetChanged
184+ RouteFailureOriginUnavailable
185+)
186+
187+func (c RouteFailureCode) valid() bool {
188+ switch c {
189+ case RouteFailureStaleSelection, RouteFailureNoSuchRoute, RouteFailureUnavailable, RouteFailureTargetChanged, RouteFailureOriginUnavailable:
190+ return true
191+ default:
192+ return false
193+ }
194+}
195+
196+const (
197+ // RouteSnapshotMaxEntries bounds one immutable publication and the amount
198+ // of work a receiver performs before returning to its transport loop.
199+ RouteSnapshotMaxEntries = 32
200+ RouteLabelMaxBytes = 128
201+)
@@ -0,0 +1,201 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+// RouteOrigin identifies how a client reached a daemon. The origin is client
11+// composition metadata, not an authority granted to the daemon.
12+type RouteOrigin uint8
13+
14+const (
15+ RouteOriginLocal RouteOrigin = iota + 1
16+ RouteOriginRemote
17+ RouteOriginDiscovery
18+)
19+
20+func (o RouteOrigin) valid() bool {
21+ switch o {
22+ case RouteOriginLocal, RouteOriginRemote, RouteOriginDiscovery:
23+ return true
24+ default:
25+ return false
26+ }
27+}
28+
29+func (o RouteOrigin) Validate() error {
30+ if !o.valid() {
31+ return errors.New("invalid route origin")
32+ }
33+ return nil
34+}
35+
36+// RouteKind identifies the kind of target represented by one display entry.
37+type RouteKind uint8
38+
39+const (
40+ RouteKindLocal RouteKind = iota + 1
41+ RouteKindRemote
42+)
43+
44+func (k RouteKind) valid() bool {
45+ switch k {
46+ case RouteKindLocal, RouteKindRemote:
47+ return true
48+ default:
49+ return false
50+ }
51+}
52+
53+func (k RouteKind) Validate() error {
54+ if !k.valid() {
55+ return errors.New("invalid route kind")
56+ }
57+ return nil
58+}
59+
60+// RouteReachability is a closed display hint. It is never used as proof that
61+// a route can be attached; the exact target and daemon still decide that.
62+type RouteReachability uint8
63+
64+const (
65+ RouteReachabilityUnknown RouteReachability = iota
66+ RouteReachabilityReachable
67+ RouteReachabilityUnavailable
68+)
69+
70+func (r RouteReachability) valid() bool {
71+ switch r {
72+ case RouteReachabilityUnknown, RouteReachabilityReachable, RouteReachabilityUnavailable:
73+ return true
74+ default:
75+ return false
76+ }
77+}
78+
79+func (r RouteReachability) Validate() error {
80+ if !r.valid() {
81+ return errors.New("invalid route reachability")
82+ }
83+ return nil
84+}
85+
86+// ExactSessionTarget is the daemon-neutral identity required to attach to a
87+// specific session lifecycle. The transport endpoint is deliberately absent:
88+// the selected route's dialer owns that boundary.
89+type ExactSessionTarget struct {
90+ LifecycleID domain.SessionLifecycleID
91+ SessionName string
92+}
93+
94+func (t ExactSessionTarget) Validate() error {
95+ if t.LifecycleID == (domain.SessionLifecycleID{}) {
96+ return errors.New("missing session lifecycle")
97+ }
98+ if err := domain.ValidateSessionName(t.SessionName); err != nil {
99+ return fmt.Errorf("invalid session name: %w", err)
100+ }
101+ return nil
102+}
103+
104+// CommittedRouteIdentity is the identity a daemon commits after accepting an
105+// attach. It is separate from display data so a stale presentation cannot
106+// become an attach authority.
107+type CommittedRouteIdentity struct {
108+ Target ExactSessionTarget
109+ Ephemeral bool
110+}
111+
112+func (i CommittedRouteIdentity) Validate() error {
113+ if err := i.Target.Validate(); err != nil {
114+ return err
115+ }
116+ return nil
117+}
118+
119+// RouteRef is an opaque client-ledger reference. It is intentionally not a
120+// lifecycle identifier and is meaningful only with the matching generation.
121+type RouteRef struct {
122+ Key uint64
123+ Generation uint64
124+}
125+
126+func (r RouteRef) empty() bool { return r.Key == 0 && r.Generation == 0 }
127+
128+func (r RouteRef) Validate() error {
129+ if r.empty() {
130+ return nil
131+ }
132+ if r.Key == 0 || r.Generation == 0 {
133+ return errors.New("route reference must contain key and generation")
134+ }
135+ return nil
136+}
137+
138+// RecentRouteEntry is the complete daemon-neutral display view of one route.
139+// It contains no dialer, credential, endpoint, live session pointer, or attach
140+// target. The client ledger keeps those private beside this value.
141+type RecentRouteEntry struct {
142+ Key uint64
143+ Generation uint64
144+ Name string
145+ HostLabel string
146+ Kind RouteKind
147+ Ephemeral bool
148+ Attention bool
149+ Reachability RouteReachability
150+}
151+
152+// RecentRouteSnapshot is an immutable, bounded publication of the client
153+// route ledger. Entries are ordered from most recent to least recent.
154+type RecentRouteSnapshot struct {
155+ Generation uint64
156+ Active RouteRef
157+ Previous RouteRef
158+ Home RouteRef
159+ Entries []RecentRouteEntry
160+}
161+
162+// RouteNavigationAction asks the client to resolve one captured route key and
163+// generation. It carries no session name, endpoint, or credential.
164+type RouteNavigationAction struct {
165+ Key uint64
166+ Generation uint64
167+}
168+
169+// RouteNavigationFailure is a bounded taxonomy for a rejected or stale route
170+// action. User-facing text remains local to the receiving client.
171+type RouteNavigationFailure struct {
172+ Key uint64
173+ Generation uint64
174+ Code RouteFailureCode
175+}
176+
177+type RouteFailureCode uint8
178+
179+const (
180+ RouteFailureStaleSelection RouteFailureCode = iota + 1
181+ RouteFailureNoSuchRoute
182+ RouteFailureUnavailable
183+ RouteFailureTargetChanged
184+ RouteFailureOriginUnavailable
185+)
186+
187+func (c RouteFailureCode) valid() bool {
188+ switch c {
189+ case RouteFailureStaleSelection, RouteFailureNoSuchRoute, RouteFailureUnavailable, RouteFailureTargetChanged, RouteFailureOriginUnavailable:
190+ return true
191+ default:
192+ return false
193+ }
194+}
195+
196+const (
197+ // RouteSnapshotMaxEntries bounds one immutable publication and the amount
198+ // of work a receiver performs before returning to its transport loop.
199+ RouteSnapshotMaxEntries = 32
200+ RouteLabelMaxBytes = 128
201+)
A internal/ports/routes_test.go +154 -0
@@ -0,0 +1,154 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func testExactTarget() ExactSessionTarget {
11+ return ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1, 2, 3}, SessionName: "work"}
12+}
13+
14+func TestExactSessionTargetCodecIsStrict(t *testing.T) {
15+ want := testExactTarget()
16+ encoded, err := MarshalExactSessionTarget(want)
17+ require.NoError(t, err)
18+
19+ got, err := UnmarshalExactSessionTarget(encoded)
20+ require.NoError(t, err)
21+ require.Equal(t, want, got)
22+
23+ for _, payload := range [][]byte{encoded[:len(encoded)-1], append(append([]byte(nil), encoded...), 0)} {
24+ _, err := UnmarshalExactSessionTarget(payload)
25+ require.Error(t, err)
26+ }
27+
28+ _, err = MarshalExactSessionTarget(ExactSessionTarget{SessionName: "work"})
29+ require.ErrorIs(t, err, ErrInvalidRouteWire)
30+}
31+
32+func TestHelloExactTargetRoundTrip(t *testing.T) {
33+ target := testExactTarget()
34+ msg := Hello{
35+ Version: ProtocolVersion,
36+ Intent: IntentAttach,
37+ Name: target.SessionName,
38+ Size: domain.Size{Cols: 80, Rows: 24},
39+ ExactTarget: &target,
40+ }
41+
42+ encoded := MarshalHello(msg)
43+ require.NotEmpty(t, encoded)
44+ got, err := UnmarshalHello(encoded)
45+ require.NoError(t, err)
46+ require.Equal(t, msg, got)
47+
48+ invalid := msg
49+ invalid.Intent = IntentNew
50+ require.Error(t, ValidateHello(invalid))
51+}
52+
53+func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) {
54+ identity := &CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true}
55+ want := Welcome{SessionID: "daemon-session", SessionName: "work", Ephemeral: true, CommittedIdentity: identity}
56+
57+ got, err := UnmarshalWelcome(MarshalWelcome(want))
58+ require.NoError(t, err)
59+ require.Equal(t, want, got)
60+}
61+
62+func TestWelcomeRejectsMismatchedCommittedIdentity(t *testing.T) {
63+ identity := &CommittedRouteIdentity{Target: testExactTarget()}
64+ payload := MarshalWelcome(Welcome{SessionID: "daemon-session", SessionName: "other", CommittedIdentity: identity})
65+
66+ _, err := UnmarshalWelcome(payload)
67+ require.ErrorIs(t, err, ErrInvalidRouteWire)
68+}
69+
70+func TestCommittedRouteIdentityCodec(t *testing.T) {
71+ want := CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true}
72+ encoded, err := MarshalCommittedRouteIdentity(want)
73+ require.NoError(t, err)
74+
75+ got, err := UnmarshalCommittedRouteIdentity(encoded)
76+ require.NoError(t, err)
77+ require.Equal(t, want, got)
78+
79+ mutated := append([]byte(nil), encoded...)
80+ mutated[len(mutated)-1] = 2
81+ _, err = UnmarshalCommittedRouteIdentity(mutated)
82+ require.Error(t, err)
83+}
84+
85+func testRouteSnapshot() RecentRouteSnapshot {
86+ return RecentRouteSnapshot{
87+ Generation: 9,
88+ Active: RouteRef{Key: 11, Generation: 3},
89+ Previous: RouteRef{Key: 12, Generation: 4},
90+ Home: RouteRef{Key: 11, Generation: 3},
91+ Entries: []RecentRouteEntry{
92+ {Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityReachable},
93+ {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
94+ },
95+ }
96+}
97+
98+func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
99+ want := testRouteSnapshot()
100+ encoded, err := MarshalRecentRouteSnapshot(want)
101+ require.NoError(t, err)
102+
103+ got, err := UnmarshalRecentRouteSnapshot(encoded)
104+ require.NoError(t, err)
105+ require.Equal(t, want, got)
106+
107+ for _, payload := range [][]byte{encoded[:len(encoded)-1], append(append([]byte(nil), encoded...), 0)} {
108+ _, err := UnmarshalRecentRouteSnapshot(payload)
109+ require.Error(t, err)
110+ }
111+
112+ invalid := want
113+ invalid.Active = RouteRef{Key: 99, Generation: 99}
114+ _, err = MarshalRecentRouteSnapshot(invalid)
115+ require.ErrorIs(t, err, ErrInvalidRouteWire)
116+
117+ invalid = want
118+ invalid.Entries = append(invalid.Entries, invalid.Entries[0])
119+ _, err = MarshalRecentRouteSnapshot(invalid)
120+ require.ErrorIs(t, err, ErrInvalidRouteWire)
121+
122+ invalid = want
123+ invalid.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)
124+ for i := range invalid.Entries {
125+ invalid.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal}
126+ }
127+ _, err = MarshalRecentRouteSnapshot(invalid)
128+ require.ErrorIs(t, err, ErrInvalidRouteWire)
129+}
130+
131+func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) {
132+ action := RouteNavigationAction{Key: 7, Generation: 8}
133+ encoded, err := MarshalRouteNavigationAction(action)
134+ require.NoError(t, err)
135+ got, err := UnmarshalRouteNavigationAction(encoded)
136+ require.NoError(t, err)
137+ require.Equal(t, action, got)
138+
139+ failure := RouteNavigationFailure{Key: 7, Generation: 8, Code: RouteFailureStaleSelection}
140+ encoded, err = MarshalRouteNavigationFailure(failure)
141+ require.NoError(t, err)
142+ gotFailure, err := UnmarshalRouteNavigationFailure(encoded)
143+ require.NoError(t, err)
144+ require.Equal(t, failure, gotFailure)
145+
146+ for _, invalid := range []RouteNavigationAction{{}, {Key: 1}, {Generation: 1}} {
147+ _, err := MarshalRouteNavigationAction(invalid)
148+ require.Error(t, err)
149+ }
150+ for _, invalid := range []RouteNavigationFailure{{}, {Key: 1, Generation: 1}, {Key: 1, Generation: 1, Code: 255}} {
151+ _, err := MarshalRouteNavigationFailure(invalid)
152+ require.Error(t, err)
153+ }
154+}
@@ -0,0 +1,154 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func testExactTarget() ExactSessionTarget {
11+ return ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1, 2, 3}, SessionName: "work"}
12+}
13+
14+func TestExactSessionTargetCodecIsStrict(t *testing.T) {
15+ want := testExactTarget()
16+ encoded, err := MarshalExactSessionTarget(want)
17+ require.NoError(t, err)
18+
19+ got, err := UnmarshalExactSessionTarget(encoded)
20+ require.NoError(t, err)
21+ require.Equal(t, want, got)
22+
23+ for _, payload := range [][]byte{encoded[:len(encoded)-1], append(append([]byte(nil), encoded...), 0)} {
24+ _, err := UnmarshalExactSessionTarget(payload)
25+ require.Error(t, err)
26+ }
27+
28+ _, err = MarshalExactSessionTarget(ExactSessionTarget{SessionName: "work"})
29+ require.ErrorIs(t, err, ErrInvalidRouteWire)
30+}
31+
32+func TestHelloExactTargetRoundTrip(t *testing.T) {
33+ target := testExactTarget()
34+ msg := Hello{
35+ Version: ProtocolVersion,
36+ Intent: IntentAttach,
37+ Name: target.SessionName,
38+ Size: domain.Size{Cols: 80, Rows: 24},
39+ ExactTarget: &target,
40+ }
41+
42+ encoded := MarshalHello(msg)
43+ require.NotEmpty(t, encoded)
44+ got, err := UnmarshalHello(encoded)
45+ require.NoError(t, err)
46+ require.Equal(t, msg, got)
47+
48+ invalid := msg
49+ invalid.Intent = IntentNew
50+ require.Error(t, ValidateHello(invalid))
51+}
52+
53+func TestWelcomeCarriesCommittedRouteIdentity(t *testing.T) {
54+ identity := &CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true}
55+ want := Welcome{SessionID: "daemon-session", SessionName: "work", Ephemeral: true, CommittedIdentity: identity}
56+
57+ got, err := UnmarshalWelcome(MarshalWelcome(want))
58+ require.NoError(t, err)
59+ require.Equal(t, want, got)
60+}
61+
62+func TestWelcomeRejectsMismatchedCommittedIdentity(t *testing.T) {
63+ identity := &CommittedRouteIdentity{Target: testExactTarget()}
64+ payload := MarshalWelcome(Welcome{SessionID: "daemon-session", SessionName: "other", CommittedIdentity: identity})
65+
66+ _, err := UnmarshalWelcome(payload)
67+ require.ErrorIs(t, err, ErrInvalidRouteWire)
68+}
69+
70+func TestCommittedRouteIdentityCodec(t *testing.T) {
71+ want := CommittedRouteIdentity{Target: testExactTarget(), Ephemeral: true}
72+ encoded, err := MarshalCommittedRouteIdentity(want)
73+ require.NoError(t, err)
74+
75+ got, err := UnmarshalCommittedRouteIdentity(encoded)
76+ require.NoError(t, err)
77+ require.Equal(t, want, got)
78+
79+ mutated := append([]byte(nil), encoded...)
80+ mutated[len(mutated)-1] = 2
81+ _, err = UnmarshalCommittedRouteIdentity(mutated)
82+ require.Error(t, err)
83+}
84+
85+func testRouteSnapshot() RecentRouteSnapshot {
86+ return RecentRouteSnapshot{
87+ Generation: 9,
88+ Active: RouteRef{Key: 11, Generation: 3},
89+ Previous: RouteRef{Key: 12, Generation: 4},
90+ Home: RouteRef{Key: 11, Generation: 3},
91+ Entries: []RecentRouteEntry{
92+ {Key: 11, Generation: 3, Name: "work", Kind: RouteKindLocal, Reachability: RouteReachabilityReachable},
93+ {Key: 12, Generation: 4, Name: "logs", HostLabel: "edge", Kind: RouteKindRemote, Attention: true, Reachability: RouteReachabilityUnknown},
94+ },
95+ }
96+}
97+
98+func TestRecentRouteSnapshotCodecRoundTripAndBounds(t *testing.T) {
99+ want := testRouteSnapshot()
100+ encoded, err := MarshalRecentRouteSnapshot(want)
101+ require.NoError(t, err)
102+
103+ got, err := UnmarshalRecentRouteSnapshot(encoded)
104+ require.NoError(t, err)
105+ require.Equal(t, want, got)
106+
107+ for _, payload := range [][]byte{encoded[:len(encoded)-1], append(append([]byte(nil), encoded...), 0)} {
108+ _, err := UnmarshalRecentRouteSnapshot(payload)
109+ require.Error(t, err)
110+ }
111+
112+ invalid := want
113+ invalid.Active = RouteRef{Key: 99, Generation: 99}
114+ _, err = MarshalRecentRouteSnapshot(invalid)
115+ require.ErrorIs(t, err, ErrInvalidRouteWire)
116+
117+ invalid = want
118+ invalid.Entries = append(invalid.Entries, invalid.Entries[0])
119+ _, err = MarshalRecentRouteSnapshot(invalid)
120+ require.ErrorIs(t, err, ErrInvalidRouteWire)
121+
122+ invalid = want
123+ invalid.Entries = make([]RecentRouteEntry, RouteSnapshotMaxEntries+1)
124+ for i := range invalid.Entries {
125+ invalid.Entries[i] = RecentRouteEntry{Key: uint64(i + 1), Generation: uint64(i + 1), Name: "x", Kind: RouteKindLocal}
126+ }
127+ _, err = MarshalRecentRouteSnapshot(invalid)
128+ require.ErrorIs(t, err, ErrInvalidRouteWire)
129+}
130+
131+func TestRouteActionAndFailureCodecsAreBounded(t *testing.T) {
132+ action := RouteNavigationAction{Key: 7, Generation: 8}
133+ encoded, err := MarshalRouteNavigationAction(action)
134+ require.NoError(t, err)
135+ got, err := UnmarshalRouteNavigationAction(encoded)
136+ require.NoError(t, err)
137+ require.Equal(t, action, got)
138+
139+ failure := RouteNavigationFailure{Key: 7, Generation: 8, Code: RouteFailureStaleSelection}
140+ encoded, err = MarshalRouteNavigationFailure(failure)
141+ require.NoError(t, err)
142+ gotFailure, err := UnmarshalRouteNavigationFailure(encoded)
143+ require.NoError(t, err)
144+ require.Equal(t, failure, gotFailure)
145+
146+ for _, invalid := range []RouteNavigationAction{{}, {Key: 1}, {Generation: 1}} {
147+ _, err := MarshalRouteNavigationAction(invalid)
148+ require.Error(t, err)
149+ }
150+ for _, invalid := range []RouteNavigationFailure{{}, {Key: 1, Generation: 1}, {Key: 1, Generation: 1, Code: 255}} {
151+ _, err := MarshalRouteNavigationFailure(invalid)
152+ require.Error(t, err)
153+ }
154+}
A internal/ports/routes_wire.go +438 -0
@@ -0,0 +1,438 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "fmt"
6+)
7+
8+var ErrInvalidRouteWire = errors.New("invalid route wire message")
9+
10+func validateRouteString(value, field string, allowEmpty bool) error {
11+ if !allowEmpty && value == "" {
12+ return fmt.Errorf("%s is empty", field)
13+ }
14+ if len(value) > RouteLabelMaxBytes {
15+ return fmt.Errorf("%s is too long", field)
16+ }
17+ return nil
18+}
19+
20+func marshalExactSessionTarget(w *payloadWriter, target ExactSessionTarget) {
21+ w.putBytes(target.LifecycleID[:])
22+ w.putString(target.SessionName)
23+}
24+
25+func marshalExactTargetSection(w *payloadWriter, target *ExactSessionTarget) {
26+ w.putBool(target != nil)
27+ if target != nil {
28+ marshalExactSessionTarget(w, *target)
29+ }
30+}
31+
32+func unmarshalExactTargetSection(r *payloadReader) (*ExactSessionTarget, error) {
33+ present, err := r.getBool()
34+ if err != nil {
35+ return nil, err
36+ }
37+ if !present {
38+ return nil, nil
39+ }
40+ target, err := unmarshalExactSessionTarget(r)
41+ if err != nil {
42+ return nil, err
43+ }
44+ return &target, nil
45+}
46+
47+func skipExactTargetSection(r *payloadReader) error {
48+ present, err := r.getBool()
49+ if err != nil {
50+ return err
51+ }
52+ if !present {
53+ return nil
54+ }
55+ if _, err := unmarshalExactSessionTarget(r); err != nil {
56+ return err
57+ }
58+ return nil
59+}
60+
61+func unmarshalExactSessionTarget(r *payloadReader) (ExactSessionTarget, error) {
62+ var target ExactSessionTarget
63+ lifecycle, err := r.getBytes(len(target.LifecycleID))
64+ if err != nil {
65+ return ExactSessionTarget{}, err
66+ }
67+ copy(target.LifecycleID[:], lifecycle)
68+ if target.SessionName, err = r.getString(); err != nil {
69+ return ExactSessionTarget{}, err
70+ }
71+ if err := target.Validate(); err != nil {
72+ return ExactSessionTarget{}, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err)
73+ }
74+ return target, nil
75+}
76+
77+// MarshalExactSessionTarget encodes the exact lifecycle/name pair used by
78+// Hello and route-transition tests.
79+func MarshalExactSessionTarget(target ExactSessionTarget) ([]byte, error) {
80+ if err := target.Validate(); err != nil {
81+ return nil, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err)
82+ }
83+ w := payloadWriter{}
84+ marshalExactSessionTarget(&w, target)
85+ return w.b, nil
86+}
87+
88+// UnmarshalExactSessionTarget decodes one exact lifecycle/name pair and rejects
89+// truncation, invalid identity, and trailing bytes.
90+func UnmarshalExactSessionTarget(b []byte) (ExactSessionTarget, error) {
91+ r := payloadReader{b: b}
92+ target, err := unmarshalExactSessionTarget(&r)
93+ if err != nil {
94+ return ExactSessionTarget{}, err
95+ }
96+ if err := r.done(); err != nil {
97+ return ExactSessionTarget{}, err
98+ }
99+ return target, nil
100+}
101+
102+func validateCommittedRouteIdentity(identity CommittedRouteIdentity) error {
103+ if err := identity.Validate(); err != nil {
104+ return fmt.Errorf("%w: committed identity: %v", ErrInvalidRouteWire, err)
105+ }
106+ return nil
107+}
108+
109+// MarshalCommittedRouteIdentity encodes the daemon's committed exact session
110+// identity without exposing transport or client-ledger details.
111+func MarshalCommittedRouteIdentity(identity CommittedRouteIdentity) ([]byte, error) {
112+ if err := validateCommittedRouteIdentity(identity); err != nil {
113+ return nil, err
114+ }
115+ w := payloadWriter{}
116+ marshalExactSessionTarget(&w, identity.Target)
117+ w.putBool(identity.Ephemeral)
118+ return w.b, nil
119+}
120+
121+func marshalCommittedIdentitySection(w *payloadWriter, identity *CommittedRouteIdentity) {
122+ w.putBool(identity != nil)
123+ if identity != nil {
124+ marshalExactSessionTarget(w, identity.Target)
125+ w.putBool(identity.Ephemeral)
126+ }
127+}
128+
129+func unmarshalCommittedIdentitySection(r *payloadReader) (*CommittedRouteIdentity, error) {
130+ present, err := r.getBool()
131+ if err != nil {
132+ return nil, err
133+ }
134+ if !present {
135+ return nil, nil
136+ }
137+ target, err := unmarshalExactSessionTarget(r)
138+ if err != nil {
139+ return nil, err
140+ }
141+ ephemeral, err := r.getBool()
142+ if err != nil {
143+ return nil, err
144+ }
145+ identity := &CommittedRouteIdentity{Target: target, Ephemeral: ephemeral}
146+ if err := validateCommittedRouteIdentity(*identity); err != nil {
147+ return nil, err
148+ }
149+ return identity, nil
150+}
151+
152+func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) {
153+ r := payloadReader{b: b}
154+ target, err := unmarshalExactSessionTarget(&r)
155+ if err != nil {
156+ return CommittedRouteIdentity{}, err
157+ }
158+ ephemeral, err := r.getBool()
159+ if err != nil {
160+ return CommittedRouteIdentity{}, err
161+ }
162+ if err := r.done(); err != nil {
163+ return CommittedRouteIdentity{}, err
164+ }
165+ identity := CommittedRouteIdentity{Target: target, Ephemeral: ephemeral}
166+ if err := validateCommittedRouteIdentity(identity); err != nil {
167+ return CommittedRouteIdentity{}, err
168+ }
169+ return identity, nil
170+}
171+
172+func validateRouteRef(ref RouteRef) error {
173+ if err := ref.Validate(); err != nil {
174+ return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err)
175+ }
176+ return nil
177+}
178+
179+func marshalRouteRef(w *payloadWriter, ref RouteRef) {
180+ w.putUint64(ref.Key)
181+ w.putUint64(ref.Generation)
182+}
183+
184+func unmarshalRouteRef(r *payloadReader) (RouteRef, error) {
185+ key, err := r.getUint64()
186+ if err != nil {
187+ return RouteRef{}, err
188+ }
189+ generation, err := r.getUint64()
190+ if err != nil {
191+ return RouteRef{}, err
192+ }
193+ ref := RouteRef{Key: key, Generation: generation}
194+ if err := validateRouteRef(ref); err != nil {
195+ return RouteRef{}, err
196+ }
197+ return ref, nil
198+}
199+
200+func validateRecentRouteEntry(entry RecentRouteEntry) error {
201+ if entry.Key == 0 || entry.Generation == 0 {
202+ return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire)
203+ }
204+ if err := validateRouteString(entry.Name, "route name", false); err != nil {
205+ return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)
206+ }
207+ if err := validateRouteString(entry.HostLabel, "route host label", true); err != nil {
208+ return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)
209+ }
210+ if !entry.Kind.valid() {
211+ return fmt.Errorf("%w: invalid route kind", ErrInvalidRouteWire)
212+ }
213+ if !entry.Reachability.valid() {
214+ return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire)
215+ }
216+ return nil
217+}
218+
219+func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
220+ if len(snapshot.Entries) > RouteSnapshotMaxEntries {
221+ return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
222+ }
223+ if snapshot.Generation == 0 && len(snapshot.Entries) != 0 {
224+ return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)
225+ }
226+ if err := validateRouteRef(snapshot.Active); err != nil {
227+ return err
228+ }
229+ if err := validateRouteRef(snapshot.Previous); err != nil {
230+ return err
231+ }
232+ if err := validateRouteRef(snapshot.Home); err != nil {
233+ return err
234+ }
235+ if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous {
236+ return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire)
237+ }
238+ refs := make(map[RouteRef]struct{}, len(snapshot.Entries))
239+ for _, entry := range snapshot.Entries {
240+ if err := validateRecentRouteEntry(entry); err != nil {
241+ return err
242+ }
243+ ref := RouteRef{Key: entry.Key, Generation: entry.Generation}
244+ if _, exists := refs[ref]; exists {
245+ return fmt.Errorf("%w: duplicate route entry", ErrInvalidRouteWire)
246+ }
247+ refs[ref] = struct{}{}
248+ }
249+ for _, ref := range []RouteRef{snapshot.Active, snapshot.Previous, snapshot.Home} {
250+ if ref.empty() {
251+ continue
252+ }
253+ if _, exists := refs[ref]; !exists {
254+ return fmt.Errorf("%w: route reference is absent from snapshot", ErrInvalidRouteWire)
255+ }
256+ }
257+ return nil
258+}
259+
260+// MarshalRecentRouteSnapshot encodes a complete bounded client route view.
261+func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
262+ if err := validateRecentRouteSnapshot(snapshot); err != nil {
263+ return nil, err
264+ }
265+ w := payloadWriter{}
266+ w.putUint64(snapshot.Generation)
267+ marshalRouteRef(&w, snapshot.Active)
268+ marshalRouteRef(&w, snapshot.Previous)
269+ marshalRouteRef(&w, snapshot.Home)
270+ w.putUint8(uint8(len(snapshot.Entries)))
271+ for _, entry := range snapshot.Entries {
272+ w.putUint64(entry.Key)
273+ w.putUint64(entry.Generation)
274+ w.putString(entry.Name)
275+ w.putString(entry.HostLabel)
276+ w.putUint8(uint8(entry.Kind))
277+ w.putBool(entry.Ephemeral)
278+ w.putBool(entry.Attention)
279+ w.putUint8(uint8(entry.Reachability))
280+ }
281+ if len(w.b) > MaxFrameLen-1 {
282+ return nil, fmt.Errorf("%w: route snapshot is too large", ErrInvalidRouteWire)
283+ }
284+ return w.b, nil
285+}
286+
287+// UnmarshalRecentRouteSnapshot decodes and strictly validates one complete
288+// immutable snapshot, including every referenced active/previous/home entry.
289+func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
290+ if len(b) > MaxFrameLen-1 {
291+ return RecentRouteSnapshot{}, ErrInvalidRouteWire
292+ }
293+ r := payloadReader{b: b}
294+ var snapshot RecentRouteSnapshot
295+ var err error
296+ if snapshot.Generation, err = r.getUint64(); err != nil {
297+ return RecentRouteSnapshot{}, err
298+ }
299+ if snapshot.Active, err = unmarshalRouteRef(&r); err != nil {
300+ return RecentRouteSnapshot{}, err
301+ }
302+ if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil {
303+ return RecentRouteSnapshot{}, err
304+ }
305+ if snapshot.Home, err = unmarshalRouteRef(&r); err != nil {
306+ return RecentRouteSnapshot{}, err
307+ }
308+ count, err := r.getUint8()
309+ if err != nil {
310+ return RecentRouteSnapshot{}, err
311+ }
312+ if int(count) > RouteSnapshotMaxEntries {
313+ return RecentRouteSnapshot{}, fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
314+ }
315+ if count != 0 {
316+ snapshot.Entries = make([]RecentRouteEntry, 0, int(count))
317+ }
318+ for range int(count) {
319+ var entry RecentRouteEntry
320+ if entry.Key, err = r.getUint64(); err != nil {
321+ return RecentRouteSnapshot{}, err
322+ }
323+ if entry.Generation, err = r.getUint64(); err != nil {
324+ return RecentRouteSnapshot{}, err
325+ }
326+ if entry.Name, err = r.getString(); err != nil {
327+ return RecentRouteSnapshot{}, err
328+ }
329+ if entry.HostLabel, err = r.getString(); err != nil {
330+ return RecentRouteSnapshot{}, err
331+ }
332+ kind, err := r.getUint8()
333+ if err != nil {
334+ return RecentRouteSnapshot{}, err
335+ }
336+ entry.Kind = RouteKind(kind)
337+ if entry.Ephemeral, err = r.getBool(); err != nil {
338+ return RecentRouteSnapshot{}, err
339+ }
340+ if entry.Attention, err = r.getBool(); err != nil {
341+ return RecentRouteSnapshot{}, err
342+ }
343+ reachability, err := r.getUint8()
344+ if err != nil {
345+ return RecentRouteSnapshot{}, err
346+ }
347+ entry.Reachability = RouteReachability(reachability)
348+ snapshot.Entries = append(snapshot.Entries, entry)
349+ }
350+ if err := r.done(); err != nil {
351+ return RecentRouteSnapshot{}, err
352+ }
353+ if err := validateRecentRouteSnapshot(snapshot); err != nil {
354+ return RecentRouteSnapshot{}, err
355+ }
356+ return snapshot, nil
357+}
358+
359+func validateRouteNavigationAction(action RouteNavigationAction) error {
360+ if action.Key == 0 || action.Generation == 0 {
361+ return fmt.Errorf("%w: navigation action identity is zero", ErrInvalidRouteWire)
362+ }
363+ return nil
364+}
365+
366+func MarshalRouteNavigationAction(action RouteNavigationAction) ([]byte, error) {
367+ if err := validateRouteNavigationAction(action); err != nil {
368+ return nil, err
369+ }
370+ w := payloadWriter{}
371+ w.putUint64(action.Key)
372+ w.putUint64(action.Generation)
373+ return w.b, nil
374+}
375+
376+func UnmarshalRouteNavigationAction(b []byte) (RouteNavigationAction, error) {
377+ r := payloadReader{b: b}
378+ var action RouteNavigationAction
379+ var err error
380+ if action.Key, err = r.getUint64(); err != nil {
381+ return RouteNavigationAction{}, err
382+ }
383+ if action.Generation, err = r.getUint64(); err != nil {
384+ return RouteNavigationAction{}, err
385+ }
386+ if err := r.done(); err != nil {
387+ return RouteNavigationAction{}, err
388+ }
389+ if err := validateRouteNavigationAction(action); err != nil {
390+ return RouteNavigationAction{}, err
391+ }
392+ return action, nil
393+}
394+
395+func validateRouteNavigationFailure(failure RouteNavigationFailure) error {
396+ if failure.Key == 0 || failure.Generation == 0 {
397+ return fmt.Errorf("%w: failure identity is zero", ErrInvalidRouteWire)
398+ }
399+ if !failure.Code.valid() {
400+ return fmt.Errorf("%w: invalid failure code", ErrInvalidRouteWire)
401+ }
402+ return nil
403+}
404+
405+func MarshalRouteNavigationFailure(failure RouteNavigationFailure) ([]byte, error) {
406+ if err := validateRouteNavigationFailure(failure); err != nil {
407+ return nil, err
408+ }
409+ w := payloadWriter{}
410+ w.putUint64(failure.Key)
411+ w.putUint64(failure.Generation)
412+ w.putUint8(uint8(failure.Code))
413+ return w.b, nil
414+}
415+
416+func UnmarshalRouteNavigationFailure(b []byte) (RouteNavigationFailure, error) {
417+ r := payloadReader{b: b}
418+ var failure RouteNavigationFailure
419+ var err error
420+ if failure.Key, err = r.getUint64(); err != nil {
421+ return RouteNavigationFailure{}, err
422+ }
423+ if failure.Generation, err = r.getUint64(); err != nil {
424+ return RouteNavigationFailure{}, err
425+ }
426+ code, err := r.getUint8()
427+ if err != nil {
428+ return RouteNavigationFailure{}, err
429+ }
430+ failure.Code = RouteFailureCode(code)
431+ if err := r.done(); err != nil {
432+ return RouteNavigationFailure{}, err
433+ }
434+ if err := validateRouteNavigationFailure(failure); err != nil {
435+ return RouteNavigationFailure{}, err
436+ }
437+ return failure, nil
438+}
@@ -0,0 +1,438 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "fmt"
6+)
7+
8+var ErrInvalidRouteWire = errors.New("invalid route wire message")
9+
10+func validateRouteString(value, field string, allowEmpty bool) error {
11+ if !allowEmpty && value == "" {
12+ return fmt.Errorf("%s is empty", field)
13+ }
14+ if len(value) > RouteLabelMaxBytes {
15+ return fmt.Errorf("%s is too long", field)
16+ }
17+ return nil
18+}
19+
20+func marshalExactSessionTarget(w *payloadWriter, target ExactSessionTarget) {
21+ w.putBytes(target.LifecycleID[:])
22+ w.putString(target.SessionName)
23+}
24+
25+func marshalExactTargetSection(w *payloadWriter, target *ExactSessionTarget) {
26+ w.putBool(target != nil)
27+ if target != nil {
28+ marshalExactSessionTarget(w, *target)
29+ }
30+}
31+
32+func unmarshalExactTargetSection(r *payloadReader) (*ExactSessionTarget, error) {
33+ present, err := r.getBool()
34+ if err != nil {
35+ return nil, err
36+ }
37+ if !present {
38+ return nil, nil
39+ }
40+ target, err := unmarshalExactSessionTarget(r)
41+ if err != nil {
42+ return nil, err
43+ }
44+ return &target, nil
45+}
46+
47+func skipExactTargetSection(r *payloadReader) error {
48+ present, err := r.getBool()
49+ if err != nil {
50+ return err
51+ }
52+ if !present {
53+ return nil
54+ }
55+ if _, err := unmarshalExactSessionTarget(r); err != nil {
56+ return err
57+ }
58+ return nil
59+}
60+
61+func unmarshalExactSessionTarget(r *payloadReader) (ExactSessionTarget, error) {
62+ var target ExactSessionTarget
63+ lifecycle, err := r.getBytes(len(target.LifecycleID))
64+ if err != nil {
65+ return ExactSessionTarget{}, err
66+ }
67+ copy(target.LifecycleID[:], lifecycle)
68+ if target.SessionName, err = r.getString(); err != nil {
69+ return ExactSessionTarget{}, err
70+ }
71+ if err := target.Validate(); err != nil {
72+ return ExactSessionTarget{}, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err)
73+ }
74+ return target, nil
75+}
76+
77+// MarshalExactSessionTarget encodes the exact lifecycle/name pair used by
78+// Hello and route-transition tests.
79+func MarshalExactSessionTarget(target ExactSessionTarget) ([]byte, error) {
80+ if err := target.Validate(); err != nil {
81+ return nil, fmt.Errorf("%w: exact target: %v", ErrInvalidRouteWire, err)
82+ }
83+ w := payloadWriter{}
84+ marshalExactSessionTarget(&w, target)
85+ return w.b, nil
86+}
87+
88+// UnmarshalExactSessionTarget decodes one exact lifecycle/name pair and rejects
89+// truncation, invalid identity, and trailing bytes.
90+func UnmarshalExactSessionTarget(b []byte) (ExactSessionTarget, error) {
91+ r := payloadReader{b: b}
92+ target, err := unmarshalExactSessionTarget(&r)
93+ if err != nil {
94+ return ExactSessionTarget{}, err
95+ }
96+ if err := r.done(); err != nil {
97+ return ExactSessionTarget{}, err
98+ }
99+ return target, nil
100+}
101+
102+func validateCommittedRouteIdentity(identity CommittedRouteIdentity) error {
103+ if err := identity.Validate(); err != nil {
104+ return fmt.Errorf("%w: committed identity: %v", ErrInvalidRouteWire, err)
105+ }
106+ return nil
107+}
108+
109+// MarshalCommittedRouteIdentity encodes the daemon's committed exact session
110+// identity without exposing transport or client-ledger details.
111+func MarshalCommittedRouteIdentity(identity CommittedRouteIdentity) ([]byte, error) {
112+ if err := validateCommittedRouteIdentity(identity); err != nil {
113+ return nil, err
114+ }
115+ w := payloadWriter{}
116+ marshalExactSessionTarget(&w, identity.Target)
117+ w.putBool(identity.Ephemeral)
118+ return w.b, nil
119+}
120+
121+func marshalCommittedIdentitySection(w *payloadWriter, identity *CommittedRouteIdentity) {
122+ w.putBool(identity != nil)
123+ if identity != nil {
124+ marshalExactSessionTarget(w, identity.Target)
125+ w.putBool(identity.Ephemeral)
126+ }
127+}
128+
129+func unmarshalCommittedIdentitySection(r *payloadReader) (*CommittedRouteIdentity, error) {
130+ present, err := r.getBool()
131+ if err != nil {
132+ return nil, err
133+ }
134+ if !present {
135+ return nil, nil
136+ }
137+ target, err := unmarshalExactSessionTarget(r)
138+ if err != nil {
139+ return nil, err
140+ }
141+ ephemeral, err := r.getBool()
142+ if err != nil {
143+ return nil, err
144+ }
145+ identity := &CommittedRouteIdentity{Target: target, Ephemeral: ephemeral}
146+ if err := validateCommittedRouteIdentity(*identity); err != nil {
147+ return nil, err
148+ }
149+ return identity, nil
150+}
151+
152+func UnmarshalCommittedRouteIdentity(b []byte) (CommittedRouteIdentity, error) {
153+ r := payloadReader{b: b}
154+ target, err := unmarshalExactSessionTarget(&r)
155+ if err != nil {
156+ return CommittedRouteIdentity{}, err
157+ }
158+ ephemeral, err := r.getBool()
159+ if err != nil {
160+ return CommittedRouteIdentity{}, err
161+ }
162+ if err := r.done(); err != nil {
163+ return CommittedRouteIdentity{}, err
164+ }
165+ identity := CommittedRouteIdentity{Target: target, Ephemeral: ephemeral}
166+ if err := validateCommittedRouteIdentity(identity); err != nil {
167+ return CommittedRouteIdentity{}, err
168+ }
169+ return identity, nil
170+}
171+
172+func validateRouteRef(ref RouteRef) error {
173+ if err := ref.Validate(); err != nil {
174+ return fmt.Errorf("%w: route reference: %v", ErrInvalidRouteWire, err)
175+ }
176+ return nil
177+}
178+
179+func marshalRouteRef(w *payloadWriter, ref RouteRef) {
180+ w.putUint64(ref.Key)
181+ w.putUint64(ref.Generation)
182+}
183+
184+func unmarshalRouteRef(r *payloadReader) (RouteRef, error) {
185+ key, err := r.getUint64()
186+ if err != nil {
187+ return RouteRef{}, err
188+ }
189+ generation, err := r.getUint64()
190+ if err != nil {
191+ return RouteRef{}, err
192+ }
193+ ref := RouteRef{Key: key, Generation: generation}
194+ if err := validateRouteRef(ref); err != nil {
195+ return RouteRef{}, err
196+ }
197+ return ref, nil
198+}
199+
200+func validateRecentRouteEntry(entry RecentRouteEntry) error {
201+ if entry.Key == 0 || entry.Generation == 0 {
202+ return fmt.Errorf("%w: route entry identity is zero", ErrInvalidRouteWire)
203+ }
204+ if err := validateRouteString(entry.Name, "route name", false); err != nil {
205+ return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)
206+ }
207+ if err := validateRouteString(entry.HostLabel, "route host label", true); err != nil {
208+ return fmt.Errorf("%w: %v", ErrInvalidRouteWire, err)
209+ }
210+ if !entry.Kind.valid() {
211+ return fmt.Errorf("%w: invalid route kind", ErrInvalidRouteWire)
212+ }
213+ if !entry.Reachability.valid() {
214+ return fmt.Errorf("%w: invalid route reachability", ErrInvalidRouteWire)
215+ }
216+ return nil
217+}
218+
219+func validateRecentRouteSnapshot(snapshot RecentRouteSnapshot) error {
220+ if len(snapshot.Entries) > RouteSnapshotMaxEntries {
221+ return fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
222+ }
223+ if snapshot.Generation == 0 && len(snapshot.Entries) != 0 {
224+ return fmt.Errorf("%w: non-empty snapshot has zero generation", ErrInvalidRouteWire)
225+ }
226+ if err := validateRouteRef(snapshot.Active); err != nil {
227+ return err
228+ }
229+ if err := validateRouteRef(snapshot.Previous); err != nil {
230+ return err
231+ }
232+ if err := validateRouteRef(snapshot.Home); err != nil {
233+ return err
234+ }
235+ if !snapshot.Active.empty() && snapshot.Active == snapshot.Previous {
236+ return fmt.Errorf("%w: active and previous routes are identical", ErrInvalidRouteWire)
237+ }
238+ refs := make(map[RouteRef]struct{}, len(snapshot.Entries))
239+ for _, entry := range snapshot.Entries {
240+ if err := validateRecentRouteEntry(entry); err != nil {
241+ return err
242+ }
243+ ref := RouteRef{Key: entry.Key, Generation: entry.Generation}
244+ if _, exists := refs[ref]; exists {
245+ return fmt.Errorf("%w: duplicate route entry", ErrInvalidRouteWire)
246+ }
247+ refs[ref] = struct{}{}
248+ }
249+ for _, ref := range []RouteRef{snapshot.Active, snapshot.Previous, snapshot.Home} {
250+ if ref.empty() {
251+ continue
252+ }
253+ if _, exists := refs[ref]; !exists {
254+ return fmt.Errorf("%w: route reference is absent from snapshot", ErrInvalidRouteWire)
255+ }
256+ }
257+ return nil
258+}
259+
260+// MarshalRecentRouteSnapshot encodes a complete bounded client route view.
261+func MarshalRecentRouteSnapshot(snapshot RecentRouteSnapshot) ([]byte, error) {
262+ if err := validateRecentRouteSnapshot(snapshot); err != nil {
263+ return nil, err
264+ }
265+ w := payloadWriter{}
266+ w.putUint64(snapshot.Generation)
267+ marshalRouteRef(&w, snapshot.Active)
268+ marshalRouteRef(&w, snapshot.Previous)
269+ marshalRouteRef(&w, snapshot.Home)
270+ w.putUint8(uint8(len(snapshot.Entries)))
271+ for _, entry := range snapshot.Entries {
272+ w.putUint64(entry.Key)
273+ w.putUint64(entry.Generation)
274+ w.putString(entry.Name)
275+ w.putString(entry.HostLabel)
276+ w.putUint8(uint8(entry.Kind))
277+ w.putBool(entry.Ephemeral)
278+ w.putBool(entry.Attention)
279+ w.putUint8(uint8(entry.Reachability))
280+ }
281+ if len(w.b) > MaxFrameLen-1 {
282+ return nil, fmt.Errorf("%w: route snapshot is too large", ErrInvalidRouteWire)
283+ }
284+ return w.b, nil
285+}
286+
287+// UnmarshalRecentRouteSnapshot decodes and strictly validates one complete
288+// immutable snapshot, including every referenced active/previous/home entry.
289+func UnmarshalRecentRouteSnapshot(b []byte) (RecentRouteSnapshot, error) {
290+ if len(b) > MaxFrameLen-1 {
291+ return RecentRouteSnapshot{}, ErrInvalidRouteWire
292+ }
293+ r := payloadReader{b: b}
294+ var snapshot RecentRouteSnapshot
295+ var err error
296+ if snapshot.Generation, err = r.getUint64(); err != nil {
297+ return RecentRouteSnapshot{}, err
298+ }
299+ if snapshot.Active, err = unmarshalRouteRef(&r); err != nil {
300+ return RecentRouteSnapshot{}, err
301+ }
302+ if snapshot.Previous, err = unmarshalRouteRef(&r); err != nil {
303+ return RecentRouteSnapshot{}, err
304+ }
305+ if snapshot.Home, err = unmarshalRouteRef(&r); err != nil {
306+ return RecentRouteSnapshot{}, err
307+ }
308+ count, err := r.getUint8()
309+ if err != nil {
310+ return RecentRouteSnapshot{}, err
311+ }
312+ if int(count) > RouteSnapshotMaxEntries {
313+ return RecentRouteSnapshot{}, fmt.Errorf("%w: too many route entries", ErrInvalidRouteWire)
314+ }
315+ if count != 0 {
316+ snapshot.Entries = make([]RecentRouteEntry, 0, int(count))
317+ }
318+ for range int(count) {
319+ var entry RecentRouteEntry
320+ if entry.Key, err = r.getUint64(); err != nil {
321+ return RecentRouteSnapshot{}, err
322+ }
323+ if entry.Generation, err = r.getUint64(); err != nil {
324+ return RecentRouteSnapshot{}, err
325+ }
326+ if entry.Name, err = r.getString(); err != nil {
327+ return RecentRouteSnapshot{}, err
328+ }
329+ if entry.HostLabel, err = r.getString(); err != nil {
330+ return RecentRouteSnapshot{}, err
331+ }
332+ kind, err := r.getUint8()
333+ if err != nil {
334+ return RecentRouteSnapshot{}, err
335+ }
336+ entry.Kind = RouteKind(kind)
337+ if entry.Ephemeral, err = r.getBool(); err != nil {
338+ return RecentRouteSnapshot{}, err
339+ }
340+ if entry.Attention, err = r.getBool(); err != nil {
341+ return RecentRouteSnapshot{}, err
342+ }
343+ reachability, err := r.getUint8()
344+ if err != nil {
345+ return RecentRouteSnapshot{}, err
346+ }
347+ entry.Reachability = RouteReachability(reachability)
348+ snapshot.Entries = append(snapshot.Entries, entry)
349+ }
350+ if err := r.done(); err != nil {
351+ return RecentRouteSnapshot{}, err
352+ }
353+ if err := validateRecentRouteSnapshot(snapshot); err != nil {
354+ return RecentRouteSnapshot{}, err
355+ }
356+ return snapshot, nil
357+}
358+
359+func validateRouteNavigationAction(action RouteNavigationAction) error {
360+ if action.Key == 0 || action.Generation == 0 {
361+ return fmt.Errorf("%w: navigation action identity is zero", ErrInvalidRouteWire)
362+ }
363+ return nil
364+}
365+
366+func MarshalRouteNavigationAction(action RouteNavigationAction) ([]byte, error) {
367+ if err := validateRouteNavigationAction(action); err != nil {
368+ return nil, err
369+ }
370+ w := payloadWriter{}
371+ w.putUint64(action.Key)
372+ w.putUint64(action.Generation)
373+ return w.b, nil
374+}
375+
376+func UnmarshalRouteNavigationAction(b []byte) (RouteNavigationAction, error) {
377+ r := payloadReader{b: b}
378+ var action RouteNavigationAction
379+ var err error
380+ if action.Key, err = r.getUint64(); err != nil {
381+ return RouteNavigationAction{}, err
382+ }
383+ if action.Generation, err = r.getUint64(); err != nil {
384+ return RouteNavigationAction{}, err
385+ }
386+ if err := r.done(); err != nil {
387+ return RouteNavigationAction{}, err
388+ }
389+ if err := validateRouteNavigationAction(action); err != nil {
390+ return RouteNavigationAction{}, err
391+ }
392+ return action, nil
393+}
394+
395+func validateRouteNavigationFailure(failure RouteNavigationFailure) error {
396+ if failure.Key == 0 || failure.Generation == 0 {
397+ return fmt.Errorf("%w: failure identity is zero", ErrInvalidRouteWire)
398+ }
399+ if !failure.Code.valid() {
400+ return fmt.Errorf("%w: invalid failure code", ErrInvalidRouteWire)
401+ }
402+ return nil
403+}
404+
405+func MarshalRouteNavigationFailure(failure RouteNavigationFailure) ([]byte, error) {
406+ if err := validateRouteNavigationFailure(failure); err != nil {
407+ return nil, err
408+ }
409+ w := payloadWriter{}
410+ w.putUint64(failure.Key)
411+ w.putUint64(failure.Generation)
412+ w.putUint8(uint8(failure.Code))
413+ return w.b, nil
414+}
415+
416+func UnmarshalRouteNavigationFailure(b []byte) (RouteNavigationFailure, error) {
417+ r := payloadReader{b: b}
418+ var failure RouteNavigationFailure
419+ var err error
420+ if failure.Key, err = r.getUint64(); err != nil {
421+ return RouteNavigationFailure{}, err
422+ }
423+ if failure.Generation, err = r.getUint64(); err != nil {
424+ return RouteNavigationFailure{}, err
425+ }
426+ code, err := r.getUint8()
427+ if err != nil {
428+ return RouteNavigationFailure{}, err
429+ }
430+ failure.Code = RouteFailureCode(code)
431+ if err := r.done(); err != nil {
432+ return RouteNavigationFailure{}, err
433+ }
434+ if err := validateRouteNavigationFailure(failure); err != nil {
435+ return RouteNavigationFailure{}, err
436+ }
437+ return failure, nil
438+}
M internal/ports/wire.go +42 -8
@@ -160,7 +160,11 @@ type Hello struct {
160160 // RemoteTarget is present only for a picker-selected remote attach. It is
161161 // carried through reconnects and is validated by the owning daemon before
162162 // it creates or publishes any session resources.
163- RemoteTarget *domain.RemoteSessionTarget
163+ RemoteTarget *domain.RemoteSessionTarget
164+ // ExactTarget is an optional lifecycle/name identity selected by the client
165+ // ledger. It is transport-neutral and never replaces the daemon's authority
166+ // to validate or commit the target.
167+ ExactTarget *ExactSessionTarget
164168 EnvironmentPolicy EnvironmentPolicy
165169 NavigationCapabilities NavigationCapabilities
166170 StartupOverlay StartupOverlay
@@ -250,11 +254,12 @@ type Ack struct {
250254
251255 // Welcome is the daemon's reply to a successful Hello.
252256 type Welcome struct {
253- SessionID string
254- SessionName string
255- Ephemeral bool
256- ResumeToken uint64
257- Capabilities uint32
257+ SessionID string
258+ SessionName string
259+ Ephemeral bool
260+ ResumeToken uint64
261+ Capabilities uint32
262+ CommittedIdentity *CommittedRouteIdentity
258263 }
259264
260265 // ErrorMsg reports a protocol- or session-level failure to the client.
@@ -386,9 +391,10 @@ func (w *payloadWriter) putLongBytes(b []byte) {
386391 w.b = append(w.b, b...)
387392 }
388393
389-// marshalRemoteTargetSection writes the required v26 target/policy section.
394+// marshalRemoteTargetSection writes the required v27 target/policy section.
390395 // A target may be absent for direct local attaches, but its presence marker and
391-// environment policy are never optional: v26 has no extension or legacy tail.
396+// environment policy are never optional: v27 has no extension or legacy tail
397+// inside this section; the exact-target section follows it.
392398 func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
393399 if target == nil {
394400 w.putBool(false)
@@ -718,6 +724,20 @@ func ValidateHello(h Hello) error {
718724 return ErrInvalidHello
719725 }
720726 }
727+ if h.ExactTarget != nil {
728+ if h.Intent != IntentAttach && h.Intent != IntentResume {
729+ return ErrInvalidHello
730+ }
731+ if err := h.ExactTarget.Validate(); err != nil {
732+ return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
733+ }
734+ if h.Name != h.ExactTarget.SessionName {
735+ return ErrInvalidHello
736+ }
737+ if h.RemoteTarget != nil && (h.RemoteTarget.LifecycleID != h.ExactTarget.LifecycleID || h.RemoteTarget.SessionName != h.ExactTarget.SessionName) {
738+ return ErrInvalidHello
739+ }
740+ }
721741 if h.RemoteTarget == nil {
722742 if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
723743 return ErrInvalidHello
@@ -830,6 +850,7 @@ func MarshalHello(h Hello) []byte {
830850 w.putLongString(entry)
831851 }
832852 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
853+ marshalExactTargetSection(&w, h.ExactTarget)
833854 w.putUint8(uint8(h.NavigationCapabilities))
834855 w.putUint8(uint8(h.StartupOverlay))
835856 return w.b
@@ -890,6 +911,9 @@ func preflightHello(b []byte) error {
890911 if err := skipRemoteTargetSection(&r); err != nil {
891912 return err
892913 }
914+ if err := skipExactTargetSection(&r); err != nil {
915+ return err
916+ }
893917 if _, err := r.getUint8(); err != nil {
894918 return err
895919 }
@@ -971,6 +995,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
971995 if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
972996 return Hello{}, err
973997 }
998+ if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {
999+ return Hello{}, err
1000+ }
9741001 capabilities, err := r.getUint8()
9751002 if err != nil {
9761003 return Hello{}, err
@@ -1275,6 +1302,7 @@ func MarshalWelcome(m Welcome) []byte {
12751302 }
12761303 w.putUint64(m.ResumeToken)
12771304 w.putUint32(m.Capabilities)
1305+ marshalCommittedIdentitySection(&w, m.CommittedIdentity)
12781306 return w.b
12791307 }
12801308
@@ -1301,6 +1329,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
13011329 if m.Capabilities, err = r.getUint32(); err != nil {
13021330 return Welcome{}, err
13031331 }
1332+ if m.CommittedIdentity, err = unmarshalCommittedIdentitySection(&r); err != nil {
1333+ return Welcome{}, err
1334+ }
1335+ if m.CommittedIdentity != nil && (m.CommittedIdentity.Target.SessionName != m.SessionName || m.CommittedIdentity.Ephemeral != m.Ephemeral) {
1336+ return Welcome{}, fmt.Errorf("%w: committed identity does not match welcome", ErrInvalidRouteWire)
1337+ }
13041338 if err := r.done(); err != nil {
13051339 return Welcome{}, err
13061340 }
@@ -160,7 +160,11 @@ type Hello struct {
160 // RemoteTarget is present only for a picker-selected remote attach. It is160 // RemoteTarget is present only for a picker-selected remote attach. It is
161 // carried through reconnects and is validated by the owning daemon before161 // carried through reconnects and is validated by the owning daemon before
162 // it creates or publishes any session resources.162 // it creates or publishes any session resources.
163- RemoteTarget *domain.RemoteSessionTarget163+ RemoteTarget *domain.RemoteSessionTarget
164+ // ExactTarget is an optional lifecycle/name identity selected by the client
165+ // ledger. It is transport-neutral and never replaces the daemon's authority
166+ // to validate or commit the target.
167+ ExactTarget *ExactSessionTarget
164 EnvironmentPolicy EnvironmentPolicy168 EnvironmentPolicy EnvironmentPolicy
165 NavigationCapabilities NavigationCapabilities169 NavigationCapabilities NavigationCapabilities
166 StartupOverlay StartupOverlay170 StartupOverlay StartupOverlay
@@ -250,11 +254,12 @@ type Ack struct {
250 254
251 // Welcome is the daemon's reply to a successful Hello.255 // Welcome is the daemon's reply to a successful Hello.
252 type Welcome struct {256 type Welcome struct {
253- SessionID string257+ SessionID string
254- SessionName string258+ SessionName string
255- Ephemeral bool259+ Ephemeral bool
256- ResumeToken uint64260+ ResumeToken uint64
257- Capabilities uint32261+ Capabilities uint32
262+ CommittedIdentity *CommittedRouteIdentity
258 }263 }
259 264
260 // ErrorMsg reports a protocol- or session-level failure to the client.265 // ErrorMsg reports a protocol- or session-level failure to the client.
@@ -386,9 +391,10 @@ func (w *payloadWriter) putLongBytes(b []byte) {
386 w.b = append(w.b, b...)391 w.b = append(w.b, b...)
387 }392 }
388 393
389-// marshalRemoteTargetSection writes the required v26 target/policy section.394+// marshalRemoteTargetSection writes the required v27 target/policy section.
390 // A target may be absent for direct local attaches, but its presence marker and395 // A target may be absent for direct local attaches, but its presence marker and
391-// environment policy are never optional: v26 has no extension or legacy tail.396+// environment policy are never optional: v27 has no extension or legacy tail
397+// inside this section; the exact-target section follows it.
392 func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {398 func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
393 if target == nil {399 if target == nil {
394 w.putBool(false)400 w.putBool(false)
@@ -718,6 +724,20 @@ func ValidateHello(h Hello) error {
718 return ErrInvalidHello724 return ErrInvalidHello
719 }725 }
720 }726 }
727+ if h.ExactTarget != nil {
728+ if h.Intent != IntentAttach && h.Intent != IntentResume {
729+ return ErrInvalidHello
730+ }
731+ if err := h.ExactTarget.Validate(); err != nil {
732+ return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
733+ }
734+ if h.Name != h.ExactTarget.SessionName {
735+ return ErrInvalidHello
736+ }
737+ if h.RemoteTarget != nil && (h.RemoteTarget.LifecycleID != h.ExactTarget.LifecycleID || h.RemoteTarget.SessionName != h.ExactTarget.SessionName) {
738+ return ErrInvalidHello
739+ }
740+ }
721 if h.RemoteTarget == nil {741 if h.RemoteTarget == nil {
722 if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {742 if h.EnvironmentPolicy != EnvironmentPolicyClientOwned && h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
723 return ErrInvalidHello743 return ErrInvalidHello
@@ -830,6 +850,7 @@ func MarshalHello(h Hello) []byte {
830 w.putLongString(entry)850 w.putLongString(entry)
831 }851 }
832 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)852 marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
853+ marshalExactTargetSection(&w, h.ExactTarget)
833 w.putUint8(uint8(h.NavigationCapabilities))854 w.putUint8(uint8(h.NavigationCapabilities))
834 w.putUint8(uint8(h.StartupOverlay))855 w.putUint8(uint8(h.StartupOverlay))
835 return w.b856 return w.b
@@ -890,6 +911,9 @@ func preflightHello(b []byte) error {
890 if err := skipRemoteTargetSection(&r); err != nil {911 if err := skipRemoteTargetSection(&r); err != nil {
891 return err912 return err
892 }913 }
914+ if err := skipExactTargetSection(&r); err != nil {
915+ return err
916+ }
893 if _, err := r.getUint8(); err != nil {917 if _, err := r.getUint8(); err != nil {
894 return err918 return err
895 }919 }
@@ -971,6 +995,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
971 if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {995 if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
972 return Hello{}, err996 return Hello{}, err
973 }997 }
998+ if h.ExactTarget, err = unmarshalExactTargetSection(&r); err != nil {
999+ return Hello{}, err
1000+ }
974 capabilities, err := r.getUint8()1001 capabilities, err := r.getUint8()
975 if err != nil {1002 if err != nil {
976 return Hello{}, err1003 return Hello{}, err
@@ -1275,6 +1302,7 @@ func MarshalWelcome(m Welcome) []byte {
1275 }1302 }
1276 w.putUint64(m.ResumeToken)1303 w.putUint64(m.ResumeToken)
1277 w.putUint32(m.Capabilities)1304 w.putUint32(m.Capabilities)
1305+ marshalCommittedIdentitySection(&w, m.CommittedIdentity)
1278 return w.b1306 return w.b
1279 }1307 }
1280 1308
@@ -1301,6 +1329,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
1301 if m.Capabilities, err = r.getUint32(); err != nil {1329 if m.Capabilities, err = r.getUint32(); err != nil {
1302 return Welcome{}, err1330 return Welcome{}, err
1303 }1331 }
1332+ if m.CommittedIdentity, err = unmarshalCommittedIdentitySection(&r); err != nil {
1333+ return Welcome{}, err
1334+ }
1335+ if m.CommittedIdentity != nil && (m.CommittedIdentity.Target.SessionName != m.SessionName || m.CommittedIdentity.Ephemeral != m.Ephemeral) {
1336+ return Welcome{}, fmt.Errorf("%w: committed identity does not match welcome", ErrInvalidRouteWire)
1337+ }
1304 if err := r.done(); err != nil {1338 if err := r.done(); err != nil {
1305 return Welcome{}, err1339 return Welcome{}, err
1306 }1340 }
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 != 26 {
15- t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion)
14+ if ProtocolVersion != 27 {
15+ t.Fatalf("ProtocolVersion = %d, want 27", 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, 26, 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)
29+ want := append([]byte{0, 27, 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)
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 != 26 {14+ if ProtocolVersion != 27 {
15- t.Fatalf("ProtocolVersion = %d, want 26", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 27", 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, 26, 2}, make([]byte, 16+8)...)29+ want := append([]byte{0, 27, 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)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)
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 +12 -12
@@ -92,7 +92,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
9292
9393 for _, tt := range tests {
9494 t.Run(tt.name, func(t *testing.T) {
95- want := append(append([]byte(nil), tt.want...), 0, 0)
95+ want := append(append([]byte(nil), tt.want...), 0, 0, 0)
9696 got := MarshalHello(tt.msg)
9797 if !bytes.Equal(got, want) {
9898 t.Fatalf("MarshalHello() = %#v, want %#v", got, want)
@@ -125,7 +125,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
125125 }
126126 got := MarshalHello(msg)
127127 want := []byte{
128- 0x00, 0x1a, 0x00, // version, intent, first client ID byte
128+ 0x00, 0x1b, 0x00, // version, intent, first client ID byte
129129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
130130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
131131 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -139,7 +139,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
139139 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
140140 0x00, 0x00, // no remote target, client-owned environment
141141 }
142- want = append(want, 0, 0)
142+ want = append(want, 0, 0, 0)
143143 require.Equal(t, want, got)
144144 decoded, err := UnmarshalHello(want)
145145 require.NoError(t, err)
@@ -167,7 +167,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
167167 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
168168 withCount := func(count byte) []byte {
169169 payload := append([]byte(nil), base...)
170- payload[len(payload)-5] = count
170+ payload[len(payload)-6] = count
171171 return payload
172172 }
173173 tests := []struct {
@@ -344,7 +344,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
344344 }
345345 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
346346 require.Equal(t, want, MarshalTheme(cleared))
347- require.Equal(t, uint16(26), ProtocolVersion)
347+ require.Equal(t, uint16(27), ProtocolVersion)
348348 }
349349
350350 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -424,12 +424,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
424424 {
425425 name: "ephemeral",
426426 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
427- want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05},
427+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05, 0x00},
428428 },
429429 {
430430 name: "non-ephemeral empty name",
431431 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
432- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
432+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
433433 },
434434 }
435435
@@ -472,7 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
472472 name: "minimal",
473473 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
474474 want: []byte{
475- 0x00, 0x1a,
475+ 0x00, 0x1b,
476476 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
477477 0x00, // attached
478478 0x00, // self
@@ -495,7 +495,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
495495 JSON: true,
496496 },
497497 want: []byte{
498- 0x00, 0x1a,
498+ 0x00, 0x1b,
499499 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
500500 0x00, // attached
501501 0x01, // self
@@ -894,7 +894,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
894894 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
895895 // The empty Hello has a fixed 1x1 size before the negotiated output window.
896896 want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0)
897- want = append(want, 0, 0)
897+ want = append(want, 0, 0, 0)
898898 want[1] = 14
899899 want[30], want[32] = 1, 1
900900 got := MarshalHello(hello)
@@ -924,7 +924,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
924924 name: "attached",
925925 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
926926 want: []byte{
927- 0x00, 0x1a, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
927+ 0x00, 0x1b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
928928 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
929929 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
930930 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -934,7 +934,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
934934 name: "control",
935935 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
936936 want: []byte{
937- 0x00, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
937+ 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
938938 0x00, 0x02, 'l', 's', 0x00, 0x00,
939939 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
940940 },
@@ -92,7 +92,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
92 92
93 for _, tt := range tests {93 for _, tt := range tests {
94 t.Run(tt.name, func(t *testing.T) {94 t.Run(tt.name, func(t *testing.T) {
95- want := append(append([]byte(nil), tt.want...), 0, 0)95+ want := append(append([]byte(nil), tt.want...), 0, 0, 0)
96 got := MarshalHello(tt.msg)96 got := MarshalHello(tt.msg)
97 if !bytes.Equal(got, want) {97 if !bytes.Equal(got, want) {
98 t.Fatalf("MarshalHello() = %#v, want %#v", got, want)98 t.Fatalf("MarshalHello() = %#v, want %#v", got, want)
@@ -125,7 +125,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
125 }125 }
126 got := MarshalHello(msg)126 got := MarshalHello(msg)
127 want := []byte{127 want := []byte{
128- 0x00, 0x1a, 0x00, // version, intent, first client ID byte128+ 0x00, 0x1b, 0x00, // version, intent, first client ID byte
129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
131 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token131 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -139,7 +139,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
139 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',139 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
140 0x00, 0x00, // no remote target, client-owned environment140 0x00, 0x00, // no remote target, client-owned environment
141 }141 }
142- want = append(want, 0, 0)142+ want = append(want, 0, 0, 0)
143 require.Equal(t, want, got)143 require.Equal(t, want, got)
144 decoded, err := UnmarshalHello(want)144 decoded, err := UnmarshalHello(want)
145 require.NoError(t, err)145 require.NoError(t, err)
@@ -167,7 +167,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
167 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})167 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
168 withCount := func(count byte) []byte {168 withCount := func(count byte) []byte {
169 payload := append([]byte(nil), base...)169 payload := append([]byte(nil), base...)
170- payload[len(payload)-5] = count170+ payload[len(payload)-6] = count
171 return payload171 return payload
172 }172 }
173 tests := []struct {173 tests := []struct {
@@ -344,7 +344,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
344 }344 }
345 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)345 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
346 require.Equal(t, want, MarshalTheme(cleared))346 require.Equal(t, want, MarshalTheme(cleared))
347- require.Equal(t, uint16(26), ProtocolVersion)347+ require.Equal(t, uint16(27), ProtocolVersion)
348 }348 }
349 349
350 func TestResizeGoldenAndRoundTrip(t *testing.T) {350 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -424,12 +424,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
424 {424 {
425 name: "ephemeral",425 name: "ephemeral",
426 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},426 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
427- want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05},427+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05, 0x00},
428 },428 },
429 {429 {
430 name: "non-ephemeral empty name",430 name: "non-ephemeral empty name",
431 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},431 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
432- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},432+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
433 },433 },
434 }434 }
435 435
@@ -472,7 +472,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
472 name: "minimal",472 name: "minimal",
473 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},473 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
474 want: []byte{474 want: []byte{
475- 0x00, 0x1a,475+ 0x00, 0x1b,
476 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID476 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
477 0x00, // attached477 0x00, // attached
478 0x00, // self478 0x00, // self
@@ -495,7 +495,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
495 JSON: true,495 JSON: true,
496 },496 },
497 want: []byte{497 want: []byte{
498- 0x00, 0x1a,498+ 0x00, 0x1b,
499 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID499 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
500 0x00, // attached500 0x00, // attached
501 0x01, // self501 0x01, // self
@@ -894,7 +894,7 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
894 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}894 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
895 // The empty Hello has a fixed 1x1 size before the negotiated output window.895 // The empty Hello has a fixed 1x1 size before the negotiated output window.
896 want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0)896 want := append(make([]byte, 38), window, 0, 0, 0, 0, 0, 0)
897- want = append(want, 0, 0)897+ want = append(want, 0, 0, 0)
898 want[1] = 14898 want[1] = 14
899 want[30], want[32] = 1, 1899 want[30], want[32] = 1, 1
900 got := MarshalHello(hello)900 got := MarshalHello(hello)
@@ -924,7 +924,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
924 name: "attached",924 name: "attached",
925 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},925 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
926 want: []byte{926 want: []byte{
927- 0x00, 0x1a, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,927+ 0x00, 0x1b, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
928 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',928 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
929 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',929 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
930 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,930 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -934,7 +934,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
934 name: "control",934 name: "control",
935 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},935 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
936 want: []byte{936 want: []byte{
937- 0x00, 0x1a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,937+ 0x00, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
938 0x00, 0x02, 'l', 's', 0x00, 0x00,938 0x00, 0x02, 'l', 's', 0x00, 0x00,
939 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,939 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
940 },940 },
M internal/usecase/client/client.go +65 -8
@@ -163,6 +163,9 @@ type Dependencies struct {
163163 // Remote selects client-side carriage presentation only; it never enters
164164 // the daemon-facing session request.
165165 Remote bool
166+ // Origin is explicit composition metadata for the initial route. A zero
167+ // value is normalized to local for direct package callers and old tests.
168+ Origin ports.RouteOrigin
166169 }
167170
168171 // AttachRequest identifies the session for one client run. Remote is
@@ -172,7 +175,9 @@ type AttachRequest struct {
172175 Intent uint8
173176 SessionName string
174177 Remote bool
178+ Origin ports.RouteOrigin
175179 RemoteTarget *domain.RemoteSessionTarget
180+ ExactTarget *ports.ExactSessionTarget
176181 EnvironmentPolicy ports.EnvironmentPolicy
177182 NavigationCapabilities ports.NavigationCapabilities
178183 StartupOverlay ports.StartupOverlay
@@ -196,6 +201,8 @@ type Runner struct {
196201 remoteHostLearner ports.RemoteHostLearner
197202 attachHandoff AttachHandoffFunc
198203 remote bool
204+ origin ports.RouteOrigin
205+ ledger *routeLedger
199206
200207 inputMu sync.Mutex
201208 input *terminalInputPump
@@ -217,6 +224,8 @@ func NewRunner(deps Dependencies) *Runner {
217224 remoteHostLearner: deps.RemoteHostLearner,
218225 attachHandoff: deps.AttachHandoff,
219226 remote: deps.Remote,
227+ origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
228+ ledger: newRouteLedger(),
220229 }
221230 }
222231
@@ -236,6 +245,22 @@ func (r *Runner) terminalInput() *terminalInputPump {
236245 }
237246
238247 func validateAttachRequest(request AttachRequest) error {
248+ if request.Origin != 0 {
249+ if err := request.Origin.Validate(); err != nil {
250+ return fmt.Errorf("vev: invalid route origin: %w", err)
251+ }
252+ }
253+ if request.ExactTarget != nil {
254+ if err := request.ExactTarget.Validate(); err != nil {
255+ return fmt.Errorf("vev: invalid exact target: %w", err)
256+ }
257+ if request.SessionName != request.ExactTarget.SessionName {
258+ return errors.New("vev: exact target session mismatch")
259+ }
260+ if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != request.ExactTarget.LifecycleID || request.RemoteTarget.SessionName != request.ExactTarget.SessionName) {
261+ return errors.New("vev: exact target does not match remote target")
262+ }
263+ }
239264 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {
240265 return fmt.Errorf("vev: invalid session target: %w", err)
241266 }
@@ -271,6 +296,9 @@ func validateAttachRequest(request AttachRequest) error {
271296 // redials a lost link.
272297 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
273298 request = cloneAttachRequest(request)
299+ if request.Origin == 0 {
300+ request.Origin = r.origin
301+ }
274302 if err := validateAttachRequest(request); err != nil {
275303 return err
276304 }
@@ -477,6 +505,18 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
477505 }).run(ctx)
478506 stopHandshakeTransport()
479507 finishHandshake()
508+ if result.welcomed && result.err == nil && result.committedIdentity != nil && r.ledger != nil {
509+ if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != result.committedIdentity.Target {
510+ return errRouteTargetChanged
511+ }
512+ if attemptRequest.RemoteTarget != nil && (attemptRequest.RemoteTarget.LifecycleID != result.committedIdentity.Target.LifecycleID || attemptRequest.RemoteTarget.SessionName != result.committedIdentity.Target.SessionName) {
513+ return errRouteTargetChanged
514+ }
515+ home := r.ledger.homeRef().Key == 0
516+ if _, err := r.ledger.commit(routeCandidateForAttach(attemptRequest, *result.committedIdentity, dialer, result.resumeToken, home)); err != nil {
517+ return fmt.Errorf("vev: committing route identity: %w", err)
518+ }
519+ }
480520 if result.welcomed {
481521 backoff = defaultReconnectBackoff.initial
482522 }
@@ -764,13 +804,14 @@ type attachAttempt struct {
764804 }
765805
766806 type attachResult struct {
767- resumeToken uint64
768- sessionName string
769- welcomed bool
770- transportClosed bool
771- target *ports.AttachTarget
772- action ports.NavigationAction
773- err error
807+ resumeToken uint64
808+ sessionName string
809+ committedIdentity *ports.CommittedRouteIdentity
810+ welcomed bool
811+ transportClosed bool
812+ target *ports.AttachTarget
813+ action ports.NavigationAction
814+ err error
774815 }
775816
776817 // AttachTargetError requests that the composition root replace the current
@@ -877,6 +918,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
877918 colorTerm := os.Getenv("COLORTERM")
878919 trueColor := DetectTrueColor(termEnv, colorTerm)
879920 themeState.setTrueColor(trueColor)
921+ exactTarget := request.ExactTarget
922+ if exactTarget == nil && request.RemoteTarget != nil {
923+ exactTarget = &ports.ExactSessionTarget{
924+ LifecycleID: request.RemoteTarget.LifecycleID,
925+ SessionName: request.RemoteTarget.SessionName,
926+ }
927+ }
880928 hello := ports.Hello{
881929 Version: ports.ProtocolVersion,
882930 Intent: intent,
@@ -890,6 +938,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
890938 MaxOutputInFlight: requestedOutputWindow(transport),
891939 Env: os.Environ(),
892940 RemoteTarget: request.RemoteTarget,
941+ ExactTarget: exactTarget,
893942 EnvironmentPolicy: request.EnvironmentPolicy,
894943 NavigationCapabilities: request.NavigationCapabilities,
895944 StartupOverlay: request.StartupOverlay,
@@ -910,8 +959,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
910959 }); err != nil {
911960 return handshakeFailure("awaiting welcome", err)
912961 }
962+ var committedIdentity *ports.CommittedRouteIdentity
913963 result := func(welcomed bool, err error) attachResult {
914- return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}
964+ return attachResult{
965+ resumeToken: resumeToken,
966+ sessionName: name,
967+ committedIdentity: cloneCommittedIdentity(committedIdentity),
968+ welcomed: welcomed,
969+ err: err,
970+ }
915971 }
916972 switch reply.Type {
917973 case ports.MsgWelcome:
@@ -921,6 +977,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
921977 }
922978 resumeToken = welcome.ResumeToken
923979 name = welcome.SessionName
980+ committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity)
924981 ms.welcomed = true
925982 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
926983 if remote {
@@ -163,6 +163,9 @@ type Dependencies struct {
163 // Remote selects client-side carriage presentation only; it never enters163 // Remote selects client-side carriage presentation only; it never enters
164 // the daemon-facing session request.164 // the daemon-facing session request.
165 Remote bool165 Remote bool
166+ // Origin is explicit composition metadata for the initial route. A zero
167+ // value is normalized to local for direct package callers and old tests.
168+ Origin ports.RouteOrigin
166 }169 }
167 170
168 // AttachRequest identifies the session for one client run. Remote is171 // AttachRequest identifies the session for one client run. Remote is
@@ -172,7 +175,9 @@ type AttachRequest struct {
172 Intent uint8175 Intent uint8
173 SessionName string176 SessionName string
174 Remote bool177 Remote bool
178+ Origin ports.RouteOrigin
175 RemoteTarget *domain.RemoteSessionTarget179 RemoteTarget *domain.RemoteSessionTarget
180+ ExactTarget *ports.ExactSessionTarget
176 EnvironmentPolicy ports.EnvironmentPolicy181 EnvironmentPolicy ports.EnvironmentPolicy
177 NavigationCapabilities ports.NavigationCapabilities182 NavigationCapabilities ports.NavigationCapabilities
178 StartupOverlay ports.StartupOverlay183 StartupOverlay ports.StartupOverlay
@@ -196,6 +201,8 @@ type Runner struct {
196 remoteHostLearner ports.RemoteHostLearner201 remoteHostLearner ports.RemoteHostLearner
197 attachHandoff AttachHandoffFunc202 attachHandoff AttachHandoffFunc
198 remote bool203 remote bool
204+ origin ports.RouteOrigin
205+ ledger *routeLedger
199 206
200 inputMu sync.Mutex207 inputMu sync.Mutex
201 input *terminalInputPump208 input *terminalInputPump
@@ -217,6 +224,8 @@ func NewRunner(deps Dependencies) *Runner {
217 remoteHostLearner: deps.RemoteHostLearner,224 remoteHostLearner: deps.RemoteHostLearner,
218 attachHandoff: deps.AttachHandoff,225 attachHandoff: deps.AttachHandoff,
219 remote: deps.Remote,226 remote: deps.Remote,
227+ origin: normalizeRouteOrigin(deps.Origin, deps.Remote),
228+ ledger: newRouteLedger(),
220 }229 }
221 }230 }
222 231
@@ -236,6 +245,22 @@ func (r *Runner) terminalInput() *terminalInputPump {
236 }245 }
237 246
238 func validateAttachRequest(request AttachRequest) error {247 func validateAttachRequest(request AttachRequest) error {
248+ if request.Origin != 0 {
249+ if err := request.Origin.Validate(); err != nil {
250+ return fmt.Errorf("vev: invalid route origin: %w", err)
251+ }
252+ }
253+ if request.ExactTarget != nil {
254+ if err := request.ExactTarget.Validate(); err != nil {
255+ return fmt.Errorf("vev: invalid exact target: %w", err)
256+ }
257+ if request.SessionName != request.ExactTarget.SessionName {
258+ return errors.New("vev: exact target session mismatch")
259+ }
260+ if request.RemoteTarget != nil && (request.RemoteTarget.LifecycleID != request.ExactTarget.LifecycleID || request.RemoteTarget.SessionName != request.ExactTarget.SessionName) {
261+ return errors.New("vev: exact target does not match remote target")
262+ }
263+ }
239 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {264 if err := (SessionTarget{Intent: request.Intent, SessionName: request.SessionName}).validate(); err != nil {
240 return fmt.Errorf("vev: invalid session target: %w", err)265 return fmt.Errorf("vev: invalid session target: %w", err)
241 }266 }
@@ -271,6 +296,9 @@ func validateAttachRequest(request AttachRequest) error {
271 // redials a lost link.296 // redials a lost link.
272 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {297 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
273 request = cloneAttachRequest(request)298 request = cloneAttachRequest(request)
299+ if request.Origin == 0 {
300+ request.Origin = r.origin
301+ }
274 if err := validateAttachRequest(request); err != nil {302 if err := validateAttachRequest(request); err != nil {
275 return err303 return err
276 }304 }
@@ -477,6 +505,18 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
477 }).run(ctx)505 }).run(ctx)
478 stopHandshakeTransport()506 stopHandshakeTransport()
479 finishHandshake()507 finishHandshake()
508+ if result.welcomed && result.err == nil && result.committedIdentity != nil && r.ledger != nil {
509+ if attemptRequest.ExactTarget != nil && *attemptRequest.ExactTarget != result.committedIdentity.Target {
510+ return errRouteTargetChanged
511+ }
512+ if attemptRequest.RemoteTarget != nil && (attemptRequest.RemoteTarget.LifecycleID != result.committedIdentity.Target.LifecycleID || attemptRequest.RemoteTarget.SessionName != result.committedIdentity.Target.SessionName) {
513+ return errRouteTargetChanged
514+ }
515+ home := r.ledger.homeRef().Key == 0
516+ if _, err := r.ledger.commit(routeCandidateForAttach(attemptRequest, *result.committedIdentity, dialer, result.resumeToken, home)); err != nil {
517+ return fmt.Errorf("vev: committing route identity: %w", err)
518+ }
519+ }
480 if result.welcomed {520 if result.welcomed {
481 backoff = defaultReconnectBackoff.initial521 backoff = defaultReconnectBackoff.initial
482 }522 }
@@ -764,13 +804,14 @@ type attachAttempt struct {
764 }804 }
765 805
766 type attachResult struct {806 type attachResult struct {
767- resumeToken uint64807+ resumeToken uint64
768- sessionName string808+ sessionName string
769- welcomed bool809+ committedIdentity *ports.CommittedRouteIdentity
770- transportClosed bool810+ welcomed bool
771- target *ports.AttachTarget811+ transportClosed bool
772- action ports.NavigationAction812+ target *ports.AttachTarget
773- err error813+ action ports.NavigationAction
814+ err error
774 }815 }
775 816
776 // AttachTargetError requests that the composition root replace the current817 // AttachTargetError requests that the composition root replace the current
@@ -877,6 +918,13 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
877 colorTerm := os.Getenv("COLORTERM")918 colorTerm := os.Getenv("COLORTERM")
878 trueColor := DetectTrueColor(termEnv, colorTerm)919 trueColor := DetectTrueColor(termEnv, colorTerm)
879 themeState.setTrueColor(trueColor)920 themeState.setTrueColor(trueColor)
921+ exactTarget := request.ExactTarget
922+ if exactTarget == nil && request.RemoteTarget != nil {
923+ exactTarget = &ports.ExactSessionTarget{
924+ LifecycleID: request.RemoteTarget.LifecycleID,
925+ SessionName: request.RemoteTarget.SessionName,
926+ }
927+ }
880 hello := ports.Hello{928 hello := ports.Hello{
881 Version: ports.ProtocolVersion,929 Version: ports.ProtocolVersion,
882 Intent: intent,930 Intent: intent,
@@ -890,6 +938,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
890 MaxOutputInFlight: requestedOutputWindow(transport),938 MaxOutputInFlight: requestedOutputWindow(transport),
891 Env: os.Environ(),939 Env: os.Environ(),
892 RemoteTarget: request.RemoteTarget,940 RemoteTarget: request.RemoteTarget,
941+ ExactTarget: exactTarget,
893 EnvironmentPolicy: request.EnvironmentPolicy,942 EnvironmentPolicy: request.EnvironmentPolicy,
894 NavigationCapabilities: request.NavigationCapabilities,943 NavigationCapabilities: request.NavigationCapabilities,
895 StartupOverlay: request.StartupOverlay,944 StartupOverlay: request.StartupOverlay,
@@ -910,8 +959,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
910 }); err != nil {959 }); err != nil {
911 return handshakeFailure("awaiting welcome", err)960 return handshakeFailure("awaiting welcome", err)
912 }961 }
962+ var committedIdentity *ports.CommittedRouteIdentity
913 result := func(welcomed bool, err error) attachResult {963 result := func(welcomed bool, err error) attachResult {
914- return attachResult{resumeToken: resumeToken, sessionName: name, welcomed: welcomed, err: err}964+ return attachResult{
965+ resumeToken: resumeToken,
966+ sessionName: name,
967+ committedIdentity: cloneCommittedIdentity(committedIdentity),
968+ welcomed: welcomed,
969+ err: err,
970+ }
915 }971 }
916 switch reply.Type {972 switch reply.Type {
917 case ports.MsgWelcome:973 case ports.MsgWelcome:
@@ -921,6 +977,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
921 }977 }
922 resumeToken = welcome.ResumeToken978 resumeToken = welcome.ResumeToken
923 name = welcome.SessionName979 name = welcome.SessionName
980+ committedIdentity = cloneCommittedIdentity(welcome.CommittedIdentity)
924 ms.welcomed = true981 ms.welcomed = true
925 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)982 log.Debug("welcomed by daemon", "resume_token_present", resumeToken != 0)
926 if remote {983 if remote {
A internal/usecase/client/routes.go +468 -0
@@ -0,0 +1,468 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+const maxRouteLedgerEntries = ports.RouteSnapshotMaxEntries
13+
14+var (
15+ errRouteResumeUnavailable = errors.New("route resume unavailable")
16+ errRouteStaleSelection = errors.New("route selection is stale")
17+ errRouteTargetChanged = errors.New("route target changed")
18+)
19+
20+// routeKey is process-local identity. It is never derived from a daemon
21+// lifecycle ID and is not exposed outside the client ledger.
22+type routeKey uint64
23+
24+type routeGeneration uint64
25+
26+type routeIdentity struct {
27+ key routeKey
28+ generation routeGeneration
29+}
30+
31+func (i routeIdentity) empty() bool { return i.key == 0 && i.generation == 0 }
32+
33+func (i routeIdentity) wire() ports.RouteRef {
34+ return ports.RouteRef{Key: uint64(i.key), Generation: uint64(i.generation)}
35+}
36+
37+func identityFromWire(ref ports.RouteRef) routeIdentity {
38+ return routeIdentity{key: routeKey(ref.Key), generation: routeGeneration(ref.Generation)}
39+}
40+
41+// routePresentation contains only immutable display data. Connection and
42+// attach capabilities live in routeRecord beside it and are never copied into
43+// a RecentRouteSnapshot.
44+type routePresentation struct {
45+ name string
46+ hostLabel string
47+ kind ports.RouteKind
48+ ephemeral bool
49+ attention bool
50+ reachability ports.RouteReachability
51+}
52+
53+type routeCandidate struct {
54+ origin ports.RouteOrigin
55+ target ports.ExactSessionTarget
56+ presentation routePresentation
57+ dialer ports.Dialer
58+ request AttachRequest
59+ resumeToken uint64
60+ home bool
61+}
62+
63+type routeRecord struct {
64+ identity routeIdentity
65+ origin ports.RouteOrigin
66+ target ports.ExactSessionTarget
67+ presentation routePresentation
68+ dialer ports.Dialer
69+ request AttachRequest
70+ resumeToken uint64
71+ home bool
72+}
73+
74+// routeLedger owns client-global route identity/history for one client
75+// process. Its lock protects both the bounded record set and active/previous
76+// references; snapshots are copied while holding the lock and are immutable
77+// after publication.
78+type routeLedger struct {
79+ mu sync.RWMutex
80+ nextKey routeKey
81+ generation routeGeneration
82+ entries []routeRecord
83+ active routeIdentity
84+ previous routeIdentity
85+ home routeIdentity
86+
87+ discoveryMu sync.Mutex
88+ discoveryCycle uint64
89+}
90+
91+func newRouteLedger() *routeLedger { return &routeLedger{} }
92+
93+func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.CommittedRouteIdentity {
94+ if identity == nil {
95+ return nil
96+ }
97+ copy := *identity
98+ return &copy
99+}
100+
101+func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64, home bool) routeCandidate {
102+ origin := normalizeRouteOrigin(request.Origin, request.Remote)
103+ kind := ports.RouteKindLocal
104+ hostLabel := ""
105+ if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal {
106+ kind = ports.RouteKindRemote
107+ if request.RemoteTarget != nil {
108+ hostLabel = request.RemoteTarget.DisplayOrigin
109+ }
110+ }
111+ request = cloneAttachRequest(request)
112+ request.Origin = origin
113+ request.SessionName = identity.Target.SessionName
114+ request.ExactTarget = &identity.Target
115+ return routeCandidate{
116+ origin: origin,
117+ target: identity.Target,
118+ presentation: routePresentation{
119+ name: identity.Target.SessionName,
120+ hostLabel: hostLabel,
121+ kind: kind,
122+ ephemeral: identity.Ephemeral,
123+ reachability: ports.RouteReachabilityReachable,
124+ },
125+ dialer: dialer,
126+ request: request,
127+ resumeToken: resumeToken,
128+ home: home,
129+ }
130+}
131+
132+func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin {
133+ if origin != 0 {
134+ return origin
135+ }
136+ if remote {
137+ return ports.RouteOriginRemote
138+ }
139+ return ports.RouteOriginLocal
140+}
141+
142+func validateRoutePresentation(p routePresentation) error {
143+ if p.name == "" {
144+ return errors.New("route name is empty")
145+ }
146+ if len(p.name) > ports.RouteLabelMaxBytes || len(p.hostLabel) > ports.RouteLabelMaxBytes {
147+ return errors.New("route label is too long")
148+ }
149+ if err := p.kind.Validate(); err != nil {
150+ return err
151+ }
152+ if err := p.reachability.Validate(); err != nil {
153+ return err
154+ }
155+ return nil
156+}
157+
158+func (l *routeLedger) nextIdentityLocked() (routeIdentity, error) {
159+ if l.nextKey == ^routeKey(0) || l.generation == ^routeGeneration(0) {
160+ return routeIdentity{}, errors.New("route ledger identity space exhausted")
161+ }
162+ l.nextKey++
163+ l.generation++
164+ return routeIdentity{key: l.nextKey, generation: l.generation}, nil
165+}
166+
167+func (l *routeLedger) findByTargetLocked(origin ports.RouteOrigin, target ports.ExactSessionTarget) int {
168+ for i := range l.entries {
169+ entry := l.entries[i]
170+ if entry.origin == origin && entry.target == target {
171+ return i
172+ }
173+ }
174+ return -1
175+}
176+
177+func (l *routeLedger) moveToFrontLocked(index int, entry routeRecord) {
178+ if index < 0 {
179+ l.entries = append([]routeRecord{entry}, l.entries...)
180+ return
181+ }
182+ l.entries = append(l.entries[:index], l.entries[index+1:]...)
183+ l.entries = append([]routeRecord{entry}, l.entries...)
184+}
185+
186+func (l *routeLedger) removeAtLocked(index int) {
187+ if index < 0 || index >= len(l.entries) {
188+ return
189+ }
190+ removed := l.entries[index].identity
191+ l.entries = append(l.entries[:index], l.entries[index+1:]...)
192+ for _, ref := range []*routeIdentity{&l.active, &l.previous, &l.home} {
193+ if *ref == removed {
194+ *ref = routeIdentity{}
195+ }
196+ }
197+}
198+
199+func (l *routeLedger) evictLocked() {
200+ for len(l.entries) > maxRouteLedgerEntries {
201+ index := len(l.entries) - 1
202+ for i := len(l.entries) - 1; i >= 0; i-- {
203+ identity := l.entries[i].identity
204+ if identity != l.active && identity != l.previous && identity != l.home {
205+ index = i
206+ break
207+ }
208+ }
209+ l.removeAtLocked(index)
210+ }
211+}
212+
213+func (l *routeLedger) commit(candidate routeCandidate) (routeIdentity, error) {
214+ if err := candidate.origin.Validate(); err != nil {
215+ return routeIdentity{}, err
216+ }
217+ if err := candidate.target.Validate(); err != nil {
218+ return routeIdentity{}, err
219+ }
220+ if err := validateRoutePresentation(candidate.presentation); err != nil {
221+ return routeIdentity{}, err
222+ }
223+ candidate.request = cloneAttachRequest(candidate.request)
224+ candidate.request.Origin = candidate.origin
225+ candidate.request.ExactTarget = &candidate.target
226+
227+ l.mu.Lock()
228+ defer l.mu.Unlock()
229+ return l.commitLocked(candidate)
230+}
231+
232+func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) {
233+ identity, err := l.nextIdentityLocked()
234+ if err != nil {
235+ return routeIdentity{}, err
236+ }
237+ index := l.findByTargetLocked(candidate.origin, candidate.target)
238+ if index >= 0 {
239+ identity.key = l.entries[index].identity.key
240+ }
241+ entry := routeRecord{
242+ identity: identity,
243+ origin: candidate.origin,
244+ target: candidate.target,
245+ presentation: candidate.presentation,
246+ dialer: candidate.dialer,
247+ request: candidate.request,
248+ resumeToken: candidate.resumeToken,
249+ home: candidate.home,
250+ }
251+ oldActive := l.active
252+ l.moveToFrontLocked(index, entry)
253+ if !oldActive.empty() && oldActive.key != identity.key {
254+ l.previous = oldActive
255+ }
256+ if l.previous.key == identity.key {
257+ l.previous = routeIdentity{}
258+ }
259+ l.active = identity
260+ if candidate.home {
261+ l.home = identity
262+ }
263+ if l.home.key == identity.key {
264+ l.home.generation = identity.generation
265+ }
266+ if l.previous.key != 0 {
267+ if previousIndex := l.indexByIdentityLocked(l.previous); previousIndex < 0 {
268+ l.previous = routeIdentity{}
269+ }
270+ }
271+ l.evictLocked()
272+ return identity, nil
273+}
274+
275+func (l *routeLedger) indexByIdentityLocked(identity routeIdentity) int {
276+ for i := range l.entries {
277+ if l.entries[i].identity == identity {
278+ return i
279+ }
280+ }
281+ return -1
282+}
283+
284+func (l *routeLedger) lookup(ref ports.RouteRef) (routeRecord, bool) {
285+ identity := identityFromWire(ref)
286+ if identity.empty() {
287+ return routeRecord{}, false
288+ }
289+ l.mu.RLock()
290+ defer l.mu.RUnlock()
291+ for _, entry := range l.entries {
292+ if entry.identity == identity {
293+ return cloneRouteRecord(entry), true
294+ }
295+ }
296+ return routeRecord{}, false
297+}
298+
299+func cloneRouteRecord(entry routeRecord) routeRecord {
300+ entry.request = cloneAttachRequest(entry.request)
301+ return entry
302+}
303+
304+func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
305+ l.mu.RLock()
306+ defer l.mu.RUnlock()
307+
308+ snapshot := ports.RecentRouteSnapshot{
309+ Generation: uint64(l.generation),
310+ Active: l.active.wire(),
311+ Previous: l.previous.wire(),
312+ Home: l.home.wire(),
313+ }
314+ if len(l.entries) == 0 {
315+ return snapshot
316+ }
317+ snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries))
318+ for _, entry := range l.entries {
319+ snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{
320+ Key: uint64(entry.identity.key),
321+ Generation: uint64(entry.identity.generation),
322+ Name: entry.presentation.name,
323+ HostLabel: entry.presentation.hostLabel,
324+ Kind: entry.presentation.kind,
325+ Ephemeral: entry.presentation.ephemeral,
326+ Attention: entry.presentation.attention,
327+ Reachability: entry.presentation.reachability,
328+ })
329+ }
330+ return snapshot
331+}
332+
333+func (l *routeLedger) activeRef() ports.RouteRef {
334+ l.mu.RLock()
335+ defer l.mu.RUnlock()
336+ return l.active.wire()
337+}
338+
339+func (l *routeLedger) previousRef() ports.RouteRef {
340+ l.mu.RLock()
341+ defer l.mu.RUnlock()
342+ return l.previous.wire()
343+}
344+
345+func (l *routeLedger) homeRef() ports.RouteRef {
346+ l.mu.RLock()
347+ defer l.mu.RUnlock()
348+ return l.home.wire()
349+}
350+
351+// routeTransitionPlan is the private lookup result used by navigation. It
352+// preserves the original origin and exact target while leaving display labels
353+// and transport capabilities separate.
354+type routeTransitionPlan struct {
355+ identity routeIdentity
356+ record routeRecord
357+ origin ports.RouteOrigin
358+ target ports.ExactSessionTarget
359+}
360+
361+type routeTransitionCommit struct {
362+ identity ports.CommittedRouteIdentity
363+ presentation routePresentation
364+ dialer ports.Dialer
365+ resumeToken uint64
366+}
367+
368+type routeTransitionConnector interface {
369+ Resume(context.Context, routeRecord) (routeTransitionCommit, error)
370+ Attach(context.Context, routeRecord) (routeTransitionCommit, error)
371+}
372+
373+// navigate resolves a captured key/generation, attempts resume before exact
374+// attach, and commits only after the connector returns a matching identity.
375+// A failed transition leaves active/previous history unchanged.
376+func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error {
377+ if action.Key == 0 || action.Generation == 0 {
378+ return errRouteStaleSelection
379+ }
380+ if connector == nil {
381+ return errors.New("route transition connector is nil")
382+ }
383+ if ctx == nil {
384+ ctx = context.Background()
385+ }
386+ selected := ports.RouteRef{Key: action.Key, Generation: action.Generation}
387+ record, ok := l.lookup(selected)
388+ if !ok {
389+ return errRouteStaleSelection
390+ }
391+ plan := routeTransitionPlan{
392+ identity: identityFromWire(selected),
393+ record: record,
394+ origin: record.origin,
395+ target: record.target,
396+ }
397+
398+ var commit routeTransitionCommit
399+ var err error
400+ if record.resumeToken != 0 {
401+ commit, err = connector.Resume(ctx, record)
402+ if errors.Is(err, errRouteResumeUnavailable) {
403+ commit, err = connector.Attach(ctx, record)
404+ }
405+ } else {
406+ commit, err = connector.Attach(ctx, record)
407+ }
408+ if err != nil {
409+ return err
410+ }
411+ if err := commit.identity.Validate(); err != nil {
412+ return fmt.Errorf("invalid committed route identity: %w", err)
413+ }
414+ if commit.identity.Target != plan.target {
415+ return errRouteTargetChanged
416+ }
417+ if err := validateRoutePresentation(commit.presentation); err != nil {
418+ return err
419+ }
420+
421+ candidate := routeCandidate{
422+ origin: plan.origin,
423+ target: plan.target,
424+ presentation: commit.presentation,
425+ dialer: commit.dialer,
426+ request: cloneAttachRequest(plan.record.request),
427+ resumeToken: commit.resumeToken,
428+ home: plan.record.home,
429+ }
430+ candidate.request.Origin = plan.origin
431+ candidate.request.ExactTarget = &candidate.target
432+ return l.commitTransition(plan.identity, candidate)
433+}
434+
435+func (l *routeLedger) commitTransition(selected routeIdentity, candidate routeCandidate) error {
436+ l.mu.Lock()
437+ defer l.mu.Unlock()
438+ index := l.indexByIdentityLocked(selected)
439+ if index < 0 {
440+ return errRouteStaleSelection
441+ }
442+ if _, err := l.commitLocked(candidate); err != nil {
443+ return err
444+ }
445+ return nil
446+}
447+
448+// beginDiscoveryCycle and discoveryCycleCurrent deliberately use a lock
449+// separate from route history. A stale discovery result cannot clear or commit
450+// unrelated navigation history.
451+func (l *routeLedger) beginDiscoveryCycle() uint64 {
452+ l.discoveryMu.Lock()
453+ defer l.discoveryMu.Unlock()
454+ l.discoveryCycle++
455+ if l.discoveryCycle == 0 {
456+ l.discoveryCycle++
457+ }
458+ return l.discoveryCycle
459+}
460+
461+func (l *routeLedger) discoveryCycleCurrent(cycle uint64) bool {
462+ if cycle == 0 {
463+ return false
464+ }
465+ l.discoveryMu.Lock()
466+ defer l.discoveryMu.Unlock()
467+ return l.discoveryCycle == cycle
468+}
@@ -0,0 +1,468 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+const maxRouteLedgerEntries = ports.RouteSnapshotMaxEntries
13+
14+var (
15+ errRouteResumeUnavailable = errors.New("route resume unavailable")
16+ errRouteStaleSelection = errors.New("route selection is stale")
17+ errRouteTargetChanged = errors.New("route target changed")
18+)
19+
20+// routeKey is process-local identity. It is never derived from a daemon
21+// lifecycle ID and is not exposed outside the client ledger.
22+type routeKey uint64
23+
24+type routeGeneration uint64
25+
26+type routeIdentity struct {
27+ key routeKey
28+ generation routeGeneration
29+}
30+
31+func (i routeIdentity) empty() bool { return i.key == 0 && i.generation == 0 }
32+
33+func (i routeIdentity) wire() ports.RouteRef {
34+ return ports.RouteRef{Key: uint64(i.key), Generation: uint64(i.generation)}
35+}
36+
37+func identityFromWire(ref ports.RouteRef) routeIdentity {
38+ return routeIdentity{key: routeKey(ref.Key), generation: routeGeneration(ref.Generation)}
39+}
40+
41+// routePresentation contains only immutable display data. Connection and
42+// attach capabilities live in routeRecord beside it and are never copied into
43+// a RecentRouteSnapshot.
44+type routePresentation struct {
45+ name string
46+ hostLabel string
47+ kind ports.RouteKind
48+ ephemeral bool
49+ attention bool
50+ reachability ports.RouteReachability
51+}
52+
53+type routeCandidate struct {
54+ origin ports.RouteOrigin
55+ target ports.ExactSessionTarget
56+ presentation routePresentation
57+ dialer ports.Dialer
58+ request AttachRequest
59+ resumeToken uint64
60+ home bool
61+}
62+
63+type routeRecord struct {
64+ identity routeIdentity
65+ origin ports.RouteOrigin
66+ target ports.ExactSessionTarget
67+ presentation routePresentation
68+ dialer ports.Dialer
69+ request AttachRequest
70+ resumeToken uint64
71+ home bool
72+}
73+
74+// routeLedger owns client-global route identity/history for one client
75+// process. Its lock protects both the bounded record set and active/previous
76+// references; snapshots are copied while holding the lock and are immutable
77+// after publication.
78+type routeLedger struct {
79+ mu sync.RWMutex
80+ nextKey routeKey
81+ generation routeGeneration
82+ entries []routeRecord
83+ active routeIdentity
84+ previous routeIdentity
85+ home routeIdentity
86+
87+ discoveryMu sync.Mutex
88+ discoveryCycle uint64
89+}
90+
91+func newRouteLedger() *routeLedger { return &routeLedger{} }
92+
93+func cloneCommittedIdentity(identity *ports.CommittedRouteIdentity) *ports.CommittedRouteIdentity {
94+ if identity == nil {
95+ return nil
96+ }
97+ copy := *identity
98+ return &copy
99+}
100+
101+func routeCandidateForAttach(request AttachRequest, identity ports.CommittedRouteIdentity, dialer ports.Dialer, resumeToken uint64, home bool) routeCandidate {
102+ origin := normalizeRouteOrigin(request.Origin, request.Remote)
103+ kind := ports.RouteKindLocal
104+ hostLabel := ""
105+ if request.Remote || request.RemoteTarget != nil || origin != ports.RouteOriginLocal {
106+ kind = ports.RouteKindRemote
107+ if request.RemoteTarget != nil {
108+ hostLabel = request.RemoteTarget.DisplayOrigin
109+ }
110+ }
111+ request = cloneAttachRequest(request)
112+ request.Origin = origin
113+ request.SessionName = identity.Target.SessionName
114+ request.ExactTarget = &identity.Target
115+ return routeCandidate{
116+ origin: origin,
117+ target: identity.Target,
118+ presentation: routePresentation{
119+ name: identity.Target.SessionName,
120+ hostLabel: hostLabel,
121+ kind: kind,
122+ ephemeral: identity.Ephemeral,
123+ reachability: ports.RouteReachabilityReachable,
124+ },
125+ dialer: dialer,
126+ request: request,
127+ resumeToken: resumeToken,
128+ home: home,
129+ }
130+}
131+
132+func normalizeRouteOrigin(origin ports.RouteOrigin, remote bool) ports.RouteOrigin {
133+ if origin != 0 {
134+ return origin
135+ }
136+ if remote {
137+ return ports.RouteOriginRemote
138+ }
139+ return ports.RouteOriginLocal
140+}
141+
142+func validateRoutePresentation(p routePresentation) error {
143+ if p.name == "" {
144+ return errors.New("route name is empty")
145+ }
146+ if len(p.name) > ports.RouteLabelMaxBytes || len(p.hostLabel) > ports.RouteLabelMaxBytes {
147+ return errors.New("route label is too long")
148+ }
149+ if err := p.kind.Validate(); err != nil {
150+ return err
151+ }
152+ if err := p.reachability.Validate(); err != nil {
153+ return err
154+ }
155+ return nil
156+}
157+
158+func (l *routeLedger) nextIdentityLocked() (routeIdentity, error) {
159+ if l.nextKey == ^routeKey(0) || l.generation == ^routeGeneration(0) {
160+ return routeIdentity{}, errors.New("route ledger identity space exhausted")
161+ }
162+ l.nextKey++
163+ l.generation++
164+ return routeIdentity{key: l.nextKey, generation: l.generation}, nil
165+}
166+
167+func (l *routeLedger) findByTargetLocked(origin ports.RouteOrigin, target ports.ExactSessionTarget) int {
168+ for i := range l.entries {
169+ entry := l.entries[i]
170+ if entry.origin == origin && entry.target == target {
171+ return i
172+ }
173+ }
174+ return -1
175+}
176+
177+func (l *routeLedger) moveToFrontLocked(index int, entry routeRecord) {
178+ if index < 0 {
179+ l.entries = append([]routeRecord{entry}, l.entries...)
180+ return
181+ }
182+ l.entries = append(l.entries[:index], l.entries[index+1:]...)
183+ l.entries = append([]routeRecord{entry}, l.entries...)
184+}
185+
186+func (l *routeLedger) removeAtLocked(index int) {
187+ if index < 0 || index >= len(l.entries) {
188+ return
189+ }
190+ removed := l.entries[index].identity
191+ l.entries = append(l.entries[:index], l.entries[index+1:]...)
192+ for _, ref := range []*routeIdentity{&l.active, &l.previous, &l.home} {
193+ if *ref == removed {
194+ *ref = routeIdentity{}
195+ }
196+ }
197+}
198+
199+func (l *routeLedger) evictLocked() {
200+ for len(l.entries) > maxRouteLedgerEntries {
201+ index := len(l.entries) - 1
202+ for i := len(l.entries) - 1; i >= 0; i-- {
203+ identity := l.entries[i].identity
204+ if identity != l.active && identity != l.previous && identity != l.home {
205+ index = i
206+ break
207+ }
208+ }
209+ l.removeAtLocked(index)
210+ }
211+}
212+
213+func (l *routeLedger) commit(candidate routeCandidate) (routeIdentity, error) {
214+ if err := candidate.origin.Validate(); err != nil {
215+ return routeIdentity{}, err
216+ }
217+ if err := candidate.target.Validate(); err != nil {
218+ return routeIdentity{}, err
219+ }
220+ if err := validateRoutePresentation(candidate.presentation); err != nil {
221+ return routeIdentity{}, err
222+ }
223+ candidate.request = cloneAttachRequest(candidate.request)
224+ candidate.request.Origin = candidate.origin
225+ candidate.request.ExactTarget = &candidate.target
226+
227+ l.mu.Lock()
228+ defer l.mu.Unlock()
229+ return l.commitLocked(candidate)
230+}
231+
232+func (l *routeLedger) commitLocked(candidate routeCandidate) (routeIdentity, error) {
233+ identity, err := l.nextIdentityLocked()
234+ if err != nil {
235+ return routeIdentity{}, err
236+ }
237+ index := l.findByTargetLocked(candidate.origin, candidate.target)
238+ if index >= 0 {
239+ identity.key = l.entries[index].identity.key
240+ }
241+ entry := routeRecord{
242+ identity: identity,
243+ origin: candidate.origin,
244+ target: candidate.target,
245+ presentation: candidate.presentation,
246+ dialer: candidate.dialer,
247+ request: candidate.request,
248+ resumeToken: candidate.resumeToken,
249+ home: candidate.home,
250+ }
251+ oldActive := l.active
252+ l.moveToFrontLocked(index, entry)
253+ if !oldActive.empty() && oldActive.key != identity.key {
254+ l.previous = oldActive
255+ }
256+ if l.previous.key == identity.key {
257+ l.previous = routeIdentity{}
258+ }
259+ l.active = identity
260+ if candidate.home {
261+ l.home = identity
262+ }
263+ if l.home.key == identity.key {
264+ l.home.generation = identity.generation
265+ }
266+ if l.previous.key != 0 {
267+ if previousIndex := l.indexByIdentityLocked(l.previous); previousIndex < 0 {
268+ l.previous = routeIdentity{}
269+ }
270+ }
271+ l.evictLocked()
272+ return identity, nil
273+}
274+
275+func (l *routeLedger) indexByIdentityLocked(identity routeIdentity) int {
276+ for i := range l.entries {
277+ if l.entries[i].identity == identity {
278+ return i
279+ }
280+ }
281+ return -1
282+}
283+
284+func (l *routeLedger) lookup(ref ports.RouteRef) (routeRecord, bool) {
285+ identity := identityFromWire(ref)
286+ if identity.empty() {
287+ return routeRecord{}, false
288+ }
289+ l.mu.RLock()
290+ defer l.mu.RUnlock()
291+ for _, entry := range l.entries {
292+ if entry.identity == identity {
293+ return cloneRouteRecord(entry), true
294+ }
295+ }
296+ return routeRecord{}, false
297+}
298+
299+func cloneRouteRecord(entry routeRecord) routeRecord {
300+ entry.request = cloneAttachRequest(entry.request)
301+ return entry
302+}
303+
304+func (l *routeLedger) snapshot() ports.RecentRouteSnapshot {
305+ l.mu.RLock()
306+ defer l.mu.RUnlock()
307+
308+ snapshot := ports.RecentRouteSnapshot{
309+ Generation: uint64(l.generation),
310+ Active: l.active.wire(),
311+ Previous: l.previous.wire(),
312+ Home: l.home.wire(),
313+ }
314+ if len(l.entries) == 0 {
315+ return snapshot
316+ }
317+ snapshot.Entries = make([]ports.RecentRouteEntry, 0, len(l.entries))
318+ for _, entry := range l.entries {
319+ snapshot.Entries = append(snapshot.Entries, ports.RecentRouteEntry{
320+ Key: uint64(entry.identity.key),
321+ Generation: uint64(entry.identity.generation),
322+ Name: entry.presentation.name,
323+ HostLabel: entry.presentation.hostLabel,
324+ Kind: entry.presentation.kind,
325+ Ephemeral: entry.presentation.ephemeral,
326+ Attention: entry.presentation.attention,
327+ Reachability: entry.presentation.reachability,
328+ })
329+ }
330+ return snapshot
331+}
332+
333+func (l *routeLedger) activeRef() ports.RouteRef {
334+ l.mu.RLock()
335+ defer l.mu.RUnlock()
336+ return l.active.wire()
337+}
338+
339+func (l *routeLedger) previousRef() ports.RouteRef {
340+ l.mu.RLock()
341+ defer l.mu.RUnlock()
342+ return l.previous.wire()
343+}
344+
345+func (l *routeLedger) homeRef() ports.RouteRef {
346+ l.mu.RLock()
347+ defer l.mu.RUnlock()
348+ return l.home.wire()
349+}
350+
351+// routeTransitionPlan is the private lookup result used by navigation. It
352+// preserves the original origin and exact target while leaving display labels
353+// and transport capabilities separate.
354+type routeTransitionPlan struct {
355+ identity routeIdentity
356+ record routeRecord
357+ origin ports.RouteOrigin
358+ target ports.ExactSessionTarget
359+}
360+
361+type routeTransitionCommit struct {
362+ identity ports.CommittedRouteIdentity
363+ presentation routePresentation
364+ dialer ports.Dialer
365+ resumeToken uint64
366+}
367+
368+type routeTransitionConnector interface {
369+ Resume(context.Context, routeRecord) (routeTransitionCommit, error)
370+ Attach(context.Context, routeRecord) (routeTransitionCommit, error)
371+}
372+
373+// navigate resolves a captured key/generation, attempts resume before exact
374+// attach, and commits only after the connector returns a matching identity.
375+// A failed transition leaves active/previous history unchanged.
376+func (l *routeLedger) navigate(ctx context.Context, action ports.RouteNavigationAction, connector routeTransitionConnector) error {
377+ if action.Key == 0 || action.Generation == 0 {
378+ return errRouteStaleSelection
379+ }
380+ if connector == nil {
381+ return errors.New("route transition connector is nil")
382+ }
383+ if ctx == nil {
384+ ctx = context.Background()
385+ }
386+ selected := ports.RouteRef{Key: action.Key, Generation: action.Generation}
387+ record, ok := l.lookup(selected)
388+ if !ok {
389+ return errRouteStaleSelection
390+ }
391+ plan := routeTransitionPlan{
392+ identity: identityFromWire(selected),
393+ record: record,
394+ origin: record.origin,
395+ target: record.target,
396+ }
397+
398+ var commit routeTransitionCommit
399+ var err error
400+ if record.resumeToken != 0 {
401+ commit, err = connector.Resume(ctx, record)
402+ if errors.Is(err, errRouteResumeUnavailable) {
403+ commit, err = connector.Attach(ctx, record)
404+ }
405+ } else {
406+ commit, err = connector.Attach(ctx, record)
407+ }
408+ if err != nil {
409+ return err
410+ }
411+ if err := commit.identity.Validate(); err != nil {
412+ return fmt.Errorf("invalid committed route identity: %w", err)
413+ }
414+ if commit.identity.Target != plan.target {
415+ return errRouteTargetChanged
416+ }
417+ if err := validateRoutePresentation(commit.presentation); err != nil {
418+ return err
419+ }
420+
421+ candidate := routeCandidate{
422+ origin: plan.origin,
423+ target: plan.target,
424+ presentation: commit.presentation,
425+ dialer: commit.dialer,
426+ request: cloneAttachRequest(plan.record.request),
427+ resumeToken: commit.resumeToken,
428+ home: plan.record.home,
429+ }
430+ candidate.request.Origin = plan.origin
431+ candidate.request.ExactTarget = &candidate.target
432+ return l.commitTransition(plan.identity, candidate)
433+}
434+
435+func (l *routeLedger) commitTransition(selected routeIdentity, candidate routeCandidate) error {
436+ l.mu.Lock()
437+ defer l.mu.Unlock()
438+ index := l.indexByIdentityLocked(selected)
439+ if index < 0 {
440+ return errRouteStaleSelection
441+ }
442+ if _, err := l.commitLocked(candidate); err != nil {
443+ return err
444+ }
445+ return nil
446+}
447+
448+// beginDiscoveryCycle and discoveryCycleCurrent deliberately use a lock
449+// separate from route history. A stale discovery result cannot clear or commit
450+// unrelated navigation history.
451+func (l *routeLedger) beginDiscoveryCycle() uint64 {
452+ l.discoveryMu.Lock()
453+ defer l.discoveryMu.Unlock()
454+ l.discoveryCycle++
455+ if l.discoveryCycle == 0 {
456+ l.discoveryCycle++
457+ }
458+ return l.discoveryCycle
459+}
460+
461+func (l *routeLedger) discoveryCycleCurrent(cycle uint64) bool {
462+ if cycle == 0 {
463+ return false
464+ }
465+ l.discoveryMu.Lock()
466+ defer l.discoveryMu.Unlock()
467+ return l.discoveryCycle == cycle
468+}
A internal/usecase/client/routes_test.go +194 -0
@@ -0,0 +1,194 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func routeTestTarget(index byte) ports.ExactSessionTarget {
15+ return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{index + 1}, SessionName: "session-" + string(rune('a'+index%26)) + string(rune('0'+index/26))}
16+}
17+
18+func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
19+ target := routeTestTarget(index)
20+ return routeCandidate{
21+ origin: origin,
22+ target: target,
23+ presentation: routePresentation{
24+ name: target.SessionName,
25+ kind: ports.RouteKindLocal,
26+ reachability: ports.RouteReachabilityReachable,
27+ },
28+ request: AttachRequest{
29+ Intent: ports.IntentAttach,
30+ SessionName: target.SessionName,
31+ Origin: origin,
32+ },
33+ }
34+}
35+
36+func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
37+ ledger := newRouteLedger()
38+ var keys []routeKey
39+ for i := byte(0); i < maxRouteLedgerEntries+8; i++ {
40+ candidate := routeTestCandidate(i, ports.RouteOriginLocal)
41+ candidate.home = i == 0
42+ identity, err := ledger.commit(candidate)
43+ require.NoError(t, err)
44+ keys = append(keys, identity.key)
45+ }
46+
47+ snapshot := ledger.snapshot()
48+ require.Len(t, snapshot.Entries, maxRouteLedgerEntries)
49+ require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key)
50+ require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key)
51+ require.Equal(t, uint64(keys[0]), snapshot.Home.Key, "home is retained when older entries are evicted")
52+
53+ for i := 1; i < len(snapshot.Entries); i++ {
54+ require.Greater(t, snapshot.Entries[i-1].Generation, snapshot.Entries[i].Generation)
55+ }
56+ originalName := snapshot.Entries[0].Name
57+ snapshot.Entries[0].Name = "mutated"
58+ fresh := ledger.snapshot()
59+ require.Equal(t, originalName, fresh.Entries[0].Name)
60+
61+ old := ledger.activeRef()
62+ _, err := ledger.commit(routeTestCandidate(maxRouteLedgerEntries+9, ports.RouteOriginLocal))
63+ require.NoError(t, err)
64+ require.NotEqual(t, old, ledger.activeRef())
65+ require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
66+}
67+
68+func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) {
69+ ledger := newRouteLedger()
70+ candidate := routeTestCandidate(1, ports.RouteOriginRemote)
71+ first, err := ledger.commit(candidate)
72+ require.NoError(t, err)
73+
74+ candidate.presentation.attention = true
75+ second, err := ledger.commit(candidate)
76+ require.NoError(t, err)
77+ require.Equal(t, first.key, second.key)
78+ require.Greater(t, second.generation, first.generation)
79+ _, ok := ledger.lookup(first.wire())
80+ require.False(t, ok, "old generation must not resolve")
81+}
82+
83+func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) {
84+ ledger := newRouteLedger()
85+ first := ledger.beginDiscoveryCycle()
86+ second := ledger.beginDiscoveryCycle()
87+
88+ require.NotEqual(t, first, second)
89+ require.False(t, ledger.discoveryCycleCurrent(first))
90+ require.True(t, ledger.discoveryCycleCurrent(second))
91+}
92+
93+type routeTestConnector struct {
94+ resumeErr error
95+ attachErr error
96+ resumeCalls int
97+ attachCalls int
98+}
99+
100+func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) {
101+ c.resumeCalls++
102+ if c.resumeErr != nil {
103+ return routeTransitionCommit{}, c.resumeErr
104+ }
105+ return routeTransitionCommit{}, errors.New("unexpected resume success")
106+}
107+
108+func (c *routeTestConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
109+ c.attachCalls++
110+ if c.attachErr != nil {
111+ return routeTransitionCommit{}, c.attachErr
112+ }
113+ return routeTransitionCommit{
114+ identity: ports.CommittedRouteIdentity{Target: record.target},
115+ presentation: routePresentation{
116+ name: record.presentation.name,
117+ kind: record.presentation.kind,
118+ reachability: ports.RouteReachabilityReachable,
119+ },
120+ resumeToken: 123,
121+ }, nil
122+}
123+
124+func TestRouteLedgerNavigateResumesThenFallsBackAndRestoresOrigin(t *testing.T) {
125+ ledger := newRouteLedger()
126+ candidate := routeTestCandidate(2, ports.RouteOriginDiscovery)
127+ candidate.resumeToken = 77
128+ identity, err := ledger.commit(candidate)
129+ require.NoError(t, err)
130+
131+ connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable}
132+ err = ledger.navigate(context.Background(), ports.RouteNavigationAction{Key: uint64(identity.key), Generation: uint64(identity.generation)}, connector)
133+ require.NoError(t, err)
134+ require.Equal(t, 1, connector.resumeCalls)
135+ require.Equal(t, 1, connector.attachCalls)
136+
137+ active, ok := ledger.lookup(ledger.activeRef())
138+ require.True(t, ok)
139+ require.Equal(t, ports.RouteOriginDiscovery, active.origin)
140+ require.Equal(t, ports.RouteOriginDiscovery, active.request.Origin)
141+ require.Equal(t, candidate.target, active.target)
142+ require.Greater(t, active.identity.generation, identity.generation)
143+}
144+
145+func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) {
146+ ledger := newRouteLedger()
147+ identity, err := ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal))
148+ require.NoError(t, err)
149+ before := ledger.snapshot()
150+
151+ connector := &routeTestConnector{attachErr: errors.New("offline")}
152+ err = ledger.navigate(context.Background(), identity.wireNavigationAction(), connector)
153+ require.Error(t, err)
154+ require.Equal(t, before, ledger.snapshot())
155+
156+ connector = &routeTestConnector{}
157+ connector.attachErr = nil
158+ // Force a mismatched committed target through a small wrapper.
159+ mismatch := mismatchingRouteConnector{routeTestConnector: connector}
160+ err = ledger.navigate(context.Background(), identity.wireNavigationAction(), mismatch)
161+ require.ErrorIs(t, err, errRouteTargetChanged)
162+ require.Equal(t, before, ledger.snapshot())
163+}
164+
165+type mismatchingRouteConnector struct{ *routeTestConnector }
166+
167+func (c mismatchingRouteConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
168+ return routeTransitionCommit{
169+ identity: ports.CommittedRouteIdentity{Target: routeTestTarget(99)},
170+ presentation: routePresentation{name: record.presentation.name, kind: record.presentation.kind},
171+ }, nil
172+}
173+
174+func (i routeIdentity) wireNavigationAction() ports.RouteNavigationAction {
175+ return ports.RouteNavigationAction{Key: uint64(i.key), Generation: uint64(i.generation)}
176+}
177+
178+func TestRouteLedgerConcurrentSnapshotsAndCommits(t *testing.T) {
179+ ledger := newRouteLedger()
180+ var wg sync.WaitGroup
181+ for worker := byte(0); worker < 4; worker++ {
182+ worker := worker
183+ wg.Add(1)
184+ go func() {
185+ defer wg.Done()
186+ for i := byte(0); i < 20; i++ {
187+ _, _ = ledger.commit(routeTestCandidate(worker*20+i, ports.RouteOriginLocal))
188+ _ = ledger.snapshot()
189+ }
190+ }()
191+ }
192+ wg.Wait()
193+ require.LessOrEqual(t, len(ledger.snapshot().Entries), maxRouteLedgerEntries)
194+}
@@ -0,0 +1,194 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func routeTestTarget(index byte) ports.ExactSessionTarget {
15+ return ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{index + 1}, SessionName: "session-" + string(rune('a'+index%26)) + string(rune('0'+index/26))}
16+}
17+
18+func routeTestCandidate(index byte, origin ports.RouteOrigin) routeCandidate {
19+ target := routeTestTarget(index)
20+ return routeCandidate{
21+ origin: origin,
22+ target: target,
23+ presentation: routePresentation{
24+ name: target.SessionName,
25+ kind: ports.RouteKindLocal,
26+ reachability: ports.RouteReachabilityReachable,
27+ },
28+ request: AttachRequest{
29+ Intent: ports.IntentAttach,
30+ SessionName: target.SessionName,
31+ Origin: origin,
32+ },
33+ }
34+}
35+
36+func TestRouteLedgerBoundsAndImmutableSnapshot(t *testing.T) {
37+ ledger := newRouteLedger()
38+ var keys []routeKey
39+ for i := byte(0); i < maxRouteLedgerEntries+8; i++ {
40+ candidate := routeTestCandidate(i, ports.RouteOriginLocal)
41+ candidate.home = i == 0
42+ identity, err := ledger.commit(candidate)
43+ require.NoError(t, err)
44+ keys = append(keys, identity.key)
45+ }
46+
47+ snapshot := ledger.snapshot()
48+ require.Len(t, snapshot.Entries, maxRouteLedgerEntries)
49+ require.Equal(t, uint64(keys[len(keys)-1]), snapshot.Active.Key)
50+ require.Equal(t, uint64(keys[len(keys)-2]), snapshot.Previous.Key)
51+ require.Equal(t, uint64(keys[0]), snapshot.Home.Key, "home is retained when older entries are evicted")
52+
53+ for i := 1; i < len(snapshot.Entries); i++ {
54+ require.Greater(t, snapshot.Entries[i-1].Generation, snapshot.Entries[i].Generation)
55+ }
56+ originalName := snapshot.Entries[0].Name
57+ snapshot.Entries[0].Name = "mutated"
58+ fresh := ledger.snapshot()
59+ require.Equal(t, originalName, fresh.Entries[0].Name)
60+
61+ old := ledger.activeRef()
62+ _, err := ledger.commit(routeTestCandidate(maxRouteLedgerEntries+9, ports.RouteOriginLocal))
63+ require.NoError(t, err)
64+ require.NotEqual(t, old, ledger.activeRef())
65+ require.Equal(t, old, ledger.previousRef(), "the old active route becomes the previous route")
66+}
67+
68+func TestRouteLedgerUpsertChangesGenerationWithoutChangingOpaqueKey(t *testing.T) {
69+ ledger := newRouteLedger()
70+ candidate := routeTestCandidate(1, ports.RouteOriginRemote)
71+ first, err := ledger.commit(candidate)
72+ require.NoError(t, err)
73+
74+ candidate.presentation.attention = true
75+ second, err := ledger.commit(candidate)
76+ require.NoError(t, err)
77+ require.Equal(t, first.key, second.key)
78+ require.Greater(t, second.generation, first.generation)
79+ _, ok := ledger.lookup(first.wire())
80+ require.False(t, ok, "old generation must not resolve")
81+}
82+
83+func TestRouteLedgerDiscoveryCyclesAreIndependent(t *testing.T) {
84+ ledger := newRouteLedger()
85+ first := ledger.beginDiscoveryCycle()
86+ second := ledger.beginDiscoveryCycle()
87+
88+ require.NotEqual(t, first, second)
89+ require.False(t, ledger.discoveryCycleCurrent(first))
90+ require.True(t, ledger.discoveryCycleCurrent(second))
91+}
92+
93+type routeTestConnector struct {
94+ resumeErr error
95+ attachErr error
96+ resumeCalls int
97+ attachCalls int
98+}
99+
100+func (c *routeTestConnector) Resume(context.Context, routeRecord) (routeTransitionCommit, error) {
101+ c.resumeCalls++
102+ if c.resumeErr != nil {
103+ return routeTransitionCommit{}, c.resumeErr
104+ }
105+ return routeTransitionCommit{}, errors.New("unexpected resume success")
106+}
107+
108+func (c *routeTestConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
109+ c.attachCalls++
110+ if c.attachErr != nil {
111+ return routeTransitionCommit{}, c.attachErr
112+ }
113+ return routeTransitionCommit{
114+ identity: ports.CommittedRouteIdentity{Target: record.target},
115+ presentation: routePresentation{
116+ name: record.presentation.name,
117+ kind: record.presentation.kind,
118+ reachability: ports.RouteReachabilityReachable,
119+ },
120+ resumeToken: 123,
121+ }, nil
122+}
123+
124+func TestRouteLedgerNavigateResumesThenFallsBackAndRestoresOrigin(t *testing.T) {
125+ ledger := newRouteLedger()
126+ candidate := routeTestCandidate(2, ports.RouteOriginDiscovery)
127+ candidate.resumeToken = 77
128+ identity, err := ledger.commit(candidate)
129+ require.NoError(t, err)
130+
131+ connector := &routeTestConnector{resumeErr: errRouteResumeUnavailable}
132+ err = ledger.navigate(context.Background(), ports.RouteNavigationAction{Key: uint64(identity.key), Generation: uint64(identity.generation)}, connector)
133+ require.NoError(t, err)
134+ require.Equal(t, 1, connector.resumeCalls)
135+ require.Equal(t, 1, connector.attachCalls)
136+
137+ active, ok := ledger.lookup(ledger.activeRef())
138+ require.True(t, ok)
139+ require.Equal(t, ports.RouteOriginDiscovery, active.origin)
140+ require.Equal(t, ports.RouteOriginDiscovery, active.request.Origin)
141+ require.Equal(t, candidate.target, active.target)
142+ require.Greater(t, active.identity.generation, identity.generation)
143+}
144+
145+func TestRouteLedgerNavigateIsTransactionalOnFailureOrTargetChange(t *testing.T) {
146+ ledger := newRouteLedger()
147+ identity, err := ledger.commit(routeTestCandidate(3, ports.RouteOriginLocal))
148+ require.NoError(t, err)
149+ before := ledger.snapshot()
150+
151+ connector := &routeTestConnector{attachErr: errors.New("offline")}
152+ err = ledger.navigate(context.Background(), identity.wireNavigationAction(), connector)
153+ require.Error(t, err)
154+ require.Equal(t, before, ledger.snapshot())
155+
156+ connector = &routeTestConnector{}
157+ connector.attachErr = nil
158+ // Force a mismatched committed target through a small wrapper.
159+ mismatch := mismatchingRouteConnector{routeTestConnector: connector}
160+ err = ledger.navigate(context.Background(), identity.wireNavigationAction(), mismatch)
161+ require.ErrorIs(t, err, errRouteTargetChanged)
162+ require.Equal(t, before, ledger.snapshot())
163+}
164+
165+type mismatchingRouteConnector struct{ *routeTestConnector }
166+
167+func (c mismatchingRouteConnector) Attach(_ context.Context, record routeRecord) (routeTransitionCommit, error) {
168+ return routeTransitionCommit{
169+ identity: ports.CommittedRouteIdentity{Target: routeTestTarget(99)},
170+ presentation: routePresentation{name: record.presentation.name, kind: record.presentation.kind},
171+ }, nil
172+}
173+
174+func (i routeIdentity) wireNavigationAction() ports.RouteNavigationAction {
175+ return ports.RouteNavigationAction{Key: uint64(i.key), Generation: uint64(i.generation)}
176+}
177+
178+func TestRouteLedgerConcurrentSnapshotsAndCommits(t *testing.T) {
179+ ledger := newRouteLedger()
180+ var wg sync.WaitGroup
181+ for worker := byte(0); worker < 4; worker++ {
182+ worker := worker
183+ wg.Add(1)
184+ go func() {
185+ defer wg.Done()
186+ for i := byte(0); i < 20; i++ {
187+ _, _ = ledger.commit(routeTestCandidate(worker*20+i, ports.RouteOriginLocal))
188+ _ = ledger.snapshot()
189+ }
190+ }()
191+ }
192+ wg.Wait()
193+ require.LessOrEqual(t, len(ledger.snapshot().Entries), maxRouteLedgerEntries)
194+}
M internal/usecase/client/session_connection.go +4 -0
@@ -80,6 +80,10 @@ func cloneAttachRequest(request AttachRequest) AttachRequest {
8080 target := *request.RemoteTarget
8181 request.RemoteTarget = &target
8282 }
83+ if request.ExactTarget != nil {
84+ target := *request.ExactTarget
85+ request.ExactTarget = &target
86+ }
8387 return request
8488 }
8589
@@ -80,6 +80,10 @@ func cloneAttachRequest(request AttachRequest) AttachRequest {
80 target := *request.RemoteTarget80 target := *request.RemoteTarget
81 request.RemoteTarget = &target81 request.RemoteTarget = &target
82 }82 }
83+ if request.ExactTarget != nil {
84+ target := *request.ExactTarget
85+ request.ExactTarget = &target
86+ }
83 return request87 return request
84 }88 }
85 89
M internal/usecase/daemon/daemon.go +28 -0
@@ -1352,6 +1352,29 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
13521352 // route resolves a Hello to a session and a freshly attached client, creating
13531353 // the session for ephemeral/new intents. Direct package callers retain the
13541354 // daemon lifetime context; inbound handshakes use routeWithContext.
1355+func (d *Daemon) validateExactSessionTarget(ctx context.Context, target ports.ExactSessionTarget) error {
1356+ if err := target.Validate(); err != nil {
1357+ return &protoErr{ports.ErrNoSuchSession, "invalid exact session target"}
1358+ }
1359+ if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
1360+ return err
1361+ }
1362+
1363+ d.mu.Lock()
1364+ defer d.mu.Unlock()
1365+ if sess := d.findByNameLocked(target.SessionName); sess != nil {
1366+ if sess.incarnation != target.LifecycleID {
1367+ return &protoErr{ports.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName}
1368+ }
1369+ return nil
1370+ }
1371+ stopped, ok := d.stopped[target.SessionName]
1372+ if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
1373+ return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
1374+ }
1375+ return nil
1376+}
1377+
13551378 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
13561379 ctx := d.serveCtx
13571380 if ctx == nil {
@@ -1378,6 +1401,11 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
13781401 if h.RemoteTarget != nil && h.ResumeToken == 0 {
13791402 return d.routeRemoteTargetWithContext(ctx, h, tr)
13801403 }
1404+ if h.ExactTarget != nil && h.ResumeToken == 0 {
1405+ if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil {
1406+ return nil, nil, err
1407+ }
1408+ }
13811409
13821410 // A non-zero token is an authoritative resume credential. If it is unknown,
13831411 // expired, or raced with lifecycle teardown, fail closed instead of routing
@@ -1352,6 +1352,29 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1352 // route resolves a Hello to a session and a freshly attached client, creating1352 // route resolves a Hello to a session and a freshly attached client, creating
1353 // the session for ephemeral/new intents. Direct package callers retain the1353 // the session for ephemeral/new intents. Direct package callers retain the
1354 // daemon lifetime context; inbound handshakes use routeWithContext.1354 // daemon lifetime context; inbound handshakes use routeWithContext.
1355+func (d *Daemon) validateExactSessionTarget(ctx context.Context, target ports.ExactSessionTarget) error {
1356+ if err := target.Validate(); err != nil {
1357+ return &protoErr{ports.ErrNoSuchSession, "invalid exact session target"}
1358+ }
1359+ if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
1360+ return err
1361+ }
1362+
1363+ d.mu.Lock()
1364+ defer d.mu.Unlock()
1365+ if sess := d.findByNameLocked(target.SessionName); sess != nil {
1366+ if sess.incarnation != target.LifecycleID {
1367+ return &protoErr{ports.ErrNoSuchSession, "session lifecycle has changed: " + target.SessionName}
1368+ }
1369+ return nil
1370+ }
1371+ stopped, ok := d.stopped[target.SessionName]
1372+ if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
1373+ return &protoErr{ports.ErrNoSuchSession, "no such session lifecycle: " + target.SessionName}
1374+ }
1375+ return nil
1376+}
1377+
1355 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {1378 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
1356 ctx := d.serveCtx1379 ctx := d.serveCtx
1357 if ctx == nil {1380 if ctx == nil {
@@ -1378,6 +1401,11 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1378 if h.RemoteTarget != nil && h.ResumeToken == 0 {1401 if h.RemoteTarget != nil && h.ResumeToken == 0 {
1379 return d.routeRemoteTargetWithContext(ctx, h, tr)1402 return d.routeRemoteTargetWithContext(ctx, h, tr)
1380 }1403 }
1404+ if h.ExactTarget != nil && h.ResumeToken == 0 {
1405+ if err := d.validateExactSessionTarget(ctx, *h.ExactTarget); err != nil {
1406+ return nil, nil, err
1407+ }
1408+ }
1381 1409
1382 // A non-zero token is an authoritative resume credential. If it is unknown,1410 // A non-zero token is an authoritative resume credential. If it is unknown,
1383 // expired, or raced with lifecycle teardown, fail closed instead of routing1411 // expired, or raced with lifecycle teardown, fail closed instead of routing
A internal/usecase/daemon/exact_route_test.go +54 -0
@@ -0,0 +1,54 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRouteExactSessionTargetSelectsLifecycle(t *testing.T) {
13+ d := newTestDaemon(t, nil, stubClock{})
14+ sess := addControlSession(d, "work", "tab-1", "pane-1")
15+ sess.ephemeral = false
16+ sess.incarnation = remoteLifecycleForTest()
17+ tr, _ := newCapturingTransport(t)
18+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
19+ hello := ports.Hello{
20+ Version: ports.ProtocolVersion,
21+ Intent: ports.IntentAttach,
22+ Name: target.SessionName,
23+ Size: domain.Size{Cols: 80, Rows: 24},
24+ ExactTarget: &target,
25+ }
26+
27+ routed, ac, err := d.routeWithContext(context.Background(), hello, tr)
28+ require.NoError(t, err)
29+ require.Same(t, sess, routed)
30+ require.Same(t, sess, ac.currentAttachmentSession())
31+ d.clientGone(sess, ac, tr, false)
32+}
33+
34+func TestRouteExactSessionTargetRejectsLifecycleReplacement(t *testing.T) {
35+ d := newTestDaemon(t, nil, stubClock{})
36+ sess := addControlSession(d, "work", "tab-1", "pane-1")
37+ sess.ephemeral = false
38+ sess.incarnation = remoteLifecycleForTest()
39+ tr, _ := newCapturingTransport(t)
40+ wrong := sess.incarnation
41+ wrong[0]++
42+ hello := ports.Hello{
43+ Version: ports.ProtocolVersion,
44+ Intent: ports.IntentAttach,
45+ Name: sess.name,
46+ Size: domain.Size{Cols: 80, Rows: 24},
47+ ExactTarget: &ports.ExactSessionTarget{LifecycleID: wrong, SessionName: sess.name},
48+ }
49+
50+ _, _, err := d.routeWithContext(context.Background(), hello, tr)
51+ var protocol *protoErr
52+ require.ErrorAs(t, err, &protocol)
53+ require.Equal(t, ports.ErrNoSuchSession, protocol.code)
54+}
@@ -0,0 +1,54 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRouteExactSessionTargetSelectsLifecycle(t *testing.T) {
13+ d := newTestDaemon(t, nil, stubClock{})
14+ sess := addControlSession(d, "work", "tab-1", "pane-1")
15+ sess.ephemeral = false
16+ sess.incarnation = remoteLifecycleForTest()
17+ tr, _ := newCapturingTransport(t)
18+ target := ports.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
19+ hello := ports.Hello{
20+ Version: ports.ProtocolVersion,
21+ Intent: ports.IntentAttach,
22+ Name: target.SessionName,
23+ Size: domain.Size{Cols: 80, Rows: 24},
24+ ExactTarget: &target,
25+ }
26+
27+ routed, ac, err := d.routeWithContext(context.Background(), hello, tr)
28+ require.NoError(t, err)
29+ require.Same(t, sess, routed)
30+ require.Same(t, sess, ac.currentAttachmentSession())
31+ d.clientGone(sess, ac, tr, false)
32+}
33+
34+func TestRouteExactSessionTargetRejectsLifecycleReplacement(t *testing.T) {
35+ d := newTestDaemon(t, nil, stubClock{})
36+ sess := addControlSession(d, "work", "tab-1", "pane-1")
37+ sess.ephemeral = false
38+ sess.incarnation = remoteLifecycleForTest()
39+ tr, _ := newCapturingTransport(t)
40+ wrong := sess.incarnation
41+ wrong[0]++
42+ hello := ports.Hello{
43+ Version: ports.ProtocolVersion,
44+ Intent: ports.IntentAttach,
45+ Name: sess.name,
46+ Size: domain.Size{Cols: 80, Rows: 24},
47+ ExactTarget: &ports.ExactSessionTarget{LifecycleID: wrong, SessionName: sess.name},
48+ }
49+
50+ _, _, err := d.routeWithContext(context.Background(), hello, tr)
51+ var protocol *protoErr
52+ require.ErrorAs(t, err, &protocol)
53+ require.Equal(t, ports.ErrNoSuchSession, protocol.code)
54+}
M internal/usecase/daemon/wire.go +12 -4
@@ -8,11 +8,19 @@ import (
88
99 func frameWelcome(s *session, resumeToken uint64) ports.Frame {
1010 s.mu.Lock()
11+ var identity *ports.CommittedRouteIdentity
12+ if s.incarnation != (domain.SessionLifecycleID{}) {
13+ identity = &ports.CommittedRouteIdentity{
14+ Target: ports.ExactSessionTarget{LifecycleID: s.incarnation, SessionName: s.name},
15+ Ephemeral: s.ephemeral,
16+ }
17+ }
1118 w := ports.Welcome{
12- SessionID: string(s.id),
13- SessionName: s.name,
14- Ephemeral: s.ephemeral,
15- Capabilities: ports.CapabilityResume,
19+ SessionID: string(s.id),
20+ SessionName: s.name,
21+ Ephemeral: s.ephemeral,
22+ Capabilities: ports.CapabilityResume,
23+ CommittedIdentity: identity,
1624 }
1725 s.mu.Unlock()
1826 w.ResumeToken = resumeToken
@@ -8,11 +8,19 @@ import (
8 8
9 func frameWelcome(s *session, resumeToken uint64) ports.Frame {9 func frameWelcome(s *session, resumeToken uint64) ports.Frame {
10 s.mu.Lock()10 s.mu.Lock()
11+ var identity *ports.CommittedRouteIdentity
12+ if s.incarnation != (domain.SessionLifecycleID{}) {
13+ identity = &ports.CommittedRouteIdentity{
14+ Target: ports.ExactSessionTarget{LifecycleID: s.incarnation, SessionName: s.name},
15+ Ephemeral: s.ephemeral,
16+ }
17+ }
11 w := ports.Welcome{18 w := ports.Welcome{
12- SessionID: string(s.id),19+ SessionID: string(s.id),
13- SessionName: s.name,20+ SessionName: s.name,
14- Ephemeral: s.ephemeral,21+ Ephemeral: s.ephemeral,
15- Capabilities: ports.CapabilityResume,22+ Capabilities: ports.CapabilityResume,
23+ CommittedIdentity: identity,
16 }24 }
17 s.mu.Unlock()25 s.mu.Unlock()
18 w.ResumeToken = resumeToken26 w.ResumeToken = resumeToken

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