@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.
- package/dist/index.d.ts +122 -0
- package/dist/index.js +261 -0
- package/dist/test/vercel-sandbox.test.d.ts +1 -0
- package/dist/test/vercel-sandbox.test.js +254 -0
- package/package.json +34 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|