Skip to content

feat(vercel): Vercel Sandbox provider adapter - #16

Open
khaliqgant wants to merge 5 commits into
mainfrom
agent/vercel-adapter-0821
Open

feat(vercel): Vercel Sandbox provider adapter#16
khaliqgant wants to merge 5 commits into
mainfrom
agent/vercel-adapter-0821

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Aug 21, 2026

Copy link
Copy Markdown
Member

Adds VercelSandboxRuntime against the shared SandboxRuntime / WorkflowRuntime ports, following the Freestyle adapter's structure (76cc7463).

Review status: review-ready, pending live evidence. Vercel is not yet in the team's 1Password, so the n=1 canary and n=7 benchmark have not run — zero sandboxes created, zero spend. Every behavioral capability ships false, and docs/vercel.md dates the block explicitly so no cell can be misread as measured. The live run will only flip capability cells and add an economics-doc row; it does not change the code under review here.

Provider-specific decisions

  • Identity is the sandbox name, not an opaque id. RuntimeHandle.id carries the name, which makes the configured prefix a real ownership boundary rather than a labelling convention — stop/start/destroy throw VercelForeignSandboxError outside it, even for a handle attached owned: true.
  • getById goes through list, not Sandbox.get. get resumes the session as a side effect: it boots a VM and starts billing. A crash-recovery scan over a hundred handles must not be able to wake every one of them.
  • Label lookup is a real server-side search (Sandbox.list takes namePrefix and tags), unlike Freestyle's degraded []. Rows are still re-checked against the requested tags in process — a server filter that were ever ignored would hand back a foreign sandbox as a warm lease, which is worse than no lease at all.
  • Delete verification handles name reuse. Vercel has no soft-delete flag, so absence is the only proof; a same-name row with a different createdAt counts as proof ours is gone rather than as a survivor. Failed verification retains the registration so cleanup stays retryable.
  • Commands run as sh -c with cwd/env passed through provider fields, so no caller value is spliced into a command string.
  • One deadline per logical operation, shared across its round trips, under an absolute retryDeadlineMs ceiling that spans the SDK's internal async-retry. Errors name which cap fired.
  • launchDetached is omitted, not faked — create resolves only once the sandbox is running, so there is no mid-boot handle to hand back.
  • acquire() returns an AsyncDisposable for await using scopes (harvested from the opencoredev/sandbox-sdk eval).

Capabilities

All behavioral cells are false pending live evidence, including cleanupVerified — implemented and unit-tested, but not promoted without a dated live-canary comment, matching the discipline Freestyle used. neverIdle is false for a different reason: it is settled false, because every Vercel sandbox carries a termination deadline.

Construction-time reconciliation walks all three registries and throws on any true claim lacking its implementation. reconcileVercelCapabilities is exported and takes a structural surface so its failure paths are unit-tested with broken fakes.

Economics correction

The premise that active-CPU billing makes Vercel dramatically cheapest for I/O-heavy agents needed a correction, documented in docs/vercel.md: Active CPU genuinely excludes I/O wait, but Provisioned Memory is billed on wall clock, not active time. At the default 2 vCPU / 4 GB shape, one wall-clock hour costs $0.341 at 100% CPU, $0.110 at 10%, and $0.085 at 0% — a ~3.1x spread with a $0.0848/h floor set by memory. Cross-provider comparison has to be made against that floor. Active-CPU billing makes the CPU line small for I/O-bound agents; wall-clock memory sets the floor, and stopping the sandbox promptly — not merely leaving it idle — is what removes it.

Testing

46 mocked contract tests (charter minimum was 20). Suite 202 -> 250, 0 failures, 3 pre-existing skips. npm run build and tsc --noEmit clean.

Because the tests were written alongside the implementation, a red-baseline claim would have been fiction. Instead, six invariants were mutation-tested one at a time — client-side tag re-verification, the ownership guard in destroy, the createdAt reuse guard, the sh -c wrapping, registration retention on unverified destroy, and upload verification. Each was caught by the suite, and the file restored byte-identical.

Dependency provenance

@vercel/sandbox@3.0.1, exact optional peer + dev dependency, Apache-2.0. The packument carries no gitHead (monorepo release-bot publish), so the upstream commit d7c3bf55d520c6e5b5381ed87285967b30ecc083 is taken from the package's SLSA v1 provenance attestation rather than fabricated. Integrity hash, shasum, and attestation URL are all in docs/vercel.md. The SDK is imported in exactly one dynamically-imported module and assigned through a structural interface, so vendor drift breaks the build rather than a live sandbox.

🤖 Generated with Claude Code


Summary by cubic

Adds a Vercel Sandbox adapter behind the SandboxRuntime/WorkflowRuntime ports and a live-benchmark harness; previously Vercel was unsupported. Recent updates withdraw non‑idempotent async exec APIs, enforce provider limits, improve pagination/verification, and harden cleanup and reporting.

  • Identity is the sandbox name; start/stop/destroy refuse names outside the configured prefix. getById uses list to avoid waking/billing stopped sandboxes; tag lookup uses server filters plus in‑process rechecks. Delete verification treats same‑name/different‑createdAt as “ours is gone” and retains registrations when absence can’t be proven. Commands run via sh -c with explicit cwd/env. One deadline is shared across each logical operation; errors name whether the op budget or the SDK retry ceiling fired. launchDetached is omitted; acquire() returns an AsyncDisposable. Async exec is now declared false and startScript/getScriptStatus/getScriptLogs are removed. Enforces the 15‑port ceiling and reserves 17 chars for the generated name suffix; findAllByLabels paginates incrementally and short‑circuits on limit≤0; countByLabels short‑circuits on maxCount≤0; uploadBundle verification runs within the shared bundle deadline. The SDK sits behind one dynamic import with checked structural typing; capability reconciliation throws on any true claim without an implementation. All behavioral capabilities remain false pending live evidence.

  • Benchmark harness: sweep runs in the outer finally; destroy failures are classified (only verified‑delete errors count as unverified); cleanup.created includes burst admits; readiness probe fails fast on nonzero/null exit; CLI persists a labeled failure report; sweepError surfaces and marks clean=false. Internal tooling; not exported.

  • Install optional peer @vercel/sandbox (>=3.0.1 <4.0.0).

  • Provide token, teamId, projectId, namePrefix, and defaultHomeDir; ambient VERCEL_* and on‑disk OAuth are ignored.

  • Do not rely on async exec or detached launch; use runScript and acquire() disposal. Orchestrators must read declared capabilities; behavioral flags stay false until live evidence promotes them (no code change required).

Written for commit 3356fca. Summary will update on new commits.

Review in cubic

Implements VercelSandboxRuntime against the shared SandboxRuntime and
WorkflowRuntime ports, following the Freestyle adapter's structure
(76cc7463): SDK-free config/capabilities, the vendor SDK isolated behind
structural interfaces in one dynamically-imported module, and a
construction-time reconciliation that throws if any capability claims
`true` without the implementation behind it.

Provider-specific decisions worth calling out:

- Identity is the sandbox *name*, not an opaque id, so `RuntimeHandle.id`
  carries the name and the configured prefix is a real ownership
  boundary. stop/start/destroy refuse any name outside it.
- `getById` goes through `list`, not `Sandbox.get`, because `get` resumes
  and bills the sandbox as a side effect; a crash-recovery scan must not
  be able to wake every handle it inspects.
- `Sandbox.list` supports server-side `namePrefix` and `tags` filters, so
  label lookup is a real search. Rows are still re-checked against the
  requested tags in process: a server filter that were ever ignored would
  hand back a foreign sandbox as a warm lease.
- Delete verification treats a same-name row with a different `createdAt`
  as proof our sandbox is gone rather than as a survivor, and retains the
  registration when absence cannot be verified so cleanup stays retryable.
- Commands run as `sh -c` with cwd/env passed through provider fields, so
  no caller value is spliced into a command string.
- `retryDeadlineMs` caps a whole logical operation including the SDK's
  internal `async-retry` attempts, carried via AsyncLocalStorage so
  concurrent operations cannot abort each other.
- `launchDetached` is omitted rather than faked: create resolves only once
  the sandbox is running, so there is no mid-boot handle to hand back.
- `acquire()` returns an AsyncDisposable for `await using` scopes.

All behavioral capabilities are declared false pending live evidence,
including `cleanupVerified`, which is implemented and unit-tested but not
yet promoted. `neverIdle` is settled false: every Vercel sandbox carries a
termination deadline.

Dependency: exact optional peer + dev dependency on @vercel/sandbox@3.0.1
(integrity sha512-q/Ne1UaqZ4PWmOj0kTl/KByAR9ZRxOgJCH8WNWcV3ERgb00lO6TQKwS1dSsypHSBHHDfjihByMKX/mlfjIw/cg==,
shasum 30a7a6d9d6366b20ed46fa59a157274455419227). The packument carries no
gitHead; the upstream commit d7c3bf55d520c6e5b5381ed87285967b30ecc083 is
taken from the package's SLSA v1 provenance attestation. Full provenance
and the active-CPU-versus-wall-clock pricing analysis are in
docs/vercel.md.

Tests: 45 mocked contract tests. Suite 202 -> 247, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 5fab24e9-9a59-4f9e-8f5c-f2bbc9dda832
…ndary

Three hardening changes, two adopted from modal-adapter-0821's parallel
lane review and one they prompted.

1. Composite operations now share ONE budget across their round trips.
   Previously each round trip took a fresh copy of the timeout, so
   `runScript(timeoutMs: 60_000)` could wait 120s (command, then output),
   and `uploadBundle`'s worst case was (parents + 2) x fileTimeoutMs —
   unbounded in the number of directories a bundle spans. A `Deadline`
   fixes `expiresAt` once and every later call draws from what is left.
   Applied to runScript/collect, uploadBundle, stop, start, destroy, and
   the list pagination and settle/delete poll loops.

   The timeout error now names which cap fired — the operation budget or
   the SDK retry ceiling. Reporting "did not complete within 60000ms"
   after 25ms because the retry ceiling cut it short sends a reader
   hunting for a slow network instead of at the ceiling they configured.

2. The vendor boundary is a checked assignment, not `as unknown as`.
   Each SDK result is assigned through the structural interface, so a
   vendor signature change breaks the build in the one file that owns the
   boundary instead of surfacing at runtime against a live sandbox. I
   probed assignability against the real .d.ts first; it holds with no
   contortions. `create` narrows to the image or runtime branch rather
   than passing an object that claims both are possible.

3. `reconcileVercelCapabilities` is exported and takes a structural
   surface, so its failure paths are unit-testable with broken fakes —
   including the check that will gate the cleanupVerified promotion.

   Deliberately NOT adopted from that lane: their rule that `start`/`stop`
   present while `lifecycle` is not true should throw. Under-claiming is
   how a capability waits for live evidence here, the orchestrator reads
   the declaration rather than method presence, and a test now pins that
   this under-claim is intentional.

Evidence the tests constrain behaviour: rather than claim a red-baseline
I cannot honestly claim (tests were written alongside the implementation),
I mutation-tested six invariants one at a time — dropped the client-side
tag re-verification, the ownership guard in destroy, the createdAt reuse
guard, the sh -c wrapping, the registration retention on unverified
destroy, and the upload verification. Each was caught (1, 1, 1, 2, 1, 1
failures respectively), and the file restored byte-identical
(sha256 609d4e26267b69a22f3581cdf35c42b245cafe2fdaee15d97b49b2bffcf59bf3).

Also lands, per sandbox-lead-claude-0820b's rulings:
- scripts/vercel-bench.ts, the live harness: ledger-before-use with an
  fsync before the create call goes out, hard caps on count/vCPUs/lifetime,
  and an independent post-run prefix audit reporting destroyed and
  verifiedGone as separate numbers.
- docs/vercel.md states the credential block explicitly and dates it, so
  no capability cell can be misread as measured.

Tests: 46 vercel contract tests (was 45). Suite 247 -> 250, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 5fab24e9-9a59-4f9e-8f5c-f2bbc9dda832
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ae944cd4-271f-4486-a816-a3a2c393d163


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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 883daefd16

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/vercel/runtime.ts Outdated
Comment thread src/vercel/runtime.ts Outdated
Comment thread src/vercel/runtime.ts Outdated
Comment thread src/vercel/runtime.ts Outdated
Comment thread scripts/vercel-bench.ts Outdated

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 11 files

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread scripts/vercel-bench.ts Outdated
Comment thread src/vercel/runtime.ts Outdated
Comment thread src/vercel/runtime.ts
Comment thread src/vercel/runtime.ts Outdated
Comment thread src/vercel/runtime.ts
Comment thread scripts/vercel-bench.ts Outdated
Comment thread scripts/vercel-bench.ts Outdated
Comment thread package.json Outdated
Comment thread src/vercel/config.ts Outdated
Comment thread src/vercel/runtime.test.ts Outdated
The provenance section previously asserted an upstream commit taken from
the SLSA attestation without resolving it. modal-adapter-0821's lane hit
the case that makes that a real gap: a packument `gitHead` that is
present but resolves to "No commit found" in either candidate repo, and a
`repository` field pointing at a different language's client entirely. A
present-but-unresolvable gitHead is worse than a missing one, because it
reads as provenance and is not.

So the claims are now verified rather than quoted, and recorded as a
ladder because these mechanisms are not universally available:

1. SLSA attestation — present here, but only 69 of 241 packages in this
   tree have one. The next provider may not.
2. Registry signature — 241 of 241 verified via `npm audit signatures`.
3. Tarball hash — verified by download; SHA-1 and SHA-512 both match the
   packument. Always available, so this is the floor.
4. gitHead — absent here, which is why rung 1 does the work. Resolved the
   attested commit with `gh api repos/vercel/sandbox/commits/d7c3bf55…`
   ("Version Packages (#284)", 2026-08-20T08:54:45Z), and checked the
   `repository` field against the attestation rather than trusting it.
   Both name github.com/vercel/sandbox, so no mismatch here.

No code change; docs only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 5fab24e9-9a59-4f9e-8f5c-f2bbc9dda832
…untime

The harness shipped with unverified safety guards, which is not a
standard I get to apply to the adapter and not to the thing that will
create billing resources. Prompted by modal-adapter-0821, whose harness
tests its guards before touching a real account.

Splits the core out of the CLI into src/vercel/bench.ts. The CLI keeps
only credentials, durable ledger I/O, and argv; every guard now has a
test:

- ledger-before-use: the fake's own launch call is interleaved into the
  same log, so the ordering is observed rather than assumed. Also covers
  a failed create, where the intent record is the only trace.
- bounded cost: the cap is refused up front and asserts nothing was
  created, because a bound checked after the fact is not a bound.
- verified cleanup: everything created is destroyed; teardown still runs
  when the measurement throws; destroyed and verifiedGone are reported
  separately when verification fails.
- the delete that lied: destroy resolves successfully but the sandbox is
  still listed. Only the independent sweep catches this, and it is the
  failure mode that would otherwise promote cleanupVerified on nothing.
- burst probe: a partially-rejected burst still tears down what it did
  admit.
- cost arithmetic: pins that the memory line is identical at 100%, 10%
  and 0% CPU. That is the whole correction to the "cheapest provider"
  premise, so it is enforced rather than left to a comment.

Mutation-tested the guards themselves: ledgering after create (2 fails),
dropping the bounds check (1), conflating destroyed with verifiedGone
(1), skipping the sweep (2), and billing memory on active CPU rather than
wall clock (1) were all caught.

Moving teardown out of `finally` was NOT caught, and that is honest to
record: it is an equivalent mutant, since the catch above is total and no
behaviour differs. Keeping `finally` anyway — it stays correct if anyone
later makes that catch selective — with a comment saying so.

Restored byte-identical
(sha256 b6ff57f032371456834b0c9c4d5f9799f214f99f24575a4030c78dab4ebbd9e1).

Not exported from the barrel: internal measurement tooling, not API.

Tests: 11 new. Suite 250 -> 261, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Session-Id: 5fab24e9-9a59-4f9e-8f5c-f2bbc9dda832

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 4 files (changes from recent commits).

You’re at about 93% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/vercel/bench.ts Outdated
Comment thread scripts/vercel-bench.ts Outdated
Comment thread src/vercel/bench.ts Outdated
Comment thread src/vercel/bench.ts
Runtime (src/vercel/runtime.ts):
- Withdraw startScript/getScriptStatus/getScriptLogs. Vercel's runCommand has
  no admission key, so a retry after a lost response would submit a second
  command rather than reconcile with the first. The port's asyncExec is
  all-or-nothing; the honest reading is asyncExec:false until the SDK offers
  idempotent submission (or this adapter builds reconciliation on top of it).
- findAllByLabels now paginates incrementally: with a small limit the first
  match on page one skips the rest of the listing, instead of materialising
  every page and truncating. Short-circuits on limit<=0.
- countByLabels short-circuits on maxCount<=0.
- validateNamePrefix reserves the full 17 characters the generated suffix
  uses ("-<16 hex>") rather than 10.
- validatePorts enforces the provider's documented 15-port ceiling.
- uploadBundle's verification step now runs through the shared bundle
  deadline instead of starting a fresh runScript budget after mkdirs/writes
  have already consumed most of fileTimeoutMs.

Benchmark (src/vercel/bench.ts + scripts/vercel-bench.ts):
- Sweep runs in the outer finally so a failed measurement, a failed burst
  probe, or even a ledger error on run-end cannot strand admitted burst
  sandboxes with the cleanup audit unfinished.
- Destroy failure is classified: only VercelDestroyVerificationError counts
  as destroyed-but-unverified; any other error is a submission failure and
  leaves destroyed=false so cleanup counts stay honest.
- cleanup.created now includes burst admits, matching destroyed and
  verifiedGone which already do.
- Readiness probe fails fast on a nonzero or null exit code rather than
  publishing a green-looking latency for a broken sandbox.
- CLI persists a labelled failure report even if runBenchmark rejects, so
  the ledger, the results file, and the exit code all agree.
- sweepError surfaces in the report; clean is false whenever the sweep did
  not certify itself.

Docs / config:
- package.json: relax @vercel/sandbox peer to ">=3.0.1 <4.0.0" for parity
  with the other providers and the structural-interface drift-tolerance
  design.
- docs/vercel.md + src/vercel/config.ts: correct the asyncExec capability
  row and the "two forms" doc that should have said three.

Tests: cover port limit, prefix length, sweep-failure surfacing, readiness
probe failure, destroy-submit-vs-verification classification, and burst
admits in cleanup.created; replace the flaky wall-clock assertion with
timeoutMs/outputCalls checks that the shared budget already proves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Session-Id: a0528f13-7875-43cb-9b06-cbfcc2e9fe33
@kjgbot

kjgbot commented Aug 21, 2026

Copy link
Copy Markdown

Review backlog cleared — merge-ready

Threads resolved: 19 / 19 (16 previously unresolved + 3 already resolved).
Threads remaining: 0.
Merge-ready: yes, from my seat. Final call is Khaliq's per lane policy.

Fixes landed in 3356fca

P1

  • Withdrew startScript / getScriptStatus / getScriptLogs; port now reports asyncExec: false. Vercel's runCommand has no admission key, and the port capability is all-or-nothing — exposing the trio without durable dedup would let a lost-response retry submit a duplicate command. Trio will return when the SDK offers idempotent submission (or this adapter builds reconciliation on top of it).
  • sweep() now runs in the outer finally of runBenchmark; the run-end ledger write is wrapped so its failure cannot preempt the audit. Sweep errors surface on report.cleanup.sweepError; clean is false whenever the sweep did not certify itself.

P2

  • findAllByLabels paginates incrementally through a new listPages async generator and returns as soon as limit matches accrue — single-result lookups stop on page one instead of tripping the page cap on projects that happened to match on page one.
  • countByLabels / findAllByLabels short-circuit on non-positive maxCount / limit before any lookup.
  • Destroy failures in the benchmark are classified: only VercelDestroyVerificationError counts as destroyed-but-unverified; any other error is a submission failure and leaves sample.destroyed = false so cleanup counts stay honest.
  • validateNamePrefix reserves the full 17 characters the generated suffix uses (GENERATED_SUFFIX_LENGTH) instead of 10.
  • validatePorts enforces the documented 15-port ceiling.
  • uploadBundle verification now runs through the shared bundle Deadline via a new runScriptWithDeadline, instead of starting a fresh runScript budget after mkdirs and writes have already consumed most of fileTimeoutMs.
  • CLI wraps runBenchmark in try/catch and always persists a report (labelled with the error on rejection), so ledger, JSON, and exit code stay in sync.
  • cleanup.created adds burst?.admitted, matching destroyed and verifiedGone.
  • measureOne fails fast on a nonzero or null readiness-probe exit rather than publishing a green-looking latency for a broken sandbox.

P3

  • @vercel/sandbox peer relaxed to >=3.0.1 <4.0.0 for parity with the other providers and the structural-interface drift-tolerance design.
  • VercelSandboxSource doc corrected to "three forms".
  • Flaky wall-clock assertion in runtime.test.ts replaced with error.timeoutMs === 40 + outputCalls === 1 — the shared-budget invariant those already prove.

CI

Green per-workflow (single CI workflow, verified via gh run list --branch agent/vercel-adapter-0821, not the rollup):

  • 32515725265 (head 3356fca): completed / success.

Coverage grew from 260 to 267 tests: added port-limit, prefix-length, sweep-failure, readiness-probe (nonzero + null), destroy-submit-vs-verification, and burst-admits-in-cleanup.created assertions.

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.

2 participants