Skip to content

feat: per-org base snapshots for fast sandbox startup#9

Merged
sweetmantech merged 1 commit into
mainfrom
feat/per-org-snapshots
Apr 22, 2026
Merged

feat: per-org base snapshots for fast sandbox startup#9
sweetmantech merged 1 commit into
mainfrom
feat/per-org-snapshots

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented Apr 22, 2026

Summary

Targets the 75-second git clone that PR #8's instrumentation showed accounts for 99.3% of sandbox startup time (from 76,116ms total: clone = 75,599ms, VM provisioning = 234ms, everything else = ~280ms combined).

Replaces the per-session full-repo clone with a per-org base snapshot pattern. On a cold sandbox create:

  1. Look up a per-org base snapshot by name (the org repo name, e.g. org-rostrum-pacific-cebcc866-...) via Snapshot.list({ name }).
  2. Fast path — if a snapshot exists: boot the session sandbox from it. Repo is already in /vercel/sandbox. Run git fetch --depth=1 origin <branch> && git reset --hard origin/<branch> to absorb any drift since the snapshot was built. Expected ~1-3s instead of ~75s.
  3. First-session path — if no snapshot exists: fall back to the current full-clone path (transparent for the user) and fire a background Vercel Workflow to build the snapshot for next time.

Expected outcome: new-sandbox startup goes from ~76s → ~5s for the 99% of sessions where a per-org snapshot exists.

Design choices (per discussion)

  • No DB changes. Snapshots are looked up by Vercel sandbox name; we just use the org repo name as the build sandbox's name and filter Snapshot.list({ name }) on it.
  • No refresh scheduling. Drift between snapshot build time and current origin/main is absorbed by the per-session fetch --depth=1 + reset --hard. The snapshot itself is "build once, use forever" — the user explicitly chose this over webhooks/cron.
  • Transparent fallback. First session for any org pays the old 75s cost; everyone after is fast. No "block while building" UX.
  • Vercel Workflow for the build. Same pattern as sandboxLifecycleWorkflow (use workflow / use step); kicked fire-and-forget from the create handler.
  • Org id derivation from cloneUrl via extractOrgRepoName, parsing the convention https://github.com/recoupable/<repoName><repoName>.

Files

Package (packages/sandbox/):

  • Source.prebuilt?: boolean — when true, skip clone and run git fetch + reset --hard origin/<branch> instead.
  • ConnectOptions.forceCreate?: boolean — bypass reconnect-first for named ephemeral build sandboxes (so the build path always creates fresh, even when the name was used in a prior run).
  • refreshBaseSnapshot gains sandboxName? + githubToken? so build sandboxes can be named (filter-able) and authenticate clones via credential brokering.

App (apps/web/):

  • lib/recoupable/extract-org-repo-name.ts (+ tests) — parses the repo name from the clone URL.
  • lib/sandbox/find-org-snapshot.tsSnapshot.list({ name, sortOrder: \"desc\" }) and returns the most recent created snapshot id, or null.
  • lib/sandbox/build-org-snapshot-kick.ts — fire-and-forget start(buildOrgSnapshotWorkflow, [...]).
  • app/workflows/build-org-snapshot.ts — calls refreshBaseSnapshot({ baseSnapshotId, sandboxName: orgRepoName, commands: [\git clone --depth=1 ${cloneUrl} .`] })`.
  • lib/sandbox/create-sandbox-handler.ts — wires the lookup + fallback + kick into the existing flow.

Concurrency / dedup

If two sessions for the same org race when no snapshot exists yet, both kick the build workflow. Both runs create snapshots with the same name, both succeed, future lookups pick the most recent. Wasted compute on one extra build, harmless. We could dedup later by checking workflow run state if it becomes a problem.

Test plan

  • Unit tests: extractOrgRepoName (6 cases), refreshBaseSnapshot with new sandboxName + githubToken + forceCreate flow.
  • bun run check clean, turbo typecheck clean across all 4 packages.
  • Verification (post-merge, with PR feat: instrument sandbox startup timing #8's timing logs deployed): click an org for the first time → confirm fallback path runs (75s) and workflow kicks. Wait for workflow to complete (~90s). Click a different session for the same org → confirm fast path (~5s) and timing logs show vm_provision ~250ms + git_clone skipped + new fetch/reset step.
  • After verifying the fast path: revert PR feat: instrument sandbox startup timing #8 (timing logs) since we'll have proven the win.

Notes for review

  • Stacks logically with PR feat: instrument sandbox startup timing #8: that PR proved where the time goes; this PR removes it. Both are independently reviewable; merging order doesn't matter, but keeping feat: instrument sandbox startup timing #8 around for a deploy or two after this lands lets you measure the improvement directly.
  • findOrgSnapshot relies on the Vercel SDK's auto-credential resolution via VERCEL_OIDC_TOKEN (already present in Vercel runtime). No new env vars.

🤖 Generated with Claude Code


Summary by cubic

Speed up sandbox startup by using per-org base snapshots and skipping the full repo clone. First run falls back to a full clone and kicks a background build; later sessions start in ~3–5s.

  • New Features
    • Per-org snapshot lookup by name via @vercel/sandbox Snapshot.list({ name }); background workflow builds snapshots when missing.
    • Source.prebuilt to skip git clone and run shallow git fetch --depth=1 + git reset --hard origin/<branch>.
    • ConnectOptions.forceCreate to always create fresh named build sandboxes.
    • refreshBaseSnapshot now accepts sandboxName and githubToken for named, authenticated builds.
    • Web integration: extract org repo name, find snapshot, kick build workflow, and wire into create handler; added unit tests.

Written for commit cbf39e0. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Added organization snapshot building to pre-populate and cache repositories for faster sandbox initialization.
    • Sandboxes now automatically reuse pre-built organization snapshots when available, reducing setup time.
    • Introduced optimized synchronization mode for sandboxes with pre-cloned repositories, using faster fetch and reset operations instead of full clones.

On a cold sandbox create for an org, look up a per-org base snapshot by
name (the org repo name, e.g. `org-rostrum-pacific-<uuid>`). If one
exists, boot from it and run `git fetch --depth=1 && git reset --hard
origin/<branch>` instead of a full clone. If none exists, fall back to
the current full-clone path and fire a background Vercel Workflow to
build the snapshot for next time.

Target outcome (measured in a follow-up): 75s git_clone phase → ~3s
fetch+reset, taking new-sandbox startup from ~76s to ~5s.

Approach:
- No DB changes. Snapshots are looked up via `Snapshot.list({ name })`
  using the build-sandbox name as the filter key. No refresh scheduling:
  drift is absorbed by the per-session fetch+reset step.
- `packages/sandbox`: new `Source.prebuilt` flag; when set, skip the
  initial git clone and run fetch+reset. New `ConnectOptions.forceCreate`
  flag bypasses reconnect-first for named ephemeral build sandboxes.
- `refreshBaseSnapshot` gains `sandboxName` + `githubToken` options so
  build sandboxes can be named (so their snapshots are filter-able) and
  can authenticate clones via credential brokering.
- `apps/web`: new `extractOrgRepoName` parses the repo name from the
  clone URL; `findOrgSnapshot` queries the SDK; `kickBuildOrgSnapshotWorkflow`
  starts the async builder; `buildOrgSnapshotWorkflow` clones the org
  repo shallowly inside a build sandbox and snapshots it.
- First session for an org is still the slow path (transparent fallback);
  every subsequent session hits the per-org snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 22, 2026

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

Project Deployment Actions Updated (UTC)
open-agents Ready Ready Preview Apr 22, 2026 6:11pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

📝 Walkthrough

Walkthrough

Introduces a new workflow system for building and caching organization-specific base snapshots, integrating with sandbox creation to detect existing org snapshots or trigger their background construction. Changes support a "prebuilt" mode for sandboxes that reuses pre-cloned snapshots via git fetch/reset instead of recloning.

Changes

Cohort / File(s) Summary
Workflow and Snapshot Building
apps/web/app/workflows/build-org-snapshot.ts, apps/web/lib/sandbox/build-org-snapshot-kick.ts, apps/web/lib/sandbox/find-org-snapshot.ts
New workflow module to clone a repository and build an org-specific snapshot; fire-and-forget trigger function; and utility to query for existing org snapshots by name and creation status.
Org/Repo Extraction
apps/web/lib/recoupable/extract-org-repo-name.ts, apps/web/lib/recoupable/extract-org-repo-name.test.ts
Regex-based utility to extract org/repo identifier from GitHub HTTPS clone URLs under the recoupable/ namespace, with comprehensive test coverage for valid URLs, malformed inputs, and SSH format rejection.
Sandbox Creation Integration
apps/web/lib/sandbox/create-sandbox-handler.ts
Modified to extract org name from repo URL, check for existing org snapshot, set sandbox source to prebuilt when found, and asynchronously kick snapshot build workflow if org is present but snapshot not yet created.
Prebuilt Snapshot Configuration
packages/sandbox/types.ts, packages/sandbox/vercel/config.ts
Added optional prebuilt?: boolean flag to Source and VercelSandboxConfig to signal that a repository is already cloned and only needs git fetch/reset synchronization.
Sandbox Connection and Bootstrap
packages/sandbox/factory.ts, packages/sandbox/vercel/connect.ts, packages/sandbox/vercel/sandbox.ts
Extended ConnectOptions with forceCreate flag; updated connection logic to skip named-sandbox reconnect when flag is set; replaced git clone with shallow git fetch and hard reset for prebuilt sources, with explicit error handling for operation failures.
Snapshot Refresh and Export
packages/sandbox/vercel/snapshot-refresh.ts, packages/sandbox/vercel/snapshot-refresh.test.ts, packages/sandbox/index.ts
Extended snapshot refresh function to accept optional sandboxName and githubToken for authenticated builds; propagate forceCreate: true and token to sandbox connection; added public exports for refresh function and related types.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Handler as createSandboxHandler
    participant Extractor as extractOrgRepoName
    participant Finder as findOrgSnapshot
    participant Kicker as kickBuildOrgSnapshotWorkflow
    participant Workflow as buildOrgSnapshotWorkflow
    participant Snapshot as Snapshot Service

    Client->>Handler: Create sandbox (repoUrl)
    Handler->>Extractor: Extract org/repo from URL
    Extractor-->>Handler: org name
    
    alt Org name found
        Handler->>Finder: Query for existing org snapshot
        Finder->>Snapshot: List snapshots by name
        Snapshot-->>Finder: snapshots (or error)
        
        alt Snapshot exists and created
            Finder-->>Handler: snapshot ID
            Handler-->>Client: Sandbox with prebuilt source
        else No snapshot found
            Finder-->>Handler: null
            Handler->>Kicker: Trigger workflow async
            Kicker->>Workflow: Start buildOrgSnapshotWorkflow
            Workflow->>Snapshot: Clone repo, refreshBaseSnapshot
            Snapshot-->>Workflow: snapshot ID
            Handler-->>Client: Sandbox with default snapshot (workflow building in background)
        end
    else No org name
        Handler-->>Client: Sandbox with default snapshot
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Snapshots spring from cloned repos with care,
Org-built bases shared across the warren fair!
Fetch and reset, no more clone delays,
Prebuilt sandboxes hop through brighter days! 🌱✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: introducing per-organization base snapshots to enable faster sandbox startup, which is the primary objective of this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/per-org-snapshots

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
packages/sandbox/vercel/snapshot-refresh.ts (1)

99-115: LGTM — option plumbing is correct.

sandboxName gating forceCreate correctly routes through VercelSandbox.create() per connect.ts:128-147, ensuring the build sandbox tags snapshots with the org name for later Snapshot.list({ name }) lookups.

Minor nit: options.githubToken !== undefined will forward an empty string, which then falls back to the default (no credential brokering) inside buildGitHubCredentialBrokeringPolicy. Consider options.githubToken ? { githubToken: options.githubToken } : {} for clearer intent. Current callers trim-to-undefined so this is effectively unreachable today.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sandbox/vercel/snapshot-refresh.ts` around lines 99 - 115, The
conditional currently uses options.githubToken !== undefined which will pass an
empty string through; change the args to only include githubToken when it's a
non-empty truthy value (e.g., use a truthy check rather than !== undefined) so
connectSnapshotSandbox(...) receives no githubToken field when callers
intentionally supply empty string; update the object construction in
snapshot-refresh.ts (the options block passed to connectSnapshotSandbox) to gate
githubToken using options.githubToken truthiness to avoid forwarding empty
strings that then fall back inside buildGitHubCredentialBrokeringPolicy.
apps/web/app/workflows/build-org-snapshot.ts (1)

41-62: Swallowed failure silently re-triggers on every subsequent session.

On success: false, the workflow logs and returns a result object. Nothing prevents the next session's findOrgSnapshot from finding no ready snapshot and kicking yet another build that will fail for the same reason (bad clone URL, bad token, rate limit). For repos that permanently fail to clone, every session for that org pays both the full-clone cost and kicks a doomed workflow.

Consider short-circuiting with a negative cache (e.g., a small "recent failed org" TTL in Redis/KV) or at least surfacing the error to an alertable channel so it doesn't silently burn workflow quota.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/workflows/build-org-snapshot.ts` around lines 41 - 62, The
workflow currently returns { success: false } from buildOrgSnapshotWorkflow
after buildSnapshotStep fails but does not record that failure, causing
findOrgSnapshot to repeatedly trigger doomed rebuilds; update
buildOrgSnapshotWorkflow to record a negative cache entry (e.g., set a Redis/KV
key for the org/sandbox with a short TTL) when catching an error and include the
error details in that entry, and ensure the existing findOrgSnapshot path
consults that negative cache before scheduling a new build; alternatively (or in
addition) emit the error to the alerting channel/function used by the app so
permanent failures are surfaced.
apps/web/lib/sandbox/build-org-snapshot-kick.ts (1)

15-27: Use after() to prevent the serverless runtime from dropping the workflow start request.

void start(...) returns a dangling promise. In Vercel's serverless runtime, the function instance can be frozen as soon as the enclosing Route Handler returns its Response, potentially cancelling the in-flight HTTP request that start() makes to the workflow service.

While the handler tolerates kick failure (fallback to full clone, and next session re-kicks), silent dropped kicks add unnecessary ~75s full-clones until a request survives long enough to register the workflow.

Wrap the call in after() from next/server (Next.js 15.1+, available in your 16.2.1 environment) to keep the runtime alive:

Proposed fix
 import "server-only";

+import { after } from "next/server";
 import { start } from "workflow/api";
 import { buildOrgSnapshotWorkflow } from "@/app/workflows/build-org-snapshot";

 interface KickBuildOrgSnapshotInput {
   cloneUrl: string;
   sandboxName: string;
 }

 export function kickBuildOrgSnapshotWorkflow(input: KickBuildOrgSnapshotInput) {
-  void start(buildOrgSnapshotWorkflow, [input]).then(
+  after(
+    start(buildOrgSnapshotWorkflow, [input]).then(
       (run) =>
         console.log(
           `[build-org-snapshot] Started workflow run ${run.runId} for '${input.sandboxName}' (${input.cloneUrl})`,
         ),
       (error) =>
         console.error(
           `[build-org-snapshot] Failed to start workflow for '${input.sandboxName}' (${input.cloneUrl}):`,
           error,
         ),
-  );
+    ),
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/sandbox/build-org-snapshot-kick.ts` around lines 15 - 27, The
kickBuildOrgSnapshotWorkflow uses a dangling promise from start(...) which can
be dropped by Vercel; import after from 'next/server' and wrap the start call in
after(...) so the runtime stays alive until the request completes (e.g., replace
the standalone void start(buildOrgSnapshotWorkflow, [input]).then(..., ...) with
after(() => start(buildOrgSnapshotWorkflow, [input]).then(run =>
console.log(...), error => console.error(...)))); keep the same logging handlers
and reference the existing kickBuildOrgSnapshotWorkflow and start symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/app/workflows/build-org-snapshot.ts`:
- Around line 28-36: The command construction in refreshBaseSnapshot uses direct
string interpolation of input.cloneUrl into commands (commands: [`git clone
--depth=1 ${input.cloneUrl} .`]) which allows shell metacharacters to cause
command injection; update the build-org-snapshot workflow to avoid passing an
interpolated shell string to the sandbox: either (1) validate/allow-list
input.cloneUrl at the workflow boundary (reuse parseGitHubUrl or an equivalent
strict URL validator) and reject unsafe values, or (2) change how commands are
invoked by refreshBaseSnapshot so it accepts argv-style exec (no shell
expansion) and pass the git clone arguments as an array (i.e., call the sandbox
API with an exec-style command rather than a single bash -c string) so
input.cloneUrl is never interpreted by a shell; make the change around the
refreshBaseSnapshot call and the commands parameter and ensure input.cloneUrl is
referenced safely (or replaced by the validated URL) before passing to
refreshBaseSnapshot.

In `@apps/web/lib/recoupable/extract-org-repo-name.ts`:
- Around line 1-11: The current ORG_REPO_URL_PATTERN in extractOrgRepoName
accepts any recoupable/<repo>, letting non-org repos like recoupable/open-agents
be treated as org snapshots; tighten the regex to only capture repo names that
follow the generated-org naming convention (e.g. start with "org-") by changing
ORG_REPO_URL_PATTERN to require the captured group to begin with "org-" (update
the pattern used in extractOrgRepoName accordingly), and add a regression test
that verifies a URL like https://github.com/recoupable/open-agents returns null
while https://github.com/recoupable/org-... returns the repo name.

In `@apps/web/lib/sandbox/find-org-snapshot.ts`:
- Around line 15-22: The Snapshot.list call uses limit: 5 which can miss older
ready snapshots; update the call in find-org-snapshot to use limit: 25 so
client-side filtering has a larger window to find a snapshot with status ===
"created" (i.e., keep using Snapshot.list({ name: sandboxName, sortOrder:
"desc", limit: 25 }), then continue to find ready via result.snapshots.find((s)
=> s.status === "created") and return ready?.id ?? null).

In `@packages/sandbox/vercel/sandbox.ts`:
- Around line 600-622: The code currently sets refBranch = source.branch ??
"main" inside the prebuilt snapshot branch handling (in the block guarded by
source.prebuilt && baseSnapshotId) which breaks repos whose default branch is
not "main"; change this to (a) if source.branch is provided use it, (b) if
source.branch is undefined and the session is a new branch (isNewBranch true)
resolve the remote HEAD to determine the default branch (e.g. use git
symbolic-ref refs/remotes/origin/HEAD or git remote show origin via
sdk.runCommand) and use that name, or (c) if source.branch is undefined and this
is not a new branch, skip the fetch/reset entirely and rely on the existing
snapshot state; update references to refBranch, the fetch/reset sdk.runCommand
calls, and error messages accordingly so we no longer hardcode "main".

In `@packages/sandbox/vercel/snapshot-refresh.test.ts`:
- Around line 174-183: The test fixture uses a GitHub-token-like string
("ghs_secret") which triggers secret scanners; update the value passed to
refreshBaseSnapshot (in the test data object used by the refreshBaseSnapshot
call) to a neutral non-secret-looking string (e.g., "fake-token" or
"TEST_TOKEN") so the input to the githubToken field is clearly a fixture and not
a credential; ensure you change the githubToken property in the
refreshBaseSnapshot call and any related test assertions that depend on its
exact value.

---

Nitpick comments:
In `@apps/web/app/workflows/build-org-snapshot.ts`:
- Around line 41-62: The workflow currently returns { success: false } from
buildOrgSnapshotWorkflow after buildSnapshotStep fails but does not record that
failure, causing findOrgSnapshot to repeatedly trigger doomed rebuilds; update
buildOrgSnapshotWorkflow to record a negative cache entry (e.g., set a Redis/KV
key for the org/sandbox with a short TTL) when catching an error and include the
error details in that entry, and ensure the existing findOrgSnapshot path
consults that negative cache before scheduling a new build; alternatively (or in
addition) emit the error to the alerting channel/function used by the app so
permanent failures are surfaced.

In `@apps/web/lib/sandbox/build-org-snapshot-kick.ts`:
- Around line 15-27: The kickBuildOrgSnapshotWorkflow uses a dangling promise
from start(...) which can be dropped by Vercel; import after from 'next/server'
and wrap the start call in after(...) so the runtime stays alive until the
request completes (e.g., replace the standalone void
start(buildOrgSnapshotWorkflow, [input]).then(..., ...) with after(() =>
start(buildOrgSnapshotWorkflow, [input]).then(run => console.log(...), error =>
console.error(...)))); keep the same logging handlers and reference the existing
kickBuildOrgSnapshotWorkflow and start symbols.

In `@packages/sandbox/vercel/snapshot-refresh.ts`:
- Around line 99-115: The conditional currently uses options.githubToken !==
undefined which will pass an empty string through; change the args to only
include githubToken when it's a non-empty truthy value (e.g., use a truthy check
rather than !== undefined) so connectSnapshotSandbox(...) receives no
githubToken field when callers intentionally supply empty string; update the
object construction in snapshot-refresh.ts (the options block passed to
connectSnapshotSandbox) to gate githubToken using options.githubToken truthiness
to avoid forwarding empty strings that then fall back inside
buildGitHubCredentialBrokeringPolicy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: deffd445-f96d-4a7e-b0f0-6f86d053131e

📥 Commits

Reviewing files that changed from the base of the PR and between 66abefb and cbf39e0.

📒 Files selected for processing (14)
  • apps/web/app/workflows/build-org-snapshot.ts
  • apps/web/lib/recoupable/extract-org-repo-name.test.ts
  • apps/web/lib/recoupable/extract-org-repo-name.ts
  • apps/web/lib/sandbox/build-org-snapshot-kick.ts
  • apps/web/lib/sandbox/create-sandbox-handler.ts
  • apps/web/lib/sandbox/find-org-snapshot.ts
  • packages/sandbox/factory.ts
  • packages/sandbox/index.ts
  • packages/sandbox/types.ts
  • packages/sandbox/vercel/config.ts
  • packages/sandbox/vercel/connect.ts
  • packages/sandbox/vercel/sandbox.ts
  • packages/sandbox/vercel/snapshot-refresh.test.ts
  • packages/sandbox/vercel/snapshot-refresh.ts

Comment on lines +28 to +36
const result = await refreshBaseSnapshot({
baseSnapshotId: DEFAULT_SANDBOX_BASE_SNAPSHOT_ID,
sandboxName: input.sandboxName,
sandboxTimeoutMs: BUILD_SANDBOX_TIMEOUT_MS,
commandTimeoutMs: BUILD_COMMAND_TIMEOUT_MS,
githubToken,
commands: [`git clone --depth=1 ${input.cloneUrl} .`],
log: (message) => console.log(`[build-org-snapshot] ${message}`),
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential command injection via cloneUrl interpolation.

commands: [\git clone --depth=1 ${input.cloneUrl} .`]is ultimately passed tobash -c 'cd "${cwd}" && ${command}'inside the sandbox. AcloneUrl containing shell metacharacters (;, &&, `` ``, $(...)) would execute arbitrary commands in the build sandbox VM. The current upstream caller validates through `parseGitHubUrl`, so this is not exploitable today, but this workflow is now an exported entry point and shouldn't depend on every future caller sanitizing input.

Either validate/allow-list the URL at the workflow boundary, or use a form that doesn't go through a shell (single-shot argv exec). A cheap hardening step:

Proposed fix
+  if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+?(\.git)?$/.test(input.cloneUrl)) {
+    throw new Error(
+      `[build-org-snapshot] Refusing to clone non-GitHub URL: '${input.cloneUrl}'`,
+    );
+  }
+
   const result = await refreshBaseSnapshot({

Also note: git clone --depth=1 <url> . with no --branch captures the remote's default branch into the snapshot. Combined with the "main" fallback in packages/sandbox/vercel/sandbox.ts prebuilt fast path, this breaks any repo whose default isn't main (flagged separately on that file).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const result = await refreshBaseSnapshot({
baseSnapshotId: DEFAULT_SANDBOX_BASE_SNAPSHOT_ID,
sandboxName: input.sandboxName,
sandboxTimeoutMs: BUILD_SANDBOX_TIMEOUT_MS,
commandTimeoutMs: BUILD_COMMAND_TIMEOUT_MS,
githubToken,
commands: [`git clone --depth=1 ${input.cloneUrl} .`],
log: (message) => console.log(`[build-org-snapshot] ${message}`),
});
if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+?(\.git)?$/.test(input.cloneUrl)) {
throw new Error(
`[build-org-snapshot] Refusing to clone non-GitHub URL: '${input.cloneUrl}'`,
);
}
const result = await refreshBaseSnapshot({
baseSnapshotId: DEFAULT_SANDBOX_BASE_SNAPSHOT_ID,
sandboxName: input.sandboxName,
sandboxTimeoutMs: BUILD_SANDBOX_TIMEOUT_MS,
commandTimeoutMs: BUILD_COMMAND_TIMEOUT_MS,
githubToken,
commands: [`git clone --depth=1 ${input.cloneUrl} .`],
log: (message) => console.log(`[build-org-snapshot] ${message}`),
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/workflows/build-org-snapshot.ts` around lines 28 - 36, The
command construction in refreshBaseSnapshot uses direct string interpolation of
input.cloneUrl into commands (commands: [`git clone --depth=1 ${input.cloneUrl}
.`]) which allows shell metacharacters to cause command injection; update the
build-org-snapshot workflow to avoid passing an interpolated shell string to the
sandbox: either (1) validate/allow-list input.cloneUrl at the workflow boundary
(reuse parseGitHubUrl or an equivalent strict URL validator) and reject unsafe
values, or (2) change how commands are invoked by refreshBaseSnapshot so it
accepts argv-style exec (no shell expansion) and pass the git clone arguments as
an array (i.e., call the sandbox API with an exec-style command rather than a
single bash -c string) so input.cloneUrl is never interpreted by a shell; make
the change around the refreshBaseSnapshot call and the commands parameter and
ensure input.cloneUrl is referenced safely (or replaced by the validated URL)
before passing to refreshBaseSnapshot.

Comment on lines +1 to +11
const ORG_REPO_URL_PATTERN =
/^https:\/\/github\.com\/recoupable\/([^/]+?)(?:\.git)?\/?$/;

/**
* Extracts the repo name from a Recoupable org clone URL.
* Example: `https://github.com/recoupable/org-rostrum-pacific-<uuid>` → `org-rostrum-pacific-<uuid>`.
* Returns `null` for any URL that does not match the expected pattern.
*/
export function extractOrgRepoName(cloneUrl: string): string | null {
const match = cloneUrl.match(ORG_REPO_URL_PATTERN);
return match?.[1] ?? null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Constrain extraction to generated org repositories.

Line 2 currently accepts any recoupable/<repo> value, so https://github.com/recoupable/open-agents would be treated as an org snapshot name. That can build or reuse snapshots outside the intended per-org namespace.

Proposed tightening
 const ORG_REPO_URL_PATTERN =
-  /^https:\/\/github\.com\/recoupable\/([^/]+?)(?:\.git)?\/?$/;
+  /^https:\/\/github\.com\/recoupable\/(org-[A-Za-z0-9_-]+)(?:\.git)?\/?$/;

Add a matching regression test:

+  test("returns null for non-org repos under recoupable", () => {
+    expect(
+      extractOrgRepoName("https://github.com/recoupable/open-agents"),
+    ).toBeNull();
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/recoupable/extract-org-repo-name.ts` around lines 1 - 11, The
current ORG_REPO_URL_PATTERN in extractOrgRepoName accepts any
recoupable/<repo>, letting non-org repos like recoupable/open-agents be treated
as org snapshots; tighten the regex to only capture repo names that follow the
generated-org naming convention (e.g. start with "org-") by changing
ORG_REPO_URL_PATTERN to require the captured group to begin with "org-" (update
the pattern used in extractOrgRepoName accordingly), and add a regression test
that verifies a URL like https://github.com/recoupable/open-agents returns null
while https://github.com/recoupable/org-... returns the repo name.

Comment on lines +15 to +22
try {
const result = await Snapshot.list({
name: sandboxName,
sortOrder: "desc",
limit: 5,
});
const ready = result.snapshots.find((s) => s.status === "created");
return ready?.id ?? null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

@vercel/sandbox Snapshot.list options status filter

💡 Result:

In the @vercel/sandbox SDK, Snapshot.list is a static method on the Snapshot class used to list snapshots for the project. Official SDK documentation confirms its existence but does not provide detailed options in available snippets. From the corresponding Vercel REST API endpoints (which the SDK likely wraps), the list snapshots operation supports these query parameters (options): - project: string (optional) - Project ID or name. - limit: number (optional) - Max results per page (e.g., 1-50). - since: number (optional) - Unix timestamp (ms) to filter snapshots created after. - until: number (optional) - Unix timestamp (ms) to filter snapshots created before. - teamId/slug: string (optional) - For team-scoped requests. - Additional in v2: name, cursor, sortOrder (asc/desc). No explicit 'status' filter parameter is documented in the API query params. Snapshots have a status field ("failed", "created", "deleted") returned in results, which can be filtered client-side after fetching. Example usage (inferred from docs): await Snapshot.list({ limit: 20, since: Date.now - 246060*1000 }); For full details, see SDK reference at https://vercel.com/docs/vercel-sandbox/sdk-reference and REST API at https://vercel.com/docs/rest-api/sandboxes/list-snapshots.

Citations:


Widen limit: 5 to reduce risk of missing ready snapshots during churn.

If the 5 most-recent snapshots are all in a non-created state (e.g., building, failed, expired), this returns null and falls back to the slow ~75s full-clone path even though older ready snapshots exist. Given the PR explicitly tolerates concurrent duplicate builds, multiple in-flight snapshots for the same org name are plausible.

The Snapshot.list API does not support server-side filtering by status, so client-side filtering is required. Increase limit to 25 to scan a wider window for ready snapshots:

Proposed fix
    const result = await Snapshot.list({
      name: sandboxName,
      sortOrder: "desc",
-     limit: 5,
+     limit: 25,
    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/lib/sandbox/find-org-snapshot.ts` around lines 15 - 22, The
Snapshot.list call uses limit: 5 which can miss older ready snapshots; update
the call in find-org-snapshot to use limit: 25 so client-side filtering has a
larger window to find a snapshot with status === "created" (i.e., keep using
Snapshot.list({ name: sandboxName, sortOrder: "desc", limit: 25 }), then
continue to find ready via result.snapshots.find((s) => s.status === "created")
and return ready?.id ?? null).

Comment on lines +600 to +622
if (source?.prebuilt && baseSnapshotId) {
const refBranch = source.branch ?? "main";
const fetchResult = await sdk.runCommand({
cmd: "git",
args: ["fetch", "--depth=1", "origin", refBranch],
cwd: workingDirectory,
});
if (fetchResult.exitCode !== 0) {
throw new Error(
`Failed to fetch '${refBranch}' in prebuilt snapshot for '${source.url}' (exit code ${fetchResult.exitCode})`,
);
}
const resetResult = await sdk.runCommand({
cmd: "git",
args: ["reset", "--hard", `origin/${refBranch}`],
cwd: workingDirectory,
});
if (resetResult.exitCode !== 0) {
throw new Error(
`Failed to reset to 'origin/${refBranch}' in prebuilt snapshot for '${source.url}' (exit code ${resetResult.exitCode})`,
);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hardcoded "main" fallback diverges from repo's default branch and will break on non-main repos.

Two cases are broken:

  1. isNewBranch === true: source.branch is undefined, so the fast path fetches/resets to origin/main. The non-prebuilt git clone path in the same function (line 574) clones without --branch, i.e. whatever the remote HEAD points to. For a repo whose default is master/develop/trunk, these two paths now produce different starting commits.
  2. Repos whose default branch isn't main: the base snapshot built by build-org-snapshot.ts runs git clone --depth=1 <cloneUrl> . (no branch flag) so the checkout is the default branch. Then a session with branch: "main" omitted would try git fetch --depth=1 origin main → fatal on any repo without a main branch, surfacing as Failed to fetch 'main' ... and a 502.

Prefer resolving the remote HEAD when no branch is specified, e.g. via git remote show origin / git symbolic-ref refs/remotes/origin/HEAD, or skip the fetch/reset and rely on the snapshot's state when source.branch is undefined and !source.newBranch.

Proposed fix sketch
-    if (source?.prebuilt && baseSnapshotId) {
-      const refBranch = source.branch ?? "main";
+    if (source?.prebuilt && baseSnapshotId) {
+      let refBranch = source.branch;
+      if (!refBranch) {
+        const headResult = await sdk.runCommand({
+          cmd: "bash",
+          args: [
+            "-c",
+            "git remote set-head origin -a >/dev/null && git symbolic-ref --short refs/remotes/origin/HEAD | sed 's#^origin/##'",
+          ],
+          cwd: workingDirectory,
+        });
+        refBranch = (await headResult.stdout()).trim() || "main";
+      }
       const fetchResult = await sdk.runCommand({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sandbox/vercel/sandbox.ts` around lines 600 - 622, The code
currently sets refBranch = source.branch ?? "main" inside the prebuilt snapshot
branch handling (in the block guarded by source.prebuilt && baseSnapshotId)
which breaks repos whose default branch is not "main"; change this to (a) if
source.branch is provided use it, (b) if source.branch is undefined and the
session is a new branch (isNewBranch true) resolve the remote HEAD to determine
the default branch (e.g. use git symbolic-ref refs/remotes/origin/HEAD or git
remote show origin via sdk.runCommand) and use that name, or (c) if
source.branch is undefined and this is not a new branch, skip the fetch/reset
entirely and rely on the existing snapshot state; update references to
refBranch, the fetch/reset sdk.runCommand calls, and error messages accordingly
so we no longer hardcode "main".

Comment on lines +174 to +183
await refreshBaseSnapshot(
{
baseSnapshotId: "snap-current",
sandboxTimeoutMs: 600_000,
sandboxName: "org-rostrum-pacific-abc",
githubToken: "ghs_secret",
commands: [
"git clone --depth=1 https://github.com/recoupable/org-rostrum-pacific-abc .",
],
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid token-looking fixture strings.

Line 179 uses a GitHub-token-like ghs_ prefix. Even though it is fake, this can create noisy secret-scanner findings; use a neutral fixture value instead.

Suggested cleanup
-        githubToken: "ghs_secret",
+        githubToken: "test-github-token",
-          githubToken: "ghs_secret",
+          githubToken: "test-github-token",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/sandbox/vercel/snapshot-refresh.test.ts` around lines 174 - 183, The
test fixture uses a GitHub-token-like string ("ghs_secret") which triggers
secret scanners; update the value passed to refreshBaseSnapshot (in the test
data object used by the refreshBaseSnapshot call) to a neutral
non-secret-looking string (e.g., "fake-token" or "TEST_TOKEN") so the input to
the githubToken field is clearly a fixture and not a credential; ensure you
change the githubToken property in the refreshBaseSnapshot call and any related
test assertions that depend on its exact value.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 14 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/web/lib/sandbox/build-org-snapshot-kick.ts">

<violation number="1" location="apps/web/lib/sandbox/build-org-snapshot-kick.ts:19">
P2: Avoid logging raw `cloneUrl`; it can contain credentials or other sensitive user-provided data.</violation>
</file>

<file name="apps/web/lib/sandbox/find-org-snapshot.ts">

<violation number="1" location="apps/web/lib/sandbox/find-org-snapshot.ts:19">
P2: Hard-coding `limit: 5` can miss an existing ready snapshot and trigger unnecessary slow-path sandbox startup.</violation>
</file>

<file name="apps/web/lib/sandbox/create-sandbox-handler.ts">

<violation number="1" location="apps/web/lib/sandbox/create-sandbox-handler.ts:115">
P2: Enabling `prebuilt` for new-branch requests can break sandbox creation on repos whose default branch is not `main`.</violation>
</file>

<file name="apps/web/app/workflows/build-org-snapshot.ts">

<violation number="1" location="apps/web/app/workflows/build-org-snapshot.ts:22">
P2: Do not throw when `GITHUB_TOKEN` is unset; this makes the snapshot workflow fail early and prevents base snapshots from being created.</violation>
</file>

<file name="packages/sandbox/vercel/sandbox.ts">

<violation number="1" location="packages/sandbox/vercel/sandbox.ts:601">
P1: The prebuilt sync path hardcodes `main` when `source.branch` is absent, which breaks repos that use a different default branch.</violation>

<violation number="2" location="packages/sandbox/vercel/sandbox.ts:614">
P1: Using `git reset --hard origin/<branch>` here updates commit content but does not switch HEAD to that branch, so branch-aware flows can run against the wrong branch name.</violation>
</file>

You're on the cubic free plan with 20 free PR reviews remaining this month. Upgrade for unlimited reviews.

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

// the tip of the requested branch with a shallow fetch + hard reset so any
// drift since the snapshot was built is caught up in one round-trip.
if (source?.prebuilt && baseSnapshotId) {
const refBranch = source.branch ?? "main";
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The prebuilt sync path hardcodes main when source.branch is absent, which breaks repos that use a different default branch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sandbox/vercel/sandbox.ts, line 601:

<comment>The prebuilt sync path hardcodes `main` when `source.branch` is absent, which breaks repos that use a different default branch.</comment>

<file context>
@@ -594,6 +594,33 @@ ${hostLine}${portLines}${runtimeEnvLine}`;
+    // the tip of the requested branch with a shallow fetch + hard reset so any
+    // drift since the snapshot was built is caught up in one round-trip.
+    if (source?.prebuilt && baseSnapshotId) {
+      const refBranch = source.branch ?? "main";
+      const fetchResult = await sdk.runCommand({
+        cmd: "git",
</file context>
Fix with Cubic

}
const resetResult = await sdk.runCommand({
cmd: "git",
args: ["reset", "--hard", `origin/${refBranch}`],
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Using git reset --hard origin/<branch> here updates commit content but does not switch HEAD to that branch, so branch-aware flows can run against the wrong branch name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sandbox/vercel/sandbox.ts, line 614:

<comment>Using `git reset --hard origin/<branch>` here updates commit content but does not switch HEAD to that branch, so branch-aware flows can run against the wrong branch name.</comment>

<file context>
@@ -594,6 +594,33 @@ ${hostLine}${portLines}${runtimeEnvLine}`;
+      }
+      const resetResult = await sdk.runCommand({
+        cmd: "git",
+        args: ["reset", "--hard", `origin/${refBranch}`],
+        cwd: workingDirectory,
+      });
</file context>
Suggested change
args: ["reset", "--hard", `origin/${refBranch}`],
args: ["checkout", "-B", refBranch, `origin/${refBranch}`],
Fix with Cubic

void start(buildOrgSnapshotWorkflow, [input]).then(
(run) =>
console.log(
`[build-org-snapshot] Started workflow run ${run.runId} for '${input.sandboxName}' (${input.cloneUrl})`,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Avoid logging raw cloneUrl; it can contain credentials or other sensitive user-provided data.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/lib/sandbox/build-org-snapshot-kick.ts, line 19:

<comment>Avoid logging raw `cloneUrl`; it can contain credentials or other sensitive user-provided data.</comment>

<file context>
@@ -0,0 +1,27 @@
+  void start(buildOrgSnapshotWorkflow, [input]).then(
+    (run) =>
+      console.log(
+        `[build-org-snapshot] Started workflow run ${run.runId} for '${input.sandboxName}' (${input.cloneUrl})`,
+      ),
+    (error) =>
</file context>
Fix with Cubic

const result = await Snapshot.list({
name: sandboxName,
sortOrder: "desc",
limit: 5,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Hard-coding limit: 5 can miss an existing ready snapshot and trigger unnecessary slow-path sandbox startup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/lib/sandbox/find-org-snapshot.ts, line 19:

<comment>Hard-coding `limit: 5` can miss an existing ready snapshot and trigger unnecessary slow-path sandbox startup.</comment>

<file context>
@@ -0,0 +1,30 @@
+    const result = await Snapshot.list({
+      name: sandboxName,
+      sortOrder: "desc",
+      limit: 5,
+    });
+    const ready = result.snapshots.find((s) => s.status === "created");
</file context>
Fix with Cubic

repo: repoUrl,
branch: isNewBranch ? undefined : branch,
newBranch: isNewBranch ? branch : undefined,
prebuilt: !!orgSnapshotId,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Enabling prebuilt for new-branch requests can break sandbox creation on repos whose default branch is not main.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/lib/sandbox/create-sandbox-handler.ts, line 115:

<comment>Enabling `prebuilt` for new-branch requests can break sandbox creation on repos whose default branch is not `main`.</comment>

<file context>
@@ -92,10 +95,24 @@ export async function handleCreateSandboxRequest(
     repo: repoUrl,
     branch: isNewBranch ? undefined : branch,
     newBranch: isNewBranch ? branch : undefined,
+    prebuilt: !!orgSnapshotId,
   };
 
</file context>
Fix with Cubic

);

const githubToken = process.env.GITHUB_TOKEN?.trim() || undefined;
if (!githubToken) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Do not throw when GITHUB_TOKEN is unset; this makes the snapshot workflow fail early and prevents base snapshots from being created.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/workflows/build-org-snapshot.ts, line 22:

<comment>Do not throw when `GITHUB_TOKEN` is unset; this makes the snapshot workflow fail early and prevents base snapshots from being created.</comment>

<file context>
@@ -0,0 +1,62 @@
+  );
+
+  const githubToken = process.env.GITHUB_TOKEN?.trim() || undefined;
+  if (!githubToken) {
+    throw new Error(
+      "[build-org-snapshot] GITHUB_TOKEN is not set; cannot clone org repo",
</file context>
Fix with Cubic

@sweetmantech sweetmantech merged commit a900c42 into main Apr 22, 2026
4 checks passed
@sweetmantech sweetmantech deleted the feat/per-org-snapshots branch April 22, 2026 19:42
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