detached 2d2ac84

feat(remote): implement picker session parity

commit
2d2ac84a6087a384288315666ed2f21f0237c157
author
brice · · 2026-08-05 08:53 UTC
parent
9344cd0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
54 files · +5140 -224
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 +25 -0
@@ -13,6 +13,31 @@ 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+Remote catalogues are bounded and versioned. Complete catalogues contain ordered
29+tab IDs, names, state, active tab, attachment state, and MRU sequence. Stopped
30+tab metadata is retained across daemon restart; legacy count-only catalogues are
31+read-only compatibility rows and cannot provide exact tab identity.
32+
33+Remote picker previews are bounded styled-cell snapshots fetched without
34+attachment or PTY mutation. They are held in a short-lived in-memory cache only;
35+viewport contents are not persisted or written to diagnostics. Live preview
36+requests use debounce, same-key single-flight, bounded concurrency, TTL
37+stale-while-revalidate, and failure cooldowns. Stopped sessions show a static
38+placeholder, and late or canceled preview responses are discarded when the
39+picker selection changes.
40+
1641 ## Connection limits
1742
1843 - 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 +60 -18
@@ -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 := uint16(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
A internal/adapters/remote/preview.go +88 -0
@@ -0,0 +1,88 @@
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+ return ports.RemotePreview{}, ctxErr
64+ }
65+ if stdout.overflow || stderr.overflow {
66+ return ports.RemotePreview{}, errRemotePreviewTooLarge
67+ }
68+ return ports.RemotePreview{}, fmt.Errorf("%w: %v", errRemotePreviewSSH, err)
69+ }
70+ if stdout.overflow || stderr.overflow {
71+ return ports.RemotePreview{}, errRemotePreviewTooLarge
72+ }
73+ preview, err := ports.UnmarshalRemotePreview(stdout.Bytes())
74+ if err != nil {
75+ return ports.RemotePreview{}, err
76+ }
77+ if preview.Status != ports.RemotePreviewOK {
78+ return ports.RemotePreview{}, fmt.Errorf("remote preview unavailable: status %d", preview.Status)
79+ }
80+ return preview, nil
81+}
82+
83+func (c *PreviewClient) previewTimeout() time.Duration {
84+ if c != nil && c.timeout > 0 {
85+ return c.timeout
86+ }
87+ return remotePreviewCommandTimeout
88+}
A internal/adapters/remote/preview_test.go +78 -0
@@ -0,0 +1,78 @@
1+package remote
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "os/exec"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/adapters/sshstdio"
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T) {
19+ var lifecycle domain.SessionLifecycleID
20+ lifecycle[0] = 9
21+ target := domain.RemoteSessionTarget{
22+ Endpoint: "build@mule", DisplayOrigin: "mule", LifecycleID: lifecycle,
23+ SessionName: "work", LiveTabID: "tab-1",
24+ }
25+ want := ports.RemotePreview{
26+ Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK,
27+ LifecycleID: lifecycle, TabID: target.LiveTabID, Revision: 7, Width: 1, Height: 1,
28+ Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}},
29+ }
30+ payload := ports.MarshalRemotePreview(want)
31+ require.NotNil(t, payload)
32+
33+ var gotPath string
34+ var gotArgs []string
35+ client := &PreviewClient{command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
36+ gotPath = name
37+ gotArgs = append([]string(nil), args...)
38+ if len(args) != 3 {
39+ t.Fatalf("ssh args = %#v, want target and one remote command", args)
40+ }
41+ words := strings.Split(args[2], " ")
42+ if len(words) != 3 {
43+ t.Fatalf("remote command = %q, want three shell words", args[2])
44+ }
45+ encoded := strings.Trim(words[2], "'")
46+ requestPayload, err := base64.RawURLEncoding.DecodeString(encoded)
47+ if err != nil {
48+ t.Fatalf("decode request: %v", err)
49+ }
50+ request, err := ports.UnmarshalRemotePreviewRequest(requestPayload)
51+ if err != nil {
52+ t.Fatalf("decode request payload: %v", err)
53+ }
54+ if request.Target != target || request.Width != 1 || request.Height != 1 {
55+ t.Fatalf("request = %#v, want target and 1x1 dimensions", request)
56+ }
57+ return stdoutCmd(ctx, string(payload))
58+ }}
59+
60+ got, err := client.Preview(context.Background(), target, 1, 1)
61+ require.NoError(t, err)
62+ require.Equal(t, want, got)
63+ wantCommand := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", "request")
64+ require.Equal(t, wantCommand.Path, gotPath)
65+ require.Equal(t, target.Endpoint, gotArgs[1])
66+ require.Contains(t, gotArgs[2], "'_remote-preview'")
67+}
68+
69+func TestRemotePreviewClientRejectsMalformedResponse(t *testing.T) {
70+ client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
71+ return stdoutCmd(ctx, "not-a-preview")
72+ }}
73+ var lifecycle domain.SessionLifecycleID
74+ lifecycle[0] = 1
75+ target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
76+ _, err := client.Preview(context.Background(), target, 1, 1)
77+ require.Error(t, err)
78+}
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 +60 -0
@@ -0,0 +1,60 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/base64"
6+ "fmt"
7+ "os"
8+
9+ "github.com/bnema/vev/internal/adapters/ipc"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// runRemotePreview is the hidden SSH-side carriage. The request is encoded in
14+// one shell-safe argument, then the daemon returns one bounded binary response
15+// whose payload is written verbatim to stdout; no viewport bytes are logged.
16+func runRemotePreview(ctx context.Context, encoded string) error {
17+ request, err := base64.RawURLEncoding.DecodeString(encoded)
18+ if err != nil {
19+ return fmt.Errorf("vev: invalid remote preview request: %w", err)
20+ }
21+ if _, err := ports.UnmarshalRemotePreviewRequest(request); err != nil {
22+ return fmt.Errorf("vev: invalid remote preview request: %w", err)
23+ }
24+ transport, err := ensureDaemonWithLifecycle(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
25+ if err != nil {
26+ return err
27+ }
28+ defer func() { _ = transport.Close() }()
29+ if err := transport.Send(ports.Frame{Type: ports.MsgRemotePreviewRequest, Payload: request}); err != nil {
30+ return fmt.Errorf("vev: sending remote preview request: %w", err)
31+ }
32+ frameCh := make(chan struct {
33+ frame ports.Frame
34+ err error
35+ }, 1)
36+ go func() {
37+ frame, err := transport.Recv()
38+ frameCh <- struct {
39+ frame ports.Frame
40+ err error
41+ }{frame: frame, err: err}
42+ }()
43+ select {
44+ case <-ctx.Done():
45+ _ = transport.Close()
46+ return ctx.Err()
47+ case result := <-frameCh:
48+ if result.err != nil {
49+ return fmt.Errorf("vev: receiving remote preview: %w", result.err)
50+ }
51+ if result.frame.Type != ports.MsgRemotePreviewResponse {
52+ return fmt.Errorf("vev: unexpected remote preview response %d", result.frame.Type)
53+ }
54+ if _, err := ports.UnmarshalRemotePreview(result.frame.Payload); err != nil {
55+ return fmt.Errorf("vev: malformed remote preview response: %w", err)
56+ }
57+ _, err := os.Stdout.Write(result.frame.Payload)
58+ return err
59+ }
60+}
M internal/app/run.go +97 -18
@@ -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,19 +821,27 @@ 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
818847 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
@@ -833,6 +862,8 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
833862 }
834863 mode, modeErr := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
835864 factory := deps.remoteDialerFactory
865+ var remoteSelection *domain.RemoteSessionTarget
866+ var remoteEnvironmentPolicy ports.EnvironmentPolicy
836867 seenHandoffs := make(map[attachHandoffKey]struct{})
837868 if remoteTarget != "" {
838869 if modeErr != nil {
@@ -843,6 +874,36 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
843874 }
844875 seenHandoffs[attachHandoffKey{endpoint: remoteTarget, session: name, intent: intent}] = struct{}{}
845876 }
877+ var handoff client.AttachHandoffFunc
878+ handoff = func(target ports.AttachTarget) (ports.Dialer, client.AttachRequest, error) {
879+ if err := validateRemoteAttachHandoff(target); err != nil {
880+ return nil, client.AttachRequest{}, fmt.Errorf("vev: invalid remote attach handoff: %w", err)
881+ }
882+ if modeErr != nil {
883+ return nil, client.AttachRequest{}, modeErr
884+ }
885+ if factory == nil {
886+ factory = defaultRemoteDialerFactory()
887+ }
888+ next := attachHandoffKey{endpoint: target.Endpoint, session: target.Session, intent: target.Intent}
889+ var selection *domain.RemoteSessionTarget
890+ if target.RemoteTarget != nil {
891+ copyTarget := *target.RemoteTarget
892+ selection = &copyTarget
893+ next.lifecycle = copyTarget.LifecycleID
894+ next.liveTab = copyTarget.LiveTabID
895+ next.stopped = copyTarget.StoppedTab
896+ }
897+ if _, seen := seenHandoffs[next]; seen {
898+ return nil, client.AttachRequest{}, fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
899+ }
900+ seenHandoffs[next] = struct{}{}
901+ dialer, err := factory.DialerForRemote(target.Endpoint, target.Session, mode, log)
902+ if err != nil {
903+ return nil, client.AttachRequest{}, err
904+ }
905+ return dialer, client.AttachRequest{Intent: target.Intent, SessionName: target.Session, Remote: true, RemoteTarget: selection, EnvironmentPolicy: target.EnvironmentPolicy}, nil
906+ }
846907
847908 for {
848909 var err error
@@ -862,8 +923,14 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
862923 Logger: log,
863924 RuntimeObserver: deps.runtimeObserver,
864925 RemoteHostLearner: attachRememberLearner(deps, remoteTarget, log),
926+ AttachHandoff: handoff,
865927 Remote: true,
866- }, client.AttachRequest{Intent: intent, SessionName: name})
928+ }, client.AttachRequest{
929+ Intent: intent,
930+ SessionName: name,
931+ RemoteTarget: remoteSelection,
932+ EnvironmentPolicy: remoteEnvironmentPolicy,
933+ })
867934 } else {
868935 if log != nil {
869936 log.Info("attaching to local session", "intent", intent, "name", name)
@@ -874,6 +941,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
874941 Clock: clock.New(),
875942 Logger: log,
876943 RuntimeObserver: deps.runtimeObserver,
944+ AttachHandoff: handoff,
877945 Remote: false,
878946 }, client.AttachRequest{Intent: intent, SessionName: name})
879947 }
@@ -889,6 +957,11 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
889957 return fmt.Errorf("vev: invalid remote attach handoff: %w", err)
890958 }
891959 next := attachHandoffKey{endpoint: handoff.Target.Endpoint, session: handoff.Target.Session, intent: handoff.Target.Intent}
960+ if handoff.Target.RemoteTarget != nil {
961+ next.lifecycle = handoff.Target.RemoteTarget.LifecycleID
962+ next.liveTab = handoff.Target.RemoteTarget.LiveTabID
963+ next.stopped = handoff.Target.RemoteTarget.StoppedTab
964+ }
892965 if _, seen := seenHandoffs[next]; seen {
893966 return fmt.Errorf("vev: attach handoff loop for %q/%q", next.endpoint, next.session)
894967 }
@@ -896,6 +969,12 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
896969 remoteTarget = handoff.Target.Endpoint
897970 name = handoff.Target.Session
898971 intent = handoff.Target.Intent
972+ remoteSelection = nil
973+ remoteEnvironmentPolicy = handoff.Target.EnvironmentPolicy
974+ if handoff.Target.RemoteTarget != nil {
975+ copyTarget := *handoff.Target.RemoteTarget
976+ remoteSelection = &copyTarget
977+ }
899978 if modeErr != nil {
900979 return modeErr
901980 }
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 +20 -0
@@ -0,0 +1,20 @@
1+package domain
2+
3+import "testing"
4+
5+func TestOrdinalTabSelectorPreservesUnnamedTabs(t *testing.T) {
6+ selector := NewOrdinalTabSelector(1, "", 3)
7+ tabs := []TabSelectorTab{{Name: "first"}, {Name: ""}, {Name: "trailing"}}
8+ index, ok := selector.Resolve(tabs)
9+ if !ok || index != 1 {
10+ t.Fatalf("Resolve() = %d, %t, want 1, true", index, ok)
11+ }
12+}
13+
14+func TestValidateTabStableIDRejectsUnsafeIdentity(t *testing.T) {
15+ for _, id := range []TabStableID{"", "tab id", "tab\n1"} {
16+ if err := ValidateTabStableID(id); err == nil {
17+ t.Fatalf("ValidateTabStableID(%q) = nil, want error", id)
18+ }
19+ }
20+}
M internal/domain/recovery.go +27 -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,11 @@ func (r CatalogueRecord) Validate() error {
119133 return errors.New("zero catalogue incarnation ID")
120134 }
121135
136+ for _, tab := range r.TabRecords {
137+ if !utf8.ValidString(string(tab.StableID)) || !utf8.ValidString(tab.Name) {
138+ return errors.New("invalid catalogue tab metadata")
139+ }
140+ }
122141 if r.DegradedReason != "" && r.Committed == nil {
123142 return errors.New("broken session has no committed checkpoint")
124143 }
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 +28 -26
@@ -7,7 +7,7 @@ import "time"
77 const HandshakeTimeout = 15 * time.Second
88
99 // ProtocolVersion is the current vev IPC wire protocol version.
10-const ProtocolVersion uint16 = 23
10+const ProtocolVersion uint16 = 24
1111
1212 // MaxFrameLen is the largest permitted frame length, including the type byte
1313 // and excluding the four-byte length prefix.
@@ -17,32 +17,34 @@ const MaxFrameLen = 16 << 20
1717 type MsgType uint8
1818
1919 // 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.
20+// through 15, server-originated messages from 16, with reserved values kept
21+// for future extensions.
2222 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
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+ MsgRemotePreviewRequest MsgType = 14
37+
38+ MsgWelcome MsgType = 16
39+ MsgError MsgType = 17
40+ MsgOutput MsgType = 18
41+ MsgDetached MsgType = 19
42+ MsgPong MsgType = 20
43+ MsgSessions MsgType = 21
44+ MsgCommandResult MsgType = 22
45+ MsgSessionMeta MsgType = 23
46+ MsgRemotePreviewResponse MsgType = 24
47+ MsgAttachTarget MsgType = 25
4648 )
4749
4850 // Frame is the unit of exchange over a Transport: a typed, length-delimited
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 +356 -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,14 +55,55 @@ 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
5585 Sessions []RemoteCatalogSession
5686 }
5787
88+// RemoteCatalogCacheTab is the durable subset of a tab catalogue entry.
89+type RemoteCatalogCacheTab struct {
90+ ID string `json:"id,omitempty"`
91+ Index uint16 `json:"index"`
92+ Name string `json:"name"`
93+}
94+
95+// RemoteCatalogCacheSession is the durable subset of a remote session entry.
96+type RemoteCatalogCacheSession struct {
97+ LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
98+ Name string `json:"name"`
99+ State string `json:"state"`
100+ Ephemeral bool `json:"ephemeral"`
101+ Attached bool `json:"attached"`
102+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
103+ ActiveTabID string `json:"active_tab_id,omitempty"`
104+ Tabs []RemoteCatalogCacheTab `json:"tabs"`
105+}
106+
58107 // RemoteCatalogCache persists complete remote discovery snapshots independently
59108 // from the remote host registry.
60109 type RemoteCatalogCache interface {
@@ -62,40 +111,334 @@ type RemoteCatalogCache interface {
62111 Store([]RemoteCatalogCacheEntry) error
63112 }
64113
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 {
114+// RemoteCatalogTab is one bounded, untrusted tab presentation hint. Detail
115+// and Attention are deliberately omitted from the durable cache.
116+type RemoteCatalogTab struct {
117+ ID string `json:"id"`
118+ Index uint16 `json:"index"`
68119 Name string `json:"name"`
69- State string `json:"state"`
70- Ephemeral bool `json:"ephemeral"`
71- Tabs uint16 `json:"tabs"`
72- Attached bool `json:"attached"`
120+ Detail string `json:"detail,omitempty"`
121+ Attention bool `json:"attention,omitempty"`
122+}
123+
124+// RemoteCatalogSession is one session in the remote discovery catalog. State
125+// is an explicit string contract (running|stopped|broken), not SessionState.
126+//
127+// Tabs is interface-typed only to keep old v0.x callers that supplied a count
128+// source-compatible while the versioned schema carries []RemoteCatalogTab.
129+// New code must use CatalogTabs and never infer routing identity from a count.
130+type RemoteCatalogSession struct {
131+ LifecycleID domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
132+ Name string `json:"name"`
133+ State string `json:"state"`
134+ Ephemeral bool `json:"ephemeral"`
135+ Tabs any `json:"tabs"`
136+ Attached bool `json:"attached"`
137+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
138+ ActiveTabID string `json:"active_tab_id,omitempty"`
139+ Reason string `json:"reason,omitempty"`
140+}
141+
142+// MarshalJSON omits a zero lifecycle ID for legacy count-only catalogues.
143+// Encoding/json does not apply omitempty to a zero array that implements
144+// encoding.TextMarshaler, so the pointer is made explicit here.
145+func (s RemoteCatalogSession) MarshalJSON() ([]byte, error) {
146+ type envelope struct {
147+ LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
148+ Name string `json:"name"`
149+ State string `json:"state"`
150+ Ephemeral bool `json:"ephemeral"`
151+ Tabs any `json:"tabs"`
152+ Attached bool `json:"attached"`
153+ LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
154+ ActiveTabID string `json:"active_tab_id,omitempty"`
155+ Reason string `json:"reason,omitempty"`
156+ }
157+ var lifecycle *domain.SessionLifecycleID
158+ if s.LifecycleID != (domain.SessionLifecycleID{}) {
159+ id := s.LifecycleID
160+ lifecycle = &id
161+ }
162+ 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})
163+}
164+
165+// UnmarshalJSON preserves the legacy numeric tab-count representation while
166+// decoding the current array representation into concrete tab values.
167+func (s *RemoteCatalogSession) UnmarshalJSON(data []byte) error {
168+ type envelope struct {
169+ LifecycleID domain.SessionLifecycleID `json:"lifecycle_id"`
170+ Name string `json:"name"`
171+ State string `json:"state"`
172+ Ephemeral bool `json:"ephemeral"`
173+ Tabs json.RawMessage `json:"tabs"`
174+ Attached bool `json:"attached"`
175+ LastUsedSeq uint64 `json:"last_used_seq"`
176+ ActiveTabID string `json:"active_tab_id"`
177+ Reason string `json:"reason"`
178+ }
179+ var raw envelope
180+ if err := json.Unmarshal(data, &raw); err != nil {
181+ return err
182+ }
183+ *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}
184+ if len(raw.Tabs) == 0 || string(raw.Tabs) == "null" {
185+ s.Tabs = 0
186+ return nil
187+ }
188+ var tabs []RemoteCatalogTab
189+ if err := json.Unmarshal(raw.Tabs, &tabs); err == nil {
190+ s.Tabs = tabs
191+ return nil
192+ }
193+ var count uint16
194+ if err := json.Unmarshal(raw.Tabs, &count); err != nil {
195+ return err
196+ }
197+ if count == math.MaxUint16 {
198+ s.Tabs = count
199+ } else {
200+ s.Tabs = int(count)
201+ }
202+ return nil
203+}
204+
205+// CatalogTabs returns a copied typed tab list. Legacy count-only entries have
206+// no stable tab identity and therefore return nil.
207+func CatalogTabs(session RemoteCatalogSession) []RemoteCatalogTab {
208+ tabs, ok := session.Tabs.([]RemoteCatalogTab)
209+ if !ok {
210+ return nil
211+ }
212+ return slices.Clone(tabs)
213+}
214+
215+// CatalogTabCount returns the count represented by either schema form.
216+func CatalogTabCount(session RemoteCatalogSession) int {
217+ switch tabs := session.Tabs.(type) {
218+ case []RemoteCatalogTab:
219+ return len(tabs)
220+ case uint16:
221+ return int(tabs)
222+ case uint32:
223+ return int(tabs)
224+ case int:
225+ return tabs
226+ case float64:
227+ return int(tabs)
228+ default:
229+ return 0
230+ }
73231 }
74232
75-// SaturateUint16 clamps n to the uint16 range.
233+// SaturateUint16 clamps n to the uint16 range for legacy command output only.
76234 func SaturateUint16(n int) uint16 {
77235 if n <= 0 {
78236 return 0
79237 }
80238 if n >= 1<<16 {
81- return ^uint16(0)
239+ return math.MaxUint16
82240 }
83241 return uint16(n)
84242 }
85243
86-// RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.
244+// RemoteCatalog is the independently versioned JSON envelope returned by
245+// remote-catalog --json. ProtocolVersion remains for legacy peers; new peers
246+// must also provide SchemaVersion.
87247 type RemoteCatalog struct {
88248 ProtocolVersion uint16 `json:"protocol_version"`
249+ SchemaVersion uint16 `json:"schema_version,omitempty"`
89250 Sessions []RemoteCatalogSession `json:"sessions"`
90251 }
91252
92253 // RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
93-// reports a protocol_version that is not equal to ProtocolVersion.
254+// reports an incompatible protocol or catalogue schema version.
94255 type RemoteCatalogVersionMismatchError struct {
95256 Got uint16
96257 Want uint16
258+ Kind string
97259 }
98260
99261 func (e *RemoteCatalogVersionMismatchError) Error() string {
100- return fmt.Sprintf("remote catalog: protocol version mismatch: got %d, want %d", e.Got, e.Want)
262+ kind := e.Kind
263+ if kind == "" {
264+ kind = "protocol"
265+ }
266+ return fmt.Sprintf("remote catalog: %s version mismatch: got %d, want %d", kind, e.Got, e.Want)
267+}
268+
269+var (
270+ ErrInvalidRemoteCatalog = errors.New("ports: invalid remote catalog")
271+ ErrRemoteCatalogTooLarge = errors.New("ports: remote catalog exceeds size limit")
272+ ErrRemoteCatalogUnknownState = errors.New("ports: remote catalog has unknown state")
273+ ErrRemoteCatalogInvalidReason = errors.New("ports: remote catalog has unknown reason")
274+)
275+
276+// ValidateRemoteCatalog applies all limits before a catalogue is cached or
277+// rendered. It is deliberately strict for the current schema; zero schema is
278+// the read-only compatibility form used by pre-parity v0.x peers.
279+func ValidateRemoteCatalog(c RemoteCatalog) error {
280+ if c.ProtocolVersion != ProtocolVersion {
281+ return &RemoteCatalogVersionMismatchError{Got: c.ProtocolVersion, Want: ProtocolVersion, Kind: "protocol"}
282+ }
283+ if c.SchemaVersion != 0 && c.SchemaVersion != RemoteCatalogSchemaVersion {
284+ return &RemoteCatalogVersionMismatchError{Got: c.SchemaVersion, Want: RemoteCatalogSchemaVersion, Kind: "catalog"}
285+ }
286+ if len(c.Sessions) > RemoteCatalogMaxSessions {
287+ return fmt.Errorf("%w: too many sessions", ErrRemoteCatalogTooLarge)
288+ }
289+ seen := make(map[domain.SessionLifecycleID]string, len(c.Sessions))
290+ bytes := 0
291+ for _, session := range c.Sessions {
292+ if !validRemoteCatalogText(session.Name) || !validRemoteCatalogText(session.State) || !validRemoteCatalogText(session.Reason) {
293+ return fmt.Errorf("%w: invalid session text", ErrInvalidRemoteCatalog)
294+ }
295+ if len(session.Name) > RemoteCatalogMaxNameBytes || len(session.Reason) > RemoteCatalogMaxDetailBytes {
296+ return fmt.Errorf("%w: session text too long", ErrRemoteCatalogTooLarge)
297+ }
298+ if err := domain.ValidateSessionName(session.Name); err != nil {
299+ return fmt.Errorf("%w: session name: %v", ErrInvalidRemoteCatalog, err)
300+ }
301+ if !validRemoteCatalogState(session.State) {
302+ return fmt.Errorf("%w: %q", ErrRemoteCatalogUnknownState, session.State)
303+ }
304+ if session.ActiveTabID != "" {
305+ if err := domain.ValidateTabStableID(domain.TabStableID(session.ActiveTabID)); err != nil {
306+ return fmt.Errorf("%w: invalid active tab ID", ErrInvalidRemoteCatalog)
307+ }
308+ }
309+ if session.Reason != "" && !validRemoteCatalogReason(session.Reason) {
310+ return fmt.Errorf("%w: %q", ErrRemoteCatalogInvalidReason, session.Reason)
311+ }
312+ if c.SchemaVersion != 0 {
313+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
314+ return fmt.Errorf("%w: zero lifecycle ID", ErrInvalidRemoteCatalog)
315+ }
316+ if prior, exists := seen[session.LifecycleID]; exists {
317+ return fmt.Errorf("%w: lifecycle ID reused by %q and %q", ErrInvalidRemoteCatalog, prior, session.Name)
318+ }
319+ seen[session.LifecycleID] = session.Name
320+ }
321+ tabs := CatalogTabs(session)
322+ if len(tabs) == 0 {
323+ if c.SchemaVersion != 0 {
324+ if session.ActiveTabID != "" {
325+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
326+ }
327+ if session.State == "running" && session.Tabs == nil {
328+ return fmt.Errorf("%w: missing tab list", ErrInvalidRemoteCatalog)
329+ }
330+ }
331+ } else {
332+ if len(tabs) > RemoteCatalogMaxTabsPerSess {
333+ return fmt.Errorf("%w: too many tabs", ErrRemoteCatalogTooLarge)
334+ }
335+ tabIDs := make(map[string]struct{}, len(tabs))
336+ for i, tab := range tabs {
337+ if !validRemoteCatalogText(tab.ID) || !validRemoteCatalogText(tab.Name) || !validRemoteCatalogText(tab.Detail) {
338+ return fmt.Errorf("%w: invalid tab text", ErrInvalidRemoteCatalog)
339+ }
340+ if len(tab.ID) > RemoteCatalogMaxIDBytes || len(tab.Name) > RemoteCatalogMaxNameBytes || len(tab.Detail) > RemoteCatalogMaxDetailBytes {
341+ return fmt.Errorf("%w: tab text too long", ErrRemoteCatalogTooLarge)
342+ }
343+ if tab.Index != uint16(i) {
344+ return fmt.Errorf("%w: tab indexes are not ordered", ErrInvalidRemoteCatalog)
345+ }
346+ if session.State == "running" && tab.ID == "" {
347+ return fmt.Errorf("%w: running tab has zero ID", ErrInvalidRemoteCatalog)
348+ }
349+ if tab.ID != "" {
350+ if err := domain.ValidateTabStableID(domain.TabStableID(tab.ID)); err != nil {
351+ return fmt.Errorf("%w: invalid tab ID", ErrInvalidRemoteCatalog)
352+ }
353+ if _, duplicate := tabIDs[tab.ID]; duplicate {
354+ return fmt.Errorf("%w: duplicate tab ID", ErrInvalidRemoteCatalog)
355+ }
356+ tabIDs[tab.ID] = struct{}{}
357+ }
358+ bytes += len(tab.ID) + len(tab.Name) + len(tab.Detail)
359+ }
360+ if session.ActiveTabID != "" {
361+ if session.State != "running" {
362+ return fmt.Errorf("%w: stopped or broken session has an active tab", ErrInvalidRemoteCatalog)
363+ }
364+ if _, ok := tabIDs[session.ActiveTabID]; !ok {
365+ return fmt.Errorf("%w: active tab is absent", ErrInvalidRemoteCatalog)
366+ }
367+ }
368+ }
369+ bytes += len(session.Name) + len(session.State) + len(session.Reason)
370+ }
371+ if bytes > RemoteCatalogMaxCatalogBytes {
372+ return ErrRemoteCatalogTooLarge
373+ }
374+ return nil
375+}
376+
377+// ValidateRemoteCatalogCacheEntries validates the bounded cache DTO before it
378+// is loaded into picker state or written to disk. A cache may contain legacy
379+// count-only sessions, but any complete typed snapshot must carry lifecycle
380+// identity and the same tab invariants as a live catalog.
381+func ValidateRemoteCatalogCacheEntries(entries []RemoteCatalogCacheEntry) error {
382+ if len(entries) > RemoteCatalogMaxHosts {
383+ return fmt.Errorf("%w: too many hosts", ErrRemoteCatalogTooLarge)
384+ }
385+ seenHosts := make(map[string]struct{}, len(entries))
386+ for _, entry := range entries {
387+ if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil || len(entry.Host) > RemoteCatalogMaxNameBytes {
388+ return fmt.Errorf("%w: invalid host", ErrInvalidRemoteCatalog)
389+ }
390+ if entry.FetchedAt.IsZero() || entry.FetchedAt.UnixNano() <= 0 {
391+ return fmt.Errorf("%w: invalid fetched time", ErrInvalidRemoteCatalog)
392+ }
393+ if _, exists := seenHosts[entry.Host]; exists {
394+ return fmt.Errorf("%w: duplicate host", ErrInvalidRemoteCatalog)
395+ }
396+ seenHosts[entry.Host] = struct{}{}
397+ complete := false
398+ for _, session := range entry.Sessions {
399+ if session.LifecycleID != (domain.SessionLifecycleID{}) || CatalogTabs(session) != nil {
400+ complete = true
401+ break
402+ }
403+ }
404+ schema := uint16(0)
405+ if complete {
406+ schema = RemoteCatalogSchemaVersion
407+ }
408+ catalog := RemoteCatalog{ProtocolVersion: ProtocolVersion, SchemaVersion: schema, Sessions: entry.Sessions}
409+ if err := ValidateRemoteCatalog(catalog); err != nil {
410+ return fmt.Errorf("%w: host %q: %v", ErrInvalidRemoteCatalog, entry.Host, err)
411+ }
412+ }
413+ return nil
414+}
415+
416+func validRemoteCatalogText(value string) bool {
417+ if !utf8.ValidString(value) {
418+ return false
419+ }
420+ for _, r := range value {
421+ if unicode.IsControl(r) {
422+ return false
423+ }
424+ }
425+ return true
426+}
427+
428+func validRemoteCatalogState(state string) bool {
429+ switch state {
430+ case "running", "stopped", "broken":
431+ return true
432+ default:
433+ return false
434+ }
435+}
436+
437+func validRemoteCatalogReason(reason string) bool {
438+ switch reason {
439+ case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_stopped", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
440+ return true
441+ default:
442+ return false
443+ }
101444 }
A internal/ports/remote_preview.go +395 -0
@@ -0,0 +1,395 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "math"
6+ "unicode/utf8"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+// RemotePreviewSchemaVersion is independent from the attachment IPC version.
13+const RemotePreviewSchemaVersion uint16 = 1
14+
15+const (
16+ RemotePreviewMaxWidth = 256
17+ RemotePreviewMaxHeight = 128
18+ RemotePreviewMaxCells = RemotePreviewMaxWidth * RemotePreviewMaxHeight
19+ RemotePreviewMaxBytes = 1 << 20
20+)
21+
22+// RemotePreviewStatus is a closed response taxonomy. Terminal contents are
23+// never carried in an error response.
24+type RemotePreviewStatus uint8
25+
26+const (
27+ RemotePreviewOK RemotePreviewStatus = iota
28+ RemotePreviewUnavailable
29+ RemotePreviewNoSuchTarget
30+ RemotePreviewStale
31+ RemotePreviewMalformed
32+ RemotePreviewTooLarge
33+)
34+
35+// RemotePreviewRequest asks the owning daemon for an in-memory live viewport.
36+type RemotePreviewRequest struct {
37+ Version uint16
38+ Target domain.RemoteSessionTarget
39+ Width uint16
40+ Height uint16
41+}
42+
43+// RemotePreview is a bounded row-major styled-cell viewport. It is a
44+// process-memory DTO and is never persisted, logged, or traced.
45+type RemotePreview struct {
46+ Version uint16
47+ Status RemotePreviewStatus
48+ LifecycleID domain.SessionLifecycleID
49+ TabID domain.TabStableID
50+ Revision uint64
51+ Width uint16
52+ Height uint16
53+ Cells []renderer.Cell
54+}
55+
56+var (
57+ ErrInvalidRemotePreviewRequest = errors.New("ports: invalid remote preview request")
58+ ErrInvalidRemotePreview = errors.New("ports: invalid remote preview")
59+ ErrRemotePreviewTooLarge = errors.New("ports: remote preview exceeds size limit")
60+ ErrRemotePreviewUnsupportedStyle = errors.New("ports: remote preview has unsupported style")
61+)
62+
63+func ValidateRemotePreviewRequest(request RemotePreviewRequest) error {
64+ if request.Version != RemotePreviewSchemaVersion || request.Target.Stopped || request.Target.Validate() != nil {
65+ return ErrInvalidRemotePreviewRequest
66+ }
67+ // These fields are encoded with uint16 byte lengths. Reject an oversized
68+ // nested route before putString truncates it on the wire.
69+ if len(request.Target.Endpoint) > math.MaxUint16 ||
70+ len(request.Target.DisplayOrigin) > math.MaxUint16 ||
71+ len(request.Target.SessionName) > math.MaxUint16 ||
72+ len(request.Target.LiveTabID) > math.MaxUint16 {
73+ return ErrInvalidRemotePreviewRequest
74+ }
75+ if request.Width == 0 || request.Height == 0 || int(request.Width) > RemotePreviewMaxWidth || int(request.Height) > RemotePreviewMaxHeight {
76+ return ErrInvalidRemotePreviewRequest
77+ }
78+ return nil
79+}
80+
81+func ValidateRemotePreview(preview RemotePreview) error {
82+ if preview.Version != RemotePreviewSchemaVersion || preview.Status > RemotePreviewTooLarge {
83+ return ErrInvalidRemotePreview
84+ }
85+ if preview.Status != RemotePreviewOK {
86+ if preview.Width != 0 || preview.Height != 0 || len(preview.Cells) != 0 {
87+ return ErrInvalidRemotePreview
88+ }
89+ return nil
90+ }
91+ if preview.LifecycleID == (domain.SessionLifecycleID{}) || domain.ValidateTabStableID(preview.TabID) != nil || preview.Revision == 0 {
92+ return ErrInvalidRemotePreview
93+ }
94+ if preview.Width == 0 || preview.Height == 0 || int(preview.Width) > RemotePreviewMaxWidth || int(preview.Height) > RemotePreviewMaxHeight {
95+ return ErrInvalidRemotePreview
96+ }
97+ want := int(preview.Width) * int(preview.Height)
98+ if want > RemotePreviewMaxCells || len(preview.Cells) != want {
99+ return ErrRemotePreviewTooLarge
100+ }
101+ for i, cell := range preview.Cells {
102+ if !utf8.ValidRune(cell.Rune) || !validRemotePreviewStyle(cell.Style) {
103+ return ErrInvalidRemotePreview
104+ }
105+ if cell.Continuation {
106+ if cell.Rune != 0 || i == 0 || preview.Cells[i-1].Continuation || renderer.RuneWidth(preview.Cells[i-1].Rune) != 2 {
107+ return ErrInvalidRemotePreview
108+ }
109+ } else if renderer.RuneWidth(cell.Rune) == 2 && (i+1 >= len(preview.Cells) || !preview.Cells[i+1].Continuation) {
110+ return ErrInvalidRemotePreview
111+ }
112+ }
113+ return nil
114+}
115+
116+func validRemotePreviewStyle(style renderer.Style) bool {
117+ return style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) == 0 &&
118+ style.UnderlineStyle <= renderer.UnderlineDashed &&
119+ style.Foreground >= math.MinInt16 && style.Foreground <= math.MaxInt16 &&
120+ style.Background >= math.MinInt16 && style.Background <= math.MaxInt16 &&
121+ style.UnderlineColor >= math.MinInt16 && style.UnderlineColor <= math.MaxInt16
122+}
123+
124+func putInt16(w *payloadWriter, n int) { w.putUint16(uint16(int16(n))) }
125+func getInt16(r *payloadReader) (int, error) {
126+ n, err := r.getUint16()
127+ return int(int16(n)), err
128+}
129+func putRGB(w *payloadWriter, c renderer.RGB) { w.putUint8(c.R); w.putUint8(c.G); w.putUint8(c.B) }
130+func getRGB(r *payloadReader) (renderer.RGB, error) {
131+ red, err := r.getUint8()
132+ if err != nil {
133+ return renderer.RGB{}, err
134+ }
135+ green, err := r.getUint8()
136+ if err != nil {
137+ return renderer.RGB{}, err
138+ }
139+ blue, err := r.getUint8()
140+ if err != nil {
141+ return renderer.RGB{}, err
142+ }
143+ return renderer.RGB{R: red, G: green, B: blue}, nil
144+}
145+
146+func putPreviewStyle(w *payloadWriter, s renderer.Style) {
147+ var flags uint8
148+ if s.Bold {
149+ flags |= 1 << 0
150+ }
151+ if s.Italic {
152+ flags |= 1 << 1
153+ }
154+ if s.Inverse {
155+ flags |= 1 << 2
156+ }
157+ if s.HasForegroundRGB {
158+ flags |= 1 << 3
159+ }
160+ if s.HasBackgroundRGB {
161+ flags |= 1 << 4
162+ }
163+ if s.HasUnderlineColor {
164+ flags |= 1 << 5
165+ }
166+ if s.HasUnderlineColorRGB {
167+ flags |= 1 << 6
168+ }
169+ w.putUint8(flags)
170+ w.putUint16(uint16(s.Attrs))
171+ putInt16(w, s.Foreground)
172+ putInt16(w, s.Background)
173+ w.putUint8(uint8(s.UnderlineStyle))
174+ putInt16(w, s.UnderlineColor)
175+ putRGB(w, s.ForegroundRGB)
176+ putRGB(w, s.BackgroundRGB)
177+ putRGB(w, s.UnderlineColorRGB)
178+}
179+
180+func getPreviewStyle(r *payloadReader) (renderer.Style, error) {
181+ flags, err := r.getUint8()
182+ if err != nil {
183+ return renderer.Style{}, err
184+ }
185+ if flags&0x80 != 0 {
186+ return renderer.Style{}, ErrRemotePreviewUnsupportedStyle
187+ }
188+ attrs, err := r.getUint16()
189+ if err != nil {
190+ return renderer.Style{}, err
191+ }
192+ fg, err := getInt16(r)
193+ if err != nil {
194+ return renderer.Style{}, err
195+ }
196+ bg, err := getInt16(r)
197+ if err != nil {
198+ return renderer.Style{}, err
199+ }
200+ ul, err := r.getUint8()
201+ if err != nil {
202+ return renderer.Style{}, err
203+ }
204+ ulc, err := getInt16(r)
205+ if err != nil {
206+ return renderer.Style{}, err
207+ }
208+ fgrgb, err := getRGB(r)
209+ if err != nil {
210+ return renderer.Style{}, err
211+ }
212+ bgrgb, err := getRGB(r)
213+ if err != nil {
214+ return renderer.Style{}, err
215+ }
216+ ulrgb, err := getRGB(r)
217+ if err != nil {
218+ return renderer.Style{}, err
219+ }
220+ return renderer.Style{Bold: flags&1 != 0, Italic: flags&(1<<1) != 0, Inverse: flags&(1<<2) != 0,
221+ HasForegroundRGB: flags&(1<<3) != 0, HasBackgroundRGB: flags&(1<<4) != 0,
222+ HasUnderlineColor: flags&(1<<5) != 0, HasUnderlineColorRGB: flags&(1<<6) != 0,
223+ Attrs: renderer.StyleAttrs(attrs), Foreground: fg, Background: bg, UnderlineStyle: renderer.UnderlineStyle(ul), UnderlineColor: ulc,
224+ ForegroundRGB: fgrgb, BackgroundRGB: bgrgb, UnderlineColorRGB: ulrgb}, nil
225+}
226+
227+func MarshalRemotePreviewRequest(request RemotePreviewRequest) []byte {
228+ if ValidateRemotePreviewRequest(request) != nil {
229+ return nil
230+ }
231+ w := payloadWriter{}
232+ w.putUint16(request.Version)
233+ w.putBytes(request.Target.LifecycleID[:])
234+ w.putString(request.Target.Endpoint)
235+ w.putString(request.Target.DisplayOrigin)
236+ w.putString(request.Target.SessionName)
237+ w.putString(string(request.Target.LiveTabID))
238+ w.putUint16(request.Width)
239+ w.putUint16(request.Height)
240+ return w.b
241+}
242+
243+func UnmarshalRemotePreviewRequest(data []byte) (RemotePreviewRequest, error) {
244+ if len(data) > RemotePreviewMaxBytes {
245+ return RemotePreviewRequest{}, ErrInvalidRemotePreviewRequest
246+ }
247+ r := payloadReader{b: data}
248+ var q RemotePreviewRequest
249+ var err error
250+ if q.Version, err = r.getUint16(); err != nil {
251+ return q, err
252+ }
253+ id, err := r.getBytes(16)
254+ if err != nil {
255+ return q, err
256+ }
257+ copy(q.Target.LifecycleID[:], id)
258+ if q.Target.Endpoint, err = r.getString(); err != nil {
259+ return q, err
260+ }
261+ if q.Target.DisplayOrigin, err = r.getString(); err != nil {
262+ return q, err
263+ }
264+ if q.Target.SessionName, err = r.getString(); err != nil {
265+ return q, err
266+ }
267+ tab, err := r.getString()
268+ if err != nil {
269+ return q, err
270+ }
271+ q.Target.LiveTabID = domain.TabStableID(tab)
272+ if q.Width, err = r.getUint16(); err != nil {
273+ return q, err
274+ }
275+ if q.Height, err = r.getUint16(); err != nil {
276+ return q, err
277+ }
278+ if err := r.done(); err != nil {
279+ return q, err
280+ }
281+ if err := ValidateRemotePreviewRequest(q); err != nil {
282+ return q, err
283+ }
284+ return q, nil
285+}
286+
287+const previewCellWireSize = 4 + 1 + 1 + 2 + 2 + 2 + 1 + 2 + 3 + 3 + 3
288+
289+func MarshalRemotePreview(preview RemotePreview) []byte {
290+ if ValidateRemotePreview(preview) != nil {
291+ return nil
292+ }
293+ w := payloadWriter{}
294+ w.putUint16(preview.Version)
295+ w.putUint8(uint8(preview.Status))
296+ w.putBytes(preview.LifecycleID[:])
297+ w.putString(string(preview.TabID))
298+ w.putUint64(preview.Revision)
299+ w.putUint16(preview.Width)
300+ w.putUint16(preview.Height)
301+ w.putUint32(uint32(len(preview.Cells)))
302+ for _, cell := range preview.Cells {
303+ var flags uint8
304+ if cell.Continuation {
305+ flags = 1
306+ }
307+ w.putUint32(uint32(cell.Rune))
308+ w.putUint8(flags)
309+ putPreviewStyle(&w, cell.Style)
310+ }
311+ return w.b
312+}
313+
314+func UnmarshalRemotePreview(data []byte) (RemotePreview, error) {
315+ if len(data) > RemotePreviewMaxBytes {
316+ return RemotePreview{}, ErrRemotePreviewTooLarge
317+ }
318+ r := payloadReader{b: data}
319+ var p RemotePreview
320+ var err error
321+ if p.Version, err = r.getUint16(); err != nil {
322+ return p, err
323+ }
324+ status, err := r.getUint8()
325+ if err != nil {
326+ return p, err
327+ }
328+ p.Status = RemotePreviewStatus(status)
329+ id, err := r.getBytes(16)
330+ if err != nil {
331+ return p, err
332+ }
333+ copy(p.LifecycleID[:], id)
334+ tab, err := r.getString()
335+ if err != nil {
336+ return p, err
337+ }
338+ p.TabID = domain.TabStableID(tab)
339+ if p.Revision, err = r.getUint64(); err != nil {
340+ return p, err
341+ }
342+ if p.Width, err = r.getUint16(); err != nil {
343+ return p, err
344+ }
345+ if p.Height, err = r.getUint16(); err != nil {
346+ return p, err
347+ }
348+ count, err := r.getUint32()
349+ if err != nil {
350+ return p, err
351+ }
352+ if count > RemotePreviewMaxCells || uint64(count) > uint64(len(r.b)/previewCellWireSize) {
353+ return p, ErrRemotePreviewTooLarge
354+ }
355+ if count != 0 {
356+ p.Cells = make([]renderer.Cell, 0, int(count))
357+ for range int(count) {
358+ runeValue, e := r.getUint32()
359+ if e != nil {
360+ return p, e
361+ }
362+ flags, e := r.getUint8()
363+ if e != nil {
364+ return p, e
365+ }
366+ if flags&^uint8(1) != 0 {
367+ return p, ErrInvalidRemotePreview
368+ }
369+ style, e := getPreviewStyle(&r)
370+ if e != nil {
371+ return p, e
372+ }
373+ p.Cells = append(p.Cells, renderer.Cell{Rune: rune(runeValue), Continuation: flags&1 != 0, Style: style})
374+ }
375+ }
376+ if err := r.done(); err != nil {
377+ return p, err
378+ }
379+ if err := ValidateRemotePreview(p); err != nil {
380+ return p, err
381+ }
382+ return p, nil
383+}
384+
385+func (p RemotePreview) FrameRows() [][]renderer.Cell {
386+ if p.Width == 0 || p.Height == 0 || len(p.Cells) != int(p.Width)*int(p.Height) {
387+ return nil
388+ }
389+ rows := make([][]renderer.Cell, p.Height)
390+ width := int(p.Width)
391+ for y := range rows {
392+ rows[y] = append([]renderer.Cell(nil), p.Cells[y*width:(y+1)*width]...)
393+ }
394+ return rows
395+}
A internal/ports/remote_preview_hardening_test.go +185 -0
@@ -0,0 +1,185 @@
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+func remotePreviewGoldenPayload() []byte {
14+ return []byte{
15+ 0x00, 0x01, 0x00,
16+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
17+ 0x00, 0x05, 't', 'a', 'b', '-', '1',
18+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
19+ 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02,
20+ 0x00, 0x00, 0x00, 0x6f, 0x00,
21+ 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff,
22+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
23+ 0x00, 0x00, 0x00, 0x6b, 0x00,
24+ 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff,
25+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
26+ }
27+}
28+
29+func TestRemotePreviewCodecGoldenRoundTrip(t *testing.T) {
30+ golden := remotePreviewGoldenPayload()
31+ got, err := UnmarshalRemotePreview(golden)
32+ if err != nil {
33+ t.Fatalf("UnmarshalRemotePreview() error = %v", err)
34+ }
35+ if got.Version != RemotePreviewSchemaVersion || got.Status != RemotePreviewOK {
36+ t.Fatalf("header = (%d, %d), want (%d, %d)", got.Version, got.Status, RemotePreviewSchemaVersion, RemotePreviewOK)
37+ }
38+ var wantLifecycle domain.SessionLifecycleID
39+ for i := range wantLifecycle {
40+ wantLifecycle[i] = byte(i + 1)
41+ }
42+ if got.LifecycleID != wantLifecycle || got.TabID != "tab-1" || got.Revision != 1 || got.Width != 2 || got.Height != 1 {
43+ t.Fatalf("metadata = %#v, want lifecycle/tab/revision/size from golden", got)
44+ }
45+ if len(got.Cells) != 2 || got.Cells[0].Rune != 'o' || got.Cells[1].Rune != 'k' {
46+ t.Fatalf("cells = %#v, want [o k]", got.Cells)
47+ }
48+ if !bytes.Equal(MarshalRemotePreview(got), golden) {
49+ t.Fatalf("marshal changed golden payload: %x", MarshalRemotePreview(got))
50+ }
51+}
52+
53+func TestRemotePreviewCodecRejectsInvalidStatusFlagsAndRanges(t *testing.T) {
54+ golden := remotePreviewGoldenPayload()
55+ tests := []struct {
56+ name string
57+ mutate func([]byte)
58+ }{
59+ {name: "status", mutate: func(b []byte) { b[2] = 0xff }},
60+ {name: "cell flags", mutate: func(b []byte) { b[46] = 0x02 }},
61+ {name: "style flags", mutate: func(b []byte) { b[47] = 0x80 }},
62+ {name: "style attrs", mutate: func(b []byte) { b[48] = 0x80 }},
63+ {name: "underline range", mutate: func(b []byte) { b[54] = byte(renderer.UnderlineDashed + 1) }},
64+ }
65+ for _, test := range tests {
66+ t.Run(test.name, func(t *testing.T) {
67+ payload := append([]byte(nil), golden...)
68+ test.mutate(payload)
69+ if _, err := UnmarshalRemotePreview(payload); err == nil {
70+ t.Fatal("malformed payload decoded successfully")
71+ }
72+ })
73+ }
74+}
75+
76+func TestRemotePreviewCodecRejectsWideCellBoundaryAndInvalidIDs(t *testing.T) {
77+ var lifecycle domain.SessionLifecycleID
78+ lifecycle[0] = 1
79+ valid := RemotePreview{
80+ Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK,
81+ LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1,
82+ Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true}},
83+ }
84+ if MarshalRemotePreview(valid) == nil {
85+ t.Fatal("valid wide-cell preview did not marshal")
86+ }
87+ for _, malformed := range []RemotePreview{
88+ {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 1, Height: 1, Cells: []renderer.Cell{{Rune: '界'}}},
89+ {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1, Cells: []renderer.Cell{{Continuation: true}, {Rune: 'x'}}},
90+ {Version: RemotePreviewSchemaVersion, Status: RemotePreviewOK, LifecycleID: lifecycle, TabID: "tab-1", Revision: 1, Width: 2, Height: 1, Cells: []renderer.Cell{{Rune: '界'}, {Continuation: true, Rune: 'x'}}},
91+ } {
92+ if MarshalRemotePreview(malformed) != nil {
93+ t.Fatalf("malformed wide-cell preview marshaled: %#v", malformed.Cells)
94+ }
95+ }
96+ invalidLifecycle := valid
97+ invalidLifecycle.LifecycleID = domain.SessionLifecycleID{}
98+ if MarshalRemotePreview(invalidLifecycle) != nil {
99+ t.Fatal("zero lifecycle ID marshaled")
100+ }
101+ invalidTab := valid
102+ invalidTab.TabID = "tab id"
103+ if MarshalRemotePreview(invalidTab) != nil {
104+ t.Fatal("invalid tab ID marshaled")
105+ }
106+}
107+
108+func TestRemotePreviewRequestRejectsOversizedNestedRouteData(t *testing.T) {
109+ target := previewTargetForTest()
110+ target.Endpoint = strings.Repeat("h", math.MaxUint16+1)
111+ request := RemotePreviewRequest{Version: RemotePreviewSchemaVersion, Target: target, Width: 20, Height: 5}
112+ if MarshalRemotePreviewRequest(request) != nil {
113+ t.Fatal("oversized endpoint was truncated and marshaled")
114+ }
115+}
116+
117+func TestRemoteTargetWireRejectsInvalidSelectorCombinations(t *testing.T) {
118+ cases := []struct {
119+ name string
120+ mutate func(*domain.RemoteSessionTarget)
121+ }{
122+ {name: "zero lifecycle", mutate: func(target *domain.RemoteSessionTarget) { target.LifecycleID = domain.SessionLifecycleID{} }},
123+ {name: "live and stopped selector", mutate: func(target *domain.RemoteSessionTarget) {
124+ target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 1)
125+ }},
126+ {name: "stopped with live ID", mutate: func(target *domain.RemoteSessionTarget) {
127+ target.Stopped = true
128+ target.LiveTabID = "tab-1"
129+ target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 1)
130+ }},
131+ {name: "stable selector with ordinal fields", mutate: func(target *domain.RemoteSessionTarget) {
132+ target.Stopped = true
133+ target.LiveTabID = ""
134+ target.StoppedTab = domain.TabSelector{Kind: domain.TabSelectorByStableID, StableID: "tab-1", Ordinal: 1}
135+ }},
136+ {name: "ordinal selector with zero count", mutate: func(target *domain.RemoteSessionTarget) {
137+ target.Stopped = true
138+ target.LiveTabID = ""
139+ target.StoppedTab = domain.NewOrdinalTabSelector(0, "main", 0)
140+ }},
141+ {name: "oversized tab name", mutate: func(target *domain.RemoteSessionTarget) {
142+ target.Stopped = true
143+ target.LiveTabID = ""
144+ target.StoppedTab = domain.NewOrdinalTabSelector(0, strings.Repeat("n", 257), 1)
145+ }},
146+ }
147+ for _, test := range cases {
148+ t.Run(test.name, func(t *testing.T) {
149+ target := *testRemoteTarget(false)
150+ test.mutate(&target)
151+ message := AttachTarget{Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach, RemoteTarget: &target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned}
152+ if MarshalAttachTarget(message) != nil {
153+ t.Fatal("invalid target was marshaled")
154+ }
155+ })
156+ }
157+}
158+
159+func TestRemoteTargetWireRichGoldenRoundTrip(t *testing.T) {
160+ target := testRemoteTarget(false)
161+ got := MarshalAttachTarget(AttachTarget{
162+ Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach,
163+ RemoteTarget: target, EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
164+ })
165+ want := []byte{
166+ 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
167+ 0x00, 0x04, 'w', 'o', 'r', 'k', IntentAttach,
168+ 0x01, 0x01,
169+ 0x00, 0x0f, 'b', 'u', 'i', 'l', 'd', '@', 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
170+ 0x00, 0x09, 'm', 'u', 'l', 'e', ':', '2', '2', '2', '2',
171+ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
172+ 0x00, 0x04, 'w', 'o', 'r', 'k', 0x00,
173+ 0x00, 0x05, 't', 'a', 'b', '-', '3', 0x00, 0x01,
174+ }
175+ if !bytes.Equal(got, want) {
176+ t.Fatalf("rich target bytes = %x, want %x", got, want)
177+ }
178+ decoded, err := UnmarshalAttachTarget(got)
179+ if err != nil {
180+ t.Fatalf("UnmarshalAttachTarget() error = %v", err)
181+ }
182+ if decoded.RemoteTarget == nil || *decoded.RemoteTarget != *target || decoded.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
183+ t.Fatalf("decoded target = %#v, policy = %d", decoded.RemoteTarget, decoded.EnvironmentPolicy)
184+ }
185+}
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 +107 -0
@@ -0,0 +1,107 @@
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+ t.Run(map[bool]string{false: "live", true: "stopped"}[stopped], func(t *testing.T) {
33+ target := testRemoteTarget(stopped)
34+ msg := Hello{
35+ Version: ProtocolVersion,
36+ Intent: IntentAttach,
37+ Name: target.SessionName,
38+ Size: domain.Size{Cols: 80, Rows: 24},
39+ RemoteTarget: target,
40+ EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
41+ }
42+ payload := MarshalHello(msg)
43+ if payload == nil {
44+ t.Fatal("MarshalHello returned nil")
45+ }
46+ got, err := UnmarshalHello(payload)
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ if got.RemoteTarget == nil || *got.RemoteTarget != *target {
51+ t.Fatalf("remote target = %#v, want %#v", got.RemoteTarget, target)
52+ }
53+ if got.EnvironmentPolicy != EnvironmentPolicyDaemonOwned {
54+ t.Fatalf("environment policy = %d, want daemon-owned", got.EnvironmentPolicy)
55+ }
56+
57+ attachPayload := MarshalAttachTarget(AttachTarget{
58+ Endpoint: target.Endpoint,
59+ Session: target.SessionName,
60+ Intent: IntentAttach,
61+ RemoteTarget: target,
62+ EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
63+ })
64+ attach, err := UnmarshalAttachTarget(attachPayload)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if attach.RemoteTarget == nil || *attach.RemoteTarget != *target {
69+ t.Fatalf("attach target = %#v, want %#v", attach.RemoteTarget, target)
70+ }
71+ })
72+ }
73+}
74+
75+func TestRemoteTargetWireRejectsTruncationAndTrailingData(t *testing.T) {
76+ target := testRemoteTarget(true)
77+ payload := MarshalAttachTarget(AttachTarget{
78+ Endpoint: target.Endpoint,
79+ Session: target.SessionName,
80+ Intent: IntentAttach,
81+ RemoteTarget: target,
82+ EnvironmentPolicy: EnvironmentPolicyDaemonOwned,
83+ })
84+ legacyLen := len(MarshalAttachTarget(AttachTarget{Endpoint: target.Endpoint, Session: target.SessionName, Intent: IntentAttach}))
85+ for i := 0; i < len(payload); i++ {
86+ if i == legacyLen {
87+ // The extension is optional for old peers; the complete legacy
88+ // prefix is therefore a valid count-only target by design.
89+ continue
90+ }
91+ if _, err := UnmarshalAttachTarget(payload[:i]); err == nil {
92+ t.Fatalf("prefix %d unexpectedly decoded", i)
93+ }
94+ }
95+ trailing := append(append([]byte(nil), payload...), 0xff)
96+ if _, err := UnmarshalAttachTarget(trailing); err == nil {
97+ t.Fatal("trailing garbage unexpectedly decoded")
98+ }
99+}
100+
101+func TestLegacyTargetWireBytesRemainUnchanged(t *testing.T) {
102+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', IntentAttach}
103+ got := MarshalAttachTarget(AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach})
104+ if !bytes.Equal(got, want) {
105+ t.Fatalf("legacy target bytes = %x, want %x", got, want)
106+ }
107+}
M internal/ports/wire.go +273 -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 }
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 +43 -5
@@ -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 }
@@ -220,6 +230,17 @@ func (r *Runner) terminalInput() *terminalInputPump {
220230 // above attach attempts so raw mode remains active while a live client process
221231 // redials a lost link.
222232 func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
233+ if request.RemoteTarget != nil {
234+ if err := request.RemoteTarget.Validate(); err != nil {
235+ return fmt.Errorf("vev: invalid remote attach target: %w", err)
236+ }
237+ if request.SessionName != request.RemoteTarget.SessionName {
238+ return errors.New("vev: remote attach target session mismatch")
239+ }
240+ if request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned && request.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
241+ return errors.New("vev: invalid remote environment policy")
242+ }
243+ }
223244 ms := milestones{}
224245
225246 defer func() {
@@ -393,7 +414,22 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
393414 _ = connection.Close()
394415 }
395416 if result.target != nil {
396- return &AttachTargetError{Target: *result.target}
417+ if r.attachHandoff == nil {
418+ return &AttachTargetError{Target: *result.target}
419+ }
420+ nextDialer, nextRequest, handoffErr := r.attachHandoff(*result.target)
421+ if handoffErr != nil {
422+ return handoffErr
423+ }
424+ if nextDialer == nil {
425+ return errors.New("vev: remote attach handoff returned nil dialer")
426+ }
427+ r.dialer = nextDialer
428+ attemptRequest = nextRequest
429+ remote = attemptRequest.Remote || r.remote
430+ resumeToken = 0
431+ backoff = defaultReconnectBackoff.initial
432+ continue
397433 }
398434 if result.resumeToken != 0 {
399435 resumeToken = result.resumeToken
@@ -695,6 +731,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
695731 TrueColor: trueColor,
696732 MaxOutputInFlight: requestedOutputWindow(transport),
697733 Env: os.Environ(),
734+ RemoteTarget: request.RemoteTarget,
735+ EnvironmentPolicy: request.EnvironmentPolicy,
698736 }
699737 if err := sendHandshake(func() error {
700738 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/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 +271 -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"
@@ -61,7 +62,12 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
6162 rt.pickerMu.Lock()
6263 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
6364 rt.pickerPreviewGeneration++
65+ if rt.pickerRemotePreviewCancel != nil {
66+ rt.pickerRemotePreviewCancel()
67+ rt.pickerRemotePreviewCancel = nil
68+ }
6469 rt.pickerPreviewSession = nil
70+ rt.pickerRemotePreview = picker.Preview{}
6571 rt.pickerPreview = nil
6672 rt.picker = model
6773 rt.pickerGeneration++
@@ -163,7 +169,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
163169 catalogRows := 0
164170 for _, host := range catalog {
165171 catalogRows += len(host.entry.Sessions)
166- if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
172+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
167173 catalogRows++
168174 }
169175 }
@@ -179,7 +185,7 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
179185 }
180186 views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
181187 }
182- if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch) {
188+ if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
183189 views = append(views, remotePickerHostView(host.entry.Host, host.status))
184190 }
185191 }
@@ -216,6 +222,42 @@ func attentionSuffix(label string) string {
216222 return label + " " + string(ui.AttentionGlyph)
217223 }
218224
225+func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) {
226+ reason := target.UnavailableReason
227+ if reason == "" {
228+ reason = "identity_changed"
229+ }
230+ message := "Remote session unavailable"
231+ if target.RemoteTarget != nil {
232+ message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin
233+ } else if target.RemoteHost != "" {
234+ message += ": " + target.RemoteHost
235+ }
236+ message += " — " + remotePickerReasonText(reason)
237+ d.notify(sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
238+}
239+
240+func remotePickerReasonText(reason string) string {
241+ switch reason {
242+ case "catalog_stale":
243+ return "catalog stale"
244+ case "host_unreachable":
245+ return "host unreachable"
246+ case "version_mismatch":
247+ return "version mismatch"
248+ case "session_stopped":
249+ return "session stopped"
250+ case "session_broken":
251+ return "session broken"
252+ case "refreshing":
253+ return "refreshing"
254+ case "identity_changed":
255+ return "session identity changed"
256+ default:
257+ return "unavailable"
258+ }
259+}
260+
219261 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
220262 rt := ac.overlays
221263 var previewGeneration uint64
@@ -271,13 +313,16 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
271313 }
272314 })
273315 var target picker.Target
316+ var cursor picker.Target
274317 var ok bool
318+ var cursorOK bool
275319 var intent pickerIntent
276320 var source moveSourceLocator
277321 // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
278322 prevIdx := -1
279323 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
280324 target, ok = rt.picker.Selected()
325+ cursor, cursorOK = rt.picker.Cursor()
281326 prevIdx = rt.picker.SelectedIndex()
282327 }
283328 intent, source = rt.pickerIntent, rt.pickerSource
@@ -289,6 +334,11 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
289334 d.invalidateRender(sess, ac, true, "picker.go")
290335 return
291336 }
337+ if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
338+ d.notifyRemotePickerUnavailable(sess, cursor)
339+ d.invalidateRender(sess, ac, true, "picker.go")
340+ return
341+ }
292342 if result.action == 'x' {
293343 var effect *attachmentEffectTicket
294344 if len(effects) != 0 {
@@ -379,13 +429,22 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
379429 previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
380430 ac.overlays.pickerPreviewGeneration++
381431 generation := ac.overlays.pickerPreviewGeneration
432+ if ac.overlays.pickerRemotePreviewCancel != nil {
433+ ac.overlays.pickerRemotePreviewCancel()
434+ ac.overlays.pickerRemotePreviewCancel = nil
435+ }
382436 ac.overlays.pickerPreviewSession = nil
437+ ac.overlays.pickerRemotePreview = picker.Preview{}
383438 ac.overlays.pickerPreview = nil
384439 ac.overlays.pickerMu.Unlock()
385440 d.teardownPreviewSubscription(ac, previous, previousGeneration)
386441 if !ok {
387442 return
388443 }
444+ if target.RemoteTarget != nil {
445+ d.startRemotePickerPreview(ac, target, generation)
446+ return
447+ }
389448 targetSess, next := d.previewTarget(target, intent)
390449 if next == nil || targetSess == nil {
391450 return
@@ -421,6 +480,122 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
421480 d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation)
422481 }
423482
483+func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Target, generation uint64) {
484+ if d == nil || ac == nil || target.RemoteTarget == nil || (d.remotePreviewClient == nil && !target.RemoteTarget.Stopped) {
485+ return
486+ }
487+ width, height := remotePickerPreviewSize(ac.sizeSnapshot())
488+ if width == 0 || height == 0 {
489+ return
490+ }
491+ ctx := d.serveCtx
492+ if ctx == nil {
493+ ctx = context.Background()
494+ }
495+ previewCtx, cancel := context.WithCancel(ctx)
496+ ac.overlays.pickerMu.Lock()
497+ if ac.overlays.picker == nil || ac.overlays.pickerPreviewGeneration != generation {
498+ ac.overlays.pickerMu.Unlock()
499+ cancel()
500+ return
501+ }
502+ ac.overlays.pickerRemotePreviewCancel = cancel
503+ remoteTarget := *target.RemoteTarget
504+ message := "loading remote preview…"
505+ if remoteTarget.Stopped {
506+ message = "stopped session — preview unavailable"
507+ }
508+ ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
509+ if remoteTarget.Stopped {
510+ ac.overlays.pickerRemotePreviewCancel = nil
511+ ac.overlays.pickerMu.Unlock()
512+ cancel()
513+ if sess := ac.currentAttachmentSession(); sess != nil {
514+ d.invalidateRender(sess, ac, false, "remote picker preview")
515+ }
516+ return
517+ }
518+ ac.overlays.pickerMu.Unlock()
519+ go func() {
520+ previewClock := d.clock
521+ if previewClock == nil {
522+ previewClock = systemClock{}
523+ }
524+ debounce := previewClock.NewTimer(80 * time.Millisecond)
525+ defer debounce.Stop()
526+ select {
527+ case <-debounce.C():
528+ case <-previewCtx.Done():
529+ return
530+ }
531+ preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height)
532+ if err != nil {
533+ ac.overlays.pickerMu.Lock()
534+ if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil {
535+ ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable")
536+ }
537+ ac.overlays.pickerMu.Unlock()
538+ if sess := ac.currentAttachmentSession(); sess != nil {
539+ d.invalidateRender(sess, ac, false, "remote picker preview")
540+ }
541+ return
542+ }
543+ if refreshed, refreshErr := d.awaitRemotePreviewRefresh(previewCtx, remoteTarget, width, height); refreshErr == nil {
544+ preview = refreshed
545+ } else if previewCtx.Err() != nil {
546+ return
547+ }
548+ rows := preview.FrameRows()
549+ candidate := picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
550+ ac.overlays.pickerMu.Lock()
551+ if ac.overlays.pickerPreviewGeneration != generation || ac.overlays.picker == nil {
552+ ac.overlays.pickerMu.Unlock()
553+ return
554+ }
555+ selected, stillSelected := ac.overlays.picker.Selected()
556+ if !stillSelected || !pickerTargetsEqual(selected, target) {
557+ ac.overlays.pickerMu.Unlock()
558+ return
559+ }
560+ ac.overlays.pickerRemotePreview = candidate
561+ ac.overlays.pickerRemotePreviewCancel = nil
562+ ac.overlays.pickerMu.Unlock()
563+ if sess := ac.currentAttachmentSession(); sess != nil {
564+ d.invalidateRender(sess, ac, false, "remote picker preview")
565+ }
566+ }()
567+}
568+
569+func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
570+ layout := picker.ChooseLayout(size)
571+ width, height := layout.Preview.Width, layout.Preview.Height
572+ if width <= 0 || height <= 0 {
573+ return 0, 0
574+ }
575+ return uint16(min(width, ports.RemotePreviewMaxWidth)), uint16(min(height, ports.RemotePreviewMaxHeight))
576+}
577+
578+func staticRemotePickerPreview(width, height uint16, message string) picker.Preview {
579+ if width == 0 || height == 0 {
580+ return picker.Preview{}
581+ }
582+ rows := make([][]renderer.Cell, int(height))
583+ style := renderer.DefaultStyle()
584+ for y := range rows {
585+ rows[y] = make([]renderer.Cell, int(width))
586+ for x := range rows[y] {
587+ rows[y][x] = renderer.Cell{Rune: ' ', Style: style}
588+ }
589+ }
590+ for x, r := range []rune(message) {
591+ if x >= int(width) {
592+ break
593+ }
594+ rows[0][x] = renderer.Cell{Rune: r, Style: style}
595+ }
596+ return picker.Preview{Rows: rows, Width: int(width), Height: int(height)}
597+}
598+
424599 func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {
425600 ac.overlays.pickerMu.Lock()
426601 defer ac.overlays.pickerMu.Unlock()
@@ -463,7 +638,12 @@ func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) b
463638 }
464639 previous := ac.overlays.pickerPreviewSession
465640 ac.overlays.pickerPreviewGeneration++
641+ if ac.overlays.pickerRemotePreviewCancel != nil {
642+ ac.overlays.pickerRemotePreviewCancel()
643+ ac.overlays.pickerRemotePreviewCancel = nil
644+ }
466645 ac.overlays.pickerPreviewSession = nil
646+ ac.overlays.pickerRemotePreview = picker.Preview{}
467647 ac.overlays.pickerPreview = nil
468648 ac.overlays.pickerMu.Unlock()
469649 d.teardownPreviewSubscription(ac, previous, generation)
@@ -537,8 +717,9 @@ func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, g
537717
538718 func pickerTargetsEqual(left, right picker.Target) bool {
539719 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) {
720+ left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
721+ left.UnavailableReason != right.UnavailableReason || !remoteKeysEqual(left.RemoteKey, right.RemoteKey) ||
722+ !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) {
542723 return false
543724 }
544725 if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
@@ -554,6 +735,13 @@ func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
554735 return *left == *right
555736 }
556737
738+func remoteTargetsEqual(left, right *domain.RemoteSessionTarget) bool {
739+ if left == nil || right == nil {
740+ return left == nil && right == nil
741+ }
742+ return *left == *right
743+}
744+
557745 func (d *Daemon) sessionByID(id domain.SessionID) *session {
558746 d.mu.Lock()
559747 defer d.mu.Unlock()
@@ -660,6 +848,13 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
660848 if token.sess == nil || token.ac == nil || token.effect == nil {
661849 return nil
662850 }
851+ if target.RemoteTarget != nil {
852+ var key domain.RemoteSessionKey
853+ if target.RemoteKey != nil {
854+ key = *target.RemoteKey
855+ }
856+ return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
857+ }
663858 if target.RemoteKey != nil {
664859 return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
665860 }
@@ -676,14 +871,35 @@ func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, ta
676871 // the target is sent, the local attachment is detached and the client opens a
677872 // fresh connection to the owning daemon.
678873 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) {
874+ var handoff ports.AttachTarget
875+ if target.RemoteTarget != nil {
876+ remoteTarget := *target.RemoteTarget
877+ if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
878+ return errAttachmentTransition
879+ }
880+ if target.RemoteKey != nil {
881+ if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
882+ return errAttachmentTransition
883+ }
884+ }
885+ handoff = ports.AttachTarget{
886+ Endpoint: remoteTarget.Endpoint,
887+ Session: remoteTarget.SessionName,
888+ Intent: ports.IntentAttach,
889+ RemoteTarget: &remoteTarget,
890+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
891+ }
892+ } else {
893+ if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
894+ return errAttachmentTransition
895+ }
896+ handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach}
897+ }
898+ payload := ports.MarshalAttachTarget(handoff)
899+ if payload == nil {
680900 return errAttachmentTransition
681901 }
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 {
902+ if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
687903 return err
688904 }
689905 if guard.closePicker {
@@ -712,6 +928,51 @@ func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
712928 return false
713929 }
714930
931+func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
932+ if d == nil || target.Validate() != nil {
933+ return false
934+ }
935+ d.remoteCatalog.mu.Lock()
936+ defer d.remoteCatalog.mu.Unlock()
937+ if d.remoteCatalog.status[target.Endpoint] != remoteHostFresh {
938+ return false
939+ }
940+ for _, entry := range d.remoteCatalog.cache {
941+ if entry.Host != target.Endpoint {
942+ continue
943+ }
944+ for _, session := range entry.Sessions {
945+ if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
946+ continue
947+ }
948+ if target.Stopped != (session.State == "stopped") || session.State == "broken" {
949+ return false
950+ }
951+ tabs := ports.CatalogTabs(session)
952+ metadata := make([]domain.TabSelectorTab, 0, len(tabs))
953+ for _, tab := range tabs {
954+ metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
955+ }
956+ if target.Stopped {
957+ _, ok := target.StoppedTab.Resolve(metadata)
958+ return ok
959+ }
960+ found := false
961+ for _, tab := range tabs {
962+ if tab.ID != string(target.LiveTabID) {
963+ continue
964+ }
965+ if found {
966+ return false
967+ }
968+ found = true
969+ }
970+ return found
971+ }
972+ }
973+ return false
974+}
975+
715976 // switchToTargetGuarded is retained for daemon-internal and headless callers.
716977 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
717978 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
A internal/usecase/daemon/remote_attach.go +170 -0
@@ -0,0 +1,170 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// routeRemoteTargetWithContext is the exact-target branch for picker handoffs.
13+// It is deliberately separate from legacy name routing: a lifecycle mismatch,
14+// missing tab, broken record, or stopped-record race is a no-such-target error,
15+// never an invitation to attach a same-name replacement.
16+func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
17+ if h.RemoteTarget == nil {
18+ return nil, nil, errors.New("daemon: missing remote target")
19+ }
20+ target := *h.RemoteTarget
21+ if err := target.Validate(); err != nil {
22+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "invalid remote target"}
23+ }
24+ if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
25+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires daemon-owned environment"}
26+ }
27+ if h.Intent != ports.IntentAttach && h.Intent != ports.IntentResume {
28+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote target requires attach or resume"}
29+ }
30+ if err := d.waitForTargetRestore(ctx, target.SessionName); err != nil {
31+ return nil, nil, remoteTargetError(err)
32+ }
33+
34+ d.mu.Lock()
35+ if d.closing {
36+ d.mu.Unlock()
37+ return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
38+ }
39+ if live := d.findByNameLocked(target.SessionName); live != nil {
40+ if live.incarnation != target.LifecycleID {
41+ d.mu.Unlock()
42+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle has changed"}
43+ }
44+ if target.Stopped && target.LiveTabID != "" {
45+ d.mu.Unlock()
46+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "stopped target has a live tab selector"}
47+ }
48+ if _, ok := remoteTargetTabIndexLocked(live, target); !ok {
49+ d.mu.Unlock()
50+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
51+ }
52+ ac, err := d.finishAttach(live, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h)
53+ return live, ac, err
54+ }
55+
56+ stopped, ok := d.stopped[target.SessionName]
57+ if !ok || stopped.purging || stopped.incarnation != target.LifecycleID {
58+ d.mu.Unlock()
59+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session lifecycle no longer exists"}
60+ }
61+ if stopped.state == ports.SessionBroken || stopped.record.DegradedReason != "" {
62+ d.mu.Unlock()
63+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote session is broken"}
64+ }
65+ if _, ok := remoteTargetTabIndexStopped(stopped, target); !ok {
66+ d.mu.Unlock()
67+ return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote stopped tab no longer exists"}
68+ }
69+
70+ // Picker handoffs use the daemon's own environment and persisted CWD. The
71+ // client's Env/Cwd fields remain present for wire compatibility but are not
72+ // trusted for this branch.
73+ env := copyEnvironment(os.Environ())
74+ cwd := d.dirOrHome(stopped.cwd)
75+ sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, terminalEnv{TrueColor: h.TrueColor}, env, stopped.tabNames)
76+ if err != nil {
77+ d.mu.Unlock()
78+ return nil, nil, err
79+ }
80+ ac, err := d.finishAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h)
81+ if err != nil {
82+ // The route-created session has no published client if finishAttach
83+ // fails. Retire it immediately rather than leaving a shadow live row.
84+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
85+ return nil, nil, err
86+ }
87+ ac.routeCreatedSession = true
88+ return sess, ac, nil
89+}
90+
91+func (d *Daemon) remoteTargetMatchesSession(sess *session, target domain.RemoteSessionTarget) bool {
92+ if sess == nil || sess.incarnation != target.LifecycleID || sess.name != target.SessionName {
93+ return false
94+ }
95+ d.mu.Lock()
96+ defer d.mu.Unlock()
97+ if d.sessions[sess.id] != sess {
98+ return false
99+ }
100+ _, ok := remoteTargetTabIndexLocked(sess, target)
101+ return ok
102+}
103+
104+func remoteTargetError(err error) error {
105+ var protocol *protoErr
106+ if errors.As(err, &protocol) {
107+ return protocol
108+ }
109+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
110+ return err
111+ }
112+ return &protoErr{ports.ErrNoSuchTarget, "remote target is unavailable"}
113+}
114+
115+func stoppedTabMetadata(stopped stoppedSession) []domain.TabSelectorTab {
116+ records := stopped.tabRecords
117+ if len(records) == 0 {
118+ records = make([]domain.CatalogueTabRecord, len(stopped.tabNames))
119+ for i, name := range stopped.tabNames {
120+ records[i].Name = name
121+ }
122+ }
123+ metadata := make([]domain.TabSelectorTab, 0, len(records))
124+ for _, record := range records {
125+ metadata = append(metadata, domain.TabSelectorTab{ID: record.StableID, Name: record.Name})
126+ }
127+ return metadata
128+}
129+
130+func remoteTargetTabIndexStopped(stopped stoppedSession, target domain.RemoteSessionTarget) (int, bool) {
131+ return target.StoppedTab.Resolve(stoppedTabMetadata(stopped))
132+}
133+
134+// remoteTargetTabIndexLocked resolves the selector against the live session's
135+// current ordered tabs. Caller holds d.mu; this function takes only the
136+// session/tab locks needed for a coherent metadata snapshot.
137+func remoteTargetTabIndexLocked(sess *session, target domain.RemoteSessionTarget) (int, bool) {
138+ if sess == nil {
139+ return 0, false
140+ }
141+ sess.mu.Lock()
142+ tabs := append([]*tab(nil), sess.tabs...)
143+ sess.mu.Unlock()
144+ metadata := make([]domain.TabSelectorTab, 0, len(tabs))
145+ for _, tab := range tabs {
146+ if tab == nil {
147+ return 0, false
148+ }
149+ tab.mu.Lock()
150+ metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.stableID), Name: tab.name})
151+ tab.mu.Unlock()
152+ }
153+ if target.Stopped {
154+ return target.StoppedTab.Resolve(metadata)
155+ }
156+ if target.LiveTabID == "" {
157+ return 0, false
158+ }
159+ found := -1
160+ for i, tab := range metadata {
161+ if tab.ID != target.LiveTabID {
162+ continue
163+ }
164+ if found >= 0 {
165+ return 0, false
166+ }
167+ found = i
168+ }
169+ return found, found >= 0
170+}
A internal/usecase/daemon/remote_attach_test.go +70 -0
@@ -0,0 +1,70 @@
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+ d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
53+ Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []ports.RemoteCatalogSession{{
54+ LifecycleID: lifecycle, Name: "work", State: "running", Tabs: []ports.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
55+ }},
56+ }})
57+ d.remoteCatalog.mu.Lock()
58+ d.remoteCatalog.status["arch"] = remoteHostFresh
59+ d.remoteCatalog.mu.Unlock()
60+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "local")
61+ token := sess.attachmentToken(ac, ac.transport())
62+ effect, admitted := ac.beginAttachmentEffect(token)
63+ require.True(t, admitted)
64+ token.effect = effect
65+ defer effect.End()
66+ key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
67+ remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "replaced"}
68+ target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "replaced"}
69+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, key, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
70+}
M internal/usecase/daemon/remote_catalog_state.go +8 -1
@@ -15,8 +15,10 @@ const (
1515 remoteHostCached remoteHostStatus = iota
1616 remoteHostRefreshing
1717 remoteHostFresh
18+ remoteHostStale
1819 remoteHostUnreachable
1920 remoteHostVersionMismatch
21+ remoteHostMalformed
2022 )
2123
2224 // remoteCatalogState owns the cache-derived discovery state. Its mutex never
@@ -43,7 +45,12 @@ func newRemoteCatalogState() remoteCatalogState {
4345
4446 func cloneRemoteCatalogEntry(entry ports.RemoteCatalogCacheEntry) ports.RemoteCatalogCacheEntry {
4547 sessions := make([]ports.RemoteCatalogSession, len(entry.Sessions))
46- copy(sessions, entry.Sessions)
48+ for i, session := range entry.Sessions {
49+ sessions[i] = session
50+ if tabs := ports.CatalogTabs(session); tabs != nil {
51+ sessions[i].Tabs = append([]ports.RemoteCatalogTab(nil), tabs...)
52+ }
53+ }
4754 return ports.RemoteCatalogCacheEntry{
4855 Host: entry.Host,
4956 FetchedAt: entry.FetchedAt,
M internal/usecase/daemon/remote_picker.go +153 -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,12 @@ 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) && !errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) {
526+ status = remoteHostUnreachable
394527 }
395528 d.remoteCatalog.status[host] = status
396529 d.remoteCatalog.failure[host] = now
@@ -398,11 +531,11 @@ func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalo
398531 d.refreshRemoteOpenPickers()
399532 return false
400533 }
401- d.remoteCatalog.cache[host] = ports.RemoteCatalogCacheEntry{
534+ d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
402535 Host: host,
403536 FetchedAt: now,
404- Sessions: append([]ports.RemoteCatalogSession{}, catalog.Sessions...),
405- }
537+ Sessions: catalog.Sessions,
538+ })
406539 d.remoteCatalog.status[host] = remoteHostFresh
407540 delete(d.remoteCatalog.failure, host)
408541 d.remoteCatalog.mu.Unlock()
A internal/usecase/daemon/remote_preview.go +114 -0
@@ -0,0 +1,114 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func (d *Daemon) handleRemotePreview(tr ports.Transport, frame ports.Frame) error {
11+ defer func() { _ = tr.Close() }()
12+ request, err := ports.UnmarshalRemotePreviewRequest(frame.Payload)
13+ if err != nil {
14+ return d.sendRemotePreview(tr, ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewMalformed})
15+ }
16+ preview, err := d.captureRemotePreview(request)
17+ if err != nil {
18+ status := ports.RemotePreviewUnavailable
19+ if errors.Is(err, errRemotePreviewNoSuchTarget) {
20+ status = ports.RemotePreviewNoSuchTarget
21+ }
22+ preview = ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: status}
23+ }
24+ return d.sendRemotePreview(tr, preview)
25+}
26+
27+func (d *Daemon) sendRemotePreview(tr ports.Transport, preview ports.RemotePreview) error {
28+ payload := ports.MarshalRemotePreview(preview)
29+ if payload == nil {
30+ payload = ports.MarshalRemotePreview(ports.RemotePreview{Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewMalformed})
31+ }
32+ return d.boundedControlSend(tr, ports.Frame{Type: ports.MsgRemotePreviewResponse, Payload: payload})
33+}
34+
35+var errRemotePreviewNoSuchTarget = errors.New("daemon: remote preview target does not exist")
36+
37+func (d *Daemon) captureRemotePreview(request ports.RemotePreviewRequest) (ports.RemotePreview, error) {
38+ target := request.Target
39+ d.mu.Lock()
40+ sess := d.findByNameLocked(target.SessionName)
41+ if sess == nil || sess.incarnation != target.LifecycleID {
42+ d.mu.Unlock()
43+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
44+ }
45+ index, ok := remoteTargetTabIndexLocked(sess, target)
46+ d.mu.Unlock()
47+ if !ok {
48+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
49+ }
50+
51+ sess.mu.Lock()
52+ if index < 0 || index >= len(sess.tabs) {
53+ sess.mu.Unlock()
54+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
55+ }
56+ tab := sess.tabs[index]
57+ sess.mu.Unlock()
58+ tab.mu.Lock()
59+ pane := tab.focusedPane()
60+ if pane == nil {
61+ tab.mu.Unlock()
62+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
63+ }
64+ pane.mu.Lock()
65+ if pane.screen == nil {
66+ pane.mu.Unlock()
67+ tab.mu.Unlock()
68+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
69+ }
70+ source := pane.screen.Frame.Clone()
71+ revision := pane.syncGen
72+ pane.mu.Unlock()
73+ tab.mu.Unlock()
74+ if revision == 0 {
75+ revision = 1
76+ }
77+
78+ width := min(int(request.Width), source.Width)
79+ height := min(int(request.Height), source.Height)
80+ if width <= 0 || height <= 0 {
81+ return ports.RemotePreview{}, errRemotePreviewNoSuchTarget
82+ }
83+ startY := source.Height - height
84+ cells := make([]renderer.Cell, 0, width*height)
85+ for y := 0; y < height; y++ {
86+ row := source.Row(startY + y)
87+ for x := 0; x < width; x++ {
88+ cell := row[x]
89+ // A crop may end immediately after the left half of a wide
90+ // rune. Replace that boundary cell with a styled blank rather
91+ // than sending an invalid continuation sequence to the picker.
92+ if x == width-1 && !cell.Continuation && renderer.RuneWidth(cell.Rune) == 2 {
93+ cell = renderer.Cell{Rune: ' ', Style: cell.Style}
94+ }
95+ cells = append(cells, cell)
96+ }
97+ }
98+ preview := ports.RemotePreview{
99+ Version: ports.RemotePreviewSchemaVersion,
100+ Status: ports.RemotePreviewOK,
101+ LifecycleID: target.LifecycleID,
102+ TabID: target.LiveTabID,
103+ Revision: revision,
104+ Width: uint16(width),
105+ Height: uint16(height),
106+ Cells: cells,
107+ }
108+ if err := ports.ValidateRemotePreview(preview); err != nil {
109+ // A crop must not split a wide cell. Return a safe unavailable response
110+ // instead of exposing a malformed cell stream.
111+ return ports.RemotePreview{}, err
112+ }
113+ return preview, nil
114+}
A internal/usecase/daemon/remote_preview_cache.go +241 -0
@@ -0,0 +1,241 @@
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+ preview, err := d.remotePreviewClient.Preview(ctx, target, width, height)
195+ <-d.remotePreview.slots
196+ if err == nil {
197+ if validationErr := ports.ValidateRemotePreview(preview); validationErr != nil {
198+ err = validationErr
199+ } else if preview.Status != ports.RemotePreviewOK {
200+ err = fmt.Errorf("remote preview returned status %d", preview.Status)
201+ } else if preview.LifecycleID != target.LifecycleID || preview.TabID != target.LiveTabID {
202+ err = errors.New("remote preview identity changed")
203+ }
204+ }
205+ if err != nil {
206+ preview = ports.RemotePreview{}
207+ }
208+ d.finishRemotePreviewFlight(key, flight, preview, err)
209+}
210+
211+func (d *Daemon) finishRemotePreviewFlight(key remotePreviewCacheKey, flight *remotePreviewFlight, preview ports.RemotePreview, err error) {
212+ now := d.clock.Now()
213+ d.remotePreview.mu.Lock()
214+ flight.preview = cloneRemotePreview(preview)
215+ flight.err = err
216+ delete(d.remotePreview.flights, key)
217+ if err == nil {
218+ d.remotePreview.cache[key] = remotePreviewCacheEntry{
219+ Preview: cloneRemotePreview(preview), Fetched: now, Used: now,
220+ }
221+ delete(d.remotePreview.cooldowns, key)
222+ for len(d.remotePreview.cache) > remotePreviewCacheMax {
223+ var oldestKey remotePreviewCacheKey
224+ var oldest time.Time
225+ for candidate, entry := range d.remotePreview.cache {
226+ used := entry.Used
227+ if used.IsZero() {
228+ used = entry.Fetched
229+ }
230+ if oldest.IsZero() || used.Before(oldest) {
231+ oldestKey, oldest = candidate, used
232+ }
233+ }
234+ delete(d.remotePreview.cache, oldestKey)
235+ }
236+ } else if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
237+ d.remotePreview.cooldowns[key] = now.Add(remotePreviewCooldown)
238+ }
239+ close(flight.done)
240+ d.remotePreview.mu.Unlock()
241+}
A internal/usecase/daemon/remote_preview_cache_test.go +428 -0
@@ -0,0 +1,428 @@
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 ports.RemotePreview{
116+ Version: ports.RemotePreviewSchemaVersion, Status: ports.RemotePreviewOK,
117+ LifecycleID: target.LifecycleID, TabID: target.LiveTabID,
118+ Revision: revision, Width: 1, Height: 1,
119+ Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}},
120+ }
121+}
122+
123+func TestFetchRemotePreviewSingleFlightAndCopiesCache(t *testing.T) {
124+ clock := &remotePreviewTestClock{now: time.Unix(100, 0)}
125+ target := remotePreviewCacheTarget()
126+ client := &remotePreviewTestClient{
127+ result: remotePreviewCacheResult(target, 1),
128+ started: make(chan struct{}),
129+ release: make(chan struct{}),
130+ }
131+ d := newTestDaemon(t, nil, clock)
132+ d.remotePreviewClient = client
133+
134+ firstDone := make(chan struct{})
135+ var first ports.RemotePreview
136+ var firstErr error
137+ go func() {
138+ first, firstErr = d.fetchRemotePreview(context.Background(), target, 1, 1)
139+ close(firstDone)
140+ }()
141+ <-client.started
142+
143+ secondDone := make(chan struct{})
144+ var second ports.RemotePreview
145+ var secondErr error
146+ go func() {
147+ second, secondErr = d.fetchRemotePreview(context.Background(), target, 1, 1)
148+ close(secondDone)
149+ }()
150+ close(client.release)
151+ <-firstDone
152+ <-secondDone
153+ require.NoError(t, firstErr)
154+ require.NoError(t, secondErr)
155+ require.Equal(t, first, second)
156+ require.Equal(t, 1, client.Calls(), "same key must use one remote request")
157+
158+ first.Cells[0].Rune = 'm'
159+ cached, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
160+ require.NoError(t, err)
161+ require.Equal(t, rune('x'), cached.Cells[0].Rune, "callers must not mutate the memory-only cache")
162+ require.Equal(t, 1, client.Calls())
163+}
164+
165+func TestFetchRemotePreviewRejectsMalformedResponseAndAppliesCooldown(t *testing.T) {
166+ clock := &remotePreviewTestClock{now: time.Unix(200, 0)}
167+ target := remotePreviewCacheTarget()
168+ client := &remotePreviewTestClient{err: errors.New("remote unavailable")}
169+ d := newTestDaemon(t, nil, clock)
170+ d.remotePreviewClient = client
171+
172+ _, firstErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
173+ require.Error(t, firstErr)
174+ _, secondErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
175+ require.Error(t, secondErr)
176+ require.Equal(t, 1, client.Calls(), "a failed target must be cooled down")
177+
178+ clock.Advance(remotePreviewCooldown)
179+ _, thirdErr := d.fetchRemotePreview(context.Background(), target, 1, 1)
180+ require.Error(t, thirdErr)
181+ require.Equal(t, 2, client.Calls(), "cooldown expiry must permit a retry")
182+}
183+
184+func TestFetchRemotePreviewServesStaleWhileRefreshing(t *testing.T) {
185+ clock := &remotePreviewTestClock{now: time.Unix(300, 0)}
186+ target := remotePreviewCacheTarget()
187+ client := &remotePreviewTestClient{result: remotePreviewCacheResult(target, 1)}
188+ d := newTestDaemon(t, nil, clock)
189+ d.remotePreviewClient = client
190+
191+ first, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
192+ require.NoError(t, err)
193+ require.Equal(t, uint64(1), first.Revision)
194+
195+ clock.Advance(remotePreviewCacheTTL + time.Second)
196+ client.mu.Lock()
197+ client.result = remotePreviewCacheResult(target, 2)
198+ client.started = make(chan struct{})
199+ client.release = make(chan struct{})
200+ client.startedSignal = false
201+ client.finishedSignal = false
202+ client.mu.Unlock()
203+
204+ stale, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
205+ require.NoError(t, err)
206+ require.Equal(t, uint64(1), stale.Revision, "expired content remains available during revalidation")
207+ <-client.started
208+ key := remotePreviewCacheKey{Endpoint: target.Endpoint, DisplayOrigin: target.DisplayOrigin, LifecycleID: target.LifecycleID, SessionName: target.SessionName, LiveTabID: target.LiveTabID, Width: 1, Height: 1}
209+ d.remotePreview.mu.Lock()
210+ flight := d.remotePreview.flights[key]
211+ d.remotePreview.mu.Unlock()
212+ require.NotNil(t, flight)
213+ close(client.release)
214+ <-flight.done
215+
216+ fresh, err := d.fetchRemotePreview(context.Background(), target, 1, 1)
217+ require.NoError(t, err)
218+ require.Equal(t, uint64(2), fresh.Revision)
219+ require.Equal(t, 2, client.Calls())
220+}
221+
222+type immediateRemotePreviewTimer struct{ ch <-chan time.Time }
223+
224+func (t immediateRemotePreviewTimer) C() <-chan time.Time { return t.ch }
225+func (t immediateRemotePreviewTimer) Reset(time.Duration) bool { return false }
226+func (t immediateRemotePreviewTimer) Stop() bool { return true }
227+
228+type immediateRemotePreviewClock struct{}
229+
230+func (immediateRemotePreviewClock) Now() time.Time { return time.Unix(500, 0) }
231+func (immediateRemotePreviewClock) NewTimer(time.Duration) ports.Timer {
232+ ch := make(chan time.Time, 1)
233+ ch <- time.Unix(500, 0)
234+ return immediateRemotePreviewTimer{ch: ch}
235+}
236+
237+func TestRemotePickerPreviewLateResponseCannotPublishAfterCloseAndReopen(t *testing.T) {
238+ d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
239+ firstTarget := remotePreviewCacheTarget()
240+ firstStarted := make(chan struct{})
241+ firstRelease := make(chan struct{})
242+ client := &remotePreviewTestClient{
243+ result: remotePreviewCacheResult(firstTarget, 1),
244+ started: firstStarted,
245+ release: firstRelease,
246+ ignoreCancel: true,
247+ }
248+ d.remotePreviewClient = client
249+ _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
250+
251+ firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
252+ firstView := picker.SessionView{
253+ ID: firstKey.ID(), Name: firstTarget.SessionName,
254+ Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "main"}},
255+ RemoteKey: &firstKey, RemoteTarget: &firstTarget,
256+ RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
257+ }
258+ firstModel := picker.New([]picker.SessionView{firstView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
259+ firstSelected, ok := firstModel.Selected()
260+ require.True(t, ok)
261+ ac.overlays.pickerMu.Lock()
262+ ac.overlays.picker = firstModel
263+ ac.overlays.pickerIntent = pickerNavigate
264+ ac.overlays.pickerPreviewGeneration = 1
265+ ac.overlays.pickerMu.Unlock()
266+ d.startRemotePickerPreview(ac, firstSelected, 1)
267+ <-firstStarted
268+
269+ d.closePicker(ac)
270+
271+ secondTarget := firstTarget
272+ secondTarget.LifecycleID[0]++
273+ secondTarget.LiveTabID = "tab-2"
274+ client.mu.Lock()
275+ client.result = remotePreviewCacheResult(secondTarget, 2)
276+ client.started = make(chan struct{})
277+ client.release = make(chan struct{})
278+ client.startedSignal = false
279+ client.finishedSignal = false
280+ client.mu.Unlock()
281+ secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
282+ secondView := picker.SessionView{
283+ ID: secondKey.ID(), Name: secondTarget.SessionName,
284+ Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "replacement"}},
285+ RemoteKey: &secondKey, RemoteTarget: &secondTarget,
286+ RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
287+ }
288+ secondModel := picker.New([]picker.SessionView{secondView}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
289+ secondSelected, ok := secondModel.Selected()
290+ require.True(t, ok)
291+ ac.overlays.pickerMu.Lock()
292+ ac.overlays.picker = secondModel
293+ ac.overlays.pickerIntent = pickerNavigate
294+ ac.overlays.pickerPreviewGeneration++
295+ secondGeneration := ac.overlays.pickerPreviewGeneration
296+ ac.overlays.pickerMu.Unlock()
297+ d.startRemotePickerPreview(ac, secondSelected, secondGeneration)
298+ <-client.started
299+
300+ close(firstRelease)
301+ close(client.release)
302+ width, height := remotePickerPreviewSize(ac.sizeSnapshot())
303+ firstKeyForCache := remotePreviewCacheKey{Endpoint: firstTarget.Endpoint, DisplayOrigin: firstTarget.DisplayOrigin, LifecycleID: firstTarget.LifecycleID, SessionName: firstTarget.SessionName, LiveTabID: firstTarget.LiveTabID, Width: width, Height: height}
304+ secondKeyForCache := remotePreviewCacheKey{Endpoint: secondTarget.Endpoint, DisplayOrigin: secondTarget.DisplayOrigin, LifecycleID: secondTarget.LifecycleID, SessionName: secondTarget.SessionName, LiveTabID: secondTarget.LiveTabID, Width: width, Height: height}
305+ for _, key := range []remotePreviewCacheKey{firstKeyForCache, secondKeyForCache} {
306+ d.remotePreview.mu.Lock()
307+ flight := d.remotePreview.flights[key]
308+ d.remotePreview.mu.Unlock()
309+ if flight != nil {
310+ <-flight.done
311+ }
312+ }
313+ var preview picker.Preview
314+ require.Eventually(t, func() bool {
315+ ac.overlays.pickerMu.Lock()
316+ preview = ac.overlays.pickerRemotePreview
317+ currentGeneration := ac.overlays.pickerPreviewGeneration
318+ ac.overlays.pickerMu.Unlock()
319+ return currentGeneration == secondGeneration && len(preview.Rows) != 0 && len(preview.Rows[0]) != 0 && preview.Rows[0][0].Rune == 'x'
320+ }, time.Second, time.Millisecond)
321+ require.Equal(t, rune('x'), preview.Rows[0][0].Rune)
322+ require.Equal(t, 2, client.Calls())
323+}
324+
325+func TestRemotePickerPreviewSelectionChangeFencesOldTarget(t *testing.T) {
326+ d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
327+ firstTarget := remotePreviewCacheTarget()
328+ secondTarget := firstTarget
329+ secondTarget.LifecycleID[0]++
330+ secondTarget.LiveTabID = "tab-2"
331+ client := &remotePreviewTestClient{result: remotePreviewCacheResult(firstTarget, 1), started: make(chan struct{})}
332+ d.remotePreviewClient = client
333+ _, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
334+ firstKey := domain.RemoteSessionKey{Host: firstTarget.Endpoint, Name: firstTarget.SessionName, LifecycleID: firstTarget.LifecycleID, DisplayOrigin: firstTarget.DisplayOrigin}
335+ secondKey := domain.RemoteSessionKey{Host: secondTarget.Endpoint, Name: secondTarget.SessionName, LifecycleID: secondTarget.LifecycleID, DisplayOrigin: secondTarget.DisplayOrigin}
336+ views := []picker.SessionView{
337+ {ID: firstKey.ID(), Name: firstTarget.SessionName, Tabs: []picker.TabEntry{{TabID: firstTarget.LiveTabID, Name: "first"}}, RemoteKey: &firstKey, RemoteTarget: &firstTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true},
338+ {ID: secondKey.ID(), Name: secondTarget.SessionName, Tabs: []picker.TabEntry{{TabID: secondTarget.LiveTabID, Name: "second"}}, RemoteKey: &secondKey, RemoteTarget: &secondTarget, RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true},
339+ }
340+ model := picker.New(views, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
341+ ac.overlays.pickerMu.Lock()
342+ ac.overlays.picker = model
343+ ac.overlays.pickerIntent = pickerNavigate
344+ ac.overlays.pickerPreviewGeneration = 1
345+ ac.overlays.pickerMu.Unlock()
346+ d.registerPreviewForSelection(ac)
347+ <-client.started
348+ require.Equal(t, firstTarget, client.LastTarget())
349+
350+ client.mu.Lock()
351+ client.result = remotePreviewCacheResult(secondTarget, 2)
352+ client.started = make(chan struct{})
353+ client.startedSignal = false
354+ client.mu.Unlock()
355+ ac.overlays.pickerMu.Lock()
356+ model.Down()
357+ ac.overlays.pickerMu.Unlock()
358+ d.registerPreviewForSelection(ac)
359+ <-client.started
360+ require.Equal(t, secondTarget, client.LastTarget())
361+ require.Equal(t, 2, client.Calls())
362+}
363+
364+func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) {
365+ d := newTestDaemon(t, nil, immediateRemotePreviewClock{})
366+ target := remotePreviewCacheTarget()
367+ firstStarted := make(chan struct{})
368+ client := &remotePreviewTestClient{
369+ result: remotePreviewCacheResult(target, 1),
370+ started: firstStarted,
371+ release: make(chan struct{}),
372+ }
373+ d.remotePreviewClient = client
374+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
375+ key := domain.RemoteSessionKey{Host: target.Endpoint, Name: target.SessionName, LifecycleID: target.LifecycleID, DisplayOrigin: target.DisplayOrigin}
376+ view := picker.SessionView{
377+ ID: key.ID(), Name: target.SessionName,
378+ Tabs: []picker.TabEntry{{TabID: target.LiveTabID, Name: "main"}},
379+ RemoteKey: &key, RemoteTarget: &target,
380+ RemoteAvailability: picker.RemoteFresh, RemoteAttachReady: true,
381+ }
382+ model := picker.New([]picker.SessionView{view}, picker.SelectionConfig{Mode: picker.SelectNavigationTab})
383+ selected, ok := model.Selected()
384+ require.True(t, ok)
385+ ac.overlays.pickerMu.Lock()
386+ ac.overlays.picker = model
387+ ac.overlays.pickerIntent = pickerNavigate
388+ ac.overlays.pickerPreviewGeneration = 1
389+ ac.overlays.pickerMu.Unlock()
390+ d.startRemotePickerPreview(ac, selected, 1)
391+ <-firstStarted
392+
393+ secondStarted := make(chan struct{})
394+ client.mu.Lock()
395+ client.started = secondStarted
396+ client.release = nil
397+ client.startedSignal = false
398+ client.mu.Unlock()
399+ token := sess.attachmentToken(ac, ac.transport())
400+ effect, admitted := ac.beginAttachmentEffect(token)
401+ require.True(t, admitted)
402+ token.effect = effect
403+ defer effect.End()
404+ require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30}))
405+ <-secondStarted
406+ wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30})
407+ gotWidth, gotHeight := client.LastSize()
408+ require.Equal(t, wantWidth, gotWidth)
409+ require.Equal(t, wantHeight, gotHeight)
410+
411+ require.Eventually(t, func() bool {
412+ ac.overlays.pickerMu.Lock()
413+ defer ac.overlays.pickerMu.Unlock()
414+ return ac.overlays.pickerRemotePreview.Width == 1 && ac.overlays.pickerRemotePreview.Height == 1
415+ }, time.Second, time.Millisecond)
416+ require.Equal(t, 2, client.Calls())
417+}
418+
419+func TestFetchRemotePreviewRejectsInvalidDimensionsBeforeRemoteIO(t *testing.T) {
420+ clock := &remotePreviewTestClock{now: time.Unix(400, 0)}
421+ client := &remotePreviewTestClient{result: remotePreviewCacheResult(remotePreviewCacheTarget(), 1)}
422+ d := newTestDaemon(t, nil, clock)
423+ d.remotePreviewClient = client
424+
425+ _, err := d.fetchRemotePreview(context.Background(), remotePreviewCacheTarget(), ports.RemotePreviewMaxWidth+1, 1)
426+ require.ErrorIs(t, err, ports.ErrInvalidRemotePreviewRequest)
427+ require.Zero(t, client.Calls())
428+}
A internal/usecase/daemon/remote_preview_capture_test.go +41 -0
@@ -0,0 +1,41 @@
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 TestCaptureRemotePreviewDoesNotSplitWideRuneAtCropBoundary(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(3, 1)
22+ style := renderer.DefaultStyle()
23+ style.Bold = true
24+ pane.screen.Frame.Set(1, 0, renderer.Cell{Rune: '界', Style: style})
25+ pane.screen.Frame.Set(2, 0, renderer.Cell{Continuation: true, Style: style})
26+ pane.mu.Unlock()
27+
28+ target := domain.RemoteSessionTarget{
29+ Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: sess.incarnation,
30+ SessionName: "work", LiveTabID: "tab-1",
31+ }
32+ preview, err := d.captureRemotePreview(ports.RemotePreviewRequest{
33+ Version: ports.RemotePreviewSchemaVersion, Target: target, Width: 2, Height: 1,
34+ })
35+ require.NoError(t, err)
36+ require.Equal(t, uint16(2), preview.Width)
37+ require.Equal(t, rune(' '), preview.Cells[1].Rune, "a crop cannot expose a dangling wide rune")
38+ require.False(t, preview.Cells[1].Continuation)
39+ require.True(t, preview.Cells[1].Style.Equal(style), "boundary replacement preserves the cell style")
40+ require.NoError(t, ports.ValidateRemotePreview(preview))
41+}
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 +88 -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,39 @@ 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+ tabRow.focusable = false
327+ tabRow.selectable = false
328+ } else {
329+ tabRow.focusable = true
330+ tabRow.selectable = tabRow.remoteTarget.Validate() == nil && config.Mode == SelectNavigationTab && remoteSelectable(session)
331+ }
274332 tabRow.dim = remoteDim(session)
275333 }
276334 if config.Mode != SelectNavigationTab && session.CannotAcceptMoves {
277- tabRow.selectable, tabRow.dim = false, true
335+ tabRow.selectable, tabRow.focusable, tabRow.dim = false, false, true
278336 }
279337 rows = append(rows, tabRow)
280338 }
@@ -286,7 +344,7 @@ func rowsForSession(session SessionView, config SelectionConfig) []row {
286344 }
287345
288346 func remoteSelectable(session SessionView) bool {
289- return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch
347+ return session.RemoteAttachReady && session.RemoteAvailability != RemoteVersionMismatch && session.RemoteReason == ""
290348 }
291349
292350 func remoteDim(session SessionView) bool {
@@ -429,13 +487,13 @@ func (m *Model) SelectNearestRow(idx int) {
429487 }
430488 idx = clamp(idx, 0, len(m.rows)-1)
431489 for i := idx; i < len(m.rows); i++ {
432- if m.rows[i].selectable {
490+ if m.rows[i].focusable {
433491 m.selected = i
434492 return
435493 }
436494 }
437495 for i := idx - 1; i >= 0; i-- {
438- if m.rows[i].selectable {
496+ if m.rows[i].focusable {
439497 m.selected = i
440498 return
441499 }
@@ -474,21 +532,21 @@ func (m *Model) move(delta int) {
474532 if m == nil || len(m.rows) == 0 {
475533 return
476534 }
477- if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].selectable {
478- m.selected = m.firstSelectable()
535+ if m.selected < 0 || m.selected >= len(m.rows) || !m.rows[m.selected].focusable {
536+ m.selected = m.firstFocusable()
479537 return
480538 }
481539 for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
482- if m.rows[i].selectable {
540+ if m.rows[i].focusable {
483541 m.selected = i
484542 return
485543 }
486544 }
487545 }
488546
489-func (m *Model) firstSelectable() int {
547+func (m *Model) firstFocusable() int {
490548 for i, r := range m.rows {
491- if r.selectable {
549+ if r.focusable {
492550 return i
493551 }
494552 }
@@ -496,9 +554,14 @@ func (m *Model) firstSelectable() int {
496554 }
497555
498556 func (r row) target() Target {
557+ var remoteTarget *domain.RemoteSessionTarget
558+ if r.hasRemoteTarget {
559+ copyTarget := r.remoteTarget
560+ remoteTarget = &copyTarget
561+ }
499562 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(),
563+ Session: r.session, Incarnation: r.incarnation, Name: r.targetName, RemoteKey: r.remoteKeyPointer(), RemoteTarget: remoteTarget,
564+ RemoteHost: r.remoteHost, UnavailableReason: r.unavailableReason, TabID: r.tabID, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAtPointer(),
502565 }
503566 }
504567
A internal/usecase/picker/remote_parity_test.go +49 -0
@@ -0,0 +1,49 @@
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+ first, ok := model.Selected()
36+ require.True(t, ok)
37+ require.Equal(t, firstKey.ID(), first.Session)
38+ require.NotNil(t, first.RemoteTarget)
39+ require.Equal(t, firstLifecycle, first.RemoteTarget.LifecycleID)
40+ require.Equal(t, domain.TabStableID("tab-a"), first.RemoteTarget.LiveTabID)
41+
42+ model.Down()
43+ second, ok := model.Selected()
44+ require.True(t, ok)
45+ require.Equal(t, secondKey.ID(), second.Session)
46+ require.NotNil(t, second.RemoteTarget)
47+ require.Equal(t, secondLifecycle, second.RemoteTarget.LifecycleID)
48+ require.Equal(t, domain.TabStableID("tab-b"), second.RemoteTarget.LiveTabID)
49+}
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 +23 -0
@@ -0,0 +1,23 @@
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+The run exercises real SSH-stdio and UDP attachment, typed live catalog and
16+preview requests, tab creation/removal fencing, exact lifecycle fencing after
17+replacement, daemon restart resume, slow-preview cancellation, version
18+mismatch, and unreachable-host handling. It checks environment values in
19+memory without printing terminal viewport data.
20+
21+Stopped-selector restoration and daemon-owned environment assertions are also
22+covered by the repository's daemon and route integration tests, where the
23+fixture can deterministically hold restoration at the stopped state.
A scripts/remote-picker-harness/main.go +549 -0
@@ -0,0 +1,549 @@
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/atomic"
13+ "time"
14+
15+ "github.com/bnema/vev/internal/adapters/dgram"
16+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
17+ "github.com/bnema/vev/internal/adapters/sshstdio"
18+ "github.com/bnema/vev/internal/domain"
19+ "github.com/bnema/vev/internal/ports"
20+)
21+
22+const (
23+ defaultTarget = "test@remote"
24+ sessionName = "harness"
25+)
26+
27+var requestID atomic.Uint64
28+
29+func main() {
30+ if err := run(); err != nil {
31+ fmt.Fprintf(os.Stderr, "remote picker harness: %v\n", err)
32+ os.Exit(1)
33+ }
34+ fmt.Println("remote picker harness: PASS")
35+}
36+
37+func run() error {
38+ target := os.Getenv("VEV_HARNESS_TARGET")
39+ if target == "" {
40+ target = defaultTarget
41+ }
42+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
43+ defer cancel()
44+
45+ catalog := remoteadapter.NewCatalogClient()
46+ preview := remoteadapter.NewPreviewClient()
47+
48+ stdio, err := openTransport(ctx, ports.RemoteTransportStdio, target)
49+ if err != nil {
50+ return fmt.Errorf("open stdio transport: %w", err)
51+ }
52+ if err := attachAndCheck(ctx, stdio, ports.Hello{
53+ Intent: ports.IntentNew,
54+ Name: sessionName,
55+ Env: localEnvironment(),
56+ Cwd: "/tmp/local-cwd",
57+ TermEnv: "xterm-256color",
58+ TrueColor: true,
59+ }); err != nil {
60+ _ = stdio.Close()
61+ return fmt.Errorf("stdio live attach: %w", err)
62+ }
63+ 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 {
64+ _ = stdio.Close()
65+ return fmt.Errorf("direct client environment: %w", err)
66+ }
67+
68+ live, err := waitForSession(ctx, catalog, target, sessionName, "running")
69+ if err != nil {
70+ _ = stdio.Close()
71+ return fmt.Errorf("live catalog: %w", err)
72+ }
73+ liveTarget, err := targetForSession(target, live, false)
74+ if err != nil {
75+ _ = stdio.Close()
76+ return fmt.Errorf("live target: %w", err)
77+ }
78+ if _, err := preview.Preview(ctx, liveTarget, 40, 8); err != nil {
79+ _ = stdio.Close()
80+ return fmt.Errorf("live preview: %w", err)
81+ }
82+
83+ if err := sendCommand(ctx, stdio, "new-tab"); err != nil {
84+ _ = stdio.Close()
85+ return fmt.Errorf("create second tab: %w", err)
86+ }
87+ liveWithTabs, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 2)
88+ if err != nil {
89+ _ = stdio.Close()
90+ return fmt.Errorf("multi-tab catalog: %w", err)
91+ }
92+ removedTargets := make([]domain.RemoteSessionTarget, 0, len(ports.CatalogTabs(liveWithTabs)))
93+ for _, tab := range ports.CatalogTabs(liveWithTabs) {
94+ candidate := liveWithTabs
95+ candidate.ActiveTabID = tab.ID
96+ removed, targetErr := targetForSession(target, candidate, false)
97+ if targetErr != nil {
98+ _ = stdio.Close()
99+ return fmt.Errorf("removed-tab target: %w", targetErr)
100+ }
101+ removedTargets = append(removedTargets, removed)
102+ }
103+ if err := sendCommand(ctx, stdio, "close-tab"); err != nil {
104+ _ = stdio.Close()
105+ return fmt.Errorf("remove tab: %w", err)
106+ }
107+ if _, err := waitForSessionWithTabCount(ctx, catalog, target, sessionName, "running", 1); err != nil {
108+ _ = stdio.Close()
109+ return fmt.Errorf("removed-tab catalog: %w", err)
110+ }
111+ removed := false
112+ for _, removedTarget := range removedTargets {
113+ if _, previewErr := preview.Preview(ctx, removedTarget, 40, 8); previewErr != nil {
114+ removed = true
115+ break
116+ }
117+ }
118+ if !removed {
119+ _ = stdio.Close()
120+ return errors.New("removed tab unexpectedly produced a preview")
121+ }
122+
123+ if err := detach(ctx, stdio); err != nil {
124+ _ = stdio.Close()
125+ return fmt.Errorf("stop session: %w", err)
126+ }
127+ _ = stdio.Close()
128+ if err := runRemoteCommand(ctx, target, "vev", "kill", "--daemon"); err != nil {
129+ return fmt.Errorf("stop remote daemon: %w", err)
130+ }
131+ if err := runRemoteCommand(ctx, target, "vev", "--daemon-launcher"); err != nil {
132+ return fmt.Errorf("restart remote daemon: %w", err)
133+ }
134+
135+ resumedCatalog, err := waitForSession(ctx, catalog, target, sessionName, "running")
136+ if err != nil {
137+ return fmt.Errorf("resumed catalog: %w", err)
138+ }
139+ resumedTarget, err := targetForSession(target, resumedCatalog, false)
140+ if err != nil {
141+ return fmt.Errorf("resumed target: %w", err)
142+ }
143+ resumed, err := openTransport(ctx, ports.RemoteTransportStdio, target)
144+ if err != nil {
145+ return fmt.Errorf("open restart stdio transport: %w", err)
146+ }
147+ if err := attachAndCheck(ctx, resumed, ports.Hello{
148+ Intent: ports.IntentResume,
149+ Name: sessionName,
150+ Env: localEnvironment(),
151+ Cwd: "/tmp/local-cwd",
152+ TermEnv: "xterm-256color",
153+ TrueColor: true,
154+ RemoteTarget: &resumedTarget,
155+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
156+ }); err != nil {
157+ _ = resumed.Close()
158+ return fmt.Errorf("exact restart resume: %w", err)
159+ }
160+ if _, err := preview.Preview(ctx, resumedTarget, 40, 8); err != nil {
161+ _ = resumed.Close()
162+ return fmt.Errorf("restart preview: %w", err)
163+ }
164+ if err := touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview"); err != nil {
165+ _ = resumed.Close()
166+ return fmt.Errorf("enable slow preview: %w", err)
167+ }
168+ slowCtx, slowCancel := context.WithTimeout(ctx, 150*time.Millisecond)
169+ _, slowErr := preview.Preview(slowCtx, resumedTarget, 40, 8)
170+ slowCancel()
171+ if slowErr == nil {
172+ _ = resumed.Close()
173+ return errors.New("slow preview did not honor cancellation")
174+ }
175+ _ = touchRemoteFlag(ctx, target, "/tmp/vev-slow-preview-off")
176+
177+ if err := detach(ctx, resumed); err != nil {
178+ _ = resumed.Close()
179+ return fmt.Errorf("stop resumed session: %w", err)
180+ }
181+ _ = resumed.Close()
182+
183+ udp, err := openTransport(ctx, ports.RemoteTransportUDP, target)
184+ if err != nil {
185+ return fmt.Errorf("open UDP transport: %w", err)
186+ }
187+ if err := attachAndCheck(ctx, udp, ports.Hello{
188+ Intent: ports.IntentNew,
189+ Name: "udp-harness",
190+ Env: localEnvironment(),
191+ Cwd: "/tmp/local-cwd",
192+ TermEnv: "xterm-256color",
193+ TrueColor: true,
194+ }); err != nil {
195+ _ = udp.Close()
196+ return fmt.Errorf("UDP attach: %w", err)
197+ }
198+ if err := detach(ctx, udp); err != nil {
199+ _ = udp.Close()
200+ return fmt.Errorf("UDP detach: %w", err)
201+ }
202+ _ = udp.Close()
203+
204+ if err := touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog"); err != nil {
205+ return fmt.Errorf("enable mismatched catalog: %w", err)
206+ }
207+ if _, err := catalog.List(ctx, target); err == nil {
208+ return errors.New("version-mismatched catalog unexpectedly decoded")
209+ }
210+ _ = touchRemoteFlag(ctx, target, "/tmp/vev-bad-catalog-off")
211+
212+ if _, err := catalog.List(ctx, "test@unreachable.invalid"); err == nil {
213+ return errors.New("unreachable catalog unexpectedly succeeded")
214+ }
215+
216+ if err := runRemoteCommand(ctx, target, "vev", "kill", sessionName); err != nil {
217+ return fmt.Errorf("destroy session for stale-target test: %w", err)
218+ }
219+ if err := waitForMissingSession(ctx, catalog, target, sessionName); err != nil {
220+ return fmt.Errorf("wait for destroyed session: %w", err)
221+ }
222+ stale, err := openTransport(ctx, ports.RemoteTransportStdio, target)
223+ if err != nil {
224+ return fmt.Errorf("open stale-target transport: %w", err)
225+ }
226+ if err := expectNoSuchTarget(ctx, stale, resumedTarget); err != nil {
227+ _ = stale.Close()
228+ return fmt.Errorf("stale target fence: %w", err)
229+ }
230+ _ = stale.Close()
231+
232+ return nil
233+}
234+
235+func localEnvironment() []string {
236+ return []string{
237+ "VEV_TEST_ENV=local",
238+ "XDG_RUNTIME_DIR=/run/local",
239+ "WAYLAND_DISPLAY=wayland-local",
240+ "TERM=xterm-256color",
241+ "COLORTERM=truecolor",
242+ "TERM_PROGRAM=local-harness",
243+ }
244+}
245+
246+func openTransport(ctx context.Context, mode ports.RemoteTransportMode, target string) (ports.Transport, error) {
247+ switch mode {
248+ case ports.RemoteTransportStdio:
249+ return sshstdio.DialContext(ctx, target, "", slog.New(slog.DiscardHandler))
250+ case ports.RemoteTransportUDP:
251+ dialer := dgram.NewRemoteDialerWithLogger(target, "", slog.New(slog.DiscardHandler))
252+ dialer.BootstrapTimeout = 8 * time.Second
253+ dialer.ProbeTimeout = 3 * time.Second
254+ return dialer.Dial(ctx)
255+ default:
256+ return nil, fmt.Errorf("unsupported transport %q", mode)
257+ }
258+}
259+
260+func attachAndCheck(ctx context.Context, tr ports.Transport, hello ports.Hello) error {
261+ hello.Version = ports.ProtocolVersion
262+ hello.Size = domain.Size{Cols: 80, Rows: 24}
263+ if hello.EnvironmentPolicy == 0 {
264+ hello.EnvironmentPolicy = ports.EnvironmentPolicyClientOwned
265+ }
266+ payload := ports.MarshalHello(hello)
267+ if err := tr.Send(ports.Frame{Type: ports.MsgHello, Payload: payload}); err != nil {
268+ return err
269+ }
270+ frame, err := receiveWithTimeout(ctx, tr)
271+ if err != nil {
272+ return err
273+ }
274+ if frame.Type == ports.MsgError {
275+ message, decodeErr := ports.UnmarshalErrorMsg(frame.Payload)
276+ if decodeErr != nil {
277+ return decodeErr
278+ }
279+ return fmt.Errorf("remote error code %d", message.Code)
280+ }
281+ if frame.Type != ports.MsgWelcome {
282+ return fmt.Errorf("welcome frame type %d", frame.Type)
283+ }
284+ if err := tr.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{TrueColor: hello.TrueColor, SchemeKnown: true})}); err != nil {
285+ return fmt.Errorf("send initial theme: %w", err)
286+ }
287+ return nil
288+}
289+
290+func sendInputAndAwait(ctx context.Context, tr ports.Transport, command, want string) error {
291+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte(command + "\n")})}); err != nil {
292+ return err
293+ }
294+ var received strings.Builder
295+ var outputFrames int
296+ var outputBytes int
297+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
298+ defer cancel()
299+ for {
300+ frame, err := receiveWithTimeout(deadline, tr)
301+ if err != nil {
302+ return fmt.Errorf("waiting for marker after %d output frames/%d bytes: %w", outputFrames, outputBytes, err)
303+ }
304+ if frame.Type != ports.MsgOutput {
305+ continue
306+ }
307+ output, err := ports.UnmarshalOutput(frame.Payload)
308+ if err != nil {
309+ return err
310+ }
311+ outputFrames++
312+ outputBytes += len(output.Data)
313+ if output.Epoch != 0 && output.New != 0 {
314+ ackPayload, err := ports.MarshalAck(ports.Ack{Epoch: output.Epoch, State: output.New})
315+ if err != nil {
316+ return fmt.Errorf("encode output ack: %w", err)
317+ }
318+ if err := tr.Send(ports.Frame{Type: ports.MsgAck, Payload: ackPayload}); err != nil {
319+ return fmt.Errorf("ack output: %w", err)
320+ }
321+ }
322+ received.Write(output.Data)
323+ if strings.Contains(received.String(), want) {
324+ return nil
325+ }
326+ if received.Len() > 64<<10 {
327+ text := received.String()
328+ received.Reset()
329+ received.WriteString(text[len(text)-16<<10:])
330+ }
331+ }
332+}
333+
334+func sendCommand(ctx context.Context, tr ports.Transport, slug string) error {
335+ id := requestID.Add(1)
336+ request := ports.CommandRequest{
337+ Version: ports.ProtocolVersion,
338+ RequestID: id,
339+ Attached: true,
340+ Slug: slug,
341+ }
342+ payload, err := ports.MarshalCommandRequest(request)
343+ if err != nil {
344+ return err
345+ }
346+ if err := tr.Send(ports.Frame{Type: ports.MsgCommand, Payload: payload}); err != nil {
347+ return err
348+ }
349+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
350+ defer cancel()
351+ for {
352+ frame, err := receiveWithTimeout(deadline, tr)
353+ if err != nil {
354+ return err
355+ }
356+ if frame.Type != ports.MsgCommandResult {
357+ continue
358+ }
359+ result, err := ports.UnmarshalCommandResult(frame.Payload)
360+ if err != nil {
361+ return err
362+ }
363+ if !result.OK {
364+ return fmt.Errorf("remote command failed (code %d): %s", result.Code, result.Text)
365+ }
366+ return nil
367+ }
368+}
369+
370+func detach(ctx context.Context, tr ports.Transport) error {
371+ if err := tr.Send(ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}); err != nil {
372+ return err
373+ }
374+ deadline, cancel := context.WithTimeout(ctx, 5*time.Second)
375+ defer cancel()
376+ for {
377+ frame, err := receiveWithTimeout(deadline, tr)
378+ if err != nil {
379+ return err
380+ }
381+ if frame.Type == ports.MsgDetached {
382+ return nil
383+ }
384+ }
385+}
386+
387+func expectNoSuchTarget(ctx context.Context, tr ports.Transport, target domain.RemoteSessionTarget) error {
388+ target.Endpoint = targetEndpoint(target.Endpoint)
389+ target.DisplayOrigin = "remote"
390+ target.SessionName = sessionName
391+ if err := target.Validate(); err != nil {
392+ return err
393+ }
394+ hello := ports.Hello{
395+ Version: ports.ProtocolVersion,
396+ Intent: ports.IntentResume,
397+ Name: sessionName,
398+ Size: domain.Size{Cols: 80, Rows: 24},
399+ TermEnv: "xterm-256color",
400+ TrueColor: true,
401+ Env: localEnvironment(),
402+ Cwd: "/tmp/local-cwd",
403+ RemoteTarget: &target,
404+ EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
405+ }
406+ payload := ports.MarshalHello(hello)
407+ if err := tr.Send(ports.Frame{Type: ports.MsgHello, Payload: payload}); err != nil {
408+ return err
409+ }
410+ frame, err := receiveWithTimeout(ctx, tr)
411+ if err != nil {
412+ return err
413+ }
414+ if frame.Type != ports.MsgError {
415+ return fmt.Errorf("stale target returned frame type %d", frame.Type)
416+ }
417+ message, err := ports.UnmarshalErrorMsg(frame.Payload)
418+ if err != nil {
419+ return err
420+ }
421+ if message.Code != ports.ErrNoSuchTarget {
422+ return fmt.Errorf("stale target returned error code %d", message.Code)
423+ }
424+ return nil
425+}
426+
427+func targetForSession(endpoint string, session ports.RemoteCatalogSession, stopped bool) (domain.RemoteSessionTarget, error) {
428+ tabs := ports.CatalogTabs(session)
429+ if len(tabs) == 0 {
430+ return domain.RemoteSessionTarget{}, errors.New("catalog session has no tabs")
431+ }
432+ tab := tabs[0]
433+ if !stopped && session.ActiveTabID != "" {
434+ for _, candidate := range tabs {
435+ if candidate.ID == session.ActiveTabID {
436+ tab = candidate
437+ break
438+ }
439+ }
440+ }
441+ target := domain.RemoteSessionTarget{
442+ Endpoint: endpoint,
443+ DisplayOrigin: "remote",
444+ LifecycleID: session.LifecycleID,
445+ SessionName: session.Name,
446+ Stopped: stopped,
447+ }
448+ if stopped {
449+ target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(tab.ID))
450+ } else {
451+ target.LiveTabID = domain.TabStableID(tab.ID)
452+ }
453+ if err := target.Validate(); err != nil {
454+ return domain.RemoteSessionTarget{}, err
455+ }
456+ return target, nil
457+}
458+
459+func targetEndpoint(endpoint string) string {
460+ if endpoint == "" {
461+ return defaultTarget
462+ }
463+ return endpoint
464+}
465+
466+func waitForSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string) (ports.RemoteCatalogSession, error) {
467+ return waitForSessionWithTabCount(ctx, catalog, endpoint, name, state, 0)
468+}
469+
470+func waitForSessionWithTabCount(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name, state string, tabs int) (ports.RemoteCatalogSession, error) {
471+ deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
472+ defer cancel()
473+ for {
474+ result, err := catalog.List(deadline, endpoint)
475+ if err == nil {
476+ for _, session := range result.Sessions {
477+ if session.Name == name && session.State == state && (tabs == 0 || len(ports.CatalogTabs(session)) == tabs) {
478+ return session, nil
479+ }
480+ }
481+ }
482+ select {
483+ case <-deadline.Done():
484+ if err != nil {
485+ return ports.RemoteCatalogSession{}, err
486+ }
487+ return ports.RemoteCatalogSession{}, fmt.Errorf("session %q did not reach state %q", name, state)
488+ case <-time.After(100 * time.Millisecond):
489+ }
490+ }
491+}
492+
493+func waitForMissingSession(ctx context.Context, catalog ports.RemoteCatalogClient, endpoint, name string) error {
494+ deadline, cancel := context.WithTimeout(ctx, 10*time.Second)
495+ defer cancel()
496+ for {
497+ result, err := catalog.List(deadline, endpoint)
498+ if err == nil {
499+ found := false
500+ for _, session := range result.Sessions {
501+ found = found || session.Name == name
502+ }
503+ if !found {
504+ return nil
505+ }
506+ }
507+ select {
508+ case <-deadline.Done():
509+ return errors.New("destroyed session remained in catalog")
510+ case <-time.After(100 * time.Millisecond):
511+ }
512+ }
513+}
514+
515+func touchRemoteFlag(ctx context.Context, target, path string) error {
516+ if strings.HasSuffix(path, "-off") {
517+ path = strings.TrimSuffix(path, "-off")
518+ return runRemoteCommand(ctx, target, "rm", "-f", path)
519+ }
520+ return runRemoteCommand(ctx, target, "touch", path)
521+}
522+
523+func runRemoteCommand(ctx context.Context, target string, command ...string) error {
524+ spec := sshstdio.BuildCommandForRemoteCommand(target, command...)
525+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
526+ cmd.Stdout = io.Discard
527+ cmd.Stderr = io.Discard
528+ return cmd.Run()
529+}
530+
531+func receiveWithTimeout(ctx context.Context, tr ports.Transport) (ports.Frame, error) {
532+ result := make(chan struct {
533+ frame ports.Frame
534+ err error
535+ }, 1)
536+ go func() {
537+ frame, err := tr.Recv()
538+ result <- struct {
539+ frame ports.Frame
540+ err error
541+ }{frame: frame, err: err}
542+ }()
543+ select {
544+ case <-ctx.Done():
545+ return ports.Frame{}, ctx.Err()
546+ case outcome := <-result:
547+ return outcome.frame, outcome.err
548+ }
549+}
A scripts/remote-picker-harness/run.sh +96 -0
@@ -0,0 +1,96 @@
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+ set +e
44+ docker rm -f "$local_container" "$remote_container" >/dev/null 2>&1
45+ docker network rm "$network" >/dev/null 2>&1
46+ docker image rm "$image" >/dev/null 2>&1
47+ rm -rf "$context_dir"
48+}
49+trap cleanup EXIT INT TERM
50+
51+command -v docker >/dev/null || { echo 'remote picker harness: docker is required' >&2; exit 127; }
52+command -v ssh-keygen >/dev/null || { echo 'remote picker harness: ssh-keygen is required' >&2; exit 127; }
53+
54+if ! docker info >/dev/null 2>&1; then
55+ echo 'remote picker harness: Docker is unavailable through the active DOCKER_HOST/context' >&2
56+ exit 1
57+fi
58+
59+printf 'remote picker harness: building %s\n' "$image"
60+go build -o "$context_dir/vev" "$repo_root"
61+go build -o "$context_dir/harness" "$repo_root/scripts/remote-picker-harness"
62+ssh-keygen -q -t ed25519 -N '' -f "$context_dir/id_ed25519"
63+cp "$repo_root/scripts/remote-picker-harness/Dockerfile" "$context_dir/Dockerfile"
64+cp "$repo_root/scripts/remote-picker-harness/vev-wrapper" "$context_dir/vev-wrapper"
65+chmod 0600 "$context_dir/id_ed25519"
66+
67+docker build --build-arg "BASE_IMAGE=$base_image" -t "$image" "$context_dir" >/dev/null
68+docker network create "$network" >/dev/null
69+
70+docker run -d --name "$remote_container" \
71+ --network "$network" --network-alias remote \
72+ -e VEV_UDP_PORT_RANGE=61000 \
73+ "$image" >/dev/null
74+
75+docker create --name "$local_container" \
76+ --network "$network" --network-alias local \
77+ "$image" sleep infinity >/dev/null
78+docker cp "$context_dir/id_ed25519" "$local_container:/home/test/.ssh/id_ed25519"
79+docker start "$local_container" >/dev/null
80+docker exec "$local_container" chown test:test /home/test/.ssh/id_ed25519
81+docker exec "$local_container" chmod 0600 /home/test/.ssh/id_ed25519
82+
83+printf 'remote picker harness: waiting for SSH service\n'
84+for _ in $(seq 1 60); do
85+ 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
86+ break
87+ fi
88+ sleep 0.2
89+done
90+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
91+ echo 'remote picker harness: SSH service did not become ready' >&2
92+ exit 1
93+fi
94+
95+printf 'remote picker harness: running acceptance checks\n'
96+docker exec --user test -e HOME=/home/test "$local_container" /usr/local/bin/remote-picker-harness
A scripts/remote-picker-harness/vev-wrapper +26 -0
@@ -0,0 +1,26 @@
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+ printf '%s\n' '{"protocol_version":23,"schema_version":1,"sessions":[]}'
16+ exit 0
17+ fi
18+ ;;
19+ *_remote-preview*)
20+ if [ -e /tmp/vev-slow-preview ]; then
21+ sleep 3
22+ fi
23+ ;;
24+esac
25+
26+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