
Benchmark Sandbox
- 1.1k installs
- 229 repo stars
- Updated July 27, 2026
- vercel-labs/vercel-plugin
benchmark-sandbox provides documented workflows for Run vercel-plugin eval scenarios in Vercel Sandboxes instead of local WezTerm panels. Provisions ephemeral microVMs with Claude Code + plugin pre-installed, run
About
The benchmark-sandbox skill run vercel-plugin eval scenarios in Vercel Sandboxes instead of local WezTerm panels. Provisions ephemeral microVMs with Claude Code + plugin pre-installed, runs benchmark prompts, extracts hook artifacts, and produces coverage reports. # Benchmark Sandbox - Remote Eval via Vercel Sandboxes Run benchmark scenarios inside Vercel Sandboxes - ephemeral Firecracker microVMs with node24. Each sandbox gets a fresh Claude Code + Vercel CLI + agent-browser install, the local vercel-plugin uploaded, and runs a **3-phase eval pipeline**: - **Phase 1 (BUILD)**: Claude Code builds the app with `--dangerously-skip-permissions --debug` - **Phase 2 (VERIFY)**: A follow-up Claude Code session uses `agent-browser` to walk through user stories, fixing issues until all pass (20 min timeout) - **Phase 3 (DEPLOY)**: A third Claude Code session links to vercel-labs, runs `vercel deploy`, and fixes build errors (up to 3 retries). Deployed apps have deployment protection enabled by default. Skills are tracked across **all 3 phases** - each phase may trigger additional skill injections as new files/patterns are created.
- **Phase 1 (BUILD)**: Claude Code builds the app with `--dangerously-skip-permissions --debug`
- **Phase 2 (VERIFY)**: A follow-up Claude Code session uses `agent-browser` to walk through user stories, fixing issues u
- Focus on **what the user wants**, not what tech to use
- Describe real-world apps that solve real problems with friendly, stylish UX
- Include AI features naturally (recommendations, analysis, generation)
Benchmark Sandbox by the numbers
- 1,102 all-time installs (skills.sh)
- Ranked #218 of 1,048 Mobile Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
benchmark-sandbox capabilities & compatibility
- Capabilities
- **phase 1 (build)**: claude code builds the app · **phase 2 (verify)**: a follow up claude code se · focus on **what the user wants**, not what tech · describe real world apps that solve real problem · include ai features naturally (recommendations,
- Use cases
- documentation
What benchmark-sandbox says it does
Deployed apps have deployment protection enabled by default.
npx skills add https://github.com/vercel-labs/vercel-plugin --skill benchmark-sandboxAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.1k |
|---|---|
| repo stars | ★ 229 |
| Security audit | 1 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | vercel-labs/vercel-plugin ↗ |
How do I use benchmark-sandbox for the task described in its SKILL.md triggers?
Run vercel-plugin eval scenarios in Vercel Sandboxes instead of local WezTerm panels. Provisions ephemeral microVMs with Claude Code + plugin pre-installed, runs benchmark prompts, extracts hook arti.
Who is it for?
Teams invoking benchmark-sandbox when the user request matches documented triggers and prerequisites.
Skip if: Skip when cached docs are missing, the request is a negative trigger, or another sibling skill owns the workflow.
When should I use this skill?
Run vercel-plugin eval scenarios in Vercel Sandboxes instead of local WezTerm panels. Provisions ephemeral microVMs with Claude Code + plugin pre-installed, runs benchmark prompts, extracts hook artifacts, and produces c
What you get
Step-by-step guidance grounded in benchmark-sandbox documentation and reference files.
- result.json per scenario
- Markdown coverage report
- Deployed preview URLs
By the numbers
- 3-phase BUILD-VERIFY-DEPLOY eval pipeline per scenario
- Uploads 80 vercel-plugin files (~945KB) into each sandbox
- Supports up to 10 concurrent sandboxes via --concurrency flag
Files
Benchmark Sandbox — Remote Eval via Vercel Sandboxes
Run benchmark scenarios inside Vercel Sandboxes — ephemeral Firecracker microVMs with node24. Each sandbox gets a fresh Claude Code + Vercel CLI + agent-browser install, the local vercel-plugin uploaded, and runs a 3-phase eval pipeline:
- Phase 1 (BUILD): Claude Code builds the app with
--dangerously-skip-permissions --debug - Phase 2 (VERIFY): A follow-up Claude Code session uses
agent-browserto walk through user stories, fixing issues until all pass (20 min timeout) - Phase 3 (DEPLOY): A third Claude Code session links to vercel-labs, runs
vercel deploy, and fixes build errors (up to 3 retries). Deployed apps have deployment protection enabled by default.
Skills are tracked across all 3 phases — each phase may trigger additional skill injections as new files/patterns are created. After each phase, a haiku structured scoring step (claude -p --json-schema --model haiku) evaluates the results as structured JSON.
Proven Working Script
Use run-eval.ts — the proven eval runner:
# Run default scenarios with full 3-phase pipeline
bun run .claude/skills/benchmark-sandbox/run-eval.ts
# With dynamic scenarios from a JSON file (recommended — see "Dynamic Scenarios" below)
bun run .claude/skills/benchmark-sandbox/run-eval.ts --scenarios-file /tmp/my-scenarios.json
# Keep sandboxes alive overnight with public URLs
bun run .claude/skills/benchmark-sandbox/run-eval.ts --keep-alive --keep-hours 8
# Build-only (skip verification and deploy)
bun run .claude/skills/benchmark-sandbox/run-eval.ts --skip-verify --skip-deploy
# Run specific scenarios by slug
bun run .claude/skills/benchmark-sandbox/run-eval.ts --scenarios splitwise-clone,calendly-cloneCLI Flags
| Flag | Default | Description |
|---|---|---|
--concurrency N | 5 | Max parallel sandboxes (max 10) |
--timeout MS | 1800000 (30 min) | Per-phase timeout in ms |
--keep-alive | off | Keep sandboxes running after eval |
--keep-hours N | 8 | Hours to keep alive (with --keep-alive) |
--skip-verify | off | Skip the agent-browser verification phase |
--skip-deploy | off | Skip the Vercel deploy phase |
--scenarios a,b,c | all | Only run specific scenarios by slug |
--scenarios-file path | — | Load scenarios from a JSON file instead of built-in defaults |
Dynamic Scenarios (Recommended Approach)
Instead of hardcoding tech-specific prompts, generate scenarios dynamically as a JSON file. Prompts should describe real-world apps people want to build using user stories — no tech name-dropping. Let the plugin figure out what Vercel tech to inject.
Scenario JSON Format
[
{
"slug": "pet-adoption-board",
"prompt": "Build me a pet adoption listing board where shelters can post animals...",
"expectedSkills": ["ai-sdk", "nextjs", "shadcn", "vercel-functions"],
"userStories": [
"As a visitor, I can see a grid of pet listings with photos and names",
"As a visitor, I can click a pet card to see a detail page",
"As a visitor, I can filter pets by type"
]
}
]Each scenario needs: slug (string), prompt (string), expectedSkills (string[]), userStories (tuple of exactly 3 strings).
Prompt Design Guidelines
- Focus on what the user wants, not what tech to use
- Describe real-world apps that solve real problems with friendly, stylish UX
- Include AI features naturally (recommendations, analysis, generation)
- Always end with:
"Link the project to my vercel-labs team. After building all files, start the dev server on port 3000 with \npx next dev --port 3000\." - Include storage needs (photos, uploads) to trigger vercel-storage
- Include scheduled tasks (reminders, cleanup) to trigger cron-jobs
- Include auth/middleware to trigger routing-middleware
Structured Scoring (Haiku)
Each phase gets a structured JSON score via claude -p --json-schema --model haiku --setting-sources "" running inside the sandbox. This is a separate quick pass — no tools, no hooks — just reads the phase output and returns structured data.
Build Score Schema
{
"completeness": "complete|partial|minimal|empty",
"hasApiRoutes": true,
"hasUIComponents": true,
"hasAIFeature": true,
"devServerRunning": true,
"missingFeatures": ["feature1"],
"summary": "Brief assessment"
}Verify Score Schema (per user story)
{
"stories": [
{ "index": 1, "status": "pass|fail", "reason": "Evidence from output" }
]
}Deploy Score Schema
{
"deployed": true,
"url": "https://xxx.vercel.app",
"buildSucceeded": true,
"errors": [],
"summary": "Brief assessment"
}Important: The claude -p --output-format json response wraps results — the actual schema data is in parsed.structured_output, not the top-level object.
Critical Sandbox Environment Facts
| Property | Value |
|---|---|
| Home directory | /home/vercel-sandbox (NOT /home/user/ or /root/) |
| User | vercel-sandbox (NOT root) |
| Claude binary | /home/vercel-sandbox/.global/npm/bin/claude |
| PATH (via sh -c) | Includes ~/.global/npm/bin — claude findable by name |
| Port exposure | sandbox.domain(3000) → https://subdomain.vercel.run |
| Snapshot persistence | Files AND npm globals survive snapshot restore — use sandbox.snapshot() → Sandbox.create({ source: { type: "snapshot", snapshotId } }) |
| SDK version | @vercel/sandbox@1.8.0 (v2 beta's named sandbox endpoint returns 404 for this team) |
| Team tier | Enterprise (vercel-labs) — no known sandbox time cap |
Key Discoveries (Hard-Won)
1. Snapshots work: sandbox.snapshot() preserves files AND npm globals. Use it after build to create a restore point before verify/deploy. Note: snapshotting stops the source sandbox — create a new one from the snapshot to continue. 2. Plugin install: Use npx add-plugin <path> -s project -y --target claude-code — works because claude is in PATH after npm install -g. The --target claude-code flag is required because add-plugin can't auto-detect Claude Code without an initialized ~/.claude/ dir. 3. File uploads: Use sandbox.writeFiles([{ path, content: Buffer }]) — NOT runCommand heredocs. Heredocs with special characters cause 400 errors from the sandbox API. 4. Claude flags: Always use --dangerously-skip-permissions --debug. The --debug flag writes to ~/.claude/debug/. 5. Auth: API key from macOS Keychain (ANTHROPIC_AUTH_TOKEN — a vck_* Vercel Claude Key for AI Gateway), Vercel token from ~/.local/share/com.vercel.cli/auth.json (a vca_* token). 6. OIDC for sandbox SDK: Run npx vercel link --scope vercel-labs -y + npx vercel env pull once before first use. 7. Port exposure: Pass ports: [3000] in Sandbox.create() to get a public URL immediately via sandbox.domain(3000). Works on v1.8.0 — URL is assigned at creation time, before anything listens. 8. extendTimeout: Use sandbox.extendTimeout(ms) to keep sandboxes alive past their initial timeout. Verified working — extends by the requested duration. Use this for overnight keep-alive. 9. Background commands: runCommand with backgrounded processes (& or nohup) may throw ZodError on v1. Write a script file first, then execute it. 10. Session cleanup race: The session-end-cleanup.mjs hook deletes /tmp/vercel-plugin-*-seen-skills.d/ on session end. Extract artifacts BEFORE the session completes, or rely on poll history data. 11. agent-browser works in sandboxes: Install via npm install -g agent-browser. Claude Code can use it for browser-based verification inside the sandbox. 12. No hobby tier cap: Early 301s timeouts were from lower default timeout values in earlier script iterations, not a tier limitation. Enterprise (vercel-labs) has no known sandbox time cap — sandboxes ran 10+ minutes successfully. 13. claude -p works inside sandboxes: claude -p --json-schema --output-format json --model haiku works for structured scoring passes. No nesting issue when running inside a sandbox (only fails when running Claude inside Claude on the same machine). 14. Deploy project naming: ALWAYS use timestamped slugs with minute precision (e.g., pet-adoption-board-202603101853) to avoid collisions when linking to vercel-labs team projects. These are demo projects — we generate many per day. Format: <slug>-<YYYYMMDDHHMM>.
When to Use This vs benchmark-agents
| benchmark-agents (WezTerm) | benchmark-sandbox | |
|---|---|---|
| Environment | Local macOS terminal panes | Remote Vercel Sandboxes (Amazon Linux) |
| Parallelism | Limited by local resources | Up to 10 (Hobby) or 2,000 (Pro) concurrent |
| Session type | Interactive TTY via /bin/zsh -ic | Direct sh -c invocation (PTY not required) |
| Artifact access | Direct filesystem (~/.claude/debug/) | sandbox.readFile() / poll via runCommand |
| Port exposure | localhost:3000 | Public https://sb-XXX.vercel.run URLs |
| Verification | Manual browser check | Automated agent-browser in Phase 2 |
| Deploy | Manual | Automated Phase 3 → permanent *.vercel.app URLs |
| Scoring | Manual review | Haiku structured JSON scoring per phase |
| Best for | Manual eval + iteration loop | Automated parallel coverage + verification + deploy runs |
How It Works
1. Create fresh sandbox: Sandbox.create({ runtime: "node24", ports: [3000], env: { ANTHROPIC_API_KEY, ... } }) — no snapshot 2. Install tools: npm install -g @anthropic-ai/claude-code vercel agent-browser (~20s per sandbox) 3. Auth Vercel CLI: Write token to ~/.local/share/com.vercel.cli/auth.json 4. Upload plugin: sandbox.writeFiles() for 80 plugin files, then npx add-plugin 5. Phase 1 — BUILD: Claude Code builds the app (30 min timeout) 6. Score build: Haiku evaluates completeness, API routes, UI, AI features 7. Start dev server: If not already running, start npx next dev --port 3000 8. Extend timeout: sandbox.extendTimeout() for verify + deploy + keep-alive 9. Phase 2 — VERIFY: Claude Code uses agent-browser to test user stories (20 min timeout). Prompt tells Claude to start dev server itself if not running. 10. Score verify: Haiku evaluates each user story as pass/fail with reasons 11. Re-extract skills: Skills re-collected after verify phase (agent-browser + code fixes trigger more) 12. Phase 3 — DEPLOY: Claude Code runs vercel link + vercel deploy, fixes build errors (30 min timeout) 13. Score deploy: Haiku evaluates deploy success, URL extraction, errors 14. Re-extract skills: Skills re-collected after deploy phase 15. Write incremental results: Each scenario writes its own result.json immediately on completion (survives crashes) 16. Extract source archive: source.tar.gz of project files saved locally 17. Generate report: Markdown report with build/verify/deploy scores, skill coverage, URLs
Sandbox Session Flow (Per Scenario)
Sandbox.create({ runtime: "node24", ports: [3000], env: { ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, VERCEL_PLUGIN_LOG_LEVEL: "trace" } })
│
├─ npm install -g @anthropic-ai/claude-code vercel agent-browser (~20s)
├─ Write Vercel CLI auth token to ~/.local/share/com.vercel.cli/auth.json
├─ mkdir -p /home/vercel-sandbox/<slug> && npm init -y
├─ sandbox.writeFiles() → /home/vercel-sandbox/vercel-plugin/ (80 files, ~945KB)
├─ npx add-plugin /home/vercel-sandbox/vercel-plugin -s project -y --target claude-code
│
├─ Phase 1: BUILD
│ ├─ sandbox.writeFiles() → /tmp/prompt.txt
│ ├─ claude --dangerously-skip-permissions --debug --settings <path> "$(cat /tmp/prompt.txt)"
│ │ (with AbortSignal.timeout(TIMEOUT_MS))
│ ├─ Poll every 20s:
│ │ ├─ ls /tmp/vercel-plugin-*-seen-skills.d/ (claimed skills)
│ │ ├─ cat /tmp/vercel-plugin-*-seen-skills.txt (seen skills snapshot)
│ │ ├─ find ~/.claude/debug -type f (debug log count)
│ │ ├─ find <project> -newer /tmp/prompt.txt (new project files)
│ │ └─ curl localhost:3000 (port status)
│ ├─ Extract build artifacts
│ └─ Haiku build score (structured JSON)
│
├─ Start dev server (if not already running)
├─ sandbox.extendTimeout(...)
│
├─ Phase 2: VERIFY (if >1 project file exists)
│ ├─ sandbox.writeFiles() → /tmp/verify.txt (agent-browser verification prompt)
│ ├─ claude --dangerously-skip-permissions --debug "$(cat /tmp/verify.txt)"
│ │ (with AbortSignal.timeout(1_200_000) — 20 min)
│ ├─ Re-extract skills (verify phase triggers more)
│ └─ Haiku verify score (per-story pass/fail JSON)
│
├─ Phase 3: DEPLOY (if >3 project files)
│ ├─ sandbox.writeFiles() → /tmp/deploy.txt
│ ├─ claude --dangerously-skip-permissions --debug "$(cat /tmp/deploy.txt)"
│ │ (links to vercel-labs, deploys, fixes build errors up to 3x)
│ ├─ Extract deploy URL from output (*.vercel.app)
│ ├─ Re-extract skills (deploy phase triggers more)
│ └─ Haiku deploy score (structured JSON)
│
├─ Write <slug>/result.json immediately (crash-safe)
├─ Update aggregate results.json (complete: false until all done)
├─ Extract source.tar.gz
└─ sandbox.stop() (skipped if --keep-alive)Verification Phase Details
The verify phase is the "closer" — its job is to make the app work and prove it. Key behaviors:
- Always runs if >1 project file exists (no longer gated on port 3000 being up)
- Starts dev server itself if not already running — the prompt tells Claude to check
localhost:3000and runnpx next dev --port 3000if needed - 20 minute timeout — enough for agent-browser to open pages, screenshot, interact, fix broken code, restart server, and re-verify
- Triggers skill injection — the verify session creates/edits files, triggering PreToolUse and PostToolUse hooks
- Uses agent-browser workflow:
open→wait --load networkidle→screenshot --annotate→snapshot -i→ interact → fix → re-verify - Results scored by haiku — no more parsing
STORY_1: PASSfrom free text
Deploy Phase Details
The deploy phase uses a full Claude Code session (for skill tracking) to:
1. Run vercel link --yes --scope vercel-labs --project <slug>-YYYYMMDD 2. Run vercel deploy --yes 3. If build fails, fix code and retry (up to 3 attempts) 4. Important: unsets VERCEL_TOKEN env var so CLI falls back to ~/.local/share/com.vercel.cli/auth.json 5. Deployment protection is enabled by default on vercel-labs team
Deploy URL is extracted by regex from Claude's output, with haiku as fallback URL extractor.
DO NOT (Hard Rules)
Same rules as benchmark-agents, plus sandbox-specific:
- DO NOT use
claude --printor-pflag for BUILD/VERIFY/DEPLOY phases — hooks don't fire without tool-calling sessions (use-ponly for haiku scoring passes) - DO NOT let sandboxes run without extracting artifacts — ephemeral filesystem is lost on stop
- DO NOT pass API keys via
writeFiles()— useSandbox.create({ env: { ... } }) - DO NOT skip snapshotting after build — it's your safety net if verify/deploy kills the sandbox
- DO NOT use v2 beta SDK — named sandbox endpoint returns 404 for this team; use v1.8.0
- DO NOT use
runCommandheredocs to write file content — usesandbox.writeFiles()instead - DO NOT assume
/home/user/exists — the home dir is/home/vercel-sandbox/ - DO NOT use simple project names without timestamps — always append
-YYYYMMDDHHMMto avoid collisions across runs
Prerequisites
# One-time setup: link project for OIDC sandbox auth
npx vercel link --scope vercel-labs -y
npx vercel env pull .env.local
# Auth (auto-resolved from macOS Keychain + Vercel CLI auth):
# - ANTHROPIC_API_KEY: from Keychain "ANTHROPIC_AUTH_TOKEN" (vck_* key) or env var
# - VERCEL_TOKEN: from ~/.local/share/com.vercel.cli/auth.json (vca_* token) or env var
# - ANTHROPIC_BASE_URL: defaults to https://ai-gateway.vercel.shCommands
Run eval with dynamic scenarios (recommended)
# Generate scenarios as JSON, then run
bun run .claude/skills/benchmark-sandbox/run-eval.ts --scenarios-file /tmp/my-scenarios.json
# With all phases + keep-alive for overnight
bun run .claude/skills/benchmark-sandbox/run-eval.ts --scenarios-file /tmp/scenarios.json --keep-alive --keep-hours 8
# Build-only, no verification or deploy
bun run .claude/skills/benchmark-sandbox/run-eval.ts --scenarios-file /tmp/scenarios.json --skip-verify --skip-deploy
# Filter to specific slugs from file or defaults
bun run .claude/skills/benchmark-sandbox/run-eval.ts --scenarios splitwise-clone,calendly-cloneMonitoring While Running
The orchestrator prints live status. For manual checks on a running sandbox:
// List claimed skills
const claims = await sandbox.runCommand("sh", ["-c",
"ls /tmp/vercel-plugin-*-seen-skills.d/ 2>/dev/null"
]);
// Check hook firing count
const hooks = await sandbox.runCommand("sh", ["-c",
"find /home/vercel-sandbox/.claude/debug -name '*.txt' -exec grep -c 'executePreToolHooks' {} +"
]);
// Check port 3000
const port = await sandbox.runCommand("sh", ["-c",
"curl -s -o /dev/null -w '%{http_code}' http://localhost:3000"
]);
// Get public URL (after ports: [3000] in Sandbox.create)
const url = sandbox.domain(3000);Artifact Export Layout
Results are written to ~/dev/vercel-plugin-testing/sandbox-results/<run-id>/:
<run-id>/
results.json # Aggregate results (complete: false until all done, then true)
report.md # Markdown report with scores, coverage, URLs
<slug>/
result.json # Per-scenario result (written immediately on completion)
source.tar.gz # Project source archiveEach scenario result includes:
slug,sandboxId,success,durationMsclaimedSkills[],expectedSkills[],projectFiles[]appUrl— publichttps://sb-XXX.vercel.runURL (sandbox lifetime only)deployUrl— permanenthttps://xxx.vercel.appURL (if deploy succeeded)pollHistory[]— timestamped skill/file/port snapshotsverification—{ ran, exitCode, stories: [{ index, status }], output }buildScore— haiku structured completeness assessmentdeployScore— haiku structured deploy assessment
The markdown report (report.md / .reports/<timestamp>.md) includes: 1. Summary table — slug, build status, skills, files, verify results, deploy URL, duration 2. Per-scenario details — build score, deploy score, verification per-story pass/fail 3. Skill coverage — expected vs actual per scenario, missing/bonus breakdown 4. Total unique skills across all scenarios
Proven Results (2026-03-10)
Across 34 scenarios run in 5 batches:
| Metric | Best | Typical |
|---|---|---|
| Skills per scenario | 31 (ai-interior-designer) | 12-24 |
| Expected skill coverage | 100% (pet-adoption-board 4/4, apartment-hunting-copilot 7/7, splitwise-clone 6/6) | 50-86% |
| User stories verified | 3/3 PASS (ai-dream-journal, ai-gift-finder, ai-resume-roaster, ai-music-mood-radio, team-standup-bot, pet-adoption-board) | varies |
| Files built per scenario | 37 (student-study-groups) | 6-25 |
| Build time | 5-11 min | 5-7 min |
Key findings:
- User-story-focused prompts (no tech name-dropping) work — plugin detects patterns from actual code
ai-sdk,shadcn,nextjs,vercel-functionsare the most consistently detected skillscron-jobs,routing-middlewareneed Claude to write specific file patterns to trigger- Lexical prompt inject (UserPromptSubmit) working — skills injected before any files written
session-end-cleanupdeletes claim dirs — use poll history for final skill counts- Enterprise tier (vercel-labs) — no sandbox time cap; builds ran 10+ minutes
Known Limitations
1. Snapshot stops the source sandbox: sandbox.snapshot() stops the original sandbox. Create a new sandbox from the snapshot to continue. Files and npm globals DO survive. 2. v2 beta incompatible: @vercel/sandbox@2.0.0-beta.3's named sandbox endpoint returns 404 for this team. Stick with v1.8.0. 3. Artifact window: Must extract before sandbox.stop() — filesystem is ephemeral. Session cleanup hook may delete claim dirs before extraction. 4. Amazon Linux paths: User is vercel-sandbox (home at /home/vercel-sandbox/). NOT /home/user/ or /root/. 5. `--dangerously-skip-permissions` parity: Sandbox evals auto-approve all tool calls. WezTerm evals use normal permission flow. Coverage results may differ. 6. `runCommand` timeout: Use { signal: AbortSignal.timeout(ms) } — the { timeout } option is silently ignored. 7. BrotliDecompressionError: Transient Vercel API errors can kill sandbox creation. Retry logic recommended for production runs. 8. Deploy reliability: Claude Code deploy sessions sometimes fail to output a parseable *.vercel.app URL. The haiku scoring step provides a fallback URL extraction attempt. 9. Verify timeout: Complex apps may need the full 20 minutes for agent-browser to test all stories. Simpler apps finish in 2-5 minutes.
/**
* Structured logger for the sandbox benchmark runner.
*
* Supports two output formats:
* - "human" (default): timestamped human-readable lines → stderr
* - "json": NDJSON RunnerEvent objects → stderr
*
* All log output goes to stderr so that --json mode can keep stdout clean
* for the final RunSummary.
*
* API keys are automatically redacted from all output.
*/
import type { RunnerEvent, RunnerEventName } from "./types.js";
// ---------------------------------------------------------------------------
// Secret redaction
// ---------------------------------------------------------------------------
/** Patterns that look like API keys (sk-ant-*, anthropic key prefixes, long hex strings preceded by key-like env var names). */
const SECRET_PATTERNS = [
// Anthropic keys: sk-ant-api03-... or sk-...
/\bsk-ant-[a-zA-Z0-9_-]{20,}\b/g,
/\bsk-[a-zA-Z0-9_-]{40,}\b/g,
// Generic long bearer/api tokens following common env var assignments
/(?<=(?:API_KEY|AUTH_TOKEN|SECRET|ANTHROPIC_API_KEY|AI_GATEWAY_API_KEY)\s*[=:]\s*)[^\s"']{20,}/gi,
];
export function redact(input: string): string {
let result = input;
for (const pattern of SECRET_PATTERNS) {
// Reset lastIndex for global regexes
pattern.lastIndex = 0;
result = result.replace(pattern, "[REDACTED]");
}
return result;
}
// ---------------------------------------------------------------------------
// Logger
// ---------------------------------------------------------------------------
export type LogFormat = "human" | "json";
export interface LoggerOptions {
format: LogFormat;
runId: string;
}
export class Logger {
private readonly format: LogFormat;
private readonly runId: string;
constructor(opts: LoggerOptions) {
this.format = opts.format;
this.runId = opts.runId;
}
/** Informational message (human) or structured event (json). */
info(message: string): void {
this.write("info", message);
}
/** Warning message. */
warn(message: string): void {
this.write("warn", message);
}
/** Error message. */
error(message: string): void {
this.write("error", message);
}
/**
* Emit a structured RunnerEvent. In human mode this prints a readable line;
* in json mode it writes a single NDJSON line.
*/
event(name: RunnerEventName, payload: Record<string, unknown> = {}): void {
if (this.format === "json") {
const ev: RunnerEvent = {
schema_version: 1,
run_id: this.runId,
event: name,
timestamp: new Date().toISOString(),
payload: this.redactPayload(payload),
};
process.stderr.write(JSON.stringify(ev) + "\n");
} else {
const payloadStr = Object.keys(payload).length > 0
? " " + Object.entries(payload).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ")
: "";
this.write("event", `[${name}]${payloadStr}`);
}
}
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
private write(level: string, message: string): void {
const safe = redact(message);
if (this.format === "json") {
// In json mode, non-event messages are still human-readable on stderr
// but prefixed with level for easy filtering
process.stderr.write(`${this.timestamp()} [${level}] ${safe}\n`);
} else {
process.stderr.write(`${this.timestamp()} ${safe}\n`);
}
}
private timestamp(): string {
const now = new Date();
return now.toISOString().slice(11, 23); // HH:mm:ss.SSS
}
private redactPayload(payload: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(payload)) {
if (typeof value === "string") {
result[key] = redact(value);
} else {
result[key] = value;
}
}
return result;
}
}
#!/usr/bin/env bun
/**
* Sandbox benchmark analyzer: reads artifacts from sandbox-runner output,
* extracts debug logs and claim dirs, and produces a coverage report.
*
* Usage:
* bun run .claude/skills/benchmark-sandbox/sandbox-analyze.ts [options]
*
* --run-dir <path> Path to a specific run directory (default: latest in sandbox-results/)
* --json Output JSON report instead of human-readable
* --help Print usage and exit
*/
import { readFile, readdir, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { homedir } from "node:os";
import type { RunSummary, ScenarioSummary } from "./types.js";
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: flags } = parseArgs({
options: {
"run-dir": { type: "string" },
json: { type: "boolean", default: false },
help: { type: "boolean", default: false },
},
strict: true,
});
if (flags.help) {
console.log(`Usage: bun run .claude/skills/benchmark-sandbox/sandbox-analyze.ts [options]
--run-dir <path> Path to a specific run directory
--json Output JSON report
--help Print usage`);
process.exit(0);
}
const DEFAULT_RESULTS = join(homedir(), "dev", "vercel-plugin-testing", "sandbox-results");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface HookCoverage {
sessionStart: boolean;
preToolUse: boolean;
postToolUse: boolean;
userPromptSubmit: boolean;
}
interface ScenarioAnalysis {
slug: string;
sandboxId: string;
success: boolean;
timedOut: boolean;
durationSec: number;
expectedSkills: string[];
claimedSkills: string[];
missingSkills: string[];
unexpectedSkills: string[];
coveragePercent: number;
hookCoverage: HookCoverage;
debugLogLines: number;
stderrTraceLines: number;
error?: string;
}
interface CoverageReport {
runId: string;
timestamp: string;
snapshotId: string;
totalDurationSec: number;
scenarioCount: number;
passCount: number;
failCount: number;
timeoutCount: number;
hookFiredCount: number;
scenarios: ScenarioAnalysis[];
skillInjectionMatrix: Record<string, string[]>; // skill → slugs where injected
allExpectedSkills: string[];
allClaimedSkills: string[];
coveragePercent: number; // expected skills that were actually claimed
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function findLatestRunDir(): Promise<string> {
const entries = await readdir(DEFAULT_RESULTS);
const runDirs = entries.filter((e) => e.startsWith("run-")).sort().reverse();
if (runDirs.length === 0) {
console.error(`No run directories found in ${DEFAULT_RESULTS}`);
process.exit(1);
}
return join(DEFAULT_RESULTS, runDirs[0]);
}
async function readJsonFile<T>(path: string): Promise<T> {
const content = await readFile(path, "utf-8");
return JSON.parse(content) as T;
}
async function countLines(path: string): Promise<number> {
try {
const content = await readFile(path, "utf-8");
return content.split("\n").length;
} catch {
return 0;
}
}
async function dirExists(path: string): Promise<boolean> {
try {
const s = await stat(path);
return s.isDirectory();
} catch {
return false;
}
}
function parseHookCoverageFromDebugLogs(logContent: string): HookCoverage {
return {
sessionStart:
logContent.includes("SessionStart") || logContent.includes("session-start"),
preToolUse:
logContent.includes("PreToolUse") ||
logContent.includes("pretooluse") ||
logContent.includes("skill-inject"),
postToolUse:
logContent.includes("PostToolUse") ||
logContent.includes("posttooluse") ||
logContent.includes("validate"),
userPromptSubmit:
logContent.includes("UserPromptSubmit") ||
logContent.includes("user-prompt-submit") ||
logContent.includes("prompt-signal"),
};
}
// ---------------------------------------------------------------------------
// Analysis
// ---------------------------------------------------------------------------
async function analyzeRun(runDir: string): Promise<CoverageReport> {
const manifest = await readJsonFile<RunSummary>(join(runDir, "run-manifest.json"));
const scenarios: ScenarioAnalysis[] = [];
const skillInjectionMatrix: Record<string, string[]> = {};
for (const s of manifest.scenarios) {
const slugDir = join(runDir, s.slug);
// Read debug logs if available
let debugLogContent = "";
const debugLogsDir = join(slugDir, "debug-logs");
if (await dirExists(debugLogsDir)) {
const logFiles = await readdir(debugLogsDir).catch(() => []);
for (const f of logFiles) {
debugLogContent += await readFile(join(debugLogsDir, f), "utf-8").catch(() => "");
}
}
// Read stderr trace
const stderrLines = await countLines(join(slugDir, "stderr-trace.txt"));
// Parse hook coverage from debug logs + stderr
let stderrContent = "";
try {
stderrContent = await readFile(join(slugDir, "stderr-trace.txt"), "utf-8");
} catch { /* no stderr file */ }
const combinedLogs = debugLogContent + "\n" + stderrContent;
const hookCoverage = parseHookCoverageFromDebugLogs(combinedLogs);
// If manifest already has hook evidence, merge it in
if (s.hook_evidence.pre_tool_use_in_stderr) hookCoverage.preToolUse = true;
if (s.hook_evidence.user_prompt_in_stderr) hookCoverage.userPromptSubmit = true;
// Derive boolean flags from status
const success = s.status === "pass";
const timedOut = s.status === "timeout";
// Compute skill coverage per scenario
const expectedSet = new Set(s.expected_skills);
const claimedSet = new Set(s.claimed_skills);
const missingSkills = s.expected_skills.filter((sk) => !claimedSet.has(sk));
const unexpectedSkills = s.claimed_skills.filter((sk) => !expectedSet.has(sk));
const coveredCount = s.expected_skills.filter((sk) => claimedSet.has(sk)).length;
const scenarioCoverage =
s.expected_skills.length > 0 ? Math.round((coveredCount / s.expected_skills.length) * 100) : 100;
// Track skill injection matrix
for (const skill of s.claimed_skills) {
if (!skillInjectionMatrix[skill]) skillInjectionMatrix[skill] = [];
skillInjectionMatrix[skill].push(s.slug);
}
scenarios.push({
slug: s.slug,
sandboxId: s.sandbox_id,
success,
timedOut,
durationSec: Math.round(s.duration_ms / 1000),
expectedSkills: s.expected_skills,
claimedSkills: s.claimed_skills,
missingSkills,
unexpectedSkills,
coveragePercent: scenarioCoverage,
hookCoverage,
debugLogLines: debugLogContent.split("\n").length,
stderrTraceLines: stderrLines,
error: s.error,
});
}
// Aggregate stats — average per-scenario coverage, not global unique skills
const allExpected = [...new Set(manifest.scenarios.flatMap((s) => s.expected_skills))].sort();
const allClaimed = [...new Set(manifest.scenarios.flatMap((s) => s.claimed_skills))].sort();
const scenariosWithExpectations = scenarios.filter((s) => s.expectedSkills.length > 0);
const coveragePercent =
scenariosWithExpectations.length > 0
? Math.round(
scenariosWithExpectations.reduce((sum, s) => sum + s.coveragePercent, 0) /
scenariosWithExpectations.length,
)
: 0;
return {
runId: manifest.run_id,
timestamp: manifest.timestamp,
snapshotId: manifest.snapshot.snapshot_id,
totalDurationSec: Math.round(manifest.timing.total_duration_ms / 1000),
scenarioCount: scenarios.length,
passCount: scenarios.filter((s) => s.success).length,
failCount: scenarios.filter((s) => !s.success && !s.timedOut).length,
timeoutCount: scenarios.filter((s) => s.timedOut).length,
hookFiredCount: scenarios.filter(
(s) => s.hookCoverage.preToolUse || s.hookCoverage.userPromptSubmit,
).length,
scenarios,
skillInjectionMatrix,
allExpectedSkills: allExpected,
allClaimedSkills: allClaimed,
coveragePercent,
};
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
function printReport(report: CoverageReport): void {
console.log("=".repeat(70));
console.log("SANDBOX BENCHMARK COVERAGE REPORT");
console.log("=".repeat(70));
console.log(`Run ID: ${report.runId}`);
console.log(`Timestamp: ${report.timestamp}`);
console.log(`Snapshot: ${report.snapshotId}`);
console.log(`Duration: ${report.totalDurationSec}s`);
console.log();
// Summary
console.log("--- Summary ---");
console.log(`Scenarios: ${report.scenarioCount}`);
console.log(`Passed: ${report.passCount}`);
console.log(`Failed: ${report.failCount}`);
console.log(`Timed out: ${report.timeoutCount}`);
console.log(`Hooks fired: ${report.hookFiredCount}/${report.scenarioCount}`);
console.log(`Skill coverage: ${report.coveragePercent}% (${report.allClaimedSkills.length}/${report.allExpectedSkills.length} expected skills seen)`);
console.log();
// Scenario table
console.log("--- Scenarios ---");
console.log(
`${"Slug".padEnd(28)} ${"Status".padEnd(10)} ${"Cov%".padEnd(6)} ${"Hooks".padEnd(8)} ${"Expected".padEnd(20)} ${"Claimed".padEnd(20)} Missing`,
);
console.log("-".repeat(116));
for (const s of report.scenarios) {
const status = s.timedOut ? "TIMEOUT" : s.success ? "PASS" : "FAIL";
const covPct = `${s.coveragePercent}%`;
const hooks = [
s.hookCoverage.preToolUse ? "P" : "-",
s.hookCoverage.postToolUse ? "V" : "-",
s.hookCoverage.userPromptSubmit ? "U" : "-",
s.hookCoverage.sessionStart ? "S" : "-",
].join("");
const expected = s.expectedSkills.join(",");
const claimed = s.claimedSkills.join(",") || "(none)";
const missing = s.missingSkills.join(",") || "-";
console.log(
`${s.slug.padEnd(28)} ${status.padEnd(10)} ${covPct.padEnd(6)} ${hooks.padEnd(8)} ${expected.padEnd(20)} ${claimed.padEnd(20)} ${missing}`,
);
if (s.error) {
console.log(` ERROR: ${s.error.slice(0, 100)}`);
}
}
console.log();
console.log("Hook legend: P=PreToolUse V=PostToolUse(validate) U=UserPromptSubmit S=SessionStart");
console.log();
// Skill injection matrix
console.log("--- Skill Injection Matrix ---");
const sortedSkills = Object.entries(report.skillInjectionMatrix).sort(
(a, b) => b[1].length - a[1].length,
);
for (const [skill, slugs] of sortedSkills) {
console.log(` ${skill.padEnd(25)} → ${slugs.join(", ")}`);
}
if (sortedSkills.length === 0) {
console.log(" (no skills claimed)");
}
console.log();
// Missing coverage
const uncoveredSkills = report.allExpectedSkills.filter(
(sk) => !report.allClaimedSkills.includes(sk),
);
if (uncoveredSkills.length > 0) {
console.log("--- Uncovered Expected Skills ---");
for (const sk of uncoveredSkills) {
const scenarios = report.scenarios
.filter((s) => s.expectedSkills.includes(sk))
.map((s) => s.slug);
console.log(` ${sk.padEnd(25)} expected in: ${scenarios.join(", ")}`);
}
console.log();
}
// Failures
const failures = report.scenarios.filter((s) => !s.success);
if (failures.length > 0) {
console.log("--- Failures ---");
for (const f of failures) {
console.log(` ${f.slug}: ${f.timedOut ? "TIMEOUT" : f.error ?? "unknown error"}`);
console.log(` debug logs: ${f.debugLogLines} lines, stderr: ${f.stderrTraceLines} lines`);
}
console.log();
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const runDir = flags["run-dir"] ? resolve(flags["run-dir"]) : await findLatestRunDir();
console.log(`Analyzing: ${runDir}\n`);
const report = await analyzeRun(runDir);
if (flags.json) {
console.log(JSON.stringify(report, null, 2));
} else {
printReport(report);
}
const MIN_COVERAGE_THRESHOLD = 50;
const allPassed = report.passCount === report.scenarioCount;
const coverageMet = report.coveragePercent >= MIN_COVERAGE_THRESHOLD;
if (!coverageMet) {
console.log(
`\nFAILED: Coverage ${report.coveragePercent}% is below minimum threshold of ${MIN_COVERAGE_THRESHOLD}%`,
);
}
process.exit(allPassed && coverageMet ? 0 : 1);
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(2);
});
#!/usr/bin/env bun
/**
* Sandbox benchmark runner: provisions Vercel Sandboxes from a snapshot,
* runs benchmark scenarios with Claude Code + plugin, and extracts artifacts.
*
* Scenarios are imported from scripts/benchmark-runner.ts (no duplication).
*
* Usage:
* bun run .claude/skills/benchmark-sandbox/sandbox-runner.ts [options]
*
* --snapshot-only Create/update the base snapshot and exit
* --quick Run Tier 1 only (scenarios 01, 04, 09)
* --scenario <slug> Run a single scenario by slug
* --concurrency <n> Max parallel sandboxes (default: 3)
* --timeout <ms> Per-scenario timeout (default: 300000 = 5 min)
* --results-dir <p> Override results directory
* --help Print usage and exit
*/
import { Sandbox } from "@vercel/sandbox";
import { mkdir, writeFile, readFile, readdir, stat, unlink } from "node:fs/promises";
import { join, resolve } from "node:path";
import { parseArgs } from "node:util";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
import type { RunSummary, ScenarioSummary, SnapshotMeta } from "./types.js";
import {
RunnerError,
scenarioStatus,
overallStatus,
EXIT_ALL_PASS,
EXIT_SOME_FAIL,
EXIT_FATAL,
} from "./types.js";
import { Logger, type LogFormat } from "./logger.js";
// ---------------------------------------------------------------------------
// Import scenarios from the canonical source
// ---------------------------------------------------------------------------
import { PROJECTS, type BenchmarkProject } from "../../../scripts/benchmark-scenarios.js";
const SCENARIOS: BenchmarkProject[] = PROJECTS;
// Tier 1 quick-run slugs
const TIER1_SLUGS = new Set(["01-doc-qa-agent", "04-multi-model-router", "09-code-sandbox-tutor"]);
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: flags } = parseArgs({
options: {
"snapshot-only": { type: "boolean", default: false },
"force-snapshot": { type: "boolean", default: false },
quick: { type: "boolean", default: false },
scenario: { type: "string" },
concurrency: { type: "string", default: "3" },
timeout: { type: "string", default: "300000" },
"results-dir": { type: "string" },
json: { type: "boolean", default: false },
"log-format": { type: "string", default: "human" },
help: { type: "boolean", default: false },
},
strict: true,
});
if (flags.help) {
console.log(`Usage: bun run .claude/skills/benchmark-sandbox/sandbox-runner.ts [options]
--snapshot-only Create/update the base snapshot and exit
--force-snapshot Force fresh snapshot creation (ignore cache)
--quick Run Tier 1 only (scenarios 01, 04, 09)
--scenario <slug> Run a single scenario by slug
--concurrency <n> Max parallel sandboxes (default: 3, max: 10)
--timeout <ms> Per-scenario timeout (default: 300000 = 5 min)
--results-dir <p> Override results directory
--json Write only RunSummary JSON to stdout (all other output → stderr)
--log-format <fmt> Log format: human (default) or json (NDJSON events to stderr)
--help Print usage
Exit codes:
0 All scenarios passed
1 One or more scenarios failed or timed out
2 Fatal runner error (snapshot failure, scenario load failure, etc.)`);
process.exit(0);
}
const JSON_MODE = flags.json!;
const LOG_FORMAT = (flags["log-format"] === "json" ? "json" : "human") as LogFormat;
// runId declared early so Logger can use it; the timestamped form is set later in main()
let runId = `run-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
const logger = new Logger({ format: LOG_FORMAT, runId });
/** Shorthand — all human output goes to stderr (stdout reserved for --json RunSummary). */
const log = (msg: string) => logger.info(msg);
const warn = (msg: string) => logger.warn(msg);
const MAX_CONCURRENCY = 10;
const WARN_CONCURRENCY = 6;
const CONCURRENCY_RAW = parseInt(flags.concurrency!, 10);
if (CONCURRENCY_RAW > MAX_CONCURRENCY) {
logger.error(`Concurrency ${CONCURRENCY_RAW} exceeds maximum of ${MAX_CONCURRENCY}. Clamping to ${MAX_CONCURRENCY}.`);
}
if (CONCURRENCY_RAW > WARN_CONCURRENCY && CONCURRENCY_RAW <= MAX_CONCURRENCY) {
warn(`Warning: concurrency ${CONCURRENCY_RAW} exceeds ${WARN_CONCURRENCY} — may hit sandbox rate limits.`);
}
const CONCURRENCY = Math.min(Math.max(CONCURRENCY_RAW, 1), MAX_CONCURRENCY);
const TIMEOUT_MS = parseInt(flags.timeout!, 10);
const LOCAL_PLUGIN_DIR = join(homedir(), "dev", "vercel-plugin");
const SANDBOX_HOME = "/home/vercel-sandbox";
const SANDBOX_PLUGIN_DIR = `${SANDBOX_HOME}/vercel-plugin`;
const DEFAULT_RESULTS = join(homedir(), "dev", "vercel-plugin-testing", "sandbox-results");
const RESULTS_DIR = resolve(flags["results-dir"] ?? DEFAULT_RESULTS);
const SNAPSHOT_CACHE_PATH = join(DEFAULT_RESULTS, ".snapshot-cache.json");
const SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function elapsed(start: number): string {
return `${((performance.now() - start) / 1000).toFixed(1)}s`;
}
function resolveApiKey(): string {
// VERCEL_API_KEY is for sandbox provisioning, not for Claude Code
const key =
process.env.ANTHROPIC_API_KEY ??
process.env.AI_GATEWAY_API_KEY;
if (key) return key;
// Try macOS Keychain via apiKeyHelper
try {
const keychainKey = execSync(
'security find-generic-password -a "$USER" -s "ANTHROPIC_AUTH_TOKEN" -w',
{ encoding: "utf-8", timeout: 5000 },
).trim();
if (keychainKey) return keychainKey;
} catch { /* keychain not available or key not found */ }
logger.error("Missing API key. Set ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY (or store in macOS Keychain as ANTHROPIC_AUTH_TOKEN)");
process.exit(1);
}
function resolveBaseUrl(): string | undefined {
return process.env.ANTHROPIC_BASE_URL ?? "https://ai-gateway.vercel.sh";
}
/** Essential plugin directories/files to upload into the sandbox. */
const PLUGIN_UPLOAD_DIRS = ["hooks", "skills", "generated"];
const PLUGIN_UPLOAD_FILES = ["hooks/hooks.json", "package.json"];
/**
* Recursively collect files from a local directory, returning
* { relativePath: fileContent } pairs suitable for sandbox.writeFiles().
*/
async function collectPluginFiles(): Promise<Record<string, string>> {
const files: Record<string, string> = {};
async function walkDir(dir: string, prefix: string): Promise<void> {
const entries = await readdir(join(LOCAL_PLUGIN_DIR, dir), { withFileTypes: true });
for (const entry of entries) {
const relPath = join(dir, entry.name);
const fullPath = join(LOCAL_PLUGIN_DIR, relPath);
if (entry.isDirectory()) {
// Skip node_modules, .git, src (only need compiled hooks)
if (["node_modules", ".git", "src", ".claude", "tests", "scripts", ".playground"].includes(entry.name)) continue;
await walkDir(relPath, prefix);
} else if (entry.isFile()) {
// Skip .mts source files (only need compiled .mjs), test files, and large files
if (entry.name.endsWith(".mts") || entry.name.endsWith(".test.ts")) continue;
const s = await stat(fullPath);
if (s.size > 200_000) continue; // skip files > 200KB
const content = await readFile(fullPath, "utf-8");
files[join(prefix, relPath)] = content;
}
}
}
for (const dir of PLUGIN_UPLOAD_DIRS) {
await walkDir(dir, "");
}
// Also upload root-level files
for (const f of PLUGIN_UPLOAD_FILES) {
try {
const content = await readFile(join(LOCAL_PLUGIN_DIR, f), "utf-8");
files[f] = content;
} catch { /* optional file */ }
}
return files;
}
/**
* Upload the local plugin into the sandbox at SANDBOX_PLUGIN_DIR,
* then run `npx add-plugin <sandbox-path>` to install it.
*/
async function uploadAndInstallPlugin(
sandbox: SandboxInstance,
projectDir: string,
): Promise<{ out: string; err: string; exit: number }> {
const pluginFiles = await collectPluginFiles();
const entries = Object.entries(pluginFiles);
log(` [plugin] Uploading ${entries.length} files to sandbox via writeFiles()...`);
// Use native sandbox.writeFiles() API — much more reliable than heredocs
const fileBatch = entries.map(([relPath, content]) => ({
path: join(SANDBOX_PLUGIN_DIR, relPath),
content: Buffer.from(content, "utf-8"),
}));
// Upload in chunks of 10 to avoid payload size limits
const CHUNK_SIZE = 10;
const totalChunks = Math.ceil(fileBatch.length / CHUNK_SIZE);
logger.event("plugin.upload.started", { file_count: fileBatch.length, chunk_size: CHUNK_SIZE, total_chunks: totalChunks });
for (let i = 0; i < fileBatch.length; i += CHUNK_SIZE) {
const chunk = fileBatch.slice(i, i + CHUNK_SIZE);
const chunkIndex = Math.floor(i / CHUNK_SIZE);
const chunkBytes = chunk.reduce((a, f) => a + f.content.length, 0);
try {
await sandbox.writeFiles(chunk);
logger.event("plugin.upload.chunk", { index: chunkIndex, total: totalChunks, bytes: chunkBytes });
} catch (err: any) {
logger.error(` [plugin] writeFiles chunk ${i}-${i + chunk.length} failed: ${err.message}`);
logger.error(` [plugin] Paths: ${chunk.map(f => f.path).join(", ")}`);
logger.error(` [plugin] Total bytes: ${chunkBytes}`);
throw err;
}
}
logger.event("plugin.upload.completed", { file_count: fileBatch.length });
// Manually write Claude Code plugin config instead of using add-plugin
// (add-plugin calls `claude mcp add-json` which requires claude in /bin/sh PATH)
const globalSettings = {
extraKnownMarketplaces: {
"vercel-plugin": {
source: { source: "directory", path: SANDBOX_PLUGIN_DIR },
},
},
enabledPlugins: { "vercel-plugin@vercel-plugin": true },
};
const projectSettings = {
enabledPlugins: { "vercel-plugin@vercel-plugin": true },
};
log(` [plugin] Writing plugin config manually...`);
await sandbox.writeFiles([
{
path: `${SANDBOX_HOME}/.claude/settings.json`,
content: Buffer.from(JSON.stringify(globalSettings, null, 2)),
},
{
path: `${projectDir}/.claude/settings.json`,
content: Buffer.from(JSON.stringify(projectSettings, null, 2)),
},
]);
return { out: "Plugin config written manually", err: "", exit: 0 };
}
type SandboxInstance = InstanceType<typeof Sandbox>;
async function run(
sandbox: SandboxInstance,
cmd: string,
args: string[],
opts?: { timeout?: number },
): Promise<{ out: string; err: string; exit: number }> {
// @vercel/sandbox runCommand does not accept `timeout` — use AbortSignal instead
const runOpts: { signal?: AbortSignal } = {};
if (opts?.timeout) {
runOpts.signal = AbortSignal.timeout(opts.timeout);
}
try {
const result = await sandbox.runCommand(cmd, args, runOpts);
return {
out: (await result.stdout()).trim(),
err: (await result.stderr()).trim(),
exit: (result as any).exitCode ?? 0,
};
} catch (err: any) {
// runCommand throws on 400 (e.g. executable_not_found) instead of returning exit code
const msg = err.text ?? err.message ?? String(err);
logger.error(` [run] Command failed: ${cmd} ${args.slice(0, 2).join(" ")}`);
logger.error(` [run] Error: ${msg.slice(0, 300)}`);
return { out: "", err: msg, exit: 127 };
}
}
// ---------------------------------------------------------------------------
// Snapshot cache
// ---------------------------------------------------------------------------
interface SnapshotCache {
snapshotId: string;
createdAt: string; // ISO timestamp
}
async function loadCachedSnapshot(): Promise<string | null> {
try {
const raw = await readFile(SNAPSHOT_CACHE_PATH, "utf-8");
const cache: SnapshotCache = JSON.parse(raw);
const age = Date.now() - new Date(cache.createdAt).getTime();
if (age < SNAPSHOT_MAX_AGE_MS) {
log(`[snapshot] Using cached snapshot ${cache.snapshotId} (age: ${(age / 3600_000).toFixed(1)}h)`);
return cache.snapshotId;
}
log(`[snapshot] Cached snapshot expired (age: ${(age / 3600_000).toFixed(1)}h > 24h)`);
return null;
} catch {
return null; // no cache file or invalid JSON
}
}
async function saveCachedSnapshot(snapshotId: string): Promise<void> {
await mkdir(join(SNAPSHOT_CACHE_PATH, ".."), { recursive: true });
const cache: SnapshotCache = { snapshotId, createdAt: new Date().toISOString() };
await writeFile(SNAPSHOT_CACHE_PATH, JSON.stringify(cache, null, 2));
}
async function removeCachedSnapshot(): Promise<void> {
try {
await unlink(SNAPSHOT_CACHE_PATH);
} catch { /* no cache to remove */ }
}
// ---------------------------------------------------------------------------
// Snapshot verification — boot from snapshot, run `claude --version`
// ---------------------------------------------------------------------------
async function verifySnapshot(snapshotId: string): Promise<boolean> {
logger.event("snapshot.verify.started", { snapshot_id: snapshotId });
let sandbox: SandboxInstance | undefined;
try {
sandbox = await Sandbox.create({ fromSnapshot: snapshotId, timeout: 120_000 });
const ver = await run(sandbox, "sh", ["-c", "claude --version"], { timeout: 30_000 });
if (ver.exit === 0 && ver.out.length > 0) {
logger.event("snapshot.verify.succeeded", { snapshot_id: snapshotId, claude_version: ver.out.slice(0, 80) });
return true;
}
logger.event("snapshot.verify.failed", { snapshot_id: snapshotId, exit: ver.exit, stderr: ver.err.slice(0, 200) });
return false;
} catch (err: any) {
logger.event("snapshot.verify.failed", { snapshot_id: snapshotId, error: err.message?.slice(0, 200) ?? String(err) });
return false;
} finally {
if (sandbox) {
try { await sandbox.stop(); } catch { /* already stopped */ }
}
}
}
// ---------------------------------------------------------------------------
// Snapshot creation
// ---------------------------------------------------------------------------
async function createSnapshot(): Promise<string> {
const t0 = performance.now();
const apiKey = resolveApiKey();
const baseUrl = resolveBaseUrl();
log("[snapshot] Creating base sandbox (node24)...");
const env: Record<string, string> = {
ANTHROPIC_API_KEY: apiKey,
VERCEL_PLUGIN_LOG_LEVEL: "trace",
};
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
const sandbox = await Sandbox.create({ runtime: "node24", env, timeout: 900_000 });
log(`[snapshot] Sandbox ${sandbox.sandboxId} created (${elapsed(t0)})`);
// Install Claude Code
log("[snapshot] Installing Claude Code...");
const t1 = performance.now();
const install = await run(sandbox, "sh", ["-c", "npm install -g @anthropic-ai/claude-code"]);
if (install.exit !== 0) {
await sandbox.stop();
throw new Error(`Claude Code install failed: ${install.err.slice(0, 300)}`);
}
log(`[snapshot] Claude Code installed (${elapsed(t1)})`);
// Symlink claude + node binaries to /usr/local/bin so /bin/sh subprocesses can find them
await run(sandbox, "sh", [
"-c",
"ln -sf $(which claude) /usr/local/bin/claude && ln -sf $(which node) /usr/local/bin/node && ln -sf $(which npm) /usr/local/bin/npm && ln -sf $(which npx) /usr/local/bin/npx",
]);
// Verify claude --version (use sh -c to ensure PATH resolution)
const ver = await run(sandbox, "sh", ["-c", "claude --version"]);
log(`[snapshot] claude version: ${ver.out}`);
// Take snapshot
log("[snapshot] Taking snapshot...");
const t2 = performance.now();
const snapshot = await sandbox.snapshot();
const snapshotId = (snapshot as any).snapshotId ?? (snapshot as any).id ?? String(snapshot);
log(`[snapshot] Snapshot created: ${snapshotId} (${elapsed(t2)})`);
await sandbox.stop();
log(`[snapshot] Total: ${elapsed(t0)}`);
return snapshotId;
}
// ---------------------------------------------------------------------------
// Per-scenario runner
// ---------------------------------------------------------------------------
interface ScenarioResult {
slug: string;
sandboxId: string;
success: boolean;
timedOut: boolean;
durationMs: number;
sessionMethod: string;
expectedSkills: string[];
claimedSkills: string[];
hookEvidence: {
claimDirs: boolean;
seenFile: boolean;
debugLogCount: number;
preToolUseInStderr: boolean;
userPromptInStderr: boolean;
};
error?: string;
}
async function runScenario(
project: BenchmarkProject,
snapshotId: string,
apiKey: string,
baseUrl: string | undefined,
): Promise<ScenarioResult> {
const t0 = performance.now();
const projectDir = `${SANDBOX_HOME}/${project.slug}`;
const env: Record<string, string> = {
ANTHROPIC_API_KEY: apiKey,
VERCEL_PLUGIN_LOG_LEVEL: "trace",
};
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
let sandbox: SandboxInstance | undefined;
try {
// Create sandbox from snapshot (snapshot has node runtime but global npm packages don't persist)
sandbox = await Sandbox.create({ fromSnapshot: snapshotId, env, timeout: 900_000 });
log(` [${project.slug}] Sandbox ${sandbox.sandboxId} ready (${elapsed(t0)})`);
// Install Claude Code (global npm packages don't survive snapshot restore)
const claudeInstall = await run(sandbox, "sh", ["-c", "npm install -g @anthropic-ai/claude-code"]);
const claudeBin = (await run(sandbox, "sh", ["-c", "which claude"])).out;
log(` [${project.slug}] Claude installed at ${claudeBin} (${elapsed(t0)})`);
// Set up project directory and install plugin from local dev copy
await run(sandbox, "mkdir", ["-p", projectDir]);
await run(sandbox, "sh", ["-c", `cd ${projectDir} && npm init -y`]);
const pluginInstall = await uploadAndInstallPlugin(sandbox, projectDir);
log(` [${project.slug}] add-plugin exit=${pluginInstall.exit}`);
if (pluginInstall.out) log(` [${project.slug}] add-plugin out: ${pluginInstall.out.slice(0, 200)}`);
if (pluginInstall.err) log(` [${project.slug}] add-plugin err: ${pluginInstall.err.slice(0, 200)}`);
if (pluginInstall.exit !== 0 && !pluginInstall.out.includes("successfully") && !pluginInstall.out.includes("Installed")) {
throw new Error(`Plugin install failed (exit ${pluginInstall.exit}): ${(pluginInstall.out + " " + pluginInstall.err).slice(0, 300)}`);
}
// Write prompt to a temp file to avoid shell injection from prompt content
const promptTmpPath = "/tmp/claude-prompt.txt";
await sandbox.writeFiles([{ path: promptTmpPath, content: Buffer.from(project.prompt, "utf-8") }]);
// Check for PTY wrapper
const scriptCheck = await run(sandbox, "sh", ["-c", "which script 2>/dev/null && echo FOUND || echo MISSING"]);
const hasScript = scriptCheck.out.includes("FOUND");
// Run Claude Code session — prompt is read from temp file via cat, never interpolated
const settingsPath = `${projectDir}/.claude/settings.json`;
let sessionResult: { out: string; err: string; exit: number };
let sessionMethod: string;
if (hasScript) {
sessionMethod = "script-pty";
sessionResult = await run(
sandbox,
"sh",
[
"-c",
`cd ${projectDir} && VERCEL_PLUGIN_LOG_LEVEL=trace CLAUDE_PLUGIN_ROOT=${SANDBOX_PLUGIN_DIR} script -qec "${SANDBOX_HOME}/.global/npm/bin/claude --dangerously-skip-permissions --settings ${settingsPath} \\"\\$(cat ${promptTmpPath})\\"" /dev/null`,
],
{ timeout: TIMEOUT_MS },
);
} else {
sessionMethod = "direct";
sessionResult = await run(
sandbox,
"sh",
[
"-c",
`cd ${projectDir} && VERCEL_PLUGIN_LOG_LEVEL=trace CLAUDE_PLUGIN_ROOT=${SANDBOX_PLUGIN_DIR} ${SANDBOX_HOME}/.global/npm/bin/claude --dangerously-skip-permissions --settings ${settingsPath} "$(cat ${promptTmpPath})"`,
],
{ timeout: TIMEOUT_MS },
);
}
const timedOut = sessionResult.exit === 124; // timeout exit code
// Extract hook evidence
const claimCheck = await run(sandbox, "sh", [
"-c",
"find /tmp -maxdepth 1 -name 'vercel-plugin-*-seen-skills.d' -type d 2>/dev/null | head -5",
]);
const claimDirs = claimCheck.out.split("\n").filter(Boolean);
let claimedSkills: string[] = [];
if (claimDirs.length > 0) {
const contents = await run(sandbox, "sh", ["-c", `ls ${claimDirs[0]} 2>/dev/null`]);
claimedSkills = contents.out.split("\n").filter(Boolean);
}
const seenFileCheck = await run(sandbox, "sh", [
"-c",
"find /tmp -maxdepth 1 -name 'vercel-plugin-*-seen-skills.txt' 2>/dev/null | head -1",
]);
const debugLogCheck = await run(sandbox, "sh", [
"-c",
`find /root/.claude/debug -name '*.txt' -o -name '*.log' 2>/dev/null; find ${SANDBOX_HOME}/.claude/debug -name '*.txt' -o -name '*.log' 2>/dev/null`,
]);
const debugLogFiles = debugLogCheck.out.split("\n").filter(Boolean);
const hookEvidence = {
claimDirs: claimDirs.length > 0,
seenFile: seenFileCheck.out.length > 0,
debugLogCount: debugLogFiles.length,
preToolUseInStderr:
sessionResult.err.includes("PreToolUse") ||
sessionResult.err.includes("pretooluse") ||
sessionResult.err.includes("skill-inject"),
userPromptInStderr:
sessionResult.err.includes("UserPromptSubmit") ||
sessionResult.err.includes("user-prompt-submit"),
};
// Save artifacts to results dir
const slugDir = join(RESULTS_DIR, runId, project.slug);
await mkdir(slugDir, { recursive: true });
await mkdir(join(slugDir, "claim-dir"), { recursive: true });
await mkdir(join(slugDir, "debug-logs"), { recursive: true });
await writeFile(join(slugDir, "claude-output.txt"), sessionResult.out);
await writeFile(join(slugDir, "stderr-trace.txt"), sessionResult.err);
// Copy claim dir contents
for (const skill of claimedSkills) {
await writeFile(join(slugDir, "claim-dir", skill), "");
}
// Extract seen-skills.txt
if (seenFileCheck.out.length > 0) {
const seenContents = await run(sandbox, "cat", [seenFileCheck.out.split("\n")[0]]);
await writeFile(join(slugDir, "seen-skills.txt"), seenContents.out);
}
// Extract debug logs
for (const logFile of debugLogFiles.slice(0, 5)) {
try {
const content = await run(sandbox, "sh", ["-c", `head -500 '${logFile}'`]);
const basename = logFile.split("/").pop() ?? "unknown.txt";
await writeFile(join(slugDir, "debug-logs", basename), content.out);
} catch { /* skip unreadable logs */ }
}
// Project tree
const tree = await run(sandbox, "sh", [
"-c",
`find ${projectDir} -maxdepth 3 -not -path '*/node_modules/*' -not -path '*/.git/*' | head -80`,
]);
await writeFile(join(slugDir, "project-tree.txt"), tree.out);
// Run meta
const result: ScenarioResult = {
slug: project.slug,
sandboxId: sandbox.sandboxId,
success: sessionResult.exit === 0 && !timedOut,
timedOut,
durationMs: performance.now() - t0,
sessionMethod,
expectedSkills: project.expectedSkills,
claimedSkills,
hookEvidence,
};
await writeFile(join(slugDir, "run-meta.json"), JSON.stringify(result, null, 2));
return result;
} catch (err: any) {
return {
slug: project.slug,
sandboxId: sandbox?.sandboxId ?? "unknown",
success: false,
timedOut: false,
durationMs: performance.now() - t0,
sessionMethod: "none",
expectedSkills: project.expectedSkills,
claimedSkills: [],
hookEvidence: {
claimDirs: false,
seenFile: false,
debugLogCount: 0,
preToolUseInStderr: false,
userPromptInStderr: false,
},
error: err.message?.slice(0, 400) ?? String(err),
};
} finally {
if (sandbox) {
try {
await sandbox.stop();
} catch { /* already stopped */ }
}
}
}
// ---------------------------------------------------------------------------
// Parallel orchestrator
// ---------------------------------------------------------------------------
async function runParallel(
scenarios: BenchmarkProject[],
snapshotId: string,
concurrency: number,
): Promise<ScenarioResult[]> {
const apiKey = resolveApiKey();
const baseUrl = resolveBaseUrl();
const results: ScenarioResult[] = [];
const queue = [...scenarios];
async function worker(): Promise<void> {
while (queue.length > 0) {
const project = queue.shift()!;
log(`\n--- ${project.slug} (${scenarios.length - queue.length}/${scenarios.length}) ---`);
const result = await runScenario(project, snapshotId, apiKey, baseUrl);
results.push(result);
const status = result.timedOut ? "TIMEOUT" : result.success ? "OK" : "FAIL";
log(
` [${project.slug}] ${status} | skills: ${result.claimedSkills.join(", ") || "none"} | ${(result.durationMs / 1000).toFixed(1)}s`,
);
}
}
// Launch N workers
const workers = Array.from({ length: Math.min(concurrency, scenarios.length) }, () => worker());
await Promise.all(workers);
return results;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
// runId is declared at the top alongside the Logger; no re-declaration needed here.
async function main() {
const t0 = performance.now();
log("=== Benchmark Sandbox Runner ===\n");
logger.event("runner.start", { mode: flags.scenario ? "single" : flags.quick ? "quick" : "full" });
// Step 1: Create or reuse snapshot (cached for 24h, verified before use)
let snapshotId: string | null = null;
let snapshotCached = false;
let snapshotAgeHours: number | undefined;
let snapshotCreationMs: number | undefined;
if (!flags["force-snapshot"]) {
snapshotId = await loadCachedSnapshot();
if (snapshotId) {
logger.event("snapshot.cache.hit", { snapshot_id: snapshotId });
try {
const raw = await readFile(SNAPSHOT_CACHE_PATH, "utf-8");
const cache = JSON.parse(raw) as SnapshotCache;
snapshotAgeHours = (Date.now() - new Date(cache.createdAt).getTime()) / 3600_000;
} catch { /* ignore */ }
// Verify cached snapshot is still usable
const verified = await verifySnapshot(snapshotId);
if (verified) {
snapshotCached = true;
} else {
// Stale — invalidate and recreate once
const staleErr = new RunnerError(
"SNAPSHOT_CACHE_STALE",
`Cached snapshot ${snapshotId} failed verification`,
);
warn(`[snapshot] ${staleErr.message} — ${staleErr.hint}`);
logger.event("snapshot.verify.failed", { snapshot_id: snapshotId, error_code: staleErr.code });
await removeCachedSnapshot();
snapshotId = null; // fall through to creation
}
}
}
if (!snapshotId) {
// Remove stale cache before attempting creation so a failure doesn't leave one
await removeCachedSnapshot();
logger.event("snapshot.cache.miss", {});
logger.event("snapshot.create.started", {});
const snapT0 = performance.now();
snapshotId = await createSnapshot();
snapshotCreationMs = performance.now() - snapT0;
logger.event("snapshot.create.succeeded", { snapshot_id: snapshotId, duration_ms: Math.round(snapshotCreationMs) });
// Verify newly created snapshot before caching
const newVerified = await verifySnapshot(snapshotId);
if (!newVerified) {
const verifyErr = new RunnerError(
"SNAPSHOT_VERIFY_FAILED",
`Newly created snapshot ${snapshotId} failed verification`,
);
logger.error(`Fatal: ${verifyErr.message}`);
logger.error(`Hint: ${verifyErr.hint}`);
logger.event("runner.failed", { error: verifyErr.message, error_code: verifyErr.code });
process.exit(EXIT_FATAL);
}
await saveCachedSnapshot(snapshotId);
}
log(`\nSnapshot: ${snapshotId}\n`);
if (flags["snapshot-only"]) {
log("--snapshot-only: done.");
process.exit(0);
}
// Step 2: Select scenarios
let scenarios = SCENARIOS;
if (flags.scenario) {
const match = SCENARIOS.find((s) => s.slug === flags.scenario);
if (!match) {
logger.error(`Unknown scenario: ${flags.scenario}`);
logger.error(`Available: ${SCENARIOS.map((s) => s.slug).join(", ")}`);
process.exit(1);
}
scenarios = [match];
} else if (flags.quick) {
scenarios = SCENARIOS.filter((s) => TIER1_SLUGS.has(s.slug));
}
log(`Scenarios: ${scenarios.length} (${flags.quick ? "quick" : flags.scenario ?? "full"})`);
log(`Concurrency: ${CONCURRENCY}`);
log(`Timeout: ${TIMEOUT_MS / 1000}s per scenario`);
log(`Results: ${RESULTS_DIR}/${runId}\n`);
// Create results directory
await mkdir(join(RESULTS_DIR, runId), { recursive: true });
// Step 3: Run scenarios
const results = await runParallel(scenarios, snapshotId, CONCURRENCY);
// Step 4: Build RunSummary (single source of truth for both human + JSON output)
const scenarioSummaries: ScenarioSummary[] = results.map((r) => ({
slug: r.slug,
sandbox_id: r.sandboxId,
status: scenarioStatus(r),
duration_ms: r.durationMs,
session_method: r.sessionMethod,
expected_skills: r.expectedSkills,
claimed_skills: r.claimedSkills,
hook_evidence: {
claim_dirs: r.hookEvidence.claimDirs,
seen_file: r.hookEvidence.seenFile,
debug_log_count: r.hookEvidence.debugLogCount,
pre_tool_use_in_stderr: r.hookEvidence.preToolUseInStderr,
user_prompt_in_stderr: r.hookEvidence.userPromptInStderr,
},
error: r.error,
}));
const snapshotMeta: SnapshotMeta = {
snapshot_id: snapshotId,
cached: snapshotCached,
...(snapshotAgeHours !== undefined && { age_hours: Math.round(snapshotAgeHours * 10) / 10 }),
...(snapshotCreationMs !== undefined && { creation_duration_ms: Math.round(snapshotCreationMs) }),
};
const mode = flags.scenario ? "single" as const : flags.quick ? "quick" as const : "full" as const;
const summary: RunSummary = {
schema_version: 1,
run_id: runId,
status: overallStatus(scenarioSummaries),
timestamp: new Date().toISOString(),
snapshot: snapshotMeta,
scenarios: scenarioSummaries,
timing: {
total_duration_ms: Math.round(performance.now() - t0),
scenario_durations_ms: Object.fromEntries(
scenarioSummaries.map((s) => [s.slug, Math.round(s.duration_ms)]),
),
},
config: {
concurrency: CONCURRENCY,
timeout_ms: TIMEOUT_MS,
results_dir: join(RESULTS_DIR, runId),
mode,
},
};
// Write manifest (always, regardless of --json)
await writeFile(join(RESULTS_DIR, runId, "run-manifest.json"), JSON.stringify(summary, null, 2));
// Step 5: Print human summary table (derived from RunSummary)
log("\n=== Summary ===");
log(
`${"Slug".padEnd(28)} ${"Status".padEnd(10)} ${"Method".padEnd(12)} ${"Skills".padEnd(30)} Duration`,
);
log("-".repeat(100));
for (const s of summary.scenarios) {
const dur = `${(s.duration_ms / 1000).toFixed(1)}s`;
const skills = s.claimed_skills.join(", ") || "(none)";
log(`${s.slug.padEnd(28)} ${s.status.toUpperCase().padEnd(10)} ${s.session_method.padEnd(12)} ${skills.padEnd(30)} ${dur}`);
}
const passed = summary.scenarios.filter((s) => s.status === "pass").length;
const hookFired = summary.scenarios.filter(
(s) => s.hook_evidence.claim_dirs || s.hook_evidence.pre_tool_use_in_stderr,
).length;
log(`\n${passed}/${summary.scenarios.length} scenarios succeeded`);
log(`${hookFired}/${summary.scenarios.length} scenarios had hook evidence`);
log(`Results: ${summary.config.results_dir}`);
log(`Total time: ${elapsed(t0)}\n`);
// --json: write only RunSummary to stdout (everything else already went to stderr)
if (JSON_MODE) {
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
}
// Exit codes: 0=all pass, 1=some fail, 2=fatal
logger.event("runner.completed", { status: summary.status, scenarios: summary.scenarios.length });
const exitCode = summary.status === "pass" ? EXIT_ALL_PASS : EXIT_SOME_FAIL;
process.exit(exitCode);
}
main().catch((err) => {
logger.error(`Fatal: ${err.message ?? err}`);
logger.event("runner.failed", { error: err.message ?? String(err) });
process.exit(EXIT_FATAL);
});
#!/usr/bin/env bun
/**
* Create a pre-baked Vercel Sandbox snapshot with Claude Code + vercel-plugin
* pre-installed. New sandboxes from this snapshot start in seconds instead of
* waiting for fresh installs each time.
*
* Acceptance criteria:
* 1. Snapshot includes Claude Code globally installed
* 2. Snapshot includes vercel-plugin (hooks.json + settings) in a template dir
* 3. New sandbox from snapshot starts in under 10 seconds
* 4. `claude --version` passes on snapshot-based sandbox
* 5. Hook verification (hooks.json present) passes on snapshot-based sandbox
* 6. Snapshot ID is exported (stdout JSON + optional file) for sandbox-runner.ts
*
* Usage:
* bun run .claude/skills/benchmark-sandbox/spike/create-snapshot.ts [options]
*
* --output <path> Write snapshot metadata JSON to file (default: stdout only)
* --verify Create a second sandbox from the snapshot and verify it
* --help Print usage and exit
*/
import { Sandbox } from "@vercel/sandbox";
import { writeFile, readFile, readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { parseArgs } from "node:util";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
// ── CLI ──────────────────────────────────────────────────────────────
const { values: flags } = parseArgs({
options: {
output: { type: "string" },
verify: { type: "boolean", default: false },
help: { type: "boolean", default: false },
},
strict: true,
});
if (flags.help) {
console.log(`Usage: bun run .claude/skills/benchmark-sandbox/spike/create-snapshot.ts [options]
--output <path> Write snapshot metadata JSON to file
--verify Verify the snapshot by booting a sandbox from it
--help Print usage`);
process.exit(0);
}
// ── Helpers ──────────────────────────────────────────────────────────
function resolveApiKey(): string {
const key =
process.env.ANTHROPIC_API_KEY ??
process.env.AI_GATEWAY_API_KEY ??
process.env.VERCEL_API_KEY;
if (key) return key;
// Try macOS Keychain via apiKeyHelper
try {
const keychainKey = execSync(
'security find-generic-password -a "$USER" -s "ANTHROPIC_AUTH_TOKEN" -w',
{ encoding: "utf-8", timeout: 5000 },
).trim();
if (keychainKey) return keychainKey;
} catch { /* keychain not available or key not found */ }
console.error(
"Missing API key. Set one of: ANTHROPIC_API_KEY, AI_GATEWAY_API_KEY, VERCEL_API_KEY (or store in macOS Keychain as ANTHROPIC_AUTH_TOKEN)",
);
process.exit(1);
}
function resolveBaseUrl(): string | undefined {
return process.env.ANTHROPIC_BASE_URL ?? "https://ai-gateway.vercel.sh";
}
function elapsed(start: number): string {
return `${((performance.now() - start) / 1000).toFixed(1)}s`;
}
type SandboxInstance = InstanceType<typeof Sandbox>;
async function run(
sandbox: SandboxInstance,
cmd: string,
args: string[],
opts?: { timeout?: number },
): Promise<{ out: string; err: string; exit: number }> {
const result = await sandbox.runCommand(cmd, args, opts);
return {
out: (await result.stdout()).trim(),
err: (await result.stderr()).trim(),
exit: (result as any).exitCode ?? 0,
};
}
const LOCAL_PLUGIN_DIR = join(homedir(), "dev", "vercel-plugin");
const SANDBOX_PLUGIN_DIR = "/home/vercel-sandbox/vercel-plugin";
// Template directory inside the snapshot where plugin config lives.
// sandbox-runner copies from here into each project dir for fast setup.
const TEMPLATE_DIR = "/home/vercel-sandbox/.vercel-plugin-template";
/** Essential plugin directories to upload into the sandbox. */
const PLUGIN_UPLOAD_DIRS = ["hooks", "skills", "generated"];
async function collectPluginFiles(): Promise<Record<string, string>> {
const files: Record<string, string> = {};
async function walkDir(dir: string): Promise<void> {
const entries = await readdir(join(LOCAL_PLUGIN_DIR, dir), { withFileTypes: true });
for (const entry of entries) {
const relPath = join(dir, entry.name);
const fullPath = join(LOCAL_PLUGIN_DIR, relPath);
if (entry.isDirectory()) {
if (["node_modules", ".git", "src", ".claude", "tests", "scripts", ".playground"].includes(entry.name)) continue;
await walkDir(relPath);
} else if (entry.isFile()) {
if (entry.name.endsWith(".mts") || entry.name.endsWith(".test.ts")) continue;
const s = await stat(fullPath);
if (s.size > 200_000) continue;
const content = await readFile(fullPath, "utf-8");
files[relPath] = content;
}
}
}
for (const dir of PLUGIN_UPLOAD_DIRS) {
await walkDir(dir);
}
// Root-level files
for (const f of ["hooks/hooks.json", "package.json"]) {
try {
const content = await readFile(join(LOCAL_PLUGIN_DIR, f), "utf-8");
files[f] = content;
} catch { /* optional */ }
}
return files;
}
async function uploadPluginToSandbox(sandbox: InstanceType<typeof Sandbox>): Promise<void> {
const pluginFiles = await collectPluginFiles();
console.log(`[snapshot] Uploading ${Object.keys(pluginFiles).length} plugin files to sandbox...`);
await run(sandbox, "mkdir", ["-p", SANDBOX_PLUGIN_DIR]);
for (const [relPath, content] of Object.entries(pluginFiles)) {
const sandboxPath = join(SANDBOX_PLUGIN_DIR, relPath);
const dir = sandboxPath.split("/").slice(0, -1).join("/");
await run(sandbox, "mkdir", ["-p", dir]);
await run(sandbox, "sh", ["-c", `cat > '${sandboxPath}' << 'PLUGIN_EOF'\n${content}\nPLUGIN_EOF`]);
}
}
// ── Snapshot creation ────────────────────────────────────────────────
interface SnapshotMetadata {
snapshotId: string;
claudeVersion: string;
pluginInstalled: boolean;
templateDir: string;
createdAt: string;
createDurationMs: number;
}
async function createSnapshot(): Promise<SnapshotMetadata> {
const t0 = performance.now();
const apiKey = resolveApiKey();
const baseUrl = resolveBaseUrl();
const env: Record<string, string> = {
ANTHROPIC_API_KEY: apiKey,
VERCEL_PLUGIN_LOG_LEVEL: "trace",
};
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
// Step 1: Create base sandbox
console.log("[snapshot] Creating base sandbox (node24)...");
const t1 = performance.now();
const sandbox = await Sandbox.create({ runtime: "node24", env });
console.log(`[snapshot] Sandbox ${sandbox.sandboxId} created (${elapsed(t1)})`);
try {
// Step 2: Install Claude Code globally
console.log("[snapshot] Installing Claude Code...");
const t2 = performance.now();
const install = await run(sandbox, "npm", [
"install",
"-g",
"@anthropic-ai/claude-code",
]);
if (install.exit !== 0) {
throw new Error(`Claude Code install failed (exit=${install.exit}): ${install.err.slice(0, 300)}`);
}
console.log(`[snapshot] Claude Code installed (${elapsed(t2)})`);
// Step 3: Verify claude --version
const ver = await run(sandbox, "claude", ["--version"]);
if (!/\d+\.\d+/.test(ver.out)) {
throw new Error(`claude --version returned unexpected output: ${ver.out}`);
}
console.log(`[snapshot] claude version: ${ver.out}`);
// Step 4: Upload local plugin and install into a template directory.
// This uses the local development version instead of fetching from GitHub.
console.log("[snapshot] Uploading local plugin and installing into template dir...");
const t4 = performance.now();
await uploadPluginToSandbox(sandbox);
await run(sandbox, "mkdir", ["-p", TEMPLATE_DIR]);
await run(sandbox, "sh", ["-c", `cd ${TEMPLATE_DIR} && npm init -y`]);
const pluginInstall = await run(sandbox, "sh", [
"-c",
`cd ${TEMPLATE_DIR} && npx -y add-plugin ${SANDBOX_PLUGIN_DIR} -s project -y`,
]);
const pluginOk =
pluginInstall.exit === 0 ||
pluginInstall.out.includes("successfully") ||
pluginInstall.out.includes("added");
if (!pluginOk) {
throw new Error(`Plugin install failed: ${pluginInstall.out.slice(0, 300)}`);
}
console.log(`[snapshot] Plugin installed into template (${elapsed(t4)})`);
// Verify hooks.json exists in the template
const hooksCheck = await run(sandbox, "sh", [
"-c",
`test -f ${TEMPLATE_DIR}/.claude/hooks.json && echo EXISTS || echo MISSING`,
]);
if (!hooksCheck.out.includes("EXISTS")) {
throw new Error(`hooks.json not found in template dir after plugin install`);
}
console.log("[snapshot] hooks.json verified in template");
// Verify settings.json exists
const settingsCheck = await run(sandbox, "sh", [
"-c",
`test -f ${TEMPLATE_DIR}/.claude/settings.json && echo EXISTS || echo MISSING`,
]);
console.log(`[snapshot] settings.json: ${settingsCheck.out}`);
// Step 5: Take snapshot
console.log("[snapshot] Taking snapshot...");
const t5 = performance.now();
const snapshot = await sandbox.snapshot();
const snapshotId =
(snapshot as any).snapshotId ?? (snapshot as any).id ?? String(snapshot);
console.log(`[snapshot] Snapshot created: ${snapshotId} (${elapsed(t5)})`);
const metadata: SnapshotMetadata = {
snapshotId,
claudeVersion: ver.out,
pluginInstalled: true,
templateDir: TEMPLATE_DIR,
createdAt: new Date().toISOString(),
createDurationMs: performance.now() - t0,
};
return metadata;
} finally {
try {
await sandbox.stop();
console.log("[snapshot] Base sandbox stopped");
} catch {
/* already stopped */
}
}
}
// ── Verification ─────────────────────────────────────────────────────
interface VerifyResult {
bootDurationMs: number;
claudeVersionOk: boolean;
claudeVersion: string;
hooksJsonPresent: boolean;
settingsJsonPresent: boolean;
templateDirPresent: boolean;
}
async function verifySnapshot(snapshotId: string): Promise<VerifyResult> {
const apiKey = resolveApiKey();
const baseUrl = resolveBaseUrl();
const env: Record<string, string> = { ANTHROPIC_API_KEY: apiKey };
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
console.log("\n[verify] Booting sandbox from snapshot...");
const tBoot = performance.now();
const sandbox = await Sandbox.create({ fromSnapshot: snapshotId, env });
const bootMs = performance.now() - tBoot;
console.log(`[verify] Sandbox ${sandbox.sandboxId} booted in ${(bootMs / 1000).toFixed(1)}s`);
try {
// Check claude --version
const ver = await run(sandbox, "claude", ["--version"]);
const claudeVersionOk = /\d+\.\d+/.test(ver.out);
console.log(`[verify] claude --version: ${ver.out} (${claudeVersionOk ? "OK" : "FAIL"})`);
// Check hooks.json in template
const hooksCheck = await run(sandbox, "sh", [
"-c",
`test -f ${TEMPLATE_DIR}/.claude/hooks.json && echo EXISTS || echo MISSING`,
]);
const hooksJsonPresent = hooksCheck.out.includes("EXISTS");
console.log(`[verify] hooks.json: ${hooksCheck.out}`);
// Check settings.json in template
const settingsCheck = await run(sandbox, "sh", [
"-c",
`test -f ${TEMPLATE_DIR}/.claude/settings.json && echo EXISTS || echo MISSING`,
]);
const settingsJsonPresent = settingsCheck.out.includes("EXISTS");
console.log(`[verify] settings.json: ${settingsCheck.out}`);
// Check template dir exists with content
const templateCheck = await run(sandbox, "sh", [
"-c",
`test -d ${TEMPLATE_DIR}/.claude && echo EXISTS || echo MISSING`,
]);
const templateDirPresent = templateCheck.out.includes("EXISTS");
console.log(`[verify] template dir (.claude): ${templateCheck.out}`);
// Boot time check
const bootUnder10s = bootMs < 10_000;
console.log(
`[verify] Boot time: ${(bootMs / 1000).toFixed(1)}s (${bootUnder10s ? "UNDER 10s" : "OVER 10s"})`,
);
return {
bootDurationMs: bootMs,
claudeVersionOk,
claudeVersion: ver.out,
hooksJsonPresent,
settingsJsonPresent,
templateDirPresent,
};
} finally {
try {
await sandbox.stop();
console.log("[verify] Verification sandbox stopped");
} catch {
/* already stopped */
}
}
}
// ── Main ─────────────────────────────────────────────────────────────
async function main() {
const t0 = performance.now();
console.log("=== Snapshot Creator ===\n");
// Create snapshot
const metadata = await createSnapshot();
console.log(`\n[result] Snapshot ID: ${metadata.snapshotId}`);
console.log(`[result] Claude version: ${metadata.claudeVersion}`);
console.log(`[result] Plugin pre-installed: ${metadata.pluginInstalled}`);
console.log(`[result] Template dir: ${metadata.templateDir}`);
console.log(`[result] Create time: ${(metadata.createDurationMs / 1000).toFixed(1)}s`);
// Optionally verify
let verification: VerifyResult | undefined;
if (flags.verify) {
verification = await verifySnapshot(metadata.snapshotId);
}
// Build output JSON
const output = {
...metadata,
...(verification
? {
verification: {
bootDurationMs: verification.bootDurationMs,
bootUnder10s: verification.bootDurationMs < 10_000,
claudeVersionOk: verification.claudeVersionOk,
hooksJsonPresent: verification.hooksJsonPresent,
settingsJsonPresent: verification.settingsJsonPresent,
templateDirPresent: verification.templateDirPresent,
},
}
: {}),
};
// Write to file if requested
if (flags.output) {
await writeFile(flags.output, JSON.stringify(output, null, 2));
console.log(`\n[output] Metadata written to ${flags.output}`);
}
// Always print JSON to stdout for piping
console.log("\n" + JSON.stringify(output, null, 2));
// Summary
console.log("\n" + "=".repeat(60));
const allOk =
metadata.pluginInstalled &&
/\d+\.\d+/.test(metadata.claudeVersion) &&
(!verification ||
(verification.claudeVersionOk &&
verification.hooksJsonPresent &&
verification.bootDurationMs < 10_000));
if (allOk) {
console.log("ALL CHECKS PASSED");
} else {
console.log("SOME CHECKS FAILED");
}
console.log("=".repeat(60));
console.log(`Total time: ${elapsed(t0)}`);
// Export-friendly: print just the snapshot ID on the last line for easy capture
// e.g. SNAPSHOT_ID=$(bun run create-snapshot.ts 2>/dev/null | tail -1)
console.log(`\nSNAPSHOT_ID=${metadata.snapshotId}`);
process.exit(allOk ? 0 : 1);
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(2);
});
#!/usr/bin/env bun
/**
* Spike: Prove interactive Claude Code session with hooks firing
*
* Acceptance criteria:
* 1. Claude Code session starts inside sandbox and processes a prompt
* 2. PreToolUse hook fires (verified by debug log or claim dir presence)
* 3. UserPromptSubmit hook fires (verified by debug log)
* 4. Session completes without hanging or falling back to non-interactive mode
* 5. Debug logs and claim dir contents are extractable via readFile()
*
* Strategy:
* - Create sandbox with node24 + API credentials
* - Install Claude Code globally
* - Install vercel-plugin via npx add-plugin
* - Attempt interactive session with PTY wrapper (`script` command)
* - Fall back to direct runCommand if PTY wrapper unavailable
* - Extract debug logs + claim dir contents for verification
*/
import { Sandbox } from "@vercel/sandbox";
// ── helpers ──────────────────────────────────────────────────────────
function resolveApiKey(): string {
const key =
process.env.ANTHROPIC_API_KEY ??
process.env.AI_GATEWAY_API_KEY ??
process.env.VERCEL_API_KEY;
if (!key) {
console.error(
"Missing API key. Set one of: ANTHROPIC_API_KEY, AI_GATEWAY_API_KEY, VERCEL_API_KEY"
);
process.exit(1);
}
return key;
}
function elapsed(start: number): string {
return `${((performance.now() - start) / 1000).toFixed(1)}s`;
}
type CmdResult = Awaited<
ReturnType<InstanceType<typeof Sandbox>["runCommand"]>
>;
async function run(
sandbox: InstanceType<typeof Sandbox>,
cmd: string,
args: string[],
opts?: { timeout?: number; env?: Record<string, string> }
): Promise<{ out: string; err: string; exit: number }> {
const result: CmdResult = await sandbox.runCommand(cmd, args, opts);
return {
out: (await result.stdout()).trim(),
err: (await result.stderr()).trim(),
exit: (result as any).exitCode ?? 0,
};
}
interface StepResult {
step: string;
ok: boolean;
detail: string;
time: string;
}
// ── test prompt ──────────────────────────────────────────────────────
// Deliberately product-focused (not tech-focused) to exercise skill matching.
// Should trigger: nextjs, ai-sdk at minimum.
const TEST_PROMPT = `Create a Next.js app with a single page that uses the AI SDK to stream a response from a language model. The page should have an input field and a submit button. Keep it minimal.`;
// ── main ─────────────────────────────────────────────────────────────
async function main() {
const t0 = performance.now();
const apiKey = resolveApiKey();
const baseUrl = process.env.ANTHROPIC_BASE_URL;
const results: StepResult[] = [];
let sandbox: InstanceType<typeof Sandbox> | undefined;
try {
// ── Step 1: Create sandbox ───────────────────────────────────────
console.log("[1/7] Creating sandbox (node24)...");
const t1 = performance.now();
const sandboxEnv: Record<string, string> = {
ANTHROPIC_API_KEY: apiKey,
// Enable trace-level hook logging so we can verify hook firing
VERCEL_PLUGIN_LOG_LEVEL: "trace",
};
if (baseUrl) sandboxEnv.ANTHROPIC_BASE_URL = baseUrl;
sandbox = await Sandbox.create({ runtime: "node24", env: sandboxEnv });
results.push({
step: "create-sandbox",
ok: true,
detail: `id=${sandbox.sandboxId}`,
time: elapsed(t1),
});
console.log(` OK: ${sandbox.sandboxId} (${elapsed(t1)})`);
// ── Step 2: Install Claude Code ──────────────────────────────────
console.log("[2/7] Installing Claude Code...");
const t2 = performance.now();
const install = await run(sandbox, "npm", [
"install",
"-g",
"@anthropic-ai/claude-code",
]);
results.push({
step: "install-claude-code",
ok: install.exit === 0,
detail:
install.exit === 0
? "exit=0"
: `exit=${install.exit} err=${install.err.slice(0, 200)}`,
time: elapsed(t2),
});
if (install.exit !== 0) throw new Error(`Claude Code install failed: ${install.err.slice(0, 300)}`);
console.log(` OK (${elapsed(t2)})`);
// ── Step 3: Scaffold project + install plugin ────────────────────
console.log("[3/7] Scaffolding project and installing plugin...");
const t3 = performance.now();
const projectDir = "/home/user/test-project";
// Create a minimal project so hooks have a project context
await run(sandbox, "mkdir", ["-p", projectDir]);
await run(sandbox, "sh", [
"-c",
`cd ${projectDir} && npm init -y && npm install next react react-dom`,
]);
// Install vercel-plugin into the project
const pluginInstall = await run(sandbox, "sh", [
"-c",
`cd ${projectDir} && npx -y add-plugin https://github.com/vercel/vercel-plugin -s project -y`,
]);
const pluginOk =
pluginInstall.exit === 0 ||
pluginInstall.out.includes("successfully") ||
pluginInstall.out.includes("added");
results.push({
step: "install-plugin",
ok: pluginOk,
detail: pluginOk
? "plugin installed"
: `exit=${pluginInstall.exit} out=${pluginInstall.out.slice(0, 200)}`,
time: elapsed(t3),
});
if (!pluginOk) throw new Error(`Plugin install failed: ${pluginInstall.out.slice(0, 300)}`);
console.log(` OK (${elapsed(t3)})`);
// Verify hooks.json exists
const hooksCheck = await run(sandbox, "sh", [
"-c",
`test -f ${projectDir}/.claude/hooks.json && echo EXISTS || echo MISSING`,
]);
console.log(` hooks.json: ${hooksCheck.out}`);
// ── Step 4: Check for PTY wrapper availability ───────────────────
console.log("[4/7] Checking PTY wrapper availability...");
const t4 = performance.now();
const scriptCheck = await run(sandbox, "sh", [
"-c",
"which script 2>/dev/null && echo FOUND || echo MISSING",
]);
const hasScript = scriptCheck.out.includes("FOUND");
// Also check for unbuffer as alternative
const unbufferCheck = await run(sandbox, "sh", [
"-c",
"which unbuffer 2>/dev/null && echo FOUND || echo MISSING",
]);
const hasUnbuffer = unbufferCheck.out.includes("FOUND");
results.push({
step: "pty-check",
ok: true, // informational
detail: `script=${hasScript ? "yes" : "no"} unbuffer=${hasUnbuffer ? "yes" : "no"}`,
time: elapsed(t4),
});
console.log(
` script: ${hasScript ? "yes" : "no"}, unbuffer: ${hasUnbuffer ? "yes" : "no"}`
);
// ── Step 5: Run interactive Claude Code session ──────────────────
console.log("[5/7] Running Claude Code session...");
const t5 = performance.now();
// Build the claude command. Key flags:
// --dangerously-skip-permissions: avoids interactive permission prompts
// --settings: point to plugin settings
// The prompt is passed as a positional argument
//
// We try multiple approaches in order:
// 1. `script` PTY wrapper (most likely to trigger hooks)
// 2. Direct `claude` invocation (may work if SDK detects session context)
const settingsPath = `${projectDir}/.claude/settings.json`;
const escapedPrompt = TEST_PROMPT.replace(/'/g, "'\\''");
let sessionResult: { out: string; err: string; exit: number };
let sessionMethod: string;
if (hasScript) {
// `script -qec` wraps the command in a PTY on Linux
// -q: quiet (no "Script started" banner)
// -e: return exit code of child
// -c: command to run
sessionMethod = "script-pty";
const scriptCmd = [
"-c",
`cd ${projectDir} && VERCEL_PLUGIN_LOG_LEVEL=trace script -qec 'claude --dangerously-skip-permissions --verbose "${escapedPrompt}"' /dev/null`,
];
sessionResult = await run(sandbox, "sh", scriptCmd, {
timeout: 120_000, // 2 min for a simple prompt
});
} else {
// Direct invocation — hooks may or may not fire
sessionMethod = "direct";
sessionResult = await run(
sandbox,
"sh",
[
"-c",
`cd ${projectDir} && claude --dangerously-skip-permissions --verbose '${escapedPrompt}'`,
],
{ timeout: 120_000 }
);
}
const sessionOk = sessionResult.exit === 0;
results.push({
step: "claude-session",
ok: sessionOk,
detail: `method=${sessionMethod} exit=${sessionResult.exit} out_len=${sessionResult.out.length} err_len=${sessionResult.err.length}`,
time: elapsed(t5),
});
console.log(
` ${sessionOk ? "OK" : "FAIL"}: method=${sessionMethod} exit=${sessionResult.exit} (${elapsed(t5)})`
);
if (sessionResult.out.length > 0) {
console.log(` stdout preview: ${sessionResult.out.slice(0, 300)}`);
}
if (sessionResult.err.length > 0) {
console.log(` stderr preview: ${sessionResult.err.slice(0, 300)}`);
}
// ── Step 6: Verify hook firing ───────────────────────────────────
console.log("[6/7] Verifying hook firing...");
const t6 = performance.now();
// Check 6a: Look for claim dir (dedup state = hooks wrote it)
const claimCheck = await run(sandbox, "sh", [
"-c",
`find /tmp -maxdepth 1 -name 'vercel-plugin-*-seen-skills.d' -type d 2>/dev/null | head -5`,
]);
const claimDirs = claimCheck.out
.split("\n")
.filter((l) => l.length > 0);
const hasClaimDirs = claimDirs.length > 0;
// If claim dirs exist, list their contents (= which skills were injected)
let claimedSkills: string[] = [];
if (hasClaimDirs) {
const claimContents = await run(sandbox, "sh", [
"-c",
`ls ${claimDirs[0]} 2>/dev/null`,
]);
claimedSkills = claimContents.out
.split("\n")
.filter((l) => l.length > 0)
.map((s) => decodeURIComponent(s));
}
// Check 6b: Look for seen-skills.txt file
const seenFileCheck = await run(sandbox, "sh", [
"-c",
`find /tmp -maxdepth 1 -name 'vercel-plugin-*-seen-skills.txt' 2>/dev/null | head -3`,
]);
const hasSeenFile = seenFileCheck.out.length > 0;
let seenFileContents = "";
if (hasSeenFile) {
const firstFile = seenFileCheck.out.split("\n")[0];
const contents = await run(sandbox, "cat", [firstFile]);
seenFileContents = contents.out;
}
// Check 6c: Look for debug logs
const debugLogCheck = await run(sandbox, "sh", [
"-c",
`find /root/.claude/debug -name '*.txt' -o -name '*.log' 2>/dev/null; find /home/user/.claude/debug -name '*.txt' -o -name '*.log' 2>/dev/null`,
]);
const debugLogFiles = debugLogCheck.out
.split("\n")
.filter((l) => l.length > 0);
// Check 6d: Grep stderr for hook trace markers
const preToolUseInStderr = sessionResult.err.includes("PreToolUse") ||
sessionResult.err.includes("pretooluse") ||
sessionResult.err.includes("skill-inject");
const userPromptInStderr = sessionResult.err.includes("UserPromptSubmit") ||
sessionResult.err.includes("user-prompt-submit") ||
sessionResult.err.includes("prompt-signal");
const hookEvidence = {
claimDirs: hasClaimDirs,
claimedSkills,
seenFile: hasSeenFile,
seenFileContents,
debugLogFiles,
preToolUseInStderr,
userPromptInStderr,
};
// A hook "fired" if we see any evidence at all
const anyHookEvidence =
hasClaimDirs ||
hasSeenFile ||
preToolUseInStderr ||
userPromptInStderr ||
debugLogFiles.length > 0;
results.push({
step: "hook-verification",
ok: anyHookEvidence,
detail: JSON.stringify(hookEvidence, null, 0),
time: elapsed(t6),
});
console.log(
` ${anyHookEvidence ? "OK" : "FAIL"}: hook evidence found=${anyHookEvidence}`
);
console.log(` claim dirs: ${hasClaimDirs} (skills: ${claimedSkills.join(", ") || "none"})`);
console.log(` seen-skills.txt: ${hasSeenFile} (${seenFileContents || "empty"})`);
console.log(` debug logs: ${debugLogFiles.length} file(s)`);
console.log(` stderr traces: PreToolUse=${preToolUseInStderr} UserPromptSubmit=${userPromptInStderr}`);
// ── Step 7: Extract artifacts ────────────────────────────────────
console.log("[7/7] Extracting artifacts...");
const t7 = performance.now();
const artifacts: Record<string, string> = {};
// Extract debug log contents
for (const logFile of debugLogFiles.slice(0, 3)) {
try {
const content = await run(sandbox, "sh", [
"-c",
`head -200 '${logFile}'`,
]);
artifacts[logFile] = content.out;
} catch {
artifacts[logFile] = "(read failed)";
}
}
// Extract project structure
const tree = await run(sandbox, "sh", [
"-c",
`find ${projectDir} -maxdepth 3 -not -path '*/node_modules/*' -not -path '*/.git/*' | head -50`,
]);
artifacts["project-tree"] = tree.out;
// Extract .claude directory structure
const claudeTree = await run(sandbox, "sh", [
"-c",
`find ${projectDir}/.claude -type f 2>/dev/null | head -20`,
]);
artifacts[".claude-tree"] = claudeTree.out;
// Full stderr (hook trace log)
artifacts["stderr-trace"] = sessionResult.err.slice(0, 5000);
// Claude output
artifacts["claude-output"] = sessionResult.out.slice(0, 3000);
results.push({
step: "artifact-extraction",
ok: true,
detail: `${Object.keys(artifacts).length} artifacts extracted`,
time: elapsed(t7),
});
console.log(` OK: ${Object.keys(artifacts).length} artifacts (${elapsed(t7)})`);
// Print key artifacts
console.log("\n--- Artifacts ---");
console.log("\n[.claude tree]");
console.log(artifacts[".claude-tree"] || "(empty)");
console.log("\n[project tree]");
console.log(artifacts["project-tree"]?.slice(0, 500) || "(empty)");
if (artifacts["stderr-trace"]) {
console.log("\n[stderr trace (first 1000 chars)]");
console.log(artifacts["stderr-trace"].slice(0, 1000));
}
for (const [path, content] of Object.entries(artifacts)) {
if (path.includes("debug") && content !== "(read failed)") {
console.log(`\n[debug log: ${path} (first 500 chars)]`);
console.log(content.slice(0, 500));
}
}
} catch (err: any) {
results.push({
step: "fatal",
ok: false,
detail: err.message?.slice(0, 400) ?? String(err),
time: elapsed(t0),
});
console.error(`Fatal error: ${err.message}`);
} finally {
if (sandbox) {
try {
console.log("\nStopping sandbox...");
await sandbox.stop();
console.log("Sandbox stopped");
} catch {
console.log("Sandbox stop failed (may already be stopped)");
}
}
}
// ── summary ────────────────────────────────────────────────────────
const allPassed = results.every((r) => r.ok);
const hookStep = results.find((r) => r.step === "hook-verification");
const sessionStep = results.find((r) => r.step === "claude-session");
console.log("\n" + "=".repeat(60));
console.log(allPassed ? "ALL CHECKS PASSED" : "SOME CHECKS FAILED");
console.log("=".repeat(60));
for (const r of results) {
const icon = r.ok ? "PASS" : "FAIL";
console.log(` [${icon}] ${r.step}: ${r.detail.slice(0, 120)} (${r.time})`);
}
console.log(`\nTotal time: ${elapsed(t0)}`);
// Verdict on the critical question
console.log("\n--- VERDICT ---");
if (sessionStep?.ok && hookStep?.ok) {
console.log(
"Interactive Claude Code session WORKS in sandbox with hooks firing."
);
console.log("Proceed to full sister skill implementation.");
} else if (sessionStep?.ok && !hookStep?.ok) {
console.log(
"Claude Code session completed but NO hook evidence found."
);
console.log(
"Hooks may not fire in sandbox. Investigate PTY requirements or alternative session modes."
);
} else {
console.log(
"Claude Code session did NOT complete successfully."
);
console.log(
"Review error output above. May need different install approach or sandbox configuration."
);
}
process.exit(allPassed ? 0 : 1);
}
main();
#!/usr/bin/env bun
/**
* Spike: Sandbox provisioning + Claude Code install
*
* Acceptance criteria:
* 1. Creates a Vercel Sandbox with node24 runtime
* 2. Claude Code installs via npm install -g @anthropic-ai/claude-code
* 3. `claude --version` returns a valid version string
* 4. ANTHROPIC_API_KEY is accessible inside the sandbox
* 5. Script exits cleanly and reports success/failure
*/
import { Sandbox } from "@vercel/sandbox";
// ── helpers ──────────────────────────────────────────────────────────
function resolveApiKey(): string {
// Support direct key or AI Gateway key
const key =
process.env.ANTHROPIC_API_KEY ??
process.env.AI_GATEWAY_API_KEY ??
process.env.VERCEL_API_KEY;
if (!key) {
console.error(
"❌ Missing API key. Set one of: ANTHROPIC_API_KEY, AI_GATEWAY_API_KEY, VERCEL_API_KEY"
);
process.exit(1);
}
return key;
}
function resolveBaseUrl(): string | undefined {
return process.env.ANTHROPIC_BASE_URL;
}
function elapsed(start: number): string {
return `${((performance.now() - start) / 1000).toFixed(1)}s`;
}
async function stdout(
result: Awaited<ReturnType<InstanceType<typeof Sandbox>["runCommand"]>>
): Promise<string> {
return (await result.stdout()).trim();
}
async function stderr(
result: Awaited<ReturnType<InstanceType<typeof Sandbox>["runCommand"]>>
): Promise<string> {
return (await result.stderr()).trim();
}
// ── main ─────────────────────────────────────────────────────────────
async function main() {
const t0 = performance.now();
const apiKey = resolveApiKey();
const baseUrl = resolveBaseUrl();
const results: { step: string; ok: boolean; detail: string; time: string }[] =
[];
let sandbox: InstanceType<typeof Sandbox> | undefined;
try {
// Step 1 — Create sandbox
console.log("⏳ Creating sandbox (node24)…");
const t1 = performance.now();
const sandboxEnv: Record<string, string> = {
ANTHROPIC_API_KEY: apiKey,
};
if (baseUrl) sandboxEnv.ANTHROPIC_BASE_URL = baseUrl;
sandbox = await Sandbox.create({
runtime: "node24",
env: sandboxEnv,
});
results.push({
step: "create-sandbox",
ok: true,
detail: `id=${sandbox.sandboxId} status=${sandbox.status}`,
time: elapsed(t1),
});
console.log(`✅ Sandbox created: ${sandbox.sandboxId} (${elapsed(t1)})`);
// Step 2 — Verify env vars are accessible
console.log("⏳ Verifying env vars inside sandbox…");
const t2 = performance.now();
const envCheck = await sandbox.runCommand("sh", [
"-c",
'echo "KEY=$(test -n \\"$ANTHROPIC_API_KEY\\" && echo SET || echo MISSING) BASE_URL=${ANTHROPIC_BASE_URL:-unset}"',
]);
const envOut = await stdout(envCheck);
const envOk = envOut.includes("KEY=SET");
results.push({
step: "env-var-check",
ok: envOk,
detail: envOut,
time: elapsed(t2),
});
console.log(
envOk
? `✅ Env vars verified: ${envOut} (${elapsed(t2)})`
: `❌ Env var issue: ${envOut}`
);
// Step 3 — Install Claude Code
console.log("⏳ Installing Claude Code (npm install -g)…");
const t3 = performance.now();
const install = await sandbox.runCommand("npm", [
"install",
"-g",
"@anthropic-ai/claude-code",
]);
const installExit = (install as any).exitCode ?? 0;
const installErr = await stderr(install);
const installOk = installExit === 0;
results.push({
step: "install-claude-code",
ok: installOk,
detail: installOk
? `exit=0`
: `exit=${installExit} stderr=${installErr.slice(0, 200)}`,
time: elapsed(t3),
});
console.log(
installOk
? `✅ Claude Code installed (${elapsed(t3)})`
: `❌ Install failed (exit=${installExit}): ${installErr.slice(0, 200)}`
);
// Step 4 — Verify claude --version
console.log("⏳ Checking claude --version…");
const t4 = performance.now();
const ver = await sandbox.runCommand("claude", ["--version"]);
const verOut = await stdout(ver);
const verOk = /\d+\.\d+/.test(verOut);
results.push({
step: "claude-version",
ok: verOk,
detail: verOut || "(empty)",
time: elapsed(t4),
});
console.log(
verOk
? `✅ claude --version: ${verOut} (${elapsed(t4)})`
: `❌ Unexpected version output: ${verOut}`
);
} catch (err: any) {
results.push({
step: "fatal",
ok: false,
detail: err.message?.slice(0, 300) ?? String(err),
time: elapsed(t0),
});
console.error(`💥 Fatal error: ${err.message}`);
} finally {
// Cleanup
if (sandbox) {
try {
console.log("⏳ Stopping sandbox…");
await sandbox.stop();
console.log("✅ Sandbox stopped");
} catch {
console.log("⚠️ Sandbox stop failed (may already be stopped)");
}
}
}
// ── summary ──────────────────────────────────────────────────────
const allPassed = results.every((r) => r.ok);
console.log("\n" + "─".repeat(60));
console.log(
allPassed
? "🎉 ALL CHECKS PASSED"
: "⚠️ SOME CHECKS FAILED"
);
console.log("─".repeat(60));
for (const r of results) {
console.log(` ${r.ok ? "✅" : "❌"} ${r.step}: ${r.detail} (${r.time})`);
}
console.log(`\nTotal time: ${elapsed(t0)}`);
process.exit(allPassed ? 0 : 1);
}
main();
/**
* Stable contracts for the sandbox benchmark runner.
*
* schema_version tracks breaking changes. Consumers should check this field
* and reject unknown versions rather than silently misinterpreting data.
*/
// ---------------------------------------------------------------------------
// RunnerEvent — structured NDJSON log entries (stderr when --log-format json)
// ---------------------------------------------------------------------------
export const RUNNER_EVENT_NAMES = [
"runner.start",
"runner.completed",
"runner.failed",
"snapshot.cache.hit",
"snapshot.cache.miss",
"snapshot.create.started",
"snapshot.create.succeeded",
"snapshot.verify.started",
"snapshot.verify.succeeded",
"snapshot.verify.failed",
"plugin.upload.started",
"plugin.upload.chunk",
"plugin.upload.completed",
"scenario.start",
"scenario.completed",
"scenario.command.failed",
"scenario.timeout",
"artifact.extract.succeeded",
"artifact.extract.failed",
] as const;
export type RunnerEventName = (typeof RUNNER_EVENT_NAMES)[number];
export interface RunnerEvent {
schema_version: 1;
run_id: string;
event: RunnerEventName;
timestamp: string; // ISO 8601
payload: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// RunSummary — final machine-readable output (stdout when --json)
// ---------------------------------------------------------------------------
export type RunStatus = "pass" | "fail" | "timeout" | "error";
export interface ScenarioSummary {
slug: string;
sandbox_id: string;
status: RunStatus;
duration_ms: number;
session_method: string;
expected_skills: string[];
claimed_skills: string[];
hook_evidence: {
claim_dirs: boolean;
seen_file: boolean;
debug_log_count: number;
pre_tool_use_in_stderr: boolean;
user_prompt_in_stderr: boolean;
};
error?: string;
}
export interface SnapshotMeta {
snapshot_id: string;
cached: boolean;
age_hours?: number;
creation_duration_ms?: number;
}
export interface RunSummary {
schema_version: 1;
run_id: string;
status: RunStatus;
timestamp: string; // ISO 8601
snapshot: SnapshotMeta;
scenarios: ScenarioSummary[];
timing: {
total_duration_ms: number;
scenario_durations_ms: Record<string, number>;
};
config: {
concurrency: number;
timeout_ms: number;
results_dir: string;
mode: "full" | "quick" | "single";
};
}
// ---------------------------------------------------------------------------
// RunnerError — typed error model with actionable hints
// ---------------------------------------------------------------------------
export const RUNNER_ERROR_CODES = [
"SNAPSHOT_CACHE_STALE",
"SNAPSHOT_VERIFY_FAILED",
"PLUGIN_UPLOAD_CHUNK_FAILED",
"SANDBOX_PATH_UNRESOLVED",
"SCENARIO_LOAD_FAILED",
] as const;
export type RunnerErrorCode = (typeof RUNNER_ERROR_CODES)[number];
const ERROR_HINTS: Record<RunnerErrorCode, string> = {
SNAPSHOT_CACHE_STALE:
"Run with --force-snapshot to recreate the base snapshot.",
SNAPSHOT_VERIFY_FAILED:
"The cached snapshot may be corrupted. Delete the cache file and retry.",
PLUGIN_UPLOAD_CHUNK_FAILED:
"Sandbox writeFiles() failed for a chunk. Check sandbox API limits or retry.",
SANDBOX_PATH_UNRESOLVED:
"A sandbox path could not be resolved. Verify SANDBOX_PLUGIN_DIR and project directory.",
SCENARIO_LOAD_FAILED:
"Scenarios could not be loaded. Ensure scripts/benchmark-scenarios.js exists or benchmark-runner.ts is parseable.",
};
const ERROR_RETRYABLE: Record<RunnerErrorCode, boolean> = {
SNAPSHOT_CACHE_STALE: true,
SNAPSHOT_VERIFY_FAILED: true,
PLUGIN_UPLOAD_CHUNK_FAILED: true,
SANDBOX_PATH_UNRESOLVED: false,
SCENARIO_LOAD_FAILED: false,
};
export class RunnerError extends Error {
readonly code: RunnerErrorCode;
readonly hint: string;
readonly retryable: boolean;
readonly cause?: Error;
constructor(code: RunnerErrorCode, message: string, cause?: Error) {
super(message);
this.name = "RunnerError";
this.code = code;
this.hint = ERROR_HINTS[code];
this.retryable = ERROR_RETRYABLE[code];
this.cause = cause;
}
toJSON() {
return {
code: this.code,
message: this.message,
hint: this.hint,
retryable: this.retryable,
cause: this.cause?.message,
};
}
}
// ---------------------------------------------------------------------------
// Exit codes
// ---------------------------------------------------------------------------
/** Exit 0: all scenarios passed */
export const EXIT_ALL_PASS = 0;
/** Exit 1: one or more scenarios failed/timed out */
export const EXIT_SOME_FAIL = 1;
/** Exit 2: fatal runner error (snapshot failure, scenario load failure, etc.) */
export const EXIT_FATAL = 2;
// ---------------------------------------------------------------------------
// Helpers to convert internal ScenarioResult → ScenarioSummary
// ---------------------------------------------------------------------------
export function scenarioStatus(result: {
success: boolean;
timedOut: boolean;
error?: string;
}): RunStatus {
if (result.timedOut) return "timeout";
if (result.error) return "error";
if (result.success) return "pass";
return "fail";
}
export function overallStatus(scenarios: ScenarioSummary[]): RunStatus {
if (scenarios.some((s) => s.status === "error")) return "error";
if (scenarios.every((s) => s.status === "pass")) return "pass";
if (scenarios.some((s) => s.status === "timeout")) return "timeout";
return "fail";
}
Related skills
How it compares
Pick benchmark-sandbox over local benchmark-agents when you need parallel isolated microVM runs with automated browser verification and deploy scoring.
FAQ
What does benchmark-sandbox do?
Run vercel-plugin eval scenarios in Vercel Sandboxes instead of local WezTerm panels. Provisions ephemeral microVMs with Claude Code + plugin pre-installed, runs benchmark prompts, extracts hook artifacts, and produces c
When should I use benchmark-sandbox?
Run vercel-plugin eval scenarios in Vercel Sandboxes instead of local WezTerm panels. Provisions ephemeral microVMs with Claude Code + plugin pre-installed, runs benchmark prompts, extracts hook artifacts, and produces c
What are common prerequisites?
--- name: benchmark-sandbox description: Run vercel-plugin eval scenarios in Vercel Sandboxes instead of local WezTerm panels.
Is Benchmark Sandbox safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.