@fusionkit/session-vercel-sandbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ import type { NetworkPolicy as WarrantNetworkPolicy } from "@fusionkit/protocol";
2
+ import type { BackendExecutionKind, SessionBackend, SessionBackendResult, SessionExecution } from "@fusionkit/runner";
3
+ import { Sandbox } from "@vercel/sandbox";
4
+ import type { NetworkPolicy as VercelNetworkPolicy } from "@vercel/sandbox";
5
+ export type VercelSandboxSource = {
6
+ type: "git";
7
+ url: string;
8
+ depth?: number;
9
+ revision?: string;
10
+ } | {
11
+ type: "git";
12
+ url: string;
13
+ username: string;
14
+ password: string;
15
+ depth?: number;
16
+ revision?: string;
17
+ } | {
18
+ type: "tarball";
19
+ url: string;
20
+ } | {
21
+ type: "snapshot";
22
+ snapshotId: string;
23
+ };
24
+ export type VercelSandboxResources = {
25
+ vcpus: number;
26
+ };
27
+ type VercelSandboxCreateBase = {
28
+ token: string;
29
+ teamId?: string;
30
+ projectId?: string;
31
+ timeout: number;
32
+ networkPolicy: VercelNetworkPolicy;
33
+ persistent: boolean;
34
+ resources?: VercelSandboxResources;
35
+ tags?: Record<string, string>;
36
+ };
37
+ export type VercelSandboxCreateInput = (VercelSandboxCreateBase & {
38
+ runtime: string;
39
+ source?: Exclude<VercelSandboxSource, {
40
+ type: "snapshot";
41
+ }>;
42
+ }) | (VercelSandboxCreateBase & {
43
+ source: Extract<VercelSandboxSource, {
44
+ type: "snapshot";
45
+ }>;
46
+ });
47
+ export type VercelSandboxInstance = Awaited<ReturnType<typeof Sandbox.create>>;
48
+ export type VercelSandboxFactory = (input: VercelSandboxCreateInput) => Promise<VercelSandboxInstance>;
49
+ export type VercelSandboxOptions = {
50
+ /** Sandbox runtime image. Defaults to node22. */
51
+ runtime?: string;
52
+ /** Working directory inside the VM. Defaults to /warrant/workspace. */
53
+ workdir?: string;
54
+ /** Credentials; falls back to the ambient Vercel environment. */
55
+ token?: string;
56
+ teamId?: string;
57
+ projectId?: string;
58
+ /** Initial sandbox source. Supports git, tarball, and snapshot sources. */
59
+ source?: VercelSandboxSource;
60
+ /** Convenience for `source: { type: "snapshot", snapshotId }`. */
61
+ sourceSnapshotId?: string;
62
+ /** Whether the sandbox should auto-snapshot on stop. Defaults to false. */
63
+ persistent?: boolean;
64
+ /** Sandbox tags passed to Vercel. */
65
+ tags?: Record<string, string>;
66
+ /** Resource allocation passed to Vercel. */
67
+ resources?: VercelSandboxResources;
68
+ /** Test/extension seam for creating sandboxes without live credentials. */
69
+ createSandbox?: VercelSandboxFactory;
70
+ };
71
+ /**
72
+ * Directory names never staged into a sandbox and never mirrored back:
73
+ * VCS metadata stays local (output is collected as a git diff on the
74
+ * runner side) and dependency trees are reinstalled inside the VM when
75
+ * the task needs them. Backends with runtime-specific state directories
76
+ * extend this set at the call site.
77
+ */
78
+ export declare const SANDBOX_IGNORED_DIRS: ReadonlySet<string>;
79
+ /**
80
+ * Quote a value for POSIX sh: single quotes, with embedded single quotes
81
+ * rendered as '\''. Unlike double quotes, nothing inside single quotes is
82
+ * expanded, so secret values containing $, backticks, or quotes are inert.
83
+ */
84
+ export declare function shellQuote(value: string): string;
85
+ /**
86
+ * List a workspace's files as relative paths, skipping the shared ignored
87
+ * directories plus any backend-specific extras. The one walker used to
88
+ * stage workspaces into sandboxes.
89
+ */
90
+ export declare function listWorkspaceFiles(root: string, extraIgnores?: Iterable<string>): string[];
91
+ /**
92
+ * Write one mirrored-back sandbox file into the local checkout, with the
93
+ * path validated against escape before anything touches the filesystem.
94
+ * Shared by every sandbox-shaped backend so mirror-back path safety lives
95
+ * in exactly one place.
96
+ */
97
+ export declare function writeMirroredFile(repoDir: string, rel: string, content: Uint8Array): void;
98
+ /**
99
+ * Resolve Vercel credentials from explicit options or the ambient
100
+ * environment, failing closed (capability error) when no token exists.
101
+ */
102
+ export declare function vercelCredentialsFromEnv(options?: {
103
+ token?: string;
104
+ teamId?: string;
105
+ projectId?: string;
106
+ }): {
107
+ token: string;
108
+ teamId?: string;
109
+ projectId?: string;
110
+ };
111
+ /** Map a Warrant network policy to a Vercel Sandbox network policy. */
112
+ export declare function toVercelNetwork(policy: WarrantNetworkPolicy): VercelNetworkPolicy;
113
+ export declare class VercelSandboxBackend implements SessionBackend {
114
+ readonly isolation: "vercel-sandbox";
115
+ private readonly options;
116
+ constructor(options?: VercelSandboxOptions);
117
+ supports(_kind: BackendExecutionKind, contract: SessionExecution["contract"]): boolean;
118
+ execute(input: SessionExecution): Promise<SessionBackendResult>;
119
+ }
120
+ /** Create a Vercel Sandbox session backend for a Warrant runner. */
121
+ export declare function vercelSandboxBackend(options?: VercelSandboxOptions): VercelSandboxBackend;
122
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * @fusionkit/session-vercel-sandbox — a session backend that runs each
3
+ * governed session inside a Vercel Sandbox (a Firecracker microVM).
4
+ *
5
+ * This is the strongest isolation tier in the repo: VM-level separation
6
+ * on the same infrastructure that powers Vercel's build system, with
7
+ * domain-based egress policy applied at the VM boundary rather than via
8
+ * environment variables a binary could ignore.
9
+ *
10
+ * Status: experimental and integration-gated. It compiles against the
11
+ * real @vercel/sandbox types, but running it requires Vercel credentials
12
+ * (VERCEL_TOKEN / VERCEL_TEAM_ID / VERCEL_PROJECT_ID, or an OIDC token in
13
+ * a Vercel environment). Without them, `vercelSandboxBackend()` still
14
+ * constructs; `execute` throws a clear capability error. The kernel and
15
+ * the other backends do not depend on it.
16
+ *
17
+ * This module also owns the sandbox-shaped helpers (file listing, shell
18
+ * quoting, mirror-back writes, credential resolution) shared with
19
+ * `@fusionkit/session-harness`, which drives the same microVM tier through
20
+ * the AI SDK harness bridge.
21
+ */
22
+ import { readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
23
+ import { dirname, join, relative } from "node:path";
24
+ import { CapabilityMismatchError, executionHash, resolveSessionEnv } from "@fusionkit/runner";
25
+ import { parseWorkspaceRelativePath, resolveInsideWorkspace } from "@fusionkit/workspace";
26
+ import { Sandbox } from "@vercel/sandbox";
27
+ /**
28
+ * Directory names never staged into a sandbox and never mirrored back:
29
+ * VCS metadata stays local (output is collected as a git diff on the
30
+ * runner side) and dependency trees are reinstalled inside the VM when
31
+ * the task needs them. Backends with runtime-specific state directories
32
+ * extend this set at the call site.
33
+ */
34
+ export const SANDBOX_IGNORED_DIRS = new Set([
35
+ ".git",
36
+ "node_modules",
37
+ ".warrant"
38
+ ]);
39
+ /** Defaults for the microVM; both are overridable via VercelSandboxOptions. */
40
+ const DEFAULT_WORKDIR = "/warrant/workspace";
41
+ const DEFAULT_RUNTIME = "node22";
42
+ function defaultCreateSandbox(input) {
43
+ return Sandbox.create(input);
44
+ }
45
+ function normalizeSandboxWorkdir(workdir) {
46
+ if (!workdir.startsWith("/") || workdir.includes("\0")) {
47
+ throw new CapabilityMismatchError(`vercel sandbox workdir must be an absolute VM path: ${workdir}`);
48
+ }
49
+ if (workdir.split("/").includes("..")) {
50
+ throw new CapabilityMismatchError(`vercel sandbox workdir must not contain '..': ${workdir}`);
51
+ }
52
+ return workdir.replace(/\/+$/, "") || "/";
53
+ }
54
+ function posixJoin(base, rel) {
55
+ const normalizedRel = rel.split("\\").join("/");
56
+ if (base === "/")
57
+ return `/${normalizedRel}`;
58
+ return `${base}/${normalizedRel}`;
59
+ }
60
+ function sandboxCwd(workdir, cwd) {
61
+ if (cwd === "." || cwd === "./")
62
+ return workdir;
63
+ const safeRel = parseWorkspaceRelativePath(cwd);
64
+ return posixJoin(workdir, safeRel);
65
+ }
66
+ function sandboxSource(options) {
67
+ if (options.source !== undefined && options.sourceSnapshotId !== undefined) {
68
+ throw new CapabilityMismatchError("vercel sandbox options must not set both source and sourceSnapshotId");
69
+ }
70
+ if (options.source !== undefined)
71
+ return options.source;
72
+ if (options.sourceSnapshotId !== undefined) {
73
+ return { type: "snapshot", snapshotId: options.sourceSnapshotId };
74
+ }
75
+ return undefined;
76
+ }
77
+ function sandboxCreateInput(input) {
78
+ const { credentials, runtime, timeoutMs, networkPolicy, options } = input;
79
+ const base = {
80
+ ...credentials,
81
+ timeout: timeoutMs,
82
+ networkPolicy,
83
+ persistent: options.persistent ?? false,
84
+ ...(options.resources !== undefined ? { resources: options.resources } : {}),
85
+ ...(options.tags !== undefined ? { tags: options.tags } : {})
86
+ };
87
+ const source = sandboxSource(options);
88
+ if (source?.type === "snapshot") {
89
+ // @vercel/sandbox@2.2.0 snapshot sources inherit their runtime from the
90
+ // snapshot and reject `runtime` on the same create call.
91
+ return { ...base, source };
92
+ }
93
+ return {
94
+ ...base,
95
+ runtime,
96
+ ...(source !== undefined ? { source } : {})
97
+ };
98
+ }
99
+ /**
100
+ * Quote a value for POSIX sh: single quotes, with embedded single quotes
101
+ * rendered as '\''. Unlike double quotes, nothing inside single quotes is
102
+ * expanded, so secret values containing $, backticks, or quotes are inert.
103
+ */
104
+ export function shellQuote(value) {
105
+ return `'${value.replaceAll("'", String.raw `'\''`)}'`;
106
+ }
107
+ /**
108
+ * List a workspace's files as relative paths, skipping the shared ignored
109
+ * directories plus any backend-specific extras. The one walker used to
110
+ * stage workspaces into sandboxes.
111
+ */
112
+ export function listWorkspaceFiles(root, extraIgnores = []) {
113
+ const ignored = new Set([...SANDBOX_IGNORED_DIRS, ...extraIgnores]);
114
+ const out = [];
115
+ const walk = (dir) => {
116
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
117
+ if (ignored.has(entry.name))
118
+ continue;
119
+ if (entry.isDirectory()) {
120
+ walk(join(dir, entry.name));
121
+ }
122
+ else if (entry.isFile()) {
123
+ out.push(relative(root, join(dir, entry.name)));
124
+ }
125
+ }
126
+ };
127
+ walk(root);
128
+ return out;
129
+ }
130
+ /**
131
+ * Write one mirrored-back sandbox file into the local checkout, with the
132
+ * path validated against escape before anything touches the filesystem.
133
+ * Shared by every sandbox-shaped backend so mirror-back path safety lives
134
+ * in exactly one place.
135
+ */
136
+ export function writeMirroredFile(repoDir, rel, content) {
137
+ const safeRel = parseWorkspaceRelativePath(rel);
138
+ const target = resolveInsideWorkspace(repoDir, safeRel);
139
+ mkdirSync(dirname(target), { recursive: true });
140
+ writeFileSync(target, content);
141
+ }
142
+ /**
143
+ * Resolve Vercel credentials from explicit options or the ambient
144
+ * environment, failing closed (capability error) when no token exists.
145
+ */
146
+ export function vercelCredentialsFromEnv(options = {}) {
147
+ const token = options.token ?? process.env.VERCEL_TOKEN;
148
+ if (!token) {
149
+ throw new CapabilityMismatchError("vercel sandbox requires VERCEL_TOKEN (or an explicit token)");
150
+ }
151
+ const teamId = options.teamId ?? process.env.VERCEL_TEAM_ID;
152
+ const projectId = options.projectId ?? process.env.VERCEL_PROJECT_ID;
153
+ return {
154
+ token,
155
+ ...(teamId !== undefined ? { teamId } : {}),
156
+ ...(projectId !== undefined ? { projectId } : {})
157
+ };
158
+ }
159
+ /** Map a Warrant network policy to a Vercel Sandbox network policy. */
160
+ export function toVercelNetwork(policy) {
161
+ if (!policy.defaultDeny)
162
+ return "allow-all";
163
+ if (policy.allowHosts.length === 0)
164
+ return "deny-all";
165
+ // Domain allowlist; everything else is denied by default.
166
+ return { allow: policy.allowHosts };
167
+ }
168
+ export class VercelSandboxBackend {
169
+ isolation = "vercel-sandbox";
170
+ options;
171
+ constructor(options = {}) {
172
+ this.options = options;
173
+ }
174
+ supports(_kind, contract) {
175
+ // The microVM has a real OS, so it can host real vendor CLIs and the
176
+ // command harness. (The node-based mock is for the in-process tests.)
177
+ return contract.agent.kind !== "mock";
178
+ }
179
+ async execute(input) {
180
+ const { contract, repoDir, secrets, execution, emit } = input;
181
+ const creds = vercelCredentialsFromEnv(this.options);
182
+ const workdir = normalizeSandboxWorkdir(this.options.workdir ?? DEFAULT_WORKDIR);
183
+ const cwd = sandboxCwd(workdir, execution.cwd);
184
+ const runtime = this.options.runtime ?? DEFAULT_RUNTIME;
185
+ const createSandbox = this.options.createSandbox ?? defaultCreateSandbox;
186
+ const sandbox = await createSandbox(sandboxCreateInput({
187
+ credentials: creds,
188
+ runtime,
189
+ timeoutMs: execution.timeoutMs,
190
+ networkPolicy: toVercelNetwork(contract.network),
191
+ options: this.options
192
+ }));
193
+ try {
194
+ await sandbox.fs.mkdir(workdir, { recursive: true });
195
+ const inputFiles = listWorkspaceFiles(repoDir);
196
+ if (inputFiles.length > 0) {
197
+ await sandbox.writeFiles(inputFiles.map((rel) => ({
198
+ path: posixJoin(workdir, rel),
199
+ content: readFileSync(join(repoDir, rel))
200
+ })));
201
+ }
202
+ // Secrets are injected as single-quoted exports: shellQuote renders
203
+ // values inert to expansion, so $, backticks, and quotes pass through.
204
+ const env = resolveSessionEnv(execution.env, secrets);
205
+ const envPrefix = Object.entries(env)
206
+ .map(([name, value]) => `export ${name}=${shellQuote(value)}; `)
207
+ .join("");
208
+ const script = execution.kind === "shell"
209
+ ? execution.script
210
+ : `${shellQuote(execution.cmd)} ${execution.args.map(shellQuote).join(" ")}`;
211
+ const result = await sandbox.runCommand("sh", [
212
+ "-c",
213
+ `cd ${shellQuote(cwd)} && ${envPrefix}${script}`
214
+ ]);
215
+ emit({
216
+ type: "command.executed",
217
+ argvHash: executionHash(execution),
218
+ exitCode: result.exitCode
219
+ });
220
+ await mirrorBack(sandbox, workdir, repoDir);
221
+ const [stdout, stderr] = await Promise.all([
222
+ result.stdout(),
223
+ result.stderr()
224
+ ]);
225
+ const log = Buffer.from(stdout + stderr, "utf8");
226
+ return { exitCode: result.exitCode, log };
227
+ }
228
+ finally {
229
+ await sandbox.stop().catch(() => undefined);
230
+ }
231
+ }
232
+ }
233
+ /**
234
+ * Mirror the sandbox workdir back onto the local checkout so the runner's
235
+ * standard git-based output collection sees the changes. A per-file walk is
236
+ * the operation the sandbox FS API supports (there is no bulk download),
237
+ * and the file count is bounded by the workspace that was staged in.
238
+ */
239
+ async function mirrorBack(sandbox, workdir, repoDir) {
240
+ const walk = async (dir) => {
241
+ const names = await sandbox.fs.readdir(dir);
242
+ for (const name of names) {
243
+ if (SANDBOX_IGNORED_DIRS.has(name))
244
+ continue;
245
+ const remote = `${dir}/${name}`;
246
+ const info = await sandbox.fs.stat(remote);
247
+ if (info.isDirectory()) {
248
+ await walk(remote);
249
+ continue;
250
+ }
251
+ const rel = remote.slice(workdir.length + 1);
252
+ const buffer = await sandbox.fs.readFile(remote);
253
+ writeMirroredFile(repoDir, rel, buffer);
254
+ }
255
+ };
256
+ await walk(workdir);
257
+ }
258
+ /** Create a Vercel Sandbox session backend for a Warrant runner. */
259
+ export function vercelSandboxBackend(options = {}) {
260
+ return new VercelSandboxBackend(options);
261
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,254 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { CapabilityMismatchError, prepareExecution } from "@fusionkit/runner";
7
+ import { listWorkspaceFiles, SANDBOX_IGNORED_DIRS, toVercelNetwork, VercelSandboxBackend } from "../index.js";
8
+ function contractFixture(overrides = {}) {
9
+ return {
10
+ version: "warrant.contract.v1",
11
+ runId: "run_vercel_sandbox_test",
12
+ issuedAt: "2026-06-11T00:00:00.000Z",
13
+ issuer: { keyId: "ed25519:0000000000000000", role: "plane" },
14
+ requestedBy: { kind: "human", id: "alice" },
15
+ agent: { kind: "command" },
16
+ task: { prompt: "echo hi" },
17
+ runner: { pool: "default" },
18
+ workspace: {
19
+ version: "warrant.manifest.v1",
20
+ baseRef: "abc",
21
+ bundleHash: "1".repeat(64),
22
+ untrackedFiles: [],
23
+ deniedPatterns: [],
24
+ deniedPaths: []
25
+ },
26
+ policyHash: "2".repeat(64),
27
+ secrets: [],
28
+ network: { defaultDeny: true, allowHosts: [] },
29
+ budget: {},
30
+ disclosure: "minimal-context",
31
+ expiresAt: "2026-06-11T01:00:00.000Z",
32
+ signatures: [],
33
+ ...overrides
34
+ };
35
+ }
36
+ function sessionInput(input) {
37
+ const contract = input.contract ?? contractFixture();
38
+ return {
39
+ contract,
40
+ repoDir: input.repoDir,
41
+ secrets: input.secrets ?? [],
42
+ execution: prepareExecution({ contract, mockScriptPath: "/tmp/mock-agent.js" }),
43
+ emit: input.emit ?? (() => undefined)
44
+ };
45
+ }
46
+ function makeRepo(files) {
47
+ const repoDir = mkdtempSync(join(tmpdir(), "vercel-sandbox-test-"));
48
+ for (const [path, content] of Object.entries(files)) {
49
+ const target = join(repoDir, path);
50
+ mkdirSync(join(target, ".."), { recursive: true });
51
+ writeFileSync(target, content);
52
+ }
53
+ return repoDir;
54
+ }
55
+ function makeFakeSandbox(input = {}) {
56
+ const fake = {
57
+ sandbox: undefined,
58
+ mkdirCalls: [],
59
+ runCalls: [],
60
+ stopCalled: false,
61
+ writtenFiles: []
62
+ };
63
+ fake.sandbox = {
64
+ fs: {
65
+ mkdir: async (path, options) => {
66
+ fake.mkdirCalls.push({ path, recursive: options?.recursive });
67
+ },
68
+ readdir: async (path) => input.readdir?.(path) ?? [],
69
+ stat: async (path) => ({
70
+ isDirectory: () => input.directories?.has(path) ?? false
71
+ }),
72
+ readFile: async (path) => Buffer.from(input.files?.get(path) ?? new Uint8Array())
73
+ },
74
+ writeFiles: async (files) => {
75
+ fake.writtenFiles.push(...files);
76
+ },
77
+ runCommand: async (command, args) => {
78
+ fake.runCalls.push({ command, args });
79
+ if (input.runError)
80
+ throw input.runError;
81
+ return {
82
+ exitCode: 0,
83
+ stdout: async () => input.stdout ?? "",
84
+ stderr: async () => input.stderr ?? ""
85
+ };
86
+ },
87
+ stop: async () => {
88
+ fake.stopCalled = true;
89
+ if (input.stopError)
90
+ throw input.stopError;
91
+ return {};
92
+ }
93
+ };
94
+ return fake;
95
+ }
96
+ test("network policy maps to Vercel Sandbox egress policy", () => {
97
+ assert.equal(toVercelNetwork({ defaultDeny: false, allowHosts: [] }), "allow-all");
98
+ assert.equal(toVercelNetwork({ defaultDeny: true, allowHosts: [] }), "deny-all");
99
+ assert.deepEqual(toVercelNetwork({
100
+ defaultDeny: true,
101
+ allowHosts: ["api.example.com", "registry.npmjs.org"]
102
+ }), { allow: ["api.example.com", "registry.npmjs.org"] });
103
+ });
104
+ test("workspace staging ignores VCS, dependencies, and Warrant state", () => {
105
+ assert.ok(SANDBOX_IGNORED_DIRS.has(".warrant"));
106
+ const repoDir = makeRepo({
107
+ "README.md": "keep\n",
108
+ ".git/config": "local git metadata\n",
109
+ "node_modules/pkg/index.js": "dependency\n",
110
+ ".warrant/cache.json": "local warrant state\n",
111
+ "src/index.ts": "export {}\n",
112
+ "src/.warrant/trace.json": "nested warrant state\n"
113
+ });
114
+ try {
115
+ assert.deepEqual(listWorkspaceFiles(repoDir).sort(), [
116
+ "README.md",
117
+ "src/index.ts"
118
+ ]);
119
+ }
120
+ finally {
121
+ rmSync(repoDir, { recursive: true, force: true });
122
+ }
123
+ });
124
+ test("backend passes hardened create options and honors execution cwd", async () => {
125
+ const repoDir = makeRepo({
126
+ "README.md": "root\n",
127
+ "packages/app/package.json": "{}\n"
128
+ });
129
+ const fake = makeFakeSandbox({ stdout: "ok\n" });
130
+ let createInput;
131
+ const backend = new VercelSandboxBackend({
132
+ token: "fake-token",
133
+ runtime: "node24",
134
+ sourceSnapshotId: "snap_123",
135
+ tags: { run: "test", lane: "vercel-backend" },
136
+ resources: { vcpus: 4 },
137
+ createSandbox: async (input) => {
138
+ createInput = input;
139
+ return fake.sandbox;
140
+ }
141
+ });
142
+ const contract = contractFixture({
143
+ network: { defaultDeny: true, allowHosts: ["api.example.com"] },
144
+ execution: {
145
+ kind: "shell",
146
+ script: "pwd",
147
+ cwd: "packages/app",
148
+ timeoutMs: 12_345
149
+ }
150
+ });
151
+ try {
152
+ const result = await backend.execute(sessionInput({ repoDir, contract }));
153
+ assert.equal(result.exitCode, 0);
154
+ assert.equal(result.log.toString("utf8"), "ok\n");
155
+ assert.deepEqual(createInput, {
156
+ token: "fake-token",
157
+ timeout: 12_345,
158
+ networkPolicy: { allow: ["api.example.com"] },
159
+ persistent: false,
160
+ resources: { vcpus: 4 },
161
+ tags: { run: "test", lane: "vercel-backend" },
162
+ source: { type: "snapshot", snapshotId: "snap_123" }
163
+ });
164
+ assert.ok(createInput && !("runtime" in createInput));
165
+ assert.deepEqual(fake.mkdirCalls, [
166
+ { path: "/warrant/workspace", recursive: true }
167
+ ]);
168
+ assert.deepEqual(fake.writtenFiles.map((file) => file.path).sort(), [
169
+ "/warrant/workspace/README.md",
170
+ "/warrant/workspace/packages/app/package.json"
171
+ ]);
172
+ assert.equal(fake.runCalls.length, 1);
173
+ assert.equal(fake.runCalls[0]?.command, "sh");
174
+ assert.equal(fake.runCalls[0]?.args[1], "cd '/warrant/workspace/packages/app' && pwd");
175
+ assert.equal(fake.stopCalled, true);
176
+ }
177
+ finally {
178
+ rmSync(repoDir, { recursive: true, force: true });
179
+ }
180
+ });
181
+ test("backend fails closed without Vercel credentials", async () => {
182
+ const repoDir = makeRepo({ "README.md": "root\n" });
183
+ const previous = {
184
+ VERCEL_TOKEN: process.env.VERCEL_TOKEN,
185
+ VERCEL_TEAM_ID: process.env.VERCEL_TEAM_ID,
186
+ VERCEL_PROJECT_ID: process.env.VERCEL_PROJECT_ID
187
+ };
188
+ delete process.env.VERCEL_TOKEN;
189
+ delete process.env.VERCEL_TEAM_ID;
190
+ delete process.env.VERCEL_PROJECT_ID;
191
+ let created = false;
192
+ const backend = new VercelSandboxBackend({
193
+ createSandbox: async () => {
194
+ created = true;
195
+ return makeFakeSandbox().sandbox;
196
+ }
197
+ });
198
+ try {
199
+ await assert.rejects(backend.execute(sessionInput({ repoDir })), CapabilityMismatchError);
200
+ assert.equal(created, false);
201
+ }
202
+ finally {
203
+ if (previous.VERCEL_TOKEN === undefined)
204
+ delete process.env.VERCEL_TOKEN;
205
+ else
206
+ process.env.VERCEL_TOKEN = previous.VERCEL_TOKEN;
207
+ if (previous.VERCEL_TEAM_ID === undefined)
208
+ delete process.env.VERCEL_TEAM_ID;
209
+ else
210
+ process.env.VERCEL_TEAM_ID = previous.VERCEL_TEAM_ID;
211
+ if (previous.VERCEL_PROJECT_ID === undefined) {
212
+ delete process.env.VERCEL_PROJECT_ID;
213
+ }
214
+ else {
215
+ process.env.VERCEL_PROJECT_ID = previous.VERCEL_PROJECT_ID;
216
+ }
217
+ rmSync(repoDir, { recursive: true, force: true });
218
+ }
219
+ });
220
+ test("backend stops sandbox after execution failure", async () => {
221
+ const repoDir = makeRepo({ "README.md": "root\n" });
222
+ const fake = makeFakeSandbox({ runError: new Error("boom") });
223
+ const backend = new VercelSandboxBackend({
224
+ token: "fake-token",
225
+ createSandbox: async () => fake.sandbox
226
+ });
227
+ try {
228
+ await assert.rejects(backend.execute(sessionInput({ repoDir })), /boom/);
229
+ assert.equal(fake.stopCalled, true);
230
+ }
231
+ finally {
232
+ rmSync(repoDir, { recursive: true, force: true });
233
+ }
234
+ });
235
+ test("backend swallows sandbox stop failures after successful execution", async () => {
236
+ const repoDir = makeRepo({ "README.md": "root\n" });
237
+ const fake = makeFakeSandbox({
238
+ stdout: "done\n",
239
+ stopError: new Error("stop failed")
240
+ });
241
+ const backend = new VercelSandboxBackend({
242
+ token: "fake-token",
243
+ createSandbox: async () => fake.sandbox
244
+ });
245
+ try {
246
+ const result = await backend.execute(sessionInput({ repoDir }));
247
+ assert.equal(result.exitCode, 0);
248
+ assert.equal(result.log.toString("utf8"), "done\n");
249
+ assert.equal(fake.stopCalled, true);
250
+ }
251
+ finally {
252
+ rmSync(repoDir, { recursive: true, force: true });
253
+ }
254
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@fusionkit/session-vercel-sandbox",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/velum-labs/handoffkit.git",
8
+ "directory": "packages/session-vercel-sandbox"
9
+ },
10
+ "description": "Vercel Sandbox session backend for Warrant runners: each governed session runs in a Firecracker microVM with VM-level isolation and domain-based egress policy.",
11
+ "license": "UNLICENSED",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org",
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "dependencies": {
28
+ "@vercel/sandbox": "2.2.0",
29
+ "ms": "2.1.3",
30
+ "@fusionkit/protocol": "0.1.0",
31
+ "@fusionkit/runner": "0.1.0",
32
+ "@fusionkit/workspace": "0.1.0"
33
+ }
34
+ }