detached 9fcc192

Merge pull request #153 from bnema/feat/remote-catalog

feat(remote): add versioned session catalog
commit
9fcc1926b16c0a89de8dc101de89a5015eccc0d8
author
bnema · · 2026-07-30 10:33 UTC
committer
GitHub · · 2026-07-30 10:33 UTC
merge of
e9dfaf0 + a13c2fe · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +1136 -4
A internal/adapters/remote/catalog.go +265 -0
@@ -0,0 +1,265 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "log/slog"
11+ "os/exec"
12+ "strings"
13+ "time"
14+ "unicode"
15+ "unicode/utf8"
16+
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+ maxCatalogBytes = 1 << 20
24+ maxCatalogDiagnosticBytes = 4 << 10
25+ catalogCommandTimeout = 30 * time.Second
26+ catalogCommandWaitDelay = 500 * time.Millisecond
27+)
28+
29+var (
30+ errCatalogDecode = errors.New("remote catalog: invalid JSON")
31+ errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON")
32+ errCatalogRequired = errors.New("remote catalog: missing required fields")
33+ errCatalogSession = errors.New("remote catalog: invalid session")
34+ errCatalogSSH = errors.New("remote catalog: ssh command failed")
35+ errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")
36+)
37+
38+// CatalogClient fetches remote session catalogs over SSH.
39+type CatalogClient struct {
40+ command func(ctx context.Context, name string, args ...string) *exec.Cmd
41+ // timeout bounds each List when > 0; otherwise catalogCommandTimeout is used.
42+ timeout time.Duration
43+}
44+
45+var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)
46+
47+// NewCatalogClient returns a RemoteCatalogClient that shells out to ssh.
48+func NewCatalogClient() ports.RemoteCatalogClient {
49+ return &CatalogClient{command: exec.CommandContext}
50+}
51+
52+func (c *CatalogClient) listTimeout() time.Duration {
53+ if c != nil && c.timeout > 0 {
54+ return c.timeout
55+ }
56+ return catalogCommandTimeout
57+}
58+
59+// List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes
60+// exactly one versioned catalog envelope from stdout. List always applies a
61+// bounded command timeout derived from ctx so a caller with no deadline cannot
62+// hang indefinitely, while still honoring caller cancellation.
63+func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
64+ if err := ctx.Err(); err != nil {
65+ return ports.RemoteCatalog{}, err
66+ }
67+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
68+ slog.Debug("remote catalog rejected target", "target", target, "err", err)
69+ return ports.RemoteCatalog{}, err
70+ }
71+
72+ runCtx, cancel := context.WithTimeout(ctx, c.listTimeout())
73+ defer cancel()
74+
75+ command := c.command
76+ if command == nil {
77+ command = exec.CommandContext
78+ }
79+ spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json")
80+ cmd := command(runCtx, spec.Path, spec.Args...)
81+ stdout := boundedBuffer{limit: maxCatalogBytes}
82+ stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes}
83+ cmd.Stdout = &stdout
84+ cmd.Stderr = &stderr
85+ cmd.WaitDelay = catalogCommandWaitDelay
86+ err := cmd.Run()
87+ if ctxErr := runCtx.Err(); ctxErr != nil {
88+ return ports.RemoteCatalog{}, ctxErr
89+ }
90+ if err != nil {
91+ if stdout.overflow || stderr.overflow {
92+ slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
93+ return ports.RemoteCatalog{}, errCatalogTooLarge
94+ }
95+ stderrText := sanitizeCatalogDiagnostic(string(stderr.Bytes()))
96+ slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText)
97+ if stderrText != "" {
98+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w: %s", errCatalogSSH, err, stderrText)
99+ }
100+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)
101+ }
102+ if stdout.overflow || stderr.overflow {
103+ slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
104+ return ports.RemoteCatalog{}, errCatalogTooLarge
105+ }
106+
107+ catalog, err := decodeRemoteCatalog(stdout.Bytes())
108+ if err != nil {
109+ slog.Debug("remote catalog decode failed", "target", target, "err", err)
110+ return ports.RemoteCatalog{}, err
111+ }
112+ return catalog, nil
113+}
114+
115+type catalogEnvelope struct {
116+ ProtocolVersion *uint16 `json:"protocol_version"`
117+ Sessions *[]ports.RemoteCatalogSession `json:"sessions"`
118+}
119+
120+func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
121+ dec := json.NewDecoder(bytes.NewReader(raw))
122+ var envelope catalogEnvelope
123+ if err := dec.Decode(&envelope); err != nil {
124+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogDecode, err)
125+ }
126+ if envelope.ProtocolVersion == nil || envelope.Sessions == nil {
127+ return ports.RemoteCatalog{}, errCatalogRequired
128+ }
129+ if err := rejectTrailingJSON(dec); err != nil {
130+ return ports.RemoteCatalog{}, err
131+ }
132+
133+ if *envelope.ProtocolVersion != ports.ProtocolVersion {
134+ return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
135+ Got: *envelope.ProtocolVersion,
136+ Want: ports.ProtocolVersion,
137+ }
138+ }
139+
140+ sessions := *envelope.Sessions
141+ if sessions == nil {
142+ sessions = []ports.RemoteCatalogSession{}
143+ }
144+ for _, session := range sessions {
145+ if err := domain.ValidateSessionName(session.Name); err != nil {
146+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err)
147+ }
148+ if !validCatalogState(session.State) {
149+ return ports.RemoteCatalog{}, fmt.Errorf("%w: invalid state %q", errCatalogSession, session.State)
150+ }
151+ }
152+
153+ return ports.RemoteCatalog{
154+ ProtocolVersion: *envelope.ProtocolVersion,
155+ Sessions: sessions,
156+ }, nil
157+}
158+
159+func rejectTrailingJSON(dec *json.Decoder) error {
160+ var extra json.RawMessage
161+ err := dec.Decode(&extra)
162+ if errors.Is(err, io.EOF) {
163+ return nil
164+ }
165+ if err == nil {
166+ return errCatalogTrailing
167+ }
168+ return fmt.Errorf("%w: %w", errCatalogTrailing, err)
169+}
170+
171+func validCatalogState(state string) bool {
172+ switch state {
173+ case "running", "stopped", "broken":
174+ return true
175+ default:
176+ return false
177+ }
178+}
179+
180+// sanitizeCatalogDiagnostic strips terminal escapes and control bytes while
181+// preserving useful printable diagnostic text for errors and logs.
182+func sanitizeCatalogDiagnostic(raw string) string {
183+ var b strings.Builder
184+ b.Grow(len(raw))
185+ for i := 0; i < len(raw); {
186+ if raw[i] == 0x1b {
187+ i = skipTerminalEscape(raw, i+1)
188+ continue
189+ }
190+ r, size := utf8.DecodeRuneInString(raw[i:])
191+ if r == utf8.RuneError && size == 1 {
192+ i++
193+ continue
194+ }
195+ if unicode.IsPrint(r) || r == '\t' || r == '\n' {
196+ b.WriteRune(r)
197+ }
198+ i += size
199+ }
200+ return strings.TrimSpace(b.String())
201+}
202+
203+func skipTerminalEscape(s string, i int) int {
204+ if i >= len(s) {
205+ return i
206+ }
207+ switch s[i] {
208+ case '[': // CSI: ESC [ ... final byte in 0x40-0x7E
209+ i++
210+ for i < len(s) {
211+ ch := s[i]
212+ i++
213+ if ch >= 0x40 && ch <= 0x7e {
214+ break
215+ }
216+ }
217+ return i
218+ case ']': // OSC: ESC ] ... BEL or ST (ESC \)
219+ i++
220+ for i < len(s) {
221+ if s[i] == 0x07 {
222+ return i + 1
223+ }
224+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' {
225+ return i + 2
226+ }
227+ i++
228+ }
229+ return i
230+ default:
231+ // Two-byte Fe escape or unknown: drop ESC and the next byte.
232+ return i + 1
233+ }
234+}
235+
236+// boundedBuffer captures stream output up to an explicit byte limit and records overflow.
237+type boundedBuffer struct {
238+ buf bytes.Buffer
239+ limit int
240+ overflow bool
241+}
242+
243+func (b *boundedBuffer) Write(p []byte) (int, error) {
244+ if b.overflow {
245+ return len(p), nil
246+ }
247+ remaining := b.limit - b.buf.Len()
248+ if remaining <= 0 {
249+ b.overflow = true
250+ return len(p), nil
251+ }
252+ if len(p) <= remaining {
253+ return b.buf.Write(p)
254+ }
255+ n, err := b.buf.Write(p[:remaining])
256+ b.overflow = true
257+ if err != nil {
258+ return n, err
259+ }
260+ return len(p), nil
261+}
262+
263+func (b *boundedBuffer) Bytes() []byte {
264+ return b.buf.Bytes()
265+}
@@ -0,0 +1,265 @@
1+package remote
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "log/slog"
11+ "os/exec"
12+ "strings"
13+ "time"
14+ "unicode"
15+ "unicode/utf8"
16+
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+ maxCatalogBytes = 1 << 20
24+ maxCatalogDiagnosticBytes = 4 << 10
25+ catalogCommandTimeout = 30 * time.Second
26+ catalogCommandWaitDelay = 500 * time.Millisecond
27+)
28+
29+var (
30+ errCatalogDecode = errors.New("remote catalog: invalid JSON")
31+ errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON")
32+ errCatalogRequired = errors.New("remote catalog: missing required fields")
33+ errCatalogSession = errors.New("remote catalog: invalid session")
34+ errCatalogSSH = errors.New("remote catalog: ssh command failed")
35+ errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")
36+)
37+
38+// CatalogClient fetches remote session catalogs over SSH.
39+type CatalogClient struct {
40+ command func(ctx context.Context, name string, args ...string) *exec.Cmd
41+ // timeout bounds each List when > 0; otherwise catalogCommandTimeout is used.
42+ timeout time.Duration
43+}
44+
45+var _ ports.RemoteCatalogClient = (*CatalogClient)(nil)
46+
47+// NewCatalogClient returns a RemoteCatalogClient that shells out to ssh.
48+func NewCatalogClient() ports.RemoteCatalogClient {
49+ return &CatalogClient{command: exec.CommandContext}
50+}
51+
52+func (c *CatalogClient) listTimeout() time.Duration {
53+ if c != nil && c.timeout > 0 {
54+ return c.timeout
55+ }
56+ return catalogCommandTimeout
57+}
58+
59+// List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes
60+// exactly one versioned catalog envelope from stdout. List always applies a
61+// bounded command timeout derived from ctx so a caller with no deadline cannot
62+// hang indefinitely, while still honoring caller cancellation.
63+func (c *CatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
64+ if err := ctx.Err(); err != nil {
65+ return ports.RemoteCatalog{}, err
66+ }
67+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
68+ slog.Debug("remote catalog rejected target", "target", target, "err", err)
69+ return ports.RemoteCatalog{}, err
70+ }
71+
72+ runCtx, cancel := context.WithTimeout(ctx, c.listTimeout())
73+ defer cancel()
74+
75+ command := c.command
76+ if command == nil {
77+ command = exec.CommandContext
78+ }
79+ spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json")
80+ cmd := command(runCtx, spec.Path, spec.Args...)
81+ stdout := boundedBuffer{limit: maxCatalogBytes}
82+ stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes}
83+ cmd.Stdout = &stdout
84+ cmd.Stderr = &stderr
85+ cmd.WaitDelay = catalogCommandWaitDelay
86+ err := cmd.Run()
87+ if ctxErr := runCtx.Err(); ctxErr != nil {
88+ return ports.RemoteCatalog{}, ctxErr
89+ }
90+ if err != nil {
91+ if stdout.overflow || stderr.overflow {
92+ slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
93+ return ports.RemoteCatalog{}, errCatalogTooLarge
94+ }
95+ stderrText := sanitizeCatalogDiagnostic(string(stderr.Bytes()))
96+ slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText)
97+ if stderrText != "" {
98+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w: %s", errCatalogSSH, err, stderrText)
99+ }
100+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)
101+ }
102+ if stdout.overflow || stderr.overflow {
103+ slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
104+ return ports.RemoteCatalog{}, errCatalogTooLarge
105+ }
106+
107+ catalog, err := decodeRemoteCatalog(stdout.Bytes())
108+ if err != nil {
109+ slog.Debug("remote catalog decode failed", "target", target, "err", err)
110+ return ports.RemoteCatalog{}, err
111+ }
112+ return catalog, nil
113+}
114+
115+type catalogEnvelope struct {
116+ ProtocolVersion *uint16 `json:"protocol_version"`
117+ Sessions *[]ports.RemoteCatalogSession `json:"sessions"`
118+}
119+
120+func decodeRemoteCatalog(raw []byte) (ports.RemoteCatalog, error) {
121+ dec := json.NewDecoder(bytes.NewReader(raw))
122+ var envelope catalogEnvelope
123+ if err := dec.Decode(&envelope); err != nil {
124+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogDecode, err)
125+ }
126+ if envelope.ProtocolVersion == nil || envelope.Sessions == nil {
127+ return ports.RemoteCatalog{}, errCatalogRequired
128+ }
129+ if err := rejectTrailingJSON(dec); err != nil {
130+ return ports.RemoteCatalog{}, err
131+ }
132+
133+ if *envelope.ProtocolVersion != ports.ProtocolVersion {
134+ return ports.RemoteCatalog{}, &ports.RemoteCatalogVersionMismatchError{
135+ Got: *envelope.ProtocolVersion,
136+ Want: ports.ProtocolVersion,
137+ }
138+ }
139+
140+ sessions := *envelope.Sessions
141+ if sessions == nil {
142+ sessions = []ports.RemoteCatalogSession{}
143+ }
144+ for _, session := range sessions {
145+ if err := domain.ValidateSessionName(session.Name); err != nil {
146+ return ports.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSession, err)
147+ }
148+ if !validCatalogState(session.State) {
149+ return ports.RemoteCatalog{}, fmt.Errorf("%w: invalid state %q", errCatalogSession, session.State)
150+ }
151+ }
152+
153+ return ports.RemoteCatalog{
154+ ProtocolVersion: *envelope.ProtocolVersion,
155+ Sessions: sessions,
156+ }, nil
157+}
158+
159+func rejectTrailingJSON(dec *json.Decoder) error {
160+ var extra json.RawMessage
161+ err := dec.Decode(&extra)
162+ if errors.Is(err, io.EOF) {
163+ return nil
164+ }
165+ if err == nil {
166+ return errCatalogTrailing
167+ }
168+ return fmt.Errorf("%w: %w", errCatalogTrailing, err)
169+}
170+
171+func validCatalogState(state string) bool {
172+ switch state {
173+ case "running", "stopped", "broken":
174+ return true
175+ default:
176+ return false
177+ }
178+}
179+
180+// sanitizeCatalogDiagnostic strips terminal escapes and control bytes while
181+// preserving useful printable diagnostic text for errors and logs.
182+func sanitizeCatalogDiagnostic(raw string) string {
183+ var b strings.Builder
184+ b.Grow(len(raw))
185+ for i := 0; i < len(raw); {
186+ if raw[i] == 0x1b {
187+ i = skipTerminalEscape(raw, i+1)
188+ continue
189+ }
190+ r, size := utf8.DecodeRuneInString(raw[i:])
191+ if r == utf8.RuneError && size == 1 {
192+ i++
193+ continue
194+ }
195+ if unicode.IsPrint(r) || r == '\t' || r == '\n' {
196+ b.WriteRune(r)
197+ }
198+ i += size
199+ }
200+ return strings.TrimSpace(b.String())
201+}
202+
203+func skipTerminalEscape(s string, i int) int {
204+ if i >= len(s) {
205+ return i
206+ }
207+ switch s[i] {
208+ case '[': // CSI: ESC [ ... final byte in 0x40-0x7E
209+ i++
210+ for i < len(s) {
211+ ch := s[i]
212+ i++
213+ if ch >= 0x40 && ch <= 0x7e {
214+ break
215+ }
216+ }
217+ return i
218+ case ']': // OSC: ESC ] ... BEL or ST (ESC \)
219+ i++
220+ for i < len(s) {
221+ if s[i] == 0x07 {
222+ return i + 1
223+ }
224+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' {
225+ return i + 2
226+ }
227+ i++
228+ }
229+ return i
230+ default:
231+ // Two-byte Fe escape or unknown: drop ESC and the next byte.
232+ return i + 1
233+ }
234+}
235+
236+// boundedBuffer captures stream output up to an explicit byte limit and records overflow.
237+type boundedBuffer struct {
238+ buf bytes.Buffer
239+ limit int
240+ overflow bool
241+}
242+
243+func (b *boundedBuffer) Write(p []byte) (int, error) {
244+ if b.overflow {
245+ return len(p), nil
246+ }
247+ remaining := b.limit - b.buf.Len()
248+ if remaining <= 0 {
249+ b.overflow = true
250+ return len(p), nil
251+ }
252+ if len(p) <= remaining {
253+ return b.buf.Write(p)
254+ }
255+ n, err := b.buf.Write(p[:remaining])
256+ b.overflow = true
257+ if err != nil {
258+ return n, err
259+ }
260+ return len(p), nil
261+}
262+
263+func (b *boundedBuffer) Bytes() []byte {
264+ return b.buf.Bytes()
265+}
A internal/adapters/remote/catalog_test.go +495 -0
@@ -0,0 +1,495 @@
1+package remote
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os/exec"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/adapters/sshstdio"
14+ "github.com/bnema/vev/internal/ports"
15+)
16+
17+func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
18+ t.Parallel()
19+
20+ tests := []struct {
21+ name string
22+ target string
23+ }{
24+ {name: "plain host", target: "arch"},
25+ {name: "metacharacters stay single local arg", target: "user@host;touch/tmp/pwn"},
26+ {name: "user at host", target: "build@mule"},
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ t.Parallel()
32+ var gotPath string
33+ var gotArgs []string
34+ client := &CatalogClient{
35+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
36+ gotPath = name
37+ gotArgs = append([]string(nil), args...)
38+ return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
39+ ProtocolVersion: ports.ProtocolVersion,
40+ Sessions: []ports.RemoteCatalogSession{},
41+ }))
42+ },
43+ }
44+
45+ if _, err := client.List(context.Background(), tt.target); err != nil {
46+ t.Fatalf("List() error = %v", err)
47+ }
48+
49+ want := sshstdio.BuildCommandForRemoteCommand(tt.target, "vev", "cmd", "remote-catalog", "--json")
50+ if gotPath != want.Path {
51+ t.Fatalf("Path = %q, want %q", gotPath, want.Path)
52+ }
53+ if len(gotArgs) != len(want.Args) {
54+ t.Fatalf("Args len = %d, want %d (%q)", len(gotArgs), len(want.Args), gotArgs)
55+ }
56+ for i := range want.Args {
57+ if gotArgs[i] != want.Args[i] {
58+ t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs)
59+ }
60+ }
61+ if gotArgs[0] != "--" || gotArgs[1] != tt.target {
62+ t.Fatalf("target must remain one unquoted local argv word after --; got %q", gotArgs)
63+ }
64+ if !strings.Contains(gotArgs[2], "'vev'") || !strings.Contains(gotArgs[2], "'remote-catalog'") {
65+ t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs[2])
66+ }
67+ })
68+ }
69+}
70+
71+func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) {
72+ t.Parallel()
73+ client := NewCatalogClient()
74+ _, err := client.List(context.Background(), "bad host")
75+ if err == nil {
76+ t.Fatal("List() error = nil, want target validation error")
77+ }
78+}
79+
80+func TestRemoteCatalogClientDecode(t *testing.T) {
81+ t.Parallel()
82+
83+ valid := ports.RemoteCatalog{
84+ ProtocolVersion: ports.ProtocolVersion,
85+ Sessions: []ports.RemoteCatalogSession{
86+ {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
87+ },
88+ }
89+ validJSON := mustCatalogJSON(t, valid)
90+
91+ tests := []struct {
92+ name string
93+ stdout string
94+ stderr string
95+ exit int
96+ want ports.RemoteCatalog
97+ wantErr error
98+ check func(t *testing.T, err error)
99+ }{
100+ {
101+ name: "matching protocol version",
102+ stdout: validJSON,
103+ want: valid,
104+ },
105+ {
106+ name: "trailing whitespace accepted",
107+ stdout: validJSON + " \n\t",
108+ want: valid,
109+ },
110+ {
111+ name: "mismatching protocol version",
112+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, Sessions: []ports.RemoteCatalogSession{}}),
113+ check: func(t *testing.T, err error) {
114+ t.Helper()
115+ var mismatch *ports.RemoteCatalogVersionMismatchError
116+ if !errors.As(err, &mismatch) {
117+ t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
118+ }
119+ if mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion {
120+ t.Fatalf("mismatch = %#v", mismatch)
121+ }
122+ },
123+ },
124+ {
125+ name: "malformed json",
126+ stdout: `{"protocol_version":`,
127+ wantErr: errCatalogDecode,
128+ },
129+ {
130+ name: "truncated json",
131+ stdout: `{"protocol_version":19,"sessions":[`,
132+ wantErr: errCatalogDecode,
133+ },
134+ {
135+ name: "trailing non-whitespace",
136+ stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
137+ wantErr: errCatalogTrailing,
138+ },
139+ {
140+ name: "missing protocol_version",
141+ stdout: `{"sessions":[]}` + "\n",
142+ wantErr: errCatalogRequired,
143+ },
144+ {
145+ name: "missing sessions",
146+ stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion),
147+ wantErr: errCatalogRequired,
148+ },
149+ {
150+ name: "invalid session name",
151+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "running"}}}),
152+ wantErr: errCatalogSession,
153+ },
154+ {
155+ name: "invalid session state",
156+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}),
157+ wantErr: errCatalogSession,
158+ },
159+ {
160+ name: "remote exit with stderr",
161+ stdout: "",
162+ stderr: "connection refused",
163+ exit: 255,
164+ wantErr: errCatalogSSH,
165+ },
166+ }
167+
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ t.Parallel()
171+ client := &CatalogClient{
172+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
173+ if tt.exit != 0 {
174+ cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat >&2; exit %d", tt.exit))
175+ cmd.Stdin = strings.NewReader(tt.stderr)
176+ return cmd
177+ }
178+ return stdoutCmd(ctx, tt.stdout)
179+ },
180+ }
181+ got, err := client.List(context.Background(), "arch")
182+ if tt.check != nil {
183+ if err == nil {
184+ t.Fatal("List() error = nil, want error")
185+ }
186+ tt.check(t, err)
187+ return
188+ }
189+ if tt.wantErr != nil {
190+ if !errors.Is(err, tt.wantErr) {
191+ t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
192+ }
193+ return
194+ }
195+ if err != nil {
196+ t.Fatalf("List() error = %v", err)
197+ }
198+ if got.ProtocolVersion != tt.want.ProtocolVersion {
199+ t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion)
200+ }
201+ if len(got.Sessions) != len(tt.want.Sessions) {
202+ t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
203+ }
204+ for i := range tt.want.Sessions {
205+ if got.Sessions[i] != tt.want.Sessions[i] {
206+ t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
207+ }
208+ }
209+ })
210+ }
211+}
212+
213+func TestRemoteCatalogClientContextCanceled(t *testing.T) {
214+ t.Parallel()
215+ ctx, cancel := context.WithCancel(context.Background())
216+ cancel()
217+ client := NewCatalogClient()
218+ _, err := client.List(ctx, "arch")
219+ if !errors.Is(err, context.Canceled) {
220+ t.Fatalf("List() error = %v, want context.Canceled", err)
221+ }
222+}
223+
224+func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
225+ t.Parallel()
226+
227+ var commandCtx context.Context
228+ var cmd *exec.Cmd
229+ client := &CatalogClient{
230+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
231+ commandCtx = ctx
232+ cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
233+ ProtocolVersion: ports.ProtocolVersion,
234+ Sessions: []ports.RemoteCatalogSession{},
235+ }))
236+ return cmd
237+ },
238+ }
239+ if _, err := client.List(context.Background(), "arch"); err != nil {
240+ t.Fatalf("List() error = %v", err)
241+ }
242+ deadline, ok := commandCtx.Deadline()
243+ if !ok {
244+ t.Fatal("command context missing default lifetime deadline")
245+ }
246+ if remaining := time.Until(deadline); remaining <= 0 || remaining > catalogCommandTimeout {
247+ t.Fatalf("deadline remaining = %v, want within (0, %v]", remaining, catalogCommandTimeout)
248+ }
249+ if cmd.WaitDelay != catalogCommandWaitDelay {
250+ t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, catalogCommandWaitDelay)
251+ }
252+}
253+
254+func TestRemoteCatalogClientClassifiesRunContextErrors(t *testing.T) {
255+ tests := []struct {
256+ name string
257+ newContext func() (context.Context, context.CancelFunc)
258+ timeout time.Duration
259+ cancel bool
260+ want error
261+ }{
262+ {
263+ name: "own deadline",
264+ newContext: func() (context.Context, context.CancelFunc) {
265+ return context.WithCancel(context.Background())
266+ },
267+ timeout: 20 * time.Millisecond,
268+ want: context.DeadlineExceeded,
269+ },
270+ {
271+ name: "parent deadline",
272+ newContext: func() (context.Context, context.CancelFunc) {
273+ return context.WithTimeout(context.Background(), 20*time.Millisecond)
274+ },
275+ timeout: time.Minute,
276+ want: context.DeadlineExceeded,
277+ },
278+ {
279+ name: "parent cancellation",
280+ newContext: func() (context.Context, context.CancelFunc) {
281+ return context.WithCancel(context.Background())
282+ },
283+ timeout: time.Minute,
284+ cancel: true,
285+ want: context.Canceled,
286+ },
287+ }
288+
289+ for _, tt := range tests {
290+ t.Run(tt.name, func(t *testing.T) {
291+ ctx, cancel := tt.newContext()
292+ defer cancel()
293+ started := make(chan struct{})
294+ client := &CatalogClient{
295+ timeout: tt.timeout,
296+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
297+ close(started)
298+ return exec.CommandContext(ctx, "sleep", "10")
299+ },
300+ }
301+
302+ errCh := make(chan error, 1)
303+ go func() {
304+ _, err := client.List(ctx, "arch")
305+ errCh <- err
306+ }()
307+ select {
308+ case <-started:
309+ case <-time.After(2 * time.Second):
310+ t.Fatal("command did not start")
311+ }
312+ if tt.cancel {
313+ cancel()
314+ }
315+ select {
316+ case err := <-errCh:
317+ if !errors.Is(err, tt.want) {
318+ t.Fatalf("List() error = %v, want %v", err, tt.want)
319+ }
320+ case <-time.After(2 * time.Second):
321+ t.Fatal("List() did not return after context ended")
322+ }
323+ })
324+ }
325+}
326+
327+func TestCatalogOutputSafety(t *testing.T) {
328+ t.Parallel()
329+
330+ tests := []struct {
331+ name string
332+ command func(ctx context.Context) *exec.Cmd
333+ wantErr error
334+ check func(t *testing.T, err error)
335+ }{
336+ {
337+ name: "oversized stdout",
338+ command: func(ctx context.Context) *exec.Cmd {
339+ return exec.CommandContext(ctx, "sh", "-c",
340+ fmt.Sprintf("head -c %d /dev/zero", maxCatalogBytes+1))
341+ },
342+ wantErr: errCatalogTooLarge,
343+ check: func(t *testing.T, err error) {
344+ t.Helper()
345+ if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) {
346+ t.Fatalf("error embeds oversized stdout: %v", err)
347+ }
348+ },
349+ },
350+ {
351+ name: "oversized stderr",
352+ command: func(ctx context.Context) *exec.Cmd {
353+ return exec.CommandContext(ctx, "sh", "-c",
354+ fmt.Sprintf("head -c %d /dev/zero >&2; exit 1", maxCatalogDiagnosticBytes+1))
355+ },
356+ wantErr: errCatalogTooLarge,
357+ check: func(t *testing.T, err error) {
358+ t.Helper()
359+ if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) {
360+ t.Fatalf("error embeds oversized stderr: %v", err)
361+ }
362+ if errors.Is(err, errCatalogSSH) {
363+ t.Fatalf("oversized stderr must not wrap SSH with captured output: %v", err)
364+ }
365+ },
366+ },
367+ {
368+ name: "ansi control stderr",
369+ command: func(ctx context.Context) *exec.Cmd {
370+ cmd := exec.CommandContext(ctx, "sh", "-c", "cat >&2; exit 255")
371+ cmd.Stdin = strings.NewReader("\x1b[31mconnection refused\x1b[0m\x07")
372+ return cmd
373+ },
374+ wantErr: errCatalogSSH,
375+ check: func(t *testing.T, err error) {
376+ t.Helper()
377+ msg := err.Error()
378+ if strings.ContainsRune(msg, '\x1b') || strings.ContainsRune(msg, '\x07') {
379+ t.Fatalf("error retains control/ANSI bytes: %q", msg)
380+ }
381+ if strings.Contains(msg, "[31m") || strings.Contains(msg, "[0m") {
382+ t.Fatalf("error retains terminal escape remnants: %q", msg)
383+ }
384+ if !strings.Contains(msg, "connection refused") {
385+ t.Fatalf("error = %q, want printable diagnostic text preserved", msg)
386+ }
387+ },
388+ },
389+ }
390+
391+ for _, tt := range tests {
392+ t.Run(tt.name, func(t *testing.T) {
393+ t.Parallel()
394+ client := &CatalogClient{
395+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
396+ return tt.command(ctx)
397+ },
398+ }
399+ _, err := client.List(context.Background(), "arch")
400+ if !errors.Is(err, tt.wantErr) {
401+ t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
402+ }
403+ if tt.check != nil {
404+ tt.check(t, err)
405+ }
406+ })
407+ }
408+}
409+
410+func TestBoundedBufferLimits(t *testing.T) {
411+ t.Parallel()
412+
413+ tests := []struct {
414+ name string
415+ limit int
416+ chunks [][]byte
417+ wantLen int
418+ wantOver bool
419+ wantBytes string
420+ }{
421+ {
422+ name: "under limit",
423+ limit: 8,
424+ chunks: [][]byte{[]byte("abcd"), []byte("ef")},
425+ wantLen: 6,
426+ wantOver: false,
427+ wantBytes: "abcdef",
428+ },
429+ {
430+ name: "exact limit",
431+ limit: 4,
432+ chunks: [][]byte{[]byte("abcd")},
433+ wantLen: 4,
434+ wantOver: false,
435+ wantBytes: "abcd",
436+ },
437+ {
438+ name: "overflow discards remainder",
439+ limit: 4,
440+ chunks: [][]byte{[]byte("abc"), []byte("defgh")},
441+ wantLen: 4,
442+ wantOver: true,
443+ wantBytes: "abcd",
444+ },
445+ {
446+ name: "further writes stay discarded",
447+ limit: 2,
448+ chunks: [][]byte{[]byte("abcdef"), []byte("xyz")},
449+ wantLen: 2,
450+ wantOver: true,
451+ wantBytes: "ab",
452+ },
453+ }
454+
455+ for _, tt := range tests {
456+ t.Run(tt.name, func(t *testing.T) {
457+ t.Parallel()
458+ var buf boundedBuffer
459+ buf.limit = tt.limit
460+ for _, chunk := range tt.chunks {
461+ n, err := buf.Write(chunk)
462+ if err != nil {
463+ t.Fatalf("Write(%q) error = %v", chunk, err)
464+ }
465+ if n != len(chunk) {
466+ t.Fatalf("Write(%q) n = %d, want %d", chunk, n, len(chunk))
467+ }
468+ }
469+ if buf.overflow != tt.wantOver {
470+ t.Fatalf("overflow = %v, want %v", buf.overflow, tt.wantOver)
471+ }
472+ if got := len(buf.Bytes()); got != tt.wantLen {
473+ t.Fatalf("len = %d, want %d", got, tt.wantLen)
474+ }
475+ if got := string(buf.Bytes()); got != tt.wantBytes {
476+ t.Fatalf("bytes = %q, want %q", got, tt.wantBytes)
477+ }
478+ })
479+ }
480+}
481+
482+func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd {
483+ cmd := exec.CommandContext(ctx, "cat")
484+ cmd.Stdin = strings.NewReader(stdout)
485+ return cmd
486+}
487+
488+func mustCatalogJSON(t *testing.T, catalog ports.RemoteCatalog) string {
489+ t.Helper()
490+ raw, err := json.Marshal(catalog)
491+ if err != nil {
492+ t.Fatalf("Marshal: %v", err)
493+ }
494+ return string(raw) + "\n"
495+}
@@ -0,0 +1,495 @@
1+package remote
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os/exec"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/adapters/sshstdio"
14+ "github.com/bnema/vev/internal/ports"
15+)
16+
17+func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
18+ t.Parallel()
19+
20+ tests := []struct {
21+ name string
22+ target string
23+ }{
24+ {name: "plain host", target: "arch"},
25+ {name: "metacharacters stay single local arg", target: "user@host;touch/tmp/pwn"},
26+ {name: "user at host", target: "build@mule"},
27+ }
28+
29+ for _, tt := range tests {
30+ t.Run(tt.name, func(t *testing.T) {
31+ t.Parallel()
32+ var gotPath string
33+ var gotArgs []string
34+ client := &CatalogClient{
35+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
36+ gotPath = name
37+ gotArgs = append([]string(nil), args...)
38+ return stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
39+ ProtocolVersion: ports.ProtocolVersion,
40+ Sessions: []ports.RemoteCatalogSession{},
41+ }))
42+ },
43+ }
44+
45+ if _, err := client.List(context.Background(), tt.target); err != nil {
46+ t.Fatalf("List() error = %v", err)
47+ }
48+
49+ want := sshstdio.BuildCommandForRemoteCommand(tt.target, "vev", "cmd", "remote-catalog", "--json")
50+ if gotPath != want.Path {
51+ t.Fatalf("Path = %q, want %q", gotPath, want.Path)
52+ }
53+ if len(gotArgs) != len(want.Args) {
54+ t.Fatalf("Args len = %d, want %d (%q)", len(gotArgs), len(want.Args), gotArgs)
55+ }
56+ for i := range want.Args {
57+ if gotArgs[i] != want.Args[i] {
58+ t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs)
59+ }
60+ }
61+ if gotArgs[0] != "--" || gotArgs[1] != tt.target {
62+ t.Fatalf("target must remain one unquoted local argv word after --; got %q", gotArgs)
63+ }
64+ if !strings.Contains(gotArgs[2], "'vev'") || !strings.Contains(gotArgs[2], "'remote-catalog'") {
65+ t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs[2])
66+ }
67+ })
68+ }
69+}
70+
71+func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) {
72+ t.Parallel()
73+ client := NewCatalogClient()
74+ _, err := client.List(context.Background(), "bad host")
75+ if err == nil {
76+ t.Fatal("List() error = nil, want target validation error")
77+ }
78+}
79+
80+func TestRemoteCatalogClientDecode(t *testing.T) {
81+ t.Parallel()
82+
83+ valid := ports.RemoteCatalog{
84+ ProtocolVersion: ports.ProtocolVersion,
85+ Sessions: []ports.RemoteCatalogSession{
86+ {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
87+ },
88+ }
89+ validJSON := mustCatalogJSON(t, valid)
90+
91+ tests := []struct {
92+ name string
93+ stdout string
94+ stderr string
95+ exit int
96+ want ports.RemoteCatalog
97+ wantErr error
98+ check func(t *testing.T, err error)
99+ }{
100+ {
101+ name: "matching protocol version",
102+ stdout: validJSON,
103+ want: valid,
104+ },
105+ {
106+ name: "trailing whitespace accepted",
107+ stdout: validJSON + " \n\t",
108+ want: valid,
109+ },
110+ {
111+ name: "mismatching protocol version",
112+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion + 1, Sessions: []ports.RemoteCatalogSession{}}),
113+ check: func(t *testing.T, err error) {
114+ t.Helper()
115+ var mismatch *ports.RemoteCatalogVersionMismatchError
116+ if !errors.As(err, &mismatch) {
117+ t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
118+ }
119+ if mismatch.Got != ports.ProtocolVersion+1 || mismatch.Want != ports.ProtocolVersion {
120+ t.Fatalf("mismatch = %#v", mismatch)
121+ }
122+ },
123+ },
124+ {
125+ name: "malformed json",
126+ stdout: `{"protocol_version":`,
127+ wantErr: errCatalogDecode,
128+ },
129+ {
130+ name: "truncated json",
131+ stdout: `{"protocol_version":19,"sessions":[`,
132+ wantErr: errCatalogDecode,
133+ },
134+ {
135+ name: "trailing non-whitespace",
136+ stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
137+ wantErr: errCatalogTrailing,
138+ },
139+ {
140+ name: "missing protocol_version",
141+ stdout: `{"sessions":[]}` + "\n",
142+ wantErr: errCatalogRequired,
143+ },
144+ {
145+ name: "missing sessions",
146+ stdout: fmt.Sprintf(`{"protocol_version":%d}`+"\n", ports.ProtocolVersion),
147+ wantErr: errCatalogRequired,
148+ },
149+ {
150+ name: "invalid session name",
151+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "bad name", State: "running"}}}),
152+ wantErr: errCatalogSession,
153+ },
154+ {
155+ name: "invalid session state",
156+ stdout: mustCatalogJSON(t, ports.RemoteCatalog{ProtocolVersion: ports.ProtocolVersion, Sessions: []ports.RemoteCatalogSession{{Name: "work", State: "weird"}}}),
157+ wantErr: errCatalogSession,
158+ },
159+ {
160+ name: "remote exit with stderr",
161+ stdout: "",
162+ stderr: "connection refused",
163+ exit: 255,
164+ wantErr: errCatalogSSH,
165+ },
166+ }
167+
168+ for _, tt := range tests {
169+ t.Run(tt.name, func(t *testing.T) {
170+ t.Parallel()
171+ client := &CatalogClient{
172+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
173+ if tt.exit != 0 {
174+ cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat >&2; exit %d", tt.exit))
175+ cmd.Stdin = strings.NewReader(tt.stderr)
176+ return cmd
177+ }
178+ return stdoutCmd(ctx, tt.stdout)
179+ },
180+ }
181+ got, err := client.List(context.Background(), "arch")
182+ if tt.check != nil {
183+ if err == nil {
184+ t.Fatal("List() error = nil, want error")
185+ }
186+ tt.check(t, err)
187+ return
188+ }
189+ if tt.wantErr != nil {
190+ if !errors.Is(err, tt.wantErr) {
191+ t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
192+ }
193+ return
194+ }
195+ if err != nil {
196+ t.Fatalf("List() error = %v", err)
197+ }
198+ if got.ProtocolVersion != tt.want.ProtocolVersion {
199+ t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion)
200+ }
201+ if len(got.Sessions) != len(tt.want.Sessions) {
202+ t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
203+ }
204+ for i := range tt.want.Sessions {
205+ if got.Sessions[i] != tt.want.Sessions[i] {
206+ t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
207+ }
208+ }
209+ })
210+ }
211+}
212+
213+func TestRemoteCatalogClientContextCanceled(t *testing.T) {
214+ t.Parallel()
215+ ctx, cancel := context.WithCancel(context.Background())
216+ cancel()
217+ client := NewCatalogClient()
218+ _, err := client.List(ctx, "arch")
219+ if !errors.Is(err, context.Canceled) {
220+ t.Fatalf("List() error = %v, want context.Canceled", err)
221+ }
222+}
223+
224+func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
225+ t.Parallel()
226+
227+ var commandCtx context.Context
228+ var cmd *exec.Cmd
229+ client := &CatalogClient{
230+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
231+ commandCtx = ctx
232+ cmd = stdoutCmd(ctx, mustCatalogJSON(t, ports.RemoteCatalog{
233+ ProtocolVersion: ports.ProtocolVersion,
234+ Sessions: []ports.RemoteCatalogSession{},
235+ }))
236+ return cmd
237+ },
238+ }
239+ if _, err := client.List(context.Background(), "arch"); err != nil {
240+ t.Fatalf("List() error = %v", err)
241+ }
242+ deadline, ok := commandCtx.Deadline()
243+ if !ok {
244+ t.Fatal("command context missing default lifetime deadline")
245+ }
246+ if remaining := time.Until(deadline); remaining <= 0 || remaining > catalogCommandTimeout {
247+ t.Fatalf("deadline remaining = %v, want within (0, %v]", remaining, catalogCommandTimeout)
248+ }
249+ if cmd.WaitDelay != catalogCommandWaitDelay {
250+ t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, catalogCommandWaitDelay)
251+ }
252+}
253+
254+func TestRemoteCatalogClientClassifiesRunContextErrors(t *testing.T) {
255+ tests := []struct {
256+ name string
257+ newContext func() (context.Context, context.CancelFunc)
258+ timeout time.Duration
259+ cancel bool
260+ want error
261+ }{
262+ {
263+ name: "own deadline",
264+ newContext: func() (context.Context, context.CancelFunc) {
265+ return context.WithCancel(context.Background())
266+ },
267+ timeout: 20 * time.Millisecond,
268+ want: context.DeadlineExceeded,
269+ },
270+ {
271+ name: "parent deadline",
272+ newContext: func() (context.Context, context.CancelFunc) {
273+ return context.WithTimeout(context.Background(), 20*time.Millisecond)
274+ },
275+ timeout: time.Minute,
276+ want: context.DeadlineExceeded,
277+ },
278+ {
279+ name: "parent cancellation",
280+ newContext: func() (context.Context, context.CancelFunc) {
281+ return context.WithCancel(context.Background())
282+ },
283+ timeout: time.Minute,
284+ cancel: true,
285+ want: context.Canceled,
286+ },
287+ }
288+
289+ for _, tt := range tests {
290+ t.Run(tt.name, func(t *testing.T) {
291+ ctx, cancel := tt.newContext()
292+ defer cancel()
293+ started := make(chan struct{})
294+ client := &CatalogClient{
295+ timeout: tt.timeout,
296+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
297+ close(started)
298+ return exec.CommandContext(ctx, "sleep", "10")
299+ },
300+ }
301+
302+ errCh := make(chan error, 1)
303+ go func() {
304+ _, err := client.List(ctx, "arch")
305+ errCh <- err
306+ }()
307+ select {
308+ case <-started:
309+ case <-time.After(2 * time.Second):
310+ t.Fatal("command did not start")
311+ }
312+ if tt.cancel {
313+ cancel()
314+ }
315+ select {
316+ case err := <-errCh:
317+ if !errors.Is(err, tt.want) {
318+ t.Fatalf("List() error = %v, want %v", err, tt.want)
319+ }
320+ case <-time.After(2 * time.Second):
321+ t.Fatal("List() did not return after context ended")
322+ }
323+ })
324+ }
325+}
326+
327+func TestCatalogOutputSafety(t *testing.T) {
328+ t.Parallel()
329+
330+ tests := []struct {
331+ name string
332+ command func(ctx context.Context) *exec.Cmd
333+ wantErr error
334+ check func(t *testing.T, err error)
335+ }{
336+ {
337+ name: "oversized stdout",
338+ command: func(ctx context.Context) *exec.Cmd {
339+ return exec.CommandContext(ctx, "sh", "-c",
340+ fmt.Sprintf("head -c %d /dev/zero", maxCatalogBytes+1))
341+ },
342+ wantErr: errCatalogTooLarge,
343+ check: func(t *testing.T, err error) {
344+ t.Helper()
345+ if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) {
346+ t.Fatalf("error embeds oversized stdout: %v", err)
347+ }
348+ },
349+ },
350+ {
351+ name: "oversized stderr",
352+ command: func(ctx context.Context) *exec.Cmd {
353+ return exec.CommandContext(ctx, "sh", "-c",
354+ fmt.Sprintf("head -c %d /dev/zero >&2; exit 1", maxCatalogDiagnosticBytes+1))
355+ },
356+ wantErr: errCatalogTooLarge,
357+ check: func(t *testing.T, err error) {
358+ t.Helper()
359+ if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) {
360+ t.Fatalf("error embeds oversized stderr: %v", err)
361+ }
362+ if errors.Is(err, errCatalogSSH) {
363+ t.Fatalf("oversized stderr must not wrap SSH with captured output: %v", err)
364+ }
365+ },
366+ },
367+ {
368+ name: "ansi control stderr",
369+ command: func(ctx context.Context) *exec.Cmd {
370+ cmd := exec.CommandContext(ctx, "sh", "-c", "cat >&2; exit 255")
371+ cmd.Stdin = strings.NewReader("\x1b[31mconnection refused\x1b[0m\x07")
372+ return cmd
373+ },
374+ wantErr: errCatalogSSH,
375+ check: func(t *testing.T, err error) {
376+ t.Helper()
377+ msg := err.Error()
378+ if strings.ContainsRune(msg, '\x1b') || strings.ContainsRune(msg, '\x07') {
379+ t.Fatalf("error retains control/ANSI bytes: %q", msg)
380+ }
381+ if strings.Contains(msg, "[31m") || strings.Contains(msg, "[0m") {
382+ t.Fatalf("error retains terminal escape remnants: %q", msg)
383+ }
384+ if !strings.Contains(msg, "connection refused") {
385+ t.Fatalf("error = %q, want printable diagnostic text preserved", msg)
386+ }
387+ },
388+ },
389+ }
390+
391+ for _, tt := range tests {
392+ t.Run(tt.name, func(t *testing.T) {
393+ t.Parallel()
394+ client := &CatalogClient{
395+ command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
396+ return tt.command(ctx)
397+ },
398+ }
399+ _, err := client.List(context.Background(), "arch")
400+ if !errors.Is(err, tt.wantErr) {
401+ t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
402+ }
403+ if tt.check != nil {
404+ tt.check(t, err)
405+ }
406+ })
407+ }
408+}
409+
410+func TestBoundedBufferLimits(t *testing.T) {
411+ t.Parallel()
412+
413+ tests := []struct {
414+ name string
415+ limit int
416+ chunks [][]byte
417+ wantLen int
418+ wantOver bool
419+ wantBytes string
420+ }{
421+ {
422+ name: "under limit",
423+ limit: 8,
424+ chunks: [][]byte{[]byte("abcd"), []byte("ef")},
425+ wantLen: 6,
426+ wantOver: false,
427+ wantBytes: "abcdef",
428+ },
429+ {
430+ name: "exact limit",
431+ limit: 4,
432+ chunks: [][]byte{[]byte("abcd")},
433+ wantLen: 4,
434+ wantOver: false,
435+ wantBytes: "abcd",
436+ },
437+ {
438+ name: "overflow discards remainder",
439+ limit: 4,
440+ chunks: [][]byte{[]byte("abc"), []byte("defgh")},
441+ wantLen: 4,
442+ wantOver: true,
443+ wantBytes: "abcd",
444+ },
445+ {
446+ name: "further writes stay discarded",
447+ limit: 2,
448+ chunks: [][]byte{[]byte("abcdef"), []byte("xyz")},
449+ wantLen: 2,
450+ wantOver: true,
451+ wantBytes: "ab",
452+ },
453+ }
454+
455+ for _, tt := range tests {
456+ t.Run(tt.name, func(t *testing.T) {
457+ t.Parallel()
458+ var buf boundedBuffer
459+ buf.limit = tt.limit
460+ for _, chunk := range tt.chunks {
461+ n, err := buf.Write(chunk)
462+ if err != nil {
463+ t.Fatalf("Write(%q) error = %v", chunk, err)
464+ }
465+ if n != len(chunk) {
466+ t.Fatalf("Write(%q) n = %d, want %d", chunk, n, len(chunk))
467+ }
468+ }
469+ if buf.overflow != tt.wantOver {
470+ t.Fatalf("overflow = %v, want %v", buf.overflow, tt.wantOver)
471+ }
472+ if got := len(buf.Bytes()); got != tt.wantLen {
473+ t.Fatalf("len = %d, want %d", got, tt.wantLen)
474+ }
475+ if got := string(buf.Bytes()); got != tt.wantBytes {
476+ t.Fatalf("bytes = %q, want %q", got, tt.wantBytes)
477+ }
478+ })
479+ }
480+}
481+
482+func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd {
483+ cmd := exec.CommandContext(ctx, "cat")
484+ cmd.Stdin = strings.NewReader(stdout)
485+ return cmd
486+}
487+
488+func mustCatalogJSON(t *testing.T, catalog ports.RemoteCatalog) string {
489+ t.Helper()
490+ raw, err := json.Marshal(catalog)
491+ if err != nil {
492+ t.Fatalf("Marshal: %v", err)
493+ }
494+ return string(raw) + "\n"
495+}
M internal/adapters/sshstdio/transport.go +13 -2
@@ -301,9 +301,20 @@ func BuildCommand(target, session string) CommandSpec {
301301 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
302302 // remote mode such as _stdio or _udp-bootstrap.
303303 func BuildCommandForMode(target, mode, session string) CommandSpec {
304- remote := []string{shellQuote("vev"), shellQuote(mode)}
304+ remote := []string{"vev", mode}
305305 if session != "" {
306- remote = append(remote, shellQuote(session))
306+ remote = append(remote, session)
307+ }
308+ return BuildCommandForRemoteCommand(target, remote...)
309+}
310+
311+// BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote
312+// command. Every remote word is POSIX single-quoted; the target remains one
313+// local argv word after the option terminator.
314+func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec {
315+ remote := make([]string, 0, len(command))
316+ for _, word := range command {
317+ remote = append(remote, shellQuote(word))
307318 }
308319 args := []string{"--", target, strings.Join(remote, " ")}
309320 return CommandSpec{Path: "ssh", Args: args}
@@ -301,9 +301,20 @@ func BuildCommand(target, session string) CommandSpec {
301 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev301 // BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
302 // remote mode such as _stdio or _udp-bootstrap.302 // remote mode such as _stdio or _udp-bootstrap.
303 func BuildCommandForMode(target, mode, session string) CommandSpec {303 func BuildCommandForMode(target, mode, session string) CommandSpec {
304- remote := []string{shellQuote("vev"), shellQuote(mode)}304+ remote := []string{"vev", mode}
305 if session != "" {305 if session != "" {
306- remote = append(remote, shellQuote(session))306+ remote = append(remote, session)
307+ }
308+ return BuildCommandForRemoteCommand(target, remote...)
309+}
310+
311+// BuildCommandForRemoteCommand constructs ssh argv for an arbitrary remote
312+// command. Every remote word is POSIX single-quoted; the target remains one
313+// local argv word after the option terminator.
314+func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec {
315+ remote := make([]string, 0, len(command))
316+ for _, word := range command {
317+ remote = append(remote, shellQuote(word))
307 }318 }
308 args := []string{"--", target, strings.Join(remote, " ")}319 args := []string{"--", target, strings.Join(remote, " ")}
309 return CommandSpec{Path: "ssh", Args: args}320 return CommandSpec{Path: "ssh", Args: args}
M internal/adapters/sshstdio/transport_test.go +45 -0
@@ -60,6 +60,51 @@ func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
6060 }
6161 }
6262
63+func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) {
64+ tests := []struct {
65+ name string
66+ target string
67+ command []string
68+ want []string
69+ }{
70+ {
71+ name: "catalog command",
72+ target: "arch",
73+ command: []string{"vev", "cmd", "remote-catalog", "--json"},
74+ want: []string{"--", "arch", "'vev' 'cmd' 'remote-catalog' '--json'"},
75+ },
76+ {
77+ name: "metacharacters in remote words are quoted",
78+ target: "user@host; touch /tmp/pwn",
79+ command: []string{"vev", "cmd", "remote-catalog; rm -rf /", "--json"},
80+ want: []string{"--", "user@host; touch /tmp/pwn", "'vev' 'cmd' 'remote-catalog; rm -rf /' '--json'"},
81+ },
82+ {
83+ name: "single quotes are posix escaped",
84+ target: "arch",
85+ command: []string{"it's", "fine"},
86+ want: []string{"--", "arch", "'it'\\''s' 'fine'"},
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ got := BuildCommandForRemoteCommand(tt.target, tt.command...)
93+ if got.Path != "ssh" {
94+ t.Fatalf("Path = %q, want ssh", got.Path)
95+ }
96+ if len(got.Args) != len(tt.want) {
97+ t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args)
98+ }
99+ for i := range tt.want {
100+ if got.Args[i] != tt.want[i] {
101+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args)
102+ }
103+ }
104+ })
105+ }
106+}
107+
63108 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
64109 clientRead, serverWrite := io.Pipe()
65110 serverRead, clientWrite := io.Pipe()
@@ -60,6 +60,51 @@ func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
60 }60 }
61 }61 }
62 62
63+func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) {
64+ tests := []struct {
65+ name string
66+ target string
67+ command []string
68+ want []string
69+ }{
70+ {
71+ name: "catalog command",
72+ target: "arch",
73+ command: []string{"vev", "cmd", "remote-catalog", "--json"},
74+ want: []string{"--", "arch", "'vev' 'cmd' 'remote-catalog' '--json'"},
75+ },
76+ {
77+ name: "metacharacters in remote words are quoted",
78+ target: "user@host; touch /tmp/pwn",
79+ command: []string{"vev", "cmd", "remote-catalog; rm -rf /", "--json"},
80+ want: []string{"--", "user@host; touch /tmp/pwn", "'vev' 'cmd' 'remote-catalog; rm -rf /' '--json'"},
81+ },
82+ {
83+ name: "single quotes are posix escaped",
84+ target: "arch",
85+ command: []string{"it's", "fine"},
86+ want: []string{"--", "arch", "'it'\\''s' 'fine'"},
87+ },
88+ }
89+
90+ for _, tt := range tests {
91+ t.Run(tt.name, func(t *testing.T) {
92+ got := BuildCommandForRemoteCommand(tt.target, tt.command...)
93+ if got.Path != "ssh" {
94+ t.Fatalf("Path = %q, want ssh", got.Path)
95+ }
96+ if len(got.Args) != len(tt.want) {
97+ t.Fatalf("Args len = %d, want %d (%q)", len(got.Args), len(tt.want), got.Args)
98+ }
99+ for i := range tt.want {
100+ if got.Args[i] != tt.want[i] {
101+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], tt.want[i], got.Args)
102+ }
103+ }
104+ })
105+ }
106+}
107+
63 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {108 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
64 clientRead, serverWrite := io.Pipe()109 clientRead, serverWrite := io.Pipe()
65 serverRead, clientWrite := io.Pipe()110 serverRead, clientWrite := io.Pipe()
M internal/ports/mocks/remote_mock.go +94 -0
@@ -5,6 +5,7 @@
55 package portsmocks
66
77 import (
8+ "context"
89 "log/slog"
910
1011 "github.com/bnema/vev/internal/ports"
@@ -462,3 +463,96 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st
462463 _c.Call.Return(run)
463464 return _c
464465 }
466+
467+// NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
468+// The first argument is typically a *testing.T value.
469+func NewMockRemoteCatalogClient(t interface {
470+ mock.TestingT
471+ Cleanup(func())
472+}) *MockRemoteCatalogClient {
473+ mock := &MockRemoteCatalogClient{}
474+ mock.Mock.Test(t)
475+
476+ t.Cleanup(func() { mock.AssertExpectations(t) })
477+
478+ return mock
479+}
480+
481+// MockRemoteCatalogClient is an autogenerated mock type for the RemoteCatalogClient type
482+type MockRemoteCatalogClient struct {
483+ mock.Mock
484+}
485+
486+type MockRemoteCatalogClient_Expecter struct {
487+ mock *mock.Mock
488+}
489+
490+func (_m *MockRemoteCatalogClient) EXPECT() *MockRemoteCatalogClient_Expecter {
491+ return &MockRemoteCatalogClient_Expecter{mock: &_m.Mock}
492+}
493+
494+// List provides a mock function for the type MockRemoteCatalogClient
495+func (_mock *MockRemoteCatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
496+ ret := _mock.Called(ctx, target)
497+
498+ if len(ret) == 0 {
499+ panic("no return value specified for List")
500+ }
501+
502+ var r0 ports.RemoteCatalog
503+ var r1 error
504+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteCatalog, error)); ok {
505+ return returnFunc(ctx, target)
506+ }
507+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteCatalog); ok {
508+ r0 = returnFunc(ctx, target)
509+ } else {
510+ r0 = ret.Get(0).(ports.RemoteCatalog)
511+ }
512+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
513+ r1 = returnFunc(ctx, target)
514+ } else {
515+ r1 = ret.Error(1)
516+ }
517+ return r0, r1
518+}
519+
520+// MockRemoteCatalogClient_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
521+type MockRemoteCatalogClient_List_Call struct {
522+ *mock.Call
523+}
524+
525+// List is a helper method to define mock.On call
526+// - ctx context.Context
527+// - target string
528+func (_e *MockRemoteCatalogClient_Expecter) List(ctx any, target any) *MockRemoteCatalogClient_List_Call {
529+ return &MockRemoteCatalogClient_List_Call{Call: _e.mock.On("List", ctx, target)}
530+}
531+
532+func (_c *MockRemoteCatalogClient_List_Call) Run(run func(ctx context.Context, target string)) *MockRemoteCatalogClient_List_Call {
533+ _c.Call.Run(func(args mock.Arguments) {
534+ var arg0 context.Context
535+ if args[0] != nil {
536+ arg0 = args[0].(context.Context)
537+ }
538+ var arg1 string
539+ if args[1] != nil {
540+ arg1 = args[1].(string)
541+ }
542+ run(
543+ arg0,
544+ arg1,
545+ )
546+ })
547+ return _c
548+}
549+
550+func (_c *MockRemoteCatalogClient_List_Call) Return(remoteCatalog ports.RemoteCatalog, err error) *MockRemoteCatalogClient_List_Call {
551+ _c.Call.Return(remoteCatalog, err)
552+ return _c
553+}
554+
555+func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.Context, target string) (ports.RemoteCatalog, error)) *MockRemoteCatalogClient_List_Call {
556+ _c.Call.Return(run)
557+ return _c
558+}
@@ -5,6 +5,7 @@
5 package portsmocks5 package portsmocks
6 6
7 import (7 import (
8+ "context"
8 "log/slog"9 "log/slog"
9 10
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
@@ -462,3 +463,96 @@ func (_c *MockRemoteHostStore_RemovePinned_Call) RunAndReturn(run func(target st
462 _c.Call.Return(run)463 _c.Call.Return(run)
463 return _c464 return _c
464 }465 }
466+
467+// NewMockRemoteCatalogClient creates a new instance of MockRemoteCatalogClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
468+// The first argument is typically a *testing.T value.
469+func NewMockRemoteCatalogClient(t interface {
470+ mock.TestingT
471+ Cleanup(func())
472+}) *MockRemoteCatalogClient {
473+ mock := &MockRemoteCatalogClient{}
474+ mock.Mock.Test(t)
475+
476+ t.Cleanup(func() { mock.AssertExpectations(t) })
477+
478+ return mock
479+}
480+
481+// MockRemoteCatalogClient is an autogenerated mock type for the RemoteCatalogClient type
482+type MockRemoteCatalogClient struct {
483+ mock.Mock
484+}
485+
486+type MockRemoteCatalogClient_Expecter struct {
487+ mock *mock.Mock
488+}
489+
490+func (_m *MockRemoteCatalogClient) EXPECT() *MockRemoteCatalogClient_Expecter {
491+ return &MockRemoteCatalogClient_Expecter{mock: &_m.Mock}
492+}
493+
494+// List provides a mock function for the type MockRemoteCatalogClient
495+func (_mock *MockRemoteCatalogClient) List(ctx context.Context, target string) (ports.RemoteCatalog, error) {
496+ ret := _mock.Called(ctx, target)
497+
498+ if len(ret) == 0 {
499+ panic("no return value specified for List")
500+ }
501+
502+ var r0 ports.RemoteCatalog
503+ var r1 error
504+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (ports.RemoteCatalog, error)); ok {
505+ return returnFunc(ctx, target)
506+ }
507+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ports.RemoteCatalog); ok {
508+ r0 = returnFunc(ctx, target)
509+ } else {
510+ r0 = ret.Get(0).(ports.RemoteCatalog)
511+ }
512+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
513+ r1 = returnFunc(ctx, target)
514+ } else {
515+ r1 = ret.Error(1)
516+ }
517+ return r0, r1
518+}
519+
520+// MockRemoteCatalogClient_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
521+type MockRemoteCatalogClient_List_Call struct {
522+ *mock.Call
523+}
524+
525+// List is a helper method to define mock.On call
526+// - ctx context.Context
527+// - target string
528+func (_e *MockRemoteCatalogClient_Expecter) List(ctx any, target any) *MockRemoteCatalogClient_List_Call {
529+ return &MockRemoteCatalogClient_List_Call{Call: _e.mock.On("List", ctx, target)}
530+}
531+
532+func (_c *MockRemoteCatalogClient_List_Call) Run(run func(ctx context.Context, target string)) *MockRemoteCatalogClient_List_Call {
533+ _c.Call.Run(func(args mock.Arguments) {
534+ var arg0 context.Context
535+ if args[0] != nil {
536+ arg0 = args[0].(context.Context)
537+ }
538+ var arg1 string
539+ if args[1] != nil {
540+ arg1 = args[1].(string)
541+ }
542+ run(
543+ arg0,
544+ arg1,
545+ )
546+ })
547+ return _c
548+}
549+
550+func (_c *MockRemoteCatalogClient_List_Call) Return(remoteCatalog ports.RemoteCatalog, err error) *MockRemoteCatalogClient_List_Call {
551+ _c.Call.Return(remoteCatalog, err)
552+ return _c
553+}
554+
555+func (_c *MockRemoteCatalogClient_List_Call) RunAndReturn(run func(ctx context.Context, target string) (ports.RemoteCatalog, error)) *MockRemoteCatalogClient_List_Call {
556+ _c.Call.Return(run)
557+ return _c
558+}
M internal/ports/remote.go +48 -1
@@ -1,6 +1,10 @@
11 package ports
22
3-import "log/slog"
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+)
48
59 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.
610 type RemoteTransportMode string
@@ -28,3 +32,46 @@ type RemoteHostStore interface {
2832 // Remove deletes target from both pinned and learned lists atomically.
2933 Remove(target string) error
3034 }
35+
36+// RemoteCatalogClient fetches a versioned session catalog from a remote host.
37+type RemoteCatalogClient interface {
38+ List(ctx context.Context, target string) (RemoteCatalog, error)
39+}
40+
41+// RemoteCatalogSession is one live session in the remote discovery catalog.
42+// State is an explicit string contract (running|stopped|broken), not SessionState.
43+type RemoteCatalogSession struct {
44+ Name string `json:"name"`
45+ State string `json:"state"`
46+ Ephemeral bool `json:"ephemeral"`
47+ Tabs uint16 `json:"tabs"`
48+ Attached bool `json:"attached"`
49+}
50+
51+// SaturateUint16 clamps n to the uint16 range.
52+func SaturateUint16(n int) uint16 {
53+ if n <= 0 {
54+ return 0
55+ }
56+ if n >= 1<<16 {
57+ return ^uint16(0)
58+ }
59+ return uint16(n)
60+}
61+
62+// RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.
63+type RemoteCatalog struct {
64+ ProtocolVersion uint16 `json:"protocol_version"`
65+ Sessions []RemoteCatalogSession `json:"sessions"`
66+}
67+
68+// RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
69+// reports a protocol_version that is not equal to ProtocolVersion.
70+type RemoteCatalogVersionMismatchError struct {
71+ Got uint16
72+ Want uint16
73+}
74+
75+func (e *RemoteCatalogVersionMismatchError) Error() string {
76+ return fmt.Sprintf("remote catalog: protocol version mismatch: got %d, want %d", e.Got, e.Want)
77+}
@@ -1,6 +1,10 @@
1 package ports1 package ports
2 2
3-import "log/slog"3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+)
4 8
5 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.9 // RemoteTransportMode selects the transport used for remote attach after CLI parsing.
6 type RemoteTransportMode string10 type RemoteTransportMode string
@@ -28,3 +32,46 @@ type RemoteHostStore interface {
28 // Remove deletes target from both pinned and learned lists atomically.32 // Remove deletes target from both pinned and learned lists atomically.
29 Remove(target string) error33 Remove(target string) error
30 }34 }
35+
36+// RemoteCatalogClient fetches a versioned session catalog from a remote host.
37+type RemoteCatalogClient interface {
38+ List(ctx context.Context, target string) (RemoteCatalog, error)
39+}
40+
41+// RemoteCatalogSession is one live session in the remote discovery catalog.
42+// State is an explicit string contract (running|stopped|broken), not SessionState.
43+type RemoteCatalogSession struct {
44+ Name string `json:"name"`
45+ State string `json:"state"`
46+ Ephemeral bool `json:"ephemeral"`
47+ Tabs uint16 `json:"tabs"`
48+ Attached bool `json:"attached"`
49+}
50+
51+// SaturateUint16 clamps n to the uint16 range.
52+func SaturateUint16(n int) uint16 {
53+ if n <= 0 {
54+ return 0
55+ }
56+ if n >= 1<<16 {
57+ return ^uint16(0)
58+ }
59+ return uint16(n)
60+}
61+
62+// RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json.
63+type RemoteCatalog struct {
64+ ProtocolVersion uint16 `json:"protocol_version"`
65+ Sessions []RemoteCatalogSession `json:"sessions"`
66+}
67+
68+// RemoteCatalogVersionMismatchError is returned when a remote catalog envelope
69+// reports a protocol_version that is not equal to ProtocolVersion.
70+type RemoteCatalogVersionMismatchError struct {
71+ Got uint16
72+ Want uint16
73+}
74+
75+func (e *RemoteCatalogVersionMismatchError) Error() string {
76+ return fmt.Sprintf("remote catalog: protocol version mismatch: got %d, want %d", e.Got, e.Want)
77+}
M internal/usecase/command/command.go +1 -0
@@ -155,6 +155,7 @@ type ControlContext interface {
155155 ListSessions(json bool) (string, error)
156156 ListTabs(json bool) (string, error)
157157 ListPanes(json bool) (string, error)
158+ RemoteCatalog(json bool) (string, error)
158159 }
159160
160161 // Command describes an executable command.
@@ -155,6 +155,7 @@ type ControlContext interface {
155 ListSessions(json bool) (string, error)155 ListSessions(json bool) (string, error)
156 ListTabs(json bool) (string, error)156 ListTabs(json bool) (string, error)
157 ListPanes(json bool) (string, error)157 ListPanes(json bool) (string, error)
158+ RemoteCatalog(json bool) (string, error)
158 }159 }
159 160
160 // Command describes an executable command.161 // Command describes an executable command.
M internal/usecase/command/registry.go +16 -0
@@ -51,6 +51,7 @@ func Registry() []Command {
5151 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
5252 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
5353 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
54+ remoteCatalogCommand(),
5455 sessionRecoveryCommand(),
5556 }
5657 return commands
@@ -139,6 +140,21 @@ func sessionRecoveryCommand() Command {
139140 }
140141 }
141142
143+func remoteCatalogCommand() Command {
144+ return Command{
145+ Slug: "remote-catalog", Name: "Remote catalog",
146+ Desc: "Return the versioned session catalog for remote discovery",
147+ Usage: "remote-catalog --json", Scriptable: true, Target: TargetNone,
148+ Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
149+ if len(args) != 0 || !opts.JSON {
150+ return ControlResult{}, ErrInvalidArguments
151+ }
152+ output, err := ctx.RemoteCatalog(true)
153+ return ControlResult{Output: output}, err
154+ },
155+ }
156+}
157+
142158 func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {
143159 return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
144160 if len(args) != 0 {
@@ -51,6 +51,7 @@ func Registry() []Command {
51 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),51 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
52 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),52 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
53 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),53 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
54+ remoteCatalogCommand(),
54 sessionRecoveryCommand(),55 sessionRecoveryCommand(),
55 }56 }
56 return commands57 return commands
@@ -139,6 +140,21 @@ func sessionRecoveryCommand() Command {
139 }140 }
140 }141 }
141 142
143+func remoteCatalogCommand() Command {
144+ return Command{
145+ Slug: "remote-catalog", Name: "Remote catalog",
146+ Desc: "Return the versioned session catalog for remote discovery",
147+ Usage: "remote-catalog --json", Scriptable: true, Target: TargetNone,
148+ Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
149+ if len(args) != 0 || !opts.JSON {
150+ return ControlResult{}, ErrInvalidArguments
151+ }
152+ output, err := ctx.RemoteCatalog(true)
153+ return ControlResult{Output: output}, err
154+ },
155+ }
156+}
157+
142 func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {158 func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command {
143 return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {159 return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) {
144 if len(args) != 0 {160 if len(args) != 0 {
M internal/usecase/command/registry_test.go +46 -1
@@ -145,6 +145,7 @@ func TestRegistryControlMetadata(t *testing.T) {
145145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146146 "move-pane": TargetPane, "move-tab": TargetTab,
147147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
148+ "remote-catalog": TargetNone,
148149 }
149150 notScriptable := []string{
150151 "session-picker", "notifications", "visual-mode", "yank-last-notification",
@@ -173,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
173174 if !cmd.PaletteVisible || cmd.Run == nil {
174175 t.Errorf("palette command is not executable and visible: %#v", cmd)
175176 }
176- if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" {
177+ if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" {
177178 t.Errorf("API-only command %q is visible", cmd.Slug)
178179 }
179180 }
@@ -208,6 +209,45 @@ func TestSessionRecoveryCommand(t *testing.T) {
208209 }
209210 }
210211
212+func TestRemoteCatalogCommand(t *testing.T) {
213+ cmd, ok := BySlug("remote-catalog")
214+ if !ok {
215+ t.Fatal("BySlug(\"remote-catalog\") missing")
216+ }
217+ if cmd.Name != "Remote catalog" || cmd.Desc == "" || cmd.Usage != "remote-catalog --json" {
218+ t.Fatalf("remote-catalog metadata = %#v", cmd)
219+ }
220+ if !cmd.Scriptable || cmd.PaletteVisible || cmd.Target != TargetNone || cmd.Run != nil || cmd.Control == nil {
221+ t.Fatalf("remote-catalog visibility/target = %#v", cmd)
222+ }
223+
224+ tests := []struct {
225+ name string
226+ args []string
227+ opts ControlOptions
228+ wantCall string
229+ wantErr error
230+ }{
231+ {name: "json delegates", opts: ControlOptions{JSON: true}, wantCall: "remote-catalog:true"},
232+ {name: "rejects missing json", wantErr: ErrInvalidArguments},
233+ {name: "rejects args", args: []string{"extra"}, opts: ControlOptions{JSON: true}, wantErr: ErrInvalidArguments},
234+ {name: "rejects args without json", args: []string{"extra"}, wantErr: ErrInvalidArguments},
235+ }
236+ for _, tt := range tests {
237+ t.Run(tt.name, func(t *testing.T) {
238+ ctx := &controlSpy{catalogOutput: `{"protocol_version":19,"sessions":[]}` + "\n"}
239+ got, err := cmd.Control(ctx, tt.args, tt.opts)
240+ wantOutput := ""
241+ if tt.wantCall != "" {
242+ wantOutput = ctx.catalogOutput
243+ }
244+ if !errors.Is(err, tt.wantErr) || ctx.call != tt.wantCall || got.Output != wantOutput {
245+ t.Fatalf("Control() call/output/error = %q/%q/%v, want %q/%q/%v", ctx.call, got.Output, err, tt.wantCall, wantOutput, tt.wantErr)
246+ }
247+ })
248+ }
249+}
250+
211251 func TestControlHandlersValidateAndDelegate(t *testing.T) {
212252 tests := []struct {
213253 name string
@@ -257,6 +297,7 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
257297 type controlSpy struct {
258298 call string
259299 recoveryOutput string
300+ catalogOutput string
260301 }
261302
262303 func (s *controlSpy) record(call string) error { s.call = call; return nil }
@@ -307,6 +348,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) {
307348 _ = s.record("list-panes:" + strconv.FormatBool(json))
308349 return "panes", nil
309350 }
351+func (s *controlSpy) RemoteCatalog(json bool) (string, error) {
352+ _ = s.record("remote-catalog:" + strconv.FormatBool(json))
353+ return s.catalogOutput, nil
354+}
310355
311356 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
312357 cmd, ok := ByCode("FLT")
@@ -145,6 +145,7 @@ func TestRegistryControlMetadata(t *testing.T) {
145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146 "move-pane": TargetPane, "move-tab": TargetTab,146 "move-pane": TargetPane, "move-tab": TargetTab,
147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
148+ "remote-catalog": TargetNone,
148 }149 }
149 notScriptable := []string{150 notScriptable := []string{
150 "session-picker", "notifications", "visual-mode", "yank-last-notification",151 "session-picker", "notifications", "visual-mode", "yank-last-notification",
@@ -173,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
173 if !cmd.PaletteVisible || cmd.Run == nil {174 if !cmd.PaletteVisible || cmd.Run == nil {
174 t.Errorf("palette command is not executable and visible: %#v", cmd)175 t.Errorf("palette command is not executable and visible: %#v", cmd)
175 }176 }
176- if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" {177+ if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" {
177 t.Errorf("API-only command %q is visible", cmd.Slug)178 t.Errorf("API-only command %q is visible", cmd.Slug)
178 }179 }
179 }180 }
@@ -208,6 +209,45 @@ func TestSessionRecoveryCommand(t *testing.T) {
208 }209 }
209 }210 }
210 211
212+func TestRemoteCatalogCommand(t *testing.T) {
213+ cmd, ok := BySlug("remote-catalog")
214+ if !ok {
215+ t.Fatal("BySlug(\"remote-catalog\") missing")
216+ }
217+ if cmd.Name != "Remote catalog" || cmd.Desc == "" || cmd.Usage != "remote-catalog --json" {
218+ t.Fatalf("remote-catalog metadata = %#v", cmd)
219+ }
220+ if !cmd.Scriptable || cmd.PaletteVisible || cmd.Target != TargetNone || cmd.Run != nil || cmd.Control == nil {
221+ t.Fatalf("remote-catalog visibility/target = %#v", cmd)
222+ }
223+
224+ tests := []struct {
225+ name string
226+ args []string
227+ opts ControlOptions
228+ wantCall string
229+ wantErr error
230+ }{
231+ {name: "json delegates", opts: ControlOptions{JSON: true}, wantCall: "remote-catalog:true"},
232+ {name: "rejects missing json", wantErr: ErrInvalidArguments},
233+ {name: "rejects args", args: []string{"extra"}, opts: ControlOptions{JSON: true}, wantErr: ErrInvalidArguments},
234+ {name: "rejects args without json", args: []string{"extra"}, wantErr: ErrInvalidArguments},
235+ }
236+ for _, tt := range tests {
237+ t.Run(tt.name, func(t *testing.T) {
238+ ctx := &controlSpy{catalogOutput: `{"protocol_version":19,"sessions":[]}` + "\n"}
239+ got, err := cmd.Control(ctx, tt.args, tt.opts)
240+ wantOutput := ""
241+ if tt.wantCall != "" {
242+ wantOutput = ctx.catalogOutput
243+ }
244+ if !errors.Is(err, tt.wantErr) || ctx.call != tt.wantCall || got.Output != wantOutput {
245+ t.Fatalf("Control() call/output/error = %q/%q/%v, want %q/%q/%v", ctx.call, got.Output, err, tt.wantCall, wantOutput, tt.wantErr)
246+ }
247+ })
248+ }
249+}
250+
211 func TestControlHandlersValidateAndDelegate(t *testing.T) {251 func TestControlHandlersValidateAndDelegate(t *testing.T) {
212 tests := []struct {252 tests := []struct {
213 name string253 name string
@@ -257,6 +297,7 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
257 type controlSpy struct {297 type controlSpy struct {
258 call string298 call string
259 recoveryOutput string299 recoveryOutput string
300+ catalogOutput string
260 }301 }
261 302
262 func (s *controlSpy) record(call string) error { s.call = call; return nil }303 func (s *controlSpy) record(call string) error { s.call = call; return nil }
@@ -307,6 +348,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) {
307 _ = s.record("list-panes:" + strconv.FormatBool(json))348 _ = s.record("list-panes:" + strconv.FormatBool(json))
308 return "panes", nil349 return "panes", nil
309 }350 }
351+func (s *controlSpy) RemoteCatalog(json bool) (string, error) {
352+ _ = s.record("remote-catalog:" + strconv.FormatBool(json))
353+ return s.catalogOutput, nil
354+}
310 355
311 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {356 func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
312 cmd, ok := ByCode("FLT")357 cmd, ok := ByCode("FLT")
M internal/usecase/daemon/control.go +29 -0
@@ -605,6 +605,35 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
605605 return out.String(), nil
606606 }
607607
608+func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
609+ if !asJSON {
610+ return "", command.ErrInvalidArguments
611+ }
612+ e.d.mu.Lock()
613+ sessions := make([]*session, 0, len(e.d.sessions))
614+ for _, sess := range e.d.sessions {
615+ sessions = append(sessions, sess)
616+ }
617+ e.d.mu.Unlock()
618+ rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
619+ // Stopped sessions live in d.stopped and are intentionally not in this listing.
620+ for _, sess := range sessions {
621+ snap := sess.snapshotView(viewOptions{})
622+ rows = append(rows, ports.RemoteCatalogSession{
623+ Name: snap.name,
624+ State: "running",
625+ Ephemeral: snap.ephemeral,
626+ Tabs: ports.SaturateUint16(snap.tabCount),
627+ Attached: snap.attached,
628+ })
629+ }
630+ sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
631+ return marshalListing(ports.RemoteCatalog{
632+ ProtocolVersion: ports.ProtocolVersion,
633+ Sessions: rows,
634+ })
635+}
636+
608637 func (e controlExec) ListTabs(asJSON bool) (string, error) {
609638 type row struct {
610639 Index int `json:"index"`
@@ -605,6 +605,35 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
605 return out.String(), nil605 return out.String(), nil
606 }606 }
607 607
608+func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
609+ if !asJSON {
610+ return "", command.ErrInvalidArguments
611+ }
612+ e.d.mu.Lock()
613+ sessions := make([]*session, 0, len(e.d.sessions))
614+ for _, sess := range e.d.sessions {
615+ sessions = append(sessions, sess)
616+ }
617+ e.d.mu.Unlock()
618+ rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
619+ // Stopped sessions live in d.stopped and are intentionally not in this listing.
620+ for _, sess := range sessions {
621+ snap := sess.snapshotView(viewOptions{})
622+ rows = append(rows, ports.RemoteCatalogSession{
623+ Name: snap.name,
624+ State: "running",
625+ Ephemeral: snap.ephemeral,
626+ Tabs: ports.SaturateUint16(snap.tabCount),
627+ Attached: snap.attached,
628+ })
629+ }
630+ sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
631+ return marshalListing(ports.RemoteCatalog{
632+ ProtocolVersion: ports.ProtocolVersion,
633+ Sessions: rows,
634+ })
635+}
636+
608 func (e controlExec) ListTabs(asJSON bool) (string, error) {637 func (e controlExec) ListTabs(asJSON bool) (string, error) {
609 type row struct {638 type row struct {
610 Index int `json:"index"`639 Index int `json:"index"`
M internal/usecase/daemon/control_test.go +84 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "encoding/json"
6+ "strings"
67 "sync"
78 "testing"
89 "time"
@@ -648,6 +649,89 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
648649 require.Equal(t, true, decoded[0]["focused"])
649650 }
650651
652+func TestRemoteCatalogJSONOutput(t *testing.T) {
653+ d := newTestDaemon(t, nil, stubClock{})
654+ work := addControlSession(d, "work", "t_work", "p_work")
655+ work.ephemeral = false
656+ work.client = &attachedClient{}
657+ work.mruAt.Store(2)
658+ tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
659+ tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
660+ work.mu.Lock()
661+ work.tabs = append(work.tabs, tb)
662+ work.mu.Unlock()
663+
664+ build := addControlSession(d, "build", "t_build", "p_build")
665+ build.ephemeral = true
666+ build.mruAt.Store(1)
667+
668+ d.mu.Lock()
669+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionStopped}
670+ d.mu.Unlock()
671+
672+ listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
673+ require.True(t, listBefore.OK, listBefore.Text)
674+
675+ missingJSON := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog"})
676+ require.False(t, missingJSON.OK)
677+ require.Equal(t, ports.ErrInvalidCommandArgs, missingJSON.Code)
678+
679+ withArgs := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
680+ require.False(t, withArgs.OK)
681+ require.Equal(t, ports.ErrInvalidCommandArgs, withArgs.Code)
682+
683+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
684+ require.True(t, result.OK, result.Text)
685+ require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
686+
687+ var catalog ports.RemoteCatalog
688+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
689+ require.Equal(t, ports.ProtocolVersion, catalog.ProtocolVersion)
690+
691+ raw := map[string]json.RawMessage{}
692+ require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
693+ require.Len(t, raw, 2)
694+ _, hasProtocol := raw["protocol_version"]
695+ _, hasSessions := raw["sessions"]
696+ require.True(t, hasProtocol && hasSessions)
697+
698+ require.Equal(t, []ports.RemoteCatalogSession{
699+ {Name: "build", State: "running", Ephemeral: true, Tabs: 1, Attached: false},
700+ {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
701+ }, catalog.Sessions)
702+
703+ for _, session := range catalog.Sessions {
704+ require.Equal(t, "running", session.State)
705+ require.NotEqual(t, "old", session.Name)
706+ }
707+
708+ listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
709+ require.True(t, listAfter.OK, listAfter.Text)
710+ require.Equal(t, listBefore.Output, listAfter.Output)
711+ require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
712+ require.NotContains(t, listAfter.Output, "old")
713+}
714+
715+func TestRemoteCatalogTabCountSaturates(t *testing.T) {
716+ d := newTestDaemon(t, nil, stubClock{})
717+ sess := addControlSession(d, "work", "t_work", "p_work")
718+ sess.mu.Lock()
719+ base := sess.tabs[0]
720+ sess.tabs = make([]*tab, int(^uint16(0))+1)
721+ for i := range sess.tabs {
722+ sess.tabs[i] = base
723+ }
724+ sess.mu.Unlock()
725+
726+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
727+ require.True(t, result.OK, result.Text)
728+ var catalog ports.RemoteCatalog
729+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
730+ require.Equal(t, []ports.RemoteCatalogSession{{
731+ Name: "work", State: "running", Ephemeral: true, Tabs: ^uint16(0), Attached: false,
732+ }}, catalog.Sessions)
733+}
734+
651735 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
652736 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
653737 d := newTestDaemon(t, factory, stubClock{})
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "strings"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -648,6 +649,89 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
648 require.Equal(t, true, decoded[0]["focused"])649 require.Equal(t, true, decoded[0]["focused"])
649 }650 }
650 651
652+func TestRemoteCatalogJSONOutput(t *testing.T) {
653+ d := newTestDaemon(t, nil, stubClock{})
654+ work := addControlSession(d, "work", "t_work", "p_work")
655+ work.ephemeral = false
656+ work.client = &attachedClient{}
657+ work.mruAt.Store(2)
658+ tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
659+ tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
660+ work.mu.Lock()
661+ work.tabs = append(work.tabs, tb)
662+ work.mu.Unlock()
663+
664+ build := addControlSession(d, "build", "t_build", "p_build")
665+ build.ephemeral = true
666+ build.mruAt.Store(1)
667+
668+ d.mu.Lock()
669+ d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionStopped}
670+ d.mu.Unlock()
671+
672+ listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
673+ require.True(t, listBefore.OK, listBefore.Text)
674+
675+ missingJSON := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog"})
676+ require.False(t, missingJSON.OK)
677+ require.Equal(t, ports.ErrInvalidCommandArgs, missingJSON.Code)
678+
679+ withArgs := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
680+ require.False(t, withArgs.OK)
681+ require.Equal(t, ports.ErrInvalidCommandArgs, withArgs.Code)
682+
683+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
684+ require.True(t, result.OK, result.Text)
685+ require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
686+
687+ var catalog ports.RemoteCatalog
688+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
689+ require.Equal(t, ports.ProtocolVersion, catalog.ProtocolVersion)
690+
691+ raw := map[string]json.RawMessage{}
692+ require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
693+ require.Len(t, raw, 2)
694+ _, hasProtocol := raw["protocol_version"]
695+ _, hasSessions := raw["sessions"]
696+ require.True(t, hasProtocol && hasSessions)
697+
698+ require.Equal(t, []ports.RemoteCatalogSession{
699+ {Name: "build", State: "running", Ephemeral: true, Tabs: 1, Attached: false},
700+ {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true},
701+ }, catalog.Sessions)
702+
703+ for _, session := range catalog.Sessions {
704+ require.Equal(t, "running", session.State)
705+ require.NotEqual(t, "old", session.Name)
706+ }
707+
708+ listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
709+ require.True(t, listAfter.OK, listAfter.Text)
710+ require.Equal(t, listBefore.Output, listAfter.Output)
711+ require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
712+ require.NotContains(t, listAfter.Output, "old")
713+}
714+
715+func TestRemoteCatalogTabCountSaturates(t *testing.T) {
716+ d := newTestDaemon(t, nil, stubClock{})
717+ sess := addControlSession(d, "work", "t_work", "p_work")
718+ sess.mu.Lock()
719+ base := sess.tabs[0]
720+ sess.tabs = make([]*tab, int(^uint16(0))+1)
721+ for i := range sess.tabs {
722+ sess.tabs[i] = base
723+ }
724+ sess.mu.Unlock()
725+
726+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
727+ require.True(t, result.OK, result.Text)
728+ var catalog ports.RemoteCatalog
729+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
730+ require.Equal(t, []ports.RemoteCatalogSession{{
731+ Name: "work", State: "running", Ephemeral: true, Tabs: ^uint16(0), Attached: false,
732+ }}, catalog.Sessions)
733+}
734+
651 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {735 func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
652 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}736 factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
653 d := newTestDaemon(t, factory, stubClock{})737 d := newTestDaemon(t, factory, stubClock{})

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