Skip to content

Commit ead4f13

Browse files
V48 Gate 3 (implementation-only): VercelSandboxHost — the sandbox implementation of the primitive Host
The second implementation of BitcodePipelineHost, sibling to InlineHost: same contract, sandbox mechanics. provisionRepository creates a sandbox with a git `source` (the Sandbox SDK clones the full working tree at the revision into /vercel/sandbox); the workspace lists files via `git ls-files` over runCommand, reads via readFileToBuffer (traversal-guarded), execs via runCommand, and disposes via session.stop. readWorkspaceSources bridges its checkout to {path, content}[] identically to the inline host — so the pipeline's source acquisition is host-agnostic. Distinct from the existing VercelSandboxPipelineHost (the harness-plan/command runner); this is the source/filesystem primitive. 4 tests (capabilities, git-source provision + FS + traversal guard + readWorkspaceSources, runCommand); pipeline-hosts 6 suites/31 green; tsc clean. Both implementations of the primitive Host now exist. Remaining: wire the deposit Setup to provision the full checkout through a Host + retire the API sample inventory (the prod-behavior-changing integration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 72fd38b commit ead4f13

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { VercelSandboxHost } from '../sandbox-host';
2+
import { readWorkspaceSources } from '../host';
3+
import type { SandboxCreateOptions, SandboxFactory, SandboxSession } from '../types';
4+
5+
const FILES: Record<string, string> = {
6+
'README.md': '# Demo',
7+
'src/app.ts': 'export function main() {}',
8+
};
9+
10+
function mockFactory() {
11+
const createCalls: SandboxCreateOptions[] = [];
12+
let stopped = false;
13+
const session: SandboxSession = {
14+
sandboxId: 'sbx-1',
15+
status: 'running',
16+
writeFiles: async () => {},
17+
runCommand: (async (params: any) => {
18+
if (params?.cmd === 'git' && params?.args?.includes('ls-files')) {
19+
return { exitCode: 0, stdout: async () => Object.keys(FILES).join('\n'), stderr: async () => '' };
20+
}
21+
return { exitCode: 0, stdout: async () => 'ok', stderr: async () => '' };
22+
}) as SandboxSession['runCommand'],
23+
readFileToBuffer: async ({ path }: { path: string }) => {
24+
const rel = path.replace('/vercel/sandbox/', '');
25+
return FILES[rel] != null ? Buffer.from(FILES[rel]) : null;
26+
},
27+
stop: async () => {
28+
stopped = true;
29+
return undefined;
30+
},
31+
};
32+
const sandboxFactory: SandboxFactory = {
33+
create: async (options) => {
34+
createCalls.push(options);
35+
return session;
36+
},
37+
};
38+
return { sandboxFactory, createCalls, isStopped: () => stopped };
39+
}
40+
41+
describe('VercelSandboxHost (primitive Host implementation)', () => {
42+
it('reports vercel-sandbox capabilities', () => {
43+
const { sandboxFactory } = mockFactory();
44+
const host = new VercelSandboxHost({ sandboxFactory });
45+
expect(host.capabilities).toMatchObject({
46+
hostKind: 'vercel-sandbox',
47+
clone: true,
48+
filesystem: true,
49+
exec: true,
50+
defaultWorkingDirectory: '/vercel/sandbox',
51+
});
52+
});
53+
54+
it('provisions via a git source and exposes the checkout filesystem', async () => {
55+
const { sandboxFactory, createCalls, isStopped } = mockFactory();
56+
const host = new VercelSandboxHost({ sandboxFactory });
57+
58+
const ws = await host.provisionRepository({
59+
repositoryFullName: 'o/r',
60+
url: 'https://github.com/o/r.git',
61+
revision: 'abc123',
62+
password: 'tok',
63+
});
64+
65+
// The sandbox was created with a git source at the revision.
66+
expect(createCalls).toHaveLength(1);
67+
expect(createCalls[0].source).toMatchObject({
68+
type: 'git',
69+
url: 'https://github.com/o/r.git',
70+
revision: 'abc123',
71+
password: 'tok',
72+
});
73+
expect(ws.workspacePath).toBe('/vercel/sandbox');
74+
75+
// listFiles via git ls-files; readFile via readFileToBuffer.
76+
expect((await ws.listFiles()).sort()).toEqual(['README.md', 'src/app.ts']);
77+
expect(await ws.readFile('src/app.ts')).toBe('export function main() {}');
78+
expect(await ws.readFile('../escape')).toBeNull(); // traversal guarded
79+
80+
// readWorkspaceSources bridges it identically to the inline host.
81+
const sources = await readWorkspaceSources(ws, { paths: ['README.md'] });
82+
expect(sources).toEqual([{ path: 'README.md', content: '# Demo' }]);
83+
84+
await ws.dispose();
85+
expect(isStopped()).toBe(true);
86+
});
87+
88+
it('runCommand surfaces exit code + output', async () => {
89+
const { sandboxFactory } = mockFactory();
90+
const host = new VercelSandboxHost({ sandboxFactory });
91+
const ws = await host.provisionRepository({ repositoryFullName: 'o/r', url: 'https://github.com/o/r.git', revision: 'main' });
92+
const result = await ws.runCommand('echo', ['hi']);
93+
expect(result.exitCode).toBe(0);
94+
expect(result.stdout).toBe('ok');
95+
});
96+
});

packages/pipeline-hosts/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from './asset-pack-harness';
22
export * from './distributed-execution-runtime-receipt';
33
export * from './host';
44
export * from './inline-host';
5+
export * from './sandbox-host';
56
export * from './manifest';
67
export * from './types';
78
export * from './vercel-sandbox-host';
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* VercelSandboxHost — the Vercel Sandbox implementation of the primitive Host
3+
* (V48 Gate 3). The sibling of InlineHost: same `BitcodePipelineHost` contract,
4+
* different mechanics.
5+
*
6+
* Provisions a repository by creating a sandbox with a git `source` (the Sandbox
7+
* SDK clones the full working tree at the revision into the working directory), and
8+
* exposes that checkout via the sandbox session — `git ls-files` over runCommand and
9+
* `readFileToBuffer`. This is the durable, isolated host (prod); a serverless
10+
* dispatching request hands off to it because it cannot itself clone.
11+
*
12+
* (Distinct from the existing `VercelSandboxPipelineHost`, which runs a full harness
13+
* PLAN of commands + artifacts. This class is the source/filesystem primitive.)
14+
*/
15+
16+
import type {
17+
BitcodeHostCapabilities,
18+
BitcodeHostRepositorySource,
19+
BitcodeHostWorkspace,
20+
BitcodePipelineHost,
21+
HostCommandResult,
22+
} from './host';
23+
import type {
24+
SandboxCommandResult,
25+
SandboxCreateOptions,
26+
SandboxFactory,
27+
SandboxSession,
28+
VercelSandboxRuntime,
29+
} from './types';
30+
31+
const DEFAULT_WORKING_DIRECTORY = '/vercel/sandbox';
32+
33+
async function readStream(result: SandboxCommandResult, stream: 'stdout' | 'stderr'): Promise<string> {
34+
const reader = result[stream];
35+
if (typeof reader === 'function') return reader.call(result);
36+
if (typeof result.output === 'function') return result.output(stream);
37+
return '';
38+
}
39+
40+
function joinPosix(base: string, relative: string): string {
41+
return `${base.replace(/\/+$/, '')}/${relative.replace(/^\/+/, '')}`;
42+
}
43+
44+
export interface VercelSandboxHostOptions {
45+
sandboxFactory: SandboxFactory;
46+
workingDirectory?: string;
47+
runtime?: VercelSandboxRuntime;
48+
createOptions?: Partial<SandboxCreateOptions>;
49+
}
50+
51+
class VercelSandboxWorkspace implements BitcodeHostWorkspace {
52+
constructor(
53+
private readonly session: SandboxSession,
54+
readonly workspacePath: string,
55+
) {}
56+
57+
async listFiles(): Promise<string[]> {
58+
const result = await this.session.runCommand({ cmd: 'git', args: ['ls-files'], cwd: this.workspacePath });
59+
if (result.exitCode !== 0) return [];
60+
const out = await readStream(result, 'stdout');
61+
return out
62+
.split(/\r?\n/)
63+
.map((line) => line.trim())
64+
.filter(Boolean);
65+
}
66+
67+
async readFile(relativePath: string): Promise<string | null> {
68+
// Defense-in-depth: never read outside the checkout.
69+
if (relativePath.split('/').some((segment) => segment === '..')) return null;
70+
const buffer = await this.session.readFileToBuffer({ path: joinPosix(this.workspacePath, relativePath) });
71+
return buffer ? buffer.toString('utf8') : null;
72+
}
73+
74+
async runCommand(cmd: string, args: string[] = []): Promise<HostCommandResult> {
75+
const result = await this.session.runCommand({ cmd, args, cwd: this.workspacePath });
76+
return {
77+
exitCode: result.exitCode,
78+
stdout: await readStream(result, 'stdout'),
79+
stderr: await readStream(result, 'stderr'),
80+
};
81+
}
82+
83+
async dispose(): Promise<void> {
84+
if (this.session.stop) {
85+
try {
86+
await this.session.stop({ blocking: true });
87+
} catch {
88+
// Best-effort; the sandbox is reclaimed regardless.
89+
}
90+
}
91+
}
92+
}
93+
94+
export class VercelSandboxHost implements BitcodePipelineHost {
95+
private readonly sandboxFactory: SandboxFactory;
96+
private readonly workingDirectory: string;
97+
private readonly runtime?: VercelSandboxRuntime;
98+
private readonly createOptions?: Partial<SandboxCreateOptions>;
99+
100+
constructor(options: VercelSandboxHostOptions) {
101+
this.sandboxFactory = options.sandboxFactory;
102+
this.workingDirectory = options.workingDirectory ?? DEFAULT_WORKING_DIRECTORY;
103+
this.runtime = options.runtime;
104+
this.createOptions = options.createOptions;
105+
}
106+
107+
get capabilities(): BitcodeHostCapabilities {
108+
return {
109+
hostKind: 'vercel-sandbox',
110+
clone: true,
111+
filesystem: true,
112+
exec: true,
113+
ephemeralFilesystem: true,
114+
defaultWorkingDirectory: this.workingDirectory,
115+
};
116+
}
117+
118+
async provisionRepository(source: BitcodeHostRepositorySource): Promise<BitcodeHostWorkspace> {
119+
const session = await this.sandboxFactory.create({
120+
...this.createOptions,
121+
runtime: this.runtime ?? this.createOptions?.runtime,
122+
source: {
123+
type: 'git',
124+
url: source.url,
125+
revision: source.revision,
126+
username: source.username,
127+
password: source.password,
128+
},
129+
});
130+
return new VercelSandboxWorkspace(session, this.workingDirectory);
131+
}
132+
}

0 commit comments

Comments
 (0)