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) {
575 t.Run(tt.name, func(t *testing.T) {575 t.Run(tt.name, func(t *testing.T) {
576 cmd, err := parseArgs([]string{"attach", tt.arg})576 cmd, err := parseArgs([]string{"attach", tt.arg})
577 require.NoError(t, err)577 require.NoError(t, err)
578- connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{578+ connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.AttachRequest{
579 Intent: cmd.intent,579 Intent: cmd.intent,
580 SessionName: cmd.name,580 SessionName: cmd.name,
581 })581 })
M internal/ports/frame.go +1 -1
@@ -12,7 +12,7 @@ import (
12 const HandshakeTimeout = 15 * time.Second12 const HandshakeTimeout = 15 * time.Second
13 13
14 // ProtocolVersion is the current vev IPC wire protocol version.14 // ProtocolVersion is the current vev IPC wire protocol version.
15-const ProtocolVersion uint16 = 2415+const ProtocolVersion uint16 = 25
16 16
17 // MaxFrameLen is the largest permitted frame length, including the type byte17 // MaxFrameLen is the largest permitted frame length, including the type byte
18 // and excluding the four-byte length prefix.18 // and excluding the four-byte length prefix.
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 24 {6+ if ProtocolVersion != 25 {
7- t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/remote_preview_hardening_test.go +1 -1
@@ -194,7 +194,7 @@ func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
194 want := []byte{194 want := []byte{
195 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',195 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
196 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach,196 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach,
197- 0x01, 0x01,197+ 0x01,
198 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',198 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
199 0x00, 0x09, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',199 0x00, 0x09, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
200 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,200 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
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "reflect"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -79,15 +80,54 @@ func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
79 }80 }
80 }81 }
81 82
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+
82 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {123 func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
83 target := testRemoteTarget(false)124 target := testRemoteTarget(false)
84 tests := []struct {125 tests := []struct {
85- name string126+ name string
86- validate func() error127+ validate func() error
87- marshalBad func() []byte128+ marshalBad func() []byte
88- marshalGood func() []byte129+ marshalGood func() []byte
89- marshalLegacy func() []byte130+ unmarshal func([]byte) error
90- unmarshal func([]byte) error
91 }{131 }{
92 {132 {
93 name: "hello",133 name: "hello",
@@ -112,12 +152,6 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
112 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,152 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
113 })153 })
114 },154 },
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- },
121 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },155 unmarshal: func(payload []byte) error { _, err := UnmarshalHello(payload); return err },
122 },156 },
123 {157 {
@@ -140,11 +174,6 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
140 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,174 RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
141 })175 })
142 },176 },
143- marshalLegacy: func() []byte {
144- return MarshalAttachTarget(AttachTarget{
145- Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach,
146- })
147- },
148 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },177 unmarshal: func(payload []byte) error { _, err := UnmarshalAttachTarget(payload); return err },
149 },178 },
150 }179 }
@@ -161,16 +190,7 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
161 if payload == nil {190 if payload == nil {
162 t.Fatal("failed to marshal daemon-owned control payload")191 t.Fatal("failed to marshal daemon-owned control payload")
163 }192 }
164- legacyLen := len(tt.marshalLegacy())193+ for i := range len(payload) {
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- }
174 if err := tt.unmarshal(payload[:i]); err == nil {194 if err := tt.unmarshal(payload[:i]); err == nil {
175 t.Fatalf("prefix %d unexpectedly decoded", i)195 t.Fatalf("prefix %d unexpectedly decoded", i)
176 }196 }
@@ -187,10 +207,10 @@ func TestRemoteTargetWireRejectsClientOwnedEnvironmentPolicy(t *testing.T) {
187 }207 }
188 }208 }
189 209
190-func TestLegacyTargetWireBytesRemainUnchanged(t *testing.T) {210+func TestTargetWireIncludesRequiredV25Section(t *testing.T) {
191- want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach}211+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach, 0, 0}
192 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})212 got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
193 if !bytes.Equal(got, want) {213 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)
195 }215 }
196 }216 }
M internal/ports/wire.go +122 -71
@@ -80,7 +80,18 @@ func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
80 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned80 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
81 }81 }
82 82
83-const remoteTargetWireVersion uint8 = 183+// 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+}
84 95
85 // Capability bits advertised in Welcome.96 // Capability bits advertised in Welcome.
86 const (97 const (
@@ -116,6 +127,7 @@ const (
116 type Hello struct {127 type Hello struct {
117 Version uint16128 Version uint16
118 Intent uint8129 Intent uint8
130+ RenderMode RenderMode
119 ClientID [16]byte131 ClientID [16]byte
120 ResumeToken uint64132 ResumeToken uint64
121 Name string133 Name string
@@ -224,6 +236,7 @@ type Welcome struct {
224 SessionName string236 SessionName string
225 Ephemeral bool237 Ephemeral bool
226 ResumeToken uint64238 ResumeToken uint64
239+ RenderMode RenderMode
227 Capabilities uint32240 Capabilities uint32
228 }241 }
229 242
@@ -299,16 +312,19 @@ type Sessions struct {
299 type OutputResetRequest struct{}312 type OutputResetRequest struct{}
300 313
301 // SessionTabMeta describes one tab in a remote session's metadata snapshot.314 // 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.
302 type SessionTabMeta struct {316 type SessionTabMeta struct {
303- Index uint16317+ ID domain.TabStableID
304 Name string318 Name string
305 Attention bool319 Attention bool
306 }320 }
307 321
308-// SessionMeta is authoritative tab metadata for a remote session.322+// SessionMeta is an authoritative, revisioned snapshot of remote tab metadata.
309 type SessionMeta struct {323 type SessionMeta struct {
324+ LifecycleID domain.SessionLifecycleID
325+ Revision uint64
310 SessionName string326 SessionName string
311- Active uint16327+ ActiveTabID domain.TabStableID
312 Tabs []SessionTabMeta328 Tabs []SessionTabMeta
313 }329 }
314 330
@@ -368,8 +384,10 @@ func (w *payloadWriter) putLongBytes(b []byte) {
368 w.b = append(w.b, b...)384 w.b = append(w.b, b...)
369 }385 }
370 386
371-func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {387+// marshalRemoteTargetSection writes the required v25 target/policy section.
372- w.putUint8(remoteTargetWireVersion)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) {
373 if target == nil {391 if target == nil {
374 w.putBool(false)392 w.putBool(false)
375 } else {393 } else {
@@ -393,14 +411,7 @@ func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSession
393 w.putUint8(uint8(policy))411 w.putUint8(uint8(policy))
394 }412 }
395 413
396-func skipRemoteTargetExtension(r *payloadReader) error {414+func skipRemoteTargetSection(r *payloadReader) error {
397- version, err := r.getUint8()
398- if err != nil {
399- return err
400- }
401- if version != remoteTargetWireVersion {
402- return errInvalidEnum
403- }
404 present, err := r.getBool()415 present, err := r.getBool()
405 if err != nil {416 if err != nil {
406 return err417 return err
@@ -518,14 +529,7 @@ func unmarshalRemoteTarget(r *payloadReader) (*domain.RemoteSessionTarget, error
518 return target, nil529 return target, nil
519 }530 }
520 531
521-func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarget, EnvironmentPolicy, error) {532+func unmarshalRemoteTargetSection(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- }
529 target, err := unmarshalRemoteTarget(r)533 target, err := unmarshalRemoteTarget(r)
530 if err != nil {534 if err != nil {
531 return nil, 0, err535 return nil, 0, err
@@ -534,6 +538,9 @@ func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarg
534 if err != nil {538 if err != nil {
535 return nil, 0, err539 return nil, 0, err
536 }540 }
541+ if !validEnvironmentPolicy(EnvironmentPolicy(policy)) {
542+ return nil, 0, errInvalidEnum
543+ }
537 return target, EnvironmentPolicy(policy), nil544 return target, EnvironmentPolicy(policy), nil
538 }545 }
539 546
@@ -692,6 +699,9 @@ func ValidateHello(h Hello) error {
692 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {699 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
693 return ErrInvalidHello700 return ErrInvalidHello
694 }701 }
702+ if !validRenderMode(h.RenderMode) {
703+ return ErrInvalidHello
704+ }
695 if err := ValidateSize(h.Size); err != nil {705 if err := ValidateSize(h.Size); err != nil {
696 return fmt.Errorf("%w: size", ErrInvalidHello)706 return fmt.Errorf("%w: size", ErrInvalidHello)
697 }707 }
@@ -707,7 +717,7 @@ func ValidateHello(h Hello) error {
707 }717 }
708 }718 }
709 if h.RemoteTarget == nil {719 if h.RemoteTarget == nil {
710- if h.EnvironmentPolicy != EnvironmentPolicyClientOwned {720+ if h.EnvironmentPolicy != EnvironmentPolicyClientOwned || h.RenderMode == RenderModeProxiedContent {
711 return ErrInvalidHello721 return ErrInvalidHello
712 }722 }
713 return nil723 return nil
@@ -804,6 +814,7 @@ func MarshalHello(h Hello) []byte {
804 w := payloadWriter{}814 w := payloadWriter{}
805 w.putUint16(h.Version)815 w.putUint16(h.Version)
806 w.putUint8(h.Intent)816 w.putUint8(h.Intent)
817+ w.putUint8(uint8(h.RenderMode))
807 w.putBytes(h.ClientID[:])818 w.putBytes(h.ClientID[:])
808 w.putUint64(h.ResumeToken)819 w.putUint64(h.ResumeToken)
809 w.putString(h.Name)820 w.putString(h.Name)
@@ -817,9 +828,7 @@ func MarshalHello(h Hello) []byte {
817 for _, entry := range h.Env {828 for _, entry := range h.Env {
818 w.putLongString(entry)829 w.putLongString(entry)
819 }830 }
820- if h.RemoteTarget != nil || h.EnvironmentPolicy != EnvironmentPolicyClientOwned {831+ marshalRemoteTargetSection(&w, h.RemoteTarget, h.EnvironmentPolicy)
821- marshalRemoteTargetExtension(&w, h.RemoteTarget, h.EnvironmentPolicy)
822- }
823 return w.b832 return w.b
824 }833 }
825 834
@@ -835,6 +844,13 @@ func preflightHello(b []byte) error {
835 if _, err := r.getUint8(); err != nil {844 if _, err := r.getUint8(); err != nil {
836 return err845 return err
837 }846 }
847+ mode, err := r.getUint8()
848+ if err != nil {
849+ return err
850+ }
851+ if !validRenderMode(RenderMode(mode)) {
852+ return errInvalidEnum
853+ }
838 if len(r.b) < 16 {854 if len(r.b) < 16 {
839 return errShortPayload855 return errShortPayload
840 }856 }
@@ -875,10 +891,8 @@ func preflightHello(b []byte) error {
875 return err891 return err
876 }892 }
877 }893 }
878- if len(r.b) != 0 {894+ if err := skipRemoteTargetSection(&r); err != nil {
879- if err := skipRemoteTargetExtension(&r); err != nil {895+ return err
880- return err
881- }
882 }896 }
883 return r.done()897 return r.done()
884 }898 }
@@ -901,6 +915,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
901 if h.Intent, err = r.getUint8(); err != nil {915 if h.Intent, err = r.getUint8(); err != nil {
902 return Hello{}, err916 return Hello{}, err
903 }917 }
918+ mode, err := r.getUint8()
919+ if err != nil {
920+ return Hello{}, err
921+ }
922+ h.RenderMode = RenderMode(mode)
904 clientID, err := r.getBytes(len(h.ClientID))923 clientID, err := r.getBytes(len(h.ClientID))
905 if err != nil {924 if err != nil {
906 return Hello{}, err925 return Hello{}, err
@@ -952,10 +971,8 @@ func UnmarshalHello(b []byte) (Hello, error) {
952 h.Env = append(h.Env, entry)971 h.Env = append(h.Env, entry)
953 }972 }
954 }973 }
955- if len(r.b) != 0 {974+ if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
956- if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {975+ return Hello{}, err
957- return Hello{}, err
958- }
959 }976 }
960 if err := r.done(); err != nil {977 if err := r.done(); err != nil {
961 return Hello{}, err978 return Hello{}, err
@@ -1250,6 +1267,7 @@ func MarshalWelcome(m Welcome) []byte {
1250 w.putUint8(0)1267 w.putUint8(0)
1251 }1268 }
1252 w.putUint64(m.ResumeToken)1269 w.putUint64(m.ResumeToken)
1270+ w.putUint8(uint8(m.RenderMode))
1253 w.putUint32(m.Capabilities)1271 w.putUint32(m.Capabilities)
1254 return w.b1272 return w.b
1255 }1273 }
@@ -1274,6 +1292,14 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
1274 if m.ResumeToken, err = r.getUint64(); err != nil {1292 if m.ResumeToken, err = r.getUint64(); err != nil {
1275 return Welcome{}, err1293 return Welcome{}, err
1276 }1294 }
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+ }
1277 if m.Capabilities, err = r.getUint32(); err != nil {1303 if m.Capabilities, err = r.getUint32(); err != nil {
1278 return Welcome{}, err1304 return Welcome{}, err
1279 }1305 }
@@ -1400,9 +1426,7 @@ func MarshalAttachTarget(m AttachTarget) []byte {
1400 w.putString(m.Endpoint)1426 w.putString(m.Endpoint)
1401 w.putString(m.Session)1427 w.putString(m.Session)
1402 w.putUint8(m.Intent)1428 w.putUint8(m.Intent)
1403- if m.RemoteTarget != nil || m.EnvironmentPolicy != EnvironmentPolicyClientOwned {1429+ marshalRemoteTargetSection(&w, m.RemoteTarget, m.EnvironmentPolicy)
1404- marshalRemoteTargetExtension(&w, m.RemoteTarget, m.EnvironmentPolicy)
1405- }
1406 return w.b1430 return w.b
1407 }1431 }
1408 1432
@@ -1424,10 +1448,8 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1424 if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {1448 if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {
1425 return AttachTarget{}, ErrInvalidAttachTarget1449 return AttachTarget{}, ErrInvalidAttachTarget
1426 }1450 }
1427- if len(probe.b) != 0 {1451+ if err := skipRemoteTargetSection(&probe); err != nil {
1428- if err := skipRemoteTargetExtension(&probe); err != nil {1452+ return AttachTarget{}, ErrInvalidAttachTarget
1429- return AttachTarget{}, ErrInvalidAttachTarget
1430- }
1431 }1453 }
1432 if err := probe.done(); err != nil {1454 if err := probe.done(); err != nil {
1433 return AttachTarget{}, ErrInvalidAttachTarget1455 return AttachTarget{}, ErrInvalidAttachTarget
@@ -1444,10 +1466,8 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1444 if m.Intent, err = r.getUint8(); err != nil {1466 if m.Intent, err = r.getUint8(); err != nil {
1445 return AttachTarget{}, err1467 return AttachTarget{}, err
1446 }1468 }
1447- if len(r.b) != 0 {1469+ if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetSection(&r); err != nil {
1448- if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {1470+ return AttachTarget{}, err
1449- return AttachTarget{}, err
1450- }
1451 }1471 }
1452 if err := r.done(); err != nil {1472 if err := r.done(); err != nil {
1453 return AttachTarget{}, err1473 return AttachTarget{}, err
@@ -1509,66 +1529,94 @@ func UnmarshalOutputResetRequest(b []byte) (OutputResetRequest, error) {
1509 return OutputResetRequest{}, nil1529 return OutputResetRequest{}, nil
1510 }1530 }
1511 1531
1512-// MarshalSessionMeta encodes m when it satisfies the protocol's authoritative1532+// ValidateSessionMeta validates a revisioned authoritative remote metadata
1513-// ordered-tab constraints.1533+// snapshot. Ordered tabs remain display order, while IDs are the only identity.
1514-func MarshalSessionMeta(m SessionMeta) ([]byte, error) {1534+func ValidateSessionMeta(m SessionMeta) error {
1515- if len(m.Tabs) == 0 || len(m.Tabs) > math.MaxUint16 || int(m.Active) >= len(m.Tabs) {1535+ if m.LifecycleID == (domain.SessionLifecycleID{}) || m.Revision == 0 ||
1516- return nil, ErrInvalidSessionMeta1536+ 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
1517 }1541 }
1518- if len(m.SessionName) > math.MaxUint16 {1542+ if err := domain.ValidateTabStableID(m.ActiveTabID); err != nil {
1519- return nil, ErrSessionMetaStringTooLong1543+ return ErrInvalidSessionMeta
1520 }1544 }
1521- for i, tab := range m.Tabs {1545+ activeFound := false
1522- if tab.Index != uint16(i) {1546+ seen := make(map[domain.TabStableID]struct{}, len(m.Tabs))
1523- return nil, ErrInvalidSessionMeta1547+ 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
1524 }1553 }
1525- if len(tab.Name) > math.MaxUint16 {1554+ if _, duplicate := seen[tab.ID]; duplicate {
1526- return nil, ErrSessionMetaStringTooLong1555+ return ErrInvalidSessionMeta
1527 }1556 }
1557+ seen[tab.ID] = struct{}{}
1558+ activeFound = activeFound || tab.ID == m.ActiveTabID
1528 }1559 }
1560+ if !activeFound {
1561+ return ErrInvalidSessionMeta
1562+ }
1563+ return nil
1564+}
1529 1565
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+ }
1530 w := payloadWriter{}1571 w := payloadWriter{}
1572+ w.putBytes(m.LifecycleID[:])
1573+ w.putUint64(m.Revision)
1531 w.putString(m.SessionName)1574 w.putString(m.SessionName)
1532- w.putUint16(m.Active)1575+ w.putString(string(m.ActiveTabID))
1533 w.putUint16(uint16(len(m.Tabs)))1576 w.putUint16(uint16(len(m.Tabs)))
1534 for _, tab := range m.Tabs {1577 for _, tab := range m.Tabs {
1535- w.putUint16(tab.Index)1578+ w.putString(string(tab.ID))
1536 w.putString(tab.Name)1579 w.putString(tab.Name)
1537 w.putBool(tab.Attention)1580 w.putBool(tab.Attention)
1538 }1581 }
1539 return w.b, nil1582 return w.b, nil
1540 }1583 }
1541 1584
1542-// UnmarshalSessionMeta decodes a strict authoritative metadata snapshot.1585+// UnmarshalSessionMeta decodes a strict v25 authoritative metadata snapshot.
1543 func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {1586 func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
1544 r := payloadReader{b: b}1587 r := payloadReader{b: b}
1545 var m SessionMeta1588 var m SessionMeta
1546- var err error1589+ 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+ }
1547 if m.SessionName, err = r.getString(); err != nil {1597 if m.SessionName, err = r.getString(); err != nil {
1548 return SessionMeta{}, err1598 return SessionMeta{}, err
1549 }1599 }
1550- if m.Active, err = r.getUint16(); err != nil {1600+ activeTabID, err := r.getString()
1601+ if err != nil {
1551 return SessionMeta{}, err1602 return SessionMeta{}, err
1552 }1603 }
1604+ m.ActiveTabID = domain.TabStableID(activeTabID)
1553 count, err := r.getUint16()1605 count, err := r.getUint16()
1554 if err != nil {1606 if err != nil {
1555 return SessionMeta{}, err1607 return SessionMeta{}, err
1556 }1608 }
1557- if count == 0 || int(m.Active) >= int(count) {1609+ if count == 0 || uint64(count) > uint64(len(r.b)/5) {
1558- return SessionMeta{}, ErrInvalidSessionMeta
1559- }
1560- if uint64(count) > uint64(len(r.b)/5) {
1561 return SessionMeta{}, errShortPayload1610 return SessionMeta{}, errShortPayload
1562 }1611 }
1563 m.Tabs = make([]SessionTabMeta, 0, int(count))1612 m.Tabs = make([]SessionTabMeta, 0, int(count))
1564- for i := range int(count) {1613+ for range int(count) {
1565 var tab SessionTabMeta1614 var tab SessionTabMeta
1566- if tab.Index, err = r.getUint16(); err != nil {1615+ id, err := r.getString()
1616+ if err != nil {
1567 return SessionMeta{}, err1617 return SessionMeta{}, err
1568 }1618 }
1569- if tab.Index != uint16(i) {1619+ tab.ID = domain.TabStableID(id)
1570- return SessionMeta{}, ErrInvalidSessionMeta
1571- }
1572 if tab.Name, err = r.getString(); err != nil {1620 if tab.Name, err = r.getString(); err != nil {
1573 return SessionMeta{}, err1621 return SessionMeta{}, err
1574 }1622 }
@@ -1580,6 +1628,9 @@ func UnmarshalSessionMeta(b []byte) (SessionMeta, error) {
1580 if err := r.done(); err != nil {1628 if err := r.done(); err != nil {
1581 return SessionMeta{}, err1629 return SessionMeta{}, err
1582 }1630 }
1631+ if err := ValidateSessionMeta(m); err != nil {
1632+ return SessionMeta{}, err
1633+ }
1583 return m, nil1634 return m, nil
1584 }1635 }
1585 1636
M internal/ports/wire_final_test.go +7 -7
@@ -11,8 +11,8 @@ import (
11 )11 )
12 12
13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {13 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 24 {14+ if ProtocolVersion != 25 {
15- t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)15+ t.Fatalf("ProtocolVersion = %d, want 25", ProtocolVersion)
16 }16 }
17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18 if len(payload) < 2 {18 if len(payload) < 2 {
@@ -26,8 +26,8 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
26 26
27 func TestFinalHelloGoldenStrict(t *testing.T) {27 func TestFinalHelloGoldenStrict(t *testing.T) {
28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}28 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 24, 2}, make([]byte, 16+8)...)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)30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31 got := MarshalHello(msg)31 got := MarshalHello(msg)
32 if !bytes.Equal(got, want) {32 if !bytes.Equal(got, want) {
33 t.Fatalf("Hello bytes = %x, want %x", got, want)33 t.Fatalf("Hello bytes = %x, want %x", got, want)
@@ -178,7 +178,7 @@ func TestFinalAckGoldenStrict(t *testing.T) {
178 178
179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
180 msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}180 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}
182 got := MarshalAttachTarget(msg)182 got := MarshalAttachTarget(msg)
183 if !bytes.Equal(got, want) {183 if !bytes.Equal(got, want) {
184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)184 t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
@@ -197,7 +197,7 @@ func TestFinalAttachTargetGoldenStrict(t *testing.T) {
197 }197 }
198 198
199 func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {199 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"}}})
201 if err != nil {201 if err != nil {
202 t.Fatal(err)202 t.Fatal(err)
203 }203 }
@@ -210,7 +210,7 @@ func TestFinalClosedWireValuesRejectUnknownEnumsAndBooleans(t *testing.T) {
210 name: "hello boolean",210 name: "hello boolean",
211 payload: func() []byte {211 payload: func() []byte {
212 b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})212 b := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
213- b[len(b)-6] = 2213+ b[len(b)-8] = 2
214 return b214 return b
215 }(),215 }(),
216 decode: func(b []byte) error { _, err := UnmarshalHello(b); return err },216 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) {
73 TrueColor: true,73 TrueColor: true,
74 MaxOutputInFlight: 8,74 MaxOutputInFlight: 8,
75 },75 },
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},
77 },77 },
78 {78 {
79 name: "empty strings",79 name: "empty strings",
@@ -86,7 +86,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
86 Cwd: "",86 Cwd: "",
87 TrueColor: false,87 TrueColor: false,
88 },88 },
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},
90 },90 },
91 }91 }
92 92
@@ -124,7 +124,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
124 }124 }
125 got := MarshalHello(msg)125 got := MarshalHello(msg)
126 want := []byte{126 want := []byte{
127- 0x00, 0x18, 0x00, // version, intent127+ 0x00, 0x19, 0x00, 0x00, // version, intent, render mode
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -136,6 +136,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count136 0x00, 0x00, 0x00, 0x02, // uint32 environment entry count
137 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',137 0x00, 0x00, 0x00, 0x03, 'A', '=', 'B',
138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
139+ 0x00, 0x00, // no remote target, client-owned environment
139 }140 }
140 require.Equal(t, want, got)141 require.Equal(t, want, got)
141 decoded, err := UnmarshalHello(want)142 decoded, err := UnmarshalHello(want)
@@ -164,7 +165,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
164 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})165 base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
165 withCount := func(count byte) []byte {166 withCount := func(count byte) []byte {
166 payload := append([]byte(nil), base...)167 payload := append([]byte(nil), base...)
167- payload[len(payload)-1] = count168+ payload[len(payload)-3] = count
168 return payload169 return payload
169 }170 }
170 tests := []struct {171 tests := []struct {
@@ -341,7 +342,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
341 }342 }
342 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)343 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
343 require.Equal(t, want, MarshalTheme(cleared))344 require.Equal(t, want, MarshalTheme(cleared))
344- require.Equal(t, uint16(24), ProtocolVersion)345+ require.Equal(t, uint16(25), ProtocolVersion)
345 }346 }
346 347
347 func TestResizeGoldenAndRoundTrip(t *testing.T) {348 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -421,12 +422,12 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
421 {422 {
422 name: "ephemeral",423 name: "ephemeral",
423 msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},424 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},
425 },426 },
426 {427 {
427 name: "non-ephemeral empty name",428 name: "non-ephemeral empty name",
428 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},429 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},
430 },431 },
431 }432 }
432 433
@@ -469,7 +470,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
469 name: "minimal",470 name: "minimal",
470 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},471 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
471 want: []byte{472 want: []byte{
472- 0x00, 0x18,473+ 0x00, 0x19,
473 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID474 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
474 0x00, // attached475 0x00, // attached
475 0x00, // self476 0x00, // self
@@ -492,7 +493,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
492 JSON: true,493 JSON: true,
493 },494 },
494 want: []byte{495 want: []byte{
495- 0x00, 0x18,496+ 0x00, 0x19,
496 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID497 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
497 0x00, // attached498 0x00, // attached
498 0x01, // self499 0x01, // self
@@ -890,9 +891,9 @@ func TestHelloOutputWindowByteExactValues(t *testing.T) {
890 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {891 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
891 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}892 hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
892 // The empty Hello has a fixed 1x1 size before the negotiated output window.893 // 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)
894 want[1] = 14895 want[1] = 14
895- want[30], want[32] = 1, 1896+ want[31], want[33] = 1, 1
896 got := MarshalHello(hello)897 got := MarshalHello(hello)
897 requireBytesEqual(t, want, got)898 requireBytesEqual(t, want, got)
898 back, err := UnmarshalHello(got)899 back, err := UnmarshalHello(got)
@@ -920,7 +921,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
920 name: "attached",921 name: "attached",
921 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},922 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
922 want: []byte{923 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,
924 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',925 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
925 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',926 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
926 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,927 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -930,7 +931,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
930 name: "control",931 name: "control",
931 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},932 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
932 want: []byte{933 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,
934 0x00, 0x02, 'l', 's', 0x00, 0x00,935 0x00, 0x02, 'l', 's', 0x00, 0x00,
935 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,936 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
936 },937 },
@@ -989,6 +990,7 @@ func TestOutputResetRequestStrict(t *testing.T) {
989 }990 }
990 991
991 func TestSessionMetaGoldenAndStrict(t *testing.T) {992 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}
992 tests := []struct {994 tests := []struct {
993 name string995 name string
994 msg SessionMeta996 msg SessionMeta
@@ -996,13 +998,29 @@ func TestSessionMetaGoldenAndStrict(t *testing.T) {
996 }{998 }{
997 {999 {
998 name: "one unicode tab",1000 name: "one unicode tab",
999- msg: SessionMeta{SessionName: "démo", Active: 0, Tabs: []SessionTabMeta{{Index: 0, Name: "é", Attention: true}}},1001+ msg: SessionMeta{LifecycleID: lifecycle, Revision: 2, SessionName: "démo", ActiveTabID: "tab-é", Tabs: []SessionTabMeta{{ID: "tab-é", Name: "é", Attention: true}}},
1000- want: []byte{0x00, 0x05, 'd', 0xc3, 0xa9, 'm', 'o', 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0xc3, 0xa9, 0x01},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+ },
1001 },1011 },
1002 {1012 {
1003 name: "multiple tabs",1013 name: "multiple tabs",
1004- msg: SessionMeta{SessionName: "work", Active: 1, Tabs: []SessionTabMeta{{Index: 0, Name: "shell"}, {Index: 1, Name: "build", Attention: true}}},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}}},
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},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+ },
1006 },1024 },
1007 }1025 }
1008 for _, tt := range tests {1026 for _, tt := range tests {
@@ -1019,36 +1037,40 @@ func TestSessionMetaGoldenAndStrict(t *testing.T) {
1019 }1037 }
1020 1038
1021 maxString := string(bytes.Repeat([]byte{'x'}, math.MaxUint16))1039 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}}})
1023 require.NoError(t, err)1041 require.NoError(t, err)
1024 back, err := UnmarshalSessionMeta(payload)1042 back, err := UnmarshalSessionMeta(payload)
1025 require.NoError(t, err)1043 require.NoError(t, err)
1026 require.Equal(t, maxString, back.SessionName)1044 require.Equal(t, maxString, back.SessionName)
1027 require.Equal(t, maxString, back.Tabs[0].Name)1045 require.Equal(t, maxString, back.Tabs[0].Name)
1028 1046
1047+ validMeta := func() SessionMeta {
1048+ return SessionMeta{LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}}
1049+ }
1029 invalid := []SessionMeta{1050 invalid := []SessionMeta{
1030 {},1051 {},
1031- {Tabs: []SessionTabMeta{{Name: "one"}}, Active: 1},1052+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1032- {Tabs: []SessionTabMeta{{Index: 1, Name: "one"}}},1053+ {LifecycleID: lifecycle, SessionName: "work", ActiveTabID: "tab", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1033- {Tabs: []SessionTabMeta{{Name: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1))}}, Active: 0},1054+ {LifecycleID: lifecycle, Revision: 1, SessionName: "work", ActiveTabID: "missing", Tabs: []SessionTabMeta{{ID: "tab", Name: "one"}}},
1034- {SessionName: string(bytes.Repeat([]byte{'x'}, math.MaxUint16+1)), Tabs: []SessionTabMeta{{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"}}},
1035 }1058 }
1036 for _, msg := range invalid {1059 for _, msg := range invalid {
1037 _, err := MarshalSessionMeta(msg)1060 _, err := MarshalSessionMeta(msg)
1038 require.Error(t, err)1061 require.Error(t, err)
1039 }1062 }
1040 1063
1041- valid, err := MarshalSessionMeta(tests[1].msg)1064+ valid, err := MarshalSessionMeta(validMeta())
1042 require.NoError(t, err)1065 require.NoError(t, err)
1043 for _, tt := range []struct {1066 for _, tt := range []struct {
1044 name string1067 name string
1045 mutate func([]byte)1068 mutate func([]byte)
1046 wantError error1069 wantError error
1047 }{1070 }{
1048- {name: "active outside tab count", mutate: func(b []byte) { b[6] = 0; b[7] = 2 }, wantError: ErrInvalidSessionMeta},1071+ {name: "zero lifecycle", mutate: func(b []byte) { clear(b[:16]) }, wantError: ErrInvalidSessionMeta},
1049- // The first tab index is the uint16 at bytes 10–11, after the session1072+ {name: "zero revision", mutate: func(b []byte) { clear(b[16:24]) }, wantError: ErrInvalidSessionMeta},
1050- // name, active index, and tab count.1073+ {name: "invalid active stable ID", mutate: func(b []byte) { b[32] = ' ' }, wantError: ErrInvalidSessionMeta},
1051- {name: "first tab index out of order", mutate: func(b []byte) { b[10] = 0; b[11] = 1 }, wantError: ErrInvalidSessionMeta},
1052 {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},1074 {name: "attention flag malformed", mutate: func(b []byte) { b[len(b)-1] = 2 }},
1053 } {1075 } {
1054 t.Run(tt.name, func(t *testing.T) {1076 t.Run(tt.name, func(t *testing.T) {
M internal/usecase/client/client.go +37 -5
@@ -171,6 +171,7 @@ type Dependencies struct {
171 type AttachRequest struct {171 type AttachRequest struct {
172 Intent uint8172 Intent uint8
173 SessionName string173 SessionName string
174+ RenderMode ports.RenderMode
174 Remote bool175 Remote bool
175 RemoteTarget *domain.RemoteSessionTarget176 RemoteTarget *domain.RemoteSessionTarget
176 EnvironmentPolicy ports.EnvironmentPolicy177 EnvironmentPolicy ports.EnvironmentPolicy
@@ -227,7 +228,32 @@ func (r *Runner) terminalInput() *terminalInputPump {
227 }228 }
228 229
229 func validateAttachRequest(request AttachRequest) error {230 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+ }
230 if request.RemoteTarget == nil {250 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+ }
231 return nil257 return nil
232 }258 }
233 if err := request.RemoteTarget.Validate(); err != nil {259 if err := request.RemoteTarget.Validate(); err != nil {
@@ -239,6 +265,9 @@ func validateAttachRequest(request AttachRequest) error {
239 if request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {265 if request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
240 return errors.New("vev: remote attach target requires daemon-owned environment")266 return errors.New("vev: remote attach target requires daemon-owned environment")
241 }267 }
268+ if request.Intent != ports.IntentAttach && request.Intent != ports.IntentResume {
269+ return fmt.Errorf("vev: remote attach target requires attach or resume intent")
270+ }
242 return nil271 return nil
243 }272 }
244 273
@@ -246,6 +275,7 @@ func validateAttachRequest(request AttachRequest) error {
246 // above attach attempts so raw mode remains active while a live client process275 // above attach attempts so raw mode remains active while a live client process
247 // redials a lost link.276 // redials a lost link.
248 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {277 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
278+ request = cloneAttachRequest(request)
249 if err := validateAttachRequest(request); err != nil {279 if err := validateAttachRequest(request); err != nil {
250 return err280 return err
251 }281 }
@@ -378,10 +408,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
378 return errors.New("vev: dialer returned nil transport")408 return errors.New("vev: dialer returned nil transport")
379 }409 }
380 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)410 stopHandshakeTransport := watchHandshakeTransport(handshakeCtx, transport)
381- connection, err := NewSessionConnection(transport, SessionTarget{411+ connection, err := NewSessionConnection(transport, attemptRequest)
382- Intent: attemptRequest.Intent,
383- SessionName: attemptRequest.SessionName,
384- })
385 if err != nil {412 if err != nil {
386 stopHandshakeTransport()413 stopHandshakeTransport()
387 finishHandshake()414 finishHandshake()
@@ -430,6 +457,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
430 if handoffErr != nil {457 if handoffErr != nil {
431 return handoffErr458 return handoffErr
432 }459 }
460+ nextRequest = cloneAttachRequest(nextRequest)
433 if err := validateAttachRequest(nextRequest); err != nil {461 if err := validateAttachRequest(nextRequest); err != nil {
434 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)462 return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)
435 }463 }
@@ -679,7 +707,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
679 enterRaw := a.enterRaw707 enterRaw := a.enterRaw
680 reconnect := a.reconnect708 reconnect := a.reconnect
681 linkEvents := a.linkEvents709 linkEvents := a.linkEvents
682- remote := a.remote || a.request.Remote710+ remote := a.remote || request.Remote
683 clipboard := a.runner.clipboard711 clipboard := a.runner.clipboard
684 log := a.runner.logger712 log := a.runner.logger
685 observer := a.runner.runtimeObserver713 observer := a.runner.runtimeObserver
@@ -737,6 +765,7 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
737 ClientID: clientID,765 ClientID: clientID,
738 ResumeToken: resumeToken,766 ResumeToken: resumeToken,
739 Name: name,767 Name: name,
768+ RenderMode: request.RenderMode,
740 Size: size,769 Size: size,
741 TermEnv: termEnv,770 TermEnv: termEnv,
742 Cwd: cwd,771 Cwd: cwd,
@@ -771,6 +800,9 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
771 if derr != nil {800 if derr != nil {
772 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))801 return result(false, fmt.Errorf("vev: decoding welcome: %w", derr))
773 }802 }
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+ }
774 resumeToken = welcome.ResumeToken806 resumeToken = welcome.ResumeToken
775 name = welcome.SessionName807 name = welcome.SessionName
776 ms.welcomed = true808 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) {
789 type recordingTransport struct {789 type recordingTransport struct {
790 recvs []recvItem790 recvs []recvItem
791 sends []ports.Frame791 sends []ports.Frame
792+ onSend func(ports.Frame)
792 closed atomic.Int32793 closed atomic.Int32
793 }794 }
794 795
795 func (t *recordingTransport) Send(f ports.Frame) error {796 func (t *recordingTransport) Send(f ports.Frame) error {
796 t.sends = append(t.sends, f)797 t.sends = append(t.sends, f)
798+ if t.onSend != nil {
799+ t.onSend(f)
800+ }
797 return nil801 return nil
798 }802 }
799 803
@@ -846,33 +850,157 @@ func welcomeFrame(token uint64) ports.Frame {
846 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))850 return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
847 }851 }
848 852
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+ }
850 term := newRunTerminal()936 term := newRunTerminal()
851 defer term.in.unblock()937 defer term.in.unblock()
852- tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}938+ tr := &recordingTransport{recvs: []recvItem{
853- tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}939+ {f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{
854- tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}940+ SessionID: "s1", SessionName: "main", RenderMode: ports.RenderModeProxiedContent,
855- d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}941+ }))},
856-942+ {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
857- err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})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+ })
858 require.NoError(t, err)949 require.NoError(t, err)
859- require.Equal(t, int32(3), d.calls.Load())950+ require.Equal(t, ports.RenderModeProxiedContent, helloFromSend(t, tr).RenderMode)
860- require.Equal(t, int32(1), term.rawCount.Load())951+}
861- require.Equal(t, int32(1), term.restoreCount.Load())
862 952
863- h1 := helloFromSend(t, tr1)953+func TestRunnerRejectsInvalidAttachRequestBeforeHello(t *testing.T) {
864- h2 := helloFromSend(t, tr2)954+ lifecycle := domain.SessionLifecycleID{}
865- h3 := helloFromSend(t, tr3)955+ lifecycle[0] = 1
866- require.Equal(t, ports.IntentAttach, h1.Intent)956+ validTarget := domain.RemoteSessionTarget{
867- require.Zero(t, h1.ResumeToken)957+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
868- require.Equal(t, ports.IntentResume, h2.Intent)958+ SessionName: "main", LiveTabID: "tab-live",
869- require.Equal(t, uint64(11), h2.ResumeToken)959+ }
870- require.Equal(t, ports.IntentResume, h3.Intent)960+ tests := []struct {
871- require.Equal(t, uint64(22), h3.ResumeToken)961+ name string
872- require.Equal(t, h1.ClientID, h2.ClientID)962+ request client.AttachRequest
873- require.Equal(t, h1.ClientID, h3.ClientID)963+ wantErr string
874- require.Contains(t, term.out.String(), "reconnecting…")964+ }{
875- require.Contains(t, term.out.String(), "\x1b[2K")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+ }
876 }1004 }
877 1005
878 func TestAttachRememberRemoteHost(t *testing.T) {1006 func TestAttachRememberRemoteHost(t *testing.T) {
M internal/usecase/client/session_connection.go +17 -32
@@ -4,52 +4,38 @@ import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6 6
7- "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
9 )8 )
10 9
11 var errNilSessionTransport = errors.New("client: nil session transport")10 var errNilSessionTransport = errors.New("client: nil session transport")
12 11
13-// SessionTarget is the validated daemon-facing session selection. Endpoint12+func cloneAttachRequest(request AttachRequest) AttachRequest {
14-// details are resolved before a transport is opened and never enter this type.13+ if request.RemoteTarget != nil {
15-type SessionTarget struct {14+ target := *request.RemoteTarget
16- Intent uint815+ request.RemoteTarget = &target
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)
32 }16 }
33- return domain.ValidateSessionName(t.SessionName)17+ return request
34 }18 }
35 19
36-// SessionConnection owns one already-open transport and its daemon-facing20+// SessionConnection owns one already-open transport and the complete
37-// session target. Local sockets and remote carriages use this same owner.21+// validated daemon-facing attach request. Local sockets and remote carriages
22+// use this same owner.
38 type SessionConnection struct {23 type SessionConnection struct {
39 transport ports.Transport24 transport ports.Transport
40- target SessionTarget25+ request AttachRequest
41 }26 }
42 27
43 // NewSessionConnection creates a connection for an already-selected transport.28 // NewSessionConnection creates a connection for an already-selected transport.
44-// Target validation happens before the connection is used for a Hello.29+// Request validation happens before the connection is used for a Hello.
45-func NewSessionConnection(transport ports.Transport, target SessionTarget) (*SessionConnection, error) {30+func NewSessionConnection(transport ports.Transport, request AttachRequest) (*SessionConnection, error) {
46 if transport == nil {31 if transport == nil {
47 return nil, errNilSessionTransport32 return nil, errNilSessionTransport
48 }33 }
49- if err := target.validate(); err != nil {34+ request = cloneAttachRequest(request)
50- return nil, fmt.Errorf("invalid session target: %w", err)35+ if err := validateAttachRequest(request); err != nil {
36+ return nil, fmt.Errorf("invalid attach request: %w", err)
51 }37 }
52- return &SessionConnection{transport: transport, target: target}, nil38+ return &SessionConnection{transport: transport, request: request}, nil
53 }39 }
54 40
55 // Transport returns the owned carriage for this connection.41 // Transport returns the owned carriage for this connection.
@@ -60,13 +46,12 @@ func (c *SessionConnection) Transport() ports.Transport {
60 return c.transport46 return c.transport
61 }47 }
62 48
63-// AttachRequest returns the common request shape used after transport49+// AttachRequest returns the complete request retained for this connection.
64-// selection. It contains no remote/proxy semantic.
65 func (c *SessionConnection) AttachRequest() AttachRequest {50 func (c *SessionConnection) AttachRequest() AttachRequest {
66 if c == nil {51 if c == nil {
67 return AttachRequest{}52 return AttachRequest{}
68 }53 }
69- return AttachRequest{Intent: c.target.Intent, SessionName: c.target.SessionName}54+ return cloneAttachRequest(c.request)
70 }55 }
71 56
72 // Close releases the owned transport.57 // Close releases the owned transport.
M internal/usecase/client/session_connection_test.go +40 -16
@@ -3,46 +3,70 @@ package client_test
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 portsmocks "github.com/bnema/vev/internal/ports/mocks"8 portsmocks "github.com/bnema/vev/internal/ports/mocks"
8 "github.com/bnema/vev/internal/usecase/client"9 "github.com/bnema/vev/internal/usecase/client"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
11 12
12-func TestSessionConnectionUsesOneAttachShapeForLocalAndRemoteTransports(t *testing.T) {13+func TestSessionConnectionOwnsCompleteAttachRequestForLocalAndRemoteTransports(t *testing.T) {
13- target := client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}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
14 local := portsmocks.NewMockTransport(t)31 local := portsmocks.NewMockTransport(t)
15 remote := portsmocks.NewMockTransport(t)32 remote := portsmocks.NewMockTransport(t)
16 33
17- localConnection, err := client.NewSessionConnection(local, target)34+ localConnection, err := client.NewSessionConnection(local, request)
18 require.NoError(t, err)35 require.NoError(t, err)
19- remoteConnection, err := client.NewSessionConnection(remote, target)36+ remoteConnection, err := client.NewSessionConnection(remote, request)
20 require.NoError(t, err)37 require.NoError(t, err)
21 38
39+ target.LiveTabID = "mutated-tab"
40+ target.LifecycleID = domain.SessionLifecycleID{}
41+ returned := localConnection.AttachRequest()
42+ returned.RemoteTarget.LiveTabID = "mutated-returned-tab"
43+
22 require.Equal(t, localConnection.AttachRequest(), remoteConnection.AttachRequest())44 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())
24 require.Same(t, local, localConnection.Transport())46 require.Same(t, local, localConnection.Transport())
25 require.Same(t, remote, remoteConnection.Transport())47 require.Same(t, remote, remoteConnection.Transport())
26 }48 }
27 49
28-func TestSessionTargetValidation(t *testing.T) {50+func TestSessionConnectionAttachRequestValidation(t *testing.T) {
29 tests := []struct {51 tests := []struct {
30 name string52 name string
31- target client.SessionTarget53+ request client.AttachRequest
32 wantErr bool54 wantErr bool
33 }{55 }{
34- {name: "ephemeral", target: client.SessionTarget{Intent: ports.IntentEphemeral}},56+ {name: "ephemeral", request: client.AttachRequest{Intent: ports.IntentEphemeral}},
35- {name: "new", target: client.SessionTarget{Intent: ports.IntentNew, SessionName: "work"}},57+ {name: "new", request: client.AttachRequest{Intent: ports.IntentNew, SessionName: "work"}},
36- {name: "attach", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "work"}},58+ {name: "attach", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
37- {name: "resume", target: client.SessionTarget{Intent: ports.IntentResume, SessionName: "work"}},59+ {name: "resume", request: client.AttachRequest{Intent: ports.IntentResume, SessionName: "work"}},
38- {name: "missing named session", target: client.SessionTarget{Intent: ports.IntentAttach}, wantErr: true},60+ {name: "missing named session", request: client.AttachRequest{Intent: ports.IntentAttach}, wantErr: true},
39- {name: "unsafe session", target: client.SessionTarget{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},61+ {name: "unsafe session", request: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "my work"}, wantErr: true},
40- {name: "unknown intent", target: client.SessionTarget{Intent: 99, SessionName: "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},
41 }65 }
42 for _, tt := range tests {66 for _, tt := range tests {
43 t.Run(tt.name, func(t *testing.T) {67 t.Run(tt.name, func(t *testing.T) {
44 transport := portsmocks.NewMockTransport(t)68 transport := portsmocks.NewMockTransport(t)
45- _, err := client.NewSessionConnection(transport, tt.target)69+ _, err := client.NewSessionConnection(transport, tt.request)
46 if tt.wantErr {70 if tt.wantErr {
47 require.Error(t, err)71 require.Error(t, err)
48 return72 return
@@ -53,6 +77,6 @@ func TestSessionTargetValidation(t *testing.T) {
53 }77 }
54 78
55 func TestSessionConnectionRequiresTransport(t *testing.T) {79 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})
57 require.Error(t, err)81 require.Error(t, err)
58 }82 }
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
3 import "github.com/bnema/vev/internal/ports"3 import "github.com/bnema/vev/internal/ports"
4 4
5 // attachmentConnectionToken is the exact capability for one registered5 // attachmentConnectionToken is the exact capability for one registered
6-// attachment connection incarnation. It is invalid as soon as any of the6+// attachment connection incarnation. It is invalid as soon as the owner,
7-// session, attachment membership, connection generation, transport link, or7+// attachment membership, connection generation, transport link, or
8 // coordinator lease changes.8 // coordinator lease changes.
9 type attachmentConnectionToken struct {9 type attachmentConnectionToken struct {
10- sess *session10+ owner attachmentOwner
11 ac *attachedClient11 ac *attachedClient
12 generation uint6412 generation uint64
13 transport transportSnapshot13 transport transportSnapshot
@@ -18,11 +18,18 @@ type attachmentConnectionToken struct {
18 rebase bool18 rebase bool
19 }19 }
20 20
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.
21 func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {24 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 {
23 return attachmentConnectionToken{}30 return attachmentConnectionToken{}
24 }31 }
25- // Capture one concrete link incarnation before taking the session lock. The32+ // Capture one concrete link incarnation before taking the owner lock. The
26 // same snapshot is revalidated below; do not take a second snapshot and33 // same snapshot is revalidated below; do not take a second snapshot and
27 // accidentally bind a replacement link.34 // accidentally bind a replacement link.
28 transport := ac.transportSnapshot()35 transport := ac.transportSnapshot()
@@ -32,23 +39,38 @@ func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) att
32 if ac.beforeAttachmentTokenValidation != nil {39 if ac.beforeAttachmentTokenValidation != nil {
33 ac.beforeAttachmentTokenValidation()40 ac.beforeAttachmentTokenValidation()
34 }41 }
35- core := entry.core()42+
36- core.mu.Lock()43+ token := attachmentConnectionToken{owner: owner, ac: ac, transport: transport}
37- if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(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+ }
38 core.mu.Unlock()61 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:
39 return attachmentConnectionToken{}72 return attachmentConnectionToken{}
40 }73 }
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()
52 if !token.current() {74 if !token.current() {
53 return attachmentConnectionToken{}75 return attachmentConnectionToken{}
54 }76 }
@@ -60,27 +82,39 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
60 return attachmentToken(s, ac, tr)82 return attachmentToken(s, ac, tr)
61 }83 }
62 84
63-// current validates every immutable identity captured by the token. Membership85+// localSession narrows a token only for explicitly local operations. It is
64-// is the sole authority; there is no owner, replacement, or compatibility role.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.
65 func (t attachmentConnectionToken) current() bool {95 func (t attachmentConnectionToken) current() bool {
66- return t.sess != nil && t.ac != nil &&96+ return t.owner != nil && t.ac != nil &&
67 t.ac.connectionGeneration.Load() == t.generation &&97 t.ac.connectionGeneration.Load() == t.generation &&
68- t.ac.currentAttachmentSession() == t.sess &&98+ sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) &&
69 t.ac.transportSnapshotCurrent(t.transport) &&99 t.ac.transportSnapshotCurrent(t.transport) &&
70- attachmentRegistered(t.sess, t.ac)100+ attachmentOwnerRegistered(t.owner, t.ac)
71 }101 }
72 102
73 // attachmentCurrent additionally validates the optional coordinator lease used103 // attachmentCurrent additionally validates the optional coordinator lease used
74 // by render and resize effects. Attachments without that optional lease still104 // by render and resize effects. Attachments without that optional lease still
75 // own their independent connection lifecycle.105 // own their independent connection lifecycle.
76 func (t attachmentConnectionToken) attachmentCurrent() bool {106 func (t attachmentConnectionToken) attachmentCurrent() bool {
77- if !t.current() || t.ac.currentAttachmentSession() != t.sess {107+ if !t.current() {
78 return false108 return false
79 }109 }
80 if t.lease == nil {110 if t.lease == nil {
81 return true111 return true
82 }112 }
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()
84 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)118 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
85 }119 }
86 120
@@ -108,23 +142,27 @@ func (t *attachmentConnectionToken) endAttachmentEffect() {
108 t.effect = nil142 t.effect = nil
109 }143 }
110 144
111-// attachmentEffectCurrentSessionLocked is the same check at a session-owned145+// attachmentEffectCurrentSessionLocked is the same check at a local
112-// mutation boundary where sess.core().mu is already held.146+// session-owned mutation boundary where sess.core().mu is already held.
113 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {147 func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
148+ entry := t.localSession()
149+ if entry == nil {
150+ return false
151+ }
114 if t.effect != nil && !t.effect.ended.Load() {152 if t.effect != nil && !t.effect.ended.Load() {
115 return true153 return true
116 }154 }
117- if t.sess == nil || t.ac == nil ||155+ if t.ac == nil ||
118 t.ac.connectionGeneration.Load() != t.generation ||156 t.ac.connectionGeneration.Load() != t.generation ||
119 !t.ac.transportSnapshotCurrent(t.transport) ||157 !t.ac.transportSnapshotCurrent(t.transport) ||
120- t.ac.currentAttachmentSession() != t.sess ||158+ !sameAttachmentOwner(t.ac.currentAttachmentOwner(), t.owner) ||
121- !attachmentRegisteredLocked(t.sess, t.ac) {159+ !attachmentRegisteredLocked(entry, t.ac) {
122 return false160 return false
123 }161 }
124 if t.lease == nil {162 if t.lease == nil {
125 return true163 return true
126 }164 }
127- rc := t.sess.core().coordinator.Load()165+ rc := entry.core().coordinator.Load()
128 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)166 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
129 }167 }
130 168
M internal/usecase/daemon/attachment_effect_gate.go +5 -5
@@ -18,7 +18,7 @@ const (
18 // effect admission. It deliberately contains no independently mutable state:18 // effect admission. It deliberately contains no independently mutable state:
19 // a ticket is admitted only when every field matches the frame's token.19 // a ticket is admitted only when every field matches the frame's token.
20 type attachmentCapability struct {20 type attachmentCapability struct {
21- sess *session21+ owner attachmentOwner
22 generation uint6422 generation uint64
23 transport transportSnapshot23 transport transportSnapshot
24 lease *attachmentLease24 lease *attachmentLease
@@ -26,7 +26,7 @@ type attachmentCapability struct {
26 26
27 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {27 func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
28 return attachmentCapability{28 return attachmentCapability{
29- sess: token.sess,29+ owner: token.owner,
30 generation: token.generation,30 generation: token.generation,
31 transport: token.transport,31 transport: token.transport,
32 lease: token.lease,32 lease: token.lease,
@@ -34,7 +34,7 @@ func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
34 }34 }
35 35
36 func (c attachmentCapability) matches(token attachmentConnectionToken) bool {36 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) &&
38 c.generation == token.generation && c.transport.transport == token.transport.transport &&38 c.generation == token.generation && c.transport.transport == token.transport.transport &&
39 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease39 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
40 }40 }
@@ -271,13 +271,13 @@ func (ac *attachedClient) publishAttachmentCapability(token attachmentConnection
271 // bootstrapAttachmentCapability supports direct/headless session construction. It271 // bootstrapAttachmentCapability supports direct/headless session construction. It
272 // never changes an existing or frozen production publication.272 // never changes an existing or frozen production publication.
273 func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {273 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 {
275 return275 return
276 }276 }
277 g := &ac.attachmentEffects277 g := &ac.attachmentEffects
278 g.mu.Lock()278 g.mu.Lock()
279 g.initLocked()279 g.initLocked()
280- if g.phase == attachmentEffectsStable && g.capability.sess == nil {280+ if g.phase == attachmentEffectsStable && g.capability.owner == nil {
281 g.capability = capabilityFromToken(token)281 g.capability = capabilityFromToken(token)
282 g.failedTransport = transportSnapshot{}282 g.failedTransport = transportSnapshot{}
283 }283 }
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 {
45 }45 }
46 46
47 func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {47 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 &&
49 token.generation == req.next.connectionGeneration.Load() &&49 token.generation == req.next.connectionGeneration.Load() &&
50 token.transport.transport == req.expectedTransport.transport &&50 token.transport.transport == req.expectedTransport.transport &&
51 token.transport.incarnation == req.expectedTransport.incarnation51 token.transport.incarnation == req.expectedTransport.incarnation
@@ -55,9 +55,9 @@ func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source
55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection55 // lease exists, sourceCoordinator.mu. It is the canonical exact-connection
56 // handoff check for client-originated navigation and lifecycle mutations.56 // handoff check for client-originated navigation and lifecycle mutations.
57 func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {57 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 ||
59 token.generation != req.next.connectionGeneration.Load() ||59 token.generation != req.next.connectionGeneration.Load() ||
60- token.sess == nil || token.ac.currentAttachmentSession() != source ||60+ token.localSession() == nil || !sameAttachmentOwner(token.ac.currentAttachmentOwner(), source) ||
61 !attachmentRegisteredLocked(source, req.next) ||61 !attachmentRegisteredLocked(source, req.next) ||
62 !req.next.transportSnapshotCurrent(token.transport) {62 !req.next.transportSnapshotCurrent(token.transport) {
63 return false63 return false
M internal/usecase/daemon/attachment_transition_publish.go +9 -8
@@ -29,25 +29,26 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
29 }29 }
30 30
31 func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {31 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 {
33 return false34 return false
34 }35 }
35 d.mu.Lock()36 d.mu.Lock()
36 defer d.mu.Unlock()37 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 {
38 return false39 return false
39 }40 }
40 d.notices.routingMu.Lock()41 d.notices.routingMu.Lock()
41 defer d.notices.routingMu.Unlock()42 defer d.notices.routingMu.Unlock()
42- token.sess.core().mu.Lock()43+ sess.core().mu.Lock()
43- defer token.sess.core().mu.Unlock()44+ defer sess.core().mu.Unlock()
44- coordinator := token.sess.core().coordinator.Load()45+ coordinator := sess.core().coordinator.Load()
45 if coordinator != nil {46 if coordinator != nil {
46 coordinator.mu.Lock()47 coordinator.mu.Lock()
47 defer coordinator.mu.Unlock()48 defer coordinator.mu.Unlock()
48 }49 }
49- req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}50+ req := attachmentTransitionRequest{source: sess, next: token.ac, expectedTransport: token.transport}
50- return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)51+ return transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
51 }52 }
52 53
53 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {54 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
@@ -215,7 +216,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
215 }216 }
216 }217 }
217 result.published = attachmentConnectionToken{218 result.published = attachmentConnectionToken{
218- sess: req.target, ac: req.next,219+ owner: req.target, ac: req.next,
219 generation: publication.nextGeneration,220 generation: publication.nextGeneration,
220 transport: req.expectedTransport, lease: lease,221 transport: req.expectedTransport, lease: lease,
221 rebase: publication.source != req.target,222 rebase: publication.source != req.target,
M internal/usecase/daemon/client.go +57 -17
@@ -42,6 +42,14 @@ type attachedClient struct {
42 // remoteOrigin is attachment-specific presentation metadata supplied by a42 // remoteOrigin is attachment-specific presentation metadata supplied by a
43 // validated picker handoff; it never participates in routing.43 // validated picker handoff; it never participates in routing.
44 remoteOrigin string44 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
45 // connectionGeneration is the wire-facing capability generation. attachmentEffects is53 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46 // the linearization gate for every attachment-bound observable operation. A role54 // the linearization gate for every attachment-bound observable operation. A role
47 // transition freezes and drains this gate before changing either generation55 // transition freezes and drains this gate before changing either generation
@@ -85,18 +93,18 @@ type attachedClient struct {
85 // tabs and panes without changing shared session ownership.93 // tabs and panes without changing shared session ownership.
86 viewMu sync.Mutex94 viewMu sync.Mutex
87 view attachmentView95 view attachmentView
88- sess Guarded[*session]96+ owner Guarded[attachmentOwner]
89 mouseScan mouse.Scanner97 mouseScan mouse.Scanner
90 themeMu sync.Mutex98 themeMu sync.Mutex
91 clientTheme themeui.Theme99 clientTheme themeui.Theme
92 appliedTheme appliedTheme100 appliedTheme appliedTheme
93 lastCursor cursorOut101 lastCursor cursorOut
94 renderStages renderStageHooks // optional render and handoff observability hooks102 renderStages renderStageHooks // optional render and handoff observability hooks
95- // previousSession is guarded independently. It is retained through temporary103+ // previousOwner is guarded independently. It retains navigation history
96- // setSession(nil) hand-offs and cleared only on terminal teardown.104+ // through temporary owner hand-offs and is cleared only on terminal teardown.
97- previousSession Guarded[*session]105+ previousOwner Guarded[attachmentOwner]
98- linkMu sync.Mutex106+ linkMu sync.Mutex
99- sendMu sync.Mutex107+ sendMu sync.Mutex
100 // routeCreatedSession marks a session created by this attachment's route.108 // routeCreatedSession marks a session created by this attachment's route.
101 // A handshake that never commits Welcome must tear down that exact empty109 // A handshake that never commits Welcome must tear down that exact empty
102 // session, while an attachment routed to an existing session must not.110 // session, while an attachment routed to an existing session must not.
@@ -209,18 +217,37 @@ func (ac *attachedClient) clearGeometryClaim() {
209 ac.sizeMu.Unlock()217 ac.sizeMu.Unlock()
210 }218 }
211 219
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+}
213 229
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.
215 func (ac *attachedClient) currentSession() *session {236 func (ac *attachedClient) currentSession() *session {
216 return ac.currentAttachmentSession()237 return ac.currentAttachmentSession()
217 }238 }
218 239
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) }
220 247
221 func (ac *attachedClient) clearPreviousSession() {248 func (ac *attachedClient) clearPreviousSession() {
222 if ac != nil {249 if ac != nil {
223- ac.previousSession.Set(nil)250+ ac.previousOwner.Set(nil)
224 }251 }
225 }252 }
226 253
@@ -569,6 +596,7 @@ type attachClientOptions struct {
569 resumeCapable bool596 resumeCapable bool
570 maxOutputInFlight uint8597 maxOutputInFlight uint8
571 remoteOrigin string598 remoteOrigin string
599+ renderMode ports.RenderMode
572 }600 }
573 601
574 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {602 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,
615 view: attachmentView{windowRows: sz.Rows, windowSet: true},643 view: attachmentView{windowRows: sz.Rows, windowSet: true},
616 clientID: opts.clientID,644 clientID: opts.clientID,
617 remoteOrigin: opts.remoteOrigin,645 remoteOrigin: opts.remoteOrigin,
646+ renderMode: opts.renderMode,
618 resumeCapable: opts.resumeCapable,647 resumeCapable: opts.resumeCapable,
619 resumeToken: resumeToken,648 resumeToken: resumeToken,
620 }649 }
@@ -759,10 +788,20 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
759 token.ac.captureFrames = nil788 token.ac.captureFrames = nil
760 token.ac.sendMu.Unlock()789 token.ac.sendMu.Unlock()
761 }790 }
762- if token.sess == nil {791+ sess := token.localSession()
792+ if sess == nil {
763 return false793 return false
764 }794 }
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)
766 return true805 return true
767 }806 }
768 807
@@ -834,14 +873,14 @@ func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg po
834 }873 }
835 clientTheme := themeFromMessage(msg)874 clientTheme := themeFromMessage(msg)
836 token.ac.setClientTheme(clientTheme)875 token.ac.setClientTheme(clientTheme)
837- sess := token.sess876+ sess := token.localSession()
838 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {877 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
839 return false878 return false
840 }879 }
841 if !token.attachmentEffectCurrent() {880 if !token.attachmentEffectCurrent() {
842 return false881 return false
843 }882 }
844- if rc := token.sess.core().coordinator.Load(); rc != nil {883+ if rc := sess.core().coordinator.Load(); rc != nil {
845 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})884 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
846 }885 }
847 return false886 return false
@@ -887,7 +926,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
887 d.registerPreviewForSelection(ac)926 d.registerPreviewForSelection(ac)
888 }927 }
889 928
890- sess := token.sess929+ sess := token.localSession()
891 if sess == nil {930 if sess == nil {
892 return false931 return false
893 }932 }
@@ -945,11 +984,12 @@ func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken
945 d.notices.routingMu.Unlock()984 d.notices.routingMu.Unlock()
946 return985 return
947 }986 }
948- if token.sess == nil {987+ sess := token.localSession()
988+ if sess == nil {
949 d.notices.routingMu.Unlock()989 d.notices.routingMu.Unlock()
950 return990 return
951 }991 }
952- repaint := d.mutateClientNotice(token.sess, token.ac, notice)992+ repaint := d.mutateClientNotice(sess, token.ac, notice)
953 d.notices.routingMu.Unlock()993 d.notices.routingMu.Unlock()
954 if repaint && token.attachmentEffectCurrent() {994 if repaint && token.attachmentEffectCurrent() {
955 d.repaintForNotice(token.ac)995 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
176 result.Text = "attached commands cannot override their active session target"176 result.Text = "attached commands cannot override their active session target"
177 return result177 return result
178 }178 }
179- sess := token.sess179+ sess := token.localSession()
180 if sess == nil || !token.attachmentEffectCurrent() {180 if sess == nil || !token.attachmentEffectCurrent() {
181 result.Code = ports.ErrNoSuchTarget181 result.Code = ports.ErrNoSuchTarget
182 result.Text = "attached session is no longer active"182 result.Text = "attached session is no longer active"
@@ -234,7 +234,9 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
234 ac.pipelineCache = composeCacheInput{}234 ac.pipelineCache = composeCacheInput{}
235 ac.pipelineScratch = composeCacheInput{}235 ac.pipelineScratch = composeCacheInput{}
236 ac.sendMu.Unlock()236 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+ }
238 return true240 return true
239 }241 }
240 242
@@ -246,8 +248,10 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
246 ac.sendMu.Lock()248 ac.sendMu.Lock()
247 ac.output.ack(epoch, state)249 ac.output.ack(epoch, state)
248 ac.sendMu.Unlock()250 ac.sendMu.Unlock()
249- if rc := token.sess.core().coordinator.Load(); rc != nil {251+ if sess := token.localSession(); sess != nil {
250- rc.notifyAckForLease(token.lease)252+ if rc := sess.core().coordinator.Load(); rc != nil {
253+ rc.notifyAckForLease(token.lease)
254+ }
251 }255 }
252 return true256 return true
253 }257 }
M internal/usecase/daemon/client_transport_errors.go +2 -2
@@ -37,7 +37,7 @@ func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explic
37 }37 }
38 token.effect.bindActionEnd(d, "detach")38 token.effect.bindActionEnd(d, "detach")
39 token.effect.End()39 token.effect.End()
40- sess := token.sess40+ sess := token.localSession()
41 if sess == nil {41 if sess == nil {
42 return false42 return false
43 }43 }
@@ -127,7 +127,7 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
127 if failed == nil || failed != token.transport.transport {127 if failed == nil || failed != token.transport.transport {
128 return128 return
129 }129 }
130- sess := token.sess130+ sess := token.localSession()
131 if sess == nil {131 if sess == nil {
132 return132 return
133 }133 }
M internal/usecase/daemon/clipboard.go +8 -3
@@ -27,7 +27,7 @@ var (
27 )27 )
28 28
29 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {29 func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if !token.attachmentEffectCurrent() {30+ if !token.attachmentEffectCurrent() || token.ac.renderMode == ports.RenderModeProxiedContent {
31 return31 return
32 }32 }
33 d.handleImagePushForAttachment(token, ip)33 d.handleImagePushForAttachment(token, ip)
@@ -89,7 +89,7 @@ func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToke
89 if err != nil {89 if err != nil {
90 return "", err90 return "", err
91 }91 }
92- sess := token.sess92+ sess := token.localSession()
93 if sess == nil {93 if sess == nil {
94 _ = os.Remove(path)94 _ = os.Remove(path)
95 return "", errAttachmentTransition95 return "", errAttachmentTransition
@@ -135,7 +135,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
135 }135 }
136 136
137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {137 func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess := token.sess138+ sess := token.localSession()
139 if sess == nil || !token.attachmentEffectCurrent() {139 if sess == nil || !token.attachmentEffectCurrent() {
140 return140 return
141 }141 }
@@ -236,6 +236,11 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
236 236
237 items := make([]clipboardForward, 0, len(attachments))237 items := make([]clipboardForward, 0, len(attachments))
238 for _, ac := range attachments {238 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+ }
239 transport := ac.transport()244 transport := ac.transport()
240 token := sess.attachmentToken(ac, transport)245 token := sess.attachmentToken(ac, transport)
241 if token.attachmentCurrent() {246 if token.attachmentCurrent() {
M internal/usecase/daemon/daemon.go +40 -2
@@ -72,6 +72,12 @@ type Daemon struct {
72 mu sync.Mutex72 mu sync.Mutex
73 sessions map[domain.SessionID]*session73 sessions map[domain.SessionID]*session
74 stopped map[string]stoppedSession74 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
75 // creating reserves names while durable creation I/O runs without mu.81 // creating reserves names while durable creation I/O runs without mu.
76 creating map[string]struct{}82 creating map[string]struct{}
77 nextID uint6483 nextID uint64
@@ -322,7 +328,7 @@ type Daemon struct {
322 }328 }
323 329
324 type parkedAttachment struct {330 type parkedAttachment struct {
325- sess *session331+ owner attachmentOwner
326 ac *attachedClient332 ac *attachedClient
327 pickerGeneration uint64333 pickerGeneration uint64
328 timer ports.Timer334 timer ports.Timer
@@ -335,7 +341,7 @@ type parkedAttachment struct {
335 // credential. done closes when the token is published into parked or parking341 // credential. done closes when the token is published into parked or parking
336 // is abandoned.342 // is abandoned.
337 type parkingAttachment struct {343 type parkingAttachment struct {
338- sess *session344+ owner attachmentOwner
339 ac *attachedClient345 ac *attachedClient
340 done chan struct{}346 done chan struct{}
341 doneOnce sync.Once347 doneOnce sync.Once
@@ -596,6 +602,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
596 d := &Daemon{602 d := &Daemon{
597 sessions: make(map[domain.SessionID]*session),603 sessions: make(map[domain.SessionID]*session),
598 stopped: make(map[string]stoppedSession),604 stopped: make(map[string]stoppedSession),
605+ remoteViews: make(map[remoteViewID]*remoteView),
606+ remoteViewsByKey: make(map[remoteViewKey]remoteViewID),
599 creating: make(map[string]struct{}),607 creating: make(map[string]struct{}),
600 parked: make(map[uint64]*parkedAttachment),608 parked: make(map[uint64]*parkedAttachment),
601 parking: make(map[uint64]*parkingAttachment),609 parking: make(map[uint64]*parkingAttachment),
@@ -796,9 +804,20 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
796 d.closing = true804 d.closing = true
797 d.purgeAllParkingLocked()805 d.purgeAllParkingLocked()
798 parkedRetirements := d.purgeAllParkedLocked()806 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)
799 snapshot := d.sessionsSnapshotLocked()813 snapshot := d.sessionsSnapshotLocked()
800 empty := len(snapshot) == 0814 empty := len(snapshot) == 0
801 d.mu.Unlock()815 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+ }
802 d.finishParkedAttachmentRetirements(parkedRetirements)821 d.finishParkedAttachmentRetirements(parkedRetirements)
803 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))822 d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot))
804 if empty {823 if empty {
@@ -1198,6 +1217,24 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1198 failAttachment()1217 failAttachment()
1199 return1218 return
1200 }1219 }
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+ }
1201 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {1238 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
1202 postWelcomeTicket.End()1239 postWelcomeTicket.End()
1203 // The attachment was detached while Welcome was in flight; never let1240 // 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,
1269 clientID: h.ClientID,1306 clientID: h.ClientID,
1270 resumeCapable: true,1307 resumeCapable: true,
1271 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1308 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1309+ renderMode: h.RenderMode,
1272 }1310 }
1273 if h.RemoteTarget != nil {1311 if h.RemoteTarget != nil {
1274 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin1312 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
M internal/usecase/daemon/input.go +35 -3
@@ -7,6 +7,7 @@ import (
7 "strconv"7 "strconv"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/keys"11 "github.com/bnema/vev/internal/usecase/keys"
11 "github.com/bnema/vev/internal/usecase/layout"12 "github.com/bnema/vev/internal/usecase/layout"
12 "github.com/bnema/vev/internal/usecase/mouse"13 "github.com/bnema/vev/internal/usecase/mouse"
@@ -24,9 +25,39 @@ func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionTok
24 if !token.attachmentEffectCurrent() {25 if !token.attachmentEffectCurrent() {
25 return26 return
26 }27 }
28+ if token.ac.renderMode == ports.RenderModeProxiedContent {
29+ d.handleProxiedInputForAttachment(token, data)
30+ return
31+ }
27 d.handleInputForAttachment(token, data)32 d.handleInputForAttachment(token, data)
28 }33 }
29 34
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+
30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {61 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
31 ac.initOverlays()62 ac.initOverlays()
32 ac.mouseScan.Scan(data,63 ac.mouseScan.Scan(data,
@@ -383,7 +414,7 @@ type daemonKeyHandler struct {
383 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {414 func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384 if h.connectionToken.ac != nil {415 if h.connectionToken.ac != nil {
385 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {416 if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- return h.connectionToken.sess, effect, false417+ return h.connectionToken.localSession(), effect, false
387 }418 }
388 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)419 effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
389 if h.d.afterDelayedKeyEffectAttempt != nil {420 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -397,11 +428,12 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
397 token.effect = effect428 token.effect = effect
398 h.d.afterAttachmentEffectAdmitted(token)429 h.d.afterAttachmentEffectAdmitted(token)
399 }430 }
400- if h.connectionToken.sess == nil {431+ sess := h.connectionToken.localSession()
432+ if sess == nil {
401 effect.End()433 effect.End()
402 return nil, nil, false434 return nil, nil, false
403 }435 }
404- return h.connectionToken.sess, effect, true436+ return sess, effect, true
405 }437 }
406 sess := h.ac.currentSession()438 sess := h.ac.currentSession()
407 if sess == nil {439 if sess == nil {
M internal/usecase/daemon/input_test.go +15 -15
@@ -799,8 +799,8 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
799 prepare func()799 prepare func()
800 }{800 }{
801 {name: "no target", prepare: func() {}},801 {name: "no target", prepare: func() {}},
802- {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},802+ {name: "stale target", prepare: func() { ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
803- {name: "same session", prepare: func() { ac.previousSession.Set(current) }},803+ {name: "same session", prepare: func() { ac.previousOwner.Set(current) }},
804 } {804 } {
805 t.Run(tc.name, func(t *testing.T) {805 t.Run(tc.name, func(t *testing.T) {
806 ac.clearPreviousSession()806 ac.clearPreviousSession()
@@ -808,7 +808,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
808 runPaletteCommand(t, d, current, ac, "BSK")808 runPaletteCommand(t, d, current, ac, "BSK")
809 require.Same(t, current, ac.currentSession())809 require.Same(t, current, ac.currentSession())
810 if tc.name != "no target" {810 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")
812 }812 }
813 })813 })
814 }814 }
@@ -818,7 +818,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)818 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
819 defer releaseAll(releases)819 defer releaseAll(releases)
820 target := d.sessions[domain.SessionID("recent")]820 target := d.sessions[domain.SessionID("recent")]
821- ac.previousSession.Set(target)821+ ac.previousOwner.Set(target)
822 822
823 // A displaced attachment makes the previously valid handoff stale by the823 // A displaced attachment makes the previously valid handoff stale by the
824 // time switchToTarget commits it.824 // time switchToTarget commits it.
@@ -829,7 +829,7 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
829 d.backSession(current, ac)829 d.backSession(current, ac)
830 830
831 require.Same(t, current, ac.currentSession(), "a stale handoff must leave the attachment on its origin")831 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")
833 history := d.notices.history()833 history := d.notices.history()
834 require.Len(t, history, 1, "the switch failure must be reported exactly once")834 require.Len(t, history, 1, "the switch failure must be reported exactly once")
835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)835 require.Equal(t, domain.NoticeSessionUnavailable, history[0].Code)
@@ -839,14 +839,14 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
839 ac := &attachedClient{}839 ac := &attachedClient{}
840 stale := &session{sessionCore: sessionCore{id: "stale"}}840 stale := &session{sessionCore: sessionCore{id: "stale"}}
841 updated := &session{sessionCore: sessionCore{id: "updated"}}841 updated := &session{sessionCore: sessionCore{id: "updated"}}
842- ac.previousSession.Set(stale)842+ ac.previousOwner.Set(stale)
843 843
844 // Model a completed hand-off between observing a stale target and clearing844 // Model a completed hand-off between observing a stale target and clearing
845 // it. The conditional clear must not erase the newer toggle destination.845 // it. The conditional clear must not erase the newer toggle destination.
846- ac.previousSession.Set(updated)846+ ac.previousOwner.Set(updated)
847- ac.clearPreviousSessionIf(stale)847+ ac.clearPreviousOwnerIf(stale)
848 848
849- require.Same(t, updated, ac.previousSession.Get())849+ require.Same(t, updated, ac.previousOwner.Get())
850 }850 }
851 851
852 // backSession's invalid-target fallback is a render producer: it must publish852 // backSession's invalid-target fallback is a render producer: it must publish
@@ -861,10 +861,10 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
861 }{861 }{
862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},862 {name: "no previous target", prepare: func(*session, *attachedClient) {}},
863 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {863 {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"}})
865 }},865 }},
866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {866 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
867- ac.previousSession.Set(current)867+ ac.previousOwner.Set(current)
868 }},868 }},
869 } {869 } {
870 t.Run(tc.name, func(t *testing.T) {870 t.Run(tc.name, func(t *testing.T) {
@@ -940,10 +940,10 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
940 }940 }
941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)941 require.Equal(t, domain.SessionID(tc.wantCurrent), ac.currentSession().id)
942 if tc.wantPrev == "" {942 if tc.wantPrev == "" {
943- require.Nil(t, ac.previousSession.Get())943+ require.Nil(t, ac.previousOwner.Get())
944 return944 return
945 }945 }
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)
947 })947 })
948 }948 }
949 }949 }
@@ -963,10 +963,10 @@ func TestTerminalTeardownClearsPreviousSession(t *testing.T) {
963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)963 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
964 defer releaseAll(releases)964 defer releaseAll(releases)
965 965
966- ac.previousSession.Set(d.sessions[domain.SessionID("recent")])966+ ac.previousOwner.Set(d.sessions[domain.SessionID("recent")])
967 tc.teardown(d, current, ac)967 tc.teardown(d, current, ac)
968 968
969- require.Nil(t, ac.previousSession.Get())969+ require.Nil(t, ac.previousOwner.Get())
970 })970 })
971 }971 }
972 }972 }
M internal/usecase/daemon/move_attachments.go +2 -2
@@ -1,8 +1,8 @@
1 package daemon1 package daemon
2 2
3 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {3 func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4- return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&4+ return sameAttachmentOwner(token.owner, sess) && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5- token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&5+ sameAttachmentOwner(token.ac.currentAttachmentOwner(), sess) && token.ac.transportSnapshotCurrent(token.transport) &&
6 attachmentRegisteredLocked(sess, token.ac)6 attachmentRegisteredLocked(sess, token.ac)
7 }7 }
8 8
M internal/usecase/daemon/palette_test.go +1 -1
@@ -460,7 +460,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
460 // Establish a valid back-session target, then remove that target immediately460 // Establish a valid back-session target, then remove that target immediately
461 // after the command releases its role admission.461 // after the command releases its role admission.
462 target := d.sessions[domain.SessionID("recent")]462 target := d.sessions[domain.SessionID("recent")]
463- ac.previousSession.Set(target)463+ ac.previousOwner.Set(target)
464 invalidations := installPaletteInvalidationObserver(current)464 invalidations := installPaletteInvalidationObserver(current)
465 d.enterPalette(current, ac)465 d.enterPalette(current, ac)
466 d.handlePaletteInput(ac, []byte("BSK"))466 d.handlePaletteInput(ac, []byte("BSK"))
M internal/usecase/daemon/picker.go +10 -8
@@ -794,7 +794,8 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
794 }794 }
795 795
796 func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {796 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 {
798 return nil799 return nil
799 }800 }
800 d.mu.Lock()801 d.mu.Lock()
@@ -804,15 +805,15 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
804 if !token.attachmentCurrent() {805 if !token.attachmentCurrent() {
805 return nil806 return nil
806 }807 }
807- d.invalidateRender(token.sess, token.ac, true, "picker.go")808+ d.invalidateRender(source, token.ac, true, "picker.go")
808 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)809 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
809 }810 }
810- if targetSess == token.sess {811+ if targetSess == source {
811 return nil812 return nil
812 }813 }
813 814
814 transition, err := d.transitionAttachment(attachmentTransitionRequest{815 transition, err := d.transitionAttachment(attachmentTransitionRequest{
815- source: token.sess, target: targetSess, next: token.ac,816+ source: source, target: targetSess, next: token.ac,
816 817
817 expectedTransport: token.transport, sourceToken: &token, action: action,818 expectedTransport: token.transport, sourceToken: &token, action: action,
818 expectedTargetLifecycle: pickerTargetLifecycleFence(target),819 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
@@ -834,7 +835,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnecti
834 }835 }
835 }836 }
836 d.touchMRU(targetSess)837 d.touchMRU(targetSess)
837- token.ac.recordPreviousSession(token.sess)838+ token.ac.recordPreviousSession(source)
838 d.deferAttachmentTransitionCleanups(transition)839 d.deferAttachmentTransitionCleanups(transition)
839 d.firstPaintForTransition(transition.published)840 d.firstPaintForTransition(transition.published)
840 return nil841 return nil
@@ -852,7 +853,7 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
852 // every active, stopped, and same-session target then uses transitionAttachment853 // every active, stopped, and same-session target then uses transitionAttachment
853 // for frozen, atomic source-token preflight.854 // for frozen, atomic source-token preflight.
854 func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {855 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 {
856 return nil857 return nil
857 }858 }
858 if target.RemoteTarget != nil {859 if target.RemoteTarget != nil {
@@ -867,10 +868,11 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
867 }868 }
868 token.effect.bindActionEnd(d, action)869 token.effect.bindActionEnd(d, action)
869 token.effect.End()870 token.effect.End()
870- if token.sess == nil {871+ source := token.localSession()
872+ if source == nil {
871 return errAttachmentTransition873 return errAttachmentTransition
872 }874 }
873- return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)875+ return d.switchToTargetGuardedForAttachment(source, token.ac, target, guard, &token, action)
874 }876 }
875 877
876 // sendRemoteAttachTargetForAttachment validates the catalog row and hands the878 // 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) {
920 targetPane.screen.Write([]byte("TARGET"))920 targetPane.screen.Write([]byte("TARGET"))
921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
922 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")922 require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
923- ac.previousSession.Set(target)923+ ac.previousOwner.Set(target)
924 924
925 // Exercise the user-facing previous-session route, which delegates to the925 // Exercise the user-facing previous-session route, which delegates to the
926 // real switchToTarget hand-off and immediately emits its required reset.926 // 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 (
299 )299 )
300 300
301 func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {301 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+ }
302 // Session-owned PTY preparation remains local; attachment rendering is305 // Session-owned PTY preparation remains local; attachment rendering is
303 // captured through session.captureRenderState.306 // captured through session.captureRenderState.
304 sess := entry307 sess := entry
@@ -424,7 +427,7 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
424 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,427 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
425 }428 }
426 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)429 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
427- state, ok := entry.captureRenderState(ac, renderCaptureRequest{430+ captureRequest := renderCaptureRequest{
428 bars: bars,431 bars: bars,
429 overlays: capturedOverlays,432 overlays: capturedOverlays,
430 preview: preview,433 preview: preview,
@@ -433,7 +436,8 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
433 styleGeneration: applied.Generation,436 styleGeneration: applied.Generation,
434 reset: reset,437 reset: reset,
435 lease: lease,438 lease: lease,
436- })439+ }
440+ state, ok := entry.captureRenderState(ac, captureRequest)
437 endCapture(0, ok)441 endCapture(0, ok)
438 if !ok {442 if !ok {
439 ac.sendMu.Unlock()443 ac.sendMu.Unlock()
M internal/usecase/daemon/resize_publication.go +2 -2
@@ -28,11 +28,11 @@ func (p resizeCommitPublication) current() bool {
28 if p.connection.effect == nil {28 if p.connection.effect == nil {
29 return false29 return false
30 }30 }
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) ||
32 p.connection.ac != p.attachment || p.connection.lease != p.lease ||32 p.connection.ac != p.attachment || p.connection.lease != p.lease ||
33 p.connection.generation != p.attachment.connectionGeneration.Load() ||33 p.connection.generation != p.attachment.connectionGeneration.Load() ||
34 !p.attachment.transportSnapshotCurrent(p.connection.transport) ||34 !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 {
36 return false36 return false
37 }37 }
38 registered := attachmentRegistered(p.session, p.attachment)38 registered := attachmentRegistered(p.session, p.attachment)
M internal/usecase/daemon/resume.go +48 -45
@@ -41,47 +41,48 @@ func (d *Daemon) nextResumeTokenLocked() uint64 {
41 // it returns 0 and never creates a marker after terminal41 // it returns 0 and never creates a marker after terminal
42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.42 // cleanup. Returns the resume token, or 0 when parking cannot be advertised.
43 func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {43 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 {
45 return 052 return 0
46 }53 }
47 d.mu.Lock()54 d.mu.Lock()
48 defer d.mu.Unlock()55 defer d.mu.Unlock()
49- if d.closing || d.sessions[sess.id] != sess {56+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
50- return 0
51- }
52- sess.mu.Lock()
53- registered := attachmentRegisteredLocked(sess, ac)
54- sess.mu.Unlock()
55- if !registered {
56 return 057 return 0
57 }58 }
58 if ac.resumeToken == 0 {59 if ac.resumeToken == 0 {
59 ac.resumeToken = d.nextResumeTokenLocked()60 ac.resumeToken = d.nextResumeTokenLocked()
60 }61 }
61- d.ensureParkingInFlightLocked(sess, ac)62+ d.ensureParkingInFlightLocked(owner, ac)
62- d.log.Info("parking in flight", "session", sess.name)63+ d.log.Info("parking in flight", "session", attachmentOwnerName(owner))
63 return ac.resumeToken64 return ac.resumeToken
64 }65 }
65 66
66 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.67 // ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
67 // Caller holds d.mu and has verified the daemon is not closing.68 // 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) {
69 token := ac.resumeToken70 token := ac.resumeToken
70 if token == 0 {71 if token == 0 {
71 return72 return
72 }73 }
73 if existing := d.parking[token]; existing != nil {74 if existing := d.parking[token]; existing != nil {
74 if existing.ac == ac {75 if existing.ac == ac {
75- existing.sess = sess76+ existing.owner = owner
76 return77 return
77 }78 }
78 delete(d.parking, token)79 delete(d.parking, token)
79 existing.closeDone()80 existing.closeDone()
80 }81 }
81 d.parking[token] = &parkingAttachment{82 d.parking[token] = &parkingAttachment{
82- sess: sess,83+ owner: owner,
83- ac: ac,84+ ac: ac,
84- done: make(chan struct{}),85+ done: make(chan struct{}),
85 }86 }
86 }87 }
87 88
@@ -99,21 +100,16 @@ func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
99 // If another path already detached/unrouted, that path100 // If another path already detached/unrouted, that path
100 // owns park publication.101 // owns park publication.
101 func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {102 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) {
102 if token == 0 || ac == nil {107 if token == 0 || ac == nil {
103 return108 return
104 }109 }
105 d.mu.Lock()110 d.mu.Lock()
106 defer d.mu.Unlock()111 defer d.mu.Unlock()
107- if d.parked[token] != nil {112+ if d.parked[token] != nil || !d.attachmentOwnerRegisteredByDaemonLocked(owner) || !attachmentOwnerRegistered(owner, ac) {
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 {
117 return113 return
118 }114 }
119 d.clearParkingInFlightLocked(token, ac)115 d.clearParkingInFlightLocked(token, ac)
@@ -143,7 +139,7 @@ func (d *Daemon) purgeAllParkingLocked() {
143 // the session is removed before park publication. Caller holds d.mu.139 // the session is removed before park publication. Caller holds d.mu.
144 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {140 func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
145 for token, pending := range d.parking {141 for token, pending := range d.parking {
146- if pending.sess == sess {142+ if localSession(pending.owner) == sess {
147 delete(d.parking, token)143 delete(d.parking, token)
148 pending.closeDone()144 pending.closeDone()
149 }145 }
@@ -167,7 +163,7 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
167 d.mu.Unlock()163 d.mu.Unlock()
168 return false164 return false
169 }165 }
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 {
171 d.mu.Unlock()167 d.mu.Unlock()
172 return false168 return false
173 }169 }
@@ -182,27 +178,34 @@ func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
182 }178 }
183 179
184 func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {180 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 {
186 return false186 return false
187 }187 }
188 d.mu.Lock()188 d.mu.Lock()
189 defer d.mu.Unlock()189 defer d.mu.Unlock()
190- if d.closing {190+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
191 return false191 return false
192 }192 }
193 if ac.resumeToken == 0 {193 if ac.resumeToken == 0 {
194 ac.resumeToken = d.nextResumeTokenLocked()194 ac.resumeToken = d.nextResumeTokenLocked()
195 }195 }
196 // Do not recreate parking markers here: post-detach publication races196 // Do not recreate parking markers here: post-detach publication races
197- // terminal session removal and can strand IntentResume waiters. Callers197+ // terminal owner removal and can strand IntentResume waiters. Callers that
198- // that need the detach→park gap advertised must markParkingInFlight while198+ // need the detach→park gap advertised must publish it while still exact.
199- // still the exact live owner. Direct parkAttachment still parks below.199+ d.log.Info("parking client prepared", "session", attachmentOwnerName(owner))
200- d.log.Info("parking client prepared", "session", sess.name)
201 return true200 return true
202 }201 }
203 202
204 func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {203 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) {
206 return false209 return false
207 }210 }
208 // A parked attachment has no session owner. Release pane snapshots before211 // A parked attachment has no session owner. Release pane snapshots before
@@ -217,7 +220,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
217 ac.overlays.pickerMu.Unlock()220 ac.overlays.pickerMu.Unlock()
218 }221 }
219 d.mu.Lock()222 d.mu.Lock()
220- if d.closing || d.sessions[sess.id] != sess {223+ if d.closing || !d.attachmentOwnerRegisteredByDaemonLocked(owner) {
221 d.clearParkingInFlightLocked(ac.resumeToken, ac)224 d.clearParkingInFlightLocked(ac.resumeToken, ac)
222 d.mu.Unlock()225 d.mu.Unlock()
223 return false226 return false
@@ -237,7 +240,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
237 }240 }
238 grace := d.resumeParkGrace241 grace := d.resumeParkGrace
239 timer := d.clock.NewTimer(grace)242 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{})}
241 ac.parked = true244 ac.parked = true
242 d.parked[token] = parked245 d.parked[token] = parked
243 d.clearParkingInFlightLocked(token, ac)246 d.clearParkingInFlightLocked(token, ac)
@@ -251,7 +254,7 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
251 }254 }
252 oldSame.closeDone()255 oldSame.closeDone()
253 }256 }
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)
255 d.watchParkedTimer(token, parked)258 d.watchParkedTimer(token, parked)
256 return true259 return true
257 }260 }
@@ -280,7 +283,7 @@ func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
280 d.removeParkedLocked(token, parked)283 d.removeParkedLocked(token, parked)
281 d.mu.Unlock()284 d.mu.Unlock()
282 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)285 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))
284 return287 return
285 }288 }
286 d.mu.Unlock()289 d.mu.Unlock()
@@ -349,7 +352,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
349 ac.resumeToken = token352 ac.resumeToken = token
350 ac.parked = true353 ac.parked = true
351 ac.connectionGeneration.Add(1)354 ac.connectionGeneration.Add(1)
352- sess := parked.sess355+ sess := localSession(parked.owner)
353 if sess != nil {356 if sess != nil {
354 sess.mu.Lock()357 sess.mu.Lock()
355 sess.unregisterAttachmentLocked(ac)358 sess.unregisterAttachmentLocked(ac)
@@ -363,7 +366,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
363 }366 }
364 parked.closeDone()367 parked.closeDone()
365 rearmed := &parkedAttachment{368 rearmed := &parkedAttachment{
366- sess: parked.sess,369+ owner: parked.owner,
367 ac: ac,370 ac: ac,
368 pickerGeneration: parked.pickerGeneration,371 pickerGeneration: parked.pickerGeneration,
369 timer: d.clock.NewTimer(d.resumeParkGrace),372 timer: d.clock.NewTimer(d.resumeParkGrace),
@@ -409,7 +412,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
409 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {412 func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
410 var retirements []parkedAttachmentRetirement413 var retirements []parkedAttachmentRetirement
411 for token, parked := range d.parked {414 for token, parked := range d.parked {
412- if parked.sess == sess {415+ if localSession(parked.owner) == sess {
413 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))416 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
414 }417 }
415 }418 }
@@ -562,7 +565,7 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size)
562 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}565 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
563 }566 }
564 if d.parked[h.ResumeToken] != parked || parked.claimed {567 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 {
566 d.mu.Unlock()569 d.mu.Unlock()
567 ac.sendMu.Unlock()570 ac.sendMu.Unlock()
568 return nil, nil, false, errResumeTokenLifecycleRace571 return nil, nil, false, errResumeTokenLifecycleRace
@@ -587,11 +590,11 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
587 if parked == nil || h.ResumeToken == 0 {590 if parked == nil || h.ResumeToken == 0 {
588 return nil, nil, false, nil591 return nil, nil, false, nil
589 }592 }
590- sess := parked.sess593+ sess := localSession(parked.owner)
591- registered := d.sessions[sess.id] == sess594+ registered := sess != nil && d.sessions[sess.id] == sess
592 if !registered {595 if !registered {
593 d.removeParkedLocked(h.ResumeToken, parked)596 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)
595 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}598 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
596 }599 }
597 ac := parked.ac600 ac := parked.ac
M internal/usecase/daemon/resume_test.go +7 -7
@@ -533,7 +533,7 @@ func TestResumeDuringTeardownBeforeParkRecoversSameAttachment(t *testing.T) {
533 d.mu.Unlock()533 d.mu.Unlock()
534 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")534 require.NotNil(t, parkingInGap, "fixture: parking marker must precede detach publication")
535 require.Same(t, ac, parkingInGap.ac)535 require.Same(t, ac, parkingInGap.ac)
536- require.Same(t, sess, parkingInGap.sess)536+ require.Same(t, sess, localSession(parkingInGap.owner))
537 require.False(t, parkedInGap, "fixture: park must not have published yet")537 require.False(t, parkedInGap, "fixture: park must not have published yet")
538 538
539 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})539 _, _, err = d.route(helloResumeCapable(ports.IntentResume, "work", 0xdecafbad), &closeTrackingTransport{})
@@ -1268,7 +1268,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1268 require.NoError(t, err)1268 require.NoError(t, err)
1269 1269
1270 previous := &session{sessionCore: sessionCore{id: "previous"}}1270 previous := &session{sessionCore: sessionCore{id: "previous"}}
1271- ac.previousSession.Set(previous)1271+ ac.previousOwner.Set(previous)
1272 d.clientGone(sess, ac, ac.transport(), false)1272 d.clientGone(sess, ac, ac.transport(), false)
1273 1273
1274 token := ac.resumeToken1274 token := ac.resumeToken
@@ -1276,14 +1276,14 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1276 parked := d.parked[token]1276 parked := d.parked[token]
1277 d.mu.Unlock()1277 d.mu.Unlock()
1278 require.NotNil(t, parked)1278 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")
1280 1280
1281 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))1281 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
1282 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)1282 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
1283 require.NoError(t, err)1283 require.NoError(t, err)
1284 require.Same(t, sess, resumedSess)1284 require.Same(t, sess, resumedSess)
1285 require.Same(t, ac, resumedAC)1285 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")
1287 }1287 }
1288 1288
1289 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {1289 func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
@@ -1301,7 +1301,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1301 {1301 {
1302 name: "session purge",1302 name: "session purge",
1303 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {1303 discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement {
1304- return d.purgeParkedForSessionLocked(parked.sess)1304+ return d.purgeParkedForSessionLocked(localSession(parked.owner))
1305 },1305 },
1306 },1306 },
1307 } {1307 } {
@@ -1313,7 +1313,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1313 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1313 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1314 require.NoError(t, err)1314 require.NoError(t, err)
1315 1315
1316- ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}})1316+ ac.previousOwner.Set(&session{sessionCore: sessionCore{id: "previous"}})
1317 d.clientGone(sess, ac, ac.transport(), false)1317 d.clientGone(sess, ac, ac.transport(), false)
1318 1318
1319 d.mu.Lock()1319 d.mu.Lock()
@@ -1323,7 +1323,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1323 d.mu.Unlock()1323 d.mu.Unlock()
1324 d.finishParkedAttachmentRetirements(retirements)1324 d.finishParkedAttachmentRetirements(retirements)
1325 1325
1326- require.Nil(t, ac.previousSession.Get())1326+ require.Nil(t, ac.previousOwner.Get())
1327 })1327 })
1328 }1328 }
1329 }1329 }
M internal/usecase/daemon/session.go +18 -16
@@ -467,7 +467,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
467 if err := domain.ValidateSessionName(name); err != nil {467 if err := domain.ValidateSessionName(name); err != nil {
468 return err468 return err
469 }469 }
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 {
471 return errAttachmentTransition472 return errAttachmentTransition
472 }473 }
473 token.effect.bindActionEnd(d, "create-session")474 token.effect.bindActionEnd(d, "create-session")
@@ -475,12 +476,11 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
475 476
476 var created *session477 var created *session
477 transition, err := d.transitionAttachment(attachmentTransitionRequest{478 transition, err := d.transitionAttachment(attachmentTransitionRequest{
478- source: token.sess, next: token.ac,479+ source: source, next: token.ac,
479 480
480 expectedTransport: token.transport, sourceToken: &token, action: "create-session",481 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
481 copySourceEnvironment: true, ready: true,482 copySourceEnvironment: true, ready: true,
482 createTargetLocked: func() (*session, error) {483 createTargetLocked: func() (*session, error) {
483- source := token.sess
484 if source == nil {484 if source == nil {
485 return nil, errAttachmentTransition485 return nil, errAttachmentTransition
486 }486 }
@@ -506,8 +506,8 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
506 }506 }
507 507
508 d.touchMRU(created)508 d.touchMRU(created)
509- if token.sess != nil {509+ if source != nil {
510- token.ac.recordPreviousSession(token.sess)510+ token.ac.recordPreviousSession(source)
511 }511 }
512 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)512 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
513 d.deferAttachmentTransitionCleanups(transition)513 d.deferAttachmentTransitionCleanups(transition)
@@ -773,14 +773,15 @@ func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool
773 }773 }
774 774
775 func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {775 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 {
777 return false778 return false
778 }779 }
779 // Attachments without the session's optional primary render lease still780 // Attachments without the session's optional primary render lease still
780 // own an independent connection lifecycle and detach through the same exact781 // own an independent connection lifecycle and detach through the same exact
781 // transport/generation fence.782 // transport/generation fence.
782 if token.lease == nil {783 if token.lease == nil {
783- return d.detachIfCurrentTransport(token.sess, token.ac, token.transport)784+ return d.detachIfCurrentTransport(sess, token.ac, token.transport)
784 }785 }
785 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)786 frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
786 defer frozen.unfreeze()787 defer frozen.unfreeze()
@@ -791,8 +792,8 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
791 d.afterDetachAttachmentEffectsFrozen()792 d.afterDetachAttachmentEffectsFrozen()
792 }793 }
793 d.mu.Lock()794 d.mu.Lock()
794- core := token.sess.core()795+ core := sess.core()
795- if core == nil || d.sessions[core.id] != token.sess {796+ if core == nil || d.sessions[core.id] != sess {
796 d.mu.Unlock()797 d.mu.Unlock()
797 return false798 return false
798 }799 }
@@ -803,11 +804,11 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
803 coordinator.mu.Lock()804 coordinator.mu.Lock()
804 }805 }
805 req := attachmentTransitionRequest{806 req := attachmentTransitionRequest{
806- source: token.sess, next: token.ac, expectedTransport: token.transport,807+ source: sess, next: token.ac, expectedTransport: token.transport,
807 }808 }
808- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)809+ current := coordinator != nil && transitionSourceTokenCurrentLocked(token, sess, coordinator, req)
809 if current {810 if current {
810- unregisterAttachmentSessionLocked(token.sess, token.ac)811+ unregisterAttachmentSessionLocked(sess, token.ac)
811 token.ac.connectionGeneration.Add(1)812 token.ac.connectionGeneration.Add(1)
812 token.ac.setSession(nil)813 token.ac.setSession(nil)
813 token.ac.invalidateFrozenAttachmentCapability()814 token.ac.invalidateFrozenAttachmentCapability()
@@ -1168,10 +1169,10 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1168 snapshot := sessionKillParticipants{target: target}1169 snapshot := sessionKillParticipants{target: target}
1169 if admission != nil {1170 if admission != nil {
1170 token := admission.token1171 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 {
1172 return sessionKillParticipants{}, false1173 return sessionKillParticipants{}, false
1173 }1174 }
1174- snapshot.source = token.sess1175+ snapshot.source = token.localSession()
1175 if snapshot.source == nil {1176 if snapshot.source == nil {
1176 return sessionKillParticipants{}, false1177 return sessionKillParticipants{}, false
1177 }1178 }
@@ -1271,10 +1272,11 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
1271 return true1272 return true
1272 }1273 }
1273 token := *snapshot.sourceToken1274 token := *snapshot.sourceToken
1275+ source := token.localSession()
1274 req := attachmentTransitionRequest{1276 req := attachmentTransitionRequest{
1275- source: token.sess, next: token.ac, expectedTransport: token.transport,1277+ source: source, next: token.ac, expectedTransport: token.transport,
1276 }1278 }
1277- return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)1279+ return transitionSourceTokenCurrentLocked(token, source, sourceCoordinator, req)
1278 }1280 }
1279 1281
1280 // stopInMemoryLifecycle cancels every session producer and closes every PTY1282 // stopInMemoryLifecycle cancels every session producer and closes every PTY
M internal/usecase/daemon/session_back.go +36 -16
@@ -5,15 +5,24 @@ import (
5 "github.com/bnema/vev/internal/usecase/picker"5 "github.com/bnema/vev/internal/usecase/picker"
6 )6 )
7 7
8-// backSession toggles this attachment between its current session and the8+// backSession toggles this attachment between its current local session and
9-// immediately preceding successfully activated session.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.
10 func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {12 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 {
12 return nil15 return nil
13 }16 }
14- target := token.ac.previousSession.Get()17+ previous := token.ac.previousOwner.Get()
15- if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target {18+ target := localSession(previous)
16- token.ac.clearPreviousSessionIf(target)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)
17 return nil26 return nil
18 }27 }
19 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}28 pickerTarget := picker.Target{Session: target.core().id, TabIndex: -1}
@@ -24,9 +33,16 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
24 if d == nil || current == nil || ac == nil {33 if d == nil || current == nil || ac == nil {
25 return34 return
26 }35 }
27- target := ac.previousSession.Get()36+ previous := ac.previousOwner.Get()
28- if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target {37+ target := localSession(previous)
29- ac.clearPreviousSessionIf(target)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)
30 d.invalidateRender(current, ac, true, "session_back.go")46 d.invalidateRender(current, ac, true, "session_back.go")
31 return47 return
32 }48 }
@@ -43,24 +59,28 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
43 }59 }
44 }60 }
45 61
46-// clearPreviousSessionIf clears target only if it has not been replaced since62+// clearPreviousOwnerIf clears target only if it has not been replaced since
47 // the caller observed it. This preserves a concurrent successful hand-off.63 // 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) {
49 if ac == nil {65 if ac == nil {
50 return66 return
51 }67 }
52- ac.previousSession.With(func(previous **session) {68+ ac.previousOwner.With(func(previous *attachmentOwner) {
53- if *previous == target {69+ if sameAttachmentOwner(*previous, target) {
54 *previous = nil70 *previous = nil
55 }71 }
56 })72 })
57 }73 }
58 74
59-// recordPreviousSession is called only after a cross-session hand-off has75+// recordPreviousOwner is called only after a cross-owner hand-off has
60 // committed. Keeping this after the commit makes failed/displaced switches76 // committed. Keeping this after the commit makes failed/displaced switches
61 // leave the toggle pair intact.77 // leave the toggle pair intact.
62-func (ac *attachedClient) recordPreviousSession(origin *session) {78+func (ac *attachedClient) recordPreviousOwner(origin attachmentOwner) {
63 if ac != nil && origin != nil {79 if ac != nil && origin != nil {
64- ac.previousSession.Set(origin)80+ ac.previousOwner.Set(origin)
65 }81 }
66 }82 }
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 @@
1 package daemon1 package daemon
2 2
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+)
4 7
5 // sessionGeometrySource identifies the attachment whose latest attach,8 // sessionGeometrySource identifies the attachment whose latest attach,
6 // resume, or resize claim controls shared PTY/layout geometry. claim is zero9 // resume, or resize claim controls shared PTY/layout geometry. claim is zero
@@ -62,6 +65,11 @@ func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource,
62 return domain.Size{}, sessionGeometrySource{}, false65 return domain.Size{}, sessionGeometrySource{}, false
63 }66 }
64 content := contentSize(source.size)67 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+ }
65 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true73 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
66 }74 }
67 75
M internal/usecase/daemon/wire.go +38 -0
@@ -12,6 +12,7 @@ func frameWelcome(s *session, ac *attachedClient) ports.Frame {
12 SessionID: string(s.id),12 SessionID: string(s.id),
13 SessionName: s.name,13 SessionName: s.name,
14 Ephemeral: s.ephemeral,14 Ephemeral: s.ephemeral,
15+ RenderMode: ac.renderMode,
15 Capabilities: ports.CapabilityResume,16 Capabilities: ports.CapabilityResume,
16 }17 }
17 s.mu.Unlock()18 s.mu.Unlock()
@@ -19,6 +20,43 @@ func frameWelcome(s *session, ac *attachedClient) ports.Frame {
19 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}20 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
20 }21 }
21 22
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+
22 func frameError(code uint16, text string) ports.Frame {60 func frameError(code uint16, text string) ports.Frame {
23 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}61 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
24 }62 }
M scripts/remote-picker-harness/README.md +12 -0
@@ -18,6 +18,18 @@ Environment overrides:
18 containers (default: `ubuntu:24.04`).18 containers (default: `ubuntu:24.04`).
19 - `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary19 - `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary
20 (default: `test@remote`).20 (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.
21 33
22 The run exercises real SSH-stdio and UDP attachment, typed live catalog and34 The run exercises real SSH-stdio and UDP attachment, typed live catalog and
23 preview requests, tab creation/removal fencing, exact lifecycle fencing after35 preview requests, tab creation/removal fencing, exact lifecycle fencing after
M scripts/remote-picker-harness/main.go +92 -83
@@ -20,7 +20,6 @@ import (
20 "github.com/bnema/vev/internal/adapters/sshstdio"20 "github.com/bnema/vev/internal/adapters/sshstdio"
21 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
22 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
23- "github.com/bnema/vev/pkg/vt"
24 )23 )
25 24
26 const (25 const (
@@ -38,7 +37,7 @@ func main() {
38 fmt.Println("remote picker harness: PASS")37 fmt.Println("remote picker harness: PASS")
39 }38 }
40 39
41-func run() error {40+func run() (runErr error) {
42 target := os.Getenv("VEV_HARNESS_TARGET")41 target := os.Getenv("VEV_HARNESS_TARGET")
43 if target == "" {42 if target == "" {
44 target = defaultTarget43 target = defaultTarget
@@ -46,6 +45,16 @@ func run() error {
46 ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)45 ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
47 defer cancel()46 defer cancel()
48 47
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+
49 catalog := remoteadapter.NewCatalogClient()58 catalog := remoteadapter.NewCatalogClient()
50 preview := remoteadapter.NewPreviewClient()59 preview := remoteadapter.NewPreviewClient()
51 if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {60 if err := runLocalPickerUnitedPhase(ctx, target, catalog, preview); err != nil {
@@ -148,10 +157,15 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
148 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"157 previewMarker = "VEV_REMOTE_PICKER_ACTIVE"
149 )158 )
150 159
160+ trace := &probeTrace{}
161+ if artifact := harnessArtifactFromContext(ctx); artifact != nil {
162+ artifact.registerTrace(trace)
163+ }
151 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)164 remote, err := openTransport(ctx, ports.RemoteTransportStdio, target)
152 if err != nil {165 if err != nil {
153 return fmt.Errorf("open remote picker source: %w", err)166 return fmt.Errorf("open remote picker source: %w", err)
154 }167 }
168+ remote.probe.configure("remote-picker-source", nil)
155 ownsRemoteSession := false169 ownsRemoteSession := false
156 defer func() {170 defer func() {
157 _ = remote.Close()171 _ = remote.Close()
@@ -229,6 +243,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
229 if err != nil {243 if err != nil {
230 return fmt.Errorf("open local picker transport: %w", err)244 return fmt.Errorf("open local picker transport: %w", err)
231 }245 }
246+ local.probe.configure("local-picker", trace)
232 defer func() { _ = local.Close() }()247 defer func() { _ = local.Close() }()
233 if err := attachAndCheck(ctx, local, ports.Hello{248 if err := attachAndCheck(ctx, local, ports.Hello{
234 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",249 Intent: ports.IntentNew, Name: localSession, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
@@ -286,6 +301,7 @@ func runLocalPickerUnitedPhase(ctx context.Context, target string, catalog ports
286 if err != nil {301 if err != nil {
287 return fmt.Errorf("open selected remote transport: %w", err)302 return fmt.Errorf("open selected remote transport: %w", err)
288 }303 }
304+ selected.probe.configure("selected-remote", trace)
289 defer func() { _ = selected.Close() }()305 defer func() { _ = selected.Close() }()
290 if err := attachAndCheck(ctx, selected, ports.Hello{306 if err := attachAndCheck(ctx, selected, ports.Hello{
291 Intent: ports.IntentAttach, Name: handoff.Session, Env: localEnvironment(), Cwd: "/tmp/local-cwd",307 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
297 if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {313 if err := awaitOutputContains(ctx, selected, remoteSession+" at remote"); err != nil {
298 return fmt.Errorf("remote provenance status bar: %w", err)314 return fmt.Errorf("remote provenance status bar: %w", err)
299 }315 }
316+ if err := validateHandoffEventOrdering(trace.events); err != nil {
317+ return fmt.Errorf("characterize remote picker handoff: %w", err)
318+ }
300 return nil319 return nil
301 }320 }
302 321
@@ -433,6 +452,7 @@ type harnessFrame struct {
433 452
434 type harnessTransport struct {453 type harnessTransport struct {
435 ports.Transport454 ports.Transport
455+ probe *visualProbe
436 frames chan harnessFrame456 frames chan harnessFrame
437 stop chan struct{}457 stop chan struct{}
438 closeOnce sync.Once458 closeOnce sync.Once
@@ -440,7 +460,12 @@ type harnessTransport struct {
440 }460 }
441 461
442 func newHarnessTransport(transport ports.Transport) *harnessTransport {462 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+ }
444 go h.receiveLoop()469 go h.receiveLoop()
445 return h470 return h
446 }471 }
@@ -484,7 +509,12 @@ func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target s
484 if err != nil {509 if err != nil {
485 return nil, err510 return nil, err
486 }511 }
487- return newHarnessTransport(transport), nil512+ 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
488 }518 }
489 519
490 const commandStderrLimit = 4 << 10520 const commandStderrLimit = 4 << 10
@@ -585,7 +615,12 @@ func openLocalTransport(ctx context.Context) (*harnessTransport, error) {
585 if err != nil {615 if err != nil {
586 return nil, err616 return nil, err
587 }617 }
588- return newHarnessTransport(transport), nil618+ 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
589 }624 }
590 625
591 func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {626 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
612 if frame.Type != ports.MsgWelcome {647 if frame.Type != ports.MsgWelcome {
613 return fmt.Errorf("welcome frame type %d", frame.Type)648 return fmt.Errorf("welcome frame type %d", frame.Type)
614 }649 }
650+ tr.probe.recordControl(probeEventWelcome)
615 if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {651 if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {
616 return fmt.Errorf("send initial theme: %w", err)652 return fmt.Errorf("send initial theme: %w", err)
617 }653 }
@@ -622,22 +658,41 @@ func sendRawInput(tr *harnessTransport, data string) error {
622 return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})658 return tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(data)})})
623 }659 }
624 660
625-func acknowledgeOutput(tr *harnessTransport, output ports.Output) error {661+func processOutputFrame(tr *harnessTransport, frame ports.Frame) error {
626- if output.Epoch == 0 || output.New == 0 {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 {
627 return nil678 return nil
628 }679 }
629- payload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})680+ payload, err := ports.MarshalAck(result.Ack)
630 if err != nil {681 if err != nil {
631 return err682 return err
632 }683 }
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
634 }690 }
635 691
636 func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {692 func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string) error {
637 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)693 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
638 defer cancel()694 defer cancel()
639- var received strings.Builder695+ visible := tr.probe.contains(want)
640- screen := vt.NewScreen(80, 24)
641 for {696 for {
642 frame, err := receiveWithTimeout(deadline, tr)697 frame, err := receiveWithTimeout(deadline, tr)
643 if err != nil {698 if err != nil {
@@ -646,75 +701,46 @@ func awaitOutputContains(ctx context.Context, tr *harnessTransport, want string)
646 if frame.Type != ports.MsgOutput {701 if frame.Type != ports.MsgOutput {
647 continue702 continue
648 }703 }
649- output, err := ports.UnmarshalOutput(frame.Payload)704+ if err := processOutputFrame(tr, frame); err != nil {
650- if err != nil {
651- return err
652- }
653- if err := acknowledgeOutput(tr, output); err != nil {
654 return err705 return err
655 }706 }
656- received.Write(output.Data)707+ nowVisible := tr.probe.contains(want)
657- screen.Write(output.Data)708+ if !visible && nowVisible {
658- if strings.Contains(received.String(), want) || strings.Contains(harnessScreenText(screen), want) {
659 return nil709 return nil
660 }710 }
661- if received.Len() > 64<<10 {711+ visible = nowVisible
662- text := received.String()
663- received.Reset()
664- received.WriteString(text[len(text)-16<<10:])
665- }
666 }712 }
667 }713 }
668 714
669 func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {715 func awaitRemotePickerPreview(ctx context.Context, tr *harnessTransport, marker string) error {
670 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)716 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
671 defer cancel()717 defer cancel()
672- screen := vt.NewScreen(80, 24)
673- outputFrames := 0
674 sawLoading := false718 sawLoading := false
719+ markerVisible := tr.probe.contains(marker)
675 for {720 for {
676 frame, err := receiveWithTimeout(deadline, tr)721 frame, err := receiveWithTimeout(deadline, tr)
677 if err != nil {722 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)
679 }724 }
680 if frame.Type != ports.MsgOutput {725 if frame.Type != ports.MsgOutput {
681 continue726 continue
682 }727 }
683- output, err := ports.UnmarshalOutput(frame.Payload)728+ if err := processOutputFrame(tr, frame); err != nil {
684- if err != nil {
685- return err
686- }
687- if err := acknowledgeOutput(tr, output); err != nil {
688 return err729 return err
689 }730 }
690- outputFrames++731+ text := tr.probe.text()
691- screen.Write(output.Data)732+ sawLoading = sawLoading || strings.Contains(text, "loading remote preview")
692- text := harnessScreenText(screen)733+ nowMarkerVisible := strings.Contains(text, marker)
693- sawLoading = sawLoading || strings.Contains(text, "loading remote preview") || strings.Contains(string(output.Data), "loading remote preview")734+ if !markerVisible && nowMarkerVisible {
694- if strings.Contains(text, marker) || strings.Contains(string(output.Data), marker) {
695 return nil735 return nil
696 }736 }
737+ markerVisible = nowMarkerVisible
697 if strings.Contains(text, "remote preview unavailable") {738 if strings.Contains(text, "remote preview unavailable") {
698 return errors.New("remote preview unavailable")739 return errors.New("remote preview unavailable")
699 }740 }
700 }741 }
701 }742 }
702 743
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-
718 func remotePreviewContains(preview ports.RemotePreview, want string) bool {744 func remotePreviewContains(preview ports.RemotePreview, want string) bool {
719 var text strings.Builder745 var text strings.Builder
720 for _, cell := range preview.Cells {746 for _, cell := range preview.Cells {
@@ -734,15 +760,14 @@ func receiveFrame(ctx context.Context, tr *harnessTransport, typ ports.MsgType)
734 return ports.Frame{}, err760 return ports.Frame{}, err
735 }761 }
736 if frame.Type == typ {762 if frame.Type == typ {
763+ if typ == ports.MsgAttachTarget {
764+ tr.probe.recordControl(probeEventAttachTarget)
765+ }
737 return frame, nil766 return frame, nil
738 }767 }
739 if frame.Type == ports.MsgOutput {768 if frame.Type == ports.MsgOutput {
740- output, decodeErr := ports.UnmarshalOutput(frame.Payload)769+ if err := processOutputFrame(tr, frame); err != nil {
741- if decodeErr != nil {770+ return ports.Frame{}, err
742- return ports.Frame{}, decodeErr
743- }
744- if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
745- return ports.Frame{}, ackErr
746 }771 }
747 }772 }
748 }773 }
@@ -752,37 +777,25 @@ func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want
752 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {777 if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
753 return err778 return err
754 }779 }
755- var received strings.Builder
756- var outputFrames int
757- var outputBytes int
758 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)780 deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
759 defer cancel()781 defer cancel()
782+ visible := tr.probe.contains(want)
760 for {783 for {
761 frame, err := receiveWithTimeout(deadline, tr)784 frame, err := receiveWithTimeout(deadline, tr)
762 if err != nil {785 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)
764 }787 }
765 if frame.Type != ports.MsgOutput {788 if frame.Type != ports.MsgOutput {
766 continue789 continue
767 }790 }
768- output, err := ports.UnmarshalOutput(frame.Payload)791+ if err := processOutputFrame(tr, frame); err != nil {
769- if err != nil {792+ return fmt.Errorf("process output: %w", err)
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)
776 }793 }
777- received.Write(output.Data)794+ nowVisible := tr.probe.contains(want)
778- if strings.Contains(received.String(), want) {795+ if !visible && nowVisible {
779 return nil796 return nil
780 }797 }
781- if received.Len() > 64<<10 {798+ visible = nowVisible
782- text := received.String()
783- received.Reset()
784- received.WriteString(text[len(text)-16<<10:])
785- }
786 }799 }
787 }800 }
788 801
@@ -809,12 +822,8 @@ func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
809 return err822 return err
810 }823 }
811 if frame.Type == ports.MsgOutput {824 if frame.Type == ports.MsgOutput {
812- output, decodeErr := ports.UnmarshalOutput(frame.Payload)825+ if err := processOutputFrame(tr, frame); err != nil {
813- if decodeErr != nil {826+ return err
814- return decodeErr
815- }
816- if ackErr := acknowledgeOutput(tr, output); ackErr != nil {
817- return ackErr
818 }827 }
819 continue828 continue
820 }829 }
M scripts/remote-picker-harness/run.sh +20 -1
@@ -16,6 +16,9 @@ Environment:
16 already select the rootless daemon.16 already select the rootless daemon.
17 VEV_HARNESS_BASE_IMAGE Base image for the disposable containers17 VEV_HARNESS_BASE_IMAGE Base image for the disposable containers
18 (default: ubuntu:24.04).18 (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.
19 USAGE22 USAGE
20 }23 }
21 24
@@ -33,6 +36,11 @@ esac
33 36
34 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"37 repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
35 base_image="${VEV_HARNESS_BASE_IMAGE:-ubuntu:24.04}"38 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
36 run_id="$(date +%s)-$$"44 run_id="$(date +%s)-$$"
37 image="vev-remote-picker-harness:${run_id}"45 image="vev-remote-picker-harness:${run_id}"
38 network="vev-remote-picker-harness-${run_id}"46 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
106 fi114 fi
107 115
108 printf 'remote picker harness: running acceptance checks\n'116 printf 'remote picker harness: running acceptance checks\n'
109-docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness117+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 12

Tags 11

↑↓ moveenter openesc close