detached b9061f5

Merge pull request #181 from bnema/feature/remote-picker-session-parity

feat(remote): implement picker session parity
commit
b9061f5273798a7df7eadbc8508f2ab7ef1daf34
author
bnema · · 2026-08-05 13:45 UTC
committer
GitHub · · 2026-08-05 13:45 UTC
merge of
9344cd0 + fe22594 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
59 files · +5591 -242
M AGENTS.md +1 -1
@@ -64,7 +64,7 @@ Before touching daemon teardown paths, read the lock-ordering notes at the top o
6464 Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/wire.go`. Connection framing lives in `internal/adapters/ipc/transport.go`.
6565
6666 - IPC frames on a connection are 4-byte big-endian length, 1 type byte, then payload.
67-- Client message types currently occupy `1–13` (`MsgOutputResetRequest` is `13`); server types occupy `16–23` (`MsgSessionMeta` is `23`) and `25` (`MsgAttachTarget`). Types `14–15` and `24` remain reserved.
67+- Client message types occupy `1–13` (`MsgOutputResetRequest` is `13`) and `15` (`MsgRemotePreviewRequest`); server types occupy `16–23` (`MsgSessionMeta` is `23`) and `25–26` (`MsgAttachTarget` and `MsgRemotePreviewResponse`). Types `14` and `24` remain reserved.
6868 - Version negotiation requires strict equality.
6969 - `Hello.Version` and `CommandRequest.Version` must stay first so their version peekers work.
7070 - Bump `ProtocolVersion` for any message layout change.
M Makefile +4 -1
@@ -1,4 +1,4 @@
1-.PHONY: install test test-installer lint mocks demo
1+.PHONY: install test test-installer lint mocks demo remote-acceptance
22
33 install:
44 go install .
@@ -25,6 +25,9 @@ mocks:
2525 fi
2626 mockery
2727
28+remote-acceptance:
29+ scripts/remote-picker-harness/run.sh
30+
2831 demo:
2932 docker build -f scripts/demo/Dockerfile -t vev-demo .
3033 ./scripts/demo/run.sh demo.tape
M docs/remote-resilience.md +33 -0
@@ -13,6 +13,39 @@ acknowledgements, and reconnect state. Reconnecting one attachment resumes that
1313 attachment without replacing its peers or changing their attachment-local
1414 windows/views; its latest claim may still update shared PTY geometry.
1515
16+## Remote picker identity
17+
18+The session picker shows remote live, stopped, broken, stale, and unavailable
19+rows using the same expandable session/tab shape as local rows. A row carries a
20+lifecycle ID and stable tab selector separately from its display origin; labels
21+are never parsed back into routes. Cached and stale rows remain navigable, but
22+activation is gated until the host catalog and the exact lifecycle/tab identity
23+are current. A successful picker handoff sends the structured target to the
24+owning daemon without creating a local shadow session. Picker-selected remote
25+attachments use the daemon's environment and persisted session working
26+directory; direct CLI remote attaches retain their client-request semantics.
27+
28+## Durable record compatibility
29+
30+The catalogue record format is currently version 4 because records now retain
31+stable tab identity metadata. Readers accept version 3 for one-way upgrades, but
32+older binaries reject records written at version 4. Back up the vev state
33+before a rollback; downgrading after an upgrade can leave newer catalogue
34+records unreadable and may require recreating those sessions.
35+
36+Remote catalogues are bounded and versioned. Complete catalogues contain ordered
37+tab IDs, names, state, active tab, attachment state, and MRU sequence. Stopped
38+tab metadata is retained across daemon restart; legacy count-only catalogues are
39+read-only compatibility rows and cannot provide exact tab identity.
40+
41+Remote picker previews are bounded styled-cell snapshots fetched without
42+attachment or PTY mutation. They are held in a short-lived in-memory cache only;
43+viewport contents are not persisted or written to diagnostics. Live preview
44+requests use debounce, same-key single-flight, bounded concurrency, TTL
45+stale-while-revalidate, and failure cooldowns. Stopped sessions show a static
46+placeholder, and late or canceled preview responses are discarded when the
47+picker selection changes.
48+
1649 ## Connection limits
1750
1851 - A connection handshake has one 15-second deadline from connection start
M internal/adapters/remote/catalog.go +25 -10
@@ -114,6 +114,7 @@ func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCa
114114
115115 type catalogEnvelope struct {
116116 ProtocolVersion *uint16 `json:"protocol_version"`
117+ SchemaVersion *uint16 `json:"schema_version"`
117118 Sessions *[]ports.RemoteCatalogSession `json:"sessions"`
118119 }
119120
@@ -130,10 +131,22 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
130131 return ports.RemoteCatalog{}, err
131132 }
132133
134+ schemaVersion := uint16(0)
135+ if envelope.SchemaVersion != nil {
136+ schemaVersion = *envelope.SchemaVersion
137+ }
133138 if *envelope.ProtocolVersion != ports.ProtocolVersion {
134139 return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
135140 Got: *envelope.ProtocolVersion,
136141 Want: ports.ProtocolVersion,
142+ Kind: "protocol",
143+ }
144+ }
145+ if schemaVersion != 0 && schemaVersion != ports.RemoteCatalogSchemaVersion {
146+ return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
147+ Got: schemaVersion,
148+ Want: ports.RemoteCatalogSchemaVersion,
149+ Kind: "catalog",
137150 }
138151 }
139152
@@ -141,19 +154,21 @@ func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
141154 if sessions == nil {
142155 sessions = []ports.RemoteCatalogSession{}
143156 }
144- for _, session := range sessions {
145- if err := domain.ValidateSessionName(session.Name); err != nil {
157+ catalog := ports.RemoteCatalog{
158+ ProtocolVersion: *envelope.ProtocolVersion,
159+ SchemaVersion: schemaVersion,
160+ Sessions: sessions,
161+ }
162+ if err := ports.ValidateRemoteCatalog(catalog); err != nil {
163+ if schemaVersion == 0 {
164+ // Keep the old adapter error taxonomy for pre-parity peers and
165+ // direct CLI compatibility. Current-schema payloads retain the
166+ // typed validation error for callers that need to classify it.
146167 return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err)
147168 }
148- if !validCatalogState(session.State) {
149- return ports.RemoteCatalog{}, fmt.Errorf("%w: invalid state %q", errCatalogSession, session.State)
150- }
169+ return ports.RemoteCatalog{}, err
151170 }
152-
153- return ports.RemoteCatalog{
154- ProtocolVersion: *envelope.ProtocolVersion,
155- Sessions: sessions,
156- }, nil
171+ return catalog, nil
157172 }
158173
159174 func rejectTrailingJSON(dec *json.Decoder) error {
M internal/adapters/remote/catalog_cache.go +61 -19
@@ -17,7 +17,7 @@ import (
1717
1818 const (
1919 catalogCacheFileName = "remote-catalog-cache.json"
20- catalogCacheFileVersion = 1
20+ catalogCacheFileVersion = 2
2121 )
2222
2323 // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
@@ -35,11 +35,15 @@ type catalogCacheHost struct {
3535 }
3636
3737 type catalogCacheSession struct {
38- Name *string `json:"name"`
39- State *string `json:"state"`
40- Ephemeral *bool `json:"ephemeral"`
41- Tabs *uint16 `json:"tabs"`
42- Attached *bool `json:"attached"`
38+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
39+ Name *string `json:"name"`
40+ State *string `json:"state"`
41+ Ephemeral *bool `json:"ephemeral"`
42+ LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
43+ Tabs *uint16 `json:"tabs,omitempty"`
44+ TabList *[]ports.RemoteCatalogTab `json:"tab_list,omitempty"`
45+ ActiveTabID *string `json:"active_tab_id,omitempty"`
46+ Attached *bool `json:"attached"`
4347 }
4448
4549 type fileCatalogCache struct {
@@ -103,19 +107,33 @@ func (c *fileCatalogCache) Load() ([]ports.RemoteCatalogCacheEntry, error) {
103107 Sessions: make([]ports.RemoteCatalogSession, 0, len(*host.Sessions)),
104108 }
105109 for _, session := range *host.Sessions {
106- if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil {
110+ if session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil && session.TabList == nil {
107111 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
108112 }
109113 if !utf8.ValidString(*session.Name) || !utf8.ValidString(*session.State) {
110114 return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
111115 }
112- entry.Sessions = append(entry.Sessions, ports.RemoteCatalogSession{
116+ decoded := ports.RemoteCatalogSession{
113117 Name: *session.Name,
114118 State: *session.State,
115119 Ephemeral: *session.Ephemeral,
116- Tabs: *session.Tabs,
117120 Attached: *session.Attached,
118- })
121+ }
122+ if session.LastUsedSeq != nil {
123+ decoded.LastUsedSeq = *session.LastUsedSeq
124+ }
125+ if session.LifecycleID != nil {
126+ decoded.LifecycleID = *session.LifecycleID
127+ }
128+ if session.ActiveTabID != nil {
129+ decoded.ActiveTabID = *session.ActiveTabID
130+ }
131+ if session.TabList != nil {
132+ decoded.Tabs = append([]ports.RemoteCatalogTab(nil), (*session.TabList)...)
133+ } else {
134+ decoded.Tabs = int(*session.Tabs)
135+ }
136+ entry.Sessions = append(entry.Sessions, decoded)
119137 }
120138 entries = append(entries, entry)
121139 }
@@ -141,15 +159,28 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error
141159 name := session.Name
142160 state := session.State
143161 ephemeral := session.Ephemeral
144- tabs := session.Tabs
145162 attached := session.Attached
146- sessions = append(sessions, catalogCacheSession{
147- Name: &name,
148- State: &state,
149- Ephemeral: &ephemeral,
150- Tabs: &tabs,
151- Attached: &attached,
152- })
163+ row := catalogCacheSession{Name: &name, State: &state, Ephemeral: &ephemeral, Attached: &attached}
164+ if session.LastUsedSeq != 0 {
165+ lastUsed := session.LastUsedSeq
166+ row.LastUsedSeq = &lastUsed
167+ }
168+ if session.LifecycleID != (domain.SessionLifecycleID{}) {
169+ id := session.LifecycleID
170+ row.LifecycleID = &id
171+ }
172+ if session.ActiveTabID != "" {
173+ active := session.ActiveTabID
174+ row.ActiveTabID = &active
175+ }
176+ if tabs := ports.CatalogTabs(session); tabs != nil {
177+ copyTabs := append([]ports.RemoteCatalogTab(nil), tabs...)
178+ row.TabList = &copyTabs
179+ } else {
180+ count := ports.SaturateUint16(ports.CatalogTabCount(session))
181+ row.Tabs = &count
182+ }
183+ sessions = append(sessions, row)
153184 }
154185 hosts = append(hosts, catalogCacheHost{
155186 Target: &target,
@@ -166,6 +197,9 @@ func (c *fileCatalogCache) Store(entries []ports.RemoteCatalogCacheEntry) error
166197 }
167198
168199 func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]ports.RemoteCatalogCacheEntry, error) {
200+ if err := ports.ValidateRemoteCatalogCacheEntries(entries); err != nil {
201+ return nil, err
202+ }
169203 normalized := make([]ports.RemoteCatalogCacheEntry, 0, len(entries))
170204 hosts := make(map[string]struct{}, len(entries))
171205 for _, entry := range entries {
@@ -200,7 +234,15 @@ func normalizeCatalogCacheEntries(entries []ports.RemoteCatalogCacheEntry) ([]po
200234 return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host)
201235 }
202236 sessions[session.Name] = struct{}{}
203- copyEntry.Sessions = append(copyEntry.Sessions, session)
237+ copySession := session
238+ if tabs := ports.CatalogTabs(session); tabs != nil {
239+ copyTabs := make([]ports.RemoteCatalogTab, len(tabs))
240+ for i, tab := range tabs {
241+ copyTabs[i] = ports.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
242+ }
243+ copySession.Tabs = copyTabs
244+ }
245+ copyEntry.Sessions = append(copyEntry.Sessions, copySession)
204246 }
205247 sort.Slice(copyEntry.Sessions, func(i, j int) bool {
206248 return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name
M internal/adapters/remote/catalog_cache_test.go +1 -1
@@ -115,7 +115,7 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
115115 }{
116116 {name: "truncated", raw: []byte(`{"version":1,"hosts":[`)},
117117 {name: "trailing JSON", raw: []byte(`{"version":1,"hosts":[]} {}`)},
118- {name: "unknown version", raw: []byte(`{"version":2,"hosts":[]}`)},
118+ {name: "unknown version", raw: []byte(`{"version":3,"hosts":[]}`)},
119119 {name: "missing hosts", raw: []byte(`{"version":1}`)},
120120 {name: "null hosts", raw: []byte(`{"version":1,"hosts":null}`)},
121121 {name: "null sessions", raw: []byte(`{"version":1,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
A internal/adapters/remote/preview.go +94 -0
@@ -0,0 +1,94 @@
1+package remote
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "os/exec"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/adapters/sshstdio"
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+)
15+
16+const (
17+ remotePreviewCommandTimeout = 5 * time.Second
18+ remotePreviewWaitDelay = 500 * time.Millisecond
19+ remotePreviewMaxDiagnostic = 4 << 10
20+)
21+
22+var (
23+ errRemotePreviewSSH = errors.New("remote preview: ssh command failed")
24+ errRemotePreviewTooLarge = errors.New("remote preview: output exceeds size limit")
25+)
26+
27+// PreviewClient invokes the hidden remote preview command over SSH. The
28+// command receives only a base64url request and stdout is decoded as one
29+// bounded binary response.
30+type PreviewClient struct {
31+ command func(ctx context.Context, name string, args ...string) *exec.Cmd
32+ timeout time.Duration
33+}
34+
35+var _ ports.RemotePreviewClient = (*PreviewClient)(nil)
36+
37+func NewPreviewClient() ports.RemotePreviewClient {
38+ return &PreviewClient{command: exec.CommandContext}
39+}
40+
41+func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
42+ request := ports.RemotePreviewRequest{Version: ports.RemotePreviewSchemaVersion, Target: target, Width: width, Height: height}
43+ payload := ports.MarshalRemotePreviewRequest(request)
44+ if payload == nil {
45+ return ports.RemotePreview{}, ports.ErrInvalidRemotePreviewRequest
46+ }
47+ runCtx, cancel := context.WithTimeout(ctx, c.previewTimeout())
48+ defer cancel()
49+ command := c.command
50+ if command == nil {
51+ command = exec.CommandContext
52+ }
53+ encoded := base64.RawURLEncoding.EncodeToString(payload)
54+ spec := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", encoded)
55+ cmd := command(runCtx, spec.Path, spec.Args...)
56+ stdout := boundedBuffer{limit: ports.RemotePreviewMaxBytes}
57+ stderr := boundedBuffer{limit: remotePreviewMaxDiagnostic}
58+ cmd.Stdout = &stdout
59+ cmd.Stderr = &stderr
60+ cmd.WaitDelay = remotePreviewWaitDelay
61+ if err := cmd.Run(); err != nil {
62+ if ctxErr := runCtx.Err(); ctxErr != nil {
63+ if parentErr := ctx.Err(); parentErr != nil {
64+ return ports.RemotePreview{}, parentErr
65+ }
66+ return ports.RemotePreview{}, ports.ErrRemotePreviewTimeout
67+ }
68+ if stdout.overflow || stderr.overflow {
69+ return ports.RemotePreview{}, errRemotePreviewTooLarge
70+ }
71+ if diagnostic := sanitizeCatalogDiagnostic(string(stderr.Bytes())); diagnostic != "" {
72+ return ports.RemotePreview{}, fmt.Errorf("%w: %v: %s", errRemotePreviewSSH, err, diagnostic)
73+ }
74+ return ports.RemotePreview{}, fmt.Errorf("%w: %v", errRemotePreviewSSH, err)
75+ }
76+ if stdout.overflow || stderr.overflow {
77+ return ports.RemotePreview{}, errRemotePreviewTooLarge
78+ }
79+ preview, err := ports.UnmarshalRemotePreview(stdout.Bytes())
80+ if err != nil {
81+ return ports.RemotePreview{}, err
82+ }
83+ if preview.Status != ports.RemotePreviewOK {
84+ return ports.RemotePreview{}, fmt.Errorf("remote preview unavailable: status %d", preview.Status)
85+ }
86+ return preview, nil
87+}
88+
89+func (c *PreviewClient) previewTimeout() time.Duration {
90+ if c != nil && c.timeout > 0 {
91+ return c.timeout
92+ }
93+ return remotePreviewCommandTimeout
94+}
A internal/adapters/remote/preview_test.go +138 -0
@@ -0,0 +1,138 @@
1+package remote
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "os/exec"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/adapters/sshstdio"
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/pkg/renderer"
19+)
20+
21+func previewClientTargetForTest() domain.RemoteSessionTarget {
22+ var lifecycle domain.SessionLifecycleID
23+ lifecycle[0] = 1
24+ return domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
25+}
26+
27+func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T) {
28+ var lifecycle domain.SessionLifecycleID
29+ lifecycle[0] = 9
30+ target := domain.RemoteSessionTarget{
31+ Endpoint: "build@mule", DisplayOrigin: "mule", LifecycleID: lifecycle,
32+ SessionName: "work", LiveTabID: "tab-1",
33+ }
34+ want := ports.RemotePreview{
35+ Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK,
36+ LifecycleID: lifecycle, TabID: target.LiveTabID, Revision: 7, Width: 1, Height: 1,
37+ Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}},
38+ }
39+ payload := ports.MarshalRemotePreview(want)
40+ require.NotNil(t, payload)
41+
42+ var gotPath string
43+ var gotArgs []string
44+ client := &PreviewClient{command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
45+ gotPath = name
46+ gotArgs = append([]string(nil), args...)
47+ if len(args) != 3 {
48+ t.Fatalf("ssh args = %#v, want target and one remote command", args)
49+ }
50+ words := strings.Split(args[2], " ")
51+ if len(words) != 3 {
52+ t.Fatalf("remote command = %q, want three shell words", args[2])
53+ }
54+ encoded := strings.Trim(words[2], "'")
55+ requestPayload, err := base64.RawURLEncoding.DecodeString(encoded)
56+ if err != nil {
57+ t.Fatalf("decode request: %v", err)
58+ }
59+ request, err := ports.UnmarshalRemotePreviewRequest(requestPayload)
60+ if err != nil {
61+ t.Fatalf("decode request payload: %v", err)
62+ }
63+ if request.Target != target || request.Width != 1 || request.Height != 1 {
64+ t.Fatalf("request = %#v, want target and 1x1 dimensions", request)
65+ }
66+ return stdoutCmd(ctx, string(payload))
67+ }}
68+
69+ got, err := client.Preview(context.Background(), target, 1, 1)
70+ require.NoError(t, err)
71+ require.Equal(t, want, got)
72+ wantCommand := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", "request")
73+ require.Equal(t, wantCommand.Path, gotPath)
74+ require.Equal(t, target.Endpoint, gotArgs[1])
75+ require.Contains(t, gotArgs[2], "'_remote-preview'")
76+}
77+
78+func TestRemotePreviewClientRejectsMalformedResponse(t *testing.T) {
79+ client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
80+ return stdoutCmd(ctx, "not-a-preview")
81+ }}
82+ var lifecycle domain.SessionLifecycleID
83+ lifecycle[0] = 1
84+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
85+ _, err := client.Preview(context.Background(), target, 1, 1)
86+ require.Error(t, err)
87+}
88+
89+func TestRemotePreviewClientRejectsOversizedOutput(t *testing.T) {
90+ client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
91+ return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("head -c %d /dev/zero", ports.RemotePreviewMaxBytes+1))
92+ }}
93+ target := previewClientTargetForTest()
94+ _, err := client.Preview(context.Background(), target, 1, 1)
95+ require.ErrorIs(t, err, errRemotePreviewTooLarge)
96+}
97+
98+func TestRemotePreviewClientReturnsStderrDiagnostic(t *testing.T) {
99+ client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
100+ return exec.CommandContext(ctx, "sh", "-c", "printf 'connection refused' >&2; exit 1")
101+ }}
102+ target := previewClientTargetForTest()
103+ _, err := client.Preview(context.Background(), target, 1, 1)
104+ require.ErrorIs(t, err, errRemotePreviewSSH)
105+ require.Contains(t, err.Error(), "connection refused")
106+}
107+
108+func TestRemotePreviewClientReturnsInternalTimeoutSentinel(t *testing.T) {
109+ client := &PreviewClient{
110+ timeout: 20 * time.Millisecond,
111+ command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
112+ return exec.CommandContext(ctx, "sleep", "10")
113+ },
114+ }
115+ _, err := client.Preview(context.Background(), previewClientTargetForTest(), 1, 1)
116+ require.ErrorIs(t, err, ports.ErrRemotePreviewTimeout)
117+}
118+
119+func TestRemotePreviewClientReturnsCallerCancellation(t *testing.T) {
120+ started := make(chan struct{})
121+ client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
122+ close(started)
123+ return exec.CommandContext(ctx, "sleep", "10")
124+ }}
125+ target := previewClientTargetForTest()
126+ ctx, cancel := context.WithCancel(context.Background())
127+ defer cancel()
128+ errCh := make(chan error, 1)
129+ go func() {
130+ _, err := client.Preview(ctx, target, 1, 1)
131+ errCh <- err
132+ }()
133+ <-started
134+ cancel()
135+ err := <-errCh
136+ require.ErrorIs(t, err, context.Canceled)
137+ require.False(t, errors.Is(err, errRemotePreviewSSH))
138+}
M internal/app/remote_hosts.go +1 -1
@@ -201,7 +201,7 @@ func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) [
201201 info := ports.SessionInfo{
202202 Name: session.Name + "@" + host,
203203 Ephemeral: session.Ephemeral,
204- Tabs: session.Tabs,
204+ Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),
205205 Attached: session.Attached,
206206 }
207207 switch session.State {
A internal/app/remote_preview.go +75 -0
@@ -0,0 +1,75 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "os"
9+
10+ "github.com/bnema/vev/internal/adapters/ipc"
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+// runRemotePreview is the hidden SSH-side carriage. The request is encoded in
15+// one shell-safe argument, then the daemon returns one bounded binary response
16+// whose payload is written verbatim to stdout; no viewport bytes are logged.
17+func runRemotePreview(ctx context.Context, encoded string) error {
18+ request, err := base64.RawURLEncoding.DecodeString(encoded)
19+ if err != nil {
20+ return fmt.Errorf("vev: invalid remote preview request: %w", err)
21+ }
22+ if _, err := ports.UnmarshalRemotePreviewRequest(request); err != nil {
23+ return fmt.Errorf("vev: invalid remote preview request: %w", err)
24+ }
25+ transport, err := ipc.DialContext(ctx, ipc.SocketDir())
26+ if err != nil {
27+ if ctxErr := ctx.Err(); ctxErr != nil {
28+ return ctxErr
29+ }
30+ return writeRemotePreviewStatus(ports.RemotePreviewUnavailable)
31+ }
32+ defer func() { _ = transport.Close() }()
33+ if err := transport.Send(ports.Frame{Type: ports.MsgRemotePreviewRequest, Payload: request}); err != nil {
34+ return fmt.Errorf("vev: sending remote preview request: %w", err)
35+ }
36+ frameCh := make(chan struct {
37+ frame ports.Frame
38+ err error
39+ }, 1)
40+ go func() {
41+ frame, err := transport.Recv()
42+ frameCh <- struct {
43+ frame ports.Frame
44+ err error
45+ }{frame: frame, err: err}
46+ }()
47+ select {
48+ case <-ctx.Done():
49+ _ = transport.Close()
50+ return ctx.Err()
51+ case result := <-frameCh:
52+ if result.err != nil {
53+ return fmt.Errorf("vev: receiving remote preview: %w", result.err)
54+ }
55+ if result.frame.Type != ports.MsgRemotePreviewResponse {
56+ return fmt.Errorf("vev: unexpected remote preview response %d", result.frame.Type)
57+ }
58+ if _, err := ports.UnmarshalRemotePreview(result.frame.Payload); err != nil {
59+ return fmt.Errorf("vev: malformed remote preview response: %w", err)
60+ }
61+ _, err := os.Stdout.Write(result.frame.Payload)
62+ return err
63+ }
64+}
65+
66+func writeRemotePreviewStatus(status ports.RemotePreviewStatus) error {
67+ payload := ports.MarshalRemotePreview(ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: status})
68+ if payload == nil {
69+ return errors.New("vev: failed to encode remote preview status")
70+ }
71+ if _, err := os.Stdout.Write(payload); err != nil {
72+ return fmt.Errorf("vev: writing remote preview status: %w", err)
73+ }
74+ return nil
75+}
M internal/app/run.go +120 -29
@@ -67,6 +67,7 @@ const (
6767 kindStdio
6868 kindUDPBootstrap
6969 kindUDPProxy
70+ kindRemotePreview
7071 kindHelp
7172 kindVersion
7273 )
@@ -74,17 +75,18 @@ const (
7475 // command is the parsed CLI invocation: what to do, plus the attach intent
7576 // and session name where relevant.
7677 type command struct {
77- kind cmdKind
78- intent uint8
79- name string
80- remoteTarget string
81- listHost string
82- listAll bool
83- hostAction string
84- hostTarget string
85- killAll bool
86- killDaemon bool
87- cmd cmdInvocation
78+ kind cmdKind
79+ intent uint8
80+ name string
81+ remoteTarget string
82+ listHost string
83+ listAll bool
84+ hostAction string
85+ hostTarget string
86+ killAll bool
87+ killDaemon bool
88+ cmd cmdInvocation
89+ remotePreviewPayload string
8890 }
8991
9092 // usageError is a user-facing argument error; the app prints it (with usage)
@@ -175,6 +177,11 @@ func parseArgs(args []string) (command, error) {
175177 return command{}, usagef("`_udp-proxy` does not accept a session name")
176178 }
177179 return command{kind: kindUDPProxy}, nil
180+ case "_remote-preview":
181+ if len(args) != 2 || args[1] == "" {
182+ return command{}, usagef("`_remote-preview` requires one encoded request")
183+ }
184+ return command{kind: kindRemotePreview, remotePreviewPayload: args[1]}, nil
178185 case "new":
179186 if len(args) < 2 || args[1] == "" {
180187 return command{}, usagef("`new` requires a session name")
@@ -269,6 +276,8 @@ func dispatch(ctx context.Context, cmd command) error {
269276 return runUDPBootstrap(ctx)
270277 case kindUDPProxy:
271278 return runUDPProxy(ctx, os.Stdout)
279+ case kindRemotePreview:
280+ return runRemotePreview(ctx, cmd.remotePreviewPayload)
272281 case kindList:
273282 return runList(ctx, cmd)
274283 case kindHost:
@@ -341,6 +350,7 @@ var (
341350 newRemoteHostStore = remoteadapter.NewFileHostStore
342351 newRemoteCatalogCache = remoteadapter.NewFileCatalogCache
343352 newRemoteCatalogClient = remoteadapter.NewCatalogClient
353+ newRemotePreviewClient = remoteadapter.NewPreviewClient
344354 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
345355 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
346356 }
@@ -790,7 +800,18 @@ func validateRemoteAttachHandoff(target ports.AttachTarget) error {
790800 if err := domain.ValidateRemoteHostTarget(target.Endpoint); err != nil {
791801 return err
792802 }
793- return domain.ValidateSessionName(target.Session)
803+ if err := domain.ValidateSessionName(target.Session); err != nil {
804+ return err
805+ }
806+ if target.RemoteTarget != nil {
807+ if target.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
808+ return errors.New("remote picker handoff must use daemon-owned environment")
809+ }
810+ if target.RemoteTarget.Endpoint != target.Endpoint || target.RemoteTarget.SessionName != target.Session {
811+ return errors.New("remote picker handoff identity does not match route")
812+ }
813+ }
814+ return nil
794815 }
795816
796817 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
@@ -800,21 +821,31 @@ func remoteDiscoveryDaemonOption(stateDir string, observer ports.SerializedRunti
800821 if err != nil {
801822 return nil, err
802823 }
803- return daemon.WithRemoteDiscovery(
824+ discovery := daemon.WithRemoteDiscovery(
804825 newRemoteHostStore(remoteadapter.HostStorePath(stateDir)),
805826 newRemoteCatalogClient(),
806827 newRemoteCatalogCache(remoteadapter.CatalogCachePath(stateDir)),
807828 newRemoteDialerFactoryWithRuntimeObserver(observer),
808829 mode,
809- ), nil
830+ )
831+ preview := daemon.WithRemotePreview(newRemotePreviewClient())
832+ return func(d *daemon.Daemon) {
833+ discovery(d)
834+ preview(d)
835+ }, nil
810836 }
811837
812838 type attachHandoffKey struct {
813- endpoint string
814- session string
815- intent uint8
839+ endpoint string
840+ session string
841+ intent uint8
842+ lifecycle domain.SessionLifecycleID
843+ liveTab domain.TabStableID
844+ stopped domain.TabSelector
816845 }
817846
847+const maxAttachHandoffHops = 32
848+
818849 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
819850 if activeSession != "" {
820851 if remoteTarget == "" && intent == ports.IntentNew {
@@ -833,7 +864,21 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
833864 }
834865 mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
835866 factory := deps.remoteDialerFactory
867+ var remoteSelection *domain.RemoteSessionTarget
868+ var remoteEnvironmentPolicy ports.EnvironmentPolicy
836869 seenHandoffs := make(map[attachHandoffKey]struct{})
870+ handoffHops := 0
871+ admitHandoff := func(next attachHandoffKey) error {
872+ if _, seen := seenHandoffs[next]; seen {
873+ return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
874+ }
875+ if handoffHops >= maxAttachHandoffHops {
876+ return fmt.Errorf("vev: attach handoff exceeded maximum of %d hops", maxAttachHandoffHops)
877+ }
878+ handoffHops++
879+ seenHandoffs[next] = struct{}{}
880+ return nil
881+ }
837882 if remoteTarget != "" {
838883 if modeErr != nil {
839884 return modeErr
@@ -843,6 +888,35 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
843888 }
844889 seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
845890 }
891+ var handoff client.AttachHandoffFunc
892+ handoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
893+ if err := validateRemoteAttachHandoff(target); err != nil {
894+ return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)
895+ }
896+ if modeErr != nil {
897+ return nil, client.AttachRequest{}, modeErr
898+ }
899+ if factory == nil {
900+ factory = defaultRemoteDialerFactory()
901+ }
902+ next := attachHandoffKey{endpoint: target.Endpoint, session: target.Session, intent: target.Intent}
903+ var selection *domain.RemoteSessionTarget
904+ if target.RemoteTarget != nil {
905+ copyTarget := *target.RemoteTarget
906+ selection = &copyTarget
907+ next.lifecycle = copyTarget.LifecycleID
908+ next.liveTab = copyTarget.LiveTabID
909+ next.stopped = copyTarget.StoppedTab
910+ }
911+ if err := admitHandoff(next); err != nil {
912+ return nil, client.AttachRequest{}, err
913+ }
914+ dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)
915+ if err != nil {
916+ return nil, client.AttachRequest{}, err
917+ }
918+ return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: target.EnvironmentPolicy}, nil
919+ }
846920
847921 for {
848922 var err error
@@ -862,8 +936,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
862936 Logger: log,
863937 RuntimeObserver: deps.runtimeObserver,
864938 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
939+ AttachHandoff: handoff,
865940 Remote: true,
866- }, client.AttachRequest{Intent: intent, SessionName: name})
941+ }, client.AttachRequest{
942+ Intent: intent,
943+ SessionName: name,
944+ RemoteTarget: remoteSelection,
945+ EnvironmentPolicy: remoteEnvironmentPolicy,
946+ })
867947 } else {
868948 if log != nil {
869949 log.Info("attaching to local session", "intent", intent, "name", name)
@@ -874,28 +954,39 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
874954 Clock: clock.New(),
875955 Logger: log,
876956 RuntimeObserver: deps.runtimeObserver,
957+ AttachHandoff: handoff,
877958 Remote: false,
878959 }, client.AttachRequest{Intent: intent, SessionName: name})
879960 }
880961
881- var handoff *client.AttachTargetError
882- if !errors.As(err, &handoff) {
962+ var handoffErr *client.AttachTargetError
963+ if !errors.As(err, &handoffErr) {
883964 return err
884965 }
885- if handoff == nil {
966+ if handoffErr == nil {
886967 return fmt.Errorf("vev: invalid remote attach handoff")
887968 }
888- if err := validateRemoteAttachHandoff(handoff.Target); err != nil {
969+ if err := validateRemoteAttachHandoff(handoffErr.Target); err != nil {
889970 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
890971 }
891- next := attachHandoffKey{endpoint: handoff.Target.Endpoint, session: handoff.Target.Session, intent: handoff.Target.Intent}
892- if _, seen := seenHandoffs[next]; seen {
893- return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
972+ next := attachHandoffKey{endpoint: handoffErr.Target.Endpoint, session: handoffErr.Target.Session, intent: handoffErr.Target.Intent}
973+ if handoffErr.Target.RemoteTarget != nil {
974+ next.lifecycle = handoffErr.Target.RemoteTarget.LifecycleID
975+ next.liveTab = handoffErr.Target.RemoteTarget.LiveTabID
976+ next.stopped = handoffErr.Target.RemoteTarget.StoppedTab
977+ }
978+ if err := admitHandoff(next); err != nil {
979+ return err
980+ }
981+ remoteTarget = handoffErr.Target.Endpoint
982+ name = handoffErr.Target.Session
983+ intent = handoffErr.Target.Intent
984+ remoteSelection = nil
985+ remoteEnvironmentPolicy = handoffErr.Target.EnvironmentPolicy
986+ if handoffErr.Target.RemoteTarget != nil {
987+ copyTarget := *handoffErr.Target.RemoteTarget
988+ remoteSelection = &copyTarget
894989 }
895- seenHandoffs[next] = struct{}{}
896- remoteTarget = handoff.Target.Endpoint
897- name = handoff.Target.Session
898- intent = handoff.Target.Intent
899990 if modeErr != nil {
900991 return modeErr
901992 }
M internal/app/run_test.go +17 -0
@@ -6,6 +6,7 @@ import (
66 "context"
77 "encoding/json"
88 "errors"
9+ "fmt"
910 "io"
1011 "log/slog"
1112 "net"
@@ -906,6 +907,22 @@ func TestRunAttachWithDepsRejectsHandoffLoop(t *testing.T) {
906907 require.Equal(t, 1, calls)
907908 }
908909
910+func TestRunAttachWithDepsCapsUniqueHandoffHops(t *testing.T) {
911+ factory := portsmocks.NewMockRemoteDialerFactory(t)
912+ factory.EXPECT().DialerForRemote(mock.Anything, mock.Anything, ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachHandoffHops + 1)
913+
914+ calls := 0
915+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
916+ remoteDialerFactory: factory,
917+ runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
918+ calls++
919+ return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: fmt.Sprintf("remote-%d.example", calls), Session: "work", Intent: ports.IntentAttach}}
920+ },
921+ })
922+ require.ErrorContains(t, err, "exceeded maximum")
923+ require.Equal(t, maxAttachHandoffHops+1, calls)
924+}
925+
909926 func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
910927 factory := portsmocks.NewMockRemoteDialerFactory(t)
911928 factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
A internal/domain/lifecycle.go +208 -0
@@ -0,0 +1,208 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "unicode"
9+ "unicode/utf8"
10+)
11+
12+// SessionLifecycleID is the opaque identity of one session lifecycle.
13+//
14+// IncarnationID predates the remote picker and is the durable implementation
15+// used by snapshots and the catalogue. This alias deliberately keeps one
16+// identity type rather than introducing a second, independently comparable
17+// UUID. A lifecycle ID changes only when a session is destroyed and recreated.
18+type SessionLifecycleID = IncarnationID
19+
20+// NewSessionLifecycleID allocates a non-zero lifecycle identity.
21+func NewSessionLifecycleID(r io.Reader) (SessionLifecycleID, error) {
22+ return NewIncarnationID(r)
23+}
24+
25+// TabSelectorKind identifies the exact selector form used for a stopped tab.
26+type TabSelectorKind uint8
27+
28+const (
29+ TabSelectorByStableID TabSelectorKind = iota + 1
30+ TabSelectorByOrdinal
31+)
32+
33+// TabSelector is an exact tab identity handoff. Stable IDs are preferred for
34+// persisted tabs. The ordinal form is a compatibility selector for catalogue
35+// records that predate stable stopped-tab IDs; raw name and expected count are
36+// required so a reordered or replaced tab cannot be selected accidentally.
37+type TabSelector struct {
38+ Kind TabSelectorKind
39+ StableID TabStableID
40+ Ordinal uint16
41+ RawName string
42+ ExpectedCount uint16
43+}
44+
45+// NewStableTabSelector creates an exact stable-ID selector.
46+func NewStableTabSelector(id TabStableID) TabSelector {
47+ return TabSelector{Kind: TabSelectorByStableID, StableID: id}
48+}
49+
50+// NewOrdinalTabSelector creates an exact legacy-compatible selector.
51+func NewOrdinalTabSelector(ordinal uint16, rawName string, expectedCount uint16) TabSelector {
52+ return TabSelector{Kind: TabSelectorByOrdinal, Ordinal: ordinal, RawName: rawName, ExpectedCount: expectedCount}
53+}
54+
55+// Validate rejects selectors that could silently resolve a different tab.
56+func (s TabSelector) Validate() error {
57+ switch s.Kind {
58+ case TabSelectorByStableID:
59+ if s.Ordinal != 0 || s.RawName != "" || s.ExpectedCount != 0 || ValidateTabStableID(s.StableID) != nil {
60+ return errors.New("invalid stable tab selector")
61+ }
62+ case TabSelectorByOrdinal:
63+ if s.ExpectedCount == 0 || s.Ordinal >= s.ExpectedCount || s.StableID != "" {
64+ return errors.New("invalid ordinal tab selector")
65+ }
66+ if err := validateTabSelectorName(s.RawName); err != nil {
67+ return err
68+ }
69+ default:
70+ return errors.New("unknown tab selector kind")
71+ }
72+ return nil
73+}
74+
75+// TabSelectorTab is the immutable metadata needed to resolve a selector.
76+type TabSelectorTab struct {
77+ ID TabStableID
78+ Name string
79+}
80+
81+// Resolve returns the selected tab index only when the complete selector still
82+// describes the supplied ordered tab metadata.
83+func (s TabSelector) Resolve(tabs []TabSelectorTab) (int, bool) {
84+ if s.Validate() != nil || (len(tabs) != int(s.ExpectedCount) && s.Kind == TabSelectorByOrdinal) {
85+ return 0, false
86+ }
87+ switch s.Kind {
88+ case TabSelectorByStableID:
89+ if len(tabs) == 0 {
90+ return 0, false
91+ }
92+ found := -1
93+ for i, tab := range tabs {
94+ if tab.ID != s.StableID {
95+ continue
96+ }
97+ if found != -1 {
98+ return 0, false
99+ }
100+ found = i
101+ }
102+ return found, found >= 0
103+ case TabSelectorByOrdinal:
104+ index := int(s.Ordinal)
105+ return index, index < len(tabs) && tabs[index].Name == s.RawName
106+ default:
107+ return 0, false
108+ }
109+}
110+
111+// RemoteSessionTarget is the structured identity used by picker-based remote
112+// access. Endpoint and DisplayOrigin are intentionally separate values: the
113+// latter is presentation-only and is never parsed back into a route.
114+type RemoteSessionTarget struct {
115+ Endpoint string
116+ DisplayOrigin string
117+ LifecycleID SessionLifecycleID
118+ SessionName string
119+ LiveTabID TabStableID
120+ StoppedTab TabSelector
121+ Stopped bool
122+}
123+
124+// Validate validates route, presentation, lifecycle, and selector identity as
125+// independent contract fields.
126+func (t RemoteSessionTarget) Validate() error {
127+ if err := ValidateRemoteHostTarget(t.Endpoint); err != nil {
128+ return fmt.Errorf("remote route endpoint: %w", err)
129+ }
130+ if err := ValidateRemoteDisplayOrigin(t.DisplayOrigin); err != nil {
131+ return fmt.Errorf("remote display origin: %w", err)
132+ }
133+ if t.LifecycleID == (SessionLifecycleID{}) {
134+ return errors.New("remote lifecycle ID is zero")
135+ }
136+ if err := ValidateSessionName(t.SessionName); err != nil {
137+ return err
138+ }
139+ if t.Stopped {
140+ if t.LiveTabID != "" || t.StoppedTab.Validate() != nil {
141+ return errors.New("stopped remote target requires an ordinal or stable tab selector")
142+ }
143+ return nil
144+ }
145+ if err := ValidateTabStableID(t.LiveTabID); err != nil {
146+ return errors.New("running remote target requires a stable tab ID")
147+ }
148+ if t.StoppedTab != (TabSelector{}) {
149+ return errors.New("running remote target cannot carry a stopped selector")
150+ }
151+ return nil
152+}
153+
154+// ValidateRemoteDisplayOrigin validates presentation data without imposing
155+// SSH route grammar. Aliases, users, ports, and bracketed IPv6 remain opaque.
156+func ValidateRemoteDisplayOrigin(origin string) error {
157+ return validateRemoteText(origin, 256, "remote display origin")
158+}
159+
160+// ValidateTabStableID validates the opaque stable tab identity used by
161+// picker and attachment handoffs.
162+func ValidateTabStableID(value TabStableID) error {
163+ if value == "" || len(value) > 128 || !utf8.ValidString(string(value)) {
164+ return errors.New("invalid stable tab ID")
165+ }
166+ for _, r := range value {
167+ if unicode.IsControl(r) || unicode.IsSpace(r) {
168+ return errors.New("invalid stable tab ID")
169+ }
170+ }
171+ return nil
172+}
173+
174+func validateTabSelectorName(value string) error {
175+ if len(value) > 256 {
176+ return errors.New("tab name exceeds 256 bytes")
177+ }
178+ if !utf8.ValidString(value) {
179+ return errors.New("tab name is not valid UTF-8")
180+ }
181+ for _, r := range value {
182+ if unicode.IsControl(r) || r == '\n' || r == '\r' || r == '\x1b' {
183+ return errors.New("tab name contains control characters")
184+ }
185+ }
186+ return nil
187+}
188+
189+func validateRemoteText(value string, maxBytes int, label string) error {
190+ if value == "" {
191+ return fmt.Errorf("%s is empty", label)
192+ }
193+ if len(value) > maxBytes {
194+ return fmt.Errorf("%s exceeds %d bytes", label, maxBytes)
195+ }
196+ if !utf8.ValidString(value) {
197+ return fmt.Errorf("%s is not valid UTF-8", label)
198+ }
199+ if strings.TrimSpace(value) != value {
200+ return fmt.Errorf("%s has surrounding whitespace", label)
201+ }
202+ for _, r := range value {
203+ if unicode.IsControl(r) || r == '\n' || r == '\r' || r == '\x1b' {
204+ return fmt.Errorf("%s contains control characters", label)
205+ }
206+ }
207+ return nil
208+}
A internal/domain/lifecycle_test.go +40 -0
@@ -0,0 +1,40 @@
1+package domain
2+
3+import "testing"
4+
5+func TestOrdinalTabSelectorPreservesUnnamedTabs(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ selector TabSelector
9+ tabs []TabSelectorTab
10+ want int
11+ }{
12+ {name: "unnamed middle tab", selector: NewOrdinalTabSelector(1, "", 3), tabs: []TabSelectorTab{{Name: "first"}, {Name: ""}, {Name: "trailing"}}, want: 1},
13+ }
14+ for _, test := range tests {
15+ t.Run(test.name, func(t *testing.T) {
16+ index, ok := test.selector.Resolve(test.tabs)
17+ if !ok || index != test.want {
18+ t.Fatalf("Resolve() = %d, %t, want %d, true", index, ok, test.want)
19+ }
20+ })
21+ }
22+}
23+
24+func TestValidateTabStableIDRejectsUnsafeIdentity(t *testing.T) {
25+ tests := []struct {
26+ name string
27+ id TabStableID
28+ }{
29+ {name: "empty", id: ""},
30+ {name: "space", id: "tab id"},
31+ {name: "newline", id: "tab\n1"},
32+ }
33+ for _, test := range tests {
34+ t.Run(test.name, func(t *testing.T) {
35+ if err := ValidateTabStableID(test.id); err == nil {
36+ t.Fatalf("ValidateTabStableID(%q) = nil, want error", test.id)
37+ }
38+ })
39+ }
40+}
M internal/domain/recovery.go +32 -8
@@ -7,6 +7,7 @@ import (
77 "io"
88 "slices"
99 "strings"
10+ "unicode/utf8"
1011 )
1112
1213 // IncarnationID permanently distinguishes uses of the same session name.
@@ -18,15 +19,26 @@ type CheckpointRef struct {
1819 ManifestDigest [32]byte
1920 }
2021
22+// CatalogueTabRecord is the durable ordered metadata for one stopped tab.
23+// StableID is empty for records migrated from the legacy TabNames-only format.
24+type CatalogueTabRecord struct {
25+ StableID TabStableID
26+ Name string
27+}
28+
2129 // CatalogueRecord is the durable source of truth for one named session.
2230 type CatalogueRecord struct {
23- Name string
24- IncarnationID IncarnationID
25- Cwd string
26- CreatedAt int64
27- UpdatedAt int64
28- LastUsedSeq uint64
31+ Name string
32+ IncarnationID IncarnationID
33+ Cwd string
34+ CreatedAt int64
35+ UpdatedAt int64
36+ LastUsedSeq uint64
37+ // TabNames remains the compatibility metadata consumed by older recovery
38+ // paths. TabRecords is authoritative when present and retains trailing
39+ // unnamed tabs instead of silently trimming them.
2940 TabNames []string
41+ TabRecords []CatalogueTabRecord
3042 Committed *CheckpointRef
3143 DegradedReason string
3244 }
@@ -44,7 +56,7 @@ func (r *CheckpointRef) Equal(other *CheckpointRef) bool {
4456 func (r CatalogueRecord) Equal(other CatalogueRecord) bool {
4557 return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd &&
4658 r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq &&
47- slices.Equal(r.TabNames, other.TabNames) && r.Committed.Equal(other.Committed) &&
59+ slices.Equal(r.TabNames, other.TabNames) && slices.Equal(r.TabRecords, other.TabRecords) && r.Committed.Equal(other.Committed) &&
4860 r.DegradedReason == other.DegradedReason
4961 }
5062
@@ -57,6 +69,7 @@ type CatalogueMetadataUpdate struct {
5769 UpdatedAt *int64
5870 LastUsedSeq *uint64
5971 TabNames *[]string
72+ TabRecords *[]CatalogueTabRecord
6073 }
6174
6275 // MetadataUpdate returns a complete mutable-metadata update for r. Catalogue
@@ -64,9 +77,10 @@ type CatalogueMetadataUpdate struct {
6477 func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate {
6578 cwd, updatedAt, lastUsedSeq := r.Cwd, r.UpdatedAt, r.LastUsedSeq
6679 tabNames := append([]string(nil), r.TabNames...)
80+ tabRecords := append([]CatalogueTabRecord(nil), r.TabRecords...)
6781 return CatalogueMetadataUpdate{
6882 Name: r.Name, IncarnationID: r.IncarnationID,
69- Cwd: &cwd, UpdatedAt: &updatedAt, LastUsedSeq: &lastUsedSeq, TabNames: &tabNames,
83+ Cwd: &cwd, UpdatedAt: &updatedAt, LastUsedSeq: &lastUsedSeq, TabNames: &tabNames, TabRecords: &tabRecords,
7084 }
7185 }
7286
@@ -119,6 +133,16 @@ func (r CatalogueRecord) Validate() error {
119133 return errors.New("zero catalogue incarnation ID")
120134 }
121135
136+ for _, tab := range r.TabRecords {
137+ if tab.StableID != "" {
138+ if err := ValidateTabStableID(tab.StableID); err != nil {
139+ return errors.New("invalid catalogue tab metadata")
140+ }
141+ }
142+ if !utf8.ValidString(tab.Name) {
143+ return errors.New("invalid catalogue tab metadata")
144+ }
145+ }
122146 if r.DegradedReason != "" && r.Committed == nil {
123147 return errors.New("broken session has no committed checkpoint")
124148 }
M internal/domain/remote_session.go +18 -6
@@ -8,8 +8,13 @@ import (
88 // RemoteSessionKey identifies a discovered remote session independently from
99 // its display label.
1010 type RemoteSessionKey struct {
11- Host string
12- Name string
11+ Host string
12+ Name string
13+ LifecycleID SessionLifecycleID
14+ // DisplayOrigin is presentation-only. When empty, Display derives a
15+ // conservative origin from Host for legacy callers; picker targets should
16+ // populate it explicitly and never parse Display back into routing data.
17+ DisplayOrigin string
1318 }
1419
1520 // Validate reports whether the host target and session name are both valid.
@@ -25,14 +30,21 @@ func (k RemoteSessionKey) Validate() error {
2530 func (k RemoteSessionKey) ID() SessionID {
2631 host := base64.RawURLEncoding.EncodeToString([]byte(k.Host))
2732 name := base64.RawURLEncoding.EncodeToString([]byte(k.Name))
33+ if k.LifecycleID != (SessionLifecycleID{}) {
34+ lifecycle := base64.RawURLEncoding.EncodeToString(k.LifecycleID[:])
35+ return SessionID("remote:" + host + "." + name + "." + lifecycle)
36+ }
2837 return SessionID("remote:" + host + "." + name)
2938 }
3039
3140 // Display returns the presentation label for this remote session.
3241 func (k RemoteSessionKey) Display() string {
33- host := k.Host
34- if i := strings.LastIndexByte(host, '@'); i > 0 && i < len(host)-1 {
35- host = host[i+1:]
42+ origin := k.DisplayOrigin
43+ if origin == "" {
44+ origin = k.Host
45+ if i := strings.LastIndexByte(origin, '@'); i > 0 && i < len(origin)-1 {
46+ origin = origin[i+1:]
47+ }
3648 }
37- return k.Name + "@" + host
49+ return k.Name + "@" + origin
3850 }
M internal/persist/persist.go +3 -0
@@ -402,6 +402,9 @@ func (p *Persister) UpdateMetadata(update domain.CatalogueMetadataUpdate) error
402402 if update.TabNames != nil {
403403 current.TabNames = append([]string(nil), (*update.TabNames)...)
404404 }
405+ if update.TabRecords != nil {
406+ current.TabRecords = append([]domain.CatalogueTabRecord(nil), (*update.TabRecords)...)
407+ }
405408 return p.applyLocked(map[string]*domain.CatalogueRecord{current.Name: &current}, false)
406409 }
407410
M internal/persist/record.go +43 -2
@@ -9,7 +9,10 @@ import (
99 "github.com/bnema/vev/internal/domain"
1010 )
1111
12-const catalogueRecordVersion uint16 = 3
12+const (
13+ catalogueRecordVersion uint16 = 4
14+ legacyCatalogueRecordVersion uint16 = 3
15+)
1316
1417 var errMalformedRecord = errors.New("persist: malformed catalogue record")
1518
@@ -44,6 +47,20 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) {
4447 return nil, err
4548 }
4649 }
50+ if uint64(len(record.TabRecords)) > math.MaxUint32 {
51+ return nil, errors.New("persist: too many tab records")
52+ }
53+ buf = binary.BigEndian.AppendUint32(buf, uint32(len(record.TabRecords)))
54+ for _, tab := range record.TabRecords {
55+ buf, err = appendCheckedString(buf, string(tab.StableID))
56+ if err != nil {
57+ return nil, err
58+ }
59+ buf, err = appendCheckedString(buf, tab.Name)
60+ if err != nil {
61+ return nil, err
62+ }
63+ }
4764 buf = appendRef(buf, record.Committed)
4865 buf, err = appendCheckedString(buf, record.DegradedReason)
4966 return buf, err
@@ -59,7 +76,7 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error
5976 return domain.CatalogueRecord{}, errMalformedRecord
6077 }
6178 version, ok := reader.u16()
62- if !ok || version != catalogueRecordVersion {
79+ if !ok || (version != catalogueRecordVersion && version != legacyCatalogueRecordVersion) {
6380 return domain.CatalogueRecord{}, errMalformedRecord
6481 }
6582 id, ok := reader.take(16)
@@ -99,6 +116,30 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error
99116 }
100117 record.TabNames = append(record.TabNames, tab)
101118 }
119+ if version == legacyCatalogueRecordVersion {
120+ if len(record.TabNames) != 0 {
121+ record.TabRecords = make([]domain.CatalogueTabRecord, len(record.TabNames))
122+ for i, name := range record.TabNames {
123+ record.TabRecords[i] = domain.CatalogueTabRecord{Name: name}
124+ }
125+ }
126+ } else {
127+ tabRecordCount, ok := reader.u32()
128+ if !ok || uint64(tabRecordCount) > uint64(reader.remaining())/8 {
129+ return domain.CatalogueRecord{}, errMalformedRecord
130+ }
131+ if tabRecordCount > 0 {
132+ record.TabRecords = make([]domain.CatalogueTabRecord, 0, int(tabRecordCount))
133+ }
134+ for range tabRecordCount {
135+ stableID, stableOK := reader.str()
136+ name, nameOK := reader.str()
137+ if !stableOK || !nameOK {
138+ return domain.CatalogueRecord{}, errMalformedRecord
139+ }
140+ record.TabRecords = append(record.TabRecords, domain.CatalogueTabRecord{StableID: domain.TabStableID(stableID), Name: name})
141+ }
142+ }
102143 if record.Committed, ok = reader.ref(); !ok {
103144 return domain.CatalogueRecord{}, errMalformedRecord
104145 }
M internal/ports/frame.go +72 -28
@@ -1,13 +1,18 @@
11 package ports
22
3-import "time"
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
49
510 // HandshakeTimeout bounds every transport handshake from connect through the
611 // first committed publication.
712 const HandshakeTimeout = 15 * time.Second
813
914 // ProtocolVersion is the current vev IPC wire protocol version.
10-const ProtocolVersion uint16 = 23
15+const ProtocolVersion uint16 = 24
1116
1217 // MaxFrameLen is the largest permitted frame length, including the type byte
1318 // and excluding the four-byte length prefix.
@@ -16,33 +21,35 @@ const MaxFrameLen = 16 << 20
1621 // MsgType identifies the kind of payload carried by a Frame.
1722 type MsgType uint8
1823
19-// Frame message types. Client-originated messages are numbered from 1
20-// through 15, server-originated messages from 16, leaving room for growth in
21-// each band.
24+// Frame message types. Client-originated messages occupy 1–13 and 15;
25+// server-originated messages occupy 16–23 and 25–26. Values 14 and 24 remain
26+// reserved for future extensions.
2227 const (
23- MsgHello MsgType = 1
24- MsgInput MsgType = 2
25- MsgResize MsgType = 3
26- MsgDetach MsgType = 4
27- MsgPing MsgType = 5
28- MsgList MsgType = 6
29- MsgKill MsgType = 7
30- MsgTheme MsgType = 8
31- MsgAck MsgType = 9
32- MsgImagePush MsgType = 10
33- MsgClientNotice MsgType = 11
34- MsgCommand MsgType = 12
35- MsgOutputResetRequest MsgType = 13
36-
37- MsgWelcome MsgType = 16
38- MsgError MsgType = 17
39- MsgOutput MsgType = 18
40- MsgDetached MsgType = 19
41- MsgPong MsgType = 20
42- MsgSessions MsgType = 21
43- MsgCommandResult MsgType = 22
44- MsgSessionMeta MsgType = 23
45- MsgAttachTarget MsgType = 25
28+ MsgHello MsgType = 1
29+ MsgInput MsgType = 2
30+ MsgResize MsgType = 3
31+ MsgDetach MsgType = 4
32+ MsgPing MsgType = 5
33+ MsgList MsgType = 6
34+ MsgKill MsgType = 7
35+ MsgTheme MsgType = 8
36+ MsgAck MsgType = 9
37+ MsgImagePush MsgType = 10
38+ MsgClientNotice MsgType = 11
39+ MsgCommand MsgType = 12
40+ MsgOutputResetRequest MsgType = 13
41+ MsgRemotePreviewRequest MsgType = 15
42+
43+ MsgWelcome MsgType = 16
44+ MsgError MsgType = 17
45+ MsgOutput MsgType = 18
46+ MsgDetached MsgType = 19
47+ MsgPong MsgType = 20
48+ MsgSessions MsgType = 21
49+ MsgCommandResult MsgType = 22
50+ MsgSessionMeta MsgType = 23
51+ MsgAttachTarget MsgType = 25
52+ MsgRemotePreviewResponse MsgType = 26
4653 )
4754
4855 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -53,3 +60,40 @@ type Frame struct {
5360 Type MsgType
5461 Payload []byte
5562 }
63+
64+// RemotePreviewSchemaVersion is independent from the attachment IPC version.
65+const RemotePreviewSchemaVersion uint16 = 1
66+
67+// RemotePreviewStatus is a closed response taxonomy. Terminal contents are
68+// never carried in an error response.
69+type RemotePreviewStatus uint8
70+
71+const (
72+ RemotePreviewOK RemotePreviewStatus = iota
73+ RemotePreviewUnavailable
74+ RemotePreviewNoSuchTarget
75+ RemotePreviewStale
76+ RemotePreviewMalformed
77+ RemotePreviewTooLarge
78+)
79+
80+// RemotePreviewRequest asks the owning daemon for an in-memory live viewport.
81+type RemotePreviewRequest struct {
82+ Version uint16
83+ Target domain.RemoteSessionTarget
84+ Width uint16
85+ Height uint16
86+}
87+
88+// RemotePreview is a bounded row-major styled-cell viewport. It is a
89+// process-memory DTO and is never persisted, logged, or traced.
90+type RemotePreview struct {
91+ Version uint16
92+ Status RemotePreviewStatus
93+ LifecycleID domain.SessionLifecycleID
94+ TabID domain.TabStableID
95+ Revision uint64
96+ Width uint16
97+ Height uint16
98+ Cells []renderer.Cell
99+}
M internal/ports/frame_test.go +4 -4
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 23 {
7- t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion)
6+ if ProtocolVersion != 24 {
7+ t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)
88 }
99 }
1010
@@ -32,11 +32,11 @@ func TestMsgTypeUnique(t *testing.T) {
3232 {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
3333 {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
3434 {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
35- {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest},
35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, {"MsgRemotePreviewRequest", MsgRemotePreviewRequest},
3636 {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
3737 {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
3838 {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta},
39- {"MsgAttachTarget", MsgAttachTarget},
39+ {"MsgRemotePreviewResponse", MsgRemotePreviewResponse}, {"MsgAttachTarget", MsgAttachTarget},
4040 }
4141
4242 seen := make(map[MsgType]string, len(tests))
M internal/ports/mocks/remote_mock.go +106 -0
@@ -8,6 +8,7 @@ import (
88 "context"
99 "log/slog"
1010
11+ "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/internal/ports"
1213 mock "github.com/stretchr/testify/mock"
1314 )
@@ -637,6 +638,111 @@ func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.C
637638 return _c
638639 }
639640
641+// NewMockRemotePreviewClient creates a new instance of MockRemotePreviewClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
642+// The first argument is typically a *testing.T value.
643+func NewMockRemotePreviewClient(t interface {
644+ mock.TestingT
645+ Cleanup(func())
646+}) *MockRemotePreviewClient {
647+ mock := &MockRemotePreviewClient{}
648+ mock.Mock.Test(t)
649+
650+ t.Cleanup(func() { mock.AssertExpectations(t) })
651+
652+ return mock
653+}
654+
655+// MockRemotePreviewClient is an autogenerated mock type for the RemotePreviewClient type
656+type MockRemotePreviewClient struct {
657+ mock.Mock
658+}
659+
660+type MockRemotePreviewClient_Expecter struct {
661+ mock *mock.Mock
662+}
663+
664+func (_m *MockRemotePreviewClient) EXPECT() *MockRemotePreviewClient_Expecter {
665+ return &MockRemotePreviewClient_Expecter{mock: &_m.Mock}
666+}
667+
668+// Preview provides a mock function for the type MockRemotePreviewClient
669+func (_mock *MockRemotePreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16) (ports.RemotePreview, error) {
670+ ret := _mock.Called(ctx, target, width, height)
671+
672+ if len(ret) == 0 {
673+ panic("no return value specified for Preview")
674+ }
675+
676+ var r0 ports.RemotePreview
677+ var r1 error
678+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) (ports.RemotePreview, error)); ok {
679+ return returnFunc(ctx, target, width, height)
680+ }
681+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) ports.RemotePreview); ok {
682+ r0 = returnFunc(ctx, target, width, height)
683+ } else {
684+ r0 = ret.Get(0).(ports.RemotePreview)
685+ }
686+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteSessionTarget, uint16, uint16) error); ok {
687+ r1 = returnFunc(ctx, target, width, height)
688+ } else {
689+ r1 = ret.Error(1)
690+ }
691+ return r0, r1
692+}
693+
694+// MockRemotePreviewClient_Preview_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Preview'
695+type MockRemotePreviewClient_Preview_Call struct {
696+ *mock.Call
697+}
698+
699+// Preview is a helper method to define mock.On call
700+// - ctx context.Context
701+// - target domain.RemoteSessionTarget
702+// - width uint16
703+// - height uint16
704+func (_e *MockRemotePreviewClient_Expecter) Preview(ctx any, target any, width any, height any) *MockRemotePreviewClient_Preview_Call {
705+ return &MockRemotePreviewClient_Preview_Call{Call: _e.mock.On("Preview", ctx, target, width, height)}
706+}
707+
708+func (_c *MockRemotePreviewClient_Preview_Call) Run(run func(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16)) *MockRemotePreviewClient_Preview_Call {
709+ _c.Call.Run(func(args mock.Arguments) {
710+ var arg0 context.Context
711+ if args[0] != nil {
712+ arg0 = args[0].(context.Context)
713+ }
714+ var arg1 domain.RemoteSessionTarget
715+ if args[1] != nil {
716+ arg1 = args[1].(domain.RemoteSessionTarget)
717+ }
718+ var arg2 uint16
719+ if args[2] != nil {
720+ arg2 = args[2].(uint16)
721+ }
722+ var arg3 uint16
723+ if args[3] != nil {
724+ arg3 = args[3].(uint16)
725+ }
726+ run(
727+ arg0,
728+ arg1,
729+ arg2,
730+ arg3,
731+ )
732+ })
733+ return _c
734+}
735+
736+func (_c *MockRemotePreviewClient_Preview_Call) Return(remotePreview ports.RemotePreview, err error) *MockRemotePreviewClient_Preview_Call {
737+ _c.Call.Return(remotePreview, err)
738+ return _c
739+}
740+
741+func (_c *MockRemotePreviewClient_Preview_Call) RunAndReturn(run func(ctx context.Context, target domain.RemoteSessionTarget, width uint16, height uint16) (ports.RemotePreview, error)) *MockRemotePreviewClient_Preview_Call {
742+ _c.Call.Return(run)
743+ return _c
744+}
745+
640746 // NewMockRemoteCatalogCache creates a new instance of MockRemoteCatalogCache. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
641747 // The first argument is typically a *testing.T value.
642748 func NewMockRemoteCatalogCache(t interface {
M internal/ports/remote.go +333 -13
@@ -2,9 +2,17 @@ package ports
22
33 import (
44 "context"
5+ "encoding/json"
6+ "errors"
57 "fmt"
68 "log/slog"
9+ "math"
10+ "slices"
711 "time"
12+ "unicode"
13+ "unicode/utf8"
14+
15+ "github.com/bnema/vev/internal/domain"
816 )
917
1018 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.
@@ -47,8 +55,30 @@ type RemoteCatalogClient interface {
4755 List(ctx context.Context, target string) (RemoteCatalog, error)
4856 }
4957
58+// RemotePreviewClient fetches one bounded, exact-target viewport without
59+// attaching to or mutating the remote session.
60+type RemotePreviewClient interface {
61+ Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (RemotePreview, error)
62+}
63+
64+const (
65+ // RemoteCatalogSchemaVersion is independent from the IPC protocol. A
66+ // catalogue can be rejected without changing the attachment wire layout.
67+ RemoteCatalogSchemaVersion uint16 = 1
68+
69+ RemoteCatalogMaxHosts = 64
70+ RemoteCatalogMaxSessions = 256
71+ RemoteCatalogMaxTabsPerSess = 128
72+ RemoteCatalogMaxNameBytes = 256
73+ RemoteCatalogMaxDetailBytes = 512
74+ RemoteCatalogMaxIDBytes = 128
75+ RemoteCatalogMaxCatalogBytes = 1 << 20
76+)
77+
5078 // RemoteCatalogCacheEntry is one immutable host catalog snapshot persisted for
51-// immediate remote discovery at daemon startup.
79+// immediate remote discovery at daemon startup. The legacy-shaped Sessions
80+// field is retained as a source-compatible container, but cache writers strip
81+// dynamic Detail and Attention fields before persistence.
5282 type RemoteCatalogCacheEntry struct {
5383 Host string
5484 FetchedAt time.Time
@@ -62,40 +92,330 @@ type RemoteCatalogCache interface {
6292 Store([]RemoteCatalogCacheEntry) error
6393 }
6494
65-// RemoteCatalogSession is one live session in the remote discovery catalog.
66-// State is an explicit string contract (running|stopped|broken), not SessionState.
67-type RemoteCatalogSession struct {
95+// RemoteCatalogTab is one bounded, untrusted tab presentation hint. Detail
96+// and Attention are deliberately omitted from the durable cache.
97+type RemoteCatalogTab struct {
98+ ID string `json:"id"`
99+ Index uint16 `json:"index"`
68100 Name string `json:"name"`
69- State string `json:"state"`
70- Ephemeral bool `json:"ephemeral"`
71- Tabs uint16 `json:"tabs"`
72- Attached bool `json:"attached"`
101+ Detail string `json:"detail,omitempty"`
102+ Attention bool `json:"attention,omitempty"`
103+}
104+
105+// RemoteCatalogSession is one session in the remote discovery catalog. State
106+// is an explicit string contract (running|stopped|broken), not SessionState.
107+//
108+// Tabs is interface-typed only to keep old v0.x callers that supplied a count
109+// source-compatible while the versioned schema carries []RemoteCatalogTab.
110+// New code must use CatalogTabs and never infer routing identity from a count.
111+type RemoteCatalogSession struct {
112+ LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
113+ Name string `json:"name"`
114+ State string `json:"state"`
115+ Ephemeral bool `json:"ephemeral"`
116+ Tabs any `json:"tabs"`
117+ Attached bool `json:"attached"`
118+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
119+ ActiveTabID string `json:"active_tab_id,omitempty"`
120+ Reason string `json:"reason,omitempty"`
73121 }
74122
75-// SaturateUint16 clamps n to the uint16 range.
123+// MarshalJSON omits a zero lifecycle ID for legacy count-only catalogues.
124+// Encoding/json does not apply omitempty to a zero array that implements
125+// encoding.TextMarshaler, so the pointer is made explicit here.
126+func (s RemoteCatalogSession) MarshalJSON() ([]byte, error) {
127+ type envelope struct {
128+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
129+ Name string `json:"name"`
130+ State string `json:"state"`
131+ Ephemeral bool `json:"ephemeral"`
132+ Tabs any `json:"tabs"`
133+ Attached bool `json:"attached"`
134+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
135+ ActiveTabID string `json:"active_tab_id,omitempty"`
136+ Reason string `json:"reason,omitempty"`
137+ }
138+ var lifecycle *domain.SessionLifecycleID
139+ if s.LifecycleID != (domain.SessionLifecycleID{}) {
140+ id := s.LifecycleID
141+ lifecycle = &id
142+ }
143+ return json.Marshal(envelope{LifecycleID: lifecycle, Name: s.Name, State: s.State, Ephemeral: s.Ephemeral, Tabs: s.Tabs, Attached: s.Attached, LastUsedSeq: s.LastUsedSeq, ActiveTabID: s.ActiveTabID, Reason: s.Reason})
144+}
145+
146+// UnmarshalJSON preserves the legacy numeric tab-count representation while
147+// decoding the current array representation into concrete tab values.
148+func (s *RemoteCatalogSession) UnmarshalJSON(data []byte) error {
149+ type envelope struct {
150+ LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
151+ Name string `json:"name"`
152+ State string `json:"state"`
153+ Ephemeral bool `json:"ephemeral"`
154+ Tabs json.RawMessage `json:"tabs"`
155+ Attached bool `json:"attached"`
156+ LastUsedSeq uint64 `json:"last_used_seq"`
157+ ActiveTabID string `json:"active_tab_id"`
158+ Reason string `json:"reason"`
159+ }
160+ var raw envelope
161+ if err := json.Unmarshal(data, &raw); err != nil {
162+ return err
163+ }
164+ *s = RemoteCatalogSession{LifecycleID: raw.LifecycleID, Name: raw.Name, State: raw.State, Ephemeral: raw.Ephemeral, Attached: raw.Attached, LastUsedSeq: raw.LastUsedSeq, ActiveTabID: raw.ActiveTabID, Reason: raw.Reason}
165+ if len(raw.Tabs) == 0 || string(raw.Tabs) == "null" {
166+ s.Tabs = 0
167+ return nil
168+ }
169+ var tabs []RemoteCatalogTab
170+ if err := json.Unmarshal(raw.Tabs, &tabs); err == nil {
171+ s.Tabs = tabs
172+ return nil
173+ }
174+ var count uint16
175+ if err := json.Unmarshal(raw.Tabs, &count); err != nil {
176+ return err
177+ }
178+ s.Tabs = int(count)
179+ return nil
180+}
181+
182+// CatalogTabs returns a copied typed tab list. Legacy count-only entries have
183+// no stable tab identity and therefore return nil.
184+func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab {
185+ tabs, ok := session.Tabs.([]RemoteCatalogTab)
186+ if !ok {
187+ return nil
188+ }
189+ return slices.Clone(tabs)
190+}
191+
192+// CatalogTabCount returns the count represented by either schema form.
193+func CatalogTabCount(session RemoteCatalogSession) int {
194+ switch tabs := session.Tabs.(type) {
195+ case []RemoteCatalogTab:
196+ return len(tabs)
197+ case uint16:
198+ return int(tabs)
199+ case uint32:
200+ return int(tabs)
201+ case int:
202+ return tabs
203+ case float64:
204+ return int(tabs)
205+ default:
206+ return 0
207+ }
208+}
209+
210+// SaturateUint16 clamps n to the uint16 range for legacy command output only.
76211 func SaturateUint16(n int) uint16 {
77212 if n <= 0 {
78213 return 0
79214 }
80215 if n >= 1<<16 {
81- return ^uint16(0)
216+ return math.MaxUint16
82217 }
83218 return uint16(n)
84219 }
85220
86-// RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.
221+// RemoteCatalog is the independently versioned JSON envelope returned by
222+// remote-catalog --json. ProtocolVersion remains for legacy peers; new peers
223+// must also provide SchemaVersion.
87224 type RemoteCatalog struct {
88225 ProtocolVersion uint16 `json:"protocol_version"`
226+ SchemaVersion uint16 `json:"schema_version,omitempty"`
89227 Sessions []RemoteCatalogSession `json:"sessions"`
90228 }
91229
92230 // RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
93-// reports a protocol_version that is not equal to ProtocolVersion.
231+// reports an incompatible protocol or catalogue schema version.
94232 type RemoteCatalogVersionMismatchError struct {
95233 Got uint16
96234 Want uint16
235+ Kind string
97236 }
98237
99238 func (e *RemoteCatalogVersionMismatchError) Error() string {
100- return fmt.Sprintf("remote catalog: protocol version mismatch: got %d, want %d", e.Got, e.Want)
239+ kind := e.Kind
240+ if kind == "" {
241+ kind = "protocol"
242+ }
243+ return fmt.Sprintf("remote catalog: %s version mismatch: got %d, want %d", kind, e.Got, e.Want)
244+}
245+
246+var (
247+ ErrInvalidRemoteCatalog = errors.New("ports: invalid remote catalog")
248+ ErrRemoteCatalogTooLarge = errors.New("ports: remote catalog exceeds size limit")
249+ ErrRemoteCatalogUnknownState = errors.New("ports: remote catalog has unknown state")
250+ ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")
251+)
252+
253+// ValidateRemoteCatalog applies all limits before a catalogue is cached or
254+// rendered. It is deliberately strict for the current schema; zero schema is
255+// the read-only compatibility form used by pre-parity v0.x peers.
256+func ValidateRemoteCatalog(c RemoteCatalog) error {
257+ if c.ProtocolVersion != ProtocolVersion {
258+ return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"}
259+ }
260+ if c.SchemaVersion != 0 && c.SchemaVersion != RemoteCatalogSchemaVersion {
261+ return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
262+ }
263+ if len(c.Sessions) > RemoteCatalogMaxSessions {
264+ return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge)
265+ }
266+ seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
267+ bytes := 0
268+ for _, session := range c.Sessions {
269+ if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) {
270+ return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
271+ }
272+ if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
273+ return fmt.Errorf("%w: session text too long", ErrRemoteCatalogTooLarge)
274+ }
275+ if err := domain.ValidateSessionName(session.Name); err != nil {
276+ return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
277+ }
278+ if !validRemoteCatalogState(session.State) {
279+ return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
280+ }
281+ if session.ActiveTabID != "" {
282+ if err := domain.ValidateTabStableID(domain.TabStableID(session.ActiveTabID)); err != nil {
283+ return fmt.Errorf("%w: invalid active tab ID", ErrInvalidRemoteCatalog)
284+ }
285+ }
286+ if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {
287+ return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
288+ }
289+ if c.SchemaVersion != 0 {
290+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
291+ return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
292+ }
293+ if prior, exists := seen[session.LifecycleID]; exists {
294+ return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
295+ }
296+ seen[session.LifecycleID] = session.Name
297+ }
298+ tabs := CatalogTabs(session)
299+ if len(tabs) == 0 {
300+ if c.SchemaVersion != 0 {
301+ if session.ActiveTabID != "" {
302+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
303+ }
304+ if session.State == "running" && session.Tabs == nil {
305+ return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
306+ }
307+ }
308+ } else {
309+ if len(tabs) > RemoteCatalogMaxTabsPerSess {
310+ return fmt.Errorf("%w: too many tabs", ErrRemoteCatalogTooLarge)
311+ }
312+ tabIDs := make(map[string]struct{}, len(tabs))
313+ for i, tab := range tabs {
314+ if !validRemoteCatalogText(tab.ID) || !validRemoteCatalogText(tab.Name) || !validRemoteCatalogText(tab.Detail) {
315+ return fmt.Errorf("%w: invalid tab text", ErrInvalidRemoteCatalog)
316+ }
317+ if len(tab.ID) > RemoteCatalogMaxIDBytes || len(tab.Name) > RemoteCatalogMaxNameBytes || len(tab.Detail) > RemoteCatalogMaxDetailBytes {
318+ return fmt.Errorf("%w: tab text too long", ErrRemoteCatalogTooLarge)
319+ }
320+ if tab.Index != uint16(i) {
321+ return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
322+ }
323+ if session.State == "running" && tab.ID == "" {
324+ return fmt.Errorf("%w: running tab has zero ID", ErrInvalidRemoteCatalog)
325+ }
326+ if tab.ID != "" {
327+ if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
328+ return fmt.Errorf("%w: invalid tab ID", ErrInvalidRemoteCatalog)
329+ }
330+ if _, duplicate := tabIDs[tab.ID]; duplicate {
331+ return fmt.Errorf("%w: duplicate tab ID", ErrInvalidRemoteCatalog)
332+ }
333+ tabIDs[tab.ID] = struct{}{}
334+ }
335+ bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
336+ }
337+ if session.ActiveTabID != "" {
338+ if session.State != "running" {
339+ return fmt.Errorf("%w: stopped or broken session has an active tab", ErrInvalidRemoteCatalog)
340+ }
341+ if _, ok := tabIDs[session.ActiveTabID]; !ok {
342+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
343+ }
344+ }
345+ }
346+ bytes += len(session.Name) + len(session.State) + len(session.Reason)
347+ }
348+ if bytes > RemoteCatalogMaxCatalogBytes {
349+ return ErrRemoteCatalogTooLarge
350+ }
351+ return nil
352+}
353+
354+// ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it
355+// is loaded into picker state or written to disk. A cache may contain legacy
356+// count-only sessions, but any complete typed snapshot must carry lifecycle
357+// identity and the same tab invariants as a live catalog.
358+func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
359+ if len(entries) > RemoteCatalogMaxHosts {
360+ return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
361+ }
362+ seenHosts := make(map[string]struct{}, len(entries))
363+ for _, entry := range entries {
364+ if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil || len(entry.Host) > RemoteCatalogMaxNameBytes {
365+ return fmt.Errorf("%w: invalid host", ErrInvalidRemoteCatalog)
366+ }
367+ if entry.FetchedAt.IsZero() || entry.FetchedAt.UnixNano() <= 0 {
368+ return fmt.Errorf("%w: invalid fetched time", ErrInvalidRemoteCatalog)
369+ }
370+ if _, exists := seenHosts[entry.Host]; exists {
371+ return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
372+ }
373+ seenHosts[entry.Host] = struct{}{}
374+ complete := false
375+ for _, session := range entry.Sessions {
376+ if session.LifecycleID != (domain.SessionLifecycleID{}) || CatalogTabs(session) != nil {
377+ complete = true
378+ break
379+ }
380+ }
381+ schema := uint16(0)
382+ if complete {
383+ schema = RemoteCatalogSchemaVersion
384+ }
385+ catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: schema, Sessions: entry.Sessions}
386+ if err := ValidateRemoteCatalog(catalog); err != nil {
387+ return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
388+ }
389+ }
390+ return nil
391+}
392+
393+func validRemoteCatalogText(value string) bool {
394+ if !utf8.ValidString(value) {
395+ return false
396+ }
397+ for _, r := range value {
398+ if unicode.IsControl(r) {
399+ return false
400+ }
401+ }
402+ return true
403+}
404+
405+func validRemoteCatalogState(state string) bool {
406+ switch state {
407+ case "running", "stopped", "broken":
408+ return true
409+ default:
410+ return false
411+ }
412+}
413+
414+func validRemoteCatalogReason(reason string) bool {
415+ switch reason {
416+ case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_stopped", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
417+ return true
418+ default:
419+ return false
420+ }
101421 }
A internal/ports/remote_preview.go +102 -0
@@ -0,0 +1,102 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "math"
6+ "unicode"
7+ "unicode/utf8"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const (
14+ RemotePreviewMaxWidth = 256
15+ RemotePreviewMaxHeight = 128
16+ RemotePreviewMaxCells = RemotePreviewMaxWidth * RemotePreviewMaxHeight
17+ RemotePreviewMaxBytes = 1 << 20
18+)
19+
20+var (
21+ ErrInvalidRemotePreviewRequest = errors.New("ports: invalid remote preview request")
22+ ErrInvalidRemotePreview = errors.New("ports: invalid remote preview")
23+ ErrRemotePreviewTooLarge = errors.New("ports: remote preview exceeds size limit")
24+ ErrRemotePreviewTimeout = errors.New("ports: remote preview command timed out")
25+ ErrRemotePreviewUnsupportedStyle = errors.New("ports: remote preview has unsupported style")
26+)
27+
28+func ValidateRemotePreviewRequest(request RemotePreviewRequest) error {
29+ if request.Version != RemotePreviewSchemaVersion || request.Target.Stopped || request.Target.Validate() != nil {
30+ return ErrInvalidRemotePreviewRequest
31+ }
32+ // These fields are encoded with uint16 byte lengths. Reject an oversized
33+ // nested route before putString truncates it on the wire.
34+ if len(request.Target.Endpoint) > math.MaxUint16 ||
35+ len(request.Target.DisplayOrigin) > math.MaxUint16 ||
36+ len(request.Target.SessionName) > math.MaxUint16 ||
37+ len(request.Target.LiveTabID) > math.MaxUint16 {
38+ return ErrInvalidRemotePreviewRequest
39+ }
40+ if request.Width == 0 || request.Height == 0 || int(request.Width) > RemotePreviewMaxWidth || int(request.Height) > RemotePreviewMaxHeight {
41+ return ErrInvalidRemotePreviewRequest
42+ }
43+ return nil
44+}
45+
46+func ValidateRemotePreview(preview RemotePreview) error {
47+ if preview.Version != RemotePreviewSchemaVersion || preview.Status > RemotePreviewTooLarge {
48+ return ErrInvalidRemotePreview
49+ }
50+ if preview.Status != RemotePreviewOK {
51+ if preview.Width != 0 || preview.Height != 0 || len(preview.Cells) != 0 {
52+ return ErrInvalidRemotePreview
53+ }
54+ return nil
55+ }
56+ if preview.LifecycleID == (domain.SessionLifecycleID{}) || domain.ValidateTabStableID(preview.TabID) != nil || preview.Revision == 0 {
57+ return ErrInvalidRemotePreview
58+ }
59+ if preview.Width == 0 || preview.Height == 0 || int(preview.Width) > RemotePreviewMaxWidth || int(preview.Height) > RemotePreviewMaxHeight {
60+ return ErrInvalidRemotePreview
61+ }
62+ want := int(preview.Width) * int(preview.Height)
63+ if want > RemotePreviewMaxCells || len(preview.Cells) != want {
64+ return ErrRemotePreviewTooLarge
65+ }
66+ width := int(preview.Width)
67+ for i, cell := range preview.Cells {
68+ if !utf8.ValidRune(cell.Rune) || (cell.Rune != 0 && unicode.IsControl(cell.Rune)) || !validRemotePreviewStyle(cell.Style) {
69+ return ErrInvalidRemotePreview
70+ }
71+ rowStart := (i / width) * width
72+ rowEnd := rowStart + width
73+ if cell.Continuation {
74+ if cell.Rune != 0 || i == rowStart || preview.Cells[i-1].Continuation || renderer.RuneWidth(preview.Cells[i-1].Rune) != 2 {
75+ return ErrInvalidRemotePreview
76+ }
77+ } else if renderer.RuneWidth(cell.Rune) == 2 && (i+1 >= rowEnd || !preview.Cells[i+1].Continuation) {
78+ return ErrInvalidRemotePreview
79+ }
80+ }
81+ return nil
82+}
83+
84+func validRemotePreviewStyle(style renderer.Style) bool {
85+ return style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) == 0 &&
86+ style.UnderlineStyle <= renderer.UnderlineDashed &&
87+ style.Foreground >= math.MinInt16 && style.Foreground <= math.MaxInt16 &&
88+ style.Background >= math.MinInt16 && style.Background <= math.MaxInt16 &&
89+ style.UnderlineColor >= math.MinInt16 && style.UnderlineColor <= math.MaxInt16
90+}
91+
92+func (p RemotePreview) FrameRows() [][]renderer.Cell {
93+ if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) {
94+ return nil
95+ }
96+ rows := make([][]renderer.Cell, p.Height)
97+ width := int(p.Width)
98+ for y := range rows {
99+ rows[y] = append([]renderer.Cell(nil), p.Cells[y*width:(y+1)*width]...)
100+ }
101+ return rows
102+}
A internal/ports/remote_preview_hardening_test.go +214 -0
@@ -0,0 +1,214 @@
1+package ports
2+
3+import (
4+ "bytes"
5+ "math"
6+ "strings"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+const (
14+ remotePreviewGoldenStatusOffset = 2
15+ remotePreviewGoldenCellsOffset = 2 + 1 + 16 + 2 + len("tab-1") + 8 + 2 + 2 + 4
16+ remotePreviewGoldenCellFlagsOffset = remotePreviewGoldenCellsOffset + 4
17+ remotePreviewGoldenStyleFlagsOffset = remotePreviewGoldenCellsOffset + 5
18+ remotePreviewGoldenStyleAttrsOffset = remotePreviewGoldenCellsOffset + 6
19+ remotePreviewGoldenSecondCellOffset = remotePreviewGoldenCellsOffset + previewCellWireSize
20+ remotePreviewGoldenUnderlineStyleOffset = remotePreviewGoldenSecondCellOffset + 12
21+)
22+
23+func remotePreviewGoldenPayload() []byte {
24+ return []byte{
25+ 0x00, 0x01, 0x00,
26+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
27+ 0x00, 0x05, 't', 'a', 'b', '-', '1',
28+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
29+ 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02,
30+ 0x00, 0x00, 0x00, 0x6f, 0x00,
31+ 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff,
32+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
33+ 0x00, 0x00, 0x00, 0x6b, 0x00,
34+ 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff,
35+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
36+ }
37+}
38+
39+func TestRemotePreviewCodecGoldenRoundTrip(t *testing.T) {
40+ golden := remotePreviewGoldenPayload()
41+ got, err := UnmarshalRemotePreview(golden)
42+ if err != nil {
43+ t.Fatalf("UnmarshalRemotePreview() error = %v", err)
44+ }
45+ if got.Version != RemotePreviewSchemaVersion || got.Status != RemotePreviewOK {
46+ t.Fatalf("header = (%d, %d), want (%d, %d)", got.Version, got.Status, RemotePreviewSchemaVersion, RemotePreviewOK)
47+ }
48+ var wantLifecycle domain.SessionLifecycleID
49+ for i := range wantLifecycle {
50+ wantLifecycle[i] = byte(i + 1)
51+ }
52+ if got.LifecycleID != wantLifecycle || got.TabID != "tab-1" || got.Revision != 1 || got.Width != 2 || got.Height != 1 {
53+ t.Fatalf("metadata = %#v, want lifecycle/tab/revision/size from golden", got)
54+ }
55+ if len(got.Cells) != 2 || got.Cells[0].Rune != 'o' || got.Cells[1].Rune != 'k' {
56+ t.Fatalf("cells = %#v, want [o k]", got.Cells)
57+ }
58+ if !bytes.Equal(MarshalRemotePreview(got), golden) {
59+ t.Fatalf("marshal changed golden payload: %x", MarshalRemotePreview(got))
60+ }
61+}
62+
63+func TestRemotePreviewCodecRejectsInvalidStatusFlagsAndRanges(t *testing.T) {
64+ golden := remotePreviewGoldenPayload()
65+ tests := []struct {
66+ name string
67+ mutate func([]byte)
68+ }{
69+ {name: "status", mutate: func(b []byte) { b[remotePreviewGoldenStatusOffset] = 0xff }},
70+ {name: "cell flags", mutate: func(b []byte) { b[remotePreviewGoldenCellFlagsOffset] = 0x02 }},
71+ {name: "style flags", mutate: func(b []byte) { b[remotePreviewGoldenStyleFlagsOffset] = 0x80 }},
72+ {name: "style attrs", mutate: func(b []byte) { b[remotePreviewGoldenStyleAttrsOffset] = 0x80 }},
73+ {name: "underline range", mutate: func(b []byte) { b[remotePreviewGoldenUnderlineStyleOffset] = byte(renderer.UnderlineDashed + 1) }},
74+ }
75+ for _, test := range tests {
76+ t.Run(test.name, func(t *testing.T) {
77+ payload := append([]byte(nil), golden...)
78+ test.mutate(payload)
79+ if _, err := UnmarshalRemotePreview(payload); err == nil {
80+ t.Fatal("malformed payload decoded successfully")
81+ }
82+ })
83+ }
84+}
85+
86+func TestRemotePreviewCodecRejectsWideCellBoundaryAndInvalidIDs(t *testing.T) {
87+ var lifecycle domain.SessionLifecycleID
88+ lifecycle[0] = 1
89+ valid := RemotePreview{
90+ Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK,
91+ LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1,
92+ Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true}},
93+ }
94+ if MarshalRemotePreview(valid) == nil {
95+ t.Fatal("valid wide-cell preview did not marshal")
96+ }
97+ for _, malformed := range []RemotePreview{
98+ {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 1, Height: 1, Cells: []renderer.Cell{{Rune: '界'}}},
99+ {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1, Cells: []renderer.Cell{{Continuation: true}, {Rune: 'x'}}},
100+ {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1, Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true, Rune: 'x'}}},
101+ {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 2, Cells: []renderer.Cell{{Rune: 'x'}, {Rune: '界'}, {Continuation: true}, {Rune: 'y'}}},
102+ } {
103+ if MarshalRemotePreview(malformed) != nil {
104+ t.Fatalf("malformed wide-cell preview marshaled: %#v", malformed.Cells)
105+ }
106+ }
107+ invalidLifecycle := valid
108+ invalidLifecycle.LifecycleID = domain.SessionLifecycleID{}
109+ if MarshalRemotePreview(invalidLifecycle) != nil {
110+ t.Fatal("zero lifecycle ID marshaled")
111+ }
112+ invalidTab := valid
113+ invalidTab.TabID = "tab id"
114+ if MarshalRemotePreview(invalidTab) != nil {
115+ t.Fatal("invalid tab ID marshaled")
116+ }
117+}
118+
119+func TestRemotePreviewRejectsControlRunes(t *testing.T) {
120+ valid := RemotePreview{
121+ Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK,
122+ LifecycleID: previewTargetForTest().LifecycleID, TabID: "tab-1", Revision: 1, Width: 1, Height: 1,
123+ Cells: []renderer.Cell{{Rune: 'x'}},
124+ }
125+ for _, control := range []rune{'\x01', '\x1b', '\x7f', '\u0085'} {
126+ preview := valid
127+ preview.Cells = []renderer.Cell{{Rune: control}}
128+ if err := ValidateRemotePreview(preview); err != ErrInvalidRemotePreview {
129+ t.Fatalf("control rune %U: ValidateRemotePreview() error = %v, want %v", control, err, ErrInvalidRemotePreview)
130+ }
131+ if MarshalRemotePreview(preview) != nil {
132+ t.Fatalf("control rune %U was marshaled", control)
133+ }
134+ }
135+}
136+
137+func TestRemotePreviewRequestRejectsOversizedNestedRouteData(t *testing.T) {
138+ target := previewTargetForTest()
139+ target.Endpoint = strings.Repeat("h", math.MaxUint16+1)
140+ request := RemotePreviewRequest{Version: RemotePreviewSchemaVersion, Target: target, Width: 20, Height: 5}
141+ if MarshalRemotePreviewRequest(request) != nil {
142+ t.Fatal("oversized endpoint was truncated and marshaled")
143+ }
144+}
145+
146+func TestRemoteTargetWireRejectsInvalidSelectorCombinations(t *testing.T) {
147+ cases := []struct {
148+ name string
149+ mutate func(*domain.RemoteSessionTarget)
150+ }{
151+ {name: "zero lifecycle", mutate: func(target *domain.RemoteSessionTarget) { target.LifecycleID = domain.SessionLifecycleID{} }},
152+ {name: "live and stopped selector", mutate: func(target *domain.RemoteSessionTarget) {
153+ target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 1)
154+ }},
155+ {name: "stopped with live ID", mutate: func(target *domain.RemoteSessionTarget) {
156+ target.Stopped = true
157+ target.LiveTabID = "tab-1"
158+ target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 1)
159+ }},
160+ {name: "stable selector with ordinal fields", mutate: func(target *domain.RemoteSessionTarget) {
161+ target.Stopped = true
162+ target.LiveTabID = ""
163+ target.StoppedTab = domain.TabSelector{Kind: domain.TabSelectorByStableID, StableID: "tab-1", Ordinal: 1}
164+ }},
165+ {name: "ordinal selector with zero count", mutate: func(target *domain.RemoteSessionTarget) {
166+ target.Stopped = true
167+ target.LiveTabID = ""
168+ target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 0)
169+ }},
170+ {name: "oversized tab name", mutate: func(target *domain.RemoteSessionTarget) {
171+ target.Stopped = true
172+ target.LiveTabID = ""
173+ target.StoppedTab = domain.NewOrdinalTabSelector(0, strings.Repeat("n", 257), 1)
174+ }},
175+ }
176+ for _, test := range cases {
177+ t.Run(test.name, func(t *testing.T) {
178+ target := *testRemoteTarget(false)
179+ test.mutate(&target)
180+ message := AttachTarget{Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach, RemoteTarget: &target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}
181+ if MarshalAttachTarget(message) != nil {
182+ t.Fatal("invalid target was marshaled")
183+ }
184+ })
185+ }
186+}
187+
188+func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
189+ target := testRemoteTarget(false)
190+ got := MarshalAttachTarget(AttachTarget{
191+ Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach,
192+ RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
193+ })
194+ want := []byte{
195+ 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
196+ 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach,
197+ 0x01, 0x01,
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',
200+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
201+ 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
202+ 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
203+ }
204+ if !bytes.Equal(got, want) {
205+ t.Fatalf("rich target bytes = %x, want %x", got, want)
206+ }
207+ decoded, err := UnmarshalAttachTarget(got)
208+ if err != nil {
209+ t.Fatalf("UnmarshalAttachTarget() error = %v", err)
210+ }
211+ if decoded.RemoteTarget == nil || *decoded.RemoteTarget != *target || decoded.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
212+ t.Fatalf("decoded target = %#v, policy = %d", decoded.RemoteTarget, decoded.EnvironmentPolicy)
213+ }
214+}
A internal/ports/remote_preview_test.go +70 -0
@@ -0,0 +1,70 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func previewTargetForTest() domain.RemoteSessionTarget {
11+ var lifecycle domain.SessionLifecycleID
12+ lifecycle[0] = 9
13+ return domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
14+}
15+
16+func TestRemotePreviewCodecPreservesWideStyledCells(t *testing.T) {
17+ style := renderer.DefaultStyle()
18+ style.Bold = true
19+ style.Attrs = renderer.AttrUnderline
20+ style.HasForegroundRGB = true
21+ style.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
22+ preview := RemotePreview{
23+ Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK,
24+ LifecycleID: previewTargetForTest().LifecycleID, TabID: "tab-1", Revision: 4, Width: 3, Height: 1,
25+ Cells: []renderer.Cell{{Rune: '界', Style: style}, {Continuation: true, Style: style}, {Rune: 'x', Style: style}},
26+ }
27+ payload := MarshalRemotePreview(preview)
28+ if payload == nil {
29+ t.Fatal("MarshalRemotePreview returned nil")
30+ }
31+ got, err := UnmarshalRemotePreview(payload)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if len(got.Cells) != len(preview.Cells) || !got.Cells[0].Equal(preview.Cells[0]) || !got.Cells[1].Equal(preview.Cells[1]) {
36+ t.Fatalf("cells = %#v, want %#v", got.Cells, preview.Cells)
37+ }
38+}
39+
40+func TestRemotePreviewCodecRejectsMalformedBoundsAndGarbage(t *testing.T) {
41+ preview := RemotePreview{Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: previewTargetForTest().LifecycleID, TabID: "tab-1", Revision: 1, Width: 1, Height: 1, Cells: []renderer.Cell{{Rune: 'x'}}}
42+ payload := MarshalRemotePreview(preview)
43+ for i := 0; i < len(payload); i++ {
44+ if _, err := UnmarshalRemotePreview(payload[:i]); err == nil {
45+ t.Fatalf("prefix %d unexpectedly decoded", i)
46+ }
47+ }
48+ if _, err := UnmarshalRemotePreview(append(append([]byte(nil), payload...), 1)); err == nil {
49+ t.Fatal("trailing garbage unexpectedly decoded")
50+ }
51+ bad := preview
52+ bad.Width = RemotePreviewMaxWidth + 1
53+ if MarshalRemotePreview(bad) != nil {
54+ t.Fatal("oversized preview marshaled")
55+ }
56+}
57+
58+func TestRemotePreviewRequestRejectsStoppedAndInvalidTargets(t *testing.T) {
59+ target := previewTargetForTest()
60+ request := RemotePreviewRequest{Version: RemotePreviewSchemaVersion, Target: target, Width: 20, Height: 5}
61+ if MarshalRemotePreviewRequest(request) == nil {
62+ t.Fatal("valid preview request did not marshal")
63+ }
64+ target.Stopped = true
65+ target.LiveTabID = ""
66+ target.StoppedTab = domain.NewOrdinalTabSelector(0, "", 1)
67+ if MarshalRemotePreviewRequest(RemotePreviewRequest{Version: RemotePreviewSchemaVersion, Target: target, Width: 20, Height: 5}) != nil {
68+ t.Fatal("stopped preview request marshaled")
69+ }
70+}
A internal/ports/remote_target_wire_test.go +114 -0
@@ -0,0 +1,114 @@
1+package ports
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+func testRemoteTarget(stopped bool) *domain.RemoteSessionTarget {
11+ var lifecycle domain.SessionLifecycleID
12+ for i := range lifecycle {
13+ lifecycle[i] = byte(i + 1)
14+ }
15+ target := &domain.RemoteSessionTarget{
16+ Endpoint: "build@mule:2222",
17+ DisplayOrigin: "mule:2222",
18+ LifecycleID: lifecycle,
19+ SessionName: "work",
20+ Stopped: stopped,
21+ }
22+ if stopped {
23+ target.StoppedTab = domain.NewOrdinalTabSelector(2, "", 4)
24+ } else {
25+ target.LiveTabID = "tab-3"
26+ }
27+ return target
28+}
29+
30+func TestRemoteTargetWireRoundTripPreservesExactSelector(t *testing.T) {
31+ for _, stopped := range []bool{false, true} {
32+ name := "live"
33+ if stopped {
34+ name = "stopped"
35+ }
36+ t.Run(name, func(t *testing.T) {
37+ target := testRemoteTarget(stopped)
38+ msg := Hello{
39+ Version: ProtocolVersion,
40+ Intent: IntentAttach,
41+ Name: target.SessionName,
42+ Size: domain.Size{Cols: 80, Rows: 24},
43+ RemoteTarget: target,
44+ EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
45+ }
46+ payload := MarshalHello(msg)
47+ if payload == nil {
48+ t.Fatal("MarshalHello returned nil")
49+ }
50+ got, err := UnmarshalHello(payload)
51+ if err != nil {
52+ t.Fatal(err)
53+ }
54+ if got.RemoteTarget == nil || *got.RemoteTarget != *target {
55+ t.Fatalf("remote target = %#v, want %#v", got.RemoteTarget, target)
56+ }
57+ if got.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
58+ t.Fatalf("environment policy = %d, want daemon-owned", got.EnvironmentPolicy)
59+ }
60+
61+ attachPayload := MarshalAttachTarget(AttachTarget{
62+ Endpoint: target.Endpoint,
63+ Session: target.SessionName,
64+ Intent: IntentAttach,
65+ RemoteTarget: target,
66+ EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
67+ })
68+ if attachPayload == nil {
69+ t.Fatal("MarshalAttachTarget returned nil")
70+ }
71+ attach, err := UnmarshalAttachTarget(attachPayload)
72+ if err != nil {
73+ t.Fatal(err)
74+ }
75+ if attach.RemoteTarget == nil || *attach.RemoteTarget != *target {
76+ t.Fatalf("attach target = %#v, want %#v", attach.RemoteTarget, target)
77+ }
78+ })
79+ }
80+}
81+
82+func TestRemoteTargetWireRejectsTruncationAndTrailingData(t *testing.T) {
83+ target := testRemoteTarget(true)
84+ payload := MarshalAttachTarget(AttachTarget{
85+ Endpoint: target.Endpoint,
86+ Session: target.SessionName,
87+ Intent: IntentAttach,
88+ RemoteTarget: target,
89+ EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
90+ })
91+ legacyLen := len(MarshalAttachTarget(AttachTarget{Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach}))
92+ for i := 0; i < len(payload); i++ {
93+ if i == legacyLen {
94+ // The extension is optional for old peers; the complete legacy
95+ // prefix is therefore a valid count-only target by design.
96+ continue
97+ }
98+ if _, err := UnmarshalAttachTarget(payload[:i]); err == nil {
99+ t.Fatalf("prefix %d unexpectedly decoded", i)
100+ }
101+ }
102+ trailing := append(append([]byte(nil), payload...), 0xff)
103+ if _, err := UnmarshalAttachTarget(trailing); err == nil {
104+ t.Fatal("trailing garbage unexpectedly decoded")
105+ }
106+}
107+
108+func TestLegacyTargetWireBytesRemainUnchanged(t *testing.T) {
109+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach}
110+ got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
111+ if !bytes.Equal(got, want) {
112+ t.Fatalf("legacy target bytes = %x, want %x", got, want)
113+ }
114+}
M internal/ports/wire.go +541 -7
@@ -65,6 +65,23 @@ const (
6565 IntentResume uint8 = 3
6666 )
6767
68+// EnvironmentPolicy controls which side owns the environment used for future
69+// PTY children. Direct CLI attaches keep the historical client-owned policy;
70+// picker handoffs use daemon ownership so a local environment cannot leak into
71+// a remote daemon.
72+type EnvironmentPolicy uint8
73+
74+const (
75+ EnvironmentPolicyClientOwned EnvironmentPolicy = iota
76+ EnvironmentPolicyDaemonOwned
77+)
78+
79+func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
80+ return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
81+}
82+
83+const remoteTargetWireVersion uint8 = 1
84+
6885 // Capability bits advertised in Welcome.
6986 const (
7087 CapabilityResume uint32 = 1 << 0
@@ -112,6 +129,11 @@ type Hello struct {
112129 // Env is the complete exported client environment for future PTY children.
113130 // Entries are opaque strings so their ordering and contents are preserved.
114131 Env []string
132+ // RemoteTarget is present only for a picker-selected remote attach. It is
133+ // carried through reconnects and is validated by the owning daemon before
134+ // it creates or publishes any session resources.
135+ RemoteTarget *domain.RemoteSessionTarget
136+ EnvironmentPolicy EnvironmentPolicy
115137 }
116138
117139 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -223,12 +245,15 @@ type Output struct {
223245 Data []byte
224246 }
225247
226-// AttachTarget identifies the endpoint and session selected for a new
227-// attachment. It deliberately carries no viewport, resume, or session state.
248+// AttachTarget identifies the endpoint and exact session/tab selected for a
249+// new attachment. Endpoint and Session remain the compatibility route fields;
250+// RemoteTarget is the authoritative picker identity when non-nil.
228251 type AttachTarget struct {
229- Endpoint string
230- Session string
231- Intent uint8
252+ Endpoint string
253+ Session string
254+ Intent uint8
255+ RemoteTarget *domain.RemoteSessionTarget
256+ EnvironmentPolicy EnvironmentPolicy
232257 }
233258
234259 // Detached tells a client it has been disconnected from its session and why.
@@ -343,6 +368,175 @@ func (w *payloadWriter) putLongBytes(b []byte) {
343368 w.b = append(w.b, b...)
344369 }
345370
371+func marshalRemoteTargetExtension(w *payloadWriter, target *domain.RemoteSessionTarget, policy EnvironmentPolicy) {
372+ w.putUint8(remoteTargetWireVersion)
373+ if target == nil {
374+ w.putBool(false)
375+ } else {
376+ w.putBool(true)
377+ w.putString(target.Endpoint)
378+ w.putString(target.DisplayOrigin)
379+ w.putBytes(target.LifecycleID[:])
380+ w.putString(target.SessionName)
381+ w.putBool(target.Stopped)
382+ w.putString(string(target.LiveTabID))
383+ w.putUint8(uint8(target.StoppedTab.Kind))
384+ switch target.StoppedTab.Kind {
385+ case domain.TabSelectorByStableID:
386+ w.putString(string(target.StoppedTab.StableID))
387+ case domain.TabSelectorByOrdinal:
388+ w.putUint16(target.StoppedTab.Ordinal)
389+ w.putString(target.StoppedTab.RawName)
390+ w.putUint16(target.StoppedTab.ExpectedCount)
391+ }
392+ }
393+ w.putUint8(uint8(policy))
394+}
395+
396+func skipRemoteTargetExtension(r *payloadReader) error {
397+ version, err := r.getUint8()
398+ if err != nil {
399+ return err
400+ }
401+ if version != remoteTargetWireVersion {
402+ return errInvalidEnum
403+ }
404+ present, err := r.getBool()
405+ if err != nil {
406+ return err
407+ }
408+ if present {
409+ if err := r.skipString(); err != nil {
410+ return err
411+ }
412+ if err := r.skipString(); err != nil {
413+ return err
414+ }
415+ if _, err := r.getBytes(16); err != nil {
416+ return err
417+ }
418+ if err := r.skipString(); err != nil {
419+ return err
420+ }
421+ if _, err := r.getBool(); err != nil {
422+ return err
423+ }
424+ if err := r.skipString(); err != nil {
425+ return err
426+ }
427+ kind, err := r.getUint8()
428+ if err != nil {
429+ return err
430+ }
431+ switch domain.TabSelectorKind(kind) {
432+ case domain.TabSelectorByStableID:
433+ if err := r.skipString(); err != nil {
434+ return err
435+ }
436+ case domain.TabSelectorByOrdinal:
437+ if _, err := r.getUint16(); err != nil {
438+ return err
439+ }
440+ if err := r.skipString(); err != nil {
441+ return err
442+ }
443+ if _, err := r.getUint16(); err != nil {
444+ return err
445+ }
446+ case 0:
447+ default:
448+ return errInvalidEnum
449+ }
450+ }
451+ policy, err := r.getUint8()
452+ if err != nil {
453+ return err
454+ }
455+ if !validEnvironmentPolicy(EnvironmentPolicy(policy)) {
456+ return errInvalidEnum
457+ }
458+ return nil
459+}
460+
461+func unmarshalRemoteTarget(r *payloadReader) (*domain.RemoteSessionTarget, error) {
462+ present, err := r.getBool()
463+ if err != nil {
464+ return nil, err
465+ }
466+ if !present {
467+ return nil, nil
468+ }
469+ target := &domain.RemoteSessionTarget{}
470+ if target.Endpoint, err = r.getString(); err != nil {
471+ return nil, err
472+ }
473+ if target.DisplayOrigin, err = r.getString(); err != nil {
474+ return nil, err
475+ }
476+ id, err := r.getBytes(16)
477+ if err != nil {
478+ return nil, err
479+ }
480+ copy(target.LifecycleID[:], id)
481+ if target.SessionName, err = r.getString(); err != nil {
482+ return nil, err
483+ }
484+ if target.Stopped, err = r.getBool(); err != nil {
485+ return nil, err
486+ }
487+ liveID, err := r.getString()
488+ if err != nil {
489+ return nil, err
490+ }
491+ target.LiveTabID = domain.TabStableID(liveID)
492+ kind, err := r.getUint8()
493+ if err != nil {
494+ return nil, err
495+ }
496+ target.StoppedTab.Kind = domain.TabSelectorKind(kind)
497+ switch target.StoppedTab.Kind {
498+ case domain.TabSelectorByStableID:
499+ stableID, err := r.getString()
500+ if err != nil {
501+ return nil, err
502+ }
503+ target.StoppedTab.StableID = domain.TabStableID(stableID)
504+ case domain.TabSelectorByOrdinal:
505+ if target.StoppedTab.Ordinal, err = r.getUint16(); err != nil {
506+ return nil, err
507+ }
508+ if target.StoppedTab.RawName, err = r.getString(); err != nil {
509+ return nil, err
510+ }
511+ if target.StoppedTab.ExpectedCount, err = r.getUint16(); err != nil {
512+ return nil, err
513+ }
514+ case 0:
515+ default:
516+ return nil, errInvalidEnum
517+ }
518+ return target, nil
519+}
520+
521+func unmarshalRemoteTargetExtension(r *payloadReader) (*domain.RemoteSessionTarget, EnvironmentPolicy, error) {
522+ version, err := r.getUint8()
523+ if err != nil {
524+ return nil, 0, err
525+ }
526+ if version != remoteTargetWireVersion {
527+ return nil, 0, errInvalidEnum
528+ }
529+ target, err := unmarshalRemoteTarget(r)
530+ if err != nil {
531+ return nil, 0, err
532+ }
533+ policy, err := r.getUint8()
534+ if err != nil {
535+ return nil, 0, err
536+ }
537+ return target, EnvironmentPolicy(policy), nil
538+}
539+
346540 // payloadReader consumes a message payload field by field in wire order,
347541 // erroring instead of panicking on any short read.
348542 type payloadReader struct {
@@ -501,6 +695,9 @@ func ValidateHello(h Hello) error {
501695 if err := ValidateSize(h.Size); err != nil {
502696 return fmt.Errorf("%w: size", ErrInvalidHello)
503697 }
698+ if !validEnvironmentPolicy(h.EnvironmentPolicy) {
699+ return ErrInvalidHello
700+ }
504701 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
505702 return ErrInvalidHello
506703 }
@@ -509,6 +706,21 @@ func ValidateHello(h Hello) error {
509706 return ErrInvalidHello
510707 }
511708 }
709+ if h.RemoteTarget == nil {
710+ if h.EnvironmentPolicy != EnvironmentPolicyClientOwned {
711+ return ErrInvalidHello
712+ }
713+ return nil
714+ }
715+ if h.Intent != IntentAttach && h.Intent != IntentResume {
716+ return ErrInvalidHello
717+ }
718+ if err := validateWireRemoteTarget(*h.RemoteTarget); err != nil {
719+ return fmt.Errorf("%w: remote target: %v", ErrInvalidHello, err)
720+ }
721+ if h.Name != h.RemoteTarget.SessionName {
722+ return ErrInvalidHello
723+ }
512724 return nil
513725 }
514726
@@ -541,8 +753,8 @@ func ValidateAck(m Ack) error {
541753 return nil
542754 }
543755
544-// ValidateAttachTarget validates only the endpoint, session, and intent
545-// fields carried by the server attach-target message.
756+// ValidateAttachTarget validates route fields and, when present, the exact
757+// picker identity carried by the handoff.
546758 func ValidateAttachTarget(m AttachTarget) error {
547759 if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
548760 return ErrInvalidAttachTarget
@@ -550,11 +762,39 @@ func ValidateAttachTarget(m AttachTarget) error {
550762 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
551763 return ErrInvalidAttachTarget
552764 }
765+ if !validEnvironmentPolicy(m.EnvironmentPolicy) {
766+ return ErrInvalidAttachTarget
767+ }
768+ if m.RemoteTarget == nil {
769+ if m.EnvironmentPolicy != EnvironmentPolicyClientOwned {
770+ return ErrInvalidAttachTarget
771+ }
772+ return nil
773+ }
774+ if m.Intent != IntentAttach && m.Intent != IntentResume {
775+ return ErrInvalidAttachTarget
776+ }
777+ if err := validateWireRemoteTarget(*m.RemoteTarget); err != nil {
778+ return fmt.Errorf("%w: remote target: %v", ErrInvalidAttachTarget, err)
779+ }
780+ if m.Endpoint != m.RemoteTarget.Endpoint || m.Session != m.RemoteTarget.SessionName {
781+ return ErrInvalidAttachTarget
782+ }
553783 return nil
554784 }
555785
786+func validateWireRemoteTarget(target domain.RemoteSessionTarget) error {
787+ if len(target.Endpoint) > math.MaxUint16 || len(target.DisplayOrigin) > math.MaxUint16 || len(target.SessionName) > math.MaxUint16 || len(target.LiveTabID) > math.MaxUint16 || len(target.StoppedTab.RawName) > math.MaxUint16 {
788+ return errors.New("remote target string too long")
789+ }
790+ return target.Validate()
791+}
792+
556793 // MarshalHello encodes h into a Hello message payload.
557794 func MarshalHello(h Hello) []byte {
795+ if err := ValidateHello(h); err != nil {
796+ return nil
797+ }
558798 w := payloadWriter{}
559799 w.putUint16(h.Version)
560800 w.putUint8(h.Intent)
@@ -571,6 +811,9 @@ func MarshalHello(h Hello) []byte {
571811 for _, entry := range h.Env {
572812 w.putLongString(entry)
573813 }
814+ if h.RemoteTarget != nil || h.EnvironmentPolicy != EnvironmentPolicyClientOwned {
815+ marshalRemoteTargetExtension(&w, h.RemoteTarget, h.EnvironmentPolicy)
816+ }
574817 return w.b
575818 }
576819
@@ -626,6 +869,11 @@ func preflightHello(b []byte) error {
626869 return err
627870 }
628871 }
872+ if len(r.b) != 0 {
873+ if err := skipRemoteTargetExtension(&r); err != nil {
874+ return err
875+ }
876+ }
629877 return r.done()
630878 }
631879
@@ -698,6 +946,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
698946 h.Env = append(h.Env, entry)
699947 }
700948 }
949+ if len(r.b) != 0 {
950+ if h.RemoteTarget, h.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {
951+ return Hello{}, err
952+ }
953+ }
701954 if err := r.done(); err != nil {
702955 return Hello{}, err
703956 }
@@ -1141,6 +1394,9 @@ func MarshalAttachTarget(m AttachTarget) []byte {
11411394 w.putString(m.Endpoint)
11421395 w.putString(m.Session)
11431396 w.putUint8(m.Intent)
1397+ if m.RemoteTarget != nil || m.EnvironmentPolicy != EnvironmentPolicyClientOwned {
1398+ marshalRemoteTargetExtension(&w, m.RemoteTarget, m.EnvironmentPolicy)
1399+ }
11441400 return w.b
11451401 }
11461402
@@ -1162,6 +1418,11 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
11621418 if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {
11631419 return AttachTarget{}, ErrInvalidAttachTarget
11641420 }
1421+ if len(probe.b) != 0 {
1422+ if err := skipRemoteTargetExtension(&probe); err != nil {
1423+ return AttachTarget{}, ErrInvalidAttachTarget
1424+ }
1425+ }
11651426 if err := probe.done(); err != nil {
11661427 return AttachTarget{}, ErrInvalidAttachTarget
11671428 }
@@ -1177,6 +1438,11 @@ func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
11771438 if m.Intent, err = r.getUint8(); err != nil {
11781439 return AttachTarget{}, err
11791440 }
1441+ if len(r.b) != 0 {
1442+ if m.RemoteTarget, m.EnvironmentPolicy, err = unmarshalRemoteTargetExtension(&r); err != nil {
1443+ return AttachTarget{}, err
1444+ }
1445+ }
11801446 if err := r.done(); err != nil {
11811447 return AttachTarget{}, err
11821448 }
@@ -1409,3 +1675,271 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
14091675 }
14101676 return Sessions{Sessions: sessions}, nil
14111677 }
1678+
1679+func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) }
1680+
1681+func getInt16(r *payloadReader) (int, error) {
1682+ n, err := r.getUint16()
1683+ return int(int16(n)), err
1684+}
1685+
1686+func putRGB(w *payloadWriter, c renderer.RGB) {
1687+ w.putUint8(c.R)
1688+ w.putUint8(c.G)
1689+ w.putUint8(c.B)
1690+}
1691+
1692+func getRGB(r *payloadReader) (renderer.RGB, error) {
1693+ red, err := r.getUint8()
1694+ if err != nil {
1695+ return renderer.RGB{}, err
1696+ }
1697+ green, err := r.getUint8()
1698+ if err != nil {
1699+ return renderer.RGB{}, err
1700+ }
1701+ blue, err := r.getUint8()
1702+ if err != nil {
1703+ return renderer.RGB{}, err
1704+ }
1705+ return renderer.RGB{R: red, G: green, B: blue}, nil
1706+}
1707+
1708+func putPreviewStyle(w *payloadWriter, s renderer.Style) {
1709+ var flags uint8
1710+ if s.Bold {
1711+ flags |= 1 << 0
1712+ }
1713+ if s.Italic {
1714+ flags |= 1 << 1
1715+ }
1716+ if s.Inverse {
1717+ flags |= 1 << 2
1718+ }
1719+ if s.HasForegroundRGB {
1720+ flags |= 1 << 3
1721+ }
1722+ if s.HasBackgroundRGB {
1723+ flags |= 1 << 4
1724+ }
1725+ if s.HasUnderlineColor {
1726+ flags |= 1 << 5
1727+ }
1728+ if s.HasUnderlineColorRGB {
1729+ flags |= 1 << 6
1730+ }
1731+ w.putUint8(flags)
1732+ w.putUint16(uint16(s.Attrs))
1733+ putInt16(w, s.Foreground)
1734+ putInt16(w, s.Background)
1735+ w.putUint8(uint8(s.UnderlineStyle))
1736+ putInt16(w, s.UnderlineColor)
1737+ putRGB(w, s.ForegroundRGB)
1738+ putRGB(w, s.BackgroundRGB)
1739+ putRGB(w, s.UnderlineColorRGB)
1740+}
1741+
1742+func getPreviewStyle(r *payloadReader) (renderer.Style, error) {
1743+ flags, err := r.getUint8()
1744+ if err != nil {
1745+ return renderer.Style{}, err
1746+ }
1747+ if flags&0x80 != 0 {
1748+ return renderer.Style{}, ErrRemotePreviewUnsupportedStyle
1749+ }
1750+ attrs, err := r.getUint16()
1751+ if err != nil {
1752+ return renderer.Style{}, err
1753+ }
1754+ fg, err := getInt16(r)
1755+ if err != nil {
1756+ return renderer.Style{}, err
1757+ }
1758+ bg, err := getInt16(r)
1759+ if err != nil {
1760+ return renderer.Style{}, err
1761+ }
1762+ ul, err := r.getUint8()
1763+ if err != nil {
1764+ return renderer.Style{}, err
1765+ }
1766+ ulc, err := getInt16(r)
1767+ if err != nil {
1768+ return renderer.Style{}, err
1769+ }
1770+ fgrgb, err := getRGB(r)
1771+ if err != nil {
1772+ return renderer.Style{}, err
1773+ }
1774+ bgrgb, err := getRGB(r)
1775+ if err != nil {
1776+ return renderer.Style{}, err
1777+ }
1778+ ulrgb, err := getRGB(r)
1779+ if err != nil {
1780+ return renderer.Style{}, err
1781+ }
1782+ return renderer.Style{Bold: flags&1 != 0, Italic: flags&(1<<1) != 0, Inverse: flags&(1<<2) != 0,
1783+ HasForegroundRGB: flags&(1<<3) != 0, HasBackgroundRGB: flags&(1<<4) != 0,
1784+ HasUnderlineColor: flags&(1<<5) != 0, HasUnderlineColorRGB: flags&(1<<6) != 0,
1785+ Attrs: renderer.StyleAttrs(attrs), Foreground: fg, Background: bg, UnderlineStyle: renderer.UnderlineStyle(ul), UnderlineColor: ulc,
1786+ ForegroundRGB: fgrgb, BackgroundRGB: bgrgb, UnderlineColorRGB: ulrgb}, nil
1787+}
1788+
1789+func MarshalRemotePreviewRequest(request RemotePreviewRequest) []byte {
1790+ if ValidateRemotePreviewRequest(request) != nil {
1791+ return nil
1792+ }
1793+ w := payloadWriter{}
1794+ w.putUint16(request.Version)
1795+ w.putBytes(request.Target.LifecycleID[:])
1796+ w.putString(request.Target.Endpoint)
1797+ w.putString(request.Target.DisplayOrigin)
1798+ w.putString(request.Target.SessionName)
1799+ w.putString(string(request.Target.LiveTabID))
1800+ w.putUint16(request.Width)
1801+ w.putUint16(request.Height)
1802+ return w.b
1803+}
1804+
1805+func UnmarshalRemotePreviewRequest(data []byte) (RemotePreviewRequest, error) {
1806+ if len(data) > RemotePreviewMaxBytes {
1807+ return RemotePreviewRequest{}, ErrInvalidRemotePreviewRequest
1808+ }
1809+ r := payloadReader{b: data}
1810+ var q RemotePreviewRequest
1811+ var err error
1812+ if q.Version, err = r.getUint16(); err != nil {
1813+ return q, err
1814+ }
1815+ id, err := r.getBytes(16)
1816+ if err != nil {
1817+ return q, err
1818+ }
1819+ copy(q.Target.LifecycleID[:], id)
1820+ if q.Target.Endpoint, err = r.getString(); err != nil {
1821+ return q, err
1822+ }
1823+ if q.Target.DisplayOrigin, err = r.getString(); err != nil {
1824+ return q, err
1825+ }
1826+ if q.Target.SessionName, err = r.getString(); err != nil {
1827+ return q, err
1828+ }
1829+ tab, err := r.getString()
1830+ if err != nil {
1831+ return q, err
1832+ }
1833+ q.Target.LiveTabID = domain.TabStableID(tab)
1834+ if q.Width, err = r.getUint16(); err != nil {
1835+ return q, err
1836+ }
1837+ if q.Height, err = r.getUint16(); err != nil {
1838+ return q, err
1839+ }
1840+ if err := r.done(); err != nil {
1841+ return q, err
1842+ }
1843+ if err := ValidateRemotePreviewRequest(q); err != nil {
1844+ return q, err
1845+ }
1846+ return q, nil
1847+}
1848+
1849+const previewCellWireSize = 4 + 1 + 1 + 2 + 2 + 2 + 1 + 2 + 3 + 3 + 3
1850+
1851+func MarshalRemotePreview(preview RemotePreview) []byte {
1852+ if ValidateRemotePreview(preview) != nil {
1853+ return nil
1854+ }
1855+ w := payloadWriter{}
1856+ w.putUint16(preview.Version)
1857+ w.putUint8(uint8(preview.Status))
1858+ w.putBytes(preview.LifecycleID[:])
1859+ w.putString(string(preview.TabID))
1860+ w.putUint64(preview.Revision)
1861+ w.putUint16(preview.Width)
1862+ w.putUint16(preview.Height)
1863+ w.putUint32(uint32(len(preview.Cells)))
1864+ for _, cell := range preview.Cells {
1865+ var flags uint8
1866+ if cell.Continuation {
1867+ flags = 1
1868+ }
1869+ w.putUint32(uint32(cell.Rune))
1870+ w.putUint8(flags)
1871+ putPreviewStyle(&w, cell.Style)
1872+ }
1873+ return w.b
1874+}
1875+
1876+func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
1877+ if len(data) > RemotePreviewMaxBytes {
1878+ return RemotePreview{}, ErrRemotePreviewTooLarge
1879+ }
1880+ r := payloadReader{b: data}
1881+ var p RemotePreview
1882+ var err error
1883+ if p.Version, err = r.getUint16(); err != nil {
1884+ return p, err
1885+ }
1886+ status, err := r.getUint8()
1887+ if err != nil {
1888+ return p, err
1889+ }
1890+ p.Status = RemotePreviewStatus(status)
1891+ id, err := r.getBytes(16)
1892+ if err != nil {
1893+ return p, err
1894+ }
1895+ copy(p.LifecycleID[:], id)
1896+ tab, err := r.getString()
1897+ if err != nil {
1898+ return p, err
1899+ }
1900+ p.TabID = domain.TabStableID(tab)
1901+ if p.Revision, err = r.getUint64(); err != nil {
1902+ return p, err
1903+ }
1904+ if p.Width, err = r.getUint16(); err != nil {
1905+ return p, err
1906+ }
1907+ if p.Height, err = r.getUint16(); err != nil {
1908+ return p, err
1909+ }
1910+ count, err := r.getUint32()
1911+ if err != nil {
1912+ return p, err
1913+ }
1914+ if count > RemotePreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) {
1915+ return p, ErrRemotePreviewTooLarge
1916+ }
1917+ if count != 0 {
1918+ p.Cells = make([]renderer.Cell, 0, int(count))
1919+ for range int(count) {
1920+ runeValue, e := r.getUint32()
1921+ if e != nil {
1922+ return p, e
1923+ }
1924+ flags, e := r.getUint8()
1925+ if e != nil {
1926+ return p, e
1927+ }
1928+ if flags&^uint8(1) != 0 {
1929+ return p, ErrInvalidRemotePreview
1930+ }
1931+ style, e := getPreviewStyle(&r)
1932+ if e != nil {
1933+ return p, e
1934+ }
1935+ p.Cells = append(p.Cells, renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style})
1936+ }
1937+ }
1938+ if err := r.done(); err != nil {
1939+ return p, err
1940+ }
1941+ if err := ValidateRemotePreview(p); err != nil {
1942+ return p, err
1943+ }
1944+ return p, nil
1945+}
M internal/ports/wire_final_test.go +3 -3
@@ -11,8 +11,8 @@ import (
1111 )
1212
1313 func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14- if ProtocolVersion != 23 {
15- t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion)
14+ if ProtocolVersion != 24 {
15+ t.Fatalf("ProtocolVersion = %d, want 24", ProtocolVersion)
1616 }
1717 payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
1818 if len(payload) < 2 {
@@ -26,7 +26,7 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
2626
2727 func TestFinalHelloGoldenStrict(t *testing.T) {
2828 msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29- want := append([]byte{0, 23, 2}, make([]byte, 16+8)...)
29+ want := append([]byte{0, 24, 2}, make([]byte, 16+8)...)
3030 want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
3131 got := MarshalHello(msg)
3232 if !bytes.Equal(got, want) {
M internal/ports/wire_test.go +6 -6
@@ -124,7 +124,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
124124 }
125125 got := MarshalHello(msg)
126126 want := []byte{
127- 0x00, 0x17, 0x00, // version, intent
127+ 0x00, 0x18, 0x00, // version, intent
128128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
129129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
130130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -341,7 +341,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
341341 }
342342 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
343343 require.Equal(t, want, MarshalTheme(cleared))
344- require.Equal(t, uint16(23), ProtocolVersion)
344+ require.Equal(t, uint16(24), ProtocolVersion)
345345 }
346346
347347 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -469,7 +469,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
469469 name: "minimal",
470470 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
471471 want: []byte{
472- 0x00, 0x17,
472+ 0x00, 0x18,
473473 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
474474 0x00, // attached
475475 0x00, // self
@@ -492,7 +492,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
492492 JSON: true,
493493 },
494494 want: []byte{
495- 0x00, 0x17,
495+ 0x00, 0x18,
496496 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
497497 0x00, // attached
498498 0x01, // self
@@ -920,7 +920,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
920920 name: "attached",
921921 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
922922 want: []byte{
923- 0x00, 0x17, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
923+ 0x00, 0x18, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
924924 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
925925 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
926926 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -930,7 +930,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
930930 name: "control",
931931 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
932932 want: []byte{
933- 0x00, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
933+ 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
934934 0x00, 0x02, 'l', 's', 0x00, 0x00,
935935 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
936936 },
M internal/usecase/client/client.go +56 -6
@@ -147,6 +147,8 @@ const (
147147 )
148148
149149 // Dependencies supplies the collaborators required by a Runner.
150+type AttachHandoffFunc func(ports.AttachTarget) (ports.Dialer, AttachRequest, error)
151+
150152 type Dependencies struct {
151153 Dialer ports.Dialer
152154 Terminal ports.Terminal
@@ -155,17 +157,23 @@ type Dependencies struct {
155157 Logger *slog.Logger
156158 RuntimeObserver ports.SerializedRuntimeObserver
157159 RemoteHostLearner ports.RemoteHostLearner
160+ // AttachHandoff keeps one Runner and one terminal/input ownership across a
161+ // local daemon's structured remote handoff. It is nil for direct CLI attach.
162+ AttachHandoff AttachHandoffFunc
158163 // Remote selects client-side carriage presentation only; it never enters
159164 // the daemon-facing session request.
160165 Remote bool
161166 }
162167
163168 // AttachRequest identifies the session for one client run. Remote is
164-// client-only presentation metadata; it is never serialized into Hello.
169+// client-only presentation metadata. RemoteTarget is an optional exact picker
170+// handoff and is serialized into Hello only when present.
165171 type AttachRequest struct {
166- Intent uint8
167- SessionName string
168- Remote bool
172+ Intent uint8
173+ SessionName string
174+ Remote bool
175+ RemoteTarget *domain.RemoteSessionTarget
176+ EnvironmentPolicy ports.EnvironmentPolicy
169177 }
170178
171179 // Runner owns the client lifecycle across one or more attachment attempts.
@@ -177,6 +185,7 @@ type Runner struct {
177185 logger *slog.Logger
178186 runtimeObserver ports.SerializedRuntimeObserver
179187 remoteHostLearner ports.RemoteHostLearner
188+ attachHandoff AttachHandoffFunc
180189 remote bool
181190
182191 inputMu sync.Mutex
@@ -197,6 +206,7 @@ func NewRunner(deps Dependencies) *Runner {
197206 logger: log,
198207 runtimeObserver: deps.RuntimeObserver,
199208 remoteHostLearner: deps.RemoteHostLearner,
209+ attachHandoff: deps.AttachHandoff,
200210 remote: deps.Remote,
201211 }
202212 }
@@ -216,10 +226,29 @@ func (r *Runner) terminalInput() *terminalInputPump {
216226 return r.input
217227 }
218228
229+func validateAttachRequest(request AttachRequest) error {
230+ if request.RemoteTarget == nil {
231+ return nil
232+ }
233+ if err := request.RemoteTarget.Validate(); err != nil {
234+ return fmt.Errorf("vev: invalid remote attach target: %w", err)
235+ }
236+ if request.SessionName != request.RemoteTarget.SessionName {
237+ return errors.New("vev: remote attach target session mismatch")
238+ }
239+ if request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned && request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
240+ return errors.New("vev: invalid remote environment policy")
241+ }
242+ return nil
243+}
244+
219245 // Run connects and runs the attach client. It owns the terminal lifecycle
220246 // above attach attempts so raw mode remains active while a live client process
221247 // redials a lost link.
222248 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
249+ if err := validateAttachRequest(request); err != nil {
250+ return err
251+ }
223252 ms := milestones{}
224253
225254 defer func() {
@@ -246,6 +275,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
246275
247276 resumeToken := uint64(0)
248277 attemptRequest := request
278+ dialer := r.dialer
249279 backoff := defaultReconnectBackoff.initial
250280 themeState := &terminalThemeState{}
251281 var rememberOnce sync.Once
@@ -314,7 +344,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
314344
315345 for {
316346 handshakeCtx, timedOut, finishHandshake := newHandshakeContext(ctx, r.clock)
317- transport, err := boundedDial(handshakeCtx, r.dialer)
347+ transport, err := boundedDial(handshakeCtx, dialer)
318348 if err != nil {
319349 err = handshakeContextError(ctx, timedOut, err)
320350 finishHandshake()
@@ -393,7 +423,25 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
393423 _ = connection.Close()
394424 }
395425 if result.target != nil {
396- return &AttachTargetError{Target: *result.target}
426+ if r.attachHandoff == nil {
427+ return &AttachTargetError{Target: *result.target}
428+ }
429+ nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)
430+ if handoffErr != nil {
431+ return handoffErr
432+ }
433+ if err := validateAttachRequest(nextRequest); err != nil {
434+ return fmt.Errorf("vev: invalid remote attach handoff request: %w", err)
435+ }
436+ if nextDialer == nil {
437+ return errors.New("vev: remote attach handoff returned nil dialer")
438+ }
439+ dialer = nextDialer
440+ attemptRequest = nextRequest
441+ remote = attemptRequest.Remote || r.remote
442+ resumeToken = 0
443+ backoff = defaultReconnectBackoff.initial
444+ continue
397445 }
398446 if result.resumeToken != 0 {
399447 resumeToken = result.resumeToken
@@ -695,6 +743,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
695743 TrueColor: trueColor,
696744 MaxOutputInFlight: requestedOutputWindow(transport),
697745 Env: os.Environ(),
746+ RemoteTarget: request.RemoteTarget,
747+ EnvironmentPolicy: request.EnvironmentPolicy,
698748 }
699749 if err := sendHandshake(func() error {
700750 return transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
M internal/usecase/daemon/client.go +12 -0
@@ -39,6 +39,9 @@ type attachedClient struct {
3939 overlays *overlayRuntime
4040 overlayOnce sync.Once
4141 clientID [16]byte
42+ // remoteOrigin is attachment-specific presentation metadata supplied by a
43+ // validated picker handoff; it never participates in routing.
44+ remoteOrigin string
4245 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
4346 // the linearization gate for every attachment-bound observable operation. A role
4447 // transition freezes and drains this gate before changing either generation
@@ -565,6 +568,7 @@ type attachClientOptions struct {
565568 clientID [16]byte
566569 resumeCapable bool
567570 maxOutputInFlight uint8
571+ remoteOrigin string
568572 }
569573
570574 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -610,6 +614,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
610614 size: sz,
611615 view: attachmentView{windowRows: sz.Rows, windowSet: true},
612616 clientID: opts.clientID,
617+ remoteOrigin: opts.remoteOrigin,
613618 resumeCapable: opts.resumeCapable,
614619 resumeToken: resumeToken,
615620 }
@@ -875,6 +880,13 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
875880 }
876881 ac.sendMu.Unlock()
877882
883+ if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
884+ // The picker preview geometry is attachment-local. A resize must
885+ // invalidate any in-flight remote request and fetch the selected row
886+ // at the new bounded preview dimensions.
887+ d.registerPreviewForSelection(ac)
888+ }
889+
878890 sess := token.sess
879891 if sess == nil {
880892 return false
M internal/usecase/daemon/control.go +130 -5
@@ -695,9 +695,98 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
695695 }
696696 e.d.mu.Lock()
697697 sessions := sessionsSnapshot(e.d.sessions)
698+ stopped := make([]stoppedSession, 0, len(e.d.stopped))
699+ for _, entry := range e.d.stopped {
700+ if !entry.purging {
701+ stopped = append(stopped, entry)
702+ }
703+ }
698704 e.d.mu.Unlock()
705+
706+ // Test doubles and pre-parity daemons may not have durable lifecycle
707+ // identities. Keep their count-only catalogue form readable so direct CLI
708+ // discovery remains compatible; every production session has a non-zero
709+ // incarnation and therefore takes the strict complete-schema path below.
710+ legacy := false
711+ for _, sess := range sessions {
712+ if sess.incarnation == (domain.IncarnationID{}) {
713+ legacy = true
714+ break
715+ }
716+ }
717+ if !legacy {
718+ for _, entry := range stopped {
719+ if entry.incarnation == (domain.IncarnationID{}) {
720+ legacy = true
721+ break
722+ }
723+ }
724+ }
725+ if legacy {
726+ return e.remoteCatalogLegacy(sessions)
727+ }
728+
729+ rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
730+ for _, sess := range sessions {
731+ snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})
732+ tabs, err := remoteCatalogTabs(snap)
733+ if err != nil {
734+ return "", err
735+ }
736+ row := ports.RemoteCatalogSession{
737+ LifecycleID: snap.incarnation,
738+ Name: snap.name,
739+ State: "running",
740+ Ephemeral: snap.ephemeral,
741+ Tabs: tabs,
742+ Attached: snap.attached,
743+ LastUsedSeq: snap.mruAt,
744+ }
745+ if len(tabs) != 0 {
746+ row.ActiveTabID = tabs[min(snap.defaultTab, len(tabs)-1)].ID
747+ }
748+ rows = append(rows, row)
749+ }
750+ for _, entry := range stopped {
751+ tabs, err := remoteCatalogStoppedTabs(entry)
752+ if err != nil {
753+ return "", err
754+ }
755+ state := "stopped"
756+ reason := ""
757+ if entry.state == ports.SessionBroken || entry.record.DegradedReason != "" {
758+ state = "broken"
759+ reason = "session_broken"
760+ }
761+ rows = append(rows, ports.RemoteCatalogSession{
762+ LifecycleID: entry.incarnation,
763+ Name: entry.name,
764+ State: state,
765+ Ephemeral: false,
766+ Tabs: tabs,
767+ LastUsedSeq: entry.lastUsedSeq,
768+ Reason: reason,
769+ })
770+ }
771+ sort.Slice(rows, func(i, j int) bool {
772+ if rows[i].Name != rows[j].Name {
773+ return rows[i].Name < rows[j].Name
774+ }
775+ return rows[i].LifecycleID.String() < rows[j].LifecycleID.String()
776+ })
777+ catalog := ports.RemoteCatalog{
778+ ProtocolVersion: ports.ProtocolVersion,
779+ SchemaVersion: ports.RemoteCatalogSchemaVersion,
780+ Sessions: rows,
781+ }
782+ if err := ports.ValidateRemoteCatalog(catalog); err != nil {
783+ return "", err
784+ }
785+ return marshalListing(catalog)
786+}
787+
788+func (e controlExec) remoteCatalogLegacy(sessions []*session) (string, error) {
699789 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
700- // Stopped sessions live in d.stopped and are intentionally not in this listing.
701790 for _, sess := range sessions {
702791 snap := sess.snapshotView(viewOptions{})
703792 rows = append(rows, ports.RemoteCatalogSession{
@@ -709,10 +798,46 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
709798 })
710799 }
711800 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
712- return marshalListing(ports.RemoteCatalog{
713- ProtocolVersion: ports.ProtocolVersion,
714- Sessions: rows,
715- })
801+ catalog := ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: rows}
802+ if err := ports.ValidateRemoteCatalog(catalog); err != nil {
803+ return "", err
804+ }
805+ return marshalListing(catalog)
806+}
807+
808+func remoteCatalogTabs(view sessionView) ([]ports.RemoteCatalogTab, error) {
809+ if len(view.tabs) > ports.RemoteCatalogMaxTabsPerSess {
810+ return nil, fmt.Errorf("remote catalog: session %q has too many tabs", view.name)
811+ }
812+ tabs := make([]ports.RemoteCatalogTab, 0, len(view.tabs))
813+ for i, tab := range view.tabs {
814+ tabs = append(tabs, ports.RemoteCatalogTab{
815+ ID: string(tab.id),
816+ Index: uint16(i),
817+ Name: tab.name,
818+ Detail: tabTitleDetail(tab.name, tab.focusedTitle),
819+ Attention: tab.attention,
820+ })
821+ }
822+ return tabs, nil
823+}
824+
825+func remoteCatalogStoppedTabs(entry stoppedSession) ([]ports.RemoteCatalogTab, error) {
826+ records := entry.tabRecords
827+ if len(records) == 0 && len(entry.tabNames) != 0 {
828+ records = make([]domain.CatalogueTabRecord, len(entry.tabNames))
829+ for i, name := range entry.tabNames {
830+ records[i].Name = name
831+ }
832+ }
833+ if len(records) > ports.RemoteCatalogMaxTabsPerSess {
834+ return nil, fmt.Errorf("remote catalog: session %q has too many tabs", entry.name)
835+ }
836+ tabs := make([]ports.RemoteCatalogTab, 0, len(records))
837+ for i, record := range records {
838+ tabs = append(tabs, ports.RemoteCatalogTab{ID: string(record.StableID), Index: uint16(i), Name: record.Name})
839+ }
840+ return tabs, nil
716841 }
717842
718843 func (e controlExec) ListTabs(asJSON bool) (string, error) {
M internal/usecase/daemon/control_test.go +1 -1
@@ -797,7 +797,7 @@ func TestRemoteCatalogTabCountSaturates(t *testing.T) {
797797 var catalog ports.RemoteCatalog
798798 require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
799799 require.Equal(t, []ports.RemoteCatalogSession{{
800- Name: "work", State: "running", Ephemeral: true, Tabs: ^uint16(0), Attached: false,
800+ Name: "work", State: "running", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false,
801801 }}, catalog.Sessions)
802802 }
803803
M internal/usecase/daemon/copymode_test.go +1 -0
@@ -483,6 +483,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
483483 releaseConn()
484484 closeOnce.Do(func() { close(readDone) })
485485 hg.Wait()
486+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
486487 d.sessWg.Wait()
487488 }
488489
M internal/usecase/daemon/daemon.go +50 -5
@@ -288,9 +288,11 @@ type Daemon struct {
288288 // remoteCatalog owns cache-derived discovery state independently of the
289289 // live attachment registry. Its cache reads and writes never hold d.mu.
290290 remoteCatalog remoteCatalogState
291+ remotePreview remotePreviewState
291292 remoteHostStore ports.RemoteHostStore
292293 remoteCatalogClient ports.RemoteCatalogClient
293294 remoteCatalogCache ports.RemoteCatalogCache
295+ remotePreviewClient ports.RemotePreviewClient
294296 remoteDialerFactory ports.RemoteDialerFactory
295297 remoteTransportMode ports.RemoteTransportMode
296298 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
@@ -355,6 +357,7 @@ type stoppedSession struct {
355357 incarnation domain.IncarnationID
356358 lastUsedSeq uint64
357359 tabNames []string
360+ tabRecords []domain.CatalogueTabRecord
358361 purging bool
359362 record domain.CatalogueRecord
360363 state ports.SessionState
@@ -382,6 +385,11 @@ func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalo
382385 }
383386 }
384387
388+// WithRemotePreview installs the optional non-attaching remote viewport client.
389+func WithRemotePreview(client ports.RemotePreviewClient) Option {
390+ return func(d *Daemon) { d.remotePreviewClient = client }
391+}
392+
385393 // WithShell overrides the command (and its args) each session spawns. The
386394 // default is $SHELL (or /bin/sh) with no arguments; tests use this to run a
387395 // deterministic program.
@@ -650,7 +658,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
650658 state, done := initialSessionState(r)
651659 d.stopped[r.Name] = stoppedSessionFromRecord(r, state, done)
652660 } else {
653- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)}
661+ d.stopped[r.Name] = stoppedSessionFromRecord(r, ports.SessionStopped, nil)
654662 }
655663 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
656664 maxCreatedAt = r.CreatedAt
@@ -976,6 +984,12 @@ func (d *Daemon) handleConn(tr ports.Transport) {
976984 if err := d.handleCommand(tr, first); err != nil {
977985 d.log.Warn("command handler failed", "err", err)
978986 }
987+ case ports.MsgRemotePreviewRequest:
988+ stopTransport()
989+ finishHandshake()
990+ if err := d.handleRemotePreview(tr, first); err != nil {
991+ d.log.Warn("remote preview handler failed", "err", err)
992+ }
979993 case ports.MsgKill:
980994 stopTransport()
981995 finishHandshake()
@@ -1229,10 +1243,26 @@ func (e *protoErr) Error() string { return e.text }
12291243 // terminal and role state before releasing d.mu, then defers coordinator
12301244 // cleanup so obsolete workers never delay the new handshake.
12311245 func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) {
1246+ initialTabIndex := -1
1247+ if h.RemoteTarget != nil {
1248+ var ok bool
1249+ initialTabIndex, ok = remoteTargetTabIndexLocked(sess, *h.RemoteTarget)
1250+ if !ok {
1251+ if tr != nil {
1252+ _ = tr.Close()
1253+ }
1254+ d.mu.Unlock()
1255+ return nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
1256+ }
1257+ }
12321258 // Session state is the sole source for future PTY children. Update it before
12331259 // publishing the attachment; existing PTYs keep their original environment.
1260+ // A picker handoff deliberately leaves the daemon-owned environment and CWD
1261+ // untouched, even though Hello retains those fields for direct CLI clients.
12341262 sess.mu.Lock()
1235- sess.env = copyEnvironment(h.Env)
1263+ if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
1264+ sess.env = copyEnvironment(h.Env)
1265+ }
12361266 sess.terminal = term
12371267 sess.mu.Unlock()
12381268 opts := attachClientOptions{
@@ -1240,13 +1270,17 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12401270 resumeCapable: true,
12411271 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
12421272 }
1273+ if h.RemoteTarget != nil {
1274+ opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
1275+ }
12431276 ac := d.prepareAttachedClientLocked(tr, sz, opts)
12441277 d.mu.Unlock()
12451278 result, err := d.transitionAttachment(attachmentTransitionRequest{
1246- target: sess,
1247- next: ac,
1248-
1279+ target: sess,
1280+ next: ac,
12491281 expectedTransport: ac.transportSnapshot(),
1282+ activateTargetTab: initialTabIndex >= 0,
1283+ targetTabIndex: initialTabIndex,
12501284 ready: false,
12511285 })
12521286 if err != nil {
@@ -1330,6 +1364,9 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
13301364 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
13311365 }
13321366 term := terminalEnv{TrueColor: h.TrueColor}
1367+ if h.RemoteTarget != nil && h.ResumeToken == 0 {
1368+ return d.routeRemoteTargetWithContext(ctx, h, tr)
1369+ }
13331370
13341371 // A non-zero token is an authoritative resume credential. If it is unknown,
13351372 // expired, or raced with lifecycle teardown, fail closed instead of routing
@@ -1350,12 +1387,20 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
13501387 }
13511388 return nil, nil, err
13521389 } else if ok {
1390+ if h.RemoteTarget != nil && !d.remoteTargetMatchesSession(sess, *h.RemoteTarget) {
1391+ d.clientGone(sess, ac, tr, false)
1392+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
1393+ }
13531394 return sess, ac, nil
13541395 }
13551396 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
13561397 }
13571398 if sess, ac, ok, err := d.resumeParked(h, tr, sz); err == nil {
13581399 if ok {
1400+ if h.RemoteTarget != nil && !d.remoteTargetMatchesSession(sess, *h.RemoteTarget) {
1401+ d.clientGone(sess, ac, tr, false)
1402+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
1403+ }
13591404 return sess, ac, nil
13601405 }
13611406 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
M internal/usecase/daemon/overlay_runtime.go +18 -13
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "context"
45 "sync"
56 "time"
67
@@ -21,15 +22,17 @@ type overlayRuntime struct {
2122 // pickerGeneration identifies one open lifecycle. It advances only when a
2223 // picker is published, so delayed close and registration work can prove it
2324 // still owns the exact lifecycle it captured.
24- pickerGeneration uint64
25- pickerTitle string
26- pickerIntent pickerIntent
27- pickerSource moveSourceLocator
28- pickerPreview *tab
29- pickerPreviewSession *session
30- pickerPreviewGeneration uint64
31- pickerPending []byte
32- pickerESC pendingByteTimer
25+ pickerGeneration uint64
26+ pickerTitle string
27+ pickerIntent pickerIntent
28+ pickerSource moveSourceLocator
29+ pickerPreview *tab
30+ pickerPreviewSession *session
31+ pickerRemotePreview picker.Preview
32+ pickerRemotePreviewCancel context.CancelFunc
33+ pickerPreviewGeneration uint64
34+ pickerPending []byte
35+ pickerESC pendingByteTimer
3336
3437 // Test-only, unsynchronized lifecycle seams. Assign them before picker
3538 // publication or goroutine startup. Hooks run without pickerMu or
@@ -288,10 +291,11 @@ type overlayRenderSnapshot struct {
288291 statusFeedback string
289292 resizeActive bool
290293
291- pickerActive bool
292- pickerModel *picker.Model
293- pickerTitle string
294- previewTab *tab
294+ pickerActive bool
295+ pickerModel *picker.Model
296+ pickerTitle string
297+ previewTab *tab
298+ remotePreview picker.Preview
295299
296300 noticesOverlayActive bool
297301 noticesOverlayModel *notices.Model
@@ -363,6 +367,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
363367 snap.pickerModel = rt.picker.Clone()
364368 snap.pickerTitle = rt.pickerTitle
365369 snap.previewTab = rt.pickerPreview
370+ snap.remotePreview = clonePickerPreview(rt.pickerRemotePreview)
366371 rt.pickerMu.Unlock()
367372
368373 rt.paletteMu.Lock()
M internal/usecase/daemon/picker.go +281 -10
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "sort"
8+ "time"
89
910 "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/ports"
@@ -23,6 +24,8 @@ type pickerSortMode uint32
2324 const (
2425 pickerSortRecent pickerSortMode = iota
2526 pickerSortGrouped
27+
28+ remotePickerPreviewDebounce = 80 * time.Millisecond
2629 )
2730
2831 func pickerTitle(mode pickerSortMode) string {
@@ -61,7 +64,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
6164 rt.pickerMu.Lock()
6265 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
6366 rt.pickerPreviewGeneration++
67+ if rt.pickerRemotePreviewCancel != nil {
68+ rt.pickerRemotePreviewCancel()
69+ rt.pickerRemotePreviewCancel = nil
70+ }
6471 rt.pickerPreviewSession = nil
72+ rt.pickerRemotePreview = picker.Preview{}
6573 rt.pickerPreview = nil
6674 rt.picker = model
6775 rt.pickerGeneration++
@@ -163,7 +171,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
163171 catalogRows := 0
164172 for _, host := range catalog {
165173 catalogRows += len(host.entry.Sessions)
166- if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
174+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
167175 catalogRows++
168176 }
169177 }
@@ -179,7 +187,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
179187 }
180188 views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
181189 }
182- if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
190+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
183191 views = append(views, remotePickerHostView(host.entry.Host, host.status))
184192 }
185193 }
@@ -216,6 +224,42 @@ func attentionSuffix(label string) string {
216224 return label + " " + string(ui.AttentionGlyph)
217225 }
218226
227+func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) {
228+ reason := target.UnavailableReason
229+ if reason == "" {
230+ reason = "identity_changed"
231+ }
232+ message := "Remote session unavailable"
233+ if target.RemoteTarget != nil {
234+ message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin
235+ } else if target.RemoteHost != "" {
236+ message += ": " + target.RemoteHost
237+ }
238+ message += " — " + remotePickerReasonText(reason)
239+ d.notify(sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
240+}
241+
242+func remotePickerReasonText(reason string) string {
243+ switch reason {
244+ case "catalog_stale":
245+ return "catalog stale"
246+ case "host_unreachable":
247+ return "host unreachable"
248+ case "version_mismatch":
249+ return "version mismatch"
250+ case "session_stopped":
251+ return "session stopped"
252+ case "session_broken":
253+ return "session broken"
254+ case "refreshing":
255+ return "refreshing"
256+ case "identity_changed":
257+ return "session identity changed"
258+ default:
259+ return "unavailable"
260+ }
261+}
262+
219263 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
220264 rt := ac.overlays
221265 var previewGeneration uint64
@@ -271,13 +315,16 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
271315 }
272316 })
273317 var target picker.Target
318+ var cursor picker.Target
274319 var ok bool
320+ var cursorOK bool
275321 var intent pickerIntent
276322 var source moveSourceLocator
277323 // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
278324 prevIdx := -1
279325 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
280326 target, ok = rt.picker.Selected()
327+ cursor, cursorOK = rt.picker.Cursor()
281328 prevIdx = rt.picker.SelectedIndex()
282329 }
283330 intent, source = rt.pickerIntent, rt.pickerSource
@@ -289,6 +336,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
289336 d.invalidateRender(sess, ac, true, "picker.go")
290337 return
291338 }
339+ if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
340+ d.notifyRemotePickerUnavailable(sess, cursor)
341+ d.invalidateRender(sess, ac, true, "picker.go")
342+ return
343+ }
292344 if result.action == 'x' {
293345 var effect *attachmentEffectTicket
294346 if len(effects) != 0 {
@@ -379,13 +431,22 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
379431 previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
380432 ac.overlays.pickerPreviewGeneration++
381433 generation := ac.overlays.pickerPreviewGeneration
434+ if ac.overlays.pickerRemotePreviewCancel != nil {
435+ ac.overlays.pickerRemotePreviewCancel()
436+ ac.overlays.pickerRemotePreviewCancel = nil
437+ }
382438 ac.overlays.pickerPreviewSession = nil
439+ ac.overlays.pickerRemotePreview = picker.Preview{}
383440 ac.overlays.pickerPreview = nil
384441 ac.overlays.pickerMu.Unlock()
385442 d.teardownPreviewSubscription(ac, previous, previousGeneration)
386443 if !ok {
387444 return
388445 }
446+ if target.RemoteTarget != nil {
447+ d.startRemotePickerPreview(ac, target, generation)
448+ return
449+ }
389450 targetSess, next := d.previewTarget(target, intent)
390451 if next == nil || targetSess == nil {
391452 return
@@ -421,6 +482,130 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
421482 d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation)
422483 }
423484
485+func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Target, generation uint64) {
486+ if d == nil || ac == nil || target.RemoteTarget == nil || (d.remotePreviewClient == nil && !target.RemoteTarget.Stopped) {
487+ return
488+ }
489+ width, height := remotePickerPreviewSize(ac.sizeSnapshot())
490+ if width == 0 || height == 0 {
491+ return
492+ }
493+ ctx := d.serveCtx
494+ if ctx == nil {
495+ ctx = context.Background()
496+ }
497+ previewCtx, cancel := context.WithCancel(ctx)
498+ ac.overlays.pickerMu.Lock()
499+ if ac.overlays.picker == nil || ac.overlays.pickerPreviewGeneration != generation {
500+ ac.overlays.pickerMu.Unlock()
501+ cancel()
502+ return
503+ }
504+ ac.overlays.pickerRemotePreviewCancel = cancel
505+ remoteTarget := *target.RemoteTarget
506+ message := "loading remote preview…"
507+ if remoteTarget.Stopped {
508+ message = "stopped session — preview unavailable"
509+ }
510+ ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
511+ if remoteTarget.Stopped {
512+ ac.overlays.pickerRemotePreviewCancel = nil
513+ ac.overlays.pickerMu.Unlock()
514+ cancel()
515+ if sess := ac.currentAttachmentSession(); sess != nil {
516+ d.invalidateRender(sess, ac, false, "remote picker preview")
517+ }
518+ return
519+ }
520+ ac.overlays.pickerMu.Unlock()
521+ go func() {
522+ defer cancel()
523+ previewClock := d.clock
524+ if previewClock == nil {
525+ previewClock = systemClock{}
526+ }
527+ debounce := previewClock.NewTimer(remotePickerPreviewDebounce)
528+ defer debounce.Stop()
529+ select {
530+ case <-debounce.C():
531+ case <-previewCtx.Done():
532+ return
533+ }
534+ preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height)
535+ if err != nil {
536+ ac.overlays.pickerMu.Lock()
537+ matching := false
538+ if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil {
539+ selected, stillSelected := ac.overlays.picker.Selected()
540+ matching = stillSelected && pickerTargetsEqual(selected, target)
541+ if matching {
542+ ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable")
543+ }
544+ }
545+ ac.overlays.pickerMu.Unlock()
546+ if matching {
547+ if sess := ac.currentAttachmentSession(); sess != nil {
548+ d.invalidateRender(sess, ac, false, "remote picker preview")
549+ }
550+ }
551+ return
552+ }
553+ if refreshed, refreshErr := d.awaitRemotePreviewRefresh(previewCtx, remoteTarget, width, height); refreshErr == nil {
554+ preview = refreshed
555+ } else if previewCtx.Err() != nil {
556+ return
557+ }
558+ rows := preview.FrameRows()
559+ candidate := picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
560+ ac.overlays.pickerMu.Lock()
561+ if ac.overlays.pickerPreviewGeneration != generation || ac.overlays.picker == nil {
562+ ac.overlays.pickerMu.Unlock()
563+ return
564+ }
565+ selected, stillSelected := ac.overlays.picker.Selected()
566+ if !stillSelected || !pickerTargetsEqual(selected, target) {
567+ ac.overlays.pickerMu.Unlock()
568+ return
569+ }
570+ ac.overlays.pickerRemotePreview = candidate
571+ ac.overlays.pickerRemotePreviewCancel = nil
572+ ac.overlays.pickerMu.Unlock()
573+ if sess := ac.currentAttachmentSession(); sess != nil {
574+ d.invalidateRender(sess, ac, false, "remote picker preview")
575+ }
576+ }()
577+}
578+
579+func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
580+ layout := picker.ChooseLayout(size)
581+ width, height := layout.Preview.Width, layout.Preview.Height
582+ if width <= 0 || height <= 0 {
583+ return 0, 0
584+ }
585+ return uint16(min(width, ports.RemotePreviewMaxWidth)), uint16(min(height, ports.RemotePreviewMaxHeight))
586+}
587+
588+func staticRemotePickerPreview(width, height uint16, message string) picker.Preview {
589+ if width == 0 || height == 0 {
590+ return picker.Preview{}
591+ }
592+ rows := make([][]renderer.Cell, int(height))
593+ style := renderer.DefaultStyle()
594+ for y := range rows {
595+ rows[y] = make([]renderer.Cell, int(width))
596+ for x := range rows[y] {
597+ rows[y][x] = renderer.Cell{Rune: ' ', Style: style}
598+ }
599+ }
600+ for x, r := range []rune(message) {
601+ if x >= int(width) {
602+ break
603+ }
604+ rows[0][x] = renderer.Cell{Rune: r, Style: style}
605+ }
606+ return picker.Preview{Rows: rows, Width: int(width), Height: int(height)}
607+}
608+
424609 func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {
425610 ac.overlays.pickerMu.Lock()
426611 defer ac.overlays.pickerMu.Unlock()
@@ -463,7 +648,12 @@ func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) b
463648 }
464649 previous := ac.overlays.pickerPreviewSession
465650 ac.overlays.pickerPreviewGeneration++
651+ if ac.overlays.pickerRemotePreviewCancel != nil {
652+ ac.overlays.pickerRemotePreviewCancel()
653+ ac.overlays.pickerRemotePreviewCancel = nil
654+ }
466655 ac.overlays.pickerPreviewSession = nil
656+ ac.overlays.pickerRemotePreview = picker.Preview{}
467657 ac.overlays.pickerPreview = nil
468658 ac.overlays.pickerMu.Unlock()
469659 d.teardownPreviewSubscription(ac, previous, generation)
@@ -537,8 +727,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
537727
538728 func pickerTargetsEqual(left, right picker.Target) bool {
539729 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
540- left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
541- !remoteKeysEqual(left.RemoteKey, right.RemoteKey) {
730+ left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
731+ left.UnavailableReason != right.UnavailableReason || !remoteKeysEqual(left.RemoteKey, right.RemoteKey) ||
732+ !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) {
542733 return false
543734 }
544735 if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
@@ -554,6 +745,13 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
554745 return *left == *right
555746 }
556747
748+func remoteTargetsEqual(left, right *domain.RemoteSessionTarget) bool {
749+ if left == nil || right == nil {
750+ return left == nil && right == nil
751+ }
752+ return *left == *right
753+}
754+
557755 func (d *Daemon) sessionByID(id domain.SessionID) *session {
558756 d.mu.Lock()
559757 defer d.mu.Unlock()
@@ -660,6 +858,13 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
660858 if token.sess == nil || token.ac == nil || token.effect == nil {
661859 return nil
662860 }
861+ if target.RemoteTarget != nil {
862+ var key domain.RemoteSessionKey
863+ if target.RemoteKey != nil {
864+ key = *target.RemoteKey
865+ }
866+ return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
867+ }
663868 if target.RemoteKey != nil {
664869 return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
665870 }
@@ -676,14 +881,35 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
676881 // the target is sent, the local attachment is detached and the client opens a
677882 // fresh connection to the owning daemon.
678883 func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
679- if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
884+ var handoff ports.AttachTarget
885+ if target.RemoteTarget != nil {
886+ remoteTarget := *target.RemoteTarget
887+ if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
888+ return errAttachmentTransition
889+ }
890+ if target.RemoteKey != nil {
891+ if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
892+ return errAttachmentTransition
893+ }
894+ }
895+ handoff = ports.AttachTarget{
896+ Endpoint: remoteTarget.Endpoint,
897+ Session: remoteTarget.SessionName,
898+ Intent: ports.IntentAttach,
899+ RemoteTarget: &remoteTarget,
900+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
901+ }
902+ } else {
903+ if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
904+ return errAttachmentTransition
905+ }
906+ handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach}
907+ }
908+ payload := ports.MarshalAttachTarget(handoff)
909+ if payload == nil {
680910 return errAttachmentTransition
681911 }
682- if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(ports.AttachTarget{
683- Endpoint: key.Host,
684- Session: key.Name,
685- Intent: ports.IntentAttach,
686- })}); err != nil {
912+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
687913 return err
688914 }
689915 if guard.closePicker {
@@ -712,6 +938,51 @@ func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
712938 return false
713939 }
714940
941+func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
942+ if d == nil || target.Validate() != nil {
943+ return false
944+ }
945+ d.remoteCatalog.mu.Lock()
946+ defer d.remoteCatalog.mu.Unlock()
947+ if d.remoteCatalog.status[target.Endpoint] != remoteHostFresh {
948+ return false
949+ }
950+ for _, entry := range d.remoteCatalog.cache {
951+ if entry.Host != target.Endpoint {
952+ continue
953+ }
954+ for _, session := range entry.Sessions {
955+ if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
956+ continue
957+ }
958+ if target.Stopped != (session.State == "stopped") || session.State == "broken" {
959+ return false
960+ }
961+ tabs := ports.CatalogTabs(session)
962+ metadata := make([]domain.TabSelectorTab, 0, len(tabs))
963+ for _, tab := range tabs {
964+ metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
965+ }
966+ if target.Stopped {
967+ _, ok := target.StoppedTab.Resolve(metadata)
968+ return ok
969+ }
970+ found := false
971+ for _, tab := range tabs {
972+ if tab.ID != string(target.LiveTabID) {
973+ continue
974+ }
975+ if found {
976+ return false
977+ }
978+ found = true
979+ }
980+ return found
981+ }
982+ }
983+ return false
984+}
985+
715986 // switchToTargetGuarded is retained for daemon-internal and headless callers.
716987 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
717988 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
A internal/usecase/daemon/remote_attach.go +172 -0
@@ -0,0 +1,172 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+// routeRemoteTargetWithContext is the exact-target branch for picker handoffs.
12+// It is deliberately separate from legacy name routing: a lifecycle mismatch,
13+// missing tab, broken record, or stopped-record race is a no-such-target error,
14+// never an invitation to attach a same-name replacement.
15+func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
16+ if h.RemoteTarget == nil {
17+ return nil, nil, errors.New("daemon: missing remote target")
18+ }
19+ target := *h.RemoteTarget
20+ if err := target.Validate(); err != nil {
21+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "invalid remote target"}
22+ }
23+ if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
24+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires daemon-owned environment"}
25+ }
26+ if h.Intent != ports.IntentAttach && h.Intent != ports.IntentResume {
27+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires attach or resume"}
28+ }
29+ if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
30+ return nil, nil, remoteTargetError(err)
31+ }
32+
33+ d.mu.Lock()
34+ if d.closing {
35+ d.mu.Unlock()
36+ return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
37+ }
38+ if live := d.findByNameLocked(target.SessionName); live != nil {
39+ if live.incarnation != target.LifecycleID {
40+ d.mu.Unlock()
41+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle has changed"}
42+ }
43+ if target.Stopped && target.LiveTabID != "" {
44+ d.mu.Unlock()
45+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "stopped target has a live tab selector"}
46+ }
47+ if _, ok := remoteTargetTabIndexLocked(live, target); !ok {
48+ d.mu.Unlock()
49+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
50+ }
51+ ac, err := d.finishAttach(live, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h)
52+ return live, ac, err
53+ }
54+
55+ stopped, ok := d.stopped[target.SessionName]
56+ if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
57+ d.mu.Unlock()
58+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"}
59+ }
60+ if stopped.state == ports.SessionBroken || stopped.record.DegradedReason != "" {
61+ d.mu.Unlock()
62+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is broken"}
63+ }
64+ if _, ok := remoteTargetTabIndexStopped(stopped, target); !ok {
65+ d.mu.Unlock()
66+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"}
67+ }
68+
69+ // Picker handoffs use the daemon's own environment and persisted CWD. The
70+ // client's Env/Cwd fields remain present for wire compatibility but are not
71+ // trusted for this branch.
72+ env := copyEnvironment(d.baseEnv)
73+ cwd := d.dirOrHome(stopped.cwd)
74+ sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, terminalEnv{TrueColor: h.TrueColor}, env, stopped.tabNames)
75+ if err != nil {
76+ d.mu.Unlock()
77+ return nil, nil, err
78+ }
79+ ac, err := d.finishAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h)
80+ if err != nil {
81+ // The route-created session has no published client if finishAttach
82+ // fails. Retire it immediately rather than leaving a shadow live row.
83+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
84+ return nil, nil, err
85+ }
86+ ac.routeCreatedSession = true
87+ return sess, ac, nil
88+}
89+
90+func (d *Daemon) remoteTargetMatchesSession(sess *session, target domain.RemoteSessionTarget) bool {
91+ if sess == nil {
92+ return false
93+ }
94+ d.mu.Lock()
95+ defer d.mu.Unlock()
96+ sess.mu.Lock()
97+ matches := d.sessions[sess.id] == sess && sess.incarnation == target.LifecycleID && sess.name == target.SessionName
98+ sess.mu.Unlock()
99+ if !matches {
100+ return false
101+ }
102+ _, ok := remoteTargetTabIndexLocked(sess, target)
103+ return ok
104+}
105+
106+func remoteTargetError(err error) error {
107+ var protocol *protoErr
108+ if errors.As(err, &protocol) {
109+ return protocol
110+ }
111+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
112+ return err
113+ }
114+ return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"}
115+}
116+
117+func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {
118+ records := stopped.tabRecords
119+ if len(records) == 0 {
120+ records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))
121+ for i, name := range stopped.tabNames {
122+ records[i].Name = name
123+ }
124+ }
125+ metadata := make([]domain.TabSelectorTab, 0, len(records))
126+ for _, record := range records {
127+ metadata = append(metadata, domain.TabSelectorTab{ID: record.StableID, Name: record.Name})
128+ }
129+ return metadata
130+}
131+
132+func remoteTargetTabIndexStopped(stopped stoppedSession, target domain.RemoteSessionTarget) (int, bool) {
133+ return target.StoppedTab.Resolve(stoppedTabMetadata(stopped))
134+}
135+
136+// remoteTargetTabIndexLocked resolves the selector against the live session's
137+// current ordered tabs. Caller holds d.mu; this function takes only the
138+// session/tab locks needed for a coherent metadata snapshot.
139+func remoteTargetTabIndexLocked(sess *session, target domain.RemoteSessionTarget) (int, bool) {
140+ if sess == nil {
141+ return 0, false
142+ }
143+ sess.mu.Lock()
144+ tabs := append([]*tab(nil), sess.tabs...)
145+ sess.mu.Unlock()
146+ metadata := make([]domain.TabSelectorTab, 0, len(tabs))
147+ for _, tab := range tabs {
148+ if tab == nil {
149+ return 0, false
150+ }
151+ tab.mu.Lock()
152+ metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})
153+ tab.mu.Unlock()
154+ }
155+ if target.Stopped {
156+ return target.StoppedTab.Resolve(metadata)
157+ }
158+ if target.LiveTabID == "" {
159+ return 0, false
160+ }
161+ found := -1
162+ for i, tab := range metadata {
163+ if tab.ID != target.LiveTabID {
164+ continue
165+ }
166+ if found >= 0 {
167+ return 0, false
168+ }
169+ found = i
170+ }
171+ return found, found >= 0
172+}
A internal/usecase/daemon/remote_attach_test.go +72 -0
@@ -0,0 +1,72 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/usecase/picker"
12+)
13+
14+func remoteLifecycleForTest() domain.SessionLifecycleID {
15+ var id domain.SessionLifecycleID
16+ id[0] = 7
17+ return id
18+}
19+
20+func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
21+ d := newRemotePickerDaemon(nil)
22+ lifecycle := remoteLifecycleForTest()
23+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
24+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
25+ LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
26+ }},
27+ }})
28+ d.remoteCatalog.mu.Lock()
29+ d.remoteCatalog.status["arch"] = remoteHostFresh
30+ d.remoteCatalog.mu.Unlock()
31+ sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
32+ token := sess.attachmentToken(ac, ac.transport())
33+ effect, admitted := ac.beginAttachmentEffect(token)
34+ require.True(t, admitted)
35+ token.effect = effect
36+ defer effect.End()
37+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
38+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
39+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
40+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{closePicker: true}, "picker-select"))
41+ frame := receiveRemotePicker(t, sends, "rich attach target")
42+ got, err := ports.UnmarshalAttachTarget(frame.Payload)
43+ require.NoError(t, err)
44+ require.NotNil(t, got.RemoteTarget)
45+ require.Equal(t, remoteTarget, *got.RemoteTarget)
46+ require.Equal(t, ports.EnvironmentPolicyDaemonOwned, got.EnvironmentPolicy)
47+}
48+
49+func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
50+ d := newRemotePickerDaemon(nil)
51+ lifecycle := remoteLifecycleForTest()
52+ cachedLifecycle := lifecycle
53+ cachedLifecycle[0]++
54+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
55+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
56+ LifecycleID: cachedLifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
57+ }},
58+ }})
59+ d.remoteCatalog.mu.Lock()
60+ d.remoteCatalog.status["arch"] = remoteHostFresh
61+ d.remoteCatalog.mu.Unlock()
62+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
63+ token := sess.attachmentToken(ac, ac.transport())
64+ effect, admitted := ac.beginAttachmentEffect(token)
65+ require.True(t, admitted)
66+ token.effect = effect
67+ defer effect.End()
68+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
69+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
70+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
71+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
72+}
M internal/usecase/daemon/remote_catalog_state.go +9 -1
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "slices"
56 "sort"
67 "sync"
78 "time"
@@ -15,8 +16,10 @@ const (
1516 remoteHostCached remoteHostStatus = iota
1617 remoteHostRefreshing
1718 remoteHostFresh
19+ remoteHostStale
1820 remoteHostUnreachable
1921 remoteHostVersionMismatch
22+ remoteHostMalformed
2023 )
2124
2225 // remoteCatalogState owns the cache-derived discovery state. Its mutex never
@@ -43,7 +46,12 @@ func newRemoteCatalogState() remoteCatalogState {
4346
4447 func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
4548 sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions))
46- copy(sessions, entry.Sessions)
49+ for i, session := range entry.Sessions {
50+ sessions[i] = session
51+ if tabs := ports.CatalogTabs(session); tabs != nil {
52+ sessions[i].Tabs = slices.Clone(tabs)
53+ }
54+ }
4755 return ports.RemoteCatalogCacheEntry{
4856 Host: entry.Host,
4957 FetchedAt: entry.FetchedAt,
M internal/usecase/daemon/remote_picker.go +156 -20
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "sort"
9+ "strings"
910 "time"
1011
1112 "github.com/bnema/vev/internal/domain"
@@ -30,11 +31,19 @@ type remotePickerInstance struct {
3031 // remotePickerCatalogSnapshot copies the cache-derived state needed for a
3132 // picker before any row sorting begins. No remote-state lock survives this
3233 // snapshot.
34+const remotePickerAttachTTL = 30 * time.Second
35+
3336 func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
3437 if d == nil {
3538 return nil
3639 }
40+ now := d.clock.Now()
3741 d.remoteCatalog.mu.Lock()
42+ for host, entry := range d.remoteCatalog.cache {
43+ if d.remoteCatalog.status[host] == remoteHostFresh && remoteCatalogExpired(entry.FetchedAt, now) {
44+ d.remoteCatalog.status[host] = remoteHostStale
45+ }
46+ }
3847 entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
3948 seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
4049 for host, entry := range d.remoteCatalog.cache {
@@ -45,7 +54,7 @@ func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
4554 seen[host] = struct{}{}
4655 }
4756 for host, status := range d.remoteCatalog.status {
48- if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch) {
57+ if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) {
4958 continue
5059 }
5160 entries = append(entries, remotePickerCatalogEntry{
@@ -75,11 +84,15 @@ func (d *Daemon) remotePickerHostRanks() map[string]int {
7584 return ranks
7685 }
7786
87+func remoteCatalogExpired(fetchedAt, now time.Time) bool {
88+ return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) >= remotePickerAttachTTL)
89+}
90+
7891 func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
7992 switch status {
8093 case remoteHostFresh:
8194 return picker.RemoteFresh
82- case remoteHostUnreachable:
95+ case remoteHostStale, remoteHostUnreachable, remoteHostMalformed:
8396 return picker.RemoteStale
8497 case remoteHostVersionMismatch:
8598 return picker.RemoteVersionMismatch
@@ -88,15 +101,13 @@ func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability
88101 }
89102 }
90103
91-func remotePickerDetail(tabs uint16) string {
92- if tabs == 1 {
93- return "1 tab"
94- }
95- return fmt.Sprintf("%d tabs", tabs)
96-}
97-
98104 func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
99105 switch status {
106+ case remoteHostStale:
107+ if !fetchedAt.IsZero() {
108+ return "catalog stale since " + fetchedAt.Format(time.RFC3339)
109+ }
110+ return "catalog stale"
100111 case remoteHostUnreachable:
101112 if !fetchedAt.IsZero() {
102113 return "stale since " + fetchedAt.Format(time.RFC3339)
@@ -104,34 +115,148 @@ func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) stri
104115 return "unreachable"
105116 case remoteHostVersionMismatch:
106117 return "version mismatch"
118+ case remoteHostMalformed:
119+ return "catalog malformed"
107120 default:
108121 return ""
109122 }
110123 }
111124
125+func remoteSessionOrigin(host string) string {
126+ if i := strings.LastIndexByte(host, '@'); i >= 0 && i+1 < len(host) {
127+ return host[i+1:]
128+ }
129+ return host
130+}
131+
112132 func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
133+ key.LifecycleID = session.LifecycleID
134+ key.DisplayOrigin = remoteSessionOrigin(key.Host)
135+ availability := remotePickerAvailability(status)
136+ stopped := session.State == "stopped"
137+ broken := session.State == "broken"
138+ tabs := ports.CatalogTabs(session)
139+ viewTabs := make([]picker.TabEntry, 0, len(tabs))
140+ for _, tab := range tabs {
141+ name := tab.Name
142+ if name == "" {
143+ name = fmt.Sprintf("%d", int(tab.Index)+1)
144+ }
145+ viewTabs = append(viewTabs, picker.TabEntry{
146+ TabID: domain.TabStableID(tab.ID),
147+ Name: name,
148+ RawName: tab.Name,
149+ Detail: tab.Detail,
150+ Attention: tab.Attention,
151+ })
152+ }
153+ legacy := session.LifecycleID == (domain.SessionLifecycleID{})
154+ var remoteTarget *domain.RemoteSessionTarget
155+ if !legacy {
156+ target := domain.RemoteSessionTarget{
157+ Endpoint: key.Host,
158+ DisplayOrigin: key.DisplayOrigin,
159+ LifecycleID: session.LifecycleID,
160+ SessionName: session.Name,
161+ Stopped: stopped,
162+ }
163+ if len(tabs) != 0 {
164+ first := tabs[0]
165+ if stopped {
166+ if first.ID != "" {
167+ target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
168+ } else {
169+ target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs)))
170+ }
171+ } else {
172+ target.LiveTabID = domain.TabStableID(first.ID)
173+ }
174+ }
175+ // Keep the structured session identity on rows even when one tab ID is
176+ // malformed or a broken session cannot be activated. Cursor navigation
177+ // must remain possible, while sendRemoteAttachTargetForAttachment will
178+ // fail closed on the invalid target.
179+ remoteTarget = &target
180+ }
181+ reason := ""
182+ if !legacy && status != remoteHostFresh {
183+ reason = remoteReasonForStatus(status)
184+ }
185+ targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
186+ if broken {
187+ reason = "session_broken"
188+ } else if !legacy && !targetValid {
189+ reason = "identity_changed"
190+ }
191+ ready := status == remoteHostFresh && targetValid && !broken
192+ if legacy {
193+ // Count-only peers cannot prove lifecycle/tab identity. Preserve the
194+ // historical direct-connect row for compatibility, while all current
195+ // schema rows remain gated by exact identity and fresh ownership.
196+ ready = status != remoteHostVersionMismatch && !broken
197+ if status == remoteHostVersionMismatch {
198+ reason = remoteReasonForStatus(status)
199+ }
200+ }
113201 detail := remotePickerStatusDetail(status, fetchedAt)
114202 if detail == "" {
115- detail = remotePickerDetail(session.Tabs)
203+ switch session.State {
204+ case "running":
205+ detail = "running"
206+ if session.Attached {
207+ detail += " · attached"
208+ }
209+ case "stopped":
210+ detail = "stopped"
211+ case "broken":
212+ detail = "broken"
213+ }
214+ }
215+ if broken {
216+ detail = "session broken"
116217 }
117218 return picker.SessionView{
118219 ID: key.ID(),
119220 Name: key.Display(),
120221 RemoteKey: &key,
222+ RemoteTarget: remoteTarget,
121223 RemoteHost: key.Host,
122- RemoteAvailability: remotePickerAvailability(status),
224+ RemoteAvailability: availability,
123225 RemoteDetail: detail,
124- ConnectOnly: true,
125- RemoteAttachReady: status != remoteHostVersionMismatch,
226+ RemoteReason: reason,
227+ Tabs: viewTabs,
228+ Stopped: stopped,
229+ ConnectOnly: legacy,
230+ RemoteAttachReady: ready,
126231 CannotAcceptMoves: true,
127232 }
128233 }
129234
235+func remoteReasonForStatus(status remoteHostStatus) string {
236+ switch status {
237+ case remoteHostRefreshing:
238+ return "refreshing"
239+ case remoteHostStale:
240+ return "catalog_stale"
241+ case remoteHostUnreachable:
242+ return "host_unreachable"
243+ case remoteHostVersionMismatch:
244+ return "version_mismatch"
245+ case remoteHostMalformed:
246+ return "malformed"
247+ case remoteHostCached:
248+ return "refreshing"
249+ default:
250+ return ""
251+ }
252+}
253+
130254 func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView {
131255 return picker.SessionView{
132256 ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))),
133257 Name: host,
134258 RemoteHost: host,
259+ RemoteReason: remoteReasonForStatus(status),
135260 RemoteAvailability: remotePickerAvailability(status),
136261 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
137262 ConnectOnly: true,
@@ -142,7 +267,11 @@ func remotePickerHostView(host string, status remoteHostStatus) picker.SessionVi
142267 func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
143268 for i := range entries {
144269 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
145- return entries[i].entry.Sessions[left].Name < entries[i].entry.Sessions[right].Name
270+ l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]
271+ if l.LastUsedSeq != r.LastUsedSeq {
272+ return l.LastUsedSeq > r.LastUsedSeq
273+ }
274+ return l.Name < r.Name
146275 })
147276 }
148277 sort.Slice(entries, func(i, j int) bool {
@@ -365,8 +494,10 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
365494 }
366495 stillKnown := containsRemotePickerHost(hosts, host)
367496 now := d.clock.Now()
368- if listErr == nil && catalog.ProtocolVersion != ports.ProtocolVersion {
369- listErr = &ports.RemoteCatalogVersionMismatchError{Got: catalog.ProtocolVersion, Want: ports.ProtocolVersion}
497+ if listErr == nil {
498+ if err := ports.ValidateRemoteCatalog(catalog); err != nil {
499+ listErr = err
500+ }
370501 }
371502
372503 d.remoteCatalog.mu.Lock()
@@ -387,10 +518,15 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
387518 return cacheChanged
388519 }
389520 if listErr != nil {
390- status := remoteHostUnreachable
521+ status := remoteHostMalformed
391522 var mismatch *ports.RemoteCatalogVersionMismatchError
392523 if errors.As(listErr, &mismatch) {
393524 status = remoteHostVersionMismatch
525+ } else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) &&
526+ !errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) &&
527+ !errors.Is(listErr, ports.ErrRemoteCatalogUnknownState) &&
528+ !errors.Is(listErr, ports.ErrRemoteCatalogInvalidReason) {
529+ status = remoteHostUnreachable
394530 }
395531 d.remoteCatalog.status[host] = status
396532 d.remoteCatalog.failure[host] = now
@@ -398,11 +534,11 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
398534 d.refreshRemoteOpenPickers()
399535 return false
400536 }
401- d.remoteCatalog.cache[host] = ports.RemoteCatalogCacheEntry{
537+ d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
402538 Host: host,
403539 FetchedAt: now,
404- Sessions: append([]ports.RemoteCatalogSession{}, catalog.Sessions...),
405- }
540+ Sessions: catalog.Sessions,
541+ })
406542 d.remoteCatalog.status[host] = remoteHostFresh
407543 delete(d.remoteCatalog.failure, host)
408544 d.remoteCatalog.mu.Unlock()
M internal/usecase/daemon/remote_picker_test.go +43 -0
@@ -55,6 +55,27 @@ func newRemotePickerDaemon(store ports.RemoteHostStore) *Daemon {
5555 return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil, nil, ports.RemoteTransportUDP))
5656 }
5757
58+func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
59+ now := time.Unix(100, 0)
60+ clock := &remotePreviewTestClock{now: now}
61+ d := newTestDaemon(t, nil, clock)
62+ lifecycle := remoteLifecycleForTest()
63+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
64+ Host: "arch", FetchedAt: now.Add(-remotePickerAttachTTL), Sessions: []ports.RemoteCatalogSession{{
65+ LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
66+ }},
67+ }})
68+ d.remoteCatalog.mu.Lock()
69+ d.remoteCatalog.status["arch"] = remoteHostFresh
70+ d.remoteCatalog.mu.Unlock()
71+
72+ entries := d.remotePickerCatalogSnapshot()
73+ require.Len(t, entries, 1)
74+ require.Equal(t, remoteHostStale, entries[0].status)
75+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
76+ require.False(t, d.remotePickerTargetReadyTarget(target), "attachment must reject a catalogue exactly at the TTL boundary")
77+}
78+
5879 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
5980 tests := []struct {
6081 name string
@@ -403,6 +424,28 @@ func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
403424 d.remoteCatalog.mu.Unlock()
404425 }
405426
427+func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
428+ lifecycle := remoteLifecycleForTest()
429+ for _, test := range []struct {
430+ name string
431+ session ports.RemoteCatalogSession
432+ }{
433+ {name: "unknown state", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},
434+ {name: "invalid reason", session: ports.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "running", Reason: "bogus"}},
435+ } {
436+ t.Run(test.name, func(t *testing.T) {
437+ hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
438+ d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
439+ d.remoteCatalog.refresh = 1
440+ catalog := ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, SchemaVersion: ports.RemoteCatalogSchemaVersion, Sessions: []ports.RemoteCatalogSession{test.session}}
441+ require.False(t, d.applyRemoteRefreshResult(1, "arch", catalog, nil))
442+ d.remoteCatalog.mu.Lock()
443+ require.Equal(t, remoteHostMalformed, d.remoteCatalog.status["arch"])
444+ d.remoteCatalog.mu.Unlock()
445+ })
446+ }
447+}
448+
406449 func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
407450 hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
408451 d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
A internal/usecase/daemon/remote_preview.go +119 -0
@@ -0,0 +1,119 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func (d *Daemon) handleRemotePreview(tr ports.Transport, frame ports.Frame) error {
12+ defer func() { _ = tr.Close() }()
13+ request, err := ports.UnmarshalRemotePreviewRequest(frame.Payload)
14+ if err != nil {
15+ return d.sendRemotePreview(tr, ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewMalformed})
16+ }
17+ preview, err := d.captureRemotePreview(request)
18+ if err != nil {
19+ status := ports.RemotePreviewUnavailable
20+ if errors.Is(err, errRemotePreviewNoSuchTarget) {
21+ status = ports.RemotePreviewNoSuchTarget
22+ }
23+ preview = ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: status}
24+ }
25+ return d.sendRemotePreview(tr, preview)
26+}
27+
28+func (d *Daemon) sendRemotePreview(tr ports.Transport, preview ports.RemotePreview) error {
29+ payload := ports.MarshalRemotePreview(preview)
30+ if payload == nil {
31+ payload = ports.MarshalRemotePreview(ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewMalformed})
32+ }
33+ return d.boundedControlSend(tr, ports.Frame{Type: ports.MsgRemotePreviewResponse, Payload: payload})
34+}
35+
36+var errRemotePreviewNoSuchTarget = errors.New("daemon: remote preview target does not exist")
37+
38+func (d *Daemon) captureRemotePreview(request ports.RemotePreviewRequest) (ports.RemotePreview, error) {
39+ target := request.Target
40+ d.mu.Lock()
41+ sess := d.findByNameLocked(target.SessionName)
42+ if sess == nil || sess.incarnation != target.LifecycleID {
43+ d.mu.Unlock()
44+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
45+ }
46+ index, ok := remoteTargetTabIndexLocked(sess, target)
47+ d.mu.Unlock()
48+ if !ok {
49+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
50+ }
51+
52+ sess.mu.Lock()
53+ if index < 0 || index >= len(sess.tabs) {
54+ sess.mu.Unlock()
55+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
56+ }
57+ tab := sess.tabs[index]
58+ if tab == nil || domain.TabStableID(tab.stableID) != target.LiveTabID {
59+ sess.mu.Unlock()
60+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
61+ }
62+ sess.mu.Unlock()
63+ tab.mu.Lock()
64+ pane := tab.focusedPane()
65+ if pane == nil {
66+ tab.mu.Unlock()
67+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
68+ }
69+ pane.mu.Lock()
70+ if pane.screen == nil {
71+ pane.mu.Unlock()
72+ tab.mu.Unlock()
73+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
74+ }
75+ source := pane.screen.Frame.Clone()
76+ revision := pane.syncGen
77+ pane.mu.Unlock()
78+ tab.mu.Unlock()
79+ if revision == 0 {
80+ revision = 1
81+ }
82+
83+ width := min(int(request.Width), source.Width)
84+ height := min(int(request.Height), source.Height)
85+ if width <= 0 || height <= 0 {
86+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
87+ }
88+ startY := source.Height - height
89+ cells := make([]renderer.Cell, 0, width*height)
90+ for y := 0; y < height; y++ {
91+ row := source.Row(startY + y)
92+ for x := 0; x < width; x++ {
93+ cell := row[x]
94+ // A crop may end immediately after the left half of a wide
95+ // rune. Replace that boundary cell with a styled blank rather
96+ // than sending an invalid continuation sequence to the picker.
97+ if x == width-1 && !cell.Continuation && renderer.RuneWidth(cell.Rune) == 2 {
98+ cell = renderer.Cell{Rune: ' ', Style: cell.Style}
99+ }
100+ cells = append(cells, cell)
101+ }
102+ }
103+ preview := ports.RemotePreview{
104+ Version: ports.RemotePreviewSchemaVersion,
105+ Status: ports.RemotePreviewOK,
106+ LifecycleID: target.LifecycleID,
107+ TabID: target.LiveTabID,
108+ Revision: revision,
109+ Width: uint16(width),
110+ Height: uint16(height),
111+ Cells: cells,
112+ }
113+ if err := ports.ValidateRemotePreview(preview); err != nil {
114+ // A crop must not split a wide cell. Return a safe unavailable response
115+ // instead of exposing a malformed cell stream.
116+ return ports.RemotePreview{}, err
117+ }
118+ return preview, nil
119+}
A internal/usecase/daemon/remote_preview_cache.go +257 -0
@@ -0,0 +1,257 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "slices"
8+ "sync"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+const (
16+ remotePreviewCacheTTL = 5 * time.Second
17+ remotePreviewCooldown = 1 * time.Second
18+ remotePreviewCacheMax = 64
19+ remotePreviewSlots = 4
20+)
21+
22+var errRemotePreviewCooldown = errors.New("remote preview request is cooling down")
23+
24+type remotePreviewCacheKey struct {
25+ Endpoint string
26+ DisplayOrigin string
27+ LifecycleID domain.SessionLifecycleID
28+ SessionName string
29+ LiveTabID domain.TabStableID
30+ Stopped bool
31+ StoppedTab domain.TabSelector
32+ Width uint16
33+ Height uint16
34+}
35+
36+type remotePreviewCacheEntry struct {
37+ Preview ports.RemotePreview
38+ Fetched time.Time
39+ Used time.Time
40+}
41+
42+type remotePreviewFlight struct {
43+ done chan struct{}
44+ preview ports.RemotePreview
45+ err error
46+}
47+
48+type remotePreviewState struct {
49+ mu sync.Mutex
50+ cache map[remotePreviewCacheKey]remotePreviewCacheEntry
51+ flights map[remotePreviewCacheKey]*remotePreviewFlight
52+ cooldowns map[remotePreviewCacheKey]time.Time
53+ slots chan struct{}
54+}
55+
56+func cloneRemotePreview(preview ports.RemotePreview) ports.RemotePreview {
57+ preview.Cells = slices.Clone(preview.Cells)
58+ return preview
59+}
60+
61+func (s *remotePreviewState) initializeLocked() {
62+ if s.cache == nil {
63+ s.cache = make(map[remotePreviewCacheKey]remotePreviewCacheEntry)
64+ }
65+ if s.flights == nil {
66+ s.flights = make(map[remotePreviewCacheKey]*remotePreviewFlight)
67+ }
68+ if s.cooldowns == nil {
69+ s.cooldowns = make(map[remotePreviewCacheKey]time.Time)
70+ }
71+ if s.slots == nil {
72+ s.slots = make(chan struct{}, remotePreviewSlots)
73+ }
74+}
75+
76+func remotePreviewKeyFor(target domain.RemoteSessionTarget, width, height uint16) remotePreviewCacheKey {
77+ return remotePreviewCacheKey{
78+ Endpoint: target.Endpoint, DisplayOrigin: target.DisplayOrigin,
79+ LifecycleID: target.LifecycleID, SessionName: target.SessionName,
80+ LiveTabID: target.LiveTabID, Stopped: target.Stopped,
81+ StoppedTab: target.StoppedTab, Width: width, Height: height,
82+ }
83+}
84+
85+func (d *Daemon) fetchRemotePreview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
86+ if d == nil || d.remotePreviewClient == nil {
87+ return ports.RemotePreview{}, errors.New("remote preview client unavailable")
88+ }
89+ if ctx == nil {
90+ ctx = context.Background()
91+ }
92+ if err := ctx.Err(); err != nil {
93+ return ports.RemotePreview{}, err
94+ }
95+ request := ports.RemotePreviewRequest{
96+ Version: ports.RemotePreviewSchemaVersion,
97+ Target: target,
98+ Width: width,
99+ Height: height,
100+ }
101+ if err := ports.ValidateRemotePreviewRequest(request); err != nil {
102+ return ports.RemotePreview{}, err
103+ }
104+
105+ key := remotePreviewKeyFor(target, width, height)
106+ now := d.clock.Now()
107+ d.remotePreview.mu.Lock()
108+ d.remotePreview.initializeLocked()
109+
110+ if cached, ok := d.remotePreview.cache[key]; ok {
111+ cached.Used = now
112+ d.remotePreview.cache[key] = cached
113+ if now.Sub(cached.Fetched) <= remotePreviewCacheTTL {
114+ preview := cloneRemotePreview(cached.Preview)
115+ d.remotePreview.mu.Unlock()
116+ return preview, nil
117+ }
118+
119+ // Expired data remains useful to the picker. Revalidation is detached
120+ // from the navigation request so cursor movement never waits on remote
121+ // I/O, and cooldowns prevent a failed host from being hammered.
122+ if until, cooling := d.remotePreview.cooldowns[key]; cooling && now.Before(until) {
123+ preview := cloneRemotePreview(cached.Preview)
124+ d.remotePreview.mu.Unlock()
125+ return preview, nil
126+ }
127+ if flight := d.remotePreview.flights[key]; flight != nil {
128+ preview := cloneRemotePreview(cached.Preview)
129+ d.remotePreview.mu.Unlock()
130+ return preview, nil
131+ }
132+ flight := &remotePreviewFlight{done: make(chan struct{})}
133+ d.remotePreview.flights[key] = flight
134+ preview := cloneRemotePreview(cached.Preview)
135+ d.remotePreview.mu.Unlock()
136+ go d.runRemotePreviewFlight(d.remotePreviewContext(), key, target, width, height, flight)
137+ return preview, nil
138+ }
139+
140+ if until, cooling := d.remotePreview.cooldowns[key]; cooling && now.Before(until) {
141+ d.remotePreview.mu.Unlock()
142+ return ports.RemotePreview{}, errRemotePreviewCooldown
143+ }
144+ if flight := d.remotePreview.flights[key]; flight != nil {
145+ d.remotePreview.mu.Unlock()
146+ return waitRemotePreviewFlight(ctx, flight)
147+ }
148+ flight := &remotePreviewFlight{done: make(chan struct{})}
149+ d.remotePreview.flights[key] = flight
150+ d.remotePreview.mu.Unlock()
151+
152+ d.runRemotePreviewFlight(ctx, key, target, width, height, flight)
153+ return cloneRemotePreview(flight.preview), flight.err
154+}
155+
156+func (d *Daemon) remotePreviewContext() context.Context {
157+ if d != nil && d.serveCtx != nil {
158+ return d.serveCtx
159+ }
160+ return context.Background()
161+}
162+
163+func waitRemotePreviewFlight(ctx context.Context, flight *remotePreviewFlight) (ports.RemotePreview, error) {
164+ select {
165+ case <-flight.done:
166+ return cloneRemotePreview(flight.preview), flight.err
167+ case <-ctx.Done():
168+ return ports.RemotePreview{}, ctx.Err()
169+ }
170+}
171+
172+// awaitRemotePreviewRefresh lets a picker display expired content immediately
173+// while still publishing the refreshed result once the same-key flight finishes.
174+// A completed flight is read from the cache without starting a second remote
175+// request.
176+func (d *Daemon) awaitRemotePreviewRefresh(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
177+ key := remotePreviewKeyFor(target, width, height)
178+ d.remotePreview.mu.Lock()
179+ flight := d.remotePreview.flights[key]
180+ d.remotePreview.mu.Unlock()
181+ if flight != nil {
182+ return waitRemotePreviewFlight(ctx, flight)
183+ }
184+ return d.fetchRemotePreview(ctx, target, width, height)
185+}
186+
187+func (d *Daemon) runRemotePreviewFlight(ctx context.Context, key remotePreviewCacheKey, target domain.RemoteSessionTarget, width, height uint16, flight *remotePreviewFlight) {
188+ select {
189+ case d.remotePreview.slots <- struct{}{}:
190+ case <-ctx.Done():
191+ d.finishRemotePreviewFlight(key, flight, ports.RemotePreview{}, ctx.Err())
192+ return
193+ }
194+ var preview ports.RemotePreview
195+ var err error
196+ defer func() {
197+ if recovered := recover(); recovered != nil {
198+ err = fmt.Errorf("remote preview flight panicked: %v", recovered)
199+ preview = ports.RemotePreview{}
200+ d.finishRemotePreviewFlight(key, flight, preview, err)
201+ panic(recovered)
202+ }
203+ if err != nil {
204+ preview = ports.RemotePreview{}
205+ }
206+ d.finishRemotePreviewFlight(key, flight, preview, err)
207+ }()
208+ defer func() { <-d.remotePreview.slots }()
209+
210+ preview, err = d.remotePreviewClient.Preview(ctx, target, width, height)
211+ if err == nil {
212+ if validationErr := ports.ValidateRemotePreview(preview); validationErr != nil {
213+ err = validationErr
214+ } else if preview.Status != ports.RemotePreviewOK {
215+ err = fmt.Errorf("remote preview returned status %d", preview.Status)
216+ } else if preview.LifecycleID != target.LifecycleID || preview.TabID != target.LiveTabID {
217+ err = errors.New("remote preview identity changed")
218+ }
219+ }
220+}
221+
222+func (d *Daemon) finishRemotePreviewFlight(key remotePreviewCacheKey, flight *remotePreviewFlight, preview ports.RemotePreview, err error) {
223+ now := d.clock.Now()
224+ d.remotePreview.mu.Lock()
225+ for cooldownKey, until := range d.remotePreview.cooldowns {
226+ if !now.Before(until) {
227+ delete(d.remotePreview.cooldowns, cooldownKey)
228+ }
229+ }
230+ flight.preview = cloneRemotePreview(preview)
231+ flight.err = err
232+ delete(d.remotePreview.flights, key)
233+ if err == nil {
234+ d.remotePreview.cache[key] = remotePreviewCacheEntry{
235+ Preview: cloneRemotePreview(preview), Fetched: now, Used: now,
236+ }
237+ delete(d.remotePreview.cooldowns, key)
238+ for len(d.remotePreview.cache) > remotePreviewCacheMax {
239+ var oldestKey remotePreviewCacheKey
240+ var oldest time.Time
241+ for candidate, entry := range d.remotePreview.cache {
242+ used := entry.Used
243+ if used.IsZero() {
244+ used = entry.Fetched
245+ }
246+ if oldest.IsZero() || used.Before(oldest) {
247+ oldestKey, oldest = candidate, used
248+ }
249+ }
250+ delete(d.remotePreview.cache, oldestKey)
251+ }
252+ } else if !errors.Is(err, context.Canceled) {
253+ d.remotePreview.cooldowns[key] = now.Add(remotePreviewCooldown)
254+ }
255+ close(flight.done)
256+ d.remotePreview.mu.Unlock()
257+}
A internal/usecase/daemon/remote_preview_cache_test.go +489 -0
@@ -0,0 +1,489 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/picker"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+type remotePreviewTestClock struct {
19+ mu sync.Mutex
20+ now time.Time
21+}
22+
23+func (c *remotePreviewTestClock) Now() time.Time {
24+ c.mu.Lock()
25+ defer c.mu.Unlock()
26+ return c.now
27+}
28+
29+func (c *remotePreviewTestClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
30+
31+func (c *remotePreviewTestClock) Advance(delta time.Duration) {
32+ c.mu.Lock()
33+ c.now = c.now.Add(delta)
34+ c.mu.Unlock()
35+}
36+
37+type remotePreviewTestClient struct {
38+ mu sync.Mutex
39+ calls int
40+ result ports.RemotePreview
41+ err error
42+ started chan struct{}
43+ finished chan struct{}
44+ release chan struct{}
45+ lastWidth uint16
46+ lastHeight uint16
47+ lastTarget domain.RemoteSessionTarget
48+ ignoreCancel bool
49+ startedSignal bool
50+ finishedSignal bool
51+}
52+
53+func (c *remotePreviewTestClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
54+ c.mu.Lock()
55+ c.calls++
56+ c.lastTarget = target
57+ c.lastWidth, c.lastHeight = width, height
58+ result, err := c.result, c.err
59+ started, release, finished := c.started, c.release, c.finished
60+ if started != nil && !c.startedSignal {
61+ c.startedSignal = true
62+ close(started)
63+ }
64+ c.mu.Unlock()
65+ if release != nil {
66+ if c.ignoreCancel {
67+ <-release
68+ } else {
69+ select {
70+ case <-release:
71+ case <-ctx.Done():
72+ return ports.RemotePreview{}, ctx.Err()
73+ }
74+ }
75+ }
76+ if finished != nil {
77+ c.mu.Lock()
78+ if !c.finishedSignal {
79+ c.finishedSignal = true
80+ close(finished)
81+ }
82+ c.mu.Unlock()
83+ }
84+ return cloneRemotePreview(result), err
85+}
86+
87+func (c *remotePreviewTestClient) Calls() int {
88+ c.mu.Lock()
89+ defer c.mu.Unlock()
90+ return c.calls
91+}
92+
93+func (c *remotePreviewTestClient) LastSize() (uint16, uint16) {
94+ c.mu.Lock()
95+ defer c.mu.Unlock()
96+ return c.lastWidth, c.lastHeight
97+}
98+
99+func (c *remotePreviewTestClient) LastTarget() domain.RemoteSessionTarget {
100+ c.mu.Lock()
101+ defer c.mu.Unlock()
102+ return c.lastTarget
103+}
104+
105+func remotePreviewCacheTarget() domain.RemoteSessionTarget {
106+ var lifecycle domain.SessionLifecycleID
107+ lifecycle[0] = 0x42
108+ return domain.RemoteSessionTarget{
109+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
110+ SessionName: "work", LiveTabID: "tab-1",
111+ }
112+}
113+
114+func remotePreviewCacheResult(target domain.RemoteSessionTarget, revision uint64) ports.RemotePreview {
115+ return remotePreviewCacheResultRune(target, revision, 'x')
116+}
117+
118+func remotePreviewCacheResultRune(target domain.RemoteSessionTarget, revision uint64, value rune) ports.RemotePreview {
119+ return ports.RemotePreview{
120+ Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK,
121+ LifecycleID: target.LifecycleID, TabID: target.LiveTabID,
122+ Revision: revision, Width: 1, Height: 1,
123+ Cells: []renderer.Cell{{Rune: value, Style: renderer.DefaultStyle()}},
124+ }
125+}
126+
127+func TestFetchRemotePreviewSingleFlightAndCopiesCache(t *testing.T) {
128+ clock := &remotePreviewTestClock{now: time.Unix(100, 0)}
129+ target := remotePreviewCacheTarget()
130+ client := &remotePreviewTestClient{
131+ result: remotePreviewCacheResult(target, 1),
132+ started: make(chan struct{}),
133+ release: make(chan struct{}),
134+ }
135+ d := newTestDaemon(t, nil, clock)
136+ d.remotePreviewClient = client
137+
138+ firstDone := make(chan struct{})
139+ var first ports.RemotePreview
140+ var firstErr error
141+ go func() {
142+ first, firstErr = d.fetchRemotePreview(context.Background(), target, 1, 1)
143+ close(firstDone)
144+ }()
145+ <-client.started
146+
147+ secondDone := make(chan struct{})
148+ var second ports.RemotePreview
149+ var secondErr error
150+ go func() {
151+ second, secondErr = d.fetchRemotePreview(context.Background(), target, 1, 1)
152+ close(secondDone)
153+ }()
154+ close(client.release)
155+ <-firstDone
156+ <-secondDone
157+ require.NoError(t, firstErr)
158+ require.NoError(t, secondErr)
159+ require.Equal(t, first, second)
160+ require.Equal(t, 1, client.Calls(), "same key must use one remote request")
161+
162+ first.Cells[0].Rune = 'm'
163+ cached, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
164+ require.NoError(t, err)
165+ require.Equal(t, rune('x'), cached.Cells[0].Rune, "callers must not mutate the memory-only cache")
166+ require.Equal(t, 1, client.Calls())
167+}
168+
169+func TestFetchRemotePreviewAdapterTimeoutAppliesCooldown(t *testing.T) {
170+ clock := &remotePreviewTestClock{now: time.Unix(250, 0)}
171+ target := remotePreviewCacheTarget()
172+ client := &remotePreviewTestClient{err: ports.ErrRemotePreviewTimeout}
173+ d := newTestDaemon(t, nil, clock)
174+ d.remotePreviewClient = client
175+
176+ _, firstErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
177+ require.ErrorIs(t, firstErr, ports.ErrRemotePreviewTimeout)
178+ _, secondErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
179+ require.ErrorIs(t, secondErr, errRemotePreviewCooldown)
180+ require.Equal(t, 1, client.Calls())
181+}
182+
183+func TestFetchRemotePreviewRejectsMalformedResponseAndAppliesCooldown(t *testing.T) {
184+ clock := &remotePreviewTestClock{now: time.Unix(200, 0)}
185+ target := remotePreviewCacheTarget()
186+ client := &remotePreviewTestClient{err: errors.New("remote unavailable")}
187+ d := newTestDaemon(t, nil, clock)
188+ d.remotePreviewClient = client
189+
190+ _, firstErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
191+ require.Error(t, firstErr)
192+ _, secondErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
193+ require.Error(t, secondErr)
194+ require.Equal(t, 1, client.Calls(), "a failed target must be cooled down")
195+
196+ clock.Advance(remotePreviewCooldown)
197+ _, thirdErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
198+ require.Error(t, thirdErr)
199+ require.Equal(t, 2, client.Calls(), "cooldown expiry must permit a retry")
200+}
201+
202+func TestFetchRemotePreviewServesStaleWhileRefreshing(t *testing.T) {
203+ clock := &remotePreviewTestClock{now: time.Unix(300, 0)}
204+ target := remotePreviewCacheTarget()
205+ client := &remotePreviewTestClient{result: remotePreviewCacheResult(target, 1)}
206+ d := newTestDaemon(t, nil, clock)
207+ d.remotePreviewClient = client
208+
209+ first, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
210+ require.NoError(t, err)
211+ require.Equal(t, uint64(1), first.Revision)
212+
213+ clock.Advance(remotePreviewCacheTTL + time.Second)
214+ client.mu.Lock()
215+ client.result = remotePreviewCacheResult(target, 2)
216+ client.started = make(chan struct{})
217+ client.release = make(chan struct{})
218+ client.startedSignal = false
219+ client.finishedSignal = false
220+ client.mu.Unlock()
221+
222+ stale, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
223+ require.NoError(t, err)
224+ require.Equal(t, uint64(1), stale.Revision, "expired content remains available during revalidation")
225+ <-client.started
226+ key := remotePreviewKeyFor(target, 1, 1)
227+ d.remotePreview.mu.Lock()
228+ flight := d.remotePreview.flights[key]
229+ d.remotePreview.mu.Unlock()
230+ require.NotNil(t, flight)
231+ close(client.release)
232+ <-flight.done
233+
234+ fresh, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
235+ require.NoError(t, err)
236+ require.Equal(t, uint64(2), fresh.Revision)
237+ require.Equal(t, 2, client.Calls())
238+}
239+
240+type immediateRemotePreviewTimer struct{ ch <-chan time.Time }
241+
242+func (t immediateRemotePreviewTimer) C() <-chan time.Time { return t.ch }
243+func (t immediateRemotePreviewTimer) Reset(time.Duration) bool { return false }
244+func (t immediateRemotePreviewTimer) Stop() bool { return true }
245+
246+type immediateRemotePreviewClock struct{}
247+
248+func (immediateRemotePreviewClock) Now() time.Time { return time.Unix(500, 0) }
249+func (immediateRemotePreviewClock) NewTimer(time.Duration) ports.Timer {
250+ ch := make(chan time.Time, 1)
251+ ch <- time.Unix(500, 0)
252+ return immediateRemotePreviewTimer{ch: ch}
253+}
254+
255+func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *testing.T) {
256+ d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
257+ firstTarget := remotePreviewCacheTarget()
258+ firstStarted := make(chan struct{})
259+ firstRelease := make(chan struct{})
260+ client := &remotePreviewTestClient{
261+ result: remotePreviewCacheResult(firstTarget, 1),
262+ started: firstStarted,
263+ release: firstRelease,
264+ ignoreCancel: true,
265+ }
266+ d.remotePreviewClient = client
267+ _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
268+
269+ firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
270+ firstView := picker.SessionView{
271+ ID: firstKey.ID(), Name: firstTarget.SessionName,
272+ Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}},
273+ RemoteKey: &firstKey, RemoteTarget: &firstTarget,
274+ RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
275+ }
276+ firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
277+ firstSelected, ok := firstModel.Selected()
278+ require.True(t, ok)
279+ ac.overlays.pickerMu.Lock()
280+ ac.overlays.picker = firstModel
281+ ac.overlays.pickerIntent = pickerNavigate
282+ ac.overlays.pickerPreviewGeneration = 1
283+ ac.overlays.pickerMu.Unlock()
284+ d.startRemotePickerPreview(ac, firstSelected, 1)
285+ <-firstStarted
286+
287+ d.closePicker(ac)
288+
289+ secondTarget := firstTarget
290+ secondTarget.LifecycleID[0]++
291+ secondTarget.LiveTabID = "tab-2"
292+ client.mu.Lock()
293+ client.result = remotePreviewCacheResultRune(secondTarget, 2, 'y')
294+ client.started = make(chan struct{})
295+ client.release = make(chan struct{})
296+ client.startedSignal = false
297+ client.finishedSignal = false
298+ client.mu.Unlock()
299+ secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
300+ secondView := picker.SessionView{
301+ ID: secondKey.ID(), Name: secondTarget.SessionName,
302+ Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}},
303+ RemoteKey: &secondKey, RemoteTarget: &secondTarget,
304+ RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
305+ }
306+ secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
307+ secondSelected, ok := secondModel.Selected()
308+ require.True(t, ok)
309+ ac.overlays.pickerMu.Lock()
310+ ac.overlays.picker = secondModel
311+ ac.overlays.pickerIntent = pickerNavigate
312+ ac.overlays.pickerPreviewGeneration++
313+ secondGeneration := ac.overlays.pickerPreviewGeneration
314+ ac.overlays.pickerMu.Unlock()
315+ d.startRemotePickerPreview(ac, secondSelected, secondGeneration)
316+ <-client.started
317+
318+ close(firstRelease)
319+ close(client.release)
320+ width, height := remotePickerPreviewSize(ac.sizeSnapshot())
321+ for _, key := range []remotePreviewCacheKey{
322+ remotePreviewKeyFor(firstTarget, width, height),
323+ remotePreviewKeyFor(secondTarget, width, height),
324+ } {
325+ d.remotePreview.mu.Lock()
326+ flight := d.remotePreview.flights[key]
327+ d.remotePreview.mu.Unlock()
328+ if flight != nil {
329+ <-flight.done
330+ }
331+ }
332+ var preview picker.Preview
333+ require.Eventually(t, func() bool {
334+ ac.overlays.pickerMu.Lock()
335+ preview = ac.overlays.pickerRemotePreview
336+ currentGeneration := ac.overlays.pickerPreviewGeneration
337+ ac.overlays.pickerMu.Unlock()
338+ return currentGeneration == secondGeneration && len(preview.Rows) != 0 && len(preview.Rows[0]) != 0 && preview.Rows[0][0].Rune == 'y'
339+ }, time.Second, time.Millisecond)
340+ require.Equal(t, rune('y'), preview.Rows[0][0].Rune)
341+ require.Equal(t, 2, client.Calls())
342+}
343+
344+func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) {
345+ d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
346+ firstTarget := remotePreviewCacheTarget()
347+ secondTarget := firstTarget
348+ secondTarget.LifecycleID[0]++
349+ secondTarget.LiveTabID = "tab-2"
350+ client := &remotePreviewTestClient{result: remotePreviewCacheResult(firstTarget, 1), started: make(chan struct{})}
351+ d.remotePreviewClient = client
352+ _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
353+ firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
354+ secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
355+ views := []picker.SessionView{
356+ {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true},
357+ {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true},
358+ }
359+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
360+ ac.overlays.pickerMu.Lock()
361+ ac.overlays.picker = model
362+ ac.overlays.pickerIntent = pickerNavigate
363+ ac.overlays.pickerPreviewGeneration = 1
364+ ac.overlays.pickerMu.Unlock()
365+ d.registerPreviewForSelection(ac)
366+ <-client.started
367+ require.Equal(t, firstTarget, client.LastTarget())
368+
369+ client.mu.Lock()
370+ client.result = remotePreviewCacheResult(secondTarget, 2)
371+ client.started = make(chan struct{})
372+ client.startedSignal = false
373+ client.mu.Unlock()
374+ ac.overlays.pickerMu.Lock()
375+ model.Down()
376+ ac.overlays.pickerMu.Unlock()
377+ d.registerPreviewForSelection(ac)
378+ <-client.started
379+ require.Equal(t, secondTarget, client.LastTarget())
380+ require.Equal(t, 2, client.Calls())
381+}
382+
383+func TestRemotePickerPreviewErrorCannotPublishAfterSelectionChange(t *testing.T) {
384+ d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
385+ firstTarget := remotePreviewCacheTarget()
386+ secondTarget := firstTarget
387+ secondTarget.LifecycleID[0]++
388+ secondTarget.LiveTabID = "tab-2"
389+ client := &remotePreviewTestClient{
390+ err: errors.New("remote unavailable"),
391+ started: make(chan struct{}),
392+ release: make(chan struct{}),
393+ }
394+ d.remotePreviewClient = client
395+ _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
396+ firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
397+ secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
398+ model := picker.New([]picker.SessionView{
399+ {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true},
400+ {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true},
401+ }, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
402+ selected, ok := model.Selected()
403+ require.True(t, ok)
404+ ac.overlays.pickerMu.Lock()
405+ ac.overlays.picker = model
406+ ac.overlays.pickerIntent = pickerNavigate
407+ ac.overlays.pickerPreviewGeneration = 1
408+ ac.overlays.pickerMu.Unlock()
409+ d.startRemotePickerPreview(ac, selected, 1)
410+ <-client.started
411+
412+ ac.overlays.pickerMu.Lock()
413+ ac.overlays.pickerRemotePreview = picker.Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{{Rune: 'k', Style: renderer.DefaultStyle()}}}}
414+ model.Down()
415+ ac.overlays.pickerMu.Unlock()
416+ close(client.release)
417+
418+ require.Eventually(t, func() bool {
419+ ac.overlays.pickerMu.Lock()
420+ defer ac.overlays.pickerMu.Unlock()
421+ return len(ac.overlays.pickerRemotePreview.Rows) == 1 && ac.overlays.pickerRemotePreview.Rows[0][0].Rune == 'k'
422+ }, time.Second, time.Millisecond)
423+}
424+
425+func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) {
426+ d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
427+ target := remotePreviewCacheTarget()
428+ firstStarted := make(chan struct{})
429+ client := &remotePreviewTestClient{
430+ result: remotePreviewCacheResult(target, 1),
431+ started: firstStarted,
432+ release: make(chan struct{}),
433+ }
434+ d.remotePreviewClient = client
435+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
436+ key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
437+ view := picker.SessionView{
438+ ID: key.ID(), Name: target.SessionName,
439+ Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
440+ RemoteKey: &key, RemoteTarget: &target,
441+ RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
442+ }
443+ model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
444+ selected, ok := model.Selected()
445+ require.True(t, ok)
446+ ac.overlays.pickerMu.Lock()
447+ ac.overlays.picker = model
448+ ac.overlays.pickerIntent = pickerNavigate
449+ ac.overlays.pickerPreviewGeneration = 1
450+ ac.overlays.pickerMu.Unlock()
451+ d.startRemotePickerPreview(ac, selected, 1)
452+ <-firstStarted
453+
454+ secondStarted := make(chan struct{})
455+ client.mu.Lock()
456+ client.started = secondStarted
457+ client.release = nil
458+ client.startedSignal = false
459+ client.mu.Unlock()
460+ token := sess.attachmentToken(ac, ac.transport())
461+ effect, admitted := ac.beginAttachmentEffect(token)
462+ require.True(t, admitted)
463+ token.effect = effect
464+ defer effect.End()
465+ require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30}))
466+ <-secondStarted
467+ wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30})
468+ gotWidth, gotHeight := client.LastSize()
469+ require.Equal(t, wantWidth, gotWidth)
470+ require.Equal(t, wantHeight, gotHeight)
471+
472+ require.Eventually(t, func() bool {
473+ ac.overlays.pickerMu.Lock()
474+ defer ac.overlays.pickerMu.Unlock()
475+ return ac.overlays.pickerRemotePreview.Width == 1 && ac.overlays.pickerRemotePreview.Height == 1
476+ }, time.Second, time.Millisecond)
477+ require.Equal(t, 2, client.Calls())
478+}
479+
480+func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) {
481+ clock := &remotePreviewTestClock{now: time.Unix(400, 0)}
482+ client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)}
483+ d := newTestDaemon(t, nil, clock)
484+ d.remotePreviewClient = client
485+
486+ _, err := d.fetchRemotePreview(context.Background(), remotePreviewCacheTarget(), ports.RemotePreviewMaxWidth+1, 1)
487+ require.ErrorIs(t, err, ports.ErrInvalidRemotePreviewRequest)
488+ require.Zero(t, client.Calls())
489+}
A internal/usecase/daemon/remote_preview_capture_test.go +65 -0
@@ -0,0 +1,65 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
12+)
13+
14+func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) {
15+ d := newTestDaemon(t, nil, stubClock{})
16+ sess := addControlSession(d, "work", "tab-1", "pane-1")
17+ sess.ephemeral = false
18+ sess.incarnation = remoteLifecycleForTest()
19+ pane := sess.tabs[0].focusedPane()
20+ pane.mu.Lock()
21+ pane.screen = vt.NewScreen(2, 3)
22+ pane.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'a'})
23+ pane.screen.Frame.Set(0, 1, renderer.Cell{Rune: 'b'})
24+ pane.screen.Frame.Set(0, 2, renderer.Cell{Rune: 'c'})
25+ pane.mu.Unlock()
26+
27+ target := domain.RemoteSessionTarget{
28+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
29+ SessionName: "work", LiveTabID: "tab-1",
30+ }
31+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
32+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 1, Height: 2,
33+ })
34+ require.NoError(t, err)
35+ require.Equal(t, []rune{'b', 'c'}, []rune{preview.Cells[0].Rune, preview.Cells[1].Rune})
36+}
37+
38+func TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(t *testing.T) {
39+ d := newTestDaemon(t, nil, stubClock{})
40+ sess := addControlSession(d, "work", "tab-1", "pane-1")
41+ sess.ephemeral = false
42+ sess.incarnation = remoteLifecycleForTest()
43+ pane := sess.tabs[0].focusedPane()
44+ pane.mu.Lock()
45+ pane.screen = vt.NewScreen(3, 1)
46+ style := renderer.DefaultStyle()
47+ style.Bold = true
48+ pane.screen.Frame.Set(1, 0, renderer.Cell{Rune: '界', Style: style})
49+ pane.screen.Frame.Set(2, 0, renderer.Cell{Continuation: true, Style: style})
50+ pane.mu.Unlock()
51+
52+ target := domain.RemoteSessionTarget{
53+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
54+ SessionName: "work", LiveTabID: "tab-1",
55+ }
56+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
57+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 1,
58+ })
59+ require.NoError(t, err)
60+ require.Equal(t, uint16(2), preview.Width)
61+ require.Equal(t, rune(' '), preview.Cells[1].Rune, "a crop cannot expose a dangling wide rune")
62+ require.False(t, preview.Cells[1].Continuation)
63+ require.True(t, preview.Cells[1].Style.Equal(style), "boundary replacement preserves the cell style")
64+ require.NoError(t, ports.ValidateRemotePreview(preview))
65+}
A internal/usecase/daemon/remote_route_test.go +43 -0
@@ -0,0 +1,43 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRouteRemoteTargetSelectsExactLiveTab(t *testing.T) {
14+ d := newTestDaemon(t, nil, stubClock{})
15+ sess := addControlSession(d, "work", "tab-1", "pane-1")
16+ sess.ephemeral = false
17+ sess.incarnation = remoteLifecycleForTest()
18+ tr, _ := newCapturingTransport(t)
19+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation, SessionName: "work", LiveTabID: "tab-1"}
20+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
21+ _, ac, err := d.routeWithContext(context.Background(), hello, tr)
22+ require.NoError(t, err)
23+ require.NotNil(t, ac)
24+ require.Same(t, sess, ac.currentAttachmentSession())
25+ require.Equal(t, domain.TabStableID("tab-1"), ac.viewSnapshot().tabID)
26+ d.clientGone(sess, ac, tr, false)
27+}
28+
29+func TestRouteRemoteTargetRejectsSameNameReplacement(t *testing.T) {
30+ d := newTestDaemon(t, nil, stubClock{})
31+ sess := addControlSession(d, "work", "tab-new", "pane-new")
32+ sess.ephemeral = false
33+ sess.incarnation = remoteLifecycleForTest()
34+ tr, _ := newCapturingTransport(t)
35+ var old domain.SessionLifecycleID
36+ old[0] = 8
37+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: old, SessionName: "work", LiveTabID: "tab-new"}
38+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
39+ _, _, err := d.routeWithContext(context.Background(), hello, tr)
40+ var protocol *protoErr
41+ require.ErrorAs(t, err, &protocol)
42+ require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
43+}
A internal/usecase/daemon/remote_stopped_route_test.go +47 -0
@@ -0,0 +1,47 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+func TestRouteRemoteTargetRestoresStoppedStableTabAndOwnsEnvironment(t *testing.T) {
14+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
15+ lifecycle := remoteLifecycleForTest()
16+ d.mu.Lock()
17+ d.stopped["work"] = stoppedSession{
18+ name: "work", cwd: "/remote/work", incarnation: lifecycle,
19+ tabNames: []string{"alpha", "beta"},
20+ tabRecords: []domain.CatalogueTabRecord{
21+ {StableID: "tab-a", Name: "alpha"},
22+ {StableID: "tab-b", Name: "beta"},
23+ },
24+ }
25+ d.mu.Unlock()
26+
27+ target := domain.RemoteSessionTarget{
28+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
29+ SessionName: "work", Stopped: true,
30+ StoppedTab: domain.NewStableTabSelector("tab-b"),
31+ }
32+ tr, _ := newCapturingTransport(t)
33+ hello := ports.Hello{
34+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
35+ Size: domain.Size{Cols: 80, Rows: 24}, RemoteTarget: &target,
36+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
37+ Env: []string{"VEV_REMOTE_PICKER_SENTINEL=untrusted"},
38+ }
39+ sess, ac, err := d.routeWithContext(context.Background(), hello, tr)
40+ require.NoError(t, err)
41+ require.NotNil(t, sess)
42+ require.NotNil(t, ac)
43+ require.Equal(t, lifecycle, sess.incarnation)
44+ require.Equal(t, domain.TabStableID("tab-b"), ac.viewSnapshot().tabID)
45+ require.NotContains(t, sess.env, "VEV_REMOTE_PICKER_SENTINEL=untrusted")
46+ d.clientGone(sess, ac, tr, false)
47+}
A internal/usecase/daemon/remote_stopped_selector_test.go +66 -0
@@ -0,0 +1,66 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func TestRemoteStoppedSelectorsResolveOnlyAgainstExactTabMetadata(t *testing.T) {
12+ lifecycle := remoteLifecycleForTest()
13+ stopped := stoppedSession{
14+ name: "work", incarnation: lifecycle,
15+ tabNames: []string{"alpha", "beta"},
16+ tabRecords: []domain.CatalogueTabRecord{
17+ {StableID: "tab-a", Name: "alpha"},
18+ {StableID: "tab-b", Name: "beta"},
19+ },
20+ }
21+
22+ stable := domain.RemoteSessionTarget{
23+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle,
24+ SessionName: "work", Stopped: true,
25+ StoppedTab: domain.NewStableTabSelector("tab-b"),
26+ }
27+ index, ok := remoteTargetTabIndexStopped(stopped, stable)
28+ require.True(t, ok)
29+ require.Equal(t, 1, index)
30+
31+ ordinal := stable
32+ ordinal.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 2)
33+ index, ok = remoteTargetTabIndexStopped(stopped, ordinal)
34+ require.True(t, ok)
35+ require.Equal(t, 1, index)
36+
37+ for _, invalid := range []domain.RemoteSessionTarget{
38+ func() domain.RemoteSessionTarget {
39+ candidate := stable
40+ candidate.StoppedTab = domain.NewStableTabSelector("missing")
41+ return candidate
42+ }(),
43+ func() domain.RemoteSessionTarget {
44+ candidate := ordinal
45+ candidate.StoppedTab = domain.NewOrdinalTabSelector(1, "replaced", 2)
46+ return candidate
47+ }(),
48+ func() domain.RemoteSessionTarget {
49+ candidate := ordinal
50+ candidate.StoppedTab = domain.NewOrdinalTabSelector(1, "beta", 3)
51+ return candidate
52+ }(),
53+ } {
54+ _, ok := remoteTargetTabIndexStopped(stopped, invalid)
55+ require.False(t, ok)
56+ }
57+
58+ reordered := stopped
59+ reordered.tabRecords = []domain.CatalogueTabRecord{
60+ {StableID: "tab-b", Name: "beta"},
61+ {StableID: "tab-a", Name: "alpha"},
62+ }
63+ index, ok = remoteTargetTabIndexStopped(reordered, stable)
64+ require.True(t, ok)
65+ require.Equal(t, 0, index, "stable selectors follow identity, not the old ordinal")
66+}
M internal/usecase/daemon/render.go +3 -0
@@ -375,6 +375,9 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
375375 preview := snapshotPickerPreview(nil)
376376 if local && overlays.previewTab != tb {
377377 preview = snapshotPickerPreview(overlays.previewTab)
378+ if overlays.previewTab == nil && overlays.remotePreview.Height > 0 {
379+ preview = overlays.remotePreview
380+ }
378381 }
379382 if local {
380383 d.refreshSessionFocusedTitles(sess)
M internal/usecase/daemon/session.go +44 -16
@@ -226,12 +226,12 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
226226 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
227227 }
228228 }
229- if incarnation == (domain.IncarnationID{}) {
230- var err error
231- incarnation, err = domain.NewIncarnationID(rand.Reader)
232- if err != nil {
233- return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate durable identity: %w", err))
234- }
229+ }
230+ if incarnation == (domain.IncarnationID{}) {
231+ var err error
232+ incarnation, err = domain.NewSessionLifecycleID(rand.Reader)
233+ if err != nil {
234+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate lifecycle identity: %w", err))
235235 }
236236 }
237237
@@ -240,12 +240,22 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
240240 }
241241 tbSize := contentSize(sz)
242242 var names []string
243- if len(restoredTabNames) > 0 {
243+ var tabRecords []domain.CatalogueTabRecord
244+ if resuming && len(stopped.tabRecords) != 0 {
245+ tabRecords = append([]domain.CatalogueTabRecord(nil), stopped.tabRecords...)
246+ names = make([]string, len(tabRecords))
247+ for i, record := range tabRecords {
248+ names[i] = record.Name
249+ }
250+ } else if len(restoredTabNames) > 0 {
244251 names = append([]string(nil), restoredTabNames[0]...)
245252 }
246253 tabs := make([]*tab, 0, max(1, len(names)))
247254 for i := range max(1, len(names)) {
248255 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
256+ if i < len(tabRecords) && tabRecords[i].StableID != "" {
257+ tabStableID = string(tabRecords[i].StableID)
258+ }
249259 if err != nil {
250260 closeTabs(tabs)
251261 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
@@ -317,7 +327,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
317327 }
318328 var rollbackDurable func() error
319329 if !ephemeral && d.persistEnabled {
320- record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}
330+ record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, TabRecords: tabRecordsFromTabs(tabs)}
321331 if d.recovery == nil {
322332 closeTabs(tabs)
323333 cancel()
@@ -844,11 +854,13 @@ func (d *Daemon) renameSession(sess *session, name string) error {
844854 d.mu.Unlock()
845855 return err
846856 }
847- sess.incarnation, err = domain.NewIncarnationID(rand.Reader)
848- if err != nil {
849- sess.mu.Unlock()
850- d.mu.Unlock()
851- return fmt.Errorf("generate durable identity: %w", err)
857+ if sess.incarnation == (domain.IncarnationID{}) {
858+ sess.incarnation, err = domain.NewSessionLifecycleID(rand.Reader)
859+ if err != nil {
860+ sess.mu.Unlock()
861+ d.mu.Unlock()
862+ return fmt.Errorf("generate durable identity: %w", err)
863+ }
852864 }
853865 }
854866 lastUsedSeq := sess.mruAt.Load()
@@ -925,6 +937,20 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
925937 return nil
926938 }
927939
940+func tabRecordsFromTabs(tabs []*tab) []domain.CatalogueTabRecord {
941+ if len(tabs) == 0 {
942+ return nil
943+ }
944+ records := make([]domain.CatalogueTabRecord, len(tabs))
945+ for i, tb := range tabs {
946+ if tb == nil {
947+ continue
948+ }
949+ records[i] = domain.CatalogueTabRecord{StableID: domain.TabStableID(tb.stableID), Name: tb.name}
950+ }
951+ return records
952+}
953+
928954 func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
929955 createdAt := s.createdAt
930956 tabNames := make([]string, len(s.tabs))
@@ -940,7 +966,7 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
940966 } else {
941967 tabNames = tabNames[:lastCustom+1]
942968 }
943- return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
969+ return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, TabRecords: tabRecordsFromTabs(s.tabs)}
944970 }
945971
946972 // closeTab is the public close boundary for callers that are not already
@@ -1545,13 +1571,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15451571 stoppedName := sess.name
15461572 stoppedCwd := sess.cwd
15471573 createdAt := sess.createdAt
1548- tabNames := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1))).TabNames
1574+ stoppedRecord := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1575+ tabNames := stoppedRecord.TabNames
1576+ tabRecords := stoppedRecord.TabRecords
15491577 ephemeral := sess.ephemeral
15501578 unlockCoordinators()
15511579 unlockSessions()
15521580 d.notices.routingMu.Unlock()
15531581 if !ephemeral {
1554- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
1582+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, tabRecords: tabRecords, record: stoppedRecord, state: ports.SessionStopped, purging: purge}
15551583 d.stopped[stoppedName] = stopped
15561584 }
15571585 empty := len(d.sessions) == 0
M internal/usecase/daemon/snapshot_restore_incremental.go +18 -1
@@ -69,13 +69,30 @@ func (d *Daemon) restoreCatalogue(ctx context.Context, records []domain.Catalogu
6969 }
7070
7171 func stoppedSessionFromRecord(record domain.CatalogueRecord, state ports.SessionState, done chan struct{}) stoppedSession {
72+ tabRecords := append([]domain.CatalogueTabRecord(nil), record.TabRecords...)
73+ tabNames := append([]string(nil), record.TabNames...)
74+ if len(tabRecords) != 0 {
75+ tabNames = make([]string, len(tabRecords))
76+ for i, tab := range tabRecords {
77+ tabNames[i] = tab.Name
78+ }
79+ } else if len(tabNames) != 0 {
80+ // Legacy catalogue records had no stable IDs. Preserve every encoded
81+ // ordinal, including unnamed entries, and resolve them by exact name
82+ // and expected count during stopped restoration.
83+ tabRecords = make([]domain.CatalogueTabRecord, len(tabNames))
84+ for i, name := range tabNames {
85+ tabRecords[i] = domain.CatalogueTabRecord{Name: name}
86+ }
87+ }
7288 return stoppedSession{
7389 name: record.Name,
7490 cwd: record.Cwd,
7591 createdAt: record.CreatedAt,
7692 incarnation: record.IncarnationID,
7793 lastUsedSeq: record.LastUsedSeq,
78- tabNames: append([]string(nil), record.TabNames...),
94+ tabNames: tabNames,
95+ tabRecords: tabRecords,
7996 record: record,
8097 state: state,
8198 restoreDone: done,
M internal/usecase/picker/model.go +89 -25
@@ -35,10 +35,14 @@ type SessionView struct {
3535 Stopped bool
3636 ExpectedCreatedAt *int64
3737 RemoteKey *domain.RemoteSessionKey
38+ // RemoteTarget is the structured route/lifecycle identity for picker rows.
39+ // It is never reconstructed from Name or a rendered label.
40+ RemoteTarget *domain.RemoteSessionTarget
3841 // RemoteHost marks remote host status rows that have no session key.
3942 RemoteHost string
4043 RemoteAvailability RemoteAvailability
4144 RemoteDetail string
45+ RemoteReason string
4246 ConnectOnly bool
4347 RemoteAttachReady bool
4448 // CannotAcceptMoves reports whether this session cannot receive a moved tab
@@ -51,6 +55,7 @@ type SessionView struct {
5155 type TabEntry struct {
5256 TabID domain.TabStableID // stable tab identity; independent of its current index
5357 Name string // tab display name
58+ RawName string // unformatted name used by exact remote selectors
5459 Detail string // " (paneTitle)" or "", drawn muted
5560 Attention bool // draw the attention marker right after Name, before Detail
5661 }
@@ -110,13 +115,16 @@ func defaultRenderStyles() RenderStyles {
110115 }
111116
112117 type Target struct {
113- Session domain.SessionID
114- Incarnation domain.IncarnationID
115- Name string
116- RemoteKey *domain.RemoteSessionKey
117- TabID domain.TabStableID
118- TabIndex int
119- Stopped bool
118+ Session domain.SessionID
119+ Incarnation domain.IncarnationID
120+ Name string
121+ RemoteKey *domain.RemoteSessionKey
122+ RemoteTarget *domain.RemoteSessionTarget
123+ RemoteHost string
124+ UnavailableReason string
125+ TabID domain.TabStableID
126+ TabIndex int
127+ Stopped bool
120128 // ExpectedCreatedAt optionally pins this target to a particular named
121129 // session lifecycle. Callers that obtain a snapshot outside the daemon use
122130 // it to reject a same-name replacement at commit time.
@@ -187,7 +195,12 @@ type row struct {
187195 remoteKey domain.RemoteSessionKey
188196 hasRemoteKey bool
189197 remote bool
198+ remoteTarget domain.RemoteSessionTarget
199+ hasRemoteTarget bool
200+ remoteHost string
201+ unavailableReason string
190202 selectable bool
203+ focusable bool
191204 dim bool
192205 }
193206
@@ -202,7 +215,7 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
202215 if selectionMatches(pickerRow, config.Current, config.Mode) {
203216 m.selected = idx
204217 }
205- if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.selectable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
218+ if config.Mode == SelectNavigationTab && activeSelection < 0 && pickerRow.focusable && pickerRow.kind == rowTab && pickerRow.tabIndex == normalizedActive(session) {
206219 activeSelection = idx
207220 }
208221 }
@@ -211,7 +224,7 @@ func New(sessions []SessionView, config SelectionConfig) *Model {
211224 m.selected = activeSelection
212225 }
213226 if m.selected < 0 {
214- m.selected = m.firstSelectable()
227+ m.selected = m.firstFocusable()
215228 }
216229 if m.selected < 0 && len(m.rows) > 0 {
217230 m.selected = 0
@@ -247,20 +260,38 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
247260 if session.RemoteKey != nil {
248261 common.remoteKey, common.hasRemoteKey = *session.RemoteKey, true
249262 }
250- common.remote = common.hasRemoteKey || session.RemoteHost != ""
263+ if session.RemoteTarget != nil {
264+ common.remoteTarget, common.hasRemoteTarget = *session.RemoteTarget, true
265+ }
266+ common.remoteHost = session.RemoteHost
267+ common.remote = common.hasRemoteKey || common.hasRemoteTarget || session.RemoteHost != ""
251268 header := common
252269 header.kind, header.dispName, header.tabIndex = rowSession, session.Name, -1
253270 header.selectable = header.kind.selectable(config.Mode)
271+ header.focusable = header.selectable
254272 if common.remote {
255273 header.detail = remoteRowDetail(session)
256- header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session)
274+ // Rich catalog rows use tab targets. Legacy count-only/connect-only
275+ // callers retain their header target until all peers carry the richer
276+ // identity contract.
277+ if common.hasRemoteTarget {
278+ header.selectable = false
279+ header.focusable = config.Mode == SelectNavigationTab && len(session.Tabs) == 0
280+ } else {
281+ header.selectable = common.hasRemoteKey && config.Mode == SelectNavigationTab && session.ConnectOnly && remoteSelectable(session)
282+ header.focusable = header.selectable
283+ if !common.hasRemoteKey && !common.hasRemoteTarget {
284+ header.focusable = config.Mode == SelectNavigationTab
285+ }
286+ }
287+ header.unavailableReason = session.RemoteReason
257288 header.dim = remoteDim(session)
258289 }
259290 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
260- header.selectable, header.dim = false, true
291+ header.selectable, header.focusable, header.dim = false, false, true
261292 }
262293 rows := []row{header}
263- if !session.ConnectOnly {
294+ if !session.ConnectOnly || common.hasRemoteTarget {
264295 for i, tab := range session.Tabs {
265296 if config.Mode == SelectMovePaneTab && sourceMatchesTab(config.Source, session, tab) {
266297 continue
@@ -269,12 +300,40 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
269300 tabRow.kind, tabRow.dispName, tabRow.detail, tabRow.attention = rowTab, tab.Name, tab.Detail, tab.Attention
270301 tabRow.tabID, tabRow.tabIndex = tab.TabID, i
271302 tabRow.selectable = tabRow.kind.selectable(config.Mode)
303+ tabRow.focusable = tabRow.selectable
272304 if common.remote {
273- tabRow.selectable = false
305+ tabRow.unavailableReason = session.RemoteReason
306+ if common.hasRemoteTarget {
307+ remoteTarget := common.remoteTarget
308+ if session.Stopped {
309+ remoteTarget.LiveTabID = ""
310+ if tab.TabID != "" {
311+ remoteTarget.StoppedTab = domain.NewStableTabSelector(tab.TabID)
312+ } else {
313+ remoteTarget.StoppedTab = domain.NewOrdinalTabSelector(uint16(i), tab.RawName, uint16(len(session.Tabs)))
314+ }
315+ } else {
316+ remoteTarget.StoppedTab = domain.TabSelector{}
317+ remoteTarget.LiveTabID = tab.TabID
318+ }
319+ // Keep the selected tab's structured identity even when it is
320+ // invalid. Focus remains possible for diagnostics, while both
321+ // the readiness gate and daemon wire validation reject it.
322+ tabRow.remoteTarget = remoteTarget
323+ tabRow.hasRemoteTarget = true
324+ }
325+ if !tabRow.hasRemoteTarget {
326+ legacyRemote := common.hasRemoteKey && config.Mode == SelectNavigationTab && remoteSelectable(session)
327+ tabRow.focusable = legacyRemote
328+ tabRow.selectable = legacyRemote
329+ } else {
330+ tabRow.focusable = true
331+ tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteSelectable(session)
332+ }
274333 tabRow.dim = remoteDim(session)
275334 }
276335 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
277- tabRow.selectable, tabRow.dim = false, true
336+ tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
278337 }
279338 rows = append(rows, tabRow)
280339 }
@@ -286,7 +345,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
286345 }
287346
288347 func remoteSelectable(session SessionView) bool {
289- return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch
348+ return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch && session.RemoteReason == ""
290349 }
291350
292351 func remoteDim(session SessionView) bool {
@@ -429,13 +488,13 @@ func (m *Model) SelectNearestRow(idx int) {
429488 }
430489 idx = clamp(idx, 0, len(m.rows)-1)
431490 for i := idx; i < len(m.rows); i++ {
432- if m.rows[i].selectable {
491+ if m.rows[i].focusable {
433492 m.selected = i
434493 return
435494 }
436495 }
437496 for i := idx - 1; i >= 0; i-- {
438- if m.rows[i].selectable {
497+ if m.rows[i].focusable {
439498 m.selected = i
440499 return
441500 }
@@ -474,21 +533,21 @@ func (m *Model) move(delta int) {
474533 if m == nil || len(m.rows) == 0 {
475534 return
476535 }
477- if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].selectable {
478- m.selected = m.firstSelectable()
536+ if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].focusable {
537+ m.selected = m.firstFocusable()
479538 return
480539 }
481540 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
482- if m.rows[i].selectable {
541+ if m.rows[i].focusable {
483542 m.selected = i
484543 return
485544 }
486545 }
487546 }
488547
489-func (m *Model) firstSelectable() int {
548+func (m *Model) firstFocusable() int {
490549 for i, r := range m.rows {
491- if r.selectable {
550+ if r.focusable {
492551 return i
493552 }
494553 }
@@ -496,9 +555,14 @@ func (m *Model) firstSelectable() int {
496555 }
497556
498557 func (r row) target() Target {
558+ var remoteTarget *domain.RemoteSessionTarget
559+ if r.hasRemoteTarget {
560+ copyTarget := r.remoteTarget
561+ remoteTarget = &copyTarget
562+ }
499563 return Target{
500- Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), TabID: r.tabID,
501- TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
564+ Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), RemoteTarget: remoteTarget,
565+ RemoteHost: r.remoteHost, UnavailableReason: r.unavailableReason, TabID: r.tabID, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
502566 }
503567 }
504568
A internal/usecase/picker/remote_parity_test.go +59 -0
@@ -0,0 +1,59 @@
1+package picker
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+)
10+
11+func remoteParityLifecycle(seed byte) domain.SessionLifecycleID {
12+ var id domain.SessionLifecycleID
13+ id[0] = seed
14+ return id
15+}
16+
17+func TestRemoteRowsWithDuplicateLabelsKeepDistinctRoutingIdentity(t *testing.T) {
18+ firstLifecycle := remoteParityLifecycle(1)
19+ secondLifecycle := remoteParityLifecycle(2)
20+ firstKey := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: firstLifecycle, DisplayOrigin: "same-origin"}
21+ secondKey := domain.RemoteSessionKey{Host: "mule", Name: "work", LifecycleID: secondLifecycle, DisplayOrigin: "same-origin"}
22+ firstTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "same-origin", LifecycleID: firstLifecycle, SessionName: "work", LiveTabID: "tab-a"}
23+ secondTarget := domain.RemoteSessionTarget{Endpoint: "mule", DisplayOrigin: "same-origin", LifecycleID: secondLifecycle, SessionName: "work", LiveTabID: "tab-b"}
24+ model := New([]SessionView{
25+ {
26+ ID: firstKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-a", Name: "main"}},
27+ RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: RemoteFresh, RemoteAttachReady: true,
28+ },
29+ {
30+ ID: secondKey.ID(), Name: "same visible label", Tabs: []TabEntry{{TabID: "tab-b", Name: "main"}},
31+ RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: RemoteFresh, RemoteAttachReady: true,
32+ },
33+ }, SelectionConfig{Mode: SelectNavigationTab})
34+
35+ expected := []struct {
36+ name string
37+ session domain.SessionID
38+ endpoint string
39+ lifecycle domain.SessionLifecycleID
40+ tabID domain.TabStableID
41+ }{
42+ {name: "first", session: firstKey.ID(), endpoint: "arch", lifecycle: firstLifecycle, tabID: "tab-a"},
43+ {name: "second", session: secondKey.ID(), endpoint: "mule", lifecycle: secondLifecycle, tabID: "tab-b"},
44+ }
45+ for i, want := range expected {
46+ t.Run(want.name, func(t *testing.T) {
47+ selected, ok := model.Selected()
48+ require.True(t, ok)
49+ require.Equal(t, want.session, selected.Session)
50+ require.NotNil(t, selected.RemoteTarget)
51+ require.Equal(t, want.endpoint, selected.RemoteTarget.Endpoint)
52+ require.Equal(t, want.lifecycle, selected.RemoteTarget.LifecycleID)
53+ require.Equal(t, want.tabID, selected.RemoteTarget.LiveTabID)
54+ })
55+ if i+1 < len(expected) {
56+ model.Down()
57+ }
58+ }
59+}
A scripts/remote-picker-harness/Dockerfile +48 -0
@@ -0,0 +1,48 @@
1+ARG BASE_IMAGE=ubuntu:24.04
2+FROM ${BASE_IMAGE}
3+
4+ENV DEBIAN_FRONTEND=noninteractive \
5+ XDG_STATE_HOME=/tmp/vev-state \
6+ XDG_RUNTIME_DIR=/tmp/vev-runtime \
7+ PATH=/home/test/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
8+ TERM=xterm-256color \
9+ COLORTERM=truecolor
10+
11+RUN apt-get update \
12+ && apt-get install -y --no-install-recommends ca-certificates openssh-client openssh-server \
13+ && rm -rf /var/lib/apt/lists/* \
14+ && useradd --create-home --shell /bin/bash test \
15+ && usermod --password x test \
16+ && install -d -m 0755 /run/sshd /tmp/vev-state /tmp/vev-runtime /home/test/bin \
17+ && chmod 0777 /tmp/vev-state /tmp/vev-runtime \
18+ && install -d -m 0700 -o test -g test /home/test/.ssh
19+
20+COPY vev /usr/local/bin/vev.real
21+COPY harness /usr/local/bin/remote-picker-harness
22+COPY vev-wrapper /usr/local/bin/vev
23+COPY id_ed25519.pub /home/test/.ssh/authorized_keys
24+
25+RUN chown test:test /home/test/.ssh/authorized_keys \
26+ && chmod 0600 /home/test/.ssh/authorized_keys \
27+ && chmod 0755 /usr/local/bin/vev /usr/local/bin/vev.real /usr/local/bin/remote-picker-harness \
28+ && printf '%s\n' \
29+ 'Host *' \
30+ ' IdentityFile /home/test/.ssh/id_ed25519' \
31+ ' StrictHostKeyChecking no' \
32+ ' UserKnownHostsFile /dev/null' \
33+ ' LogLevel ERROR' \
34+ > /home/test/.ssh/config \
35+ && chown test:test /home/test/.ssh/config \
36+ && chmod 0600 /home/test/.ssh/config \
37+ && printf '%s\n' \
38+ 'PermitRootLogin no' \
39+ 'PasswordAuthentication no' \
40+ 'KbdInteractiveAuthentication no' \
41+ 'PubkeyAuthentication yes' \
42+ 'UsePAM no' \
43+ 'AllowUsers test' \
44+ 'PrintMotd no' \
45+ > /etc/ssh/sshd_config.d/vev-harness.conf \
46+ && ssh-keygen -A
47+
48+CMD ["/usr/sbin/sshd", "-D", "-e"]
A scripts/remote-picker-harness/README.md +30 -0
@@ -0,0 +1,30 @@
1+# Remote picker container acceptance
2+
3+Run the disposable two-container acceptance harness with:
4+
5+```sh
6+make remote-acceptance
7+```
8+
9+The harness builds the current binary, starts a local client container and an
10+SSH-enabled remote daemon container on a private Docker network, and removes
11+all resources when it exits. Docker uses the normal CLI configuration, so
12+`DOCKER_HOST` or the active context selects a rootless daemon; the harness does
13+not assume a socket pathname.
14+
15+Environment overrides:
16+
17+- `VEV_HARNESS_BASE_IMAGE` selects the Docker base image used for both
18+ containers (default: `ubuntu:24.04`).
19+- `VEV_HARNESS_TARGET` selects the SSH target passed to the harness binary
20+ (default: `test@remote`).
21+
22+The run exercises real SSH-stdio and UDP attachment, typed live catalog and
23+preview requests, tab creation/removal fencing, exact lifecycle fencing after
24+replacement, daemon restart resume, slow-preview cancellation, version
25+mismatch, and unreachable-host handling. It checks environment values in
26+memory without printing terminal viewport data.
27+
28+Stopped-selector restoration and daemon-owned environment assertions are also
29+covered by the repository's daemon and route integration tests, where the
30+fixture can deterministically hold restoration at the stopped state.
A scripts/remote-picker-harness/main.go +599 -0
@@ -0,0 +1,599 @@
1+package main
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "log/slog"
9+ "os"
10+ "os/exec"
11+ "strings"
12+ "sync"
13+ "sync/atomic"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/adapters/dgram"
17+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
18+ "github.com/bnema/vev/internal/adapters/sshstdio"
19+ "github.com/bnema/vev/internal/domain"
20+ "github.com/bnema/vev/internal/ports"
21+)
22+
23+const (
24+ defaultTarget = "test@remote"
25+ sessionName = "harness"
26+)
27+
28+var requestID atomic.Uint64
29+
30+func main() {
31+ if err := run(); err != nil {
32+ fmt.Fprintf(os.Stderr, "remote picker harness: %v\n", err)
33+ os.Exit(1)
34+ }
35+ fmt.Println("remote picker harness: PASS")
36+}
37+
38+func run() error {
39+ target := os.Getenv("VEV_HARNESS_TARGET")
40+ if target == "" {
41+ target = defaultTarget
42+ }
43+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
44+ defer cancel()
45+
46+ catalog := remoteadapter.NewCatalogClient()
47+ preview := remoteadapter.NewPreviewClient()
48+ if err := runLiveStdioPhase(ctx, target, catalog, preview); err != nil {
49+ return err
50+ }
51+ resumedTarget, err := runRestartResumePhase(ctx, target, catalog, preview)
52+ if err != nil {
53+ return err
54+ }
55+ if err := runUDPPhase(ctx, target); err != nil {
56+ return err
57+ }
58+ if err := runCatalogFailurePhase(ctx, target, catalog); err != nil {
59+ return err
60+ }
61+ if _, err := catalog.List(ctx, "test@unreachable.invalid"); err == nil {
62+ return errors.New("unreachable catalog unexpectedly succeeded")
63+ }
64+ return runStaleTargetPhase(ctx, target, catalog, resumedTarget)
65+}
66+
67+func runLiveStdioPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) error {
68+ stdio, err := openTransport(ctx, ports.RemoteTransportStdio, target)
69+ if err != nil {
70+ return fmt.Errorf("open stdio transport: %w", err)
71+ }
72+ defer func() { _ = stdio.Close() }()
73+ if err := attachAndCheck(ctx, stdio, ports.Hello{
74+ Intent: ports.IntentNew, Name: sessionName, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
75+ TermEnv: "xterm-256color", TrueColor: true,
76+ }); err != nil {
77+ return fmt.Errorf("stdio live attach: %w", err)
78+ }
79+ if err := sendInputAndAwait(ctx, stdio, "printf 'VEV_REMOTE_HARNESS_ENV=%s:%s:%s:%s\\n' \"$VEV_TEST_ENV\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$VEV\"", "local:/run/local:wayland-local:"); err != nil {
80+ return fmt.Errorf("direct client environment: %w", err)
81+ }
82+ live, err := waitForSession(ctx, catalog, target, sessionName, "running")
83+ if err != nil {
84+ return fmt.Errorf("live catalog: %w", err)
85+ }
86+ liveTarget, err := targetForSession(target, live, false)
87+ if err != nil {
88+ return fmt.Errorf("live target: %w", err)
89+ }
90+ if _, err := preview.Preview(ctx, liveTarget, 40, 8); err != nil {
91+ return fmt.Errorf("live preview: %w", err)
92+ }
93+ if err := sendCommand(ctx, stdio, "new-tab"); err != nil {
94+ return fmt.Errorf("create second tab: %w", err)
95+ }
96+ liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 2)
97+ if err != nil {
98+ return fmt.Errorf("multi-tab catalog: %w", err)
99+ }
100+ beforeTabs := ports.CatalogTabs(liveWithTabs)
101+ if err := sendCommand(ctx, stdio, "close-tab"); err != nil {
102+ return fmt.Errorf("remove tab: %w", err)
103+ }
104+ after, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 1)
105+ if err != nil {
106+ return fmt.Errorf("removed-tab catalog: %w", err)
107+ }
108+ removedID, ok := removedCatalogTabID(beforeTabs, ports.CatalogTabs(after))
109+ if !ok {
110+ return errors.New("removed-tab catalog did not identify exactly one removed tab")
111+ }
112+ removedSession := liveWithTabs
113+ removedSession.ActiveTabID = removedID
114+ removedTarget, err := targetForSession(target, removedSession, false)
115+ if err != nil {
116+ return fmt.Errorf("removed-tab target: %w", err)
117+ }
118+ if _, err := preview.Preview(ctx, removedTarget, 40, 8); err == nil {
119+ return errors.New("removed tab unexpectedly produced a preview")
120+ }
121+ survivingTarget, err := targetForSession(target, after, false)
122+ if err != nil {
123+ return fmt.Errorf("surviving-tab target: %w", err)
124+ }
125+ if _, err := preview.Preview(ctx, survivingTarget, 40, 8); err != nil {
126+ return fmt.Errorf("surviving tab preview: %w", err)
127+ }
128+ if err := detach(ctx, stdio); err != nil {
129+ return fmt.Errorf("stop session: %w", err)
130+ }
131+ return nil
132+}
133+
134+func runRestartResumePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, preview ports.RemotePreviewClient) (domain.RemoteSessionTarget, error) {
135+ if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil {
136+ return domain.RemoteSessionTarget{}, fmt.Errorf("stop remote daemon: %w", err)
137+ }
138+ if err := runRemoteCommand(ctx, target, "vev", "--daemon-launcher"); err != nil {
139+ return domain.RemoteSessionTarget{}, fmt.Errorf("restart remote daemon: %w", err)
140+ }
141+ resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "running")
142+ if err != nil {
143+ return domain.RemoteSessionTarget{}, fmt.Errorf("resumed catalog: %w", err)
144+ }
145+ resumedTarget, err := targetForSession(target, resumedCatalog, false)
146+ if err != nil {
147+ return domain.RemoteSessionTarget{}, fmt.Errorf("resumed target: %w", err)
148+ }
149+ resumed, err := openTransport(ctx, ports.RemoteTransportStdio, target)
150+ if err != nil {
151+ return domain.RemoteSessionTarget{}, fmt.Errorf("open restart stdio transport: %w", err)
152+ }
153+ defer func() { _ = resumed.Close() }()
154+ if err := attachAndCheck(ctx, resumed, ports.Hello{
155+ Intent: ports.IntentResume, Name: sessionName, Env: localEnvironment(), Cwd: "/tmp/local-cwd",
156+ TermEnv: "xterm-256color", TrueColor: true, RemoteTarget: &resumedTarget,
157+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
158+ }); err != nil {
159+ return domain.RemoteSessionTarget{}, fmt.Errorf("exact restart resume: %w", err)
160+ }
161+ if _, err := preview.Preview(ctx, resumedTarget, 40, 8); err != nil {
162+ return domain.RemoteSessionTarget{}, fmt.Errorf("restart preview: %w", err)
163+ }
164+ if err := touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview"); err != nil {
165+ return domain.RemoteSessionTarget{}, fmt.Errorf("enable slow preview: %w", err)
166+ }
167+ slowCtx, slowCancel := context.WithTimeout(ctx, 150*time.Millisecond)
168+ _, slowErr := preview.Preview(slowCtx, resumedTarget, 40, 8)
169+ slowCancel()
170+ if err := touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview-off"); err != nil {
171+ return domain.RemoteSessionTarget{}, fmt.Errorf("disable slow preview: %w", err)
172+ }
173+ if slowErr == nil {
174+ return domain.RemoteSessionTarget{}, errors.New("slow preview did not honor cancellation")
175+ }
176+ if err := detach(ctx, resumed); err != nil {
177+ return domain.RemoteSessionTarget{}, fmt.Errorf("stop resumed session: %w", err)
178+ }
179+ return resumedTarget, nil
180+}
181+
182+func runUDPPhase(ctx context.Context, target string) error {
183+ udp, err := openTransport(ctx, ports.RemoteTransportUDP, target)
184+ if err != nil {
185+ return fmt.Errorf("open UDP transport: %w", err)
186+ }
187+ defer func() { _ = udp.Close() }()
188+ if err := attachAndCheck(ctx, udp, ports.Hello{
189+ Intent: ports.IntentNew, Name: "udp-harness", Env: localEnvironment(), Cwd: "/tmp/local-cwd",
190+ TermEnv: "xterm-256color", TrueColor: true,
191+ }); err != nil {
192+ return fmt.Errorf("UDP attach: %w", err)
193+ }
194+ if err := detach(ctx, udp); err != nil {
195+ return fmt.Errorf("UDP detach: %w", err)
196+ }
197+ return nil
198+}
199+
200+func runCatalogFailurePhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient) error {
201+ if err := touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog"); err != nil {
202+ return fmt.Errorf("enable mismatched catalog: %w", err)
203+ }
204+ if _, err := catalog.List(ctx, target); err == nil {
205+ return errors.New("version-mismatched catalog unexpectedly decoded")
206+ }
207+ if err := touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog-off"); err != nil {
208+ return fmt.Errorf("disable mismatched catalog: %w", err)
209+ }
210+ return nil
211+}
212+
213+func runStaleTargetPhase(ctx context.Context, target string, catalog ports.RemoteCatalogClient, staleTarget domain.RemoteSessionTarget) error {
214+ if err := runRemoteCommand(ctx, target, "vev", "kill", sessionName); err != nil {
215+ return fmt.Errorf("destroy session for stale-target test: %w", err)
216+ }
217+ if err := waitForMissingSession(ctx, catalog, target, sessionName); err != nil {
218+ return fmt.Errorf("wait for destroyed session: %w", err)
219+ }
220+ stale, err := openTransport(ctx, ports.RemoteTransportStdio, target)
221+ if err != nil {
222+ return fmt.Errorf("open stale-target transport: %w", err)
223+ }
224+ defer func() { _ = stale.Close() }()
225+ if err := expectNoSuchTarget(ctx, stale, staleTarget); err != nil {
226+ return fmt.Errorf("stale target fence: %w", err)
227+ }
228+ return nil
229+}
230+
231+func removedCatalogTabID(before, after []ports.RemoteCatalogTab) (string, bool) {
232+ remaining := make(map[string]struct{}, len(after))
233+ for _, tab := range after {
234+ remaining[tab.ID] = struct{}{}
235+ }
236+ var removed string
237+ for _, tab := range before {
238+ if _, exists := remaining[tab.ID]; exists {
239+ continue
240+ }
241+ if removed != "" {
242+ return "", false
243+ }
244+ removed = tab.ID
245+ }
246+ return removed, removed != ""
247+}
248+
249+func localEnvironment() []string {
250+ return []string{
251+ "VEV_TEST_ENV=local",
252+ "XDG_RUNTIME_DIR=/run/local",
253+ "WAYLAND_DISPLAY=wayland-local",
254+ "TERM=xterm-256color",
255+ "COLORTERM=truecolor",
256+ "TERM_PROGRAM=local-harness",
257+ }
258+}
259+
260+type harnessFrame struct {
261+ frame ports.Frame
262+ err error
263+}
264+
265+type harnessTransport struct {
266+ ports.Transport
267+ frames chan harnessFrame
268+ stop chan struct{}
269+ closeOnce sync.Once
270+ closeErr error
271+}
272+
273+func newHarnessTransport(transport ports.Transport) *harnessTransport {
274+ h := &harnessTransport{Transport: transport, frames: make(chan harnessFrame, 16), stop: make(chan struct{})}
275+ go h.receiveLoop()
276+ return h
277+}
278+
279+func (h *harnessTransport) receiveLoop() {
280+ for {
281+ frame, err := h.Transport.Recv()
282+ select {
283+ case h.frames <- harnessFrame{frame: frame, err: err}:
284+ case <-h.stop:
285+ return
286+ }
287+ if err != nil {
288+ return
289+ }
290+ }
291+}
292+
293+func (h *harnessTransport) Close() error {
294+ h.closeOnce.Do(func() {
295+ close(h.stop)
296+ h.closeErr = h.Transport.Close()
297+ })
298+ return h.closeErr
299+}
300+
301+func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target string) (*harnessTransport, error) {
302+ var transport ports.Transport
303+ var err error
304+ switch mode {
305+ case ports.RemoteTransportStdio:
306+ transport, err = sshstdio.DialContext(ctx, target, "", slog.New(slog.DiscardHandler))
307+ case ports.RemoteTransportUDP:
308+ dialer := dgram.NewRemoteDialerWithLogger(target, "", slog.New(slog.DiscardHandler))
309+ dialer.BootstrapTimeout = 8 * time.Second
310+ dialer.ProbeTimeout = 3 * time.Second
311+ transport, err = dialer.Dial(ctx)
312+ default:
313+ return nil, fmt.Errorf("unsupported transport %q", mode)
314+ }
315+ if err != nil {
316+ return nil, err
317+ }
318+ return newHarnessTransport(transport), nil
319+}
320+
321+func attachAndCheck(ctx context.Context, tr *harnessTransport, hello ports.Hello) error {
322+ hello.Version = ports.ProtocolVersion
323+ hello.Size = domain.Size{Cols: 80, Rows: 24}
324+ if hello.EnvironmentPolicy == 0 {
325+ hello.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
326+ }
327+ payload := ports.MarshalHello(hello)
328+ if err := tr.Send(ports.Frame{Type: ports.MsgHello, Payload: payload}); err != nil {
329+ return err
330+ }
331+ frame, err := receiveWithTimeout(ctx, tr)
332+ if err != nil {
333+ return err
334+ }
335+ if frame.Type == ports.MsgError {
336+ message, decodeErr := ports.UnmarshalErrorMsg(frame.Payload)
337+ if decodeErr != nil {
338+ return decodeErr
339+ }
340+ return fmt.Errorf("remote error code %d", message.Code)
341+ }
342+ if frame.Type != ports.MsgWelcome {
343+ return fmt.Errorf("welcome frame type %d", frame.Type)
344+ }
345+ if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {
346+ return fmt.Errorf("send initial theme: %w", err)
347+ }
348+ return nil
349+}
350+
351+func sendInputAndAwait(ctx context.Context, tr *harnessTransport, command, want string) error {
352+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
353+ return err
354+ }
355+ var received strings.Builder
356+ var outputFrames int
357+ var outputBytes int
358+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
359+ defer cancel()
360+ for {
361+ frame, err := receiveWithTimeout(deadline, tr)
362+ if err != nil {
363+ return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", outputFrames, outputBytes, err)
364+ }
365+ if frame.Type != ports.MsgOutput {
366+ continue
367+ }
368+ output, err := ports.UnmarshalOutput(frame.Payload)
369+ if err != nil {
370+ return err
371+ }
372+ outputFrames++
373+ outputBytes += len(output.Data)
374+ if output.Epoch != 0 && output.New != 0 {
375+ ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
376+ if err != nil {
377+ return fmt.Errorf("encode output ack: %w", err)
378+ }
379+ if err := tr.Send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
380+ return fmt.Errorf("ack output: %w", err)
381+ }
382+ }
383+ received.Write(output.Data)
384+ if strings.Contains(received.String(), want) {
385+ return nil
386+ }
387+ if received.Len() > 64<<10 {
388+ text := received.String()
389+ received.Reset()
390+ received.WriteString(text[len(text)-16<<10:])
391+ }
392+ }
393+}
394+
395+func sendCommand(ctx context.Context, tr *harnessTransport, slug string) error {
396+ id := requestID.Add(1)
397+ request := ports.CommandRequest{
398+ Version: ports.ProtocolVersion,
399+ RequestID: id,
400+ Attached: true,
401+ Slug: slug,
402+ }
403+ payload, err := ports.MarshalCommandRequest(request)
404+ if err != nil {
405+ return err
406+ }
407+ if err := tr.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
408+ return err
409+ }
410+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
411+ defer cancel()
412+ for {
413+ frame, err := receiveWithTimeout(deadline, tr)
414+ if err != nil {
415+ return err
416+ }
417+ if frame.Type != ports.MsgCommandResult {
418+ continue
419+ }
420+ result, err := ports.UnmarshalCommandResult(frame.Payload)
421+ if err != nil {
422+ return err
423+ }
424+ if !result.OK {
425+ return fmt.Errorf("remote command failed (code %d): %s", result.Code, result.Text)
426+ }
427+ return nil
428+ }
429+}
430+
431+func detach(ctx context.Context, tr *harnessTransport) error {
432+ if err := tr.Send(ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}); err != nil {
433+ return err
434+ }
435+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
436+ defer cancel()
437+ for {
438+ frame, err := receiveWithTimeout(deadline, tr)
439+ if err != nil {
440+ return err
441+ }
442+ if frame.Type == ports.MsgDetached {
443+ return nil
444+ }
445+ }
446+}
447+
448+func expectNoSuchTarget(ctx context.Context, tr *harnessTransport, target domain.RemoteSessionTarget) error {
449+ target.Endpoint = targetEndpoint(target.Endpoint)
450+ target.DisplayOrigin = "remote"
451+ target.SessionName = sessionName
452+ if err := target.Validate(); err != nil {
453+ return err
454+ }
455+ hello := ports.Hello{
456+ Version: ports.ProtocolVersion,
457+ Intent: ports.IntentResume,
458+ Name: sessionName,
459+ Size: domain.Size{Cols: 80, Rows: 24},
460+ TermEnv: "xterm-256color",
461+ TrueColor: true,
462+ Env: localEnvironment(),
463+ Cwd: "/tmp/local-cwd",
464+ RemoteTarget: &target,
465+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
466+ }
467+ payload := ports.MarshalHello(hello)
468+ if err := tr.Send(ports.Frame{Type: ports.MsgHello, Payload: payload}); err != nil {
469+ return err
470+ }
471+ frame, err := receiveWithTimeout(ctx, tr)
472+ if err != nil {
473+ return err
474+ }
475+ if frame.Type != ports.MsgError {
476+ return fmt.Errorf("stale target returned frame type %d", frame.Type)
477+ }
478+ message, err := ports.UnmarshalErrorMsg(frame.Payload)
479+ if err != nil {
480+ return err
481+ }
482+ if message.Code != ports.ErrNoSuchTarget {
483+ return fmt.Errorf("stale target returned error code %d", message.Code)
484+ }
485+ return nil
486+}
487+
488+func targetForSession(endpoint string, session ports.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) {
489+ tabs := ports.CatalogTabs(session)
490+ if len(tabs) == 0 {
491+ return domain.RemoteSessionTarget{}, errors.New("catalog session has no tabs")
492+ }
493+ tab := tabs[0]
494+ if !stopped && session.ActiveTabID != "" {
495+ for _, candidate := range tabs {
496+ if candidate.ID == session.ActiveTabID {
497+ tab = candidate
498+ break
499+ }
500+ }
501+ }
502+ target := domain.RemoteSessionTarget{
503+ Endpoint: endpoint,
504+ DisplayOrigin: "remote",
505+ LifecycleID: session.LifecycleID,
506+ SessionName: session.Name,
507+ Stopped: stopped,
508+ }
509+ if stopped {
510+ target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(tab.ID))
511+ } else {
512+ target.LiveTabID = domain.TabStableID(tab.ID)
513+ }
514+ if err := target.Validate(); err != nil {
515+ return domain.RemoteSessionTarget{}, err
516+ }
517+ return target, nil
518+}
519+
520+func targetEndpoint(endpoint string) string {
521+ if endpoint == "" {
522+ return defaultTarget
523+ }
524+ return endpoint
525+}
526+
527+func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string) (ports.RemoteCatalogSession, error) {
528+ return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)
529+}
530+
531+func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string, tabs int) (ports.RemoteCatalogSession, error) {
532+ deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
533+ defer cancel()
534+ for {
535+ result, err := catalog.List(deadline, endpoint)
536+ if err == nil {
537+ for _, session := range result.Sessions {
538+ if session.Name == name && session.State == state && (tabs == 0 || len(ports.CatalogTabs(session)) == tabs) {
539+ return session, nil
540+ }
541+ }
542+ }
543+ select {
544+ case <-deadline.Done():
545+ if err != nil {
546+ return ports.RemoteCatalogSession{}, err
547+ }
548+ return ports.RemoteCatalogSession{}, fmt.Errorf("session %q did not reach state %q", name, state)
549+ case <-time.After(100 * time.Millisecond):
550+ }
551+ }
552+}
553+
554+func waitForMissingSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string) error {
555+ deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
556+ defer cancel()
557+ for {
558+ result, err := catalog.List(deadline, endpoint)
559+ if err == nil {
560+ found := false
561+ for _, session := range result.Sessions {
562+ found = found || session.Name == name
563+ }
564+ if !found {
565+ return nil
566+ }
567+ }
568+ select {
569+ case <-deadline.Done():
570+ return errors.New("destroyed session remained in catalog")
571+ case <-time.After(100 * time.Millisecond):
572+ }
573+ }
574+}
575+
576+func touchRemoteFlag(ctx context.Context, target, path string) error {
577+ if strings.HasSuffix(path, "-off") {
578+ path = strings.TrimSuffix(path, "-off")
579+ return runRemoteCommand(ctx, target, "rm", "-f", path)
580+ }
581+ return runRemoteCommand(ctx, target, "touch", path)
582+}
583+
584+func runRemoteCommand(ctx context.Context, target string, command ...string) error {
585+ spec := sshstdio.BuildCommandForRemoteCommand(target, command...)
586+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
587+ cmd.Stdout = io.Discard
588+ cmd.Stderr = io.Discard
589+ return cmd.Run()
590+}
591+
592+func receiveWithTimeout(ctx context.Context, tr *harnessTransport) (ports.Frame, error) {
593+ select {
594+ case <-ctx.Done():
595+ return ports.Frame{}, ctx.Err()
596+ case outcome := <-tr.frames:
597+ return outcome.frame, outcome.err
598+ }
599+}
A scripts/remote-picker-harness/run.sh +108 -0
@@ -0,0 +1,108 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+usage() {
5+ cat >&2 <<'USAGE'
6+Usage: scripts/remote-picker-harness/run.sh
7+
8+Builds the current vev binary into a disposable two-container environment and
9+runs the SSH-stdio, UDP, catalog, preview, lifecycle-fence, and environment
10+acceptance checks. Docker is invoked normally, so DOCKER_HOST and the active
11+Docker context select the daemon; no socket path is assumed.
12+
13+Environment:
14+ DOCKER_HOST Docker endpoint, when the active context does not
15+ already select the rootless daemon.
16+ VEV_HARNESS_BASE_IMAGE Base image for the disposable containers
17+ (default: ubuntu:24.04).
18+USAGE
19+}
20+
21+case "${1:-}" in
22+ -h|--help)
23+ usage
24+ exit 0
25+ ;;
26+ "") ;;
27+ *)
28+ usage
29+ exit 2
30+ ;;
31+esac
32+
33+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
34+base_image="${VEV_HARNESS_BASE_IMAGE:-ubuntu:24.04}"
35+run_id="$(date +%s)-$$"
36+image="vev-remote-picker-harness:${run_id}"
37+network="vev-remote-picker-harness-${run_id}"
38+local_container="${network}-local"
39+remote_container="${network}-remote"
40+context_dir="$(mktemp -d "${TMPDIR:-/tmp}/vev-remote-picker-harness.XXXXXX")"
41+
42+cleanup() {
43+ status=$?
44+ trap - EXIT INT TERM
45+ set +e
46+ if [ "$status" -ne 0 ]; then
47+ printf 'remote picker harness: local container logs:\n' >&2
48+ docker logs "$local_container" >&2
49+ printf 'remote picker harness: remote container logs:\n' >&2
50+ docker logs "$remote_container" >&2
51+ fi
52+ docker rm -f "$local_container" "$remote_container" >/dev/null 2>&1
53+ docker network rm "$network" >/dev/null 2>&1
54+ docker image rm "$image" >/dev/null 2>&1
55+ rm -rf "$context_dir"
56+ exit "$status"
57+}
58+trap cleanup EXIT INT TERM
59+
60+command -v docker >/dev/null || { echo 'remote picker harness: docker is required' >&2; exit 127; }
61+command -v ssh-keygen >/dev/null || { echo 'remote picker harness: ssh-keygen is required' >&2; exit 127; }
62+
63+if ! docker info >/dev/null 2>&1; then
64+ echo 'remote picker harness: Docker is unavailable through the active DOCKER_HOST/context' >&2
65+ exit 1
66+fi
67+
68+printf 'remote picker harness: building %s\n' "$image"
69+docker pull "$base_image" >/dev/null
70+image_arch="$(docker image inspect "$base_image" --format '{{.Architecture}}')"
71+GOOS=linux GOARCH="$image_arch" CGO_ENABLED=0 go build -o "$context_dir/vev" "$repo_root"
72+GOOS=linux GOARCH="$image_arch" CGO_ENABLED=0 go build -o "$context_dir/harness" "$repo_root/scripts/remote-picker-harness"
73+ssh-keygen -q -t ed25519 -N '' -f "$context_dir/id_ed25519"
74+cp "$repo_root/scripts/remote-picker-harness/Dockerfile" "$context_dir/Dockerfile"
75+cp "$repo_root/scripts/remote-picker-harness/vev-wrapper" "$context_dir/vev-wrapper"
76+printf '%s\n' 'id_ed25519' > "$context_dir/.dockerignore"
77+chmod 0600 "$context_dir/id_ed25519"
78+
79+docker build --build-arg "BASE_IMAGE=$base_image" -t "$image" "$context_dir" >/dev/null
80+docker network create "$network" >/dev/null
81+
82+docker run -d --name "$remote_container" \
83+ --network "$network" --network-alias remote \
84+ -e VEV_UDP_PORT_RANGE=61000 \
85+ "$image" >/dev/null
86+
87+docker create --name "$local_container" \
88+ --network "$network" --network-alias local \
89+ "$image" sleep infinity >/dev/null
90+docker cp "$context_dir/id_ed25519" "$local_container:/home/test/.ssh/id_ed25519"
91+docker start "$local_container" >/dev/null
92+docker exec "$local_container" chown test:test /home/test/.ssh/id_ed25519
93+docker exec "$local_container" chmod 0600 /home/test/.ssh/id_ed25519
94+
95+printf 'remote picker harness: waiting for SSH service\n'
96+for _ in $(seq 1 60); do
97+ if docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=1 test@remote true >/dev/null 2>&1; then
98+ break
99+ fi
100+ sleep 0.2
101+done
102+if ! docker exec --user test -e HOME=/home/test "$local_container" ssh -o BatchMode=yes -o ConnectTimeout=2 test@remote true >/dev/null 2>&1; then
103+ echo 'remote picker harness: SSH service did not become ready' >&2
104+ exit 1
105+fi
106+
107+printf 'remote picker harness: running acceptance checks\n'
108+docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness
A scripts/remote-picker-harness/vev-wrapper +27 -0
@@ -0,0 +1,27 @@
1+#!/bin/sh
2+set -eu
3+
4+export XDG_STATE_HOME=/tmp/vev-state
5+export XDG_RUNTIME_DIR=/tmp/vev-runtime
6+export VEV_TEST_ENV=remote
7+export WAYLAND_DISPLAY=wayland-remote
8+export TERM=xterm-256color
9+export COLORTERM=truecolor
10+
11+
12+case "$*" in
13+ *remote-catalog*)
14+ if [ -e /tmp/vev-bad-catalog ]; then
15+ # Keep zero invalid so this fixture cannot become a valid protocol version.
16+ printf '%s\n' '{"protocol_version":0,"schema_version":1,"sessions":[]}'
17+ exit 0
18+ fi
19+ ;;
20+ *_remote-preview*)
21+ if [ -e /tmp/vev-slow-preview ]; then
22+ sleep 3
23+ fi
24+ ;;
25+esac
26+
27+exec /usr/local/bin/vev.real "$@"

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