Skip to content

feat(sandbox): shared browser-direct terminal connection vertical (#349) - #352

Merged
vutuanlinh2k2 merged 4 commits into
mainfrom
feat/349-browser-direct-terminal-connection
Jul 30, 2026
Merged

feat(sandbox): shared browser-direct terminal connection vertical (#349)#352
vutuanlinh2k2 merged 4 commits into
mainfrom
feat/349-browser-direct-terminal-connection

Conversation

@vutuanlinh2k2

@vutuanlinh2k2 vutuanlinh2k2 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #349. Phase 2 keystone of epic #343 (decision #341: browser-direct scoped-token transport).

What

  • createSandboxTerminalConnectionRoute (src/sandbox/terminal-connection.ts, new, on the existing /sandbox subpath) — the shared browser-direct terminal connection vertical, generalized behind two product seams (requireUser, ensureSandbox — workspace- vs user-scoped provisioning stays product domain), matching the SDK's own _attachTerminal reference implementation:
    • Scope is pinned session-runtime — the only scope the platform grants the terminal capability to (cap:["read","workspace","terminal"]). project/session mint gateway-read tokens (HS256, no cap claim — a different token family the sidecar rejects outright); read-only carries cap:["read"] without terminal. Scope is therefore not a parameter. ttlMinutes stays one (default 15, the SDK clamp max).
    • connectionId threads end to end — the orchestrator's terminal WS gate fails closed unless the token's sid equals the <connectionId> segment of the dialed /terminals/<connectionId>/ws. The hook forwards the client's id (tabTerminalConnectionId()) as a query parameter, the route resolves it (resolveConnectionId seam for validation/namespacing; 400 when absent/empty), mints with sessionId: connectionId, and echoes it in the response — the client hands the echoed id to TerminalView verbatim.
    • Returns the mint's sidecarProxyUrl as sidecarUrl — the only browser-safe terminal base (/v1/sidecar-proxy/{id}; it is also the only hop that decodes TerminalView's bearer.<base64url> WS subprotocol). box.connection.runtimeUrl is demoted to a readiness gate (503 {error, status} while absent) and is never handed to the browser.
    • Other shapes: verbatim-401 from requireUser; 500 on provisioning throw; 503 on mint failure; 200 {sidecarUrl, token, expiresAt, status, sandboxId, connectionId}.
  • Client hookuseSandboxTerminalConnection gains an optional connectionId input (forwarded as the query param) and carries the response's echoed connectionId into state; poll-on-503 / pre-expiry refresh / reconnect guard untouched. Refresh re-mints bound to the same terminal id.
  • Proxy seam deprecated, nothing removed@deprecated JSDoc on the full same-origin proxy family (connection handler, runtime proxy, WS upgrade relay, HMAC token mint/verify, supporting helpers/types), each pointing at the replacement. createWorkspaceSandboxManager family stays undecorated (generic box lifecycle, used by both transports). Retirement tracked in [REFACTOR] Retire the deprecated terminal proxy seam after the fleet migrates (zero-importer audit) #350.
  • Docs/sandbox + /web-react module-map rows record the transport decision, the pinned-scope/sid-binding/sidecarProxyUrl contracts, and the security posture. docs/ regenerated via docs:gen.

Review round 2 — three P1s validated and fixed

An external review flagged that the initial shape (modeled on legal-agent's production route: scope:'project' + connection.runtimeUrl) produced a token for the wrong consumer, couldn't bind to TerminalView's connection id, and returned the wrong base URL. All three claims were verified against the platform enforcement source (orchestrator consumer-scoped-token.ts scope→capability mapping; sidecar-access.ts verifyScopedSidecarCapability sid check; sidecar packages/sidecar-auth route gates) and the SDK's own _attachTerminal, and all three are correct. Legal's shape predates the platform's terminal-auth hardening (2026-06-19 capability gate, 2026-07-15 scoped-token terminal WS path, 2026-07-17/18 sid binding) and no longer authenticates — it is not the spec for the token path. Fixed in 609a930.

Security posture

The terminal token grants command execution in the sandbox. Default TTL is 15 min (the SDK's clamp max) and stays a parameter; the scope is structurally the narrowest the platform offers — one box (ensureSandbox resolved it for this user), one terminal connection (sid-bound).

Tests — prove-it-can-fail evidence

47 targeted tests green (route factory + hook-over-factory integration, incl. 503 poll-through, pre-expiry rotation re-binding the same connectionId, resolveConnectionId rewrite, 400-no-connectionId). Six break→red→restore→green drills total; the three for the final contracts:

Drill A — un-pin the scope back to 'project'

RED:

 FAIL  tests/sandbox.test.ts > ... mints with {scope:"session-runtime", ttlMinutes:15, sessionId:<connectionId>}
AssertionError: expected "vi.fn()" to be called with arguments: [ { scope: 'session-runtime', …(2) } ]
-     "scope": "session-runtime",
+     "scope": "project",
 Test Files  1 failed (1)

Restored → GREEN (1 passed | 38 skipped).

Drill B — return box.connection.runtimeUrl instead of scoped.sidecarProxyUrl

RED:

AssertionError: expected 'https://runtime.example/box-1' to be 'https://api.example/v1/sidecar-proxy/…'
Expected: "https://api.example/v1/sidecar-proxy/box-1"
Received: "https://runtime.example/box-1"
 Test Files  1 failed (1)

Restored → GREEN (1 passed | 38 skipped).

Drill C — mint with a hardcoded sessionId instead of the resolved connectionId

RED (5 tests across both files — route mint-args AND both hook integration tests):

 FAIL  tests/sandbox-terminal-hook.test.ts > ... threading connectionId end to end
   - "sessionId": "tab-term-1",
   + "sessionId": "hardcoded-session-id",
 FAIL  tests/sandbox-terminal-hook.test.ts > ... re-binding the same terminal connectionId every mint

Restored → GREEN (47/47).

(Round-1 drills — missing-runtimeUrl 503 guard, default ttl, hook rotation timer — remain in the history; post-drill greps confirm full restore each time.)

Gates

  • npx vitest run tests/sandbox.test.ts tests/sandbox-terminal-hook.test.ts → 47/47
  • pnpm typecheck → clean
  • pnpm test → 3851 passed / 13 skipped; 7 failures in tests/knowledge-loop.test.ts + src/sandbox/index.test.ts are pre-existing local-env issues — verified identical (same 7) on clean origin/main via stash
  • NODE_OPTIONS=--max-old-space-size=8192 pnpm build → clean

Acceptance criteria

  • A product can build its terminal connection route from the factory with only auth + provisioning callbacks supplied
  • useSandboxTerminalConnection drives TerminalView against a direct sidecar URL end-to-end (fake-backed test), including the 503-provisioning poll and pre-expiry refresh
  • Every proxy-seam export carries @deprecated with a pointer to the replacement; nothing removed
  • Transport decision documented; new tests meet the prove-it-can-fail bar
  • pnpm typecheck && pnpm test && pnpm build green (modulo the 7 pre-existing env failures shown identical on baseline)

createSandboxTerminalConnectionRoute mints the SDK scoped token behind
injected requireUser/ensureSandbox seams and returns
{sidecarUrl, token, expiresAt, status, sandboxId}, 503+status while the
box is provisioning — legal-agent's production route generalized.
Session-shaped scopes without their session id(s) fail at
factory-creation time; resolver callbacks returning empty fail the
request. The unmodified useSandboxTerminalConnection hook drives the new
factory end-to-end (fake-backed integration tests incl. 503 poll-through
and pre-expiry token rotation).
@deprecated JSDoc on the proxy transport family (connection handler,
runtime proxy, WS upgrade relay, HMAC token mint/verify, and their
supporting helpers/types), each pointing at
createSandboxTerminalConnectionRoute. Nothing removed — three apps still
import the seam; retirement is tracked in #350 and removal is a major.
/sandbox and /web-react module-map rows updated: browser-direct
scoped-token is the fleet default, the proxy relay is deprecated, and
the security posture is explicit — the terminal token grants command
execution, so short TTL and narrow scope are load-bearing and stay
caller parameters. docs/ regenerated via docs:gen.
…onnection id (#349)

Three review-confirmed defects against the platform's terminal-auth
contracts (verified in the orchestrator/sidecar enforcement source and
the SDK's own _attachTerminal reference implementation):

- scope is now PINNED to 'session-runtime' — the only scope granted the
  'terminal' capability; 'project'/'session' are gateway-read tokens the
  sidecar rejects outright, so scope is no longer a parameter
- the terminal connectionId threads end to end: the hook forwards it as
  a query parameter, the route resolves it (resolveConnectionId seam,
  400 when absent), mints with sessionId=connectionId (the orchestrator
  fails closed on sid != the dialed /terminals/<id>/ws segment), and
  echoes it for TerminalView to use verbatim
- the response returns the mint's sidecarProxyUrl — the only browser-
  safe terminal base; connection.runtimeUrl is demoted to a readiness
  gate and never handed to the browser

The prior shape was modeled on legal-agent's production route, which
predates the platform's terminal-auth hardening (2026-06-19 capability
gate, 2026-07-15 scoped-token terminal WS path, 2026-07-17/18 sid
binding) and no longer authenticates.
@vutuanlinh2k2
vutuanlinh2k2 merged commit 45c255a into main Jul 30, 2026
1 check passed
@vutuanlinh2k2
vutuanlinh2k2 deleted the feat/349-browser-direct-terminal-connection branch July 30, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Shared browser-direct terminal connection vertical (scoped token, no worker relay)

1 participant