detached e9e6c54

feat(remote): establish proxied content ownership

commit
e9e6c5406027c15dfeb6d37206c57fe413b04d1d
author
brice · · 2026-08-06 17:10 UTC
parent
c1cc94c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
46 files · +3015 -495
A docs/ansi-content-replay-phase0.md +11 -0
@@ -0,0 +1,11 @@
1+# Phase 0: ANSI content replay gate
2+
3+The test-only prototype in `internal/usecase/daemon/ansi_content_replay_phase0_test.go` records the current feasibility boundary for remote content:
4+
5+- ANSI produced by `pkg/renderer` is replayed into a private `pkg/vt.Screen`.
6+- The screen has nil response, bell, notification, progress, and clipboard callbacks, so replay cannot emit remote host-side effects.
7+- Full and incremental output, styles, wide-cell continuation, cursor state, and split CSI/OSC writes are captured as cells and cursor metadata.
8+- The captured grid can be handed to a local renderer for composition; the remote ANSI stream is not passed to that renderer or a transport.
9+- A test-only ordered-frame model requires metadata before the first full content frame, contiguous state-bearing deltas, and base-zero full resets. Gaps and malformed resets are rejected.
10+
11+This is feasibility evidence only. It does not change the production renderer, protocol, layout, or attach flow. The remaining gate is to specify and integrate a production remote metadata/content ownership contract around this boundary; no compatibility path is implied by Phase 0.
M internal/app/run_test.go +1 -1
@@ -575,7 +575,7 @@ func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
575575 t.Run(tt.name, func(t *testing.T) {
576576 cmd, err := parseArgs([]string{"attach", tt.arg})
577577 require.NoError(t, err)
578- connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
578+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.AttachRequest{
579579 Intent: cmd.intent,
580580 SessionName: cmd.name,
581581 })
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
1212 const HandshakeTimeout = 15 * time.Second
1313
1414 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 24
15+const ProtocolVersion uint16 = 25
1616
1717 // MaxFrameLen is the largest permitted frame length, including the type byte
1818 // and excluding the four-byte length prefix.
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 24 {
7- t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)
6+ if ProtocolVersion != 25 {
7+ t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
88 }
99 }
1010
M internal/ports/remote_preview_hardening_test.go +1 -1
@@ -194,7 +194,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
194194 want := []byte{
195195 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
196196 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach,
197- 0x01, 0x01,
197+ 0x01,
198198 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
199199 0x00, 0x09, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
200200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
M internal/ports/remote_target_wire_test.go +50 -30
@@ -2,6 +2,7 @@ package ports
22
33 import (
44 "bytes"
5+ "reflect"
56 "testing"
67
78 "github.com/bnema/vev/internal/domain"
@@ -79,15 +80,54 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
7980 }
8081 }
8182
83+func TestProxiedHelloGoldenAndStrict(t *testing.T) {
84+ target := testRemoteTarget(false)
85+ msg := Hello{
86+ Version: ProtocolVersion, Intent: IntentAttach, RenderMode: RenderModeProxiedContent,
87+ ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, ResumeToken: 0x0102030405060708,
88+ Name: target.SessionName, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 2,
89+ RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
90+ }
91+ want := []byte{
92+ 0, 25, IntentAttach, byte(RenderModeProxiedContent),
93+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
94+ 1, 2, 3, 4, 5, 6, 7, 8,
95+ 0, 4, 'w', 'o', 'r', 'k', 0, 80, 0, 24, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0,
96+ 1,
97+ 0, 15, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
98+ 0, 9, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
99+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
100+ 0, 4, 'w', 'o', 'r', 'k', 0, 0, 5, 't', 'a', 'b', '-', '3', 0, byte(EnvironmentPolicyDaemonOwned),
101+ }
102+ payload := MarshalHello(msg)
103+ if !bytes.Equal(payload, want) {
104+ t.Fatalf("proxied Hello bytes = %x, want %x", payload, want)
105+ }
106+ decoded, err := UnmarshalHello(payload)
107+ if err != nil || !reflect.DeepEqual(decoded, msg) || decoded.RemoteTarget == msg.RemoteTarget {
108+ t.Fatalf("proxied Hello = %#v, error %v", decoded, err)
109+ }
110+ assertAllPrefixesFail(t, payload, UnmarshalHello)
111+ assertTrailingGarbageFails(t, payload, UnmarshalHello)
112+
113+ if MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, RenderMode: RenderModeProxiedContent, Size: domain.Size{Cols: 80, Rows: 24}}) != nil {
114+ t.Fatal("proxied Hello without exact target marshaled")
115+ }
116+ malformed := append([]byte(nil), payload...)
117+ malformed[3] = 2
118+ if _, err := UnmarshalHello(malformed); err == nil {
119+ t.Fatal("unknown render mode decoded")
120+ }
121+}
122+
82123 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
83124 target := testRemoteTarget(false)
84125 tests := []struct {
85- name string
86- validate func() error
87- marshalBad func() []byte
88- marshalGood func() []byte
89- marshalLegacy func() []byte
90- unmarshal func([]byte) error
126+ name string
127+ validate func() error
128+ marshalBad func() []byte
129+ marshalGood func() []byte
130+ unmarshal func([]byte) error
91131 }{
92132 {
93133 name: "hello",
@@ -112,12 +152,6 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
112152 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
113153 })
114154 },
115- marshalLegacy: func() []byte {
116- return MarshalHello(Hello{
117- Version: ProtocolVersion, Intent: IntentAttach,
118- Name: target.SessionName, Size: domain.Size{Cols: 80, Rows: 24},
119- })
120- },
121155 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
122156 },
123157 {
@@ -140,11 +174,6 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
140174 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
141175 })
142176 },
143- marshalLegacy: func() []byte {
144- return MarshalAttachTarget(AttachTarget{
145- Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach,
146- })
147- },
148177 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
149178 },
150179 }
@@ -161,16 +190,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
161190 if payload == nil {
162191 t.Fatal("failed to marshal daemon-owned control payload")
163192 }
164- legacyLen := len(tt.marshalLegacy())
165- if err := tt.unmarshal(payload[:legacyLen]); err != nil {
166- t.Fatalf("complete legacy prefix failed to decode: %v", err)
167- }
168- for i := 0; i < len(payload); i++ {
169- if i == legacyLen {
170- // The complete legacy prefix is a valid count-only payload by
171- // design; every other rich-target prefix is malformed.
172- continue
173- }
193+ for i := range len(payload) {
174194 if err := tt.unmarshal(payload[:i]); err == nil {
175195 t.Fatalf("prefix %d unexpectedly decoded", i)
176196 }
@@ -187,10 +207,10 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
187207 }
188208 }
189209
190-func TestLegacyTargetWireBytesRemainUnchanged(t *testing.T) {
191- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach}
210+func TestTargetWireIncludesRequiredV25Section(t *testing.T) {
211+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
192212 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
193213 if !bytes.Equal(got, want) {
194- t.Fatalf("legacy target bytes = %x, want %x", got, want)
214+ t.Fatalf("target bytes = %x, want %x", got, want)
195215 }
196216 }
M internal/ports/wire.go +122 -71
@@ -80,7 +80,18 @@ func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
8080 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
8181 }
8282
83-const remoteTargetWireVersion uint8 = 1
83+// RenderMode determines whether the daemon emits a complete terminal view or
84+// only the private remote content surface requested by another daemon.
85+type RenderMode uint8
86+
87+const (
88+ RenderModeFullTerminal RenderMode = iota
89+ RenderModeProxiedContent
90+)
91+
92+func validRenderMode(mode RenderMode) bool {
93+ return mode == RenderModeFullTerminal || mode == RenderModeProxiedContent
94+}
8495
8596 // Capability bits advertised in Welcome.
8697 const (
@@ -116,6 +127,7 @@ const (
116127 type Hello struct {
117128 Version uint16
118129 Intent uint8
130+ RenderMode RenderMode
119131 ClientID [16]byte
120132 ResumeToken uint64
121133 Name string
@@ -224,6 +236,7 @@ type Welcome struct {
224236 SessionName string
225237 Ephemeral bool
226238 ResumeToken uint64
239+ RenderMode RenderMode
227240 Capabilities uint32
228241 }
229242
@@ -299,16 +312,19 @@ type Sessions struct {
299312 type OutputResetRequest struct{}
300313
301314 // SessionTabMeta describes one tab in a remote session's metadata snapshot.
315+// ID is stable for the lifetime of its remote tab and ordered only for display.
302316 type SessionTabMeta struct {
303- Index uint16
317+ ID domain.TabStableID
304318 Name string
305319 Attention bool
306320 }
307321
308-// SessionMeta is authoritative tab metadata for a remote session.
322+// SessionMeta is an authoritative, revisioned snapshot of remote tab metadata.
309323 type SessionMeta struct {
324+ LifecycleID domain.SessionLifecycleID
325+ Revision uint64
310326 SessionName string
311- Active uint16
327+ ActiveTabID domain.TabStableID
312328 Tabs []SessionTabMeta
313329 }
314330
@@ -368,8 +384,10 @@ func (w *payloadWriter) putLongBytes(b []byte) {
368384 w.b = append(w.b, b...)
369385 }
370386
371-func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
372- w.putUint8(remoteTargetWireVersion)
387+// marshalRemoteTargetSection writes the required v25 target/policy section.
388+// A target may be absent for direct local attaches, but its presence marker and
389+// environment policy are never optional: v25 has no extension or legacy tail.
390+func marshalRemoteTargetSection(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
373391 if target == nil {
374392 w.putBool(false)
375393 } else {
@@ -393,14 +411,7 @@ func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSession
393411 w.putUint8(uint8(policy))
394412 }
395413
396-func skipRemoteTargetExtension(r *payloadReader) error {
397- version, err := r.getUint8()
398- if err != nil {
399- return err
400- }
401- if version != remoteTargetWireVersion {
402- return errInvalidEnum
403- }
414+func skipRemoteTargetSection(r *payloadReader) error {
404415 present, err := r.getBool()
405416 if err != nil {
406417 return err
@@ -518,14 +529,7 @@ func unmarshalRemoteTarget(r *payloadReader) (*domain.RemoteSessionTarget, error
518529 return target, nil
519530 }
520531
521-func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarget, EnvironmentPolicy, error) {
522- version, err := r.getUint8()
523- if err != nil {
524- return nil, 0, err
525- }
526- if version != remoteTargetWireVersion {
527- return nil, 0, errInvalidEnum
528- }
532+func unmarshalRemoteTargetSection(r *payloadReader) (*domain.RemoteSessionTarget, EnvironmentPolicy, error) {
529533 target, err := unmarshalRemoteTarget(r)
530534 if err != nil {
531535 return nil, 0, err
@@ -534,6 +538,9 @@ func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarg
534538 if err != nil {
535539 return nil, 0, err
536540 }
541+ if !validEnvironmentPolicy(EnvironmentPolicy(policy)) {
542+ return nil, 0, errInvalidEnum
543+ }
537544 return target, EnvironmentPolicy(policy), nil
538545 }
539546
@@ -692,6 +699,9 @@ func ValidateHello(h Hello) error {
692699 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
693700 return ErrInvalidHello
694701 }
702+ if !validRenderMode(h.RenderMode) {
703+ return ErrInvalidHello
704+ }
695705 if err := ValidateSize(h.Size); err != nil {
696706 return fmt.Errorf("%w: size", ErrInvalidHello)
697707 }
@@ -707,7 +717,7 @@ func ValidateHello(h Hello) error {
707717 }
708718 }
709719 if h.RemoteTarget == nil {
710- if h.EnvironmentPolicy != EnvironmentPolicyClientOwned {
720+ if h.EnvironmentPolicy != EnvironmentPolicyClientOwned || h.RenderMode == RenderModeProxiedContent {
711721 return ErrInvalidHello
712722 }
713723 return nil
@@ -804,6 +814,7 @@ func MarshalHello(h Hello) []byte {
804814 w := payloadWriter{}
805815 w.putUint16(h.Version)
806816 w.putUint8(h.Intent)
817+ w.putUint8(uint8(h.RenderMode))
807818 w.putBytes(h.ClientID[:])
808819 w.putUint64(h.ResumeToken)
809820 w.putString(h.Name)
@@ -817,9 +828,7 @@ func MarshalHello(h Hello) []byte {
817828 for _, entry := range h.Env {
818829 w.putLongString(entry)
819830 }
820- if h.RemoteTarget != nil || h.EnvironmentPolicy != EnvironmentPolicyClientOwned {
821- marshalRemoteTargetExtension(&w, h.RemoteTarget, h.EnvironmentPolicy)
822- }
831+ marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
823832 return w.b
824833 }
825834
@@ -835,6 +844,13 @@ func preflightHello(b []byte) error {
835844 if _, err := r.getUint8(); err != nil {
836845 return err
837846 }
847+ mode, err := r.getUint8()
848+ if err != nil {
849+ return err
850+ }
851+ if !validRenderMode(RenderMode(mode)) {
852+ return errInvalidEnum
853+ }
838854 if len(r.b) < 16 {
839855 return errShortPayload
840856 }
@@ -875,10 +891,8 @@ func preflightHello(b []byte) error {
875891 return err
876892 }
877893 }
878- if len(r.b) != 0 {
879- if err := skipRemoteTargetExtension(&r); err != nil {
880- return err
881- }
894+ if err := skipRemoteTargetSection(&r); err != nil {
895+ return err
882896 }
883897 return r.done()
884898 }
@@ -901,6 +915,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
901915 if h.Intent, err = r.getUint8(); err != nil {
902916 return Hello{}, err
903917 }
918+ mode, err := r.getUint8()
919+ if err != nil {
920+ return Hello{}, err
921+ }
922+ h.RenderMode = RenderMode(mode)
904923 clientID, err := r.getBytes(len(h.ClientID))
905924 if err != nil {
906925 return Hello{}, err
@@ -952,10 +971,8 @@ func UnmarshalHello(b []byte) (Hello, error) {
952971 h.Env = append(h.Env, entry)
953972 }
954973 }
955- if len(r.b) != 0 {
956- if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {
957- return Hello{}, err
958- }
974+ if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
975+ return Hello{}, err
959976 }
960977 if err := r.done(); err != nil {
961978 return Hello{}, err
@@ -1250,6 +1267,7 @@ func MarshalWelcome(m Welcome) []byte {
12501267 w.putUint8(0)
12511268 }
12521269 w.putUint64(m.ResumeToken)
1270+ w.putUint8(uint8(m.RenderMode))
12531271 w.putUint32(m.Capabilities)
12541272 return w.b
12551273 }
@@ -1274,6 +1292,14 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
12741292 if m.ResumeToken, err = r.getUint64(); err != nil {
12751293 return Welcome{}, err
12761294 }
1295+ mode, err := r.getUint8()
1296+ if err != nil {
1297+ return Welcome{}, err
1298+ }
1299+ m.RenderMode = RenderMode(mode)
1300+ if !validRenderMode(m.RenderMode) {
1301+ return Welcome{}, errInvalidEnum
1302+ }
12771303 if m.Capabilities, err = r.getUint32(); err != nil {
12781304 return Welcome{}, err
12791305 }
@@ -1400,9 +1426,7 @@ func MarshalAttachTarget(m AttachTarget) []byte {
14001426 w.putString(m.Endpoint)
14011427 w.putString(m.Session)
14021428 w.putUint8(m.Intent)
1403- if m.RemoteTarget != nil || m.EnvironmentPolicy != EnvironmentPolicyClientOwned {
1404- marshalRemoteTargetExtension(&w, m.RemoteTarget, m.EnvironmentPolicy)
1405- }
1429+ marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
14061430 return w.b
14071431 }
14081432
@@ -1424,10 +1448,8 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
14241448 if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {
14251449 return AttachTarget{}, ErrInvalidAttachTarget
14261450 }
1427- if len(probe.b) != 0 {
1428- if err := skipRemoteTargetExtension(&probe); err != nil {
1429- return AttachTarget{}, ErrInvalidAttachTarget
1430- }
1451+ if err := skipRemoteTargetSection(&probe); err != nil {
1452+ return AttachTarget{}, ErrInvalidAttachTarget
14311453 }
14321454 if err := probe.done(); err != nil {
14331455 return AttachTarget{}, ErrInvalidAttachTarget
@@ -1444,10 +1466,8 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
14441466 if m.Intent, err = r.getUint8(); err != nil {
14451467 return AttachTarget{}, err
14461468 }
1447- if len(r.b) != 0 {
1448- if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {
1449- return AttachTarget{}, err
1450- }
1469+ if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
1470+ return AttachTarget{}, err
14511471 }
14521472 if err := r.done(); err != nil {
14531473 return AttachTarget{}, err
@@ -1509,66 +1529,94 @@ func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
15091529 return OutputResetRequest{}, nil
15101530 }
15111531
1512-// MarshalSessionMeta encodes m when it satisfies the protocol's authoritative
1513-// ordered-tab constraints.
1514-func MarshalSessionMeta(m SessionMeta) ([]byte, error) {
1515- if len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 || int(m.Active) >= len(m.Tabs) {
1516- return nil, ErrInvalidSessionMeta
1532+// ValidateSessionMeta validates a revisioned authoritative remote metadata
1533+// snapshot. Ordered tabs remain display order, while IDs are the only identity.
1534+func ValidateSessionMeta(m SessionMeta) error {
1535+ if m.LifecycleID == (domain.SessionLifecycleID{}) || m.Revision == 0 ||
1536+ m.SessionName == "" || len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 {
1537+ return ErrInvalidSessionMeta
1538+ }
1539+ if len(m.SessionName) > math.MaxUint16 || len(m.ActiveTabID) > math.MaxUint16 {
1540+ return ErrSessionMetaStringTooLong
15171541 }
1518- if len(m.SessionName) > math.MaxUint16 {
1519- return nil, ErrSessionMetaStringTooLong
1542+ if err := domain.ValidateTabStableID(m.ActiveTabID); err != nil {
1543+ return ErrInvalidSessionMeta
15201544 }
1521- for i, tab := range m.Tabs {
1522- if tab.Index != uint16(i) {
1523- return nil, ErrInvalidSessionMeta
1545+ activeFound := false
1546+ seen := make(map[domain.TabStableID]struct{}, len(m.Tabs))
1547+ for _, tab := range m.Tabs {
1548+ if len(tab.ID) > math.MaxUint16 || len(tab.Name) > math.MaxUint16 {
1549+ return ErrSessionMetaStringTooLong
1550+ }
1551+ if err := domain.ValidateTabStableID(tab.ID); err != nil {
1552+ return ErrInvalidSessionMeta
15241553 }
1525- if len(tab.Name) > math.MaxUint16 {
1526- return nil, ErrSessionMetaStringTooLong
1554+ if _, duplicate := seen[tab.ID]; duplicate {
1555+ return ErrInvalidSessionMeta
15271556 }
1557+ seen[tab.ID] = struct{}{}
1558+ activeFound = activeFound || tab.ID == m.ActiveTabID
15281559 }
1560+ if !activeFound {
1561+ return ErrInvalidSessionMeta
1562+ }
1563+ return nil
1564+}
15291565
1566+// MarshalSessionMeta encodes a strict v25 revisioned metadata snapshot.
1567+func MarshalSessionMeta(m SessionMeta) ([]byte, error) {
1568+ if err := ValidateSessionMeta(m); err != nil {
1569+ return nil, err
1570+ }
15301571 w := payloadWriter{}
1572+ w.putBytes(m.LifecycleID[:])
1573+ w.putUint64(m.Revision)
15311574 w.putString(m.SessionName)
1532- w.putUint16(m.Active)
1575+ w.putString(string(m.ActiveTabID))
15331576 w.putUint16(uint16(len(m.Tabs)))
15341577 for _, tab := range m.Tabs {
1535- w.putUint16(tab.Index)
1578+ w.putString(string(tab.ID))
15361579 w.putString(tab.Name)
15371580 w.putBool(tab.Attention)
15381581 }
15391582 return w.b, nil
15401583 }
15411584
1542-// UnmarshalSessionMeta decodes a strict authoritative metadata snapshot.
1585+// UnmarshalSessionMeta decodes a strict v25 authoritative metadata snapshot.
15431586 func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
15441587 r := payloadReader{b: b}
15451588 var m SessionMeta
1546- var err error
1589+ lifecycle, err := r.getBytes(len(m.LifecycleID))
1590+ if err != nil {
1591+ return SessionMeta{}, err
1592+ }
1593+ copy(m.LifecycleID[:], lifecycle)
1594+ if m.Revision, err = r.getUint64(); err != nil {
1595+ return SessionMeta{}, err
1596+ }
15471597 if m.SessionName, err = r.getString(); err != nil {
15481598 return SessionMeta{}, err
15491599 }
1550- if m.Active, err = r.getUint16(); err != nil {
1600+ activeTabID, err := r.getString()
1601+ if err != nil {
15511602 return SessionMeta{}, err
15521603 }
1604+ m.ActiveTabID = domain.TabStableID(activeTabID)
15531605 count, err := r.getUint16()
15541606 if err != nil {
15551607 return SessionMeta{}, err
15561608 }
1557- if count == 0 || int(m.Active) >= int(count) {
1558- return SessionMeta{}, ErrInvalidSessionMeta
1559- }
1560- if uint64(count) > uint64(len(r.b)/5) {
1609+ if count == 0 || uint64(count) > uint64(len(r.b)/5) {
15611610 return SessionMeta{}, errShortPayload
15621611 }
15631612 m.Tabs = make([]SessionTabMeta, 0, int(count))
1564- for i := range int(count) {
1613+ for range int(count) {
15651614 var tab SessionTabMeta
1566- if tab.Index, err = r.getUint16(); err != nil {
1615+ id, err := r.getString()
1616+ if err != nil {
15671617 return SessionMeta{}, err
15681618 }
1569- if tab.Index != uint16(i) {
1570- return SessionMeta{}, ErrInvalidSessionMeta
1571- }
1619+ tab.ID = domain.TabStableID(id)
15721620 if tab.Name, err = r.getString(); err != nil {
15731621 return SessionMeta{}, err
15741622 }
@@ -1580,6 +1628,9 @@ func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
15801628 if err := r.done(); err != nil {
15811629 return SessionMeta{}, err
15821630 }
1631+ if err := ValidateSessionMeta(m); err != nil {
1632+ return SessionMeta{}, err
1633+ }
15831634 return m, nil
15841635 }
15851636
M internal/ports/wire_final_test.go +7 -7
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 24 {
15- t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)
14+ if ProtocolVersion != 25 {
15+ t.Fatalf("ProtocolVersion = %d, want 25", 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, 24, 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)
29+ want := append([]byte{0, 25, 2, 0}, 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)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
3333 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -178,7 +178,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
178178
179179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
180180 msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}
181- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2}
181+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2, 0, 0}
182182 got := MarshalAttachTarget(msg)
183183 if !bytes.Equal(got, want) {
184184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -197,7 +197,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
197197 }
198198
199199 func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
200- meta, err := MarshalSessionMeta(SessionMeta{Tabs: []SessionTabMeta{{Name: "tab"}}})
200+ meta, err := MarshalSessionMeta(SessionMeta{LifecycleID: domain.SessionLifecycleID{1}, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "tab"}}})
201201 if err != nil {
202202 t.Fatal(err)
203203 }
@@ -210,7 +210,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
210210 name: "hello boolean",
211211 payload: func() []byte {
212212 b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
213- b[len(b)-6] = 2
213+ b[len(b)-8] = 2
214214 return b
215215 }(),
216216 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },
M internal/ports/wire_test.go +49 -27
@@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7373 TrueColor: true,
7474 MaxOutputInFlight: 8,
7575 },
76- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00},
76+ want: []byte{0x00, 0x01, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
7777 },
7878 {
7979 name: "empty strings",
@@ -86,7 +86,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8686 Cwd: "",
8787 TrueColor: false,
8888 },
89- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
89+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
9090 },
9191 }
9292
@@ -124,7 +124,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
124124 }
125125 got := MarshalHello(msg)
126126 want := []byte{
127- 0x00, 0x18, 0x00, // version, intent
127+ 0x00, 0x19, 0x00, 0x00, // version, intent, render mode
128128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
129129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
130130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -136,6 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136136 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
137137 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
138138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
139+ 0x00, 0x00, // no remote target, client-owned environment
139140 }
140141 require.Equal(t, want, got)
141142 decoded, err := UnmarshalHello(want)
@@ -164,7 +165,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
164165 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
165166 withCount := func(count byte) []byte {
166167 payload := append([]byte(nil), base...)
167- payload[len(payload)-1] = count
168+ payload[len(payload)-3] = count
168169 return payload
169170 }
170171 tests := []struct {
@@ -341,7 +342,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
341342 }
342343 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
343344 require.Equal(t, want, MarshalTheme(cleared))
344- require.Equal(t, uint16(24), ProtocolVersion)
345+ require.Equal(t, uint16(25), ProtocolVersion)
345346 }
346347
347348 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -421,12 +422,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
421422 {
422423 name: "ephemeral",
423424 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
424- 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},
425+ 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, 0x00, 0x05},
425426 },
426427 {
427428 name: "non-ephemeral empty name",
428429 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
429- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
430+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
430431 },
431432 }
432433
@@ -469,7 +470,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
469470 name: "minimal",
470471 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
471472 want: []byte{
472- 0x00, 0x18,
473+ 0x00, 0x19,
473474 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
474475 0x00, // attached
475476 0x00, // self
@@ -492,7 +493,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
492493 JSON: true,
493494 },
494495 want: []byte{
495- 0x00, 0x18,
496+ 0x00, 0x19,
496497 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
497498 0x00, // attached
498499 0x01, // self
@@ -890,9 +891,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
890891 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
891892 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
892893 // The empty Hello has a fixed 1x1 size before the negotiated output window.
893- want := append(make([]byte, 38), window, 0, 0, 0, 0)
894+ want := append(make([]byte, 39), window, 0, 0, 0, 0, 0, 0)
894895 want[1] = 14
895- want[30], want[32] = 1, 1
896+ want[31], want[33] = 1, 1
896897 got := MarshalHello(hello)
897898 requireBytesEqual(t, want, got)
898899 back, err := UnmarshalHello(got)
@@ -920,7 +921,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
920921 name: "attached",
921922 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
922923 want: []byte{
923- 0x00, 0x18, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
924+ 0x00, 0x19, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
924925 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
925926 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
926927 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -930,7 +931,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
930931 name: "control",
931932 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
932933 want: []byte{
933- 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
934+ 0x00, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
934935 0x00, 0x02, 'l', 's', 0x00, 0x00,
935936 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
936937 },
@@ -989,6 +990,7 @@ func TestOutputResetRequestStrict(t *testing.T) {
989990 }
990991
991992 func TestSessionMetaGoldenAndStrict(t *testing.T) {
993+ lifecycle := domain.SessionLifecycleID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
992994 tests := []struct {
993995 name string
994996 msg SessionMeta
@@ -996,13 +998,29 @@ func TestSessionMetaGoldenAndStrict(t *testing.T) {
996998 }{
997999 {
9981000 name: "one unicode tab",
999- msg: SessionMeta{SessionName: "démo", Active: 0, Tabs: []SessionTabMeta{{Index: 0, Name: "é", Attention: true}}},
1000- want: []byte{0x00, 0x05, 'd', 0xc3, 0xa9, 'm', 'o', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xc3, 0xa9, 0x01},
1001+ msg: SessionMeta{LifecycleID: lifecycle, Revision: 2, SessionName: "démo", ActiveTabID: "tab-é", Tabs: []SessionTabMeta{{ID: "tab-é", Name: "é", Attention: true}}},
1002+ want: []byte{
1003+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
1004+ 0, 0, 0, 0, 0, 0, 0, 2,
1005+ 0, 5, 'd', 0xc3, 0xa9, 'm', 'o',
1006+ 0, 6, 't', 'a', 'b', '-', 0xc3, 0xa9,
1007+ 0, 1,
1008+ 0, 6, 't', 'a', 'b', '-', 0xc3, 0xa9,
1009+ 0, 2, 0xc3, 0xa9, 1,
1010+ },
10011011 },
10021012 {
10031013 name: "multiple tabs",
1004- msg: SessionMeta{SessionName: "work", Active: 1, Tabs: []SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build", Attention: true}}},
1005- want: []byte{0x00, 0x04, 'w', 'o', 'r', 'k', 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x05, 's', 'h', 'e', 'l', 'l', 0x00, 0x00, 0x01, 0x00, 0x05, 'b', 'u', 'i', 'l', 'd', 0x01},
1014+ msg: SessionMeta{LifecycleID: lifecycle, Revision: 7, SessionName: "work", ActiveTabID: "tab-build", Tabs: []SessionTabMeta{{ID: "tab-shell", Name: "shell"}, {ID: "tab-build", Name: "build", Attention: true}}},
1015+ want: []byte{
1016+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
1017+ 0, 0, 0, 0, 0, 0, 0, 7,
1018+ 0, 4, 'w', 'o', 'r', 'k',
1019+ 0, 9, 't', 'a', 'b', '-', 'b', 'u', 'i', 'l', 'd',
1020+ 0, 2,
1021+ 0, 9, 't', 'a', 'b', '-', 's', 'h', 'e', 'l', 'l', 0, 5, 's', 'h', 'e', 'l', 'l', 0,
1022+ 0, 9, 't', 'a', 'b', '-', 'b', 'u', 'i', 'l', 'd', 0, 5, 'b', 'u', 'i', 'l', 'd', 1,
1023+ },
10061024 },
10071025 }
10081026 for _, tt := range tests {
@@ -1019,36 +1037,40 @@ func TestSessionMetaGoldenAndStrict(t *testing.T) {
10191037 }
10201038
10211039 maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16))
1022- payload, err := MarshalSessionMeta(SessionMeta{SessionName: maxString, Tabs: []SessionTabMeta{{Name: maxString}}})
1040+ payload, err := MarshalSessionMeta(SessionMeta{LifecycleID: lifecycle, Revision: 1, SessionName: maxString, ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: maxString}}})
10231041 require.NoError(t, err)
10241042 back, err := UnmarshalSessionMeta(payload)
10251043 require.NoError(t, err)
10261044 require.Equal(t, maxString, back.SessionName)
10271045 require.Equal(t, maxString, back.Tabs[0].Name)
10281046
1047+ validMeta := func() SessionMeta {
1048+ return SessionMeta{LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}}
1049+ }
10291050 invalid := []SessionMeta{
10301051 {},
1031- {Tabs: []SessionTabMeta{{Name: "one"}}, Active: 1},
1032- {Tabs: []SessionTabMeta{{Index: 1, Name: "one"}}},
1033- {Tabs: []SessionTabMeta{{Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}, Active: 0},
1034- {SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), Tabs: []SessionTabMeta{{Name: "one"}}},
1052+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1053+ {LifecycleID: lifecycle, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1054+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "missing", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1055+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}, {ID: "tab", Name: "two"}}},
1056+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}},
1057+ {LifecycleID: lifecycle, Revision: 1, SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
10351058 }
10361059 for _, msg := range invalid {
10371060 _, err := MarshalSessionMeta(msg)
10381061 require.Error(t, err)
10391062 }
10401063
1041- valid, err := MarshalSessionMeta(tests[1].msg)
1064+ valid, err := MarshalSessionMeta(validMeta())
10421065 require.NoError(t, err)
10431066 for _, tt := range []struct {
10441067 name string
10451068 mutate func([]byte)
10461069 wantError error
10471070 }{
1048- {name: "active outside tab count", mutate: func(b []byte) { b[6] = 0; b[7] = 2 }, wantError: ErrInvalidSessionMeta},
1049- // The first tab index is the uint16 at bytes 10–11, after the session
1050- // name, active index, and tab count.
1051- {name: "first tab index out of order", mutate: func(b []byte) { b[10] = 0; b[11] = 1 }, wantError: ErrInvalidSessionMeta},
1071+ {name: "zero lifecycle", mutate: func(b []byte) { clear(b[:16]) }, wantError: ErrInvalidSessionMeta},
1072+ {name: "zero revision", mutate: func(b []byte) { clear(b[16:24]) }, wantError: ErrInvalidSessionMeta},
1073+ {name: "invalid active stable ID", mutate: func(b []byte) { b[32] = ' ' }, wantError: ErrInvalidSessionMeta},
10521074 {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},
10531075 } {
10541076 t.Run(tt.name, func(t *testing.T) {
M internal/usecase/client/client.go +37 -5
@@ -171,6 +171,7 @@ type Dependencies struct {
171171 type AttachRequest struct {
172172 Intent uint8
173173 SessionName string
174+ RenderMode ports.RenderMode
174175 Remote bool
175176 RemoteTarget *domain.RemoteSessionTarget
176177 EnvironmentPolicy ports.EnvironmentPolicy
@@ -227,7 +228,32 @@ func (r *Runner) terminalInput() *terminalInputPump {
227228 }
228229
229230 func validateAttachRequest(request AttachRequest) error {
231+ switch request.RenderMode {
232+ case ports.RenderModeFullTerminal, ports.RenderModeProxiedContent:
233+ default:
234+ return fmt.Errorf("invalid render mode %d", request.RenderMode)
235+ }
236+ switch request.Intent {
237+ case ports.IntentEphemeral:
238+ case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
239+ if request.SessionName == "" {
240+ return domain.ErrInvalidSessionName
241+ }
242+ default:
243+ return fmt.Errorf("invalid session intent %d", request.Intent)
244+ }
245+ if request.SessionName != "" {
246+ if err := domain.ValidateSessionName(request.SessionName); err != nil {
247+ return err
248+ }
249+ }
230250 if request.RemoteTarget == nil {
251+ if request.RenderMode == ports.RenderModeProxiedContent {
252+ return errors.New("vev: proxied attach request requires remote target")
253+ }
254+ if request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {
255+ return errors.New("vev: attach request without remote target requires client-owned environment")
256+ }
231257 return nil
232258 }
233259 if err := request.RemoteTarget.Validate(); err != nil {
@@ -239,6 +265,9 @@ func validateAttachRequest(request AttachRequest) error {
239265 if request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
240266 return errors.New("vev: remote attach target requires daemon-owned environment")
241267 }
268+ if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {
269+ return fmt.Errorf("vev: remote attach target requires attach or resume intent")
270+ }
242271 return nil
243272 }
244273
@@ -246,6 +275,7 @@ func validateAttachRequest(request AttachRequest) error {
246275 // above attach attempts so raw mode remains active while a live client process
247276 // redials a lost link.
248277 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
278+ request = cloneAttachRequest(request)
249279 if err := validateAttachRequest(request); err != nil {
250280 return err
251281 }
@@ -378,10 +408,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
378408 return errors.New("vev: dialer returned nil transport")
379409 }
380410 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
381- connection, err := NewSessionConnection(transport, SessionTarget{
382- Intent: attemptRequest.Intent,
383- SessionName: attemptRequest.SessionName,
384- })
411+ connection, err := NewSessionConnection(transport, attemptRequest)
385412 if err != nil {
386413 stopHandshakeTransport()
387414 finishHandshake()
@@ -430,6 +457,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
430457 if handoffErr != nil {
431458 return handoffErr
432459 }
460+ nextRequest = cloneAttachRequest(nextRequest)
433461 if err := validateAttachRequest(nextRequest); err != nil {
434462 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)
435463 }
@@ -679,7 +707,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
679707 enterRaw := a.enterRaw
680708 reconnect := a.reconnect
681709 linkEvents := a.linkEvents
682- remote := a.remote || a.request.Remote
710+ remote := a.remote || request.Remote
683711 clipboard := a.runner.clipboard
684712 log := a.runner.logger
685713 observer := a.runner.runtimeObserver
@@ -737,6 +765,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
737765 ClientID: clientID,
738766 ResumeToken: resumeToken,
739767 Name: name,
768+ RenderMode: request.RenderMode,
740769 Size: size,
741770 TermEnv: termEnv,
742771 Cwd: cwd,
@@ -771,6 +800,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
771800 if derr != nil {
772801 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
773802 }
803+ if welcome.RenderMode != request.RenderMode {
804+ return result(false, fmt.Errorf("vev: daemon accepted render mode %d, want %d", welcome.RenderMode, request.RenderMode))
805+ }
774806 resumeToken = welcome.ResumeToken
775807 name = welcome.SessionName
776808 ms.welcomed = true
M internal/usecase/client/client_test.go +151 -23
@@ -789,11 +789,15 @@ func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
789789 type recordingTransport struct {
790790 recvs []recvItem
791791 sends []ports.Frame
792+ onSend func(ports.Frame)
792793 closed atomic.Int32
793794 }
794795
795796 func (t *recordingTransport) Send(f ports.Frame) error {
796797 t.sends = append(t.sends, f)
798+ if t.onSend != nil {
799+ t.onSend(f)
800+ }
797801 return nil
798802 }
799803
@@ -846,33 +850,157 @@ func welcomeFrame(token uint64) ports.Frame {
846850 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
847851 }
848852
849-func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
853+func TestRunReconnectsPreservesRichRemoteTarget(t *testing.T) {
854+ lifecycle := domain.SessionLifecycleID{}
855+ lifecycle[0] = 1
856+ otherLifecycle := domain.SessionLifecycleID{}
857+ otherLifecycle[0] = 2
858+ tests := []struct {
859+ name string
860+ target domain.RemoteSessionTarget
861+ }{
862+ {
863+ name: "live tab",
864+ target: domain.RemoteSessionTarget{
865+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
866+ SessionName: "main", LiveTabID: "tab-live",
867+ },
868+ },
869+ {
870+ name: "stopped tab",
871+ target: domain.RemoteSessionTarget{
872+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: otherLifecycle,
873+ SessionName: "main", Stopped: true,
874+ StoppedTab: domain.NewOrdinalTabSelector(1, "shell", 2),
875+ },
876+ },
877+ }
878+
879+ for _, tt := range tests {
880+ t.Run(tt.name, func(t *testing.T) {
881+ term := newRunTerminal()
882+ defer term.in.unblock()
883+ wantTarget := tt.target
884+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
885+ tr1.onSend = func(frame ports.Frame) {
886+ if frame.Type != ports.MsgHello {
887+ return
888+ }
889+ tt.target.Endpoint = "mutated.invalid"
890+ tt.target.LifecycleID = domain.SessionLifecycleID{}
891+ tt.target.LiveTabID = "mutated-tab"
892+ }
893+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
894+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
895+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
896+ request := client.AttachRequest{
897+ Intent: ports.IntentAttach,
898+ SessionName: "main",
899+ Remote: true,
900+ RemoteTarget: &tt.target,
901+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
902+ }
903+
904+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), request)
905+ require.NoError(t, err)
906+ require.Equal(t, int32(3), d.calls.Load())
907+ require.Equal(t, int32(1), term.rawCount.Load())
908+ require.Equal(t, int32(1), term.restoreCount.Load())
909+
910+ h1 := helloFromSend(t, tr1)
911+ h2 := helloFromSend(t, tr2)
912+ h3 := helloFromSend(t, tr3)
913+ require.Equal(t, ports.IntentAttach, h1.Intent)
914+ require.Zero(t, h1.ResumeToken)
915+ require.Equal(t, ports.IntentResume, h2.Intent)
916+ require.Equal(t, uint64(11), h2.ResumeToken)
917+ require.Equal(t, ports.IntentResume, h3.Intent)
918+ require.Equal(t, uint64(22), h3.ResumeToken)
919+ for _, hello := range []ports.Hello{h1, h2, h3} {
920+ require.Equal(t, &wantTarget, hello.RemoteTarget)
921+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, hello.EnvironmentPolicy)
922+ }
923+ require.Equal(t, h1.ClientID, h2.ClientID)
924+ require.Equal(t, h1.ClientID, h3.ClientID)
925+ require.Contains(t, term.out.String(), "reconnecting through SSH")
926+ })
927+ }
928+}
929+
930+func TestRunSendsRequestedProxiedRenderMode(t *testing.T) {
931+ lifecycle := domain.SessionLifecycleID{1}
932+ target := domain.RemoteSessionTarget{
933+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
934+ SessionName: "main", LiveTabID: "tab-live",
935+ }
850936 term := newRunTerminal()
851937 defer term.in.unblock()
852- tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
853- tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
854- tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
855- d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
856-
857- err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
938+ tr := &recordingTransport{recvs: []recvItem{
939+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
940+ SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
941+ }))},
942+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
943+ }}
944+ deps := testDependencies(&sequenceDialer{trs: []ports.Transport{tr}}, term, realClock{}, nil, nil)
945+ err := runTestClient(context.Background(), deps, client.AttachRequest{
946+ Intent: ports.IntentAttach, SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
947+ RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
948+ })
858949 require.NoError(t, err)
859- require.Equal(t, int32(3), d.calls.Load())
860- require.Equal(t, int32(1), term.rawCount.Load())
861- require.Equal(t, int32(1), term.restoreCount.Load())
950+ require.Equal(t, ports.RenderModeProxiedContent, helloFromSend(t, tr).RenderMode)
951+}
862952
863- h1 := helloFromSend(t, tr1)
864- h2 := helloFromSend(t, tr2)
865- h3 := helloFromSend(t, tr3)
866- require.Equal(t, ports.IntentAttach, h1.Intent)
867- require.Zero(t, h1.ResumeToken)
868- require.Equal(t, ports.IntentResume, h2.Intent)
869- require.Equal(t, uint64(11), h2.ResumeToken)
870- require.Equal(t, ports.IntentResume, h3.Intent)
871- require.Equal(t, uint64(22), h3.ResumeToken)
872- require.Equal(t, h1.ClientID, h2.ClientID)
873- require.Equal(t, h1.ClientID, h3.ClientID)
874- require.Contains(t, term.out.String(), "reconnecting…")
875- require.Contains(t, term.out.String(), "\x1b[2K")
953+func TestRunnerRejectsInvalidAttachRequestBeforeHello(t *testing.T) {
954+ lifecycle := domain.SessionLifecycleID{}
955+ lifecycle[0] = 1
956+ validTarget := domain.RemoteSessionTarget{
957+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
958+ SessionName: "main", LiveTabID: "tab-live",
959+ }
960+ tests := []struct {
961+ name string
962+ request client.AttachRequest
963+ wantErr string
964+ }{
965+ {
966+ name: "missing daemon-owned policy",
967+ request: client.AttachRequest{
968+ Intent: ports.IntentAttach,
969+ SessionName: "main",
970+ RemoteTarget: &validTarget,
971+ },
972+ wantErr: "remote attach target requires daemon-owned environment",
973+ },
974+ {
975+ name: "target session mismatch",
976+ request: client.AttachRequest{
977+ Intent: ports.IntentAttach,
978+ SessionName: "other",
979+ RemoteTarget: &validTarget,
980+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
981+ },
982+ wantErr: "remote attach target session mismatch",
983+ },
984+ {
985+ name: "malformed target",
986+ request: client.AttachRequest{
987+ Intent: ports.IntentAttach,
988+ SessionName: "main",
989+ RemoteTarget: &domain.RemoteSessionTarget{SessionName: "main", LiveTabID: "tab-live"},
990+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
991+ },
992+ wantErr: "invalid remote attach target",
993+ },
994+ }
995+
996+ for _, tt := range tests {
997+ t.Run(tt.name, func(t *testing.T) {
998+ dialer := &sequenceDialer{}
999+ err := client.NewRunner(client.Dependencies{Dialer: dialer}).Run(context.Background(), tt.request)
1000+ require.ErrorContains(t, err, tt.wantErr)
1001+ require.Zero(t, dialer.calls.Load(), "invalid request must be rejected before dialing and Hello")
1002+ })
1003+ }
8761004 }
8771005
8781006 func TestAttachRememberRemoteHost(t *testing.T) {
M internal/usecase/client/session_connection.go +17 -32
@@ -4,52 +4,38 @@ import (
44 "errors"
55 "fmt"
66
7- "github.com/bnema/vev/internal/domain"
87 "github.com/bnema/vev/internal/ports"
98 )
109
1110 var errNilSessionTransport = errors.New("client: nil session transport")
1211
13-// SessionTarget is the validated daemon-facing session selection. Endpoint
14-// details are resolved before a transport is opened and never enter this type.
15-type SessionTarget struct {
16- Intent uint8
17- SessionName string
18-}
19-
20-func (t SessionTarget) validate() error {
21- switch t.Intent {
22- case ports.IntentEphemeral:
23- if t.SessionName == "" {
24- return nil
25- }
26- case ports.IntentNew, ports.IntentAttach, ports.IntentResume:
27- if t.SessionName == "" {
28- return domain.ErrInvalidSessionName
29- }
30- default:
31- return fmt.Errorf("invalid session intent %d", t.Intent)
12+func cloneAttachRequest(request AttachRequest) AttachRequest {
13+ if request.RemoteTarget != nil {
14+ target := *request.RemoteTarget
15+ request.RemoteTarget = &target
3216 }
33- return domain.ValidateSessionName(t.SessionName)
17+ return request
3418 }
3519
36-// SessionConnection owns one already-open transport and its daemon-facing
37-// session target. Local sockets and remote carriages use this same owner.
20+// SessionConnection owns one already-open transport and the complete
21+// validated daemon-facing attach request. Local sockets and remote carriages
22+// use this same owner.
3823 type SessionConnection struct {
3924 transport ports.Transport
40- target SessionTarget
25+ request AttachRequest
4126 }
4227
4328 // NewSessionConnection creates a connection for an already-selected transport.
44-// Target validation happens before the connection is used for a Hello.
45-func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {
29+// Request validation happens before the connection is used for a Hello.
30+func NewSessionConnection(transport ports.Transport, request AttachRequest) (*SessionConnection, error) {
4631 if transport == nil {
4732 return nil, errNilSessionTransport
4833 }
49- if err := target.validate(); err != nil {
50- return nil, fmt.Errorf("invalid session target: %w", err)
34+ request = cloneAttachRequest(request)
35+ if err := validateAttachRequest(request); err != nil {
36+ return nil, fmt.Errorf("invalid attach request: %w", err)
5137 }
52- return &SessionConnection{transport: transport, target: target}, nil
38+ return &SessionConnection{transport: transport, request: request}, nil
5339 }
5440
5541 // Transport returns the owned carriage for this connection.
@@ -60,13 +46,12 @@ func (c *SessionConnection) Transport() ports.Transport {
6046 return c.transport
6147 }
6248
63-// AttachRequest returns the common request shape used after transport
64-// selection. It contains no remote/proxy semantic.
49+// AttachRequest returns the complete request retained for this connection.
6550 func (c *SessionConnection) AttachRequest() AttachRequest {
6651 if c == nil {
6752 return AttachRequest{}
6853 }
69- return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}
54+ return cloneAttachRequest(c.request)
7055 }
7156
7257 // Close releases the owned transport.
M internal/usecase/client/session_connection_test.go +40 -16
@@ -3,46 +3,70 @@ package client_test
33 import (
44 "testing"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 portsmocks "github.com/bnema/vev/internal/ports/mocks"
89 "github.com/bnema/vev/internal/usecase/client"
910 "github.com/stretchr/testify/require"
1011 )
1112
12-func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {
13- target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}
13+func TestSessionConnectionOwnsCompleteAttachRequestForLocalAndRemoteTransports(t *testing.T) {
14+ var lifecycle domain.SessionLifecycleID
15+ lifecycle[0] = 1
16+ target := &domain.RemoteSessionTarget{
17+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
18+ SessionName: "work", LiveTabID: "tab-1",
19+ }
20+ wantTarget := *target
21+ request := client.AttachRequest{
22+ Intent: ports.IntentAttach,
23+ SessionName: "work",
24+ RenderMode: ports.RenderModeProxiedContent,
25+ Remote: true,
26+ RemoteTarget: target,
27+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
28+ }
29+ wantRequest := request
30+ wantRequest.RemoteTarget = &wantTarget
1431 local := portsmocks.NewMockTransport(t)
1532 remote := portsmocks.NewMockTransport(t)
1633
17- localConnection, err := client.NewSessionConnection(local, target)
34+ localConnection, err := client.NewSessionConnection(local, request)
1835 require.NoError(t, err)
19- remoteConnection, err := client.NewSessionConnection(remote, target)
36+ remoteConnection, err := client.NewSessionConnection(remote, request)
2037 require.NoError(t, err)
2138
39+ target.LiveTabID = "mutated-tab"
40+ target.LifecycleID = domain.SessionLifecycleID{}
41+ returned := localConnection.AttachRequest()
42+ returned.RemoteTarget.LiveTabID = "mutated-returned-tab"
43+
2244 require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())
23- require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}, localConnection.AttachRequest())
45+ require.Equal(t, wantRequest, localConnection.AttachRequest())
2446 require.Same(t, local, localConnection.Transport())
2547 require.Same(t, remote, remoteConnection.Transport())
2648 }
2749
28-func TestSessionTargetValidation(t *testing.T) {
50+func TestSessionConnectionAttachRequestValidation(t *testing.T) {
2951 tests := []struct {
3052 name string
31- target client.SessionTarget
53+ request client.AttachRequest
3254 wantErr bool
3355 }{
34- {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},
35- {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},
36- {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},
37- {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},
38- {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},
39- {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
40- {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "work"}, wantErr: true},
56+ {name: "ephemeral", request: client.AttachRequest{Intent: ports.IntentEphemeral}},
57+ {name: "new", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work"}},
58+ {name: "attach", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
59+ {name: "resume", request: client.AttachRequest{Intent: ports.IntentResume, SessionName: "work"}},
60+ {name: "missing named session", request: client.AttachRequest{Intent: ports.IntentAttach}, wantErr: true},
61+ {name: "unsafe session", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
62+ {name: "unknown intent", request: client.AttachRequest{Intent: 99, SessionName: "work"}, wantErr: true},
63+ {name: "invalid render mode", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", RenderMode: ports.RenderMode(99)}, wantErr: true},
64+ {name: "proxied without remote target", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", RenderMode: ports.RenderModeProxiedContent}, wantErr: true},
4165 }
4266 for _, tt := range tests {
4367 t.Run(tt.name, func(t *testing.T) {
4468 transport := portsmocks.NewMockTransport(t)
45- _, err := client.NewSessionConnection(transport, tt.target)
69+ _, err := client.NewSessionConnection(transport, tt.request)
4670 if tt.wantErr {
4771 require.Error(t, err)
4872 return
@@ -53,6 +77,6 @@ func TestSessionTargetValidation(t *testing.T) {
5377 }
5478
5579 func TestSessionConnectionRequiresTransport(t *testing.T) {
56- _, err := client.NewSessionConnection(nil, client.SessionTarget{Intent: ports.IntentEphemeral})
80+ _, err := client.NewSessionConnection(nil, client.AttachRequest{Intent: ports.IntentEphemeral})
5781 require.Error(t, err)
5882 }
A internal/usecase/daemon/ansi_content_replay_phase0_test.go +434 -0
@@ -0,0 +1,434 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "testing"
6+ "unicode/utf8"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// phase0ReplayCapture is deliberately test-only. It models the proposed
14+// remote-content boundary: ANSI is consumed by a private VT screen, then the
15+// owned visible grid and cursor are captured for local composition.
16+type phase0ReplayCapture struct {
17+ frame renderer.Frame
18+ cursor vt.CursorSnapshot
19+}
20+
21+func capturePhase0Replay(screen *vt.Screen) phase0ReplayCapture {
22+ snapshot := screen.Snapshot()
23+ frame := renderer.NewFrame(snapshot.Columns(), snapshot.Rows())
24+ for y := range snapshot.Rows() {
25+ copy(frame.Row(y), snapshot.Row(y))
26+ }
27+ return phase0ReplayCapture{frame: frame, cursor: snapshot.Cursor()}
28+}
29+
30+func composePhase0Replay(capture phase0ReplayCapture) renderer.Frame {
31+ // Composition receives cells, not the remote ANSI stream. The private remote
32+ // surface occupies only the content rows, leaving locally owned chrome above
33+ // and below it.
34+ content := capture.frame
35+ composed := renderer.NewFrame(content.Width, content.Height+2)
36+ writePhase0Chrome(&composed, 0, "LOCAL TOP")
37+ for y := range content.Height {
38+ copy(composed.Row(y+1), content.Row(y))
39+ }
40+ writePhase0Chrome(&composed, composed.Height-1, "LOCAL BOTTOM")
41+ return composed
42+}
43+
44+func writePhase0Chrome(frame *renderer.Frame, row int, label string) {
45+ for column, r := range label {
46+ if column >= frame.Width {
47+ return
48+ }
49+ frame.Set(column, row, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
50+ }
51+}
52+
53+// replayPhase0ANSI intentionally splits every byte of CSI/OSC controls across
54+// separate writes while keeping UTF-8 runes together. vt.Screen buffers split
55+// escape sequences, but deliberately does not buffer incomplete UTF-8 runes.
56+func replayPhase0ANSI(screen *vt.Screen, data []byte) {
57+ for len(data) > 0 {
58+ if data[0] != 0x1b {
59+ _, size := utf8.DecodeRune(data)
60+ if size == 0 {
61+ size = 1
62+ }
63+ screen.Write(data[:size])
64+ data = data[size:]
65+ continue
66+ }
67+
68+ screen.Write(data[:1])
69+ data = data[1:]
70+ if len(data) == 0 {
71+ return
72+ }
73+ kind := data[0]
74+ screen.Write(data[:1])
75+ data = data[1:]
76+
77+ switch kind {
78+ case '[':
79+ for len(data) > 0 {
80+ b := data[0]
81+ screen.Write(data[:1])
82+ data = data[1:]
83+ if b >= '@' && b <= '~' {
84+ break
85+ }
86+ }
87+ case ']':
88+ for len(data) > 0 {
89+ b := data[0]
90+ screen.Write(data[:1])
91+ data = data[1:]
92+ if b == 0x07 {
93+ break
94+ }
95+ if b == 0x1b && len(data) > 0 && data[0] == '\\' {
96+ screen.Write(data[:1])
97+ data = data[1:]
98+ break
99+ }
100+ }
101+ case 'P', '_', '^', 'X':
102+ for len(data) > 0 {
103+ b := data[0]
104+ screen.Write(data[:1])
105+ data = data[1:]
106+ if b == 0x1b && len(data) > 0 && data[0] == '\\' {
107+ screen.Write(data[:1])
108+ data = data[1:]
109+ break
110+ }
111+ }
112+ }
113+ }
114+}
115+
116+func phase0CursorTail(row, col, style int, visible bool) []byte {
117+ tail := []byte(fmt.Sprintf("\x1b[%d;%dH\x1b[%d q", row+1, col+1, style))
118+ if visible {
119+ return append(tail, "\x1b[?25h"...)
120+ }
121+ return append(tail, "\x1b[?25l"...)
122+}
123+
124+func phase0StyledFrame() renderer.Frame {
125+ styled := renderer.Style{
126+ Bold: true,
127+ Italic: true,
128+ Attrs: renderer.AttrUnderline,
129+ UnderlineStyle: renderer.UnderlineCurly,
130+ Foreground: -1,
131+ Background: -1,
132+ HasForegroundRGB: true,
133+ ForegroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
134+ HasBackgroundRGB: true,
135+ BackgroundRGB: renderer.RGB{R: 200, G: 100, B: 50},
136+ }
137+ indexed := renderer.Style{Foreground: 82, Background: 17}
138+
139+ frame := renderer.NewFrame(10, 2)
140+ frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: styled})
141+ frame.Set(1, 0, renderer.Cell{Rune: '界', Style: styled})
142+ frame.Set(2, 0, renderer.Cell{Continuation: true, Style: styled})
143+ frame.Set(3, 0, renderer.Cell{Rune: 'A', Style: indexed})
144+ frame.Set(0, 1, renderer.Cell{Rune: 'q', Style: renderer.DefaultStyle()})
145+ return frame
146+}
147+
148+func requirePhase0Frame(t *testing.T, got, want renderer.Frame) {
149+ t.Helper()
150+ require.Equal(t, want.Width, got.Width)
151+ require.Equal(t, want.Height, got.Height)
152+ for y := range want.Height {
153+ for x := range want.Width {
154+ require.Equal(t, want.At(x, y), got.At(x, y), "cell(%d,%d)", x, y)
155+ }
156+ }
157+}
158+
159+func requirePhase0ComposedContent(t *testing.T, composed, content renderer.Frame) {
160+ t.Helper()
161+ require.Equal(t, content.Width, composed.Width)
162+ require.Equal(t, content.Height+2, composed.Height)
163+ for y := range content.Height {
164+ for x := range content.Width {
165+ require.Equal(t, content.At(x, y), composed.At(x, y+1), "content cell(%d,%d)", x, y)
166+ }
167+ }
168+ for column, r := range "LOCAL TOP" {
169+ if column >= composed.Width {
170+ break
171+ }
172+ require.Equal(t, r, composed.At(column, 0).Rune, "top chrome column %d", column)
173+ }
174+ for column, r := range "LOCAL BOTTOM" {
175+ if column >= composed.Width {
176+ break
177+ }
178+ require.Equal(t, r, composed.At(column, composed.Height-1).Rune, "bottom chrome column %d", column)
179+ }
180+}
181+
182+func TestPhase0RemoteANSIReplayFullAndIncremental(t *testing.T) {
183+ initial := phase0StyledFrame()
184+ updated := initial.Clone()
185+ updatedStyle := renderer.Style{
186+ Bold: true,
187+ Attrs: renderer.AttrUnderline,
188+ UnderlineStyle: renderer.UnderlineDouble,
189+ Foreground: -1,
190+ Background: -1,
191+ HasForegroundRGB: true,
192+ ForegroundRGB: renderer.RGB{R: 90, G: 80, B: 70},
193+ }
194+ updated.Set(0, 0, renderer.Cell{Rune: 'Z', Style: updatedStyle})
195+ updated.Set(1, 0, renderer.Cell{Rune: '好', Style: updatedStyle})
196+ updated.Set(2, 0, renderer.Cell{Continuation: true, Style: updatedStyle})
197+
198+ remoteRenderer := renderer.New(renderer.Capabilities{SynchronizedOutput: true})
199+ privateScreen := vt.NewScreen(initial.Width, initial.Height)
200+ // These callbacks are the security boundary under test: remote content is
201+ // replayed into a private screen that cannot answer, notify, ring, or write
202+ // the host clipboard.
203+ privateScreen.OnResponse = nil
204+ privateScreen.OnBell = nil
205+ privateScreen.OnNotify = nil
206+ privateScreen.OnProgress = nil
207+ privateScreen.OnClipboard = nil
208+
209+ full, err := remoteRenderer.Draw(initial, []renderer.Damage{renderer.FullRedraw()})
210+ require.NoError(t, err)
211+ require.Contains(t, string(full), renderer.SyncStartCSI)
212+ require.Contains(t, string(full), renderer.SyncEndCSI)
213+ full = append(full, phase0CursorTail(0, 3, 2, true)...)
214+ replayPhase0ANSI(privateScreen, full)
215+
216+ fullCapture := capturePhase0Replay(privateScreen)
217+ requirePhase0Frame(t, fullCapture.frame, initial)
218+ fullComposition := composePhase0Replay(fullCapture)
219+ requirePhase0ComposedContent(t, fullComposition, initial)
220+ require.Equal(t, vt.CursorSnapshot{Row: 0, Col: 3, Visible: true, Style: 2, StyleSet: true}, fullCapture.cursor)
221+ require.False(t, privateScreen.Snapshot().Modes().SynchronizedUpdate)
222+
223+ // A composed frame is rendered by the local renderer. The remote byte
224+ // stream is not passed to this renderer or to a transport.
225+ localRenderer := renderer.New(renderer.Capabilities{})
226+ localBytes, err := localRenderer.Draw(fullComposition, []renderer.Damage{renderer.FullRedraw()})
227+ require.NoError(t, err)
228+ localScreen := vt.NewScreen(fullComposition.Width, fullComposition.Height)
229+ replayPhase0ANSI(localScreen, localBytes)
230+ requirePhase0Frame(t, localScreen.Frame, fullComposition)
231+
232+ incremental, err := remoteRenderer.Draw(updated, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 3, Height: 1}})
233+ require.NoError(t, err)
234+ require.Contains(t, string(incremental), renderer.SyncStartCSI)
235+ require.Contains(t, string(incremental), renderer.SyncEndCSI)
236+ incremental = append(incremental, phase0CursorTail(1, 5, 3, false)...)
237+ replayPhase0ANSI(privateScreen, incremental)
238+
239+ incrementalCapture := capturePhase0Replay(privateScreen)
240+ requirePhase0Frame(t, incrementalCapture.frame, updated)
241+ requirePhase0ComposedContent(t, composePhase0Replay(incrementalCapture), updated)
242+ require.Equal(t, vt.CursorSnapshot{Row: 1, Col: 5, Visible: false, Style: 3, StyleSet: true}, incrementalCapture.cursor)
243+ require.False(t, privateScreen.Snapshot().Modes().SynchronizedUpdate)
244+
245+ noOp, err := remoteRenderer.Draw(updated, nil)
246+ require.NoError(t, err)
247+ require.Empty(t, noOp, "the remote renderer shadow must advance only once per emitted full/incremental frame")
248+}
249+
250+func TestPhase0PrivateReplaySuppressesHostSideEffects(t *testing.T) {
251+ screen := vt.NewScreen(16, 2)
252+ screen.OnResponse = nil
253+ screen.OnBell = nil
254+ screen.OnNotify = nil
255+ screen.OnProgress = nil
256+ screen.OnClipboard = nil
257+ screen.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true)
258+
259+ data := []byte("safe")
260+ data = append(data, "\x1b]52;c;Y2xpcA==\x07"...)
261+ data = append(data, "\x1b[5n\x1b[c\x1b[6n\x1b]10;?\x07"...)
262+ data = append(data, "\x1b]9;remote notification\x07"...)
263+ data = append(data, "\x1b]777;notify;title;body\x07"...)
264+ data = append(data, "\x1b]9;4;2;0\x07\x1b]9;4;0\x07\a"...)
265+ data = append(data, "\x1bP1;2|dcs-payload\x1b\\"...)
266+ data = append(data, "\x1b_Ga=T,f=100;image-payload\x1b\\"...)
267+ replayPhase0ANSI(screen, data)
268+
269+ expected := renderer.NewFrame(16, 2)
270+ expected.Set(0, 0, renderer.Cell{Rune: 's', Style: renderer.DefaultStyle()})
271+ expected.Set(1, 0, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
272+ expected.Set(2, 0, renderer.Cell{Rune: 'f', Style: renderer.DefaultStyle()})
273+ expected.Set(3, 0, renderer.Cell{Rune: 'e', Style: renderer.DefaultStyle()})
274+ capture := capturePhase0Replay(screen)
275+ requirePhase0Frame(t, capture.frame, expected)
276+ composition := composePhase0Replay(capture)
277+ requirePhase0ComposedContent(t, composition, expected)
278+ require.Equal(t, 0, capture.cursor.Row)
279+ require.Equal(t, 4, capture.cursor.Col)
280+ require.Empty(t, screen.TerminalTitle())
281+
282+ // A control sequence is consumed before composition and cannot appear in
283+ // the locally rendered result.
284+ localBytes, err := renderer.New(renderer.Capabilities{}).Draw(composition, []renderer.Damage{renderer.FullRedraw()})
285+ require.NoError(t, err)
286+ require.NotContains(t, string(localBytes), "\x1b]")
287+ require.NotContains(t, string(localBytes), "52;")
288+}
289+
290+type phase0OrderedFrame struct {
291+ metadata bool
292+ stateBearing bool
293+ epoch uint64
294+ base uint64
295+ next uint64
296+ full bool
297+}
298+
299+type phase0OrderedFrameContract struct {
300+ metadataSeen bool
301+ contentSeen bool
302+ epoch uint64
303+ next uint64
304+}
305+
306+func (c *phase0OrderedFrameContract) accept(frame phase0OrderedFrame) error {
307+ if frame.metadata {
308+ if frame.stateBearing || frame.full {
309+ return fmt.Errorf("metadata frame carries output state")
310+ }
311+ if c.metadataSeen || c.contentSeen {
312+ return fmt.Errorf("metadata must be unique and precede content")
313+ }
314+ c.metadataSeen = true
315+ return nil
316+ }
317+ if !frame.stateBearing {
318+ // Side-effect-like output does not participate in the content chain.
319+ return nil
320+ }
321+ if !c.metadataSeen {
322+ return fmt.Errorf("metadata must precede first full content")
323+ }
324+ if frame.epoch == 0 || frame.next == 0 {
325+ return fmt.Errorf("state-bearing output has invalid state numbers")
326+ }
327+ if !c.contentSeen {
328+ if !frame.full || frame.base != 0 {
329+ return fmt.Errorf("first content must be a full reset from base zero")
330+ }
331+ c.contentSeen = true
332+ c.epoch, c.next = frame.epoch, frame.next
333+ return nil
334+ }
335+ if frame.full {
336+ if frame.base != 0 {
337+ return fmt.Errorf("full content must reset from base zero")
338+ }
339+ if frame.epoch <= c.epoch {
340+ return fmt.Errorf("full content reset epoch %d is not newer than %d", frame.epoch, c.epoch)
341+ }
342+ c.epoch, c.next = frame.epoch, frame.next
343+ return nil
344+ }
345+ if frame.epoch != c.epoch {
346+ return fmt.Errorf("epoch changed without a full reset")
347+ }
348+ if frame.base != c.next {
349+ return fmt.Errorf("state-bearing output gap: base=%d want=%d", frame.base, c.next)
350+ }
351+ if frame.next != frame.base+1 {
352+ return fmt.Errorf("state-bearing output must advance by one")
353+ }
354+ c.next = frame.next
355+ return nil
356+}
357+
358+func acceptPhase0Frames(frames []phase0OrderedFrame) error {
359+ var contract phase0OrderedFrameContract
360+ for i, frame := range frames {
361+ if err := contract.accept(frame); err != nil {
362+ return fmt.Errorf("frame %d: %w", i, err)
363+ }
364+ }
365+ return nil
366+}
367+
368+func TestPhase0OrderedFrameContract(t *testing.T) {
369+ metadata := phase0OrderedFrame{metadata: true}
370+ full := phase0OrderedFrame{stateBearing: true, epoch: 1, base: 0, next: 1, full: true}
371+
372+ t.Run("metadata full contiguous deltas and full reset are accepted", func(t *testing.T) {
373+ require.NoError(t, acceptPhase0Frames([]phase0OrderedFrame{
374+ {stateBearing: false},
375+ metadata,
376+ full,
377+ {stateBearing: false},
378+ {stateBearing: true, epoch: 1, base: 1, next: 2},
379+ {stateBearing: true, epoch: 2, base: 0, next: 1, full: true},
380+ {stateBearing: true, epoch: 2, base: 1, next: 2},
381+ }))
382+ })
383+
384+ tests := []struct {
385+ name string
386+ frames []phase0OrderedFrame
387+ }{
388+ {
389+ name: "first full content before metadata",
390+ frames: []phase0OrderedFrame{full},
391+ },
392+ {
393+ name: "incremental content before first full",
394+ frames: []phase0OrderedFrame{metadata, {stateBearing: true, epoch: 1, base: 0, next: 1}},
395+ },
396+ {
397+ name: "state-bearing gap",
398+ frames: []phase0OrderedFrame{
399+ metadata, full,
400+ {stateBearing: true, epoch: 1, base: 3, next: 4},
401+ },
402+ },
403+ {
404+ name: "epoch change without full reset",
405+ frames: []phase0OrderedFrame{
406+ metadata, full,
407+ {stateBearing: true, epoch: 2, base: 1, next: 2},
408+ },
409+ },
410+ {
411+ name: "full content with nonzero base",
412+ frames: []phase0OrderedFrame{
413+ metadata, full,
414+ {stateBearing: true, epoch: 2, base: 1, next: 2, full: true},
415+ },
416+ },
417+ {
418+ name: "stale full reset",
419+ frames: []phase0OrderedFrame{
420+ metadata, full,
421+ {stateBearing: true, epoch: 1, base: 0, next: 1, full: true},
422+ },
423+ },
424+ {
425+ name: "duplicate metadata after content",
426+ frames: []phase0OrderedFrame{metadata, full, metadata},
427+ },
428+ }
429+ for _, tt := range tests {
430+ t.Run(tt.name, func(t *testing.T) {
431+ require.Error(t, acceptPhase0Frames(tt.frames))
432+ })
433+ }
434+}
M internal/usecase/daemon/attachment_connection.go +70 -32
@@ -3,11 +3,11 @@ package daemon
33 import "github.com/bnema/vev/internal/ports"
44
55 // attachmentConnectionToken is the exact capability for one registered
6-// attachment connection incarnation. It is invalid as soon as any of the
7-// session, attachment membership, connection generation, transport link, or
6+// attachment connection incarnation. It is invalid as soon as the owner,
7+// attachment membership, connection generation, transport link, or
88 // coordinator lease changes.
99 type attachmentConnectionToken struct {
10- sess *session
10+ owner attachmentOwner
1111 ac *attachedClient
1212 generation uint64
1313 transport transportSnapshot
@@ -18,11 +18,18 @@ type attachmentConnectionToken struct {
1818 rebase bool
1919 }
2020
21+// attachmentToken retains the local-session constructor for the existing
22+// local route. Remote-view token construction is added with remote attachment
23+// membership in Phase 4; it must not manufacture a local-session capability.
2124 func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
22- if entry == nil || entry.core() == nil || ac == nil || tr == nil {
25+ return attachmentOwnerToken(entry, ac, tr)
26+}
27+
28+func attachmentOwnerToken(owner attachmentOwner, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
29+ if owner == nil || ac == nil || tr == nil {
2330 return attachmentConnectionToken{}
2431 }
25- // Capture one concrete link incarnation before taking the session lock. The
32+ // Capture one concrete link incarnation before taking the owner lock. The
2633 // same snapshot is revalidated below; do not take a second snapshot and
2734 // accidentally bind a replacement link.
2835 transport := ac.transportSnapshot()
@@ -32,23 +39,38 @@ func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) att
3239 if ac.beforeAttachmentTokenValidation != nil {
3340 ac.beforeAttachmentTokenValidation()
3441 }
35- core := entry.core()
36- core.mu.Lock()
37- if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
42+
43+ token := attachmentConnectionToken{owner: owner, ac: ac, transport: transport}
44+ switch entry := owner.(type) {
45+ case *session:
46+ if entry.core() == nil {
47+ return attachmentConnectionToken{}
48+ }
49+ core := entry.core()
50+ core.mu.Lock()
51+ if !attachmentRegisteredLocked(entry, ac) ||
52+ !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) ||
53+ !ac.transportSnapshotCurrent(transport) {
54+ core.mu.Unlock()
55+ return attachmentConnectionToken{}
56+ }
57+ token.generation = ac.connectionGeneration.Load()
58+ if rc := core.coordinator.Load(); rc != nil {
59+ token.lease = rc.attachmentLease(ac)
60+ }
3861 core.mu.Unlock()
62+ case *remoteView:
63+ entry.mu.Lock()
64+ _, registered := entry.attachments[ac]
65+ if !registered || !sameAttachmentOwner(ac.currentAttachmentOwner(), owner) || !ac.transportSnapshotCurrent(transport) {
66+ entry.mu.Unlock()
67+ return attachmentConnectionToken{}
68+ }
69+ token.generation = ac.connectionGeneration.Load()
70+ entry.mu.Unlock()
71+ default:
3972 return attachmentConnectionToken{}
4073 }
41- generation := ac.connectionGeneration.Load()
42- token := attachmentConnectionToken{
43- sess: entry,
44- ac: ac,
45- generation: generation,
46- transport: transport,
47- }
48- if rc := core.coordinator.Load(); rc != nil {
49- token.lease = rc.attachmentLease(ac)
50- }
51- core.mu.Unlock()
5274 if !token.current() {
5375 return attachmentConnectionToken{}
5476 }
@@ -60,27 +82,39 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
6082 return attachmentToken(s, ac, tr)
6183 }
6284
63-// current validates every immutable identity captured by the token. Membership
64-// is the sole authority; there is no owner, replacement, or compatibility role.
85+// localSession narrows a token only for explicitly local operations. It is
86+// intentionally nil for a remote owner, so local PTY/persistence paths fail
87+// closed rather than treating remote metadata as a local session.
88+func (t attachmentConnectionToken) localSession() *session {
89+ return localSession(t.owner)
90+}
91+
92+// current validates every immutable identity captured by the token. The owner
93+// binding is the sole mutable authority; the local session is only a derived
94+// capability for local attachment membership and coordinator validation.
6595 func (t attachmentConnectionToken) current() bool {
66- return t.sess != nil && t.ac != nil &&
96+ return t.owner != nil && t.ac != nil &&
6797 t.ac.connectionGeneration.Load() == t.generation &&
68- t.ac.currentAttachmentSession() == t.sess &&
98+ sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) &&
6999 t.ac.transportSnapshotCurrent(t.transport) &&
70- attachmentRegistered(t.sess, t.ac)
100+ attachmentOwnerRegistered(t.owner, t.ac)
71101 }
72102
73103 // attachmentCurrent additionally validates the optional coordinator lease used
74104 // by render and resize effects. Attachments without that optional lease still
75105 // own their independent connection lifecycle.
76106 func (t attachmentConnectionToken) attachmentCurrent() bool {
77- if !t.current() || t.ac.currentAttachmentSession() != t.sess {
107+ if !t.current() {
78108 return false
79109 }
80110 if t.lease == nil {
81111 return true
82112 }
83- rc := t.sess.core().coordinator.Load()
113+ entry := t.localSession()
114+ if entry == nil {
115+ return false
116+ }
117+ rc := entry.core().coordinator.Load()
84118 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
85119 }
86120
@@ -108,23 +142,27 @@ func (t *attachmentConnectionToken) endAttachmentEffect() {
108142 t.effect = nil
109143 }
110144
111-// attachmentEffectCurrentSessionLocked is the same check at a session-owned
112-// mutation boundary where sess.core().mu is already held.
145+// attachmentEffectCurrentSessionLocked is the same check at a local
146+// session-owned mutation boundary where sess.core().mu is already held.
113147 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
148+ entry := t.localSession()
149+ if entry == nil {
150+ return false
151+ }
114152 if t.effect != nil && !t.effect.ended.Load() {
115153 return true
116154 }
117- if t.sess == nil || t.ac == nil ||
155+ if t.ac == nil ||
118156 t.ac.connectionGeneration.Load() != t.generation ||
119157 !t.ac.transportSnapshotCurrent(t.transport) ||
120- t.ac.currentAttachmentSession() != t.sess ||
121- !attachmentRegisteredLocked(t.sess, t.ac) {
158+ !sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) ||
159+ !attachmentRegisteredLocked(entry, t.ac) {
122160 return false
123161 }
124162 if t.lease == nil {
125163 return true
126164 }
127- rc := t.sess.core().coordinator.Load()
165+ rc := entry.core().coordinator.Load()
128166 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
129167 }
130168
M internal/usecase/daemon/attachment_effect_gate.go +5 -5
@@ -18,7 +18,7 @@ const (
1818 // effect admission. It deliberately contains no independently mutable state:
1919 // a ticket is admitted only when every field matches the frame's token.
2020 type attachmentCapability struct {
21- sess *session
21+ owner attachmentOwner
2222 generation uint64
2323 transport transportSnapshot
2424 lease *attachmentLease
@@ -26,7 +26,7 @@ type attachmentCapability struct {
2626
2727 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
2828 return attachmentCapability{
29- sess: token.sess,
29+ owner: token.owner,
3030 generation: token.generation,
3131 transport: token.transport,
3232 lease: token.lease,
@@ -34,7 +34,7 @@ func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
3434 }
3535
3636 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
37- return token.ac != nil && c.sess == token.sess &&
37+ return token.ac != nil && sameAttachmentOwner(c.owner, token.owner) &&
3838 c.generation == token.generation && c.transport.transport == token.transport.transport &&
3939 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
4040 }
@@ -271,13 +271,13 @@ func (ac *attachedClient) publishAttachmentCapability(token attachmentConnection
271271 // bootstrapAttachmentCapability supports direct/headless session construction. It
272272 // never changes an existing or frozen production publication.
273273 func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
274- if ac == nil || token.sess == nil || token.ac != ac {
274+ if ac == nil || token.owner == nil || token.ac != ac {
275275 return
276276 }
277277 g := &ac.attachmentEffects
278278 g.mu.Lock()
279279 g.initLocked()
280- if g.phase == attachmentEffectsStable && g.capability.sess == nil {
280+ if g.phase == attachmentEffectsStable && g.capability.owner == nil {
281281 g.capability = capabilityFromToken(token)
282282 g.failedTransport = transportSnapshot{}
283283 }
A internal/usecase/daemon/attachment_owner.go +217 -0
@@ -0,0 +1,217 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+// attachmentOwner is the sole mutable owner binding for an attached client.
11+// It is deliberately sealed to this package: only a local session or a
12+// daemon-owned remote view may own an attachment. Local-only operations must
13+// obtain a *session through localSession instead of assuming every owner has
14+// PTYs, persistence, or a tab tree.
15+type attachmentOwner interface {
16+ attachmentOwner()
17+}
18+
19+func (*session) attachmentOwner() {}
20+
21+// remoteViewID is allocated by the local daemon. It is distinct from the
22+// remote lifecycle identity: a remote lifecycle validates the remote endpoint,
23+// while this ID identifies the exact local object that owns attachments and
24+// asynchronous lifecycle work.
25+type remoteViewID uint64
26+
27+// remoteViewKey identifies the one reusable local view for an exact remote
28+// session lifecycle. Display data and tab selection are intentionally absent:
29+// both are mutable presentation state, not remote-link identity.
30+type remoteViewKey struct {
31+ endpoint string
32+ lifecycleID domain.SessionLifecycleID
33+ sessionName string
34+}
35+
36+func remoteViewKeyForTarget(target domain.RemoteSessionTarget) (remoteViewKey, error) {
37+ if err := target.Validate(); err != nil {
38+ return remoteViewKey{}, err
39+ }
40+ return remoteViewKey{
41+ endpoint: target.Endpoint,
42+ lifecycleID: target.LifecycleID,
43+ sessionName: target.SessionName,
44+ }, nil
45+}
46+
47+// remoteView is intentionally attachment-facing only. Link state, remote VT
48+// content, metadata, and warm lifecycle are added in Phase 4; keeping this
49+// type free of local session state prevents accidental PTY/persistence reuse.
50+type remoteView struct {
51+ id remoteViewID
52+ key remoteViewKey
53+
54+ mu sync.Mutex
55+ closed bool
56+ attachments map[*attachedClient]struct{}
57+}
58+
59+func (*remoteView) attachmentOwner() {}
60+
61+func (v *remoteView) registerAttachmentLocked(ac *attachedClient) bool {
62+ if v == nil || v.closed || ac == nil {
63+ return false
64+ }
65+ if v.attachments == nil {
66+ v.attachments = make(map[*attachedClient]struct{})
67+ }
68+ if _, exists := v.attachments[ac]; exists {
69+ return false
70+ }
71+ v.attachments[ac] = struct{}{}
72+ return true
73+}
74+
75+func (v *remoteView) unregisterAttachmentLocked(ac *attachedClient) bool {
76+ if v == nil || ac == nil {
77+ return false
78+ }
79+ if _, exists := v.attachments[ac]; !exists {
80+ return false
81+ }
82+ delete(v.attachments, ac)
83+ return true
84+}
85+
86+func (v *remoteView) attachmentRegistered(ac *attachedClient) bool {
87+ if v == nil || ac == nil {
88+ return false
89+ }
90+ v.mu.Lock()
91+ defer v.mu.Unlock()
92+ if v.closed {
93+ return false
94+ }
95+ _, registered := v.attachments[ac]
96+ return registered
97+}
98+
99+// close retires attachment membership without transport I/O. The daemon first
100+// marks shutdown under its registry lock, then calls this after releasing it;
101+// exact token validation observes the cleared membership immediately.
102+func (v *remoteView) close() {
103+ if v == nil {
104+ return
105+ }
106+ v.mu.Lock()
107+ v.closed = true
108+ clear(v.attachments)
109+ v.mu.Unlock()
110+}
111+
112+func attachmentOwnerRegistered(owner attachmentOwner, ac *attachedClient) bool {
113+ switch owner := owner.(type) {
114+ case *session:
115+ return attachmentRegistered(owner, ac)
116+ case *remoteView:
117+ return owner.attachmentRegistered(ac)
118+ default:
119+ return false
120+ }
121+}
122+
123+func normalizeAttachmentOwner(owner attachmentOwner) attachmentOwner {
124+ switch typed := owner.(type) {
125+ case *session:
126+ if typed == nil {
127+ return nil
128+ }
129+ case *remoteView:
130+ if typed == nil {
131+ return nil
132+ }
133+ }
134+ return owner
135+}
136+
137+func localSession(owner attachmentOwner) *session {
138+ sess, _ := normalizeAttachmentOwner(owner).(*session)
139+ return sess
140+}
141+
142+func sameAttachmentOwner(a, b attachmentOwner) bool {
143+ a, b = normalizeAttachmentOwner(a), normalizeAttachmentOwner(b)
144+ return a != nil && a == b
145+}
146+
147+func attachmentOwnerName(owner attachmentOwner) string {
148+ switch owner := normalizeAttachmentOwner(owner).(type) {
149+ case *session:
150+ owner.mu.Lock()
151+ defer owner.mu.Unlock()
152+ return owner.name
153+ case *remoteView:
154+ return owner.key.sessionName
155+ default:
156+ return ""
157+ }
158+}
159+
160+// attachmentOwnerRegisteredByDaemonLocked verifies the owner has not been
161+// removed or replaced in its concrete registry. Caller holds d.mu.
162+func (d *Daemon) attachmentOwnerRegisteredByDaemonLocked(owner attachmentOwner) bool {
163+ switch owner := normalizeAttachmentOwner(owner).(type) {
164+ case *session:
165+ return owner.core() != nil && d.sessions[owner.core().id] == owner
166+ case *remoteView:
167+ return d.remoteViews[owner.id] == owner && d.remoteViewsByKey[owner.key] == owner.id
168+ default:
169+ return false
170+ }
171+}
172+
173+func (d *Daemon) registerRemoteViewLocked(view *remoteView) error {
174+ if d == nil || view == nil || view.key.endpoint == "" ||
175+ view.key.lifecycleID == (domain.SessionLifecycleID{}) || view.key.sessionName == "" {
176+ return errors.New("invalid remote view")
177+ }
178+ if d.remoteViews == nil {
179+ d.remoteViews = make(map[remoteViewID]*remoteView)
180+ }
181+ if d.remoteViewsByKey == nil {
182+ d.remoteViewsByKey = make(map[remoteViewKey]remoteViewID)
183+ }
184+ if existingID, exists := d.remoteViewsByKey[view.key]; exists {
185+ if d.remoteViews[existingID] == view {
186+ return nil
187+ }
188+ return errors.New("remote view key is already registered")
189+ }
190+ if view.id == 0 {
191+ d.nextRemoteViewID++
192+ view.id = d.nextRemoteViewID
193+ } else if d.remoteViews[view.id] != nil {
194+ return errors.New("remote view ID is already registered")
195+ } else if view.id > d.nextRemoteViewID {
196+ d.nextRemoteViewID = view.id
197+ }
198+ d.remoteViews[view.id] = view
199+ d.remoteViewsByKey[view.key] = view.id
200+ return nil
201+}
202+
203+func (d *Daemon) remoteViewByKeyLocked(key remoteViewKey) *remoteView {
204+ if d == nil {
205+ return nil
206+ }
207+ return d.remoteViews[d.remoteViewsByKey[key]]
208+}
209+
210+func (d *Daemon) unregisterRemoteViewLocked(view *remoteView) bool {
211+ if d == nil || view == nil || d.remoteViews[view.id] != view || d.remoteViewsByKey[view.key] != view.id {
212+ return false
213+ }
214+ delete(d.remoteViews, view.id)
215+ delete(d.remoteViewsByKey, view.key)
216+ return true
217+}
A internal/usecase/daemon/attachment_owner_test.go +153 -0
@@ -0,0 +1,153 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestAttachedClientOwnerBindingNarrowsLocalOnlyBehavior(t *testing.T) {
12+ local := &session{sessionCore: sessionCore{id: "local"}}
13+ remote := &remoteView{id: 7}
14+ ac := &attachedClient{}
15+
16+ ac.setSession(local)
17+ require.Same(t, local, ac.currentAttachmentOwner())
18+ require.Same(t, local, ac.currentAttachmentSession())
19+
20+ ac.setAttachmentOwner(remote)
21+ require.Same(t, remote, ac.currentAttachmentOwner())
22+ require.Nil(t, ac.currentAttachmentSession(), "remote owners must not reach local-only paths")
23+}
24+
25+func TestAttachedClientOwnerBindingNormalizesTypedNil(t *testing.T) {
26+ ac := &attachedClient{}
27+
28+ ac.setSession(nil)
29+ require.Nil(t, ac.currentAttachmentOwner())
30+
31+ var remote *remoteView
32+ ac.setAttachmentOwner(remote)
33+ require.Nil(t, ac.currentAttachmentOwner())
34+}
35+
36+func TestAttachmentTokenRejectsAnOwnerReboundToRemoteView(t *testing.T) {
37+ transport := &closeTrackingTransport{}
38+ local := &session{sessionCore: sessionCore{id: "local"}}
39+ ac := &attachedClient{tr: transport}
40+ ac.setSession(local)
41+ local.mu.Lock()
42+ require.True(t, local.registerAttachmentLocked(ac))
43+ local.mu.Unlock()
44+
45+ token := local.attachmentToken(ac, transport)
46+ require.True(t, token.current())
47+
48+ ac.setAttachmentOwner(&remoteView{id: 1})
49+ require.False(t, token.current(), "a local token must not survive an exact owner rebind")
50+}
51+
52+func TestRemoteAttachmentTokenUsesExactRemoteOwnerMembership(t *testing.T) {
53+ transport := &closeTrackingTransport{}
54+ view := &remoteView{id: 1}
55+ ac := &attachedClient{tr: transport}
56+ ac.setAttachmentOwner(view)
57+ view.mu.Lock()
58+ require.True(t, view.registerAttachmentLocked(ac))
59+ view.mu.Unlock()
60+
61+ token := attachmentOwnerToken(view, ac, transport)
62+ require.True(t, token.current())
63+ require.True(t, token.attachmentCurrent(), "remote tokens do not acquire a local render lease")
64+
65+ view.mu.Lock()
66+ require.True(t, view.unregisterAttachmentLocked(ac))
67+ view.mu.Unlock()
68+ require.False(t, token.current(), "remote membership is part of exact token identity")
69+}
70+
71+func TestShutdownRetiresRemoteViewsWithoutLocalSession(t *testing.T) {
72+ d := newTestDaemon(t, nil, stubClock{})
73+ view := &remoteView{key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"}}
74+ d.mu.Lock()
75+ require.NoError(t, d.registerRemoteViewLocked(view))
76+ d.mu.Unlock()
77+
78+ transport := &closeTrackingTransport{}
79+ ac := &attachedClient{tr: transport}
80+ ac.setAttachmentOwner(view)
81+ view.mu.Lock()
82+ require.True(t, view.registerAttachmentLocked(ac))
83+ view.mu.Unlock()
84+ token := attachmentOwnerToken(view, ac, transport)
85+ require.True(t, token.current())
86+
87+ d.shutdownAll(ports.ReasonServerShutdown)
88+
89+ d.mu.Lock()
90+ require.Empty(t, d.remoteViews)
91+ require.Empty(t, d.remoteViewsByKey)
92+ d.mu.Unlock()
93+ require.False(t, token.current())
94+}
95+
96+func TestRemoteParkingRetainsStableOwnerBinding(t *testing.T) {
97+ d := newTestDaemon(t, nil, stubClock{})
98+ view := &remoteView{
99+ id: 1,
100+ key: remoteViewKey{endpoint: "host", lifecycleID: domain.SessionLifecycleID{1}, sessionName: "remote"},
101+ }
102+ d.mu.Lock()
103+ require.NoError(t, d.registerRemoteViewLocked(view))
104+ d.mu.Unlock()
105+
106+ ac := &attachedClient{tr: &closeTrackingTransport{}, resumeCapable: true}
107+ ac.setAttachmentOwner(view)
108+ view.mu.Lock()
109+ require.True(t, view.registerAttachmentLocked(ac))
110+ view.mu.Unlock()
111+
112+ token := d.markParkingInFlightOwner(view, ac)
113+ require.NotZero(t, token)
114+ view.mu.Lock()
115+ require.True(t, view.unregisterAttachmentLocked(ac))
116+ view.mu.Unlock()
117+ ac.setAttachmentOwner(nil)
118+ require.True(t, d.parkAttachmentOwner(view, ac))
119+
120+ d.mu.Lock()
121+ parked := d.parked[token]
122+ d.mu.Unlock()
123+ require.NotNil(t, parked)
124+ require.Same(t, view, parked.owner)
125+}
126+
127+func TestRemoteViewRegistryUsesDaemonLocalIDAndExactRemoteLifecycleKey(t *testing.T) {
128+ target := domain.RemoteSessionTarget{
129+ Endpoint: "user@host",
130+ DisplayOrigin: "host",
131+ LifecycleID: domain.SessionLifecycleID{1},
132+ SessionName: "remote",
133+ LiveTabID: "tab-1",
134+ }
135+ key, err := remoteViewKeyForTarget(target)
136+ require.NoError(t, err)
137+
138+ d := &Daemon{
139+ remoteViews: make(map[remoteViewID]*remoteView),
140+ remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
141+ }
142+ first := &remoteView{key: key}
143+ second := &remoteView{key: key}
144+
145+ d.mu.Lock()
146+ require.NoError(t, d.registerRemoteViewLocked(first))
147+ require.NotZero(t, first.id)
148+ require.Same(t, first, d.remoteViewByKeyLocked(key))
149+ require.Error(t, d.registerRemoteViewLocked(second), "a lifecycle key cannot alias another local view")
150+ require.True(t, d.unregisterRemoteViewLocked(first))
151+ require.Nil(t, d.remoteViewByKeyLocked(key))
152+ d.mu.Unlock()
153+}
M internal/usecase/daemon/attachment_transition.go +3 -3
@@ -45,7 +45,7 @@ type attachmentTransitionRequest struct {
4545 }
4646
4747 func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {
48- return token.sess == source && token.ac == req.next &&
48+ return sameAttachmentOwner(token.owner, source) && token.ac == req.next &&
4949 token.generation == req.next.connectionGeneration.Load() &&
5050 token.transport.transport == req.expectedTransport.transport &&
5151 token.transport.incarnation == req.expectedTransport.incarnation
@@ -55,9 +55,9 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
5555 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
5656 // handoff check for client-originated navigation and lifecycle mutations.
5757 func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
58- if token.sess != source || token.ac != req.next ||
58+ if !sameAttachmentOwner(token.owner, source) || token.ac != req.next ||
5959 token.generation != req.next.connectionGeneration.Load() ||
60- token.sess == nil || token.ac.currentAttachmentSession() != source ||
60+ token.localSession() == nil || !sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) ||
6161 !attachmentRegisteredLocked(source, req.next) ||
6262 !req.next.transportSnapshotCurrent(token.transport) {
6363 return false
M internal/usecase/daemon/attachment_transition_publish.go +9 -8
@@ -29,25 +29,26 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
2929 }
3030
3131 func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
32- if token.sess == nil || token.ac == nil {
32+ sess := token.localSession()
33+ if sess == nil || token.ac == nil {
3334 return false
3435 }
3536 d.mu.Lock()
3637 defer d.mu.Unlock()
37- if d.closing || token.sess.core() == nil || d.sessions[token.sess.core().id] != token.sess {
38+ if d.closing || sess.core() == nil || d.sessions[sess.core().id] != sess {
3839 return false
3940 }
4041 d.notices.routingMu.Lock()
4142 defer d.notices.routingMu.Unlock()
42- token.sess.core().mu.Lock()
43- defer token.sess.core().mu.Unlock()
44- coordinator := token.sess.core().coordinator.Load()
43+ sess.core().mu.Lock()
44+ defer sess.core().mu.Unlock()
45+ coordinator := sess.core().coordinator.Load()
4546 if coordinator != nil {
4647 coordinator.mu.Lock()
4748 defer coordinator.mu.Unlock()
4849 }
49- req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
50- return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
50+ req := attachmentTransitionRequest{source: sess, next: token.ac, expectedTransport: token.transport}
51+ return transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
5152 }
5253
5354 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
@@ -215,7 +216,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
215216 }
216217 }
217218 result.published = attachmentConnectionToken{
218- sess: req.target, ac: req.next,
219+ owner: req.target, ac: req.next,
219220 generation: publication.nextGeneration,
220221 transport: req.expectedTransport, lease: lease,
221222 rebase: publication.source != req.target,
M internal/usecase/daemon/client.go +57 -17
@@ -42,6 +42,14 @@ type attachedClient struct {
4242 // remoteOrigin is attachment-specific presentation metadata supplied by a
4343 // validated picker handoff; it never participates in routing.
4444 remoteOrigin string
45+ // renderMode is fixed by the accepted Hello and controls the remote daemon's
46+ // publication contract for this transport. It is immutable for one
47+ // attachment lifetime and is not local session ownership state.
48+ renderMode ports.RenderMode
49+ // proxiedMetaRevision and proxiedOutputStarted are serialized by sendMu.
50+ // They order metadata snapshots ahead of every post-handshake content output.
51+ proxiedMetaRevision uint64
52+ proxiedOutputStarted bool
4553 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
4654 // the linearization gate for every attachment-bound observable operation. A role
4755 // transition freezes and drains this gate before changing either generation
@@ -85,18 +93,18 @@ type attachedClient struct {
8593 // tabs and panes without changing shared session ownership.
8694 viewMu sync.Mutex
8795 view attachmentView
88- sess Guarded[*session]
96+ owner Guarded[attachmentOwner]
8997 mouseScan mouse.Scanner
9098 themeMu sync.Mutex
9199 clientTheme themeui.Theme
92100 appliedTheme appliedTheme
93101 lastCursor cursorOut
94102 renderStages renderStageHooks // optional render and handoff observability hooks
95- // previousSession is guarded independently. It is retained through temporary
96- // setSession(nil) hand-offs and cleared only on terminal teardown.
97- previousSession Guarded[*session]
98- linkMu sync.Mutex
99- sendMu sync.Mutex
103+ // previousOwner is guarded independently. It retains navigation history
104+ // through temporary owner hand-offs and is cleared only on terminal teardown.
105+ previousOwner Guarded[attachmentOwner]
106+ linkMu sync.Mutex
107+ sendMu sync.Mutex
100108 // routeCreatedSession marks a session created by this attachment's route.
101109 // A handshake that never commits Welcome must tear down that exact empty
102110 // session, while an attachment routed to an existing session must not.
@@ -209,18 +217,37 @@ func (ac *attachedClient) clearGeometryClaim() {
209217 ac.sizeMu.Unlock()
210218 }
211219
212-func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
220+// currentAttachmentOwner returns the one authoritative binding for this
221+// attachment. Callers that need local-only behavior must narrow it with
222+// localSession rather than retaining a second session owner.
223+func (ac *attachedClient) currentAttachmentOwner() attachmentOwner {
224+ if ac == nil {
225+ return nil
226+ }
227+ return ac.owner.Get()
228+}
213229
214-// currentSession narrows the attachment to its owning local session.
230+func (ac *attachedClient) currentAttachmentSession() *session {
231+ return localSession(ac.currentAttachmentOwner())
232+}
233+
234+// currentSession is retained for local-session call sites. It deliberately
235+// returns nil for a remote view so local PTY/persistence paths fail closed.
215236 func (ac *attachedClient) currentSession() *session {
216237 return ac.currentAttachmentSession()
217238 }
218239
219-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
240+func (ac *attachedClient) setAttachmentOwner(owner attachmentOwner) {
241+ if ac != nil {
242+ ac.owner.Set(normalizeAttachmentOwner(owner))
243+ }
244+}
245+
246+func (ac *attachedClient) setSession(sess *session) { ac.setAttachmentOwner(sess) }
220247
221248 func (ac *attachedClient) clearPreviousSession() {
222249 if ac != nil {
223- ac.previousSession.Set(nil)
250+ ac.previousOwner.Set(nil)
224251 }
225252 }
226253
@@ -569,6 +596,7 @@ type attachClientOptions struct {
569596 resumeCapable bool
570597 maxOutputInFlight uint8
571598 remoteOrigin string
599+ renderMode ports.RenderMode
572600 }
573601
574602 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -615,6 +643,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
615643 view: attachmentView{windowRows: sz.Rows, windowSet: true},
616644 clientID: opts.clientID,
617645 remoteOrigin: opts.remoteOrigin,
646+ renderMode: opts.renderMode,
618647 resumeCapable: opts.resumeCapable,
619648 resumeToken: resumeToken,
620649 }
@@ -759,10 +788,20 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
759788 token.ac.captureFrames = nil
760789 token.ac.sendMu.Unlock()
761790 }
762- if token.sess == nil {
791+ sess := token.localSession()
792+ if sess == nil {
763793 return false
764794 }
765- d.firstPaintWithLease(token.sess, token.ac, token.lease, true)
795+ if token.ac.renderMode == ports.RenderModeProxiedContent {
796+ // The proxied Hello size is already content geometry. Reconcile the shared
797+ // PTY/layout claim before the first authoritative content reset and wake
798+ // every existing attachment if that claim changed their geometry. Welcome
799+ // and the first SessionMeta were committed before this post-transition
800+ // path, so the proxied attachment cannot receive content ahead of metadata.
801+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "client.go")
802+ return d.paintProxiedContent(sess, token.ac, true, token.lease) == paintEmitted
803+ }
804+ d.firstPaintWithLease(sess, token.ac, token.lease, true)
766805 return true
767806 }
768807
@@ -834,14 +873,14 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
834873 }
835874 clientTheme := themeFromMessage(msg)
836875 token.ac.setClientTheme(clientTheme)
837- sess := token.sess
876+ sess := token.localSession()
838877 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
839878 return false
840879 }
841880 if !token.attachmentEffectCurrent() {
842881 return false
843882 }
844- if rc := token.sess.core().coordinator.Load(); rc != nil {
883+ if rc := sess.core().coordinator.Load(); rc != nil {
845884 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
846885 }
847886 return false
@@ -887,7 +926,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
887926 d.registerPreviewForSelection(ac)
888927 }
889928
890- sess := token.sess
929+ sess := token.localSession()
891930 if sess == nil {
892931 return false
893932 }
@@ -945,11 +984,12 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
945984 d.notices.routingMu.Unlock()
946985 return
947986 }
948- if token.sess == nil {
987+ sess := token.localSession()
988+ if sess == nil {
949989 d.notices.routingMu.Unlock()
950990 return
951991 }
952- repaint := d.mutateClientNotice(token.sess, token.ac, notice)
992+ repaint := d.mutateClientNotice(sess, token.ac, notice)
953993 d.notices.routingMu.Unlock()
954994 if repaint && token.attachmentEffectCurrent() {
955995 d.repaintForNotice(token.ac)
M internal/usecase/daemon/client_frame_routing.go +8 -4
@@ -176,7 +176,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
176176 result.Text = "attached commands cannot override their active session target"
177177 return result
178178 }
179- sess := token.sess
179+ sess := token.localSession()
180180 if sess == nil || !token.attachmentEffectCurrent() {
181181 result.Code = ports.ErrNoSuchTarget
182182 result.Text = "attached session is no longer active"
@@ -234,7 +234,9 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
234234 ac.pipelineCache = composeCacheInput{}
235235 ac.pipelineScratch = composeCacheInput{}
236236 ac.sendMu.Unlock()
237- go d.paint(token.sess, ac, true, token.lease)
237+ if sess := token.localSession(); sess != nil {
238+ go d.paint(sess, ac, true, token.lease)
239+ }
238240 return true
239241 }
240242
@@ -246,8 +248,10 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
246248 ac.sendMu.Lock()
247249 ac.output.ack(epoch, state)
248250 ac.sendMu.Unlock()
249- if rc := token.sess.core().coordinator.Load(); rc != nil {
250- rc.notifyAckForLease(token.lease)
251+ if sess := token.localSession(); sess != nil {
252+ if rc := sess.core().coordinator.Load(); rc != nil {
253+ rc.notifyAckForLease(token.lease)
254+ }
251255 }
252256 return true
253257 }
M internal/usecase/daemon/client_transport_errors.go +2 -2
@@ -37,7 +37,7 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
3737 }
3838 token.effect.bindActionEnd(d, "detach")
3939 token.effect.End()
40- sess := token.sess
40+ sess := token.localSession()
4141 if sess == nil {
4242 return false
4343 }
@@ -127,7 +127,7 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
127127 if failed == nil || failed != token.transport.transport {
128128 return
129129 }
130- sess := token.sess
130+ sess := token.localSession()
131131 if sess == nil {
132132 return
133133 }
M internal/usecase/daemon/clipboard.go +8 -3
@@ -27,7 +27,7 @@ var (
2727 )
2828
2929 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if !token.attachmentEffectCurrent() {
30+ if !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {
3131 return
3232 }
3333 d.handleImagePushForAttachment(token, ip)
@@ -89,7 +89,7 @@ func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToke
8989 if err != nil {
9090 return "", err
9191 }
92- sess := token.sess
92+ sess := token.localSession()
9393 if sess == nil {
9494 _ = os.Remove(path)
9595 return "", errAttachmentTransition
@@ -135,7 +135,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
135135 }
136136
137137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess := token.sess
138+ sess := token.localSession()
139139 if sess == nil || !token.attachmentEffectCurrent() {
140140 return
141141 }
@@ -236,6 +236,11 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
236236
237237 items := make([]clipboardForward, 0, len(attachments))
238238 for _, ac := range attachments {
239+ // A remote daemon's proxied content stream must never carry OSC52 or
240+ // another host-facing side effect to the local link.
241+ if ac.renderMode == ports.RenderModeProxiedContent {
242+ continue
243+ }
239244 transport := ac.transport()
240245 token := sess.attachmentToken(ac, transport)
241246 if token.attachmentCurrent() {
M internal/usecase/daemon/daemon.go +40 -2
@@ -72,6 +72,12 @@ type Daemon struct {
7272 mu sync.Mutex
7373 sessions map[domain.SessionID]*session
7474 stopped map[string]stoppedSession
75+ // remoteViews holds non-persistent, daemon-local attachment owners. The
76+ // companion key index provides warm-link reuse without ever widening
77+ // sessions beyond concrete local *session values. Both are guarded by mu.
78+ remoteViews map[remoteViewID]*remoteView
79+ remoteViewsByKey map[remoteViewKey]remoteViewID
80+ nextRemoteViewID remoteViewID
7581 // creating reserves names while durable creation I/O runs without mu.
7682 creating map[string]struct{}
7783 nextID uint64
@@ -322,7 +328,7 @@ type Daemon struct {
322328 }
323329
324330 type parkedAttachment struct {
325- sess *session
331+ owner attachmentOwner
326332 ac *attachedClient
327333 pickerGeneration uint64
328334 timer ports.Timer
@@ -335,7 +341,7 @@ type parkedAttachment struct {
335341 // credential. done closes when the token is published into parked or parking
336342 // is abandoned.
337343 type parkingAttachment struct {
338- sess *session
344+ owner attachmentOwner
339345 ac *attachedClient
340346 done chan struct{}
341347 doneOnce sync.Once
@@ -596,6 +602,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
596602 d := &Daemon{
597603 sessions: make(map[domain.SessionID]*session),
598604 stopped: make(map[string]stoppedSession),
605+ remoteViews: make(map[remoteViewID]*remoteView),
606+ remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
599607 creating: make(map[string]struct{}),
600608 parked: make(map[uint64]*parkedAttachment),
601609 parking: make(map[uint64]*parkingAttachment),
@@ -796,9 +804,20 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
796804 d.closing = true
797805 d.purgeAllParkingLocked()
798806 parkedRetirements := d.purgeAllParkedLocked()
807+ remoteViews := make([]*remoteView, 0, len(d.remoteViews))
808+ for _, view := range d.remoteViews {
809+ remoteViews = append(remoteViews, view)
810+ }
811+ clear(d.remoteViews)
812+ clear(d.remoteViewsByKey)
799813 snapshot := d.sessionsSnapshotLocked()
800814 empty := len(snapshot) == 0
801815 d.mu.Unlock()
816+ // A remote view is not a daemon-liveness root. Retire its membership after
817+ // releasing d.mu so a token cannot remain valid once shutdown begins.
818+ for _, view := range remoteViews {
819+ view.close()
820+ }
802821 d.finishParkedAttachmentRetirements(parkedRetirements)
803822 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
804823 if empty {
@@ -1198,6 +1217,24 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
11981217 failAttachment()
11991218 return
12001219 }
1220+ if postWelcomeToken.ac.renderMode == ports.RenderModeProxiedContent {
1221+ meta, metaErr := frameSessionMeta(sess, postWelcomeToken.ac, 1)
1222+ if metaErr != nil {
1223+ postWelcomeTicket.End()
1224+ failAttachment()
1225+ return
1226+ }
1227+ if err := boundedHandshakeOperation(handshakeCtx, tr, func() error {
1228+ return postWelcomeToken.ac.sendExpectedTransportForAttachment(expected, meta, postWelcomeTicket)
1229+ }); err != nil {
1230+ postWelcomeTicket.End()
1231+ failAttachment()
1232+ return
1233+ }
1234+ postWelcomeToken.ac.sendMu.Lock()
1235+ postWelcomeToken.ac.proxiedMetaRevision = 1
1236+ postWelcomeToken.ac.sendMu.Unlock()
1237+ }
12011238 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
12021239 postWelcomeTicket.End()
12031240 // The attachment was detached while Welcome was in flight; never let
@@ -1269,6 +1306,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12691306 clientID: h.ClientID,
12701307 resumeCapable: true,
12711308 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1309+ renderMode: h.RenderMode,
12721310 }
12731311 if h.RemoteTarget != nil {
12741312 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
M internal/usecase/daemon/input.go +35 -3
@@ -7,6 +7,7 @@ import (
77 "strconv"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/keys"
1112 "github.com/bnema/vev/internal/usecase/layout"
1213 "github.com/bnema/vev/internal/usecase/mouse"
@@ -24,9 +25,39 @@ func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionTok
2425 if !token.attachmentEffectCurrent() {
2526 return
2627 }
28+ if token.ac.renderMode == ports.RenderModeProxiedContent {
29+ d.handleProxiedInputForAttachment(token, data)
30+ return
31+ }
2732 d.handleInputForAttachment(token, data)
2833 }
2934
35+// handleProxiedInputForAttachment preserves remote terminal bytes exactly.
36+// Local key routing, overlays, mouse interpretation, and copy-mode state are
37+// deliberately unavailable on the remote daemon's content-only surface.
38+func (d *Daemon) handleProxiedInputForAttachment(token attachmentConnectionToken, data []byte) {
39+ sess := token.localSession()
40+ if len(data) == 0 || !token.attachmentEffectCurrent() || sess == nil {
41+ return
42+ }
43+ _ = sess.runMutation(func() error {
44+ if !token.attachmentEffectCurrent() {
45+ return nil
46+ }
47+ tab := sess.tabForAttachment(token.ac)
48+ if tab == nil {
49+ return nil
50+ }
51+ tab.mu.Lock()
52+ pane := tab.terminalTargetLocked()
53+ tab.mu.Unlock()
54+ if pane != nil {
55+ d.writeToPane(sess, pane, data)
56+ }
57+ return nil
58+ })
59+}
60+
3061 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3162 ac.initOverlays()
3263 ac.mouseScan.Scan(data,
@@ -383,7 +414,7 @@ type daemonKeyHandler struct {
383414 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384415 if h.connectionToken.ac != nil {
385416 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- return h.connectionToken.sess, effect, false
417+ return h.connectionToken.localSession(), effect, false
387418 }
388419 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
389420 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -397,11 +428,12 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
397428 token.effect = effect
398429 h.d.afterAttachmentEffectAdmitted(token)
399430 }
400- if h.connectionToken.sess == nil {
431+ sess := h.connectionToken.localSession()
432+ if sess == nil {
401433 effect.End()
402434 return nil, nil, false
403435 }
404- return h.connectionToken.sess, effect, true
436+ return sess, effect, true
405437 }
406438 sess := h.ac.currentSession()
407439 if sess == nil {
M internal/usecase/daemon/input_test.go +15 -15
@@ -799,8 +799,8 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
799799 prepare func()
800800 }{
801801 {name: "no target", prepare: func() {}},
802- {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803- {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
802+ {name: "stale target", prepare: func() { ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803+ {name: "same session", prepare: func() { ac.previousOwner.Set(current) }},
804804 } {
805805 t.Run(tc.name, func(t *testing.T) {
806806 ac.clearPreviousSession()
@@ -808,7 +808,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
808808 runPaletteCommand(t, d, current, ac, "BSK")
809809 require.Same(t, current, ac.currentSession())
810810 if tc.name != "no target" {
811- require.Nil(t, ac.previousSession.Get(), "invalid target must not remain toggleable")
811+ require.Nil(t, ac.previousOwner.Get(), "invalid target must not remain toggleable")
812812 }
813813 })
814814 }
@@ -818,7 +818,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
818818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
819819 defer releaseAll(releases)
820820 target := d.sessions[domain.SessionID("recent")]
821- ac.previousSession.Set(target)
821+ ac.previousOwner.Set(target)
822822
823823 // A displaced attachment makes the previously valid handoff stale by the
824824 // time switchToTarget commits it.
@@ -829,7 +829,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
829829 d.backSession(current, ac)
830830
831831 require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")
832- require.Same(t, target, ac.previousSession.Get(), "a failed handoff remains retryable")
832+ require.Same(t, target, ac.previousOwner.Get(), "a failed handoff remains retryable")
833833 history := d.notices.history()
834834 require.Len(t, history, 1, "the switch failure must be reported exactly once")
835835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
@@ -839,14 +839,14 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
839839 ac := &attachedClient{}
840840 stale := &session{sessionCore: sessionCore{id: "stale"}}
841841 updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousSession.Set(stale)
842+ ac.previousOwner.Set(stale)
843843
844844 // Model a completed hand-off between observing a stale target and clearing
845845 // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousSession.Set(updated)
847- ac.clearPreviousSessionIf(stale)
846+ ac.previousOwner.Set(updated)
847+ ac.clearPreviousOwnerIf(stale)
848848
849- require.Same(t, updated, ac.previousSession.Get())
849+ require.Same(t, updated, ac.previousOwner.Get())
850850 }
851851
852852 // backSession's invalid-target fallback is a render producer: it must publish
@@ -861,10 +861,10 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
861861 }{
862862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863863 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
864- ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}})
864+ ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}})
865865 }},
866866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousSession.Set(current)
867+ ac.previousOwner.Set(current)
868868 }},
869869 } {
870870 t.Run(tc.name, func(t *testing.T) {
@@ -940,10 +940,10 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
940940 }
941941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942942 if tc.wantPrev == "" {
943- require.Nil(t, ac.previousSession.Get())
943+ require.Nil(t, ac.previousOwner.Get())
944944 return
945945 }
946- require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().core().id)
946+ require.Equal(t, domain.SessionID(tc.wantPrev), localSession(ac.previousOwner.Get()).core().id)
947947 })
948948 }
949949 }
@@ -963,10 +963,10 @@ func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
963963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964964 defer releaseAll(releases)
965965
966- ac.previousSession.Set(d.sessions[domain.SessionID("recent")])
966+ ac.previousOwner.Set(d.sessions[domain.SessionID("recent")])
967967 tc.teardown(d, current, ac)
968968
969- require.Nil(t, ac.previousSession.Get())
969+ require.Nil(t, ac.previousOwner.Get())
970970 })
971971 }
972972 }
M internal/usecase/daemon/move_attachments.go +2 -2
@@ -1,8 +1,8 @@
11 package daemon
22
33 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4- return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5- token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&
4+ return sameAttachmentOwner(token.owner, sess) && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), sess) && token.ac.transportSnapshotCurrent(token.transport) &&
66 attachmentRegisteredLocked(sess, token.ac)
77 }
88
M internal/usecase/daemon/palette_test.go +1 -1
@@ -460,7 +460,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
460460 // Establish a valid back-session target, then remove that target immediately
461461 // after the command releases its role admission.
462462 target := d.sessions[domain.SessionID("recent")]
463- ac.previousSession.Set(target)
463+ ac.previousOwner.Set(target)
464464 invalidations := installPaletteInvalidationObserver(current)
465465 d.enterPalette(current, ac)
466466 d.handlePaletteInput(ac, []byte("BSK"))
M internal/usecase/daemon/picker.go +10 -8
@@ -794,7 +794,8 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
794794 }
795795
796796 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
797- if token.sess == nil || token.ac == nil {
797+ source := token.localSession()
798+ if source == nil || token.ac == nil {
798799 return nil
799800 }
800801 d.mu.Lock()
@@ -804,15 +805,15 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
804805 if !token.attachmentCurrent() {
805806 return nil
806807 }
807- d.invalidateRender(token.sess, token.ac, true, "picker.go")
808+ d.invalidateRender(source, token.ac, true, "picker.go")
808809 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
809810 }
810- if targetSess == token.sess {
811+ if targetSess == source {
811812 return nil
812813 }
813814
814815 transition, err := d.transitionAttachment(attachmentTransitionRequest{
815- source: token.sess, target: targetSess, next: token.ac,
816+ source: source, target: targetSess, next: token.ac,
816817
817818 expectedTransport: token.transport, sourceToken: &token, action: action,
818819 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
@@ -834,7 +835,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
834835 }
835836 }
836837 d.touchMRU(targetSess)
837- token.ac.recordPreviousSession(token.sess)
838+ token.ac.recordPreviousSession(source)
838839 d.deferAttachmentTransitionCleanups(transition)
839840 d.firstPaintForTransition(transition.published)
840841 return nil
@@ -852,7 +853,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
852853 // every active, stopped, and same-session target then uses transitionAttachment
853854 // for frozen, atomic source-token preflight.
854855 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
855- if token.sess == nil || token.ac == nil || token.effect == nil {
856+ if token.localSession() == nil || token.ac == nil || token.effect == nil {
856857 return nil
857858 }
858859 if target.RemoteTarget != nil {
@@ -867,10 +868,11 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
867868 }
868869 token.effect.bindActionEnd(d, action)
869870 token.effect.End()
870- if token.sess == nil {
871+ source := token.localSession()
872+ if source == nil {
871873 return errAttachmentTransition
872874 }
873- return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
875+ return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)
874876 }
875877
876878 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the
M internal/usecase/daemon/picker_test.go +1 -1
@@ -920,7 +920,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
920920 targetPane.screen.Write([]byte("TARGET"))
921921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
922922 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
923- ac.previousSession.Set(target)
923+ ac.previousOwner.Set(target)
924924
925925 // Exercise the user-facing previous-session route, which delegates to the
926926 // real switchToTarget hand-off and immediately emits its required reset.
A internal/usecase/daemon/proxied_handshake_test.go +223 -0
@@ -0,0 +1,223 @@
1+package daemon
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/bnema/vev/pkg/vt"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestProxiedInputBypassesRemoteDaemonUI(t *testing.T) {
17+ pty := portsmocks.NewMockPTY(t)
18+ pty.EXPECT().Write([]byte{'\x02', 'p'}).Return(2, nil).Once()
19+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
20+ ac.renderMode = ports.RenderModeProxiedContent
21+ token := attachmentToken(sess, ac, ac.transport())
22+ require.True(t, token.attachmentCurrent())
23+
24+ d.handleSequencedInputForAttachment(token, 1, []byte{'\x02', 'p'})
25+}
26+
27+func TestProxiedInputTargetsVisibleFloatingPane(t *testing.T) {
28+ floatingPTY := portsmocks.NewMockPTY(t)
29+ floatingPTY.EXPECT().Write([]byte("remote-input")).Return(len("remote-input"), nil).Once()
30+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
31+ ac.renderMode = ports.RenderModeProxiedContent
32+ installTestFloating(testAttachmentTab(sess), newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5}), true)
33+ token := attachmentToken(sess, ac, ac.transport())
34+ require.True(t, token.attachmentCurrent())
35+
36+ d.handleSequencedInputForAttachment(token, 1, []byte("remote-input"))
37+}
38+
39+func TestProxiedCommandKeepsRequestCorrelation(t *testing.T) {
40+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
41+ ac.renderMode = ports.RenderModeProxiedContent
42+ token := attachmentToken(sess, ac, ac.transport())
43+ payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
44+ Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "unknown-command",
45+ })
46+ require.NoError(t, err)
47+
48+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
49+ frame := <-sends
50+ require.Equal(t, ports.MsgCommandResult, frame.Type)
51+ result, err := ports.UnmarshalCommandResult(frame.Payload)
52+ require.NoError(t, err)
53+ require.Equal(t, uint64(42), result.RequestID)
54+ require.Equal(t, ports.ErrUnknownCommand, result.Code)
55+}
56+
57+func TestProxiedResizeUsesContentGeometry(t *testing.T) {
58+ pty := portsmocks.NewMockPTY(t)
59+ pty.EXPECT().Resize(domain.Size{Cols: 80, Rows: 20}).Return(nil).Once()
60+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
61+ ac.renderMode = ports.RenderModeProxiedContent
62+ token := attachmentToken(sess, ac, ac.transport())
63+ require.True(t, token.attachmentCurrent())
64+
65+ d.resizeAttachmentForLease(token, domain.Size{Cols: 80, Rows: 20})
66+ ac.sizeMu.RLock()
67+ require.Equal(t, domain.Size{Cols: 80, Rows: 20}, ac.size)
68+ ac.sizeMu.RUnlock()
69+ sess.tabs[0].mu.Lock()
70+ require.Equal(t, domain.Size{Cols: 80, Rows: 20}, sess.tabs[0].size)
71+ sess.tabs[0].mu.Unlock()
72+}
73+
74+func TestProxiedCompositionUsesAttachmentContentWindowAndFloatingSurface(t *testing.T) {
75+ paneFrame := renderer.NewFrame(20, 10)
76+ paneFrame.Set(0, 0, renderer.Cell{Rune: 'p'})
77+ floatingFrame := renderer.NewFrame(3, 2)
78+ floatingFrame.Set(0, 0, renderer.Cell{Rune: 'f'})
79+
80+ composed := composeProxiedContent(capturedRenderState{
81+ window: domain.Size{Cols: 10, Rows: 5},
82+ layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 10}},
83+ panes: []capturedPaneRenderState{{
84+ placement: layout.Placement{Content: domain.Rect{Width: 20, Height: 10}},
85+ frame: paneFrame,
86+ }},
87+ floating: capturedFloatingRenderState{
88+ visible: true,
89+ pane: capturedPaneRenderState{frame: floatingFrame},
90+ geometry: floatingGeometry{Inner: domain.Rect{X: 4, Y: 2, Width: 3, Height: 2}},
91+ },
92+ })
93+
94+ require.Equal(t, 10, composed.frame.Width)
95+ require.Equal(t, 5, composed.frame.Height)
96+ require.Equal(t, 'p', composed.frame.At(0, 0).Rune)
97+ require.Equal(t, 'f', composed.frame.At(4, 2).Rune)
98+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
99+}
100+
101+func TestProxiedMetadataFallsBackFromStaleAttachmentTab(t *testing.T) {
102+ _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
103+ sess.mu.Lock()
104+ sess.incarnation = domain.SessionLifecycleID{1}
105+ sess.tabs[0].stableID = "live-tab"
106+ sess.mu.Unlock()
107+ ac.publishView(attachmentView{tabID: "stale-tab"})
108+
109+ frame, err := frameSessionMeta(sess, ac, 1)
110+ require.NoError(t, err)
111+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
112+ require.NoError(t, err)
113+ require.Equal(t, domain.TabStableID("live-tab"), meta.ActiveTabID)
114+ require.Equal(t, domain.TabStableID("live-tab"), meta.Tabs[0].ID)
115+}
116+
117+func TestProxiedMetadataRevisionPrecedesSubsequentContent(t *testing.T) {
118+ d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
119+ sess.mu.Lock()
120+ sess.incarnation = domain.SessionLifecycleID{1}
121+ sess.tabs[0].stableID = "tab"
122+ sess.mu.Unlock()
123+ ac.renderMode = ports.RenderModeProxiedContent
124+ ac.proxiedOutputStarted = true
125+ ac.proxiedMetaRevision = 1
126+
127+ ac.sendMu.Lock()
128+ failedTransport, err := d.sendProxiedMetadataLocked(sess, ac, nil)
129+ ac.sendMu.Unlock()
130+ require.NoError(t, err)
131+ require.Same(t, ac.transport(), failedTransport)
132+ frame := <-sends
133+ require.Equal(t, ports.MsgSessionMeta, frame.Type)
134+ meta, err := ports.UnmarshalSessionMeta(frame.Payload)
135+ require.NoError(t, err)
136+ require.Equal(t, uint64(2), meta.Revision)
137+ require.Equal(t, uint64(2), ac.proxiedMetaRevision)
138+}
139+
140+func TestProxiedHandshakePublishesStableMetadataBeforeOutput(t *testing.T) {
141+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
142+ base := &closeTrackingTransport{}
143+ sess, _, err := d.route(ports.Hello{
144+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "remote",
145+ Size: domain.Size{Cols: 80, Rows: 24},
146+ }, base)
147+ require.NoError(t, err)
148+ sess.tabs[0].focusedPane().screen.Write([]byte("content-marker"))
149+
150+ sess.mu.Lock()
151+ target := domain.RemoteSessionTarget{
152+ Endpoint: "user@host", DisplayOrigin: "host", LifecycleID: sess.incarnation,
153+ SessionName: sess.name, LiveTabID: domain.TabStableID(sess.tabs[0].stableID),
154+ }
155+ sess.mu.Unlock()
156+
157+ hello := ports.Hello{
158+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach,
159+ RenderMode: ports.RenderModeProxiedContent, Name: target.SessionName,
160+ Size: domain.Size{Cols: 80, Rows: 22}, RemoteTarget: &target,
161+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
162+ }
163+ tr, sends, release := newConn(t,
164+ ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)},
165+ ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})},
166+ )
167+ done := make(chan struct{})
168+ go func() {
169+ d.handleConn(tr)
170+ close(done)
171+ }()
172+
173+ welcomeFrame := <-sends
174+ require.Equal(t, ports.MsgWelcome, welcomeFrame.Type)
175+ welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
176+ require.NoError(t, err)
177+ require.Equal(t, ports.RenderModeProxiedContent, welcome.RenderMode)
178+
179+ metadataFrame := <-sends
180+ require.Equal(t, ports.MsgSessionMeta, metadataFrame.Type)
181+ metadata, err := ports.UnmarshalSessionMeta(metadataFrame.Payload)
182+ require.NoError(t, err)
183+ require.Equal(t, target.LifecycleID, metadata.LifecycleID)
184+ require.Equal(t, uint64(1), metadata.Revision)
185+ require.Equal(t, target.SessionName, metadata.SessionName)
186+ require.Equal(t, target.LiveTabID, metadata.ActiveTabID)
187+ require.Len(t, metadata.Tabs, 1)
188+ require.Equal(t, target.LiveTabID, metadata.Tabs[0].ID)
189+
190+ outputFrame := <-sends
191+ require.Equal(t, ports.MsgOutput, outputFrame.Type)
192+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
193+ require.NoError(t, err)
194+ require.Zero(t, output.Base)
195+ require.Equal(t, uint64(1), output.New)
196+ require.True(t, output.Full)
197+ require.Equal(t, hello.Size, output.Size)
198+ private := vt.NewScreen(output.Size.Cols, output.Size.Rows)
199+ private.Write(output.Data)
200+ require.Contains(t, screenLineText(private, 0), "content-marker")
201+ for y := range private.Frame.Height {
202+ require.NotContains(t, strings.TrimSpace(screenLineText(private, y)), "remote", "proxied content must omit remote chrome")
203+ }
204+
205+ resetMetadataFrame := <-sends
206+ require.Equal(t, ports.MsgSessionMeta, resetMetadataFrame.Type)
207+ resetMetadata, err := ports.UnmarshalSessionMeta(resetMetadataFrame.Payload)
208+ require.NoError(t, err)
209+ require.Equal(t, uint64(2), resetMetadata.Revision)
210+ resetOutputFrame := <-sends
211+ require.Equal(t, ports.MsgOutput, resetOutputFrame.Type)
212+ resetOutput, err := ports.UnmarshalOutput(resetOutputFrame.Payload)
213+ require.NoError(t, err)
214+ require.Greater(t, resetOutput.Epoch, output.Epoch)
215+ require.Zero(t, resetOutput.Base)
216+ require.Equal(t, uint64(1), resetOutput.New)
217+ require.True(t, resetOutput.Full)
218+ private.Write(resetOutput.Data)
219+ require.Contains(t, screenLineText(private, 0), "content-marker")
220+
221+ release()
222+ awaitTestCompletion(t, done, "proxied connection did not stop")
223+}
A internal/usecase/daemon/proxied_render.go +149 -0
@@ -0,0 +1,149 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/ports"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// paintProxiedContent publishes only the remote session's content surface. It
10+// deliberately bypasses bars, pane title chrome, overlays, notices, and every
11+// terminal-global side-effect path used by a full terminal attachment.
12+func (d *Daemon) paintProxiedContent(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
13+ if entry == nil || ac == nil {
14+ return paintRejected
15+ }
16+ marks := d.newRuntimeMarkBatch()
17+ if lease != nil {
18+ token := attachmentToken(entry, ac, ac.transport())
19+ token.lease = lease
20+ ticket, admitted := ac.beginAttachmentEffect(token)
21+ if !admitted {
22+ return paintRejected
23+ }
24+ marks.attachmentEffect = ticket
25+ defer ticket.End()
26+ }
27+
28+ ac.sendMu.Lock()
29+ entry.core().mu.Lock()
30+ _, owned := entry.core().attachments[ac]
31+ entry.core().mu.Unlock()
32+ if !owned || ac.currentAttachmentSession() != entry {
33+ ac.sendMu.Unlock()
34+ return paintRejected
35+ }
36+ if lease != nil {
37+ rc := attachmentRenderCoordinator(entry)
38+ if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
39+ ac.sendMu.Unlock()
40+ return paintRejected
41+ }
42+ }
43+ if ac.output != nil {
44+ ac.output.syncCapacityLocked()
45+ if ac.output.atCapacity() {
46+ ac.sendMu.Unlock()
47+ return paintBlockedCapacity
48+ }
49+ }
50+ if ac.proxiedOutputStarted {
51+ failedTransport, err := d.sendProxiedMetadataLocked(entry, ac, &marks)
52+ if err != nil {
53+ ac.sendMu.Unlock()
54+ if marks.attachmentEffect == nil {
55+ d.detachOnSendError(entry, ac, failedTransport)
56+ } else {
57+ token := marks.attachmentEffect.connectionToken()
58+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, failedTransport)
59+ marks.attachmentEffect.End()
60+ launchCleanup()
61+ }
62+ return paintRejected
63+ }
64+ }
65+
66+ state, ok := captureLocalRenderState(entry, ac, renderCaptureRequest{reset: reset, lease: lease})
67+ if !ok {
68+ ac.sendMu.Unlock()
69+ return paintRejected
70+ }
71+ composed := composeProxiedContent(*state)
72+ ac.proxiedOutputStarted = true
73+ d.emitFrame(entry, ac, state, composed, &marks)
74+ return paintEmitted
75+}
76+
77+// sendProxiedMetadataLocked emits a newer authoritative snapshot before every
78+// post-handshake proxied Output. Caller holds ac.sendMu and no architecture
79+// lock; metadata capture itself completes before the transport write.
80+func (d *Daemon) sendProxiedMetadataLocked(entry *session, ac *attachedClient, marks *runtimeMarkBatch) (ports.Transport, error) {
81+ if entry == nil || ac == nil || ac.proxiedMetaRevision == ^uint64(0) {
82+ return nil, errAttachmentTransition
83+ }
84+ meta, err := frameSessionMeta(entry, ac, ac.proxiedMetaRevision+1)
85+ if err != nil {
86+ return nil, err
87+ }
88+ expected := ac.transportSnapshot()
89+ if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
90+ return expected.transport, errTransportReplaced
91+ }
92+ if marks != nil && marks.attachmentEffect != nil {
93+ if !marks.attachmentEffect.beginTransportSend(expected) {
94+ return expected.transport, errAttachmentTransition
95+ }
96+ defer marks.attachmentEffect.endTransportSend()
97+ }
98+ if err := expected.transport.Send(meta); err != nil {
99+ if marks != nil && marks.attachmentEffect != nil {
100+ marks.attachmentEffect.reportTransportFailure(expected)
101+ }
102+ return expected.transport, err
103+ }
104+ ac.proxiedMetaRevision++
105+ return expected.transport, nil
106+}
107+
108+// composeProxiedContent retains the remote terminal layout but excludes every
109+// daemon UI layer. Pane frames are copied at their content placements, so the
110+// emitted size is exactly the proxied content geometry.
111+func composeProxiedContent(state capturedRenderState) composedRenderFrame {
112+ area := state.layout.area
113+ // The proxied attachment owns the remote content window. Shared layout may
114+ // lag an accepted resize or be driven by another local attachment, so never
115+ // derive the remote wire surface from it when the attachment supplied a
116+ // valid content geometry.
117+ if state.window.Valid() {
118+ area.Width, area.Height = state.window.Cols, state.window.Rows
119+ }
120+ if area.Width <= 0 || area.Height <= 0 {
121+ return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
122+ }
123+ frame := renderer.NewFrame(area.Width, area.Height)
124+ full := state.reset || state.floating.visible
125+ damage := make([]renderer.Damage, 0, len(state.panes))
126+ for _, pane := range state.panes {
127+ if pane.placement.Collapsed || pane.placement.Content.Width <= 0 || pane.placement.Content.Height <= 0 {
128+ continue
129+ }
130+ blitPaneFrame(frame, pane.placement.Content, pane.frame, false, themeui.NewDimmer(themeui.Theme{}))
131+ if len(pane.damage) != 0 {
132+ damage = append(damage, pane.damage...)
133+ }
134+ }
135+ if state.floating.visible {
136+ // A floating pane is the active terminal surface. Proxied mode omits its
137+ // local chrome/border but must preserve its content and cursor target.
138+ blitPaneFrame(frame, state.floating.geometry.Inner, state.floating.pane.frame, false, themeui.NewDimmer(themeui.Theme{}))
139+ }
140+ if full {
141+ damage = []renderer.Damage{renderer.FullRedraw()}
142+ }
143+ return composedRenderFrame{
144+ frame: frame,
145+ damage: damage,
146+ cursor: desiredCapturedCursor(state.cursor, 0),
147+ reset: state.reset,
148+ }
149+}
M internal/usecase/daemon/render.go +6 -2
@@ -299,6 +299,9 @@ const (
299299 )
300300
301301 func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
302+ if ac != nil && ac.renderMode == ports.RenderModeProxiedContent {
303+ return d.paintProxiedContent(entry, ac, reset, lease)
304+ }
302305 // Session-owned PTY preparation remains local; attachment rendering is
303306 // captured through session.captureRenderState.
304307 sess := entry
@@ -424,7 +427,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
424427 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
425428 }
426429 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
427- state, ok := entry.captureRenderState(ac, renderCaptureRequest{
430+ captureRequest := renderCaptureRequest{
428431 bars: bars,
429432 overlays: capturedOverlays,
430433 preview: preview,
@@ -433,7 +436,8 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
433436 styleGeneration: applied.Generation,
434437 reset: reset,
435438 lease: lease,
436- })
439+ }
440+ state, ok := entry.captureRenderState(ac, captureRequest)
437441 endCapture(0, ok)
438442 if !ok {
439443 ac.sendMu.Unlock()
M internal/usecase/daemon/resize_publication.go +2 -2
@@ -28,11 +28,11 @@ func (p resizeCommitPublication) current() bool {
2828 if p.connection.effect == nil {
2929 return false
3030 }
31- if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
31+ if p.connection.effect.ended.Load() || !sameAttachmentOwner(p.connection.owner, p.session) ||
3232 p.connection.ac != p.attachment || p.connection.lease != p.lease ||
3333 p.connection.generation != p.attachment.connectionGeneration.Load() ||
3434 !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
35- p.attachment.currentSession() != p.session || p.coordinator == nil {
35+ !sameAttachmentOwner(p.attachment.currentAttachmentOwner(), p.session) || p.coordinator == nil {
3636 return false
3737 }
3838 registered := attachmentRegistered(p.session, p.attachment)
M internal/usecase/daemon/resume.go +48 -45
@@ -41,47 +41,48 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
4141 // it returns 0 and never creates a marker after terminal
4242 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
4343 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
44- if sess == nil || ac == nil || !ac.resumeCapable {
44+ return d.markParkingInFlightOwner(sess, ac)
45+}
46+
47+// markParkingInFlightOwner advertises a stable attachment binding before its
48+// live membership is removed. The resume credential identifies this owner
49+// binding, never a local session name.
50+func (d *Daemon) markParkingInFlightOwner(owner attachmentOwner, ac *attachedClient) uint64 {
51+ if normalizeAttachmentOwner(owner) == nil || ac == nil || !ac.resumeCapable {
4552 return 0
4653 }
4754 d.mu.Lock()
4855 defer d.mu.Unlock()
49- if d.closing || d.sessions[sess.id] != sess {
50- return 0
51- }
52- sess.mu.Lock()
53- registered := attachmentRegisteredLocked(sess, ac)
54- sess.mu.Unlock()
55- if !registered {
56+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
5657 return 0
5758 }
5859 if ac.resumeToken == 0 {
5960 ac.resumeToken = d.nextResumeTokenLocked()
6061 }
61- d.ensureParkingInFlightLocked(sess, ac)
62- d.log.Info("parking in flight", "session", sess.name)
62+ d.ensureParkingInFlightLocked(owner, ac)
63+ d.log.Info("parking in flight", "session", attachmentOwnerName(owner))
6364 return ac.resumeToken
6465 }
6566
6667 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
6768 // Caller holds d.mu and has verified the daemon is not closing.
68-func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
69+func (d *Daemon) ensureParkingInFlightLocked(owner attachmentOwner, ac *attachedClient) {
6970 token := ac.resumeToken
7071 if token == 0 {
7172 return
7273 }
7374 if existing := d.parking[token]; existing != nil {
7475 if existing.ac == ac {
75- existing.sess = sess
76+ existing.owner = owner
7677 return
7778 }
7879 delete(d.parking, token)
7980 existing.closeDone()
8081 }
8182 d.parking[token] = &parkingAttachment{
82- sess: sess,
83- ac: ac,
84- done: make(chan struct{}),
83+ owner: owner,
84+ ac: ac,
85+ done: make(chan struct{}),
8586 }
8687 }
8788
@@ -99,21 +100,16 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
99100 // If another path already detached/unrouted, that path
100101 // owns park publication.
101102 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
103+ d.clearParkingInFlightIfAbandonedOwner(sess, ac, token)
104+}
105+
106+func (d *Daemon) clearParkingInFlightIfAbandonedOwner(owner attachmentOwner, ac *attachedClient, token uint64) {
102107 if token == 0 || ac == nil {
103108 return
104109 }
105110 d.mu.Lock()
106111 defer d.mu.Unlock()
107- if d.parked[token] != nil {
108- return
109- }
110- stillOwner := false
111- if sess != nil {
112- sess.mu.Lock()
113- stillOwner = attachmentRegisteredLocked(sess, ac)
114- sess.mu.Unlock()
115- }
116- if !stillOwner {
112+ if d.parked[token] != nil || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
117113 return
118114 }
119115 d.clearParkingInFlightLocked(token, ac)
@@ -143,7 +139,7 @@ func (d *Daemon) purgeAllParkingLocked() {
143139 // the session is removed before park publication. Caller holds d.mu.
144140 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
145141 for token, pending := range d.parking {
146- if pending.sess == sess {
142+ if localSession(pending.owner) == sess {
147143 delete(d.parking, token)
148144 pending.closeDone()
149145 }
@@ -167,7 +163,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
167163 d.mu.Unlock()
168164 return false
169165 }
170- if h.Name != "" && pending.sess != nil && pending.sess.name != h.Name {
166+ if sess := localSession(pending.owner); h.Name != "" && sess != nil && sess.name != h.Name {
171167 d.mu.Unlock()
172168 return false
173169 }
@@ -182,27 +178,34 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
182178 }
183179
184180 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
185- if !ac.resumeCapable {
181+ return d.prepareParkAttachmentOwner(sess, ac)
182+}
183+
184+func (d *Daemon) prepareParkAttachmentOwner(owner attachmentOwner, ac *attachedClient) bool {
185+ if normalizeAttachmentOwner(owner) == nil || ac == nil || !ac.resumeCapable {
186186 return false
187187 }
188188 d.mu.Lock()
189189 defer d.mu.Unlock()
190- if d.closing {
190+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
191191 return false
192192 }
193193 if ac.resumeToken == 0 {
194194 ac.resumeToken = d.nextResumeTokenLocked()
195195 }
196196 // Do not recreate parking markers here: post-detach publication races
197- // terminal session removal and can strand IntentResume waiters. Callers
198- // that need the detach→park gap advertised must markParkingInFlight while
199- // still the exact live owner. Direct parkAttachment still parks below.
200- d.log.Info("parking client prepared", "session", sess.name)
197+ // terminal owner removal and can strand IntentResume waiters. Callers that
198+ // need the detach→park gap advertised must publish it while still exact.
199+ d.log.Info("parking client prepared", "session", attachmentOwnerName(owner))
201200 return true
202201 }
203202
204203 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
205- if !d.prepareParkAttachment(sess, ac) {
204+ return d.parkAttachmentOwner(sess, ac)
205+}
206+
207+func (d *Daemon) parkAttachmentOwner(owner attachmentOwner, ac *attachedClient) bool {
208+ if !d.prepareParkAttachmentOwner(owner, ac) {
206209 return false
207210 }
208211 // A parked attachment has no session owner. Release pane snapshots before
@@ -217,7 +220,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
217220 ac.overlays.pickerMu.Unlock()
218221 }
219222 d.mu.Lock()
220- if d.closing || d.sessions[sess.id] != sess {
223+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
221224 d.clearParkingInFlightLocked(ac.resumeToken, ac)
222225 d.mu.Unlock()
223226 return false
@@ -237,7 +240,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
237240 }
238241 grace := d.resumeParkGrace
239242 timer := d.clock.NewTimer(grace)
240- parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
243+ parked := &parkedAttachment{owner: owner, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
241244 ac.parked = true
242245 d.parked[token] = parked
243246 d.clearParkingInFlightLocked(token, ac)
@@ -251,7 +254,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
251254 }
252255 oldSame.closeDone()
253256 }
254- d.log.Info("client parked for resume", "session", sess.name, "grace", grace)
257+ d.log.Info("client parked for resume", "session", attachmentOwnerName(owner), "grace", grace)
255258 d.watchParkedTimer(token, parked)
256259 return true
257260 }
@@ -280,7 +283,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
280283 d.removeParkedLocked(token, parked)
281284 d.mu.Unlock()
282285 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
283- d.log.Warn("parked client expired", "session", parked.sess.name)
286+ d.log.Warn("parked client expired", "session", attachmentOwnerName(parked.owner))
284287 return
285288 }
286289 d.mu.Unlock()
@@ -349,7 +352,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
349352 ac.resumeToken = token
350353 ac.parked = true
351354 ac.connectionGeneration.Add(1)
352- sess := parked.sess
355+ sess := localSession(parked.owner)
353356 if sess != nil {
354357 sess.mu.Lock()
355358 sess.unregisterAttachmentLocked(ac)
@@ -363,7 +366,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
363366 }
364367 parked.closeDone()
365368 rearmed := &parkedAttachment{
366- sess: parked.sess,
369+ owner: parked.owner,
367370 ac: ac,
368371 pickerGeneration: parked.pickerGeneration,
369372 timer: d.clock.NewTimer(d.resumeParkGrace),
@@ -409,7 +412,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
409412 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
410413 var retirements []parkedAttachmentRetirement
411414 for token, parked := range d.parked {
412- if parked.sess == sess {
415+ if localSession(parked.owner) == sess {
413416 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
414417 }
415418 }
@@ -562,7 +565,7 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
562565 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
563566 }
564567 if d.parked[h.ResumeToken] != parked || parked.claimed {
565- if d.sessions[parked.sess.id] == parked.sess {
568+ if sess := localSession(parked.owner); sess != nil && d.sessions[sess.id] == sess {
566569 d.mu.Unlock()
567570 ac.sendMu.Unlock()
568571 return nil, nil, false, errResumeTokenLifecycleRace
@@ -587,11 +590,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
587590 if parked == nil || h.ResumeToken == 0 {
588591 return nil, nil, false, nil
589592 }
590- sess := parked.sess
591- registered := d.sessions[sess.id] == sess
593+ sess := localSession(parked.owner)
594+ registered := sess != nil && d.sessions[sess.id] == sess
592595 if !registered {
593596 d.removeParkedLocked(h.ResumeToken, parked)
594- d.log.Warn("resume rejected", "session", sess.name, "registered", false)
597+ d.log.Warn("resume rejected", "session", attachmentOwnerName(parked.owner), "registered", false)
595598 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
596599 }
597600 ac := parked.ac
M internal/usecase/daemon/resume_test.go +7 -7
@@ -533,7 +533,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
533533 d.mu.Unlock()
534534 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
535535 require.Same(t, ac, parkingInGap.ac)
536- require.Same(t, sess, parkingInGap.sess)
536+ require.Same(t, sess, localSession(parkingInGap.owner))
537537 require.False(t, parkedInGap, "fixture: park must not have published yet")
538538
539539 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
@@ -1268,7 +1268,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
12681268 require.NoError(t, err)
12691269
12701270 previous := &session{sessionCore: sessionCore{id: "previous"}}
1271- ac.previousSession.Set(previous)
1271+ ac.previousOwner.Set(previous)
12721272 d.clientGone(sess, ac, ac.transport(), false)
12731273
12741274 token := ac.resumeToken
@@ -1276,14 +1276,14 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
12761276 parked := d.parked[token]
12771277 d.mu.Unlock()
12781278 require.NotNil(t, parked)
1279- require.Same(t, previous, ac.previousSession.Get(), "a live parked attachment keeps its toggle")
1279+ require.Same(t, previous, ac.previousOwner.Get(), "a live parked attachment keeps its toggle")
12801280
12811281 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
12821282 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
12831283 require.NoError(t, err)
12841284 require.Same(t, sess, resumedSess)
12851285 require.Same(t, ac, resumedAC)
1286- require.Same(t, previous, resumedAC.previousSession.Get(), "resume keeps the live attachment toggle")
1286+ require.Same(t, previous, resumedAC.previousOwner.Get(), "resume keeps the live attachment toggle")
12871287 }
12881288
12891289 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
@@ -1301,7 +1301,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13011301 {
13021302 name: "session purge",
13031303 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1304- return d.purgeParkedForSessionLocked(parked.sess)
1304+ return d.purgeParkedForSessionLocked(localSession(parked.owner))
13051305 },
13061306 },
13071307 } {
@@ -1313,7 +1313,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13131313 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
13141314 require.NoError(t, err)
13151315
1316- ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}})
1316+ ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "previous"}})
13171317 d.clientGone(sess, ac, ac.transport(), false)
13181318
13191319 d.mu.Lock()
@@ -1323,7 +1323,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
13231323 d.mu.Unlock()
13241324 d.finishParkedAttachmentRetirements(retirements)
13251325
1326- require.Nil(t, ac.previousSession.Get())
1326+ require.Nil(t, ac.previousOwner.Get())
13271327 })
13281328 }
13291329 }
M internal/usecase/daemon/session.go +18 -16
@@ -467,7 +467,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
467467 if err := domain.ValidateSessionName(name); err != nil {
468468 return err
469469 }
470- if token.sess == nil || token.ac == nil || token.effect == nil {
470+ source := token.localSession()
471+ if source == nil || token.ac == nil || token.effect == nil {
471472 return errAttachmentTransition
472473 }
473474 token.effect.bindActionEnd(d, "create-session")
@@ -475,12 +476,11 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
475476
476477 var created *session
477478 transition, err := d.transitionAttachment(attachmentTransitionRequest{
478- source: token.sess, next: token.ac,
479+ source: source, next: token.ac,
479480
480481 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
481482 copySourceEnvironment: true, ready: true,
482483 createTargetLocked: func() (*session, error) {
483- source := token.sess
484484 if source == nil {
485485 return nil, errAttachmentTransition
486486 }
@@ -506,8 +506,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
506506 }
507507
508508 d.touchMRU(created)
509- if token.sess != nil {
510- token.ac.recordPreviousSession(token.sess)
509+ if source != nil {
510+ token.ac.recordPreviousSession(source)
511511 }
512512 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
513513 d.deferAttachmentTransitionCleanups(transition)
@@ -773,14 +773,15 @@ func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool
773773 }
774774
775775 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
776- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
776+ sess := token.localSession()
777+ if sess == nil || token.ac == nil || token.transport.transport == nil {
777778 return false
778779 }
779780 // Attachments without the session's optional primary render lease still
780781 // own an independent connection lifecycle and detach through the same exact
781782 // transport/generation fence.
782783 if token.lease == nil {
783- return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)
784+ return d.detachIfCurrentTransport(sess, token.ac, token.transport)
784785 }
785786 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
786787 defer frozen.unfreeze()
@@ -791,8 +792,8 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
791792 d.afterDetachAttachmentEffectsFrozen()
792793 }
793794 d.mu.Lock()
794- core := token.sess.core()
795- if core == nil || d.sessions[core.id] != token.sess {
795+ core := sess.core()
796+ if core == nil || d.sessions[core.id] != sess {
796797 d.mu.Unlock()
797798 return false
798799 }
@@ -803,11 +804,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
803804 coordinator.mu.Lock()
804805 }
805806 req := attachmentTransitionRequest{
806- source: token.sess, next: token.ac, expectedTransport: token.transport,
807+ source: sess, next: token.ac, expectedTransport: token.transport,
807808 }
808- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
809+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
809810 if current {
810- unregisterAttachmentSessionLocked(token.sess, token.ac)
811+ unregisterAttachmentSessionLocked(sess, token.ac)
811812 token.ac.connectionGeneration.Add(1)
812813 token.ac.setSession(nil)
813814 token.ac.invalidateFrozenAttachmentCapability()
@@ -1168,10 +1169,10 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
11681169 snapshot := sessionKillParticipants{target: target}
11691170 if admission != nil {
11701171 token := admission.token
1171- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1172+ if token.localSession() == nil || token.ac == nil || token.transport.transport == nil {
11721173 return sessionKillParticipants{}, false
11731174 }
1174- snapshot.source = token.sess
1175+ snapshot.source = token.localSession()
11751176 if snapshot.source == nil {
11761177 return sessionKillParticipants{}, false
11771178 }
@@ -1271,10 +1272,11 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
12711272 return true
12721273 }
12731274 token := *snapshot.sourceToken
1275+ source := token.localSession()
12741276 req := attachmentTransitionRequest{
1275- source: token.sess, next: token.ac, expectedTransport: token.transport,
1277+ source: source, next: token.ac, expectedTransport: token.transport,
12761278 }
1277- return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1279+ return transitionSourceTokenCurrentLocked(token, source, sourceCoordinator, req)
12781280 }
12791281
12801282 // stopInMemoryLifecycle cancels every session producer and closes every PTY
M internal/usecase/daemon/session_back.go +36 -16
@@ -5,15 +5,24 @@ import (
55 "github.com/bnema/vev/internal/usecase/picker"
66 )
77
8-// backSession toggles this attachment between its current session and the
9-// immediately preceding successfully activated session.
8+// backSession toggles this attachment between its current local session and
9+// the immediately preceding successfully activated local session. A remote
10+// owner is retained in the owner history but is not interpreted as a local
11+// session; remote back navigation is published with remote transitions.
1012 func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
11- if d == nil || token.sess == nil || token.ac == nil {
13+ current := token.localSession()
14+ if d == nil || current == nil || token.ac == nil {
1215 return nil
1316 }
14- target := token.ac.previousSession.Get()
15- if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target {
16- token.ac.clearPreviousSessionIf(target)
17+ previous := token.ac.previousOwner.Get()
18+ target := localSession(previous)
19+ if target == nil {
20+ // A remote predecessor remains stable history for the remote transition
21+ // path; the local-only back path must not discard it.
22+ return nil
23+ }
24+ if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
25+ token.ac.clearPreviousOwnerIf(previous)
1726 return nil
1827 }
1928 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
@@ -24,9 +33,16 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
2433 if d == nil || current == nil || ac == nil {
2534 return
2635 }
27- target := ac.previousSession.Get()
28- if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
29- ac.clearPreviousSessionIf(target)
36+ previous := ac.previousOwner.Get()
37+ target := localSession(previous)
38+ if target == nil {
39+ if previous == nil {
40+ d.invalidateRender(current, ac, true, "session_back.go")
41+ }
42+ return
43+ }
44+ if target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
45+ ac.clearPreviousOwnerIf(previous)
3046 d.invalidateRender(current, ac, true, "session_back.go")
3147 return
3248 }
@@ -43,24 +59,28 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
4359 }
4460 }
4561
46-// clearPreviousSessionIf clears target only if it has not been replaced since
62+// clearPreviousOwnerIf clears target only if it has not been replaced since
4763 // the caller observed it. This preserves a concurrent successful hand-off.
48-func (ac *attachedClient) clearPreviousSessionIf(target *session) {
64+func (ac *attachedClient) clearPreviousOwnerIf(target attachmentOwner) {
4965 if ac == nil {
5066 return
5167 }
52- ac.previousSession.With(func(previous **session) {
53- if *previous == target {
68+ ac.previousOwner.With(func(previous *attachmentOwner) {
69+ if sameAttachmentOwner(*previous, target) {
5470 *previous = nil
5571 }
5672 })
5773 }
5874
59-// recordPreviousSession is called only after a cross-session hand-off has
75+// recordPreviousOwner is called only after a cross-owner hand-off has
6076 // committed. Keeping this after the commit makes failed/displaced switches
6177 // leave the toggle pair intact.
62-func (ac *attachedClient) recordPreviousSession(origin *session) {
78+func (ac *attachedClient) recordPreviousOwner(origin attachmentOwner) {
6379 if ac != nil && origin != nil {
64- ac.previousSession.Set(origin)
80+ ac.previousOwner.Set(origin)
6581 }
6682 }
83+
84+func (ac *attachedClient) recordPreviousSession(origin *session) {
85+ ac.recordPreviousOwner(origin)
86+}
M internal/usecase/daemon/session_geometry.go +9 -1
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/ports"
6+)
47
58 // sessionGeometrySource identifies the attachment whose latest attach,
69 // resume, or resize claim controls shared PTY/layout geometry. claim is zero
@@ -62,6 +65,11 @@ func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource,
6265 return domain.Size{}, sessionGeometrySource{}, false
6366 }
6467 content := contentSize(source.size)
68+ if source.attachment.renderMode == ports.RenderModeProxiedContent {
69+ // A proxied Hello already carries content geometry; do not subtract the
70+ // remote daemon's local chrome rows from it.
71+ content = source.size
72+ }
6573 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
6674 }
6775
M internal/usecase/daemon/wire.go +38 -0
@@ -12,6 +12,7 @@ func frameWelcome(s *session, ac *attachedClient) ports.Frame {
1212 SessionID: string(s.id),
1313 SessionName: s.name,
1414 Ephemeral: s.ephemeral,
15+ RenderMode: ac.renderMode,
1516 Capabilities: ports.CapabilityResume,
1617 }
1718 s.mu.Unlock()
@@ -19,6 +20,43 @@ func frameWelcome(s *session, ac *attachedClient) ports.Frame {
1920 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
2021 }
2122
23+// frameSessionMeta snapshots the authoritative remote session identity and
24+// ordered stable tab metadata for a proxied attachment. Its first publication
25+// is emitted during the handshake before that attachment may render output.
26+func frameSessionMeta(s *session, ac *attachedClient, revision uint64) (ports.Frame, error) {
27+ if s == nil || ac == nil {
28+ return ports.Frame{}, ports.ErrInvalidSessionMeta
29+ }
30+ // Capture the session tab set and this attachment's active stable tab under
31+ // the same session snapshot. A stale attachment view must fall back to the
32+ // first current tab instead of emitting metadata whose active ID is absent
33+ // from Tabs and therefore invalid on the wire.
34+ s.mu.Lock()
35+ active := ac.viewSnapshot().tabID
36+ meta := ports.SessionMeta{
37+ LifecycleID: s.incarnation,
38+ Revision: revision,
39+ SessionName: s.name,
40+ Tabs: make([]ports.SessionTabMeta, 0, len(s.tabs)),
41+ }
42+ activePresent := false
43+ for i, tab := range s.tabs {
44+ id := domain.TabStableID(tab.stableID)
45+ meta.Tabs = append(meta.Tabs, ports.SessionTabMeta{ID: id, Name: tabDisplayName(tab, i), Attention: tab.attention})
46+ activePresent = activePresent || active == id
47+ }
48+ if !activePresent && len(meta.Tabs) != 0 {
49+ active = meta.Tabs[0].ID
50+ }
51+ meta.ActiveTabID = active
52+ s.mu.Unlock()
53+ payload, err := ports.MarshalSessionMeta(meta)
54+ if err != nil {
55+ return ports.Frame{}, err
56+ }
57+ return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil
58+}
59+
2260 func frameError(code uint16, text string) ports.Frame {
2361 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
2462 }
M scripts/remote-picker-harness/README.md +12 -0
@@ -18,6 +18,18 @@ Environment overrides:
1818 containers (default: `ubuntu:24.04`).
1919 - `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary
2020 (default: `test@remote`).
21+- `VEV_HARNESS_ARTIFACT_DIR`, when set, writes a bounded
22+ `remote-picker-harness.json` containing output-state, checkpoint, and event
23+ metadata. It is unset by default; the artifact contains no terminal bytes,
24+ raw screen text, credentials, or target output. `run.sh` copies the report to
25+ that host directory before it removes the disposable containers.
26+
27+Each logical transport has one persistent `vt.Screen` and a client-equivalent
28+output-state chain. The probe writes accepted full, incremental, and side-effect
29+frames to that screen, ACKs only accepted state-bearing frames, and retains a
30+bounded set of screen checkpoints plus non-sensitive event metadata. The local
31+picker phase uses separate local-picker and selected-remote probes to
32+characterize the existing `MsgAttachTarget` direct handoff and its event order.
2133
2234 The run exercises real SSH-stdio and UDP attachment, typed live catalog and
2335 preview requests, tab creation/removal fencing, exact lifecycle fencing after
M scripts/remote-picker-harness/main.go +92 -83
@@ -20,7 +20,6 @@ import (
2020 "github.com/bnema/vev/internal/adapters/sshstdio"
2121 "github.com/bnema/vev/internal/domain"
2222 "github.com/bnema/vev/internal/ports"
23- "github.com/bnema/vev/pkg/vt"
2423 )
2524
2625 const (
@@ -38,7 +37,7 @@ func main() {
3837 fmt.Println("remote picker harness: PASS")
3938 }
4039
41-func run() error {
40+func run() (runErr error) {
4241 target := os.Getenv("VEV_HARNESS_TARGET")
4342 if target == "" {
4443 target = defaultTarget
@@ -46,6 +45,16 @@ func run() error {
4645 ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
4746 defer cancel()
4847
48+ artifact := newHarnessArtifact(os.Getenv("VEV_HARNESS_ARTIFACT_DIR"))
49+ if artifact != nil {
50+ ctx = withHarnessArtifact(ctx, artifact)
51+ defer func() {
52+ if err := artifact.write(runErr == nil); err != nil {
53+ runErr = errors.Join(runErr, err)
54+ }
55+ }()
56+ }
57+
4958 catalog := remoteadapter.NewCatalogClient()
5059 preview := remoteadapter.NewPreviewClient()
5160 if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
@@ -148,10 +157,15 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
148157 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
149158 )
150159
160+ trace := &probeTrace{}
161+ if artifact := harnessArtifactFromContext(ctx); artifact != nil {
162+ artifact.registerTrace(trace)
163+ }
151164 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
152165 if err != nil {
153166 return fmt.Errorf("open remote picker source: %w", err)
154167 }
168+ remote.probe.configure("remote-picker-source", nil)
155169 ownsRemoteSession := false
156170 defer func() {
157171 _ = remote.Close()
@@ -229,6 +243,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
229243 if err != nil {
230244 return fmt.Errorf("open local picker transport: %w", err)
231245 }
246+ local.probe.configure("local-picker", trace)
232247 defer func() { _ = local.Close() }()
233248 if err := attachAndCheck(ctx, local, ports.Hello{
234249 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
@@ -286,6 +301,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
286301 if err != nil {
287302 return fmt.Errorf("open selected remote transport: %w", err)
288303 }
304+ selected.probe.configure("selected-remote", trace)
289305 defer func() { _ = selected.Close() }()
290306 if err := attachAndCheck(ctx, selected, ports.Hello{
291307 Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
@@ -297,6 +313,9 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
297313 if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {
298314 return fmt.Errorf("remote provenance status bar: %w", err)
299315 }
316+ if err := validateHandoffEventOrdering(trace.events); err != nil {
317+ return fmt.Errorf("characterize remote picker handoff: %w", err)
318+ }
300319 return nil
301320 }
302321
@@ -433,6 +452,7 @@ type harnessFrame struct {
433452
434453 type harnessTransport struct {
435454 ports.Transport
455+ probe *visualProbe
436456 frames chan harnessFrame
437457 stop chan struct{}
438458 closeOnce sync.Once
@@ -440,7 +460,12 @@ type harnessTransport struct {
440460 }
441461
442462 func newHarnessTransport(transport ports.Transport) *harnessTransport {
443- h := &harnessTransport{Transport: transport, frames: make(chan harnessFrame, 16), stop: make(chan struct{})}
463+ h := &harnessTransport{
464+ Transport: transport,
465+ probe: newVisualProbe(domain.Size{Cols: defaultProbeCols, Rows: defaultProbeRows}),
466+ frames: make(chan harnessFrame, 16),
467+ stop: make(chan struct{}),
468+ }
444469 go h.receiveLoop()
445470 return h
446471 }
@@ -484,7 +509,12 @@ func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target s
484509 if err != nil {
485510 return nil, err
486511 }
487- return newHarnessTransport(transport), nil
512+ h := newHarnessTransport(transport)
513+ h.probe.configure(string(mode), nil)
514+ if artifact := harnessArtifactFromContext(ctx); artifact != nil {
515+ artifact.registerProbe(h.probe)
516+ }
517+ return h, nil
488518 }
489519
490520 const commandStderrLimit = 4 << 10
@@ -585,7 +615,12 @@ func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
585615 if err != nil {
586616 return nil, err
587617 }
588- return newHarnessTransport(transport), nil
618+ h := newHarnessTransport(transport)
619+ h.probe.configure("local", nil)
620+ if artifact := harnessArtifactFromContext(ctx); artifact != nil {
621+ artifact.registerProbe(h.probe)
622+ }
623+ return h, nil
589624 }
590625
591626 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {
@@ -612,6 +647,7 @@ func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello
612647 if frame.Type != ports.MsgWelcome {
613648 return fmt.Errorf("welcome frame type %d", frame.Type)
614649 }
650+ tr.probe.recordControl(probeEventWelcome)
615651 if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {
616652 return fmt.Errorf("send initial theme: %w", err)
617653 }
@@ -622,22 +658,41 @@ func sendRawInput(tr *harnessTransport, data string) error {
622658 return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})
623659 }
624660
625-func acknowledgeOutput(tr *harnessTransport, output ports.Output) error {
626- if output.Epoch == 0 || output.New == 0 {
661+func processOutputFrame(tr *harnessTransport, frame ports.Frame) error {
662+ output, err := ports.UnmarshalOutput(frame.Payload)
663+ if err != nil {
664+ return err
665+ }
666+ result := tr.probe.apply(output)
667+ if !result.Accepted {
668+ if !tr.probe.resetPending {
669+ if err := tr.Send(ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}); err != nil {
670+ return err
671+ }
672+ tr.probe.resetPending = true
673+ tr.probe.recordControl(probeEventOutputReset)
674+ }
675+ return nil
676+ }
677+ if !result.StateBearing {
627678 return nil
628679 }
629- payload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
680+ payload, err := ports.MarshalAck(result.Ack)
630681 if err != nil {
631682 return err
632683 }
633- return tr.Send(ports.Frame{Type: ports.MsgAck, Payload: payload})
684+ if err := tr.Send(ports.Frame{Type: ports.MsgAck, Payload: payload}); err != nil {
685+ return err
686+ }
687+ tr.probe.resetPending = false
688+ tr.probe.markAcked(result)
689+ return nil
634690 }
635691
636692 func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
637693 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
638694 defer cancel()
639- var received strings.Builder
640- screen := vt.NewScreen(80, 24)
695+ visible := tr.probe.contains(want)
641696 for {
642697 frame, err := receiveWithTimeout(deadline, tr)
643698 if err != nil {
@@ -646,75 +701,46 @@ func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string)
646701 if frame.Type != ports.MsgOutput {
647702 continue
648703 }
649- output, err := ports.UnmarshalOutput(frame.Payload)
650- if err != nil {
651- return err
652- }
653- if err := acknowledgeOutput(tr, output); err != nil {
704+ if err := processOutputFrame(tr, frame); err != nil {
654705 return err
655706 }
656- received.Write(output.Data)
657- screen.Write(output.Data)
658- if strings.Contains(received.String(), want) || strings.Contains(harnessScreenText(screen), want) {
707+ nowVisible := tr.probe.contains(want)
708+ if !visible && nowVisible {
659709 return nil
660710 }
661- if received.Len() > 64<<10 {
662- text := received.String()
663- received.Reset()
664- received.WriteString(text[len(text)-16<<10:])
665- }
711+ visible = nowVisible
666712 }
667713 }
668714
669715 func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
670716 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
671717 defer cancel()
672- screen := vt.NewScreen(80, 24)
673- outputFrames := 0
674718 sawLoading := false
719+ markerVisible := tr.probe.contains(marker)
675720 for {
676721 frame, err := receiveWithTimeout(deadline, tr)
677722 if err != nil {
678- return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", outputFrames, sawLoading, err)
723+ return fmt.Errorf("no rendered remote preview after %d output frames (loading=%t): %w", tr.probe.outputFrames, sawLoading, err)
679724 }
680725 if frame.Type != ports.MsgOutput {
681726 continue
682727 }
683- output, err := ports.UnmarshalOutput(frame.Payload)
684- if err != nil {
685- return err
686- }
687- if err := acknowledgeOutput(tr, output); err != nil {
728+ if err := processOutputFrame(tr, frame); err != nil {
688729 return err
689730 }
690- outputFrames++
691- screen.Write(output.Data)
692- text := harnessScreenText(screen)
693- sawLoading = sawLoading || strings.Contains(text, "loading remote preview") || strings.Contains(string(output.Data), "loading remote preview")
694- if strings.Contains(text, marker) || strings.Contains(string(output.Data), marker) {
731+ text := tr.probe.text()
732+ sawLoading = sawLoading || strings.Contains(text, "loading remote preview")
733+ nowMarkerVisible := strings.Contains(text, marker)
734+ if !markerVisible && nowMarkerVisible {
695735 return nil
696736 }
737+ markerVisible = nowMarkerVisible
697738 if strings.Contains(text, "remote preview unavailable") {
698739 return errors.New("remote preview unavailable")
699740 }
700741 }
701742 }
702743
703-func harnessScreenText(screen *vt.Screen) string {
704- var text strings.Builder
705- for y := range screen.Frame.Height {
706- for x := range screen.Frame.Width {
707- r := screen.Frame.At(x, y).Rune
708- if r == 0 {
709- r = ' '
710- }
711- text.WriteRune(r)
712- }
713- text.WriteByte('\n')
714- }
715- return text.String()
716-}
717-
718744 func remotePreviewContains(preview ports.RemotePreview, want string) bool {
719745 var text strings.Builder
720746 for _, cell := range preview.Cells {
@@ -734,15 +760,14 @@ func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType)
734760 return ports.Frame{}, err
735761 }
736762 if frame.Type == typ {
763+ if typ == ports.MsgAttachTarget {
764+ tr.probe.recordControl(probeEventAttachTarget)
765+ }
737766 return frame, nil
738767 }
739768 if frame.Type == ports.MsgOutput {
740- output, decodeErr := ports.UnmarshalOutput(frame.Payload)
741- if decodeErr != nil {
742- return ports.Frame{}, decodeErr
743- }
744- if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
745- return ports.Frame{}, ackErr
769+ if err := processOutputFrame(tr, frame); err != nil {
770+ return ports.Frame{}, err
746771 }
747772 }
748773 }
@@ -752,37 +777,25 @@ func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want
752777 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
753778 return err
754779 }
755- var received strings.Builder
756- var outputFrames int
757- var outputBytes int
758780 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
759781 defer cancel()
782+ visible := tr.probe.contains(want)
760783 for {
761784 frame, err := receiveWithTimeout(deadline, tr)
762785 if err != nil {
763- return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", outputFrames, outputBytes, err)
786+ return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", tr.probe.outputFrames, tr.probe.outputBytes, err)
764787 }
765788 if frame.Type != ports.MsgOutput {
766789 continue
767790 }
768- output, err := ports.UnmarshalOutput(frame.Payload)
769- if err != nil {
770- return err
771- }
772- outputFrames++
773- outputBytes += len(output.Data)
774- if err := acknowledgeOutput(tr, output); err != nil {
775- return fmt.Errorf("ack output: %w", err)
791+ if err := processOutputFrame(tr, frame); err != nil {
792+ return fmt.Errorf("process output: %w", err)
776793 }
777- received.Write(output.Data)
778- if strings.Contains(received.String(), want) {
794+ nowVisible := tr.probe.contains(want)
795+ if !visible && nowVisible {
779796 return nil
780797 }
781- if received.Len() > 64<<10 {
782- text := received.String()
783- received.Reset()
784- received.WriteString(text[len(text)-16<<10:])
785- }
798+ visible = nowVisible
786799 }
787800 }
788801
@@ -809,12 +822,8 @@ func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
809822 return err
810823 }
811824 if frame.Type == ports.MsgOutput {
812- output, decodeErr := ports.UnmarshalOutput(frame.Payload)
813- if decodeErr != nil {
814- return decodeErr
815- }
816- if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
817- return ackErr
825+ if err := processOutputFrame(tr, frame); err != nil {
826+ return err
818827 }
819828 continue
820829 }
M scripts/remote-picker-harness/run.sh +20 -1
@@ -16,6 +16,9 @@ Environment:
1616 already select the rootless daemon.
1717 VEV_HARNESS_BASE_IMAGE Base image for the disposable containers
1818 (default: ubuntu:24.04).
19+ VEV_HARNESS_ARTIFACT_DIR Host directory for the bounded probe artifact.
20+ The JSON report is copied there before container
21+ cleanup; no report is written when unset.
1922 USAGE
2023 }
2124
@@ -33,6 +36,11 @@ esac
3336
3437 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
3538 base_image="${VEV_HARNESS_BASE_IMAGE:-ubuntu:24.04}"
39+artifact_dir="${VEV_HARNESS_ARTIFACT_DIR:-}"
40+if [ -n "$artifact_dir" ]; then
41+ umask 077
42+ mkdir -p "$artifact_dir"
43+fi
3644 run_id="$(date +%s)-$$"
3745 image="vev-remote-picker-harness:${run_id}"
3846 network="vev-remote-picker-harness-${run_id}"
@@ -106,4 +114,15 @@ if ! docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchM
106114 fi
107115
108116 printf 'remote picker harness: running acceptance checks\n'
109-docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness
117+harness_status=0
118+if [ -n "$artifact_dir" ]; then
119+ docker exec --user test -e HOME=/home/test -e VEV_HARNESS_ARTIFACT_DIR=/tmp/vev-harness-artifact "$local_container" /usr/local/bin/remote-picker-harness || harness_status=$?
120+ if ! docker cp "$local_container:/tmp/vev-harness-artifact/remote-picker-harness.json" "$artifact_dir/remote-picker-harness.json"; then
121+ echo 'remote picker harness: failed to collect probe artifact' >&2
122+ exit 1
123+ fi
124+ chmod 0600 "$artifact_dir/remote-picker-harness.json"
125+else
126+ docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness || harness_status=$?
127+fi
128+exit "$harness_status"
A scripts/remote-picker-harness/visual_probe.go +477 -0
@@ -0,0 +1,477 @@
1+package main
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/pkg/vt"
14+)
15+
16+const (
17+ defaultProbeCols = 80
18+ defaultProbeRows = 24
19+
20+ maxProbeEvents = 128
21+ maxProbeCheckpoints = 16
22+ maxProbeTraces = 128
23+ maxArtifactBytes = 64 << 10
24+
25+ probeEventOutput = "output"
26+ probeEventWelcome = "welcome"
27+ probeEventAttachTarget = "attach_target"
28+ probeEventOutputReset = "output_reset_request"
29+)
30+
31+// visualOutputState mirrors the client's output dependency chain. A probe may
32+// inspect output only after the frame's epoch, base, and view revision continue
33+// the last accepted state.
34+type visualOutputState struct {
35+ epoch uint64
36+ state uint64
37+ viewRevision uint64
38+ initialized bool
39+}
40+
41+func (s visualOutputState) next(output ports.Output) (visualOutputState, bool) {
42+ if output.Epoch == 0 {
43+ return visualOutputState{}, false
44+ }
45+ if !s.initialized {
46+ if output.New == 0 {
47+ return s, true
48+ }
49+ if output.Base != 0 || !output.Full {
50+ return visualOutputState{}, false
51+ }
52+ return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
53+ }
54+ if output.Epoch < s.epoch {
55+ return visualOutputState{}, false
56+ }
57+ if output.Epoch == s.epoch {
58+ if output.ViewRevision != s.viewRevision {
59+ return visualOutputState{}, false
60+ }
61+ if output.New == 0 {
62+ if output.Base != 0 || output.Full {
63+ return visualOutputState{}, false
64+ }
65+ return s, true
66+ }
67+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
68+ return visualOutputState{}, false
69+ }
70+ return visualOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
71+ }
72+ if !output.Full || output.Base != 0 || output.New == 0 {
73+ return visualOutputState{}, false
74+ }
75+ return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
76+}
77+
78+type probeEvent struct {
79+ Sequence uint64
80+ Transport string
81+ Kind string
82+ Accepted bool
83+ StateBearing bool
84+ Acked bool
85+ Epoch uint64
86+ Base uint64
87+ New uint64
88+ ViewRevision uint64
89+ Full bool
90+ DataBytes int
91+}
92+
93+type probeTrace struct {
94+ events []*probeEvent
95+}
96+
97+func (t *probeTrace) record(event *probeEvent) {
98+ if t == nil || event == nil {
99+ return
100+ }
101+ if len(t.events) == maxProbeTraces {
102+ t.events = t.events[1:]
103+ }
104+ t.events = append(t.events, event)
105+}
106+
107+type visualCheckpoint struct {
108+ event *probeEvent
109+ state visualOutputState
110+ snapshot vt.ScreenSnapshot
111+}
112+
113+type visualOutputResult struct {
114+ Accepted bool
115+ StateBearing bool
116+ Ack ports.Ack
117+ event *probeEvent
118+}
119+
120+// visualProbe is deliberately transport-local. A new physical transport gets
121+// one screen and one output state; waiting for a particular marker never
122+// creates a replacement screen and therefore cannot lose prior terminal state.
123+type visualProbe struct {
124+ label string
125+
126+ screen *vt.Screen
127+ state visualOutputState
128+
129+ events []*probeEvent
130+ checkpoints []visualCheckpoint
131+ trace *probeTrace
132+ nextEvent uint64
133+
134+ outputFrames int
135+ outputBytes int
136+ resetPending bool
137+}
138+
139+type harnessArtifactContextKey struct{}
140+
141+func withHarnessArtifact(ctx context.Context, artifact *harnessArtifact) context.Context {
142+ return context.WithValue(ctx, harnessArtifactContextKey{}, artifact)
143+}
144+
145+func harnessArtifactFromContext(ctx context.Context) *harnessArtifact {
146+ if ctx == nil {
147+ return nil
148+ }
149+ artifact, _ := ctx.Value(harnessArtifactContextKey{}).(*harnessArtifact)
150+ return artifact
151+}
152+
153+func newVisualProbe(size domain.Size) *visualProbe {
154+ width, height := size.Cols, size.Rows
155+ if width <= 0 {
156+ width = defaultProbeCols
157+ }
158+ if height <= 0 {
159+ height = defaultProbeRows
160+ }
161+ return &visualProbe{screen: vt.NewScreen(width, height)}
162+}
163+
164+func (p *visualProbe) configure(label string, trace *probeTrace) {
165+ if p == nil {
166+ return
167+ }
168+ p.label = label
169+ p.trace = trace
170+}
171+
172+func (p *visualProbe) record(event probeEvent) *probeEvent {
173+ if p == nil {
174+ return nil
175+ }
176+ p.nextEvent++
177+ event.Sequence = p.nextEvent
178+ event.Transport = p.label
179+ owned := &event
180+ if len(p.events) == maxProbeEvents {
181+ p.events = p.events[1:]
182+ }
183+ p.events = append(p.events, owned)
184+ if p.trace != nil {
185+ p.trace.record(owned)
186+ }
187+ return owned
188+}
189+
190+func (p *visualProbe) recordControl(kind string) {
191+ if p == nil {
192+ return
193+ }
194+ p.record(probeEvent{Kind: kind, Accepted: true})
195+}
196+
197+func (p *visualProbe) apply(output ports.Output) visualOutputResult {
198+ if p == nil {
199+ return visualOutputResult{}
200+ }
201+ p.outputFrames++
202+ p.outputBytes += len(output.Data)
203+ accepted := ports.ValidateOutput(output) == nil
204+ next := visualOutputState{}
205+ if accepted {
206+ next, accepted = p.state.next(output)
207+ }
208+ event := p.record(probeEvent{
209+ Kind: probeEventOutput,
210+ Accepted: accepted,
211+ StateBearing: output.New != 0,
212+ Epoch: output.Epoch,
213+ Base: output.Base,
214+ New: output.New,
215+ ViewRevision: output.ViewRevision,
216+ Full: output.Full,
217+ DataBytes: len(output.Data),
218+ })
219+ if !accepted {
220+ return visualOutputResult{event: event}
221+ }
222+
223+ // The client writes side effects as well as state-bearing frames, but only
224+ // state-bearing frames advance the dependency chain and receive an ACK.
225+ p.screen.Write(output.Data)
226+ p.state = next
227+ checkpoint := visualCheckpoint{event: event, state: p.state, snapshot: p.screen.Snapshot()}
228+ if len(p.checkpoints) == maxProbeCheckpoints {
229+ p.checkpoints = p.checkpoints[1:]
230+ }
231+ p.checkpoints = append(p.checkpoints, checkpoint)
232+ result := visualOutputResult{Accepted: true, StateBearing: output.New != 0, event: event}
233+ if result.StateBearing {
234+ result.Ack = ports.Ack{Epoch: output.Epoch, State: output.New}
235+ }
236+ return result
237+}
238+
239+func (p *visualProbe) text() string {
240+ if p == nil || p.screen == nil {
241+ return ""
242+ }
243+ return visualScreenText(p.screen)
244+}
245+
246+func (p *visualProbe) contains(want string) bool {
247+ return strings.Contains(p.text(), want)
248+}
249+
250+func (p *visualProbe) markAcked(result visualOutputResult) {
251+ if result.event != nil && result.StateBearing && result.Accepted {
252+ result.event.Acked = true
253+ }
254+}
255+
256+func visualScreenText(screen *vt.Screen) string {
257+ if screen == nil {
258+ return ""
259+ }
260+ var text strings.Builder
261+ for y := range screen.Frame.Height {
262+ for x := range screen.Frame.Width {
263+ r := screen.Frame.At(x, y).Rune
264+ if r == 0 {
265+ r = ' '
266+ }
267+ text.WriteRune(r)
268+ }
269+ text.WriteByte('\n')
270+ }
271+ return text.String()
272+}
273+
274+func validateHandoffEventOrdering(events []*probeEvent) error {
275+ acceptedState := func(event *probeEvent) bool {
276+ return event != nil && event.Kind == probeEventOutput && event.Accepted && event.StateBearing && event.Acked
277+ }
278+ localOutput := eventIndex(events, func(event *probeEvent) bool {
279+ return event.Transport == "local-picker" && acceptedState(event)
280+ })
281+ handoff := eventIndex(events, func(event *probeEvent) bool {
282+ return event.Transport == "local-picker" && event.Kind == probeEventAttachTarget
283+ })
284+ selectedWelcome := eventIndex(events, func(event *probeEvent) bool {
285+ return event.Transport == "selected-remote" && event.Kind == probeEventWelcome
286+ })
287+ selectedOutput := eventIndex(events, func(event *probeEvent) bool {
288+ return event.Transport == "selected-remote" && acceptedState(event)
289+ })
290+ if localOutput < 0 || handoff < 0 || selectedWelcome < 0 || selectedOutput < 0 {
291+ return fmt.Errorf("handoff trace omitted local output, direct handoff, selected welcome, or selected output")
292+ }
293+ if !(localOutput < handoff && handoff < selectedWelcome && selectedWelcome < selectedOutput) {
294+ return fmt.Errorf("handoff trace order was not local output, direct handoff, selected welcome, selected output")
295+ }
296+ return nil
297+}
298+
299+func eventIndex(events []*probeEvent, match func(*probeEvent) bool) int {
300+ for i, event := range events {
301+ if event != nil && match(event) {
302+ return i
303+ }
304+ }
305+ return -1
306+}
307+
308+type harnessArtifact struct {
309+ dir string
310+ probes []*visualProbe
311+ traces []*probeTrace
312+}
313+
314+func newHarnessArtifact(dir string) *harnessArtifact {
315+ if dir == "" {
316+ return nil
317+ }
318+ return &harnessArtifact{dir: dir}
319+}
320+
321+func (a *harnessArtifact) registerProbe(probe *visualProbe) {
322+ if a == nil || probe == nil {
323+ return
324+ }
325+ a.probes = append(a.probes, probe)
326+}
327+
328+func (a *harnessArtifact) registerTrace(trace *probeTrace) {
329+ if a == nil || trace == nil {
330+ return
331+ }
332+ a.traces = append(a.traces, trace)
333+}
334+
335+type artifactEvent struct {
336+ Sequence uint64 `json:"sequence"`
337+ Transport string `json:"transport"`
338+ Kind string `json:"kind"`
339+ Accepted bool `json:"accepted"`
340+ StateBearing bool `json:"state_bearing"`
341+ Acked bool `json:"acked"`
342+ Epoch uint64 `json:"epoch,omitempty"`
343+ Base uint64 `json:"base,omitempty"`
344+ New uint64 `json:"new,omitempty"`
345+ ViewRevision uint64 `json:"view_revision,omitempty"`
346+ Full bool `json:"full,omitempty"`
347+ DataBytes int `json:"data_bytes,omitempty"`
348+}
349+
350+type artifactCheckpoint struct {
351+ Sequence uint64 `json:"sequence"`
352+ Epoch uint64 `json:"epoch,omitempty"`
353+ State uint64 `json:"state,omitempty"`
354+ ViewRevision uint64 `json:"view_revision,omitempty"`
355+ Columns int `json:"columns"`
356+ Rows int `json:"rows"`
357+ CursorRow int `json:"cursor_row"`
358+ CursorColumn int `json:"cursor_column"`
359+ AlternateScreen bool `json:"alternate_screen"`
360+ BracketedPaste bool `json:"bracketed_paste"`
361+ SynchronizedUpdate bool `json:"synchronized_update"`
362+}
363+
364+type artifactProbe struct {
365+ Transport string `json:"transport"`
366+ Events []artifactEvent `json:"events"`
367+ Checkpoints []artifactCheckpoint `json:"checkpoints"`
368+}
369+
370+type artifactDocument struct {
371+ Version int `json:"version"`
372+ Passed bool `json:"passed"`
373+ Probes []artifactProbe `json:"probes"`
374+ Traces [][]artifactEvent `json:"traces,omitempty"`
375+}
376+
377+func (a *harnessArtifact) document(passed bool) artifactDocument {
378+ document := artifactDocument{Version: 1, Passed: passed}
379+ for _, probe := range a.probes {
380+ if probe == nil {
381+ continue
382+ }
383+ item := artifactProbe{Transport: probe.label}
384+ for _, event := range probe.events {
385+ if event == nil {
386+ continue
387+ }
388+ item.Events = append(item.Events, artifactEventFrom(event))
389+ }
390+ for _, checkpoint := range probe.checkpoints {
391+ snapshot := checkpoint.snapshot
392+ cursor := snapshot.Cursor()
393+ modes := snapshot.Modes()
394+ item.Checkpoints = append(item.Checkpoints, artifactCheckpoint{
395+ Sequence: checkpoint.event.Sequence,
396+ Epoch: checkpoint.state.epoch,
397+ State: checkpoint.state.state,
398+ ViewRevision: checkpoint.state.viewRevision,
399+ Columns: snapshot.Columns(),
400+ Rows: snapshot.Rows(),
401+ CursorRow: cursor.Row,
402+ CursorColumn: cursor.Col,
403+ AlternateScreen: modes.AlternateScreen,
404+ BracketedPaste: modes.BracketedPaste,
405+ SynchronizedUpdate: modes.SynchronizedUpdate,
406+ })
407+ }
408+ document.Probes = append(document.Probes, item)
409+ }
410+ for _, trace := range a.traces {
411+ if trace == nil {
412+ continue
413+ }
414+ var events []artifactEvent
415+ for _, event := range trace.events {
416+ if event != nil {
417+ events = append(events, artifactEventFrom(event))
418+ }
419+ }
420+ document.Traces = append(document.Traces, events)
421+ }
422+ return document
423+}
424+
425+func artifactEventFrom(event *probeEvent) artifactEvent {
426+ return artifactEvent{
427+ Sequence: event.Sequence,
428+ Transport: event.Transport,
429+ Kind: event.Kind,
430+ Accepted: event.Accepted,
431+ StateBearing: event.StateBearing,
432+ Acked: event.Acked,
433+ Epoch: event.Epoch,
434+ Base: event.Base,
435+ New: event.New,
436+ ViewRevision: event.ViewRevision,
437+ Full: event.Full,
438+ DataBytes: event.DataBytes,
439+ }
440+}
441+
442+func (a *harnessArtifact) write(passed bool) error {
443+ if a == nil || a.dir == "" {
444+ return nil
445+ }
446+ data, err := json.MarshalIndent(a.document(passed), "", " ")
447+ if err != nil {
448+ return fmt.Errorf("marshal harness artifact: %w", err)
449+ }
450+ if len(data) > maxArtifactBytes {
451+ return fmt.Errorf("harness artifact exceeds %d bytes", maxArtifactBytes)
452+ }
453+ if err := os.MkdirAll(a.dir, 0o700); err != nil {
454+ return fmt.Errorf("create harness artifact directory: %w", err)
455+ }
456+ tmp, err := os.CreateTemp(a.dir, ".remote-picker-harness-*.tmp")
457+ if err != nil {
458+ return fmt.Errorf("create harness artifact: %w", err)
459+ }
460+ tmpName := tmp.Name()
461+ defer os.Remove(tmpName)
462+ if err := tmp.Chmod(0o600); err != nil {
463+ _ = tmp.Close()
464+ return fmt.Errorf("protect harness artifact: %w", err)
465+ }
466+ if _, err := tmp.Write(data); err != nil {
467+ _ = tmp.Close()
468+ return fmt.Errorf("write harness artifact: %w", err)
469+ }
470+ if err := tmp.Close(); err != nil {
471+ return fmt.Errorf("close harness artifact: %w", err)
472+ }
473+ if err := os.Rename(tmpName, filepath.Join(a.dir, "remote-picker-harness.json")); err != nil {
474+ return fmt.Errorf("publish harness artifact: %w", err)
475+ }
476+ return nil
477+}
A scripts/remote-picker-harness/visual_probe_test.go +319 -0
@@ -0,0 +1,319 @@
1+package main
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "os"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+const (
15+ probeTestCols = 32
16+ probeTestRows = 2
17+)
18+
19+type probeTestTransport struct {
20+ sent []ports.Frame
21+}
22+
23+func (t *probeTestTransport) Send(frame ports.Frame) error {
24+ t.sent = append(t.sent, frame)
25+ return nil
26+}
27+
28+func (*probeTestTransport) Recv() (ports.Frame, error) { return ports.Frame{}, errors.New("not used") }
29+func (*probeTestTransport) Close() error { return nil }
30+
31+func probeTestOutput(t *testing.T, output ports.Output) ports.Frame {
32+ t.Helper()
33+ payload, err := ports.MarshalOutput(output)
34+ if err != nil {
35+ t.Fatalf("marshal output: %v", err)
36+ }
37+ return ports.Frame{Type: ports.MsgOutput, Payload: payload}
38+}
39+
40+func TestVisualProbePersistsFullAndIncrementalOutput(t *testing.T) {
41+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
42+ probe := newVisualProbe(size)
43+ for _, tt := range []struct {
44+ name string
45+ output ports.Output
46+ want string
47+ state uint64
48+ }{
49+ {
50+ name: "full",
51+ output: ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
52+ want: "full",
53+ state: 1,
54+ },
55+ {
56+ name: "incremental",
57+ output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte(" incremental")},
58+ want: "full incremental",
59+ state: 2,
60+ },
61+ } {
62+ t.Run(tt.name, func(t *testing.T) {
63+ result := probe.apply(tt.output)
64+ if !result.Accepted || !result.StateBearing {
65+ t.Fatalf("result = %+v, want accepted state-bearing output", result)
66+ }
67+ if result.Ack.Epoch != tt.output.Epoch || result.Ack.State != tt.state {
68+ t.Fatalf("ack = %+v, want epoch=%d state=%d", result.Ack, tt.output.Epoch, tt.state)
69+ }
70+ if !strings.Contains(probe.text(), tt.want) {
71+ t.Fatalf("screen = %q, want %q", probe.text(), tt.want)
72+ }
73+ if probe.state.state != tt.state {
74+ t.Fatalf("state = %d, want %d", probe.state.state, tt.state)
75+ }
76+ })
77+ }
78+ if got := len(probe.checkpoints); got != 2 {
79+ t.Fatalf("checkpoints = %d, want 2", got)
80+ }
81+ if got := len(probe.events); got != 2 {
82+ t.Fatalf("events = %d, want 2", got)
83+ }
84+}
85+
86+func TestVisualProbeRejectsOutputWithoutMutationOrAck(t *testing.T) {
87+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
88+ for _, tt := range []struct {
89+ name string
90+ output ports.Output
91+ }{
92+ {
93+ name: "base gap",
94+ output: ports.Output{Epoch: 1, Base: 0, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("rejected")},
95+ },
96+ {
97+ name: "revision gap",
98+ output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 5, Size: size, Data: []byte("rejected")},
99+ },
100+ {
101+ name: "stale epoch",
102+ output: ports.Output{Epoch: 0, Base: 1, New: 2, ViewRevision: 4, Size: size, Data: []byte("rejected")},
103+ },
104+ } {
105+ t.Run(tt.name, func(t *testing.T) {
106+ probe := newVisualProbe(size)
107+ first := ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("keep")}
108+ if result := probe.apply(first); !result.Accepted {
109+ t.Fatal("seed output was rejected")
110+ }
111+ beforeText, beforeState, beforeCheckpoints := probe.text(), probe.state, len(probe.checkpoints)
112+ result := probe.apply(tt.output)
113+ if result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
114+ t.Fatalf("result = %+v, want rejected output without ACK", result)
115+ }
116+ if got := probe.text(); got != beforeText {
117+ t.Fatalf("screen changed from %q to %q", beforeText, got)
118+ }
119+ if probe.state != beforeState {
120+ t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
121+ }
122+ if len(probe.checkpoints) != beforeCheckpoints {
123+ t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints)
124+ }
125+ })
126+ }
127+}
128+
129+func TestVisualProbeSideEffectsApplyWithoutAck(t *testing.T) {
130+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
131+ for _, tt := range []struct {
132+ name string
133+ seed *ports.Output
134+ effect ports.Output
135+ }{
136+ {
137+ name: "before state-bearing output",
138+ effect: ports.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
139+ },
140+ {
141+ name: "after state-bearing output",
142+ seed: &ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")},
143+ effect: ports.Output{Epoch: 1, ViewRevision: 4, Size: size, Data: []byte(" side effect")},
144+ },
145+ } {
146+ t.Run(tt.name, func(t *testing.T) {
147+ probe := newVisualProbe(size)
148+ if tt.seed != nil && !probe.apply(*tt.seed).Accepted {
149+ t.Fatal("seed output was rejected")
150+ }
151+ beforeState, beforeCheckpoints := probe.state, len(probe.checkpoints)
152+ result := probe.apply(tt.effect)
153+ if !result.Accepted || result.StateBearing || result.Ack != (ports.Ack{}) {
154+ t.Fatalf("result = %+v, want accepted side effect without ACK", result)
155+ }
156+ if !strings.Contains(probe.text(), string(tt.effect.Data)) {
157+ t.Fatalf("screen = %q, want side effect %q", probe.text(), tt.effect.Data)
158+ }
159+ if probe.state != beforeState {
160+ t.Fatalf("state = %+v, want unchanged %+v", probe.state, beforeState)
161+ }
162+ if len(probe.checkpoints) != beforeCheckpoints+1 {
163+ t.Fatalf("checkpoints = %d, want %d", len(probe.checkpoints), beforeCheckpoints+1)
164+ }
165+ })
166+ }
167+}
168+
169+func TestProcessOutputFrameACKsOnlyAcceptedStateBearingFrames(t *testing.T) {
170+ size := domain.Size{Cols: probeTestCols, Rows: probeTestRows}
171+ for _, tt := range []struct {
172+ name string
173+ output ports.Output
174+ wantAck bool
175+ wantReset bool
176+ }{
177+ {
178+ name: "accepted full",
179+ output: ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("full")},
180+ wantAck: true,
181+ },
182+ {
183+ name: "accepted side effect",
184+ output: ports.Output{Epoch: 1, Size: size, Data: []byte("side effect")},
185+ },
186+ {
187+ name: "rejected state",
188+ output: ports.Output{Epoch: 1, New: 3, Full: true, ViewRevision: 4, Size: size, Data: []byte("gap")},
189+ wantReset: true,
190+ },
191+ } {
192+ t.Run(tt.name, func(t *testing.T) {
193+ transport := &probeTestTransport{}
194+ tr := &harnessTransport{Transport: transport, probe: newVisualProbe(size)}
195+ if tt.name == "rejected state" {
196+ seed := probeTestOutput(t, ports.Output{Epoch: 1, New: 1, Full: true, ViewRevision: 4, Size: size, Data: []byte("seed")})
197+ if err := processOutputFrame(tr, seed); err != nil {
198+ t.Fatal(err)
199+ }
200+ transport.sent = nil
201+ }
202+ if err := processOutputFrame(tr, probeTestOutput(t, tt.output)); err != nil {
203+ t.Fatal(err)
204+ }
205+ var ackCount, resetCount int
206+ for _, frame := range transport.sent {
207+ switch frame.Type {
208+ case ports.MsgAck:
209+ ackCount++
210+ case ports.MsgOutputResetRequest:
211+ resetCount++
212+ }
213+ }
214+ if (ackCount > 0) != tt.wantAck {
215+ t.Fatalf("ACK count = %d, want ACK = %t", ackCount, tt.wantAck)
216+ }
217+ if (resetCount > 0) != tt.wantReset {
218+ t.Fatalf("reset count = %d, want reset = %t", resetCount, tt.wantReset)
219+ }
220+ })
221+ }
222+}
223+
224+func TestHandoffEventOrdering(t *testing.T) {
225+ event := func(transport, kind string, accepted bool) *probeEvent {
226+ result := &probeEvent{Transport: transport, Kind: kind, Accepted: accepted}
227+ if kind == probeEventOutput && accepted {
228+ result.StateBearing = true
229+ result.Acked = true
230+ }
231+ return result
232+ }
233+ for _, tt := range []struct {
234+ name string
235+ events []*probeEvent
236+ wantErr bool
237+ }{
238+ {
239+ name: "direct handoff order",
240+ events: []*probeEvent{
241+ event("local-picker", probeEventWelcome, true),
242+ event("local-picker", probeEventOutput, true),
243+ event("local-picker", probeEventAttachTarget, true),
244+ event("selected-remote", probeEventWelcome, true),
245+ event("selected-remote", probeEventOutput, true),
246+ },
247+ },
248+ {
249+ name: "selected output arrived before welcome",
250+ events: []*probeEvent{
251+ event("local-picker", probeEventOutput, true),
252+ event("local-picker", probeEventAttachTarget, true),
253+ event("selected-remote", probeEventOutput, true),
254+ event("selected-remote", probeEventWelcome, true),
255+ },
256+ wantErr: true,
257+ },
258+ {
259+ name: "rejected local output does not characterize handoff",
260+ events: []*probeEvent{
261+ event("local-picker", probeEventOutput, false),
262+ event("local-picker", probeEventAttachTarget, true),
263+ event("selected-remote", probeEventWelcome, true),
264+ event("selected-remote", probeEventOutput, true),
265+ },
266+ wantErr: true,
267+ },
268+ {
269+ name: "unacknowledged local output does not characterize handoff",
270+ events: []*probeEvent{
271+ {Transport: "local-picker", Kind: probeEventOutput, Accepted: true},
272+ event("local-picker", probeEventAttachTarget, true),
273+ event("selected-remote", probeEventWelcome, true),
274+ event("selected-remote", probeEventOutput, true),
275+ },
276+ wantErr: true,
277+ },
278+ } {
279+ t.Run(tt.name, func(t *testing.T) {
280+ err := validateHandoffEventOrdering(tt.events)
281+ if (err != nil) != tt.wantErr {
282+ t.Fatalf("error = %v, want error = %t", err, tt.wantErr)
283+ }
284+ })
285+ }
286+}
287+
288+func TestHarnessArtifactIsBoundedAndContainsOnlyMetadata(t *testing.T) {
289+ dir := t.TempDir()
290+ artifact := newHarnessArtifact(dir)
291+ probe := newVisualProbe(domain.Size{Cols: probeTestCols, Rows: probeTestRows})
292+ probe.configure("local-picker", nil)
293+ artifact.registerProbe(probe)
294+ secret := "not-for-artifact"
295+ if result := probe.apply(ports.Output{Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: probeTestCols, Rows: probeTestRows}, Data: []byte(secret)}); !result.Accepted {
296+ t.Fatal("probe output was rejected")
297+ }
298+ if err := artifact.write(true); err != nil {
299+ t.Fatal(err)
300+ }
301+ path := dir + "/remote-picker-harness.json"
302+ data, err := os.ReadFile(path)
303+ if err != nil {
304+ t.Fatal(err)
305+ }
306+ if len(data) > maxArtifactBytes {
307+ t.Fatalf("artifact bytes = %d, want <= %d", len(data), maxArtifactBytes)
308+ }
309+ if strings.Contains(string(data), secret) {
310+ t.Fatal("artifact contained raw output")
311+ }
312+ var document artifactDocument
313+ if err := json.Unmarshal(data, &document); err != nil {
314+ t.Fatalf("artifact JSON: %v", err)
315+ }
316+ if !document.Passed || len(document.Probes) != 1 || len(document.Probes[0].Checkpoints) != 1 {
317+ t.Fatalf("artifact document = %+v, want one passed probe checkpoint", document)
318+ }
319+}

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 2

Tags 13

↑↓ moveenter openesc close