Skip to content

chore: Allow SSH terminal for completed-run sandboxes in Factory - #13782

Merged
anthonyshew merged 3 commits into
mainfrom
sandbox-ssh-completed-runs
Aug 22, 2026
Merged

anthonyshew merged 3 commits into
mainfrom
sandbox-ssh-completed-runs

Conversation

@anthonyshew

@anthonyshew anthonyshew commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Updates the Factory sandbox terminal so an operator can open a terminal for a sandbox even after its run has completed.

Rebased onto main to resolve conflicts. Since this branch was opened, #13779 (SSH command affordance) and #13780 (full-page terminal SSH sessions) landed independently and duplicated most of what this branch carried, so the shared implementation now comes from main and this branch is down to what is still unique to it.

Changes

  • Adds isSandboxSSHable() in agent/lib/sandbox-ssh.ts to decide when a sandbox is reachable for an interactive session (running or stopped), matching Vercel Sandbox's resume behavior.
  • SandboxCard: replaces disabled={sandbox.status === "failed"} with disabled={!isSandboxSSHable(sandbox.status)} plus an explanatory title. This is the substance of the PR — stopped sandboxes stay reachable, while provisioning/pending/aborted/stopping/snapshotting are now correctly disabled instead of only failed.
  • RunTicket: adds the Terminal button alongside the existing SSH copy command, enabled for running and stopped sandboxes, so a completed run's sandbox can still be opened. Kept behind main's provider !== "eve" guard, since the terminal is only reachable for Vercel sandboxes.
  • app/api/sandbox/terminal/route.ts: adds an origin + content-type guard to the terminal route, mirroring the pattern in app/api/harness/runs/route.ts. The route mints an interactive shell session, so it should not be reachable cross-origin.
  • app/sandbox-terminal.tsx: actually invokes the cleanup returned by the setup promise (cleanupPromise.then((cleanup) => cleanup?.())) instead of discarding it with void cleanupPromise, which left the socket and terminal teardown unrun.
  • Adds tests for isSandboxSSHable.

Validation

  • pnpm test in apps/factory — 66 passing
  • pnpm exec tsc --noEmit — clean
  • pnpm exec oxlint --deny-warnings . and pnpm exec oxfmt --check — clean
  • next build — succeeds
  • pnpm install --frozen-lockfile — lockfile up to date

@anthonyshew
anthonyshew requested review from a team and tknickman August 20, 2026 00:04
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
examples-basic-web Ready Ready Preview, v0 Aug 22, 2026 1:42pm
examples-designsystem-docs Ready Ready Preview, v0 Aug 22, 2026 1:42pm
examples-gatsby-web Ready Ready Preview, v0 Aug 22, 2026 1:42pm
examples-kitchensink-blog Ready Ready Preview, v0 Aug 22, 2026 1:42pm
examples-nonmonorepo Ready Ready Preview, v0 Aug 22, 2026 1:42pm
examples-svelte-web Ready Ready Preview, v0 Aug 22, 2026 1:42pm
examples-tailwind-web Ready Ready Preview, v0 Aug 22, 2026 1:42pm
examples-vite-web Ready Ready Preview, v0 Aug 22, 2026 1:42pm
turbo-site Ready Ready Preview, v0 Aug 22, 2026 1:42pm
turborepo-factory Ready Ready Preview, v0 Aug 22, 2026 1:42pm

Comment thread apps/factory/app/api/sandbox/terminal/route.ts
Comment thread apps/factory/app/sandbox-terminal.tsx Outdated
vercel Bot and others added 3 commits August 22, 2026 13:39
- Merge the prior SSH command affordance and full-page xterm.js terminal work so the Factory UI can open interactive sessions for sandboxes.
- Add isSandboxSSHable() helper that treats running and stopped sandboxes as reachable, matching Vercel Sandbox's resume behavior.
- Use the helper in RunTicket and SandboxCard so the Terminal button is available for completed runs, not just running ones.
- Disable the Terminal button for genuinely unavailable sandboxes (failed, aborted, provisioning, pending, stopping, snapshotting) with an explanatory title.
- Add tests for isSandboxSSHable.

Co-Authored-By: Anthony Shew <35677084+anthonyshew@users.noreply.github.com>
…nteractive shell session (returning a WebSocket URL + token) without any origin/CSRF protection, unlike every other state-changing POST in the app.

This commit fixes the issue reported at apps/factory/app/api/sandbox/terminal/route.ts:5

## Bug

`apps/factory/app/api/sandbox/terminal/route.ts` forwarded the request directly to `handleTerminalRequest(request)` with no request-origin or content-type validation:

```ts
export async function POST(request: Request): Promise<Response> {
  return handleTerminalRequest(request);
}
```

`handleTerminalRequest` (in `agent/lib/sandbox-terminal.ts`) only validates the JSON body shape and that `sandboxName` starts with `ai-sdk-harness`. It performs **no** origin check.

### Why this is a problem

This endpoint is highly privileged: on success it calls `Sandbox.openInteractive()` and returns a live WebSocket `url` + `token` that grants **interactive shell access** to the sandbox. Because it lacks any CSRF/origin guard:

- **Concrete trigger:** A logged-in operator visiting a malicious page. The attacker page issues `fetch("https://<factory-host>/api/sandbox/terminal", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sandboxName: "ai-sdk-harness-..." }) })`. The browser attaches the operator's cookies (credentials for same-site requests) and the request succeeds, minting an interactive terminal session against a harness sandbox.
- This deviates from the established pattern used everywhere else. For example `apps/factory/app/api/harness/runs/route.ts` (lines 11–22) rejects any request unless `origin === same-origin`, `content-type === application/json`, and a custom `x-operator-action` header is present.

## Fix

Added an origin + content-type guard to the terminal route, mirroring the existing harness-route pattern:

```ts
export async function POST(request: Request): Promise<Response> {
  if (
    request.headers.get("origin") !== new URL(request.url).origin ||
    request.headers.get("content-type")?.split(";", 1)[0] !==
      "application/json"
  )
    return Response.json(
      { error: "Invalid terminal request." },
      { status: 403 }
    );

  return handleTerminalRequest(request);
}
```

### Why this preserves the legitimate flow

The only caller, `apps/factory/app/sandbox-terminal.tsx`, performs a **same-origin** `fetch("/api/sandbox/terminal", ...)` with `content-type: application/json`. Browsers automatically attach an `Origin` header to non-GET `fetch` requests, and for a same-origin request that value equals the request's own origin — so the guard passes for the real UI while rejecting cross-origin/forged requests.

I deliberately did **not** require the `x-operator-action` header (unlike the harness route) because the frontend caller does not send it; adding it would break the legitimate flow. The origin + content-type checks are the appropriate consistent subset here.

### Placement note

The guard was intentionally placed in `route.ts` rather than inside `handleTerminalRequest`, so the existing unit tests in `tests/sandbox-terminal.test.mjs` (which construct `Request`s without origin/content-type headers) continue to exercise the core logic unchanged. (Those tests currently fail only due to a pre-existing missing `@vercel/sandbox` dependency in this environment, unrelated to this change.)

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: anthonyshew <anthonyshew@gmail.com>

Co-Authored-By: Anthony Shew <35677084+anthonyshew@users.noreply.github.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>

Co-Authored-By: Anthony Shew <35677084+anthonyshew@users.noreply.github.com>
@anthonyshew
anthonyshew force-pushed the sandbox-ssh-completed-runs branch from 0369a05 to 023b595 Compare August 22, 2026 13:40
@anthonyshew anthonyshew changed the title Allow SSH terminal for completed-run sandboxes in Factory fix: Allow SSH terminal for completed-run sandboxes in Factory Aug 22, 2026
@anthonyshew anthonyshew changed the title fix: Allow SSH terminal for completed-run sandboxes in Factory chore: Allow SSH terminal for completed-run sandboxes in Factory Aug 22, 2026
@anthonyshew
anthonyshew merged commit 067dfba into main Aug 22, 2026
51 of 57 checks passed
@anthonyshew
anthonyshew deleted the sandbox-ssh-completed-runs branch August 22, 2026 13:45
github-actions Bot added a commit that referenced this pull request Aug 25, 2026
## Release v2.10.12

> [!CAUTION]
> Versioned docs aliasing FAILED. [View
logs](https://github.com/vercel/turborepo/actions/runs/32882264217)

### Changes

- chore: Release Turborepo 2.10.11 (#13765) (`1fb1e86`)
- feat: Refresh documentation social cards (#13764) (`510777b`)
- fix: Preserve showcase logo sizes (#13766) (`b03ec9d`)
- fix: Refine mobile homepage interactions (#13767) (`a253dce`)
- fix: Align homepage KPIs to the right (#13769) (`373fbd3`)
- fix: Prevent homepage KPI overflow (#13770) (`fc02d72`)
- chore: Update Geistdocs to 1.20.4 (#13771) (`0689ad9`)
- perf: Skip Unused Workspace Config Stats (#13746) (`5503fde`)
- chore: Update with-solid example (#13752) (`43c555d`)
- chore: Update with-tailwind example (#13762) (`c436362`)
- perf: Stream dry-run JSON output (#13760) (`8138ce0`)
- chore: Update with-ultracite example (#13772) (`37be819`)
- perf: Merge same-prefix tree-wildcard globs into one directory walk
(#13763) (`59d4901`)
- chore: Update with-svelte example (#13118) (`bebcfc8`)
- chore: Add unified agent control plane (#13757) (`695a172`)
- fix: Remove incremental task caching (#13773) (`3d69e42`)
- chore: Rename agents app to factory (#13774) (`0a01c2d`)
- chore: Update oxlint and oxfmt (#13777) (`9088245`)
- feat: Add security.txt endpoint (#13778) (`5f9260b`)
- feat: Redesign factory control plane (#13775) (`ffd06d4`)
- perf: Replace regex captures with hand-written parsers in berry
lockfile identifiers (#13776) (`00e9f66`)
- chore: Upgrade the factory eve agent to 0.39.3 (#13783) (`c4fc5c8`)
- chore: Update with-rsbuild-module-federation example (#13786)
(`45b1257`)
- perf: Index Berry lockfile resolution overrides by dependency name
(#13787) (`e89eddd`)
- chore: Update remote cache action to v1.1.0 (#13789) (`56162b8`)
- docs: Fix reference links and validation (#13784) (`0423d70`)
- feat: Add SSH command affordance for factory sandboxes (#13779)
(`0cfccdd`)
- fix: Show invalid affected task glob (#13793) (`ef1ef92`)
- perf: Skip unused repository indexing for package listings (#13792)
(`acd5ae5`)
- feat: Rebuild the factory image on every merge to main (#13781)
(`1c165b3`)
- chore: Add full-page terminal SSH sessions to Factory sandbox
inventory (#13780) (`d640c2e`)
- chore: Update with-shell-commands example (#13791) (`476e382`)
- chore: Allow SSH terminal for completed-run sandboxes in Factory
(#13782) (`067dfba`)
- fix: Decouple graceful shutdown tests from the shell commands example
(#13799) (`72fac33`)
- feat: Start ad-hoc factory work from the operator page (#13798)
(`b9d13ca`)
- chore: Update with-solid example (#13797) (`d412981`)
- fix: Rebuild factory images without custom workflows (#13801)
(`0c54a80`)
- feat: Add factory navigation (#13802) (`092ee6d`)
- refactor: Migrate Factory styles to Tailwind (#13803) (`7fe373b`)
- feat: Add durable Factory workspaces (#13804) (`ccd79d3`)
- feat: Stream Factory sandbox as terminal (#13807) (`33d8b24`)
- fix: Restore Factory workspace creation (#13812) (`fd72cca`)
- fix: Update Factory session network policy (#13814) (`c579fec`)
- fix: Improve Factory terminal line spacing (#13815) (`a88e39b`)
- fix: Install Factory publishing skill (#13817) (`7b8cb14`)
- chore: Route Factory publishing through Eve (#13816) (`e05b81c`)
- feat: Standardize Factory meta titles to Turborepo suffix (#13819)
(`fd593b5`)
- perf: Skip Factory chat verification (#13820) (`39137a6`)
- fix: Restore Factory workspaces (#13823) (`0afd4b2`)
- docs: Fix inconsistent Yarn command in basic example (#13825)
(`3dd49d1`)
- chore: Update non-monorepo example (#13808) (`d2a673f`)
- perf: Batch package detail queries (#13809) (`331183e`)
- chore: Update basic example (#13824) (`f06836d`)
- fix: Include virtual tasks in affected query (#13805) (`89a9b78`)
- fix: Add workspace approval controls (#13827) (`02dfd21`)
- fix: Prevent chat SSH command overflow (#13828) (`d9eaff5`)
- fix: Update Factory pull request branches (#13831) (`b670754`)
- chore: Add operator chat model selector (#13833) (`bb2fcb3`)
- fix: Move model selector to workspace creation (#13835) (`72805d7`)
- chore: Skip redundant Factory PR approval (#13837) (`bbe5406`)
- chore: Use geistdocs 1.23.1 (#13834) (`3787c06`)
- chore: Handle feedback on Factory pull requests (#13836) (`a76330b`)
- fix: Escape ampersands in RSS feed enclosure URLs (#13839) (`7107f26`)
- chore: Add automatic issue handling (#13840) (`e1674e4`)
- chore: Alert Slack for low-confidence issues (#13841) (`f153cda`)
- feat: Require high confidence for issue fixes (#13842) (`c97782b`)
- fix: Run pnpm directly on Windows (#13843) (`9d2b03b`)

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
anthonyshew pushed a commit that referenced this pull request Aug 27, 2026
## Release v2.10.13-canary.1

> [!CAUTION]
> Versioned docs aliasing FAILED. [View
logs](https://github.com/vercel/turborepo/actions/runs/33018929115)

### Changes

- chore: Release Turborepo 2.10.11-canary.4 (#13759) (`9f94a7d`)
- feat: Expand performance agent toolbox (#13761) (`f924510`)
- docs: Redesign Turborepo homepage (#13702) (`09bf969`)
- fix: Tolerate transient input files (#13734) (`3226457`)
- chore: Release Turborepo 2.10.11 (#13765) (`1fb1e86`)
- feat: Refresh documentation social cards (#13764) (`510777b`)
- fix: Preserve showcase logo sizes (#13766) (`b03ec9d`)
- fix: Refine mobile homepage interactions (#13767) (`a253dce`)
- fix: Align homepage KPIs to the right (#13769) (`373fbd3`)
- fix: Prevent homepage KPI overflow (#13770) (`fc02d72`)
- chore: Update Geistdocs to 1.20.4 (#13771) (`0689ad9`)
- perf: Skip Unused Workspace Config Stats (#13746) (`5503fde`)
- chore: Update with-solid example (#13752) (`43c555d`)
- chore: Update with-tailwind example (#13762) (`c436362`)
- perf: Stream dry-run JSON output (#13760) (`8138ce0`)
- chore: Update with-ultracite example (#13772) (`37be819`)
- perf: Merge same-prefix tree-wildcard globs into one directory walk
(#13763) (`59d4901`)
- chore: Update with-svelte example (#13118) (`bebcfc8`)
- chore: Add unified agent control plane (#13757) (`695a172`)
- fix: Remove incremental task caching (#13773) (`3d69e42`)
- chore: Rename agents app to factory (#13774) (`0a01c2d`)
- chore: Update oxlint and oxfmt (#13777) (`9088245`)
- feat: Add security.txt endpoint (#13778) (`5f9260b`)
- feat: Redesign factory control plane (#13775) (`ffd06d4`)
- perf: Replace regex captures with hand-written parsers in berry
lockfile identifiers (#13776) (`00e9f66`)
- chore: Upgrade the factory eve agent to 0.39.3 (#13783) (`c4fc5c8`)
- chore: Update with-rsbuild-module-federation example (#13786)
(`45b1257`)
- perf: Index Berry lockfile resolution overrides by dependency name
(#13787) (`e89eddd`)
- chore: Update remote cache action to v1.1.0 (#13789) (`56162b8`)
- docs: Fix reference links and validation (#13784) (`0423d70`)
- feat: Add SSH command affordance for factory sandboxes (#13779)
(`0cfccdd`)
- fix: Show invalid affected task glob (#13793) (`ef1ef92`)
- perf: Skip unused repository indexing for package listings (#13792)
(`acd5ae5`)
- feat: Rebuild the factory image on every merge to main (#13781)
(`1c165b3`)
- chore: Add full-page terminal SSH sessions to Factory sandbox
inventory (#13780) (`d640c2e`)
- chore: Update with-shell-commands example (#13791) (`476e382`)
- chore: Allow SSH terminal for completed-run sandboxes in Factory
(#13782) (`067dfba`)
- fix: Decouple graceful shutdown tests from the shell commands example
(#13799) (`72fac33`)
- feat: Start ad-hoc factory work from the operator page (#13798)
(`b9d13ca`)
- chore: Update with-solid example (#13797) (`d412981`)
- fix: Rebuild factory images without custom workflows (#13801)
(`0c54a80`)
- feat: Add factory navigation (#13802) (`092ee6d`)
- refactor: Migrate Factory styles to Tailwind (#13803) (`7fe373b`)
- feat: Add durable Factory workspaces (#13804) (`ccd79d3`)
- feat: Stream Factory sandbox as terminal (#13807) (`33d8b24`)
- fix: Restore Factory workspace creation (#13812) (`fd72cca`)
- fix: Update Factory session network policy (#13814) (`c579fec`)
- fix: Improve Factory terminal line spacing (#13815) (`a88e39b`)
- fix: Install Factory publishing skill (#13817) (`7b8cb14`)
- chore: Route Factory publishing through Eve (#13816) (`e05b81c`)
- feat: Standardize Factory meta titles to Turborepo suffix (#13819)
(`fd593b5`)
- perf: Skip Factory chat verification (#13820) (`39137a6`)
- fix: Restore Factory workspaces (#13823) (`0afd4b2`)
- docs: Fix inconsistent Yarn command in basic example (#13825)
(`3dd49d1`)
- chore: Update non-monorepo example (#13808) (`d2a673f`)
- perf: Batch package detail queries (#13809) (`331183e`)
- chore: Update basic example (#13824) (`f06836d`)
- fix: Include virtual tasks in affected query (#13805) (`89a9b78`)
- fix: Add workspace approval controls (#13827) (`02dfd21`)
- fix: Prevent chat SSH command overflow (#13828) (`d9eaff5`)
- fix: Update Factory pull request branches (#13831) (`b670754`)
- chore: Add operator chat model selector (#13833) (`bb2fcb3`)
- fix: Move model selector to workspace creation (#13835) (`72805d7`)
- chore: Skip redundant Factory PR approval (#13837) (`bbe5406`)
- chore: Use geistdocs 1.23.1 (#13834) (`3787c06`)
- chore: Handle feedback on Factory pull requests (#13836) (`a76330b`)
- fix: Escape ampersands in RSS feed enclosure URLs (#13839) (`7107f26`)
- chore: Add automatic issue handling (#13840) (`e1674e4`)
- chore: Alert Slack for low-confidence issues (#13841) (`f153cda`)
- feat: Require high confidence for issue fixes (#13842) (`c97782b`)
- fix: Run pnpm directly on Windows (#13843) (`9d2b03b`)
- chore: Release Turborepo 2.10.12 (#13844) (`32748f5`)
- fix: Remove unsupported remote cache environment variable (#13845)
(`b4c2eed`)
- fix: Copy TUI selections locally over SSH (#13847) (`03df632`)
- feat: Use uv workspace metadata (#13848) (`fa1ca7d`)
- feat: Support Python virtual environments (#13849) (`7f66dbd`)
- fix: Scope uv lockfile affectedness (#13850) (`1e074f3`)
- test: Isolate uv prune configuration (#13851) (`9f2fd33`)
- fix: Explain disabled uv task caching (#13852) (`0f59d11`)
- fix: Explain uv identity probe failures (#13853) (`eabe73a`)
- fix: Explain uncached Cargo library builds (#13855) (`35ce2fa`)
- fix: Explain disabled Cargo task caching (#13854) (`39821f6`)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

This branch was successfully deployed

1 active deployment
Preview – turborepo-factory 023b5951 Deployed Aug 22, 2026 by vercel[bot]
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.

1 participant