From 6d30baedb000f4b03e8a27b9843120303e6cb79a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 09:14:01 +0100 Subject: [PATCH 1/2] refactor: unify terminal transport --- CHANGELOG.md | 5 +- cmd/crabbox-ssh-gateway/main.go | 84 ++----- cmd/crabbox-ssh-gateway/main_test.go | 19 +- cmd/crabfleet/main.go | 50 +--- cmd/crabfleet/main_test.go | 24 +- docs/api.md | 35 ++- docs/architecture.md | 4 +- docs/index.md | 2 +- docs/runs.md | 2 +- docs/spec.md | 4 +- internal/terminalws/client.go | 317 ++++++++++++++++++++++++ internal/terminalws/client_test.go | 168 +++++++++++++ src/index.ts | 260 +++---------------- tests/fleet-state.test.ts | 2 +- tests/runtime-adapter.test.ts | 29 +-- tests/terminal-target.test.ts | 16 +- tests/trusted-proxy-integration.test.ts | 2 + 17 files changed, 597 insertions(+), 426 deletions(-) create mode 100644 internal/terminalws/client.go create mode 100644 internal/terminalws/client_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 860c906..3bb10d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Unify managed terminal clients on the multiplex `/api/terminal/ws` protocol, remove direct PTY routes, and share one framed Go transport across the CLI and SSH gateway. - Connect Crabfleet lifecycle and terminal traffic to Crabbox through a Cloudflare service binding and deploy an identical route-scoped credential atomically across both coordinators. - Make OpenClaw room trees recoverable with idempotent Crabbox creation and root-level admission freeze plus recursive stop. - Add root-fenced OpenClaw service supervision for Crabbox room trees, including current state, bounded transcript evidence, targeted terminal nudges, audited stop requests, and canonical browser URLs. @@ -18,7 +19,7 @@ - Reject runtime-adapter redirects with Cloudflare-compatible manual redirect handling instead of using unsupported Worker fetch semantics. - Make create, run, and admin drawers real modal dialogs with keyboard focus containment and restoration. - Move the public product hosts to safely converged Worker Custom Domains and fail deploys unless app and product endpoints are reachable. -- Bound Crabbox terminal output with negotiated acknowledgements and legacy-client compatibility. +- Bound Crabbox terminal output with negotiated acknowledgements on the multiplex terminal hub. - Enable the OpenClaw deployment's versioned Crabbox runtime adapter with a stable tenant namespace. - Add comprehensive documentation for durable GitHub Actions sessions, including registration, runner and viewer relay, work-state heartbeats, Codex steering, resumption, completion, cancellation, authentication, archives, and troubleshooting. - Name versioned provider-backed workspace lifecycle actions Delete across Fleet, the Go CLI, and SSH while retaining explicit Stop wording for legacy sessions and `stop` as a CLI compatibility alias; keep the provider stop wire action internal; and fail closed without adopting or deleting a pre-existing adapter workspace on an explicit ID conflict. @@ -35,7 +36,7 @@ - Reject stale same-generation credential-policy registrations, preflight and atomically stage failed managed Sandbox claims, require the provision bearer for standalone stop after backend removal, and backfill D1-only terminal archives when R2 is enabled later. - Proactively generation-wrap migrated legacy Sandbox credential policies under a live durable lease before cleanup, preserve live pre-token sessions, and use crash-safe cron retries that retain unattended session credentials. - Bound every runtime-adapter response stream, revalidate desktop authorization after minting, make legacy local stops atomic with scheduled crash recovery, and redact credentials before opaque provider identifiers. -- Recover active credential policies after a post-registration crash, redact provider identities from structured adapter errors, and propagate terminal dimensions through direct bridge and runner PTY routes without rewriting opaque adapter URLs. +- Recover active credential policies after a post-registration crash, redact provider identities from structured adapter errors, and propagate terminal dimensions through configured bridge and runner PTY routes without rewriting opaque adapter URLs. - Support an optional authoritative `GITHUB_REDIRECT_URI` deployment binding with strict HTTPS callback validation, canonical-origin login handoff, and callback host/path enforcement while retaining safe request-origin defaults. - Replace native browser confirms and prompts with accessible Crabfleet dialogs for session cleanup, shutdown, and share-link fallback. - Sharpen the app visual system with flatter controls, tighter surfaces, and restrained overlay elevation. diff --git a/cmd/crabbox-ssh-gateway/main.go b/cmd/crabbox-ssh-gateway/main.go index 7b2f152..13adf5d 100644 --- a/cmd/crabbox-ssh-gateway/main.go +++ b/cmd/crabbox-ssh-gateway/main.go @@ -18,7 +18,7 @@ import ( "strings" "time" - "github.com/coder/websocket" + "github.com/openclaw/crabfleet/internal/terminalws" "golang.org/x/crypto/ssh" ) @@ -62,7 +62,7 @@ type interactiveSession struct { Purpose string `json:"purpose"` Summary string `json:"summary"` Capabilities *sessionCapabilities `json:"capabilities"` - PtyAvailable *bool `json:"ptyAvailable"` + PtyAvailable bool `json:"ptyAvailable"` LeaseID string `json:"leaseId"` AttachURL string `json:"attachUrl"` VNCURL string `json:"vncUrl"` @@ -536,25 +536,7 @@ func attachable(session interactiveSession) bool { } func ptyAttachable(session interactiveSession) bool { - if session.PtyAvailable != nil { - return *session.PtyAvailable - } - if strings.HasPrefix(session.LeaseID, "sandbox:") || strings.HasPrefix(session.LeaseID, "cloudflare:") { - return true - } - return strings.HasPrefix(session.AttachURL, "/api/interactive-sessions/") || validWebSocketAttachURL(session.AttachURL) -} - -func validWebSocketAttachURL(raw string) bool { - target, err := url.Parse(raw) - if err != nil || target.Host == "" || target.User != nil { - return false - } - if target.Scheme == "wss" { - return true - } - host := target.Hostname() - return target.Scheme == "ws" && (host == "localhost" || host == "127.0.0.1" || host == "::1") + return session.PtyAvailable } func printHelp(out io.Writer, user user) { @@ -950,79 +932,51 @@ func (c *apiClient) updateSummary(ctx context.Context, fingerprint string, id st } func (c *apiClient) message(ctx context.Context, fingerprint string, id string, message string, enter bool, pty sessionPTY) error { - u, err := url.Parse(c.baseURL) + endpoint, err := terminalws.Endpoint(c.baseURL) if err != nil { return err } - switch u.Scheme { - case "https": - u.Scheme = "wss" - default: - u.Scheme = "ws" - } - u.Path = "/api/ssh/interactive-sessions/" + url.PathEscape(id) + "/pty" - q := u.Query() - q.Set("fingerprint", fingerprint) - q.Set("cols", fmt.Sprint(pty.cols)) - q.Set("rows", fmt.Sprint(pty.rows)) - u.RawQuery = q.Encode() headers := http.Header{} headers.Set("Authorization", "Bearer "+c.token) headers.Set("X-Crabfleet-SSH-Fingerprint", fingerprint) - ws, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{HTTPHeader: headers}) + client, err := terminalws.Dial(ctx, endpoint, id, terminalws.Options{ + Header: headers, + Cols: pty.cols, + Rows: pty.rows, + }) if err != nil { return err } - defer ws.Close(websocket.StatusNormalClosure, "") + defer client.Close() payload := message if enter { payload += "\n" } - return ws.Write(ctx, websocket.MessageBinary, []byte(payload)) + return client.SendInput(ctx, []byte(payload)) } func (c *apiClient) attach(ctx context.Context, fingerprint string, id string, terminal io.ReadWriter, pty sessionPTY) uint32 { - u, err := url.Parse(c.baseURL) + endpoint, err := terminalws.Endpoint(c.baseURL) if err != nil { fmt.Fprintf(terminal, "error: %v\n", err) return 1 } - switch u.Scheme { - case "https": - u.Scheme = "wss" - default: - u.Scheme = "ws" - } - u.Path = "/api/ssh/interactive-sessions/" + url.PathEscape(id) + "/pty" - q := u.Query() - q.Set("fingerprint", fingerprint) - q.Set("cols", fmt.Sprint(pty.cols)) - q.Set("rows", fmt.Sprint(pty.rows)) - u.RawQuery = q.Encode() headers := http.Header{} headers.Set("Authorization", "Bearer "+c.token) headers.Set("X-Crabfleet-SSH-Fingerprint", fingerprint) - ws, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{HTTPHeader: headers}) + client, err := terminalws.Dial(ctx, endpoint, id, terminalws.Options{ + Header: headers, + Cols: pty.cols, + Rows: pty.rows, + }) if err != nil { fmt.Fprintf(terminal, "attach failed: %v\n", err) return 1 } - defer ws.Close(websocket.StatusNormalClosure, "") - netConn := websocket.NetConn(ctx, ws, websocket.MessageBinary) - defer netConn.Close() - - errCh := make(chan error, 2) - go func() { - _, err := io.Copy(netConn, terminal) - errCh <- err - }() - go func() { - _, err := io.Copy(terminal, netConn) - errCh <- err - }() - err = <-errCh + defer client.Close() + err = client.Attach(ctx, terminal) if err != nil && !errors.Is(err, net.ErrClosed) && !strings.Contains(err.Error(), "closed") { fmt.Fprintf(terminal, "\nattach closed: %v\n", err) return 1 diff --git a/cmd/crabbox-ssh-gateway/main_test.go b/cmd/crabbox-ssh-gateway/main_test.go index 31809f5..901a071 100644 --- a/cmd/crabbox-ssh-gateway/main_test.go +++ b/cmd/crabbox-ssh-gateway/main_test.go @@ -121,26 +121,17 @@ func TestTerminalCapabilityWithdrawalSuppressesAttach(t *testing.T) { } func TestCreateAutoAttachRequiresReadyResolvablePTY(t *testing.T) { - available := true - if attachable(interactiveSession{Status: "provisioning", PtyAvailable: &available}) { + if attachable(interactiveSession{Status: "provisioning", PtyAvailable: true}) { t.Fatal("provisioning create must succeed without auto-attach") } - available = false - if attachable(interactiveSession{Status: "ready", PtyAvailable: &available}) { + if attachable(interactiveSession{Status: "ready", PtyAvailable: false}) { t.Fatal("ready session without a PTY route must not auto-attach") } - available = true - if !attachable(interactiveSession{Status: "ready", PtyAvailable: &available}) { + if !attachable(interactiveSession{Status: "ready", PtyAvailable: true}) { t.Fatal("ready session with a PTY route should auto-attach") } - if !attachable(interactiveSession{ - Status: "detached", - AttachURL: "/api/interactive-sessions/IS-1/pty", - }) { - t.Fatal("legacy API PTY routes should remain attachable") - } - if attachable(interactiveSession{Status: "ready", AttachURL: "ws://example.com/terminal"}) { - t.Fatal("insecure remote websocket should not auto-attach") + if attachable(interactiveSession{Status: "detached", AttachURL: "/api/terminal/ws"}) { + t.Fatal("attach URL must not override missing PTY availability") } } diff --git a/cmd/crabfleet/main.go b/cmd/crabfleet/main.go index bf21219..63b2bc6 100644 --- a/cmd/crabfleet/main.go +++ b/cmd/crabfleet/main.go @@ -17,7 +17,7 @@ import ( "time" "github.com/alecthomas/kong" - "github.com/coder/websocket" + "github.com/openclaw/crabfleet/internal/terminalws" ) const defaultAPIURL = "https://crabfleet.openclaw.ai" @@ -165,7 +165,7 @@ type interactiveSession struct { Purpose string `json:"purpose"` Summary string `json:"summary"` Capabilities *sessionCapabilities `json:"capabilities"` - PtyAvailable *bool `json:"ptyAvailable"` + PtyAvailable bool `json:"ptyAvailable"` LeaseID string `json:"leaseId"` AttachURL string `json:"attachUrl"` VNCURL string `json:"vncUrl"` @@ -798,39 +798,31 @@ func (c *apiClient) transcript(ctx context.Context, id string) (string, error) { } func (c *apiClient) message(ctx context.Context, id string, message string, enter bool) error { - path := "/api/ssh/interactive-sessions/" + url.PathEscape(id) + "/pty" - apiPath, authMode, err := c.authenticatedPath(path) + _, authMode, err := c.authenticatedPath("/api/terminal/ws") if err != nil { return err } - u, err := url.Parse(c.baseURL) + endpoint, err := terminalws.Endpoint(c.baseURL) if err != nil { return err } - switch u.Scheme { - case "https": - u.Scheme = "wss" - default: - u.Scheme = "ws" - } - u.Path = apiPath - q := u.Query() - q.Set("cols", "120") - q.Set("rows", "34") - u.RawQuery = q.Encode() headers := http.Header{} c.setAuthHeaders(headers, authMode) - ws, _, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{HTTPHeader: headers}) + client, err := terminalws.Dial(ctx, endpoint, id, terminalws.Options{ + Header: headers, + Cols: 120, + Rows: 34, + }) if err != nil { return err } - defer ws.Close(websocket.StatusNormalClosure, "") + defer client.Close() payload := message if enter { payload += "\n" } - return ws.Write(ctx, websocket.MessageBinary, []byte(payload)) + return client.SendInput(ctx, []byte(payload)) } func (c *apiClient) updateSummary(ctx context.Context, id string, summary string, purpose string) (interactiveSession, error) { @@ -1193,25 +1185,7 @@ func terminalCapable(session interactiveSession) bool { } func ptyAttachable(session interactiveSession) bool { - if session.PtyAvailable != nil { - return *session.PtyAvailable - } - if strings.HasPrefix(session.LeaseID, "sandbox:") || strings.HasPrefix(session.LeaseID, "cloudflare:") { - return true - } - return strings.HasPrefix(session.AttachURL, "/api/interactive-sessions/") || validWebSocketAttachURL(session.AttachURL) -} - -func validWebSocketAttachURL(raw string) bool { - target, err := url.Parse(raw) - if err != nil || target.Host == "" || target.User != nil { - return false - } - if target.Scheme == "wss" { - return true - } - host := target.Hostname() - return target.Scheme == "ws" && (host == "localhost" || host == "127.0.0.1" || host == "::1") + return session.PtyAvailable } func isTerminal(file *os.File) bool { diff --git a/cmd/crabfleet/main_test.go b/cmd/crabfleet/main_test.go index 1e19b9f..bbcdf8a 100644 --- a/cmd/crabfleet/main_test.go +++ b/cmd/crabfleet/main_test.go @@ -206,38 +206,28 @@ func TestLegacyProviderCleanupWarningRequiresConfirmedLegacyStop(t *testing.T) { } } -func TestAttachableRequiresReadySessionWithAttachURL(t *testing.T) { - if !attachable(interactiveSession{Status: "ready", AttachURL: "/api/interactive-sessions/IS-1/pty"}) { - t.Fatal("ready session with sandbox attach URL should be attachable") +func TestAttachableRequiresAuthoritativePTYAvailability(t *testing.T) { + if !attachable(interactiveSession{Status: "ready", PtyAvailable: true}) { + t.Fatal("ready session with an available PTY should be attachable") } if attachable(interactiveSession{Status: "pending_adapter", LeaseID: "sandbox:test"}) { t.Fatal("pending session should not be attachable") } - if attachable(interactiveSession{Status: "ready", AttachURL: "https://example.com/console"}) { - t.Fatal("http console URL should not be SSH attachable") - } - if attachable(interactiveSession{Status: "ready", AttachURL: "ws://example.com/terminal"}) { - t.Fatal("insecure remote websocket should not be attachable") - } - if !attachable(interactiveSession{Status: "ready", AttachURL: "ws://127.0.0.1:9000/terminal"}) { - t.Fatal("loopback websocket should be attachable") - } - if !attachable(interactiveSession{Status: "ready", LeaseID: "sandbox:test"}) { - t.Fatal("sandbox lease should be attachable") + if attachable(interactiveSession{Status: "ready", AttachURL: "/api/terminal/ws"}) { + t.Fatal("attach URL must not override missing PTY availability") } if attachable(interactiveSession{ Status: "ready", LeaseID: "sandbox:test", - AttachURL: "/api/interactive-sessions/IS-1/pty", + PtyAvailable: true, Capabilities: &sessionCapabilities{Terminal: false}, }) { t.Fatal("session with withdrawn terminal capability should not be attachable") } - available := false if attachable(interactiveSession{ Status: "ready", LeaseID: "sandbox:test", - PtyAvailable: &available, + PtyAvailable: false, }) { t.Fatal("server PTY availability should be authoritative") } diff --git a/docs/api.md b/docs/api.md index 698abff..2c6f15a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -289,7 +289,7 @@ An adapter-reported `failed` workspace is not locally terminal until Crabfleet c ### GET /api/terminal/ws -Session owner, maintainer/owner role, viewer with a current delegated control grant, or a public shared-link token for read-only sessions. Same-origin multiplex WebSocket endpoint used by the Ghostty WASM session grid. One browser socket can subscribe to multiple interactive sessions, receive PTY output frames, resize terminals, and send input only when the current user has control. +Session owner, maintainer/owner role, viewer with a current delegated control grant, SSH gateway linked-key identity, scoped session agent, or a public shared-link token for read-only sessions. Multiplex WebSocket endpoint used by the Ghostty WASM session grid, Go CLI, and SSH gateway. One socket can subscribe to multiple interactive sessions, receive PTY output frames, resize terminals, and send input only when the current user has control. The wire format is a compact binary frame: @@ -303,7 +303,7 @@ u32 payload_length payload bytes ``` -Supported browser actions: +Supported client actions: - `Subscribe`: attach to a session with output/snapshot/event flags and optional initial cols/rows. - `Unsubscribe`: detach one session without closing the hub. @@ -311,8 +311,17 @@ Supported browser actions: - `Resize`: forward terminal dimensions to the upstream PTY. - `Stop`: close the upstream subscription. - `Ping`: keepalive, answered with `Pong`. +- `Ack`: acknowledge consumed output bytes for negotiated flow control. -Server messages include `Welcome`, `Output`, `Event`, `Error`, `ControlRevoked`, and `Pong`. Shared-link viewers can subscribe and scroll output, but input frames are rejected unless an owner/maintainer grants writable control. Subscriptions require the current `terminal` capability; withdrawing it prevents new attaches, closes existing terminal sockets on the next authorization check, suppresses raw attach URLs and attachable state from app, API, fleet, CLI, and SSH responses, and removes Fleet terminal/SSH affordances. Recurring and per-input authorization use short-lived D1 snapshots only; throttled subscription reconciliation runs independently and never blocks an input frame on provider I/O. +Server messages include `Welcome`, `Output`, `Event`, `Error`, `ControlRevoked`, and `Pong`. Shared-link viewers can subscribe and scroll output, but input frames are rejected unless an owner/maintainer grants writable control. Subscriptions require the current `terminal` capability; withdrawing it prevents new attaches, closes existing terminal sockets on the next authorization check, suppresses attachable state from app, API, fleet, CLI, and SSH responses, and removes Fleet terminal/SSH affordances. Recurring and per-input authorization use short-lived D1 snapshots only; throttled subscription reconciliation runs independently and never blocks an input frame on provider I/O. + +Target resolution: + +- `CRABBOX_PTY_BRIDGE_URL`: explicit bridge WebSocket URL/template. Templates support `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`. Crabfleet appends `sessionId`, `leaseId`, `repo`, `branch`, `runtime`, and `command` query parameters. +- Provider terminal connection: if the provision adapter returned a `wss://` URL, or literal loopback `ws://` URL, Crabfleet retains it server-side and proxies to it unchanged, including its path and signed query string. +- `CRABBOX_CLOUDFLARE_RUNNER_URL`: for `cloudflare:` leases, Crabfleet proxies to `/v1/sandboxes/:sandbox/pty` on the runner. + +The hub appends terminal `cols` and `rows` only to configured bridge and Cloudflare runner endpoints, never to an adapter `attachUrl`. Crabfleet authenticates versioned-adapter terminal upgrades with `CRABBOX_RUNTIME_ADAPTER_TOKEN` only when the terminal shares the persisted and currently configured adapter origin; adapter URLs never carry reusable shell credentials. If `CRABBOX_PTY_BRIDGE_TOKEN` or `CRABBOX_CLOUDFLARE_RUNNER_TOKEN` is set, Crabfleet sends it as a bearer token only to the upstream bridge/runner. Clients never receive upstream credentials. ### POST /api/interactive-sessions/:id/clipboard @@ -322,18 +331,6 @@ Viewer+ with writable terminal control. Uploads a browser clipboard image/file b Viewer+ with writable session control. For `runtime-v1`, Crabfleet authenticates the browser session, asks the adapter to mint a current desktop connection, validates its HTTPS URL and optional bounded expiry, and issues a no-store redirect. Versioned-adapter desktop URLs are never persisted in D1 or returned by fleet state. API and CLI session views expose an absolute canonical Crabfleet browser URL for this cookie-authenticated route; the SSH gateway does not mint or receive the underlying adapter URL. Legacy adapters retain their existing validated absolute VNC URL behavior for browser and CLI clients. -### GET /api/interactive-sessions/:id/pty - -Session owner, maintainer/owner role, or viewer with a current delegated control grant. Legacy single-session WebSocket endpoint. Crabfleet authenticates the browser session, verifies the interactive session is still attachable, verifies terminal control, then proxies PTY bytes to the configured runner. - -Target resolution: - -- `CRABBOX_PTY_BRIDGE_URL`: explicit bridge WebSocket URL/template. Templates support `{id}`, `{leaseId}`, `{repo}`, `{branch}`, and `{runtime}`. Crabfleet appends `sessionId`, `leaseId`, `repo`, `branch`, `runtime`, and `command` query parameters. -- Provider terminal connection: if the provision adapter returned a `wss://` URL, or literal loopback `ws://` URL, Crabfleet retains it server-side and proxies to it unchanged, including its path and signed query string. -- `CRABBOX_CLOUDFLARE_RUNNER_URL`: for `cloudflare:` leases, Crabfleet proxies to `/v1/sandboxes/:sandbox/pty` on the runner. - -Both multiplex and legacy direct PTY routes append terminal `cols` and `rows` only to configured bridge and Cloudflare runner endpoints, never to an adapter `attachUrl`. Crabfleet authenticates versioned-adapter terminal upgrades with `CRABBOX_RUNTIME_ADAPTER_TOKEN` only when the terminal shares the persisted and currently configured adapter origin; adapter URLs never carry reusable shell credentials. If `CRABBOX_PTY_BRIDGE_TOKEN` or `CRABBOX_CLOUDFLARE_RUNNER_TOKEN` is set, Crabfleet sends it as a bearer token only to the upstream bridge/runner. The browser never receives upstream credentials. - ### POST /api/openclaw/action-sessions Internal OpenClaw service endpoint authenticated with `Authorization: Bearer CRABBOX_OPENCLAW_TOKEN`. Registers or resumes one durable `github_actions` session per `workKey`. Re-registration returns the same logical session and rotates its scoped agent token. @@ -425,7 +422,7 @@ Fields: If `CRABBOX_RUNTIME_ADAPTER_URL` or `CRABBOX_RUNTIME_ADAPTER_URL_TEMPLATE` is configured, the Worker creates and reconciles the versioned adapter workspace and records its resolved lifecycle identity, status, capabilities, expiry, and terminal connection. Otherwise `CRABBOX_INTERACTIVE_PROVISION_URL` retains the legacy create-only behavior. Without an adapter the session is stored as `pending_adapter`. -Session responses include `ptyAvailable`, the authenticated Worker's authoritative answer for whether the current terminal capability, lifecycle state, and configured Sandbox/bridge/runner route can resolve a PTY connection. A controllable `runtime-v1` session exposes only the Worker-owned `/api/interactive-sessions/:id/pty` route in `attachUrl`; the signed provider connection remains server-side even for owners and controllers. +Session responses include `ptyAvailable`, the authenticated Worker's authoritative answer for whether the current terminal capability, lifecycle state, and configured Sandbox/bridge/runner route can resolve a PTY connection. Every controllable session exposes only the Worker-owned `/api/terminal/ws` route in `attachUrl`; signed provider connections remain server-side even for owners and controllers. When the selected runtime profile configures `codexSsh`, a ready `runtime-v1` session response may include `codexSsh: { alias, setupCommand }` for session managers. The alias and optional command are resolved from bounded `{providerResourceId}`, `{workspaceId}`, `{sessionId}`, and `{profile}` placeholders. Alias components use a strict OpenSSH-safe character set. `codexSsh.setupCommand` is an argv-like array whose first and static items use a shell-safe character set and whose dynamic items must each be one complete placeholder; Crabfleet POSIX-shell-quotes every substituted argument so opaque provider identifiers remain data. Missing values, an unsafe resolved alias, or a current profile route that differs from the workspace's immutable registered adapter control plane suppresses the handoff. Shared links and delegated terminal-only controllers never receive it. The command is display/copy data only; Crabfleet never executes it. @@ -522,7 +519,8 @@ CRABBOX_SSH_GATEWAY_TOKEN`. These endpoints are not browser APIs. - `GET /api/ssh/interactive-sessions/:id/checkpoints`: lists Cloudflare Sandbox checkpoints. - `POST /api/ssh/interactive-sessions/:id/checkpoints`: creates a Cloudflare Sandbox checkpoint. - `POST /api/ssh/interactive-sessions/:id/checkpoints/:checkpoint/restore`: restores a checkpoint. -- `GET /api/ssh/interactive-sessions/:id/pty`: WebSocket PTY attach for the gateway, scoped by linked key fingerprint. + +PTY attach and message commands use `/api/terminal/ws` with the gateway bearer and linked-key fingerprint headers. ## Agent Session API @@ -534,7 +532,8 @@ Crabfleet-issued session agents use `Authorization: Bearer 0 { + if writeErr := c.SendInput(ctx, buffer[:count]); writeErr != nil { + errCh <- writeErr + return + } + } + if err != nil { + if errors.Is(err, io.EOF) { + errCh <- nil + } else { + errCh <- err + } + return + } + } + }() + go func() { + for { + current, err := c.read(ctx) + if err != nil { + errCh <- err + return + } + if current.sessionID != "" && current.sessionID != c.sessionID { + continue + } + switch current.messageType { + case messageOutput: + if _, err := terminal.Write(current.payload); err != nil { + errCh <- err + return + } + if err := c.write(ctx, frame{ + messageType: messageAck, + sessionID: c.sessionID, + payload: ackPayload(uint32(len(current.payload))), + }); err != nil { + errCh <- err + return + } + case messageError, messageControlRevoked: + errCh <- frameError(current, "terminal connection failed") + return + case messageEvent: + var event eventPayload + if json.Unmarshal(current.payload, &event) == nil && event.Type == "closed" { + errCh <- nil + return + } + } + } + }() + + err := <-errCh + cancel() + return normalizeCloseError(err) +} + +func (c *Client) write(ctx context.Context, current frame) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return c.conn.Write(ctx, websocket.MessageBinary, encodeFrame(current)) +} + +func (c *Client) read(ctx context.Context) (frame, error) { + messageType, payload, err := c.conn.Read(ctx) + if err != nil { + return frame{}, normalizeCloseError(err) + } + if messageType != websocket.MessageBinary { + return frame{}, errors.New("terminal server sent a non-binary frame") + } + return decodeFrame(payload) +} + +func encodeFrame(current frame) []byte { + sessionID := []byte(current.sessionID) + payload := make([]byte, 12+len(sessionID)+len(current.payload)) + binary.LittleEndian.PutUint16(payload[0:2], magic) + payload[2] = version + payload[3] = current.messageType + binary.LittleEndian.PutUint32(payload[4:8], uint32(len(sessionID))) + copy(payload[8:], sessionID) + offset := 8 + len(sessionID) + binary.LittleEndian.PutUint32(payload[offset:offset+4], uint32(len(current.payload))) + copy(payload[offset+4:], current.payload) + return payload +} + +func decodeFrame(payload []byte) (frame, error) { + if len(payload) < 12 || binary.LittleEndian.Uint16(payload[0:2]) != magic { + return frame{}, errors.New("invalid terminal frame") + } + if payload[2] != version { + return frame{}, fmt.Errorf("unsupported terminal protocol version %d", payload[2]) + } + sessionLength := uint64(binary.LittleEndian.Uint32(payload[4:8])) + if sessionLength > uint64(len(payload)-12) { + return frame{}, errors.New("invalid terminal session id length") + } + payloadLengthOffset := 8 + int(sessionLength) + bodyLength := uint64(binary.LittleEndian.Uint32(payload[payloadLengthOffset : payloadLengthOffset+4])) + bodyOffset := payloadLengthOffset + 4 + if bodyLength != uint64(len(payload)-bodyOffset) { + return frame{}, errors.New("invalid terminal payload length") + } + return frame{ + messageType: payload[3], + sessionID: string(payload[8:payloadLengthOffset]), + payload: payload[bodyOffset:], + }, nil +} + +func subscribePayload(cols uint32, rows uint32) []byte { + payload := make([]byte, 20) + binary.LittleEndian.PutUint32( + payload[0:4], + subscribeOutput|subscribeEvents|subscribeOutputAcknowledgements, + ) + binary.LittleEndian.PutUint32(payload[12:16], cols) + binary.LittleEndian.PutUint32(payload[16:20], rows) + return payload +} + +func ackPayload(bytes uint32) []byte { + payload := make([]byte, 4) + binary.LittleEndian.PutUint32(payload, bytes) + return payload +} + +func frameError(current frame, fallback string) error { + var event eventPayload + if json.Unmarshal(current.payload, &event) == nil { + if event.Error != "" { + return errors.New(event.Error) + } + if event.Reason != "" { + return errors.New(event.Reason) + } + } + return errors.New(fallback) +} + +func normalizeCloseError(err error) error { + if err == nil { + return nil + } + var closeError websocket.CloseError + if errors.As(err, &closeError) && + (closeError.Code == websocket.StatusNormalClosure || closeError.Code == websocket.StatusGoingAway) { + return nil + } + return err +} diff --git a/internal/terminalws/client_test.go b/internal/terminalws/client_test.go new file mode 100644 index 0000000..10f6dd3 --- /dev/null +++ b/internal/terminalws/client_test.go @@ -0,0 +1,168 @@ +package terminalws + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/coder/websocket" +) + +func TestEndpointUsesTerminalHub(t *testing.T) { + got, err := Endpoint("https://fleet.example/base?ignored=1") + if err != nil { + t.Fatal(err) + } + if got != "wss://fleet.example/api/terminal/ws" { + t.Fatalf("endpoint = %q", got) + } +} + +func TestClientSubscribesSendsInputAndAcknowledgesOutput(t *testing.T) { + receivedInput := make(chan []byte, 1) + acknowledged := make(chan uint32, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/terminal/ws" { + t.Errorf("path = %q", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer token" { + t.Errorf("authorization = %q", r.Header.Get("Authorization")) + } + conn, err := websocket.Accept(w, r, nil) + if err != nil { + t.Error(err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + + _, helloPayload, err := conn.Read(r.Context()) + if err != nil { + t.Error(err) + return + } + hello, err := decodeFrame(helloPayload) + if err != nil { + t.Error(err) + return + } + if hello.messageType != messageHello { + t.Errorf("hello type = %d", hello.messageType) + } + + _, subscribePayload, err := conn.Read(r.Context()) + if err != nil { + t.Error(err) + return + } + subscribe, err := decodeFrame(subscribePayload) + if err != nil { + t.Error(err) + return + } + if subscribe.messageType != messageSubscribe || subscribe.sessionID != "IS-1" { + t.Errorf("subscribe = %#v", subscribe) + } + flags := binary.LittleEndian.Uint32(subscribe.payload[0:4]) + if flags&subscribeOutputAcknowledgements == 0 { + t.Errorf("subscribe flags = %d", flags) + } + + event, _ := json.Marshal(eventPayload{Type: "subscribed", CanInput: true}) + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageEvent, + sessionID: "IS-1", + payload: event, + })); err != nil { + t.Error(err) + return + } + + _, inputPayload, err := conn.Read(r.Context()) + if err != nil { + t.Error(err) + return + } + input, err := decodeFrame(inputPayload) + if err != nil { + t.Error(err) + return + } + receivedInput <- append([]byte(nil), input.payload...) + + if err := conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageOutput, + sessionID: "IS-1", + payload: []byte("ready\n"), + })); err != nil { + t.Error(err) + return + } + _, ackPayload, err := conn.Read(r.Context()) + if err != nil { + t.Error(err) + return + } + ack, err := decodeFrame(ackPayload) + if err != nil { + t.Error(err) + return + } + acknowledged <- binary.LittleEndian.Uint32(ack.payload) + closed, _ := json.Marshal(eventPayload{Type: "closed"}) + _ = conn.Write(r.Context(), websocket.MessageBinary, encodeFrame(frame{ + messageType: messageEvent, + sessionID: "IS-1", + payload: closed, + })) + })) + defer server.Close() + + endpoint, err := Endpoint(server.URL) + if err != nil { + t.Fatal(err) + } + headers := http.Header{"Authorization": []string{"Bearer token"}} + client, err := Dial(context.Background(), endpoint, "IS-1", Options{ + Header: headers, + Cols: 120, + Rows: 34, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + inputReader, inputWriter := io.Pipe() + defer inputReader.Close() + defer inputWriter.Close() + terminal := &readWriter{reader: inputReader} + go func() { + _, _ = inputWriter.Write([]byte("hello\n")) + }() + if err := client.Attach(context.Background(), terminal); err != nil { + t.Fatal(err) + } + if input := <-receivedInput; string(input) != "hello\n" { + t.Fatalf("input = %q", input) + } + if terminal.String() != "ready\n" { + t.Fatalf("output = %q", terminal.String()) + } + if bytes := <-acknowledged; bytes != uint32(len("ready\n")) { + t.Fatalf("acknowledged = %d", bytes) + } +} + +type readWriter struct { + reader io.Reader + bytes.Buffer +} + +func (rw *readWriter) Read(payload []byte) (int, error) { + return rw.reader.Read(payload) +} diff --git a/src/index.ts b/src/index.ts index 6f7f9c0..0104a6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2373,32 +2373,6 @@ async function api( ); } - const sshInteractivePtyMatch = url.pathname.match( - /^\/api\/ssh\/interactive-sessions\/([^/]+)\/pty$/, - ); - if (request.method === "GET" && sshInteractivePtyMatch) { - const user = await requireSshGatewayUser(request, env); - return interactiveSessionPty( - request, - env, - user, - decodeURIComponent(sshInteractivePtyMatch[1] ?? ""), - ); - } - - const agentInteractivePtyMatch = url.pathname.match( - /^\/api\/agent\/interactive-sessions\/([^/]+)\/pty$/, - ); - if (request.method === "GET" && agentInteractivePtyMatch) { - const { user } = await requireAgentSession(request, env); - return interactiveSessionPty( - request, - env, - user, - decodeURIComponent(agentInteractivePtyMatch[1] ?? ""), - ); - } - const sharedSessionMatch = url.pathname.match(/^\/api\/shared-sessions\/([^/]+)$/); if (request.method === "GET" && sharedSessionMatch) { return json( @@ -2411,7 +2385,7 @@ async function api( } if (request.method === "GET" && url.pathname === "/api/terminal/ws") { - return interactiveTerminalHub(request, env, await optionalUser(request, env, requestAuth)); + return interactiveTerminalHub(request, env, await terminalHubUser(request, env, requestAuth)); } const user = await requireUser(request, env, requestAuth); @@ -2568,17 +2542,6 @@ async function api( ); } - const interactivePtyMatch = url.pathname.match(/^\/api\/interactive-sessions\/([^/]+)\/pty$/); - if (request.method === "GET" && interactivePtyMatch) { - requireRole(user, "viewer"); - return interactiveSessionPty( - request, - env, - user, - decodeURIComponent(interactivePtyMatch[1] ?? ""), - ); - } - const interactiveClipboardMatch = url.pathname.match( /^\/api\/interactive-sessions\/([^/]+)\/clipboard$/, ); @@ -2655,6 +2618,17 @@ async function api( function usesIndependentServiceAuth(request: Request): boolean { const pathname = new URL(request.url).pathname; + if (pathname === "/api/terminal/ws") { + const headers = request.headers; + const hasAuthorization = Boolean(headers.get("authorization")); + const hasSshIdentity = Boolean( + headers.get("x-crabfleet-ssh-fingerprint") || headers.get("x-crabbox-ssh-fingerprint"), + ); + const hasAgentIdentity = Boolean( + headers.get("x-crabfleet-session-id") || headers.get("x-crabbox-session-id"), + ); + return hasAuthorization && (hasSshIdentity || hasAgentIdentity); + } return ["/api/ssh/", "/api/agent/", "/api/openclaw/", "/api/provision/"].some((prefix) => pathname.startsWith(prefix), ); @@ -3054,6 +3028,20 @@ async function optionalUser( } } +async function terminalHubUser( + request: Request, + env: RuntimeEnv, + requestAuth: TrustedProxyAuthResult, +): Promise { + if (isSshGatewayRequest(request, env)) { + return requireSshGatewayUser(request, env); + } + if (agentSessionId(request)) { + return (await requireAgentSession(request, env)).user; + } + return optionalUser(request, env, requestAuth); +} + async function requireTrustedProxyUser( env: RuntimeEnv, identity: TrustedProxyIdentity, @@ -8803,190 +8791,6 @@ function parseTerminalControlMessage(data: string): Record | nu } } -async function interactiveSessionPty( - request: Request, - env: RuntimeEnv, - user: User, - id: string, -): Promise { - if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") { - throw badRequest("websocket upgrade required"); - } - - const session = await readFreshInteractiveSession(env, id); - if (!session) throw notFound("interactive session not found"); - if (["stopping", "expired", "failed", "stopped"].includes(session.status)) { - throw badRequest(`session is ${session.status}`); - } - if (!session.capabilities.terminal) { - throw badRequest("session does not advertise terminal access"); - } - if ( - !canControlInteractiveSession(user, session, Date.now(), canGrantDelegatedControl(env, session)) - ) { - throw forbidden("terminal control has not been granted"); - } - if (session.runtime === githubActionsRuntime) { - const upstreamConnection = await openInteractiveTerminalUpstream( - request, - env, - user, - session, - terminalSize(request, "cols", 120), - terminalSize(request, "rows", 34), - ); - const pair = new WebSocketPair(); - const client = pair[0]; - const server = pair[1]; - server.accept(); - bridgeWebSockets( - server, - upstreamConnection.socket, - terminalInputGrant(env, user, session), - terminalSubscriptionReconciler(env, id), - ); - await upstreamConnection.markConnected(); - return new Response(null, { status: 101, webSocket: client }); - } - const routeKind = interactivePtyRouteKind(env, session); - if (routeKind === "sandbox" && env.SANDBOX) { - return interactiveSandboxTerminal( - request, - env, - user, - session, - terminalInputGrant(env, user, session), - terminalSubscriptionReconciler(env, id), - ); - } - - const target = interactiveTerminalTarget(env, session, routeKind); - if (!target) throw serviceUnavailable("PTY bridge is not configured for this session"); - const upstreamOutputAcknowledgements = terminalOutputAcknowledgements(target.url); - const downstreamOutputAcknowledgements = terminalOutputAcknowledgements(request.url); - const targetUrl = sizedTerminalTargetUrl( - target.url, - routeKind, - terminalSize(request, "cols", 120), - terminalSize(request, "rows", 34), - ); - if (!targetUrl) throw serviceUnavailable("PTY bridge URL is invalid"); - - const pair = new WebSocketPair(); - const client = pair[0]; - const server = pair[1]; - let upstreamResponse: Response; - try { - upstreamResponse = await interactiveTerminalFetch( - env, - session, - targetUrl, - interactiveTerminalHeaders(session, target.authorization), - ); - } catch (error) { - server.accept(); - server.close( - 1011, - clean( - redactedAdapterMessage( - `PTY bridge failed: ${String(error)}`, - "failed", - [session.adapterWorkspaceId, session.providerResourceId], - [target.url, session.attachUrl], - ), - 120, - ), - ); - return new Response(null, { status: 101, webSocket: client }); - } - const upstream = upstreamResponse.webSocket; - if (!upstream || upstreamResponse.status !== 101) { - server.accept(); - server.close(1011, `PTY bridge HTTP ${upstreamResponse.status}`); - return new Response(null, { status: 101, webSocket: client }); - } - - server.accept(); - upstream.accept(); - bridgeWebSockets( - server, - upstream, - terminalInputGrant(env, user, session), - terminalSubscriptionReconciler(env, id), - "terminal control revoked", - upstreamOutputAcknowledgements && downstreamOutputAcknowledgements, - upstreamOutputAcknowledgements && !downstreamOutputAcknowledgements, - ); - - const now = Date.now(); - await database(env) - .updateTable("interactive_sessions") - .set({ - status: - session.status === "ready" || session.status === "detached" ? "attached" : session.status, - last_seen_at: now, - updated_at: sql`MAX(updated_at + 1, ${now})`, - last_event: "PTY terminal connected", - }) - .where("id", "=", id) - .where("status", "in", ["ready", "attached", "detached"]) - .execute(); - await appendInteractiveSessionEvent(env, id, user, "PTY terminal connected", now); - - return new Response(null, { status: 101, webSocket: client }); -} - -async function interactiveSandboxTerminal( - request: Request, - env: RuntimeEnv, - user: User, - session: InteractiveSession, - canSendLeft?: () => Promise, - reconcileSubscription?: () => void, -): Promise { - if (!env.SANDBOX) throw serviceUnavailable("Sandbox binding is not configured"); - const runtimeSession = await sandboxSessionWithGitHubToken(request, env, user, session); - const sandboxSession = await ensureCurrentSandboxLease(request, env, user, runtimeSession); - const lease = sandboxLeaseInfo(sandboxSession); - const sandbox = getSandbox(env.SANDBOX, lease.sandboxId); - const upstreamResponse = await openSandboxTerminalResponse( - request, - env, - sandbox, - sandboxSession, - { - cols: terminalSize(request, "cols", 120), - rows: terminalSize(request, "rows", 34), - }, - ); - const upstream = upstreamResponse.webSocket; - if (!upstream || upstreamResponse.status !== 101) { - await markInteractiveTerminalUnavailable( - env, - user, - sandboxSession.id, - Date.now(), - `terminal unavailable: Cloudflare Sandbox terminal HTTP ${upstreamResponse.status}`, - ); - return upstreamResponse; - } - - const pair = new WebSocketPair(); - const client = pair[0]; - const server = pair[1]; - server.accept(); - upstream.accept(); - await markInteractiveTerminalConnected( - env, - user, - sandboxSession.id, - Date.now(), - "Cloudflare Sandbox terminal connected", - ); - bridgeWebSockets(server, upstream, canSendLeft, reconcileSubscription); - return new Response(null, { status: 101, webSocket: client }); -} - async function readInteractiveSessionDiagnostics( env: RuntimeEnv, user: User, @@ -10806,7 +10610,7 @@ async function provisionWithSandbox( attachUrl: "provisionId" in ownershipFence ? standaloneSandboxAttachUrl(env, session.id) - : `/api/interactive-sessions/${encodeURIComponent(session.id)}/pty`, + : "/api/terminal/ws", vncUrl: null, message: `Cloudflare Sandbox ready for ${session.repo}`, }; @@ -15973,15 +15777,7 @@ function decorateInteractiveSession( session.capabilities.terminal && ["ready", "attached", "detached"].includes(session.status) && routeAvailable; - const proxyManagedTerminal = - session.runtime === githubActionsRuntime || session.adapter === runtimeAdapterName; - const attachUrl = proxyManagedTerminal - ? ptyAvailable - ? `/api/interactive-sessions/${encodeURIComponent(session.id)}/pty` - : null - : canControl && session.capabilities.terminal - ? session.attachUrl - : null; + const attachUrl = ptyAvailable ? "/api/terminal/ws" : null; const codexSshReady = session.adapter === runtimeAdapterName && session.capabilities.terminal && diff --git a/tests/fleet-state.test.ts b/tests/fleet-state.test.ts index 208745b..7db3d69 100644 --- a/tests/fleet-state.test.ts +++ b/tests/fleet-state.test.ts @@ -15,7 +15,7 @@ const baseSession = { summary: "tracking fleet visibility", status: "ready" as const, leaseId: "sandbox:crabbox-s1-abcd1234:terminal-s1-abcd1234:autostart-v4", - attachUrl: "/api/interactive-sessions/s1/pty", + attachUrl: "/api/terminal/ws", vncUrl: null, lastEvent: "Cloudflare Sandbox ready", createdAt: 10, diff --git a/tests/runtime-adapter.test.ts b/tests/runtime-adapter.test.ts index 20958c5..1423fd4 100644 --- a/tests/runtime-adapter.test.ts +++ b/tests/runtime-adapter.test.ts @@ -684,7 +684,10 @@ test("runtime reconciliation has scheduled and targeted lifecycle clocks", async reconcileSource.indexOf("inspectRuntimeAdapterWorkspace"), ); assert.match(source, /async function readFreshInteractiveSession/); - assert.match(source, /async function interactiveSessionPty[\s\S]*readFreshInteractiveSession/); + assert.match( + source, + /async function subscribeTerminalHubSession[\s\S]*readFreshInteractiveSession/, + ); assert.match(source, /async function interactiveSessionVnc[\s\S]*readFreshInteractiveSession/); assert.match(source, /scheduled interactive session reconciliation failed/); assert.match( @@ -1737,9 +1740,6 @@ test("terminal endpoints enforce current runtime capabilities", async () => { decorateStart, ); const decorateSource = source.slice(decorateStart, decorateEnd); - const directPtyStart = source.indexOf("async function interactiveSessionPty"); - const directPtyEnd = source.indexOf("async function interactiveSandboxTerminal", directPtyStart); - const directPtySource = source.slice(directPtyStart, directPtyEnd); assert.match(source, /type InteractiveSession = \{[\s\S]*ptyAvailable\?: boolean;/); assert.match(source, /if \(!session\.capabilities\.terminal\)/); @@ -1749,21 +1749,18 @@ test("terminal endpoints enforce current runtime capabilities", async () => { assert.match(decorateSource, /const routeKind = interactivePtyRouteKind\(env, session\)/); assert.match(decorateSource, /interactiveTerminalTarget\(env, session, routeKind\)/); assert.match(decorateSource, /routeAvailable/); - assert.match( - decorateSource, - /const proxyManagedTerminal =[\s\S]*session\.runtime === githubActionsRuntime \|\|[\s\S]*session\.adapter === runtimeAdapterName/, - ); - assert.match( - decorateSource, - /const attachUrl = proxyManagedTerminal[\s\S]*\? ptyAvailable[\s\S]*`\/api\/interactive-sessions\/\$\{encodeURIComponent\(session\.id\)\}\/pty`[\s\S]*: null/, - ); - assert.match(directPtySource, /session\.runtime === githubActionsRuntime/); - assert.match(directPtySource, /openInteractiveTerminalUpstream\(/); + assert.match(decorateSource, /const attachUrl = ptyAvailable \? "\/api\/terminal\/ws" : null/); assert.match(decorateSource, /attachUrl,/); assert.doesNotMatch( decorateSource, /attachUrl: canControl && session\.capabilities\.terminal \? session\.attachUrl : null/, ); + assert.doesNotMatch(source, /async function interactiveSessionPty/); + assert.doesNotMatch(source, /\/api\/(?:ssh\/|agent\/)?interactive-sessions\/\(\[\^\/\]\+\)\/pty/); + assert.match( + source, + /async function terminalHubUser[\s\S]*isSshGatewayRequest[\s\S]*requireSshGatewayUser[\s\S]*agentSessionId[\s\S]*requireAgentSession/, + ); }); test("non-retryable adapter client errors do not enter ambiguous replay", () => { @@ -2160,15 +2157,11 @@ test("runtime adapter terminal upgrades use the coordinator service binding", as const openStart = source.indexOf("async function openInteractiveTerminalUpstream"); const openEnd = source.indexOf("async function markInteractiveTerminalConnected", openStart); const openSource = source.slice(openStart, openEnd); - const legacyStart = source.indexOf("async function interactiveSessionPty"); - const legacyEnd = source.indexOf("async function interactiveSandboxTerminal", legacyStart); - const legacySource = source.slice(legacyStart, legacyEnd); const fetchStart = source.indexOf("async function interactiveTerminalFetch"); const fetchEnd = source.indexOf("async function runtimeAdapterFetch", fetchStart); const fetchSource = source.slice(fetchStart, fetchEnd); assert.match(openSource, /interactiveTerminalFetch\(/); - assert.match(legacySource, /interactiveTerminalFetch\(/); assert.match(fetchSource, /session\.adapter === runtimeAdapterName/); assert.match(fetchSource, /runtimeAdapterFetcher\(env, target\)/); assert.match(fetchSource, /fetchTarget\.protocol === "wss:"/); diff --git a/tests/terminal-target.test.ts b/tests/terminal-target.test.ts index 389c6e5..b57b6df 100644 --- a/tests/terminal-target.test.ts +++ b/tests/terminal-target.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { sizedTerminalTargetUrl } from "../src/terminal-target.ts"; -test("opaque direct terminal URLs pass through the multiplex hub unchanged", async () => { +test("opaque provider terminal URLs pass through the multiplex hub unchanged", async () => { const signed = "wss://controller.example/v1/pty?signature=a%2Bb%2Fc%3D&cols=provider-owned&opaque=1"; assert.equal(sizedTerminalTargetUrl(signed, "attach", 120, 34), signed); @@ -20,19 +20,7 @@ test("opaque direct terminal URLs pass through the multiplex hub unchanged", asy /interactiveTerminalFetch\(\s*env,\s*session,\s*sizedTerminalTargetUrl\(target\.url, routeKind, cols, rows\)/, ); assert.doesNotMatch(upstreamSource, /addQuery\(target\.url/); - - const directStart = source.indexOf("async function interactiveSessionPty"); - const directEnd = source.indexOf("function sendTerminalJson", directStart); - const directSource = source.slice(directStart, directEnd); - assert.match(directSource, /const targetUrl = sizedTerminalTargetUrl\(/); - assert.match(directSource, /const targetUrl = sizedTerminalTargetUrl\(\s*target\.url,/); - assert.match(directSource, /terminalSize\(request, "cols", 120\)/); - assert.match(directSource, /terminalSize\(request, "rows", 34\)/); - assert.match( - directSource, - /upstreamResponse = await interactiveTerminalFetch\(\s*env,\s*session,\s*targetUrl,/, - ); - assert.doesNotMatch(directSource, /interactiveTerminalFetch\([^)]*target\.url/); + assert.doesNotMatch(source, /async function interactiveSessionPty/); }); test("known bridge and runner targets receive terminal dimensions", () => { diff --git a/tests/trusted-proxy-integration.test.ts b/tests/trusted-proxy-integration.test.ts index ed3a7d8..3aafd15 100644 --- a/tests/trusted-proxy-integration.test.ts +++ b/tests/trusted-proxy-integration.test.ts @@ -77,6 +77,8 @@ test("service-token routes bypass only the mandatory proxy assertion", async () for (const prefix of ["/api/ssh/", "/api/agent/", "/api/openclaw/", "/api/provision/"]) { assert.match(serviceSource, new RegExp(prefix.replaceAll("/", "\\/"))); } + assert.match(serviceSource, /pathname === "\/api\/terminal\/ws"/); + assert.match(serviceSource, /hasAuthorization && \(hasSshIdentity \|\| hasAgentIdentity\)/); assert.match(source, /requestAuth\.kind === "missing"\) throw unauthorized\(\)/); assert.match(source, /trustedProxy\.kind === "rejected"/); assert.match( From e324766368baeb585b655a4a4fd03b14c879b491 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 15 Jun 2026 09:17:59 +0100 Subject: [PATCH 2/2] build: include terminal client in gateway image --- .dockerignore | 3 +++ Dockerfile.ssh-gateway | 1 + 2 files changed, 4 insertions(+) diff --git a/.dockerignore b/.dockerignore index 172cbf3..fb6923e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,6 @@ !go.sum !cmd/ !cmd/** +!internal/ +!internal/terminalws/ +!internal/terminalws/** diff --git a/Dockerfile.ssh-gateway b/Dockerfile.ssh-gateway index fb17848..1899fd5 100644 --- a/Dockerfile.ssh-gateway +++ b/Dockerfile.ssh-gateway @@ -3,6 +3,7 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY cmd ./cmd +COPY internal/terminalws ./internal/terminalws RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/crabbox-ssh-gateway ./cmd/crabbox-ssh-gateway FROM alpine:3.22