ai-sdk-sandbox-sbx 1.0.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.
Potentially problematic release.
This version of ai-sdk-sandbox-sbx might be problematic. Click here for more details.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/dist/create-args.d.ts +11 -0
- package/dist/create-args.js +78 -0
- package/dist/credential-broker.d.ts +35 -0
- package/dist/credential-broker.js +143 -0
- package/dist/free-loopback-port.d.ts +2 -0
- package/dist/free-loopback-port.js +12 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/open-sandbox.d.ts +22 -0
- package/dist/open-sandbox.js +54 -0
- package/dist/port-publisher.d.ts +30 -0
- package/dist/port-publisher.js +75 -0
- package/dist/sandbox-scripts.d.ts +29 -0
- package/dist/sandbox-scripts.js +41 -0
- package/dist/sandbox-template.d.ts +26 -0
- package/dist/sandbox-template.js +74 -0
- package/dist/sbx-cli.d.ts +42 -0
- package/dist/sbx-cli.js +77 -0
- package/dist/sbx-error.d.ts +11 -0
- package/dist/sbx-error.js +14 -0
- package/dist/sbx-network-sandbox-session.d.ts +74 -0
- package/dist/sbx-network-sandbox-session.js +133 -0
- package/dist/sbx-network-sandbox.d.ts +26 -0
- package/dist/sbx-network-sandbox.js +68 -0
- package/dist/sbx-sandbox-not-found-error.d.ts +5 -0
- package/dist/sbx-sandbox-not-found-error.js +10 -0
- package/dist/sbx-sandbox-session.d.ts +60 -0
- package/dist/sbx-sandbox-session.js +159 -0
- package/dist/sbx-settings.d.ts +118 -0
- package/dist/sbx-settings.js +1 -0
- package/package.json +87 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { HarnessCapabilityUnsupportedError } from '@ai-sdk/harness';
|
|
2
|
+
import { headerBroker, placeholderBroker, SBX_SANDBOX_PROVIDER_ID } from './credential-broker.js';
|
|
3
|
+
import { cloudPorts, endpointUrl, loopbackPorts } from './port-publisher.js';
|
|
4
|
+
import { KILL_ALL } from './sandbox-scripts.js';
|
|
5
|
+
import { SbxSandboxSession } from './sbx-sandbox-session.js';
|
|
6
|
+
export { SBX_SANDBOX_PROVIDER_ID } from './credential-broker.js';
|
|
7
|
+
/**
|
|
8
|
+
* The whole of a Docker Sandbox, as `HarnessAgent.createSession({ sandboxSession })` expects it:
|
|
9
|
+
* files and processes, plus the ports a harness bridge listens on and the credentials the sandbox
|
|
10
|
+
* must never see.
|
|
11
|
+
*
|
|
12
|
+
* - **Ports** are published the first time the harness asks for them: on this host's loopback for
|
|
13
|
+
* a local sandbox, at the public URL the control plane assigns for a cloud one.
|
|
14
|
+
* - **Credentials** stay out of the sandbox. Each request transformation the harness asks for
|
|
15
|
+
* becomes a custom secret of the Docker Sandboxes proxy, scoped to this sandbox: the value only
|
|
16
|
+
* exists on the host and in the proxy.
|
|
17
|
+
*
|
|
18
|
+
* Obtain one from `createSbxNetworkSandboxSession()` or `resumeSbxNetworkSandboxSession()`.
|
|
19
|
+
*/
|
|
20
|
+
export class SbxNetworkSandboxSession extends SbxSandboxSession {
|
|
21
|
+
id;
|
|
22
|
+
defaultWorkingDirectory;
|
|
23
|
+
/** Absent when brokering is off: the harness then forwards the real credential instead. */
|
|
24
|
+
addRequestTransformations;
|
|
25
|
+
/** Absent when brokering is off, like {@link addRequestTransformations}. */
|
|
26
|
+
setRequestTransformations;
|
|
27
|
+
/** Whether the sandbox runs in Docker Sandboxes Cloud rather than on this host. */
|
|
28
|
+
cloud;
|
|
29
|
+
exposed;
|
|
30
|
+
broker;
|
|
31
|
+
publisher;
|
|
32
|
+
/** Sandbox port → where it is published. */
|
|
33
|
+
published = new Map();
|
|
34
|
+
constructor(handle, ports, brokerCredentials) {
|
|
35
|
+
super(handle);
|
|
36
|
+
this.id = handle.name;
|
|
37
|
+
this.defaultWorkingDirectory = handle.workingDirectory;
|
|
38
|
+
this.cloud = handle.cli.cloud;
|
|
39
|
+
this.exposed = [...ports];
|
|
40
|
+
this.broker = this.cloud
|
|
41
|
+
? headerBroker(handle.cli, handle.name)
|
|
42
|
+
: placeholderBroker(handle.cli, handle.name);
|
|
43
|
+
this.publisher = this.cloud
|
|
44
|
+
? cloudPorts(handle.cli, handle.name)
|
|
45
|
+
: loopbackPorts(handle.cli, handle.name);
|
|
46
|
+
if (brokerCredentials) {
|
|
47
|
+
this.addRequestTransformations = (transformations) => this.broker.add(transformations);
|
|
48
|
+
this.setRequestTransformations = async (transformations) => {
|
|
49
|
+
await this.broker.clear();
|
|
50
|
+
await this.broker.add(transformations);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Ports the sandbox exposes, resolvable with {@link getPortEndpoint}. */
|
|
55
|
+
get ports() {
|
|
56
|
+
return this.exposed;
|
|
57
|
+
}
|
|
58
|
+
restricted = () => new SbxSandboxSession(this.handle);
|
|
59
|
+
getPortEndpoint = async ({ port, protocol = 'http', }) => {
|
|
60
|
+
if (!this.exposed.includes(port)) {
|
|
61
|
+
throw new HarnessCapabilityUnsupportedError({
|
|
62
|
+
harnessId: SBX_SANDBOX_PROVIDER_ID,
|
|
63
|
+
message: `Port ${port} is not exposed by sandbox "${this.id}". Exposed ports: [${this.exposed.join(', ')}].`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
let published = this.published.get(port);
|
|
67
|
+
if (published === undefined) {
|
|
68
|
+
published = this.publisher.publish(port);
|
|
69
|
+
this.published.set(port, published);
|
|
70
|
+
published.catch(() => this.published.delete(port));
|
|
71
|
+
}
|
|
72
|
+
return { url: endpointUrl((await published).url, protocol) };
|
|
73
|
+
};
|
|
74
|
+
/** @deprecated Use {@link getPortEndpoint}. */
|
|
75
|
+
getPortUrl = async (options) => (await this.getPortEndpoint(options)).url;
|
|
76
|
+
/** Replaces the exposed ports; a port left out is unpublished. */
|
|
77
|
+
setPorts = async (ports) => {
|
|
78
|
+
const removed = [...this.published.keys()].filter((port) => !ports.includes(port));
|
|
79
|
+
await Promise.all(removed.map((port) => this.unpublish(port)));
|
|
80
|
+
this.exposed = [...ports];
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Hands the sandbox back without stopping it: the processes this session started are stopped,
|
|
84
|
+
* its ports unpublished and its secrets withdrawn from the proxy. What an application that
|
|
85
|
+
* keeps one sandbox across harness sessions calls between them.
|
|
86
|
+
*/
|
|
87
|
+
release = async () => {
|
|
88
|
+
await this.killAll();
|
|
89
|
+
await Promise.all([...this.published.keys()].map((port) => this.unpublish(port)));
|
|
90
|
+
await this.broker.release();
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Stops every process any session started in this sandbox, including those a previous run of the
|
|
94
|
+
* application left behind, such as a harness bridge still holding its port. Call it right after
|
|
95
|
+
* resuming a sandbox no other process uses.
|
|
96
|
+
*/
|
|
97
|
+
killAllProcesses = async () => {
|
|
98
|
+
await this.handle.cli.check([...this.execArgs({}), 'sh', '-c', KILL_ALL]);
|
|
99
|
+
this.handle.processes.clear();
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Extends the time-to-live of a cloud sandbox by `duration` (`30m`, `2h`), within the 24 hours
|
|
103
|
+
* after its creation the platform allows. Local sandboxes have no time-to-live.
|
|
104
|
+
*/
|
|
105
|
+
extendTtl = async (duration) => {
|
|
106
|
+
if (!this.cloud)
|
|
107
|
+
throw new Error('Only a cloud sandbox has a time-to-live to extend.');
|
|
108
|
+
await this.handle.cli.check(['ttl', `+${duration}`, this.handle.name]);
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Stops the sandbox, keeping it for the next `sbx exec`: a local microVM keeps its filesystem, a
|
|
112
|
+
* cloud one is suspended with its memory. Idempotent.
|
|
113
|
+
*/
|
|
114
|
+
stop = async () => {
|
|
115
|
+
await this.release();
|
|
116
|
+
await this.handle.cli.run(['stop', this.handle.name]);
|
|
117
|
+
};
|
|
118
|
+
/** Removes the sandbox, with its filesystem and the secrets scoped to it. Idempotent. */
|
|
119
|
+
destroy = async () => {
|
|
120
|
+
await this.killAll();
|
|
121
|
+
this.published.clear();
|
|
122
|
+
await this.handle.cli.run(['rm', '--force', this.handle.name]);
|
|
123
|
+
// A local sandbox takes its secrets with it; a cloud secret is the account's, until removed.
|
|
124
|
+
if (this.cloud)
|
|
125
|
+
await this.broker.release();
|
|
126
|
+
};
|
|
127
|
+
async unpublish(port) {
|
|
128
|
+
const published = await this.published.get(port)?.catch(() => undefined);
|
|
129
|
+
this.published.delete(port);
|
|
130
|
+
if (published !== undefined)
|
|
131
|
+
await this.publisher.unpublish(published);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SbxNetworkSandboxSession } from './sbx-network-sandbox-session.js';
|
|
2
|
+
import type { SbxNetworkSandboxSessionCreateOptions, SbxNetworkSandboxSessionResumeOptions } from './sbx-settings.js';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/), a microVM run by the `sbx`
|
|
5
|
+
* CLI on this host, or in Docker Sandboxes Cloud with `cloud: true`, and returns a network sandbox
|
|
6
|
+
* session on it, to hand to `HarnessAgent.createSession({ sandboxSession })`.
|
|
7
|
+
*
|
|
8
|
+
* Creation never resumes: a sandbox already named `sandboxId` is a conflict. Persist the returned
|
|
9
|
+
* session's `id` and reattach with {@link resumeSbxNetworkSandboxSession}.
|
|
10
|
+
*
|
|
11
|
+
* Pass `template: await agent.getSandboxTemplate()` to install the harness once: the first call
|
|
12
|
+
* bakes a prepared sandbox into an image (`sbx template save`), in the local image store or in the
|
|
13
|
+
* cloud registry, and every later sandbox starts from it.
|
|
14
|
+
*
|
|
15
|
+
* The caller owns the sandbox: `stop()` keeps its filesystem for later, `destroy()` removes it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createSbxNetworkSandboxSession(options?: SbxNetworkSandboxSessionCreateOptions): Promise<SbxNetworkSandboxSession>;
|
|
18
|
+
/**
|
|
19
|
+
* Reattaches to a Docker Sandbox made by {@link createSbxNetworkSandboxSession}, from this
|
|
20
|
+
* process or another one, starting it again if it was stopped. Never creates one: throws
|
|
21
|
+
* `SbxSandboxNotFoundError` when no sandbox is named `sandboxId`.
|
|
22
|
+
*
|
|
23
|
+
* Ports are not stored by `sbx` until published: pass the same `ports` the sandbox was created
|
|
24
|
+
* with.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resumeSbxNetworkSandboxSession(options: SbxNetworkSandboxSessionResumeOptions): Promise<SbxNetworkSandboxSession>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { assertSandboxName, assertSettingsFit, createArgs } from './create-args.js';
|
|
3
|
+
import { assertSandboxExists, listSandboxes, openSandbox, runSetup } from './open-sandbox.js';
|
|
4
|
+
import { ensureTemplateImage } from './sandbox-template.js';
|
|
5
|
+
import { SbxCli } from './sbx-cli.js';
|
|
6
|
+
/** Fails, before anything is created, when `name` is taken. */
|
|
7
|
+
async function assertNameFree(cli, name, abortSignal) {
|
|
8
|
+
if ((await listSandboxes(cli, abortSignal)).has(name)) {
|
|
9
|
+
throw new Error(`A Docker Sandbox named "${name}" already exists. Reattach to it with ` +
|
|
10
|
+
'resumeSbxNetworkSandboxSession({ sandboxId }), or remove it with `sbx rm`.');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Creates a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/), a microVM run by the `sbx`
|
|
15
|
+
* CLI on this host, or in Docker Sandboxes Cloud with `cloud: true`, and returns a network sandbox
|
|
16
|
+
* session on it, to hand to `HarnessAgent.createSession({ sandboxSession })`.
|
|
17
|
+
*
|
|
18
|
+
* Creation never resumes: a sandbox already named `sandboxId` is a conflict. Persist the returned
|
|
19
|
+
* session's `id` and reattach with {@link resumeSbxNetworkSandboxSession}.
|
|
20
|
+
*
|
|
21
|
+
* Pass `template: await agent.getSandboxTemplate()` to install the harness once: the first call
|
|
22
|
+
* bakes a prepared sandbox into an image (`sbx template save`), in the local image store or in the
|
|
23
|
+
* cloud registry, and every later sandbox starts from it.
|
|
24
|
+
*
|
|
25
|
+
* The caller owns the sandbox: `stop()` keeps its filesystem for later, `destroy()` removes it.
|
|
26
|
+
*/
|
|
27
|
+
export async function createSbxNetworkSandboxSession(options = {}) {
|
|
28
|
+
const { abortSignal, template, setup = [] } = options;
|
|
29
|
+
abortSignal?.throwIfAborted();
|
|
30
|
+
const cloud = options.cloud ?? false;
|
|
31
|
+
const name = options.sandboxId ?? `ai-sdk-${randomBytes(4).toString('hex')}`;
|
|
32
|
+
assertSandboxName(name, cloud);
|
|
33
|
+
assertSettingsFit(options, cloud);
|
|
34
|
+
const cli = new SbxCli(options.binary, cloud);
|
|
35
|
+
await assertNameFree(cli, name, abortSignal);
|
|
36
|
+
const agent = options.agent ?? 'shell';
|
|
37
|
+
const image = template
|
|
38
|
+
? await ensureTemplateImage(cli, { template, agent, image: options.image, setup }, abortSignal)
|
|
39
|
+
: options.image;
|
|
40
|
+
await cli.check(createArgs({ name, image, fromTemplate: template !== undefined, settings: options }), {
|
|
41
|
+
abortSignal,
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
// A template already ran them, before it was saved.
|
|
45
|
+
if (!template)
|
|
46
|
+
await runSetup(cli, name, { setup, abortSignal });
|
|
47
|
+
return await openSandbox(cli, name, options);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// Half-made, the sandbox would block a retry under the same id.
|
|
51
|
+
await cli.run(['rm', '--force', name]);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Reattaches to a Docker Sandbox made by {@link createSbxNetworkSandboxSession}, from this
|
|
57
|
+
* process or another one, starting it again if it was stopped. Never creates one: throws
|
|
58
|
+
* `SbxSandboxNotFoundError` when no sandbox is named `sandboxId`.
|
|
59
|
+
*
|
|
60
|
+
* Ports are not stored by `sbx` until published: pass the same `ports` the sandbox was created
|
|
61
|
+
* with.
|
|
62
|
+
*/
|
|
63
|
+
export async function resumeSbxNetworkSandboxSession(options) {
|
|
64
|
+
options.abortSignal?.throwIfAborted();
|
|
65
|
+
const cli = new SbxCli(options.binary, options.cloud ?? false);
|
|
66
|
+
await assertSandboxExists(cli, options.sandboxId, options.abortSignal);
|
|
67
|
+
return openSandbox(cli, options.sandboxId, options);
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** No sandbox of that name exists on this host: it was never created, or `sbx rm` removed it. */
|
|
2
|
+
export class SbxSandboxNotFoundError extends Error {
|
|
3
|
+
sandboxId;
|
|
4
|
+
constructor(sandboxId) {
|
|
5
|
+
super(`No Docker Sandbox named "${sandboxId}" exists on this host. ` +
|
|
6
|
+
'Create it with createSbxNetworkSandboxSession({ sandboxId }) first.');
|
|
7
|
+
this.name = 'SbxSandboxNotFoundError';
|
|
8
|
+
this.sandboxId = sandboxId;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Experimental_SandboxProcess as SandboxProcess, Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
|
|
2
|
+
import type { SbxCli } from './sbx-cli.js';
|
|
3
|
+
type ProcessOptions = Parameters<SandboxSession['spawn']>[0];
|
|
4
|
+
type ReadOptions = Parameters<SandboxSession['readFile']>[0];
|
|
5
|
+
type ReadTextOptions = Parameters<SandboxSession['readTextFile']>[0];
|
|
6
|
+
type WriteOptions = Parameters<SandboxSession['writeFile']>[0];
|
|
7
|
+
type WriteBinaryOptions = Parameters<SandboxSession['writeBinaryFile']>[0];
|
|
8
|
+
type WriteTextOptions = Parameters<SandboxSession['writeTextFile']>[0];
|
|
9
|
+
/** What every view of one sandbox shares: its name, the CLI that reaches it, its processes. */
|
|
10
|
+
export interface SbxSandboxHandle {
|
|
11
|
+
readonly cli: SbxCli;
|
|
12
|
+
/** The sandbox's name in `sbx ls`. */
|
|
13
|
+
readonly name: string;
|
|
14
|
+
/** The sandbox's own working directory, which relative paths resolve against. */
|
|
15
|
+
readonly workingDirectory: string;
|
|
16
|
+
/** Pid files of the processes spawned and not yet exited. */
|
|
17
|
+
readonly processes: Set<string>;
|
|
18
|
+
/** Variables of the sandbox's own environment dropped from every command that does not set them. */
|
|
19
|
+
readonly clearEnv: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
/** Throws unless every key of `env` can be forwarded as a bare `sbx exec -e NAME`. */
|
|
22
|
+
export declare function assertEnvNames(names: Iterable<string>): void;
|
|
23
|
+
/**
|
|
24
|
+
* The file and process surface of a Docker Sandbox: what a harness hands to its tools. Every call
|
|
25
|
+
* is an `sbx exec` into the sandbox's microVM: nothing runs on the host, and only the variables a
|
|
26
|
+
* command names are forwarded, never this process's environment.
|
|
27
|
+
*/
|
|
28
|
+
export declare class SbxSandboxSession implements SandboxSession {
|
|
29
|
+
protected readonly handle: SbxSandboxHandle;
|
|
30
|
+
constructor(handle: SbxSandboxHandle);
|
|
31
|
+
get description(): string;
|
|
32
|
+
run: (options: ProcessOptions) => Promise<{
|
|
33
|
+
exitCode: number;
|
|
34
|
+
stderr: string;
|
|
35
|
+
stdout: string;
|
|
36
|
+
}>;
|
|
37
|
+
spawn: (options: ProcessOptions) => Promise<SandboxProcess>;
|
|
38
|
+
/** Starts `command` in the sandbox, its pid recorded so it can be stopped from outside. */
|
|
39
|
+
private start;
|
|
40
|
+
readBinaryFile: ({ path, abortSignal }: ReadOptions) => Promise<null | Uint8Array>;
|
|
41
|
+
readFile: (options: ReadOptions) => Promise<null | ReadableStream<Uint8Array>>;
|
|
42
|
+
readTextFile: ({ encoding, startLine, endLine, ...options }: ReadTextOptions) => Promise<null | string>;
|
|
43
|
+
writeFile: ({ path, content, abortSignal }: WriteOptions) => Promise<void>;
|
|
44
|
+
writeBinaryFile: ({ content, ...options }: WriteBinaryOptions) => Promise<void>;
|
|
45
|
+
writeTextFile: ({ content, encoding, ...options }: WriteTextOptions) => Promise<void>;
|
|
46
|
+
/** Stops every process this view still runs in the sandbox. */
|
|
47
|
+
protected killAll(): Promise<void>;
|
|
48
|
+
private killTree;
|
|
49
|
+
private resolve;
|
|
50
|
+
/**
|
|
51
|
+
* `sbx exec` up to the sandbox name. Variables go as bare `-e NAME`: `sbx` then reads each value
|
|
52
|
+
* from its own environment, so none of them ever shows on a command line.
|
|
53
|
+
*/
|
|
54
|
+
protected execArgs({ workingDirectory, env, stdin, }: {
|
|
55
|
+
env?: Record<string, string>;
|
|
56
|
+
stdin?: boolean;
|
|
57
|
+
workingDirectory?: string;
|
|
58
|
+
}): string[];
|
|
59
|
+
}
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { KILL_TREE, PROCESS_DIR, READ, READ_DIRECTORY, READ_MISSING, TRACKED, WRITE, } from './sandbox-scripts.js';
|
|
5
|
+
import { abortReason } from './sbx-cli.js';
|
|
6
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
/** Throws unless every key of `env` can be forwarded as a bare `sbx exec -e NAME`. */
|
|
8
|
+
export function assertEnvNames(names) {
|
|
9
|
+
for (const name of names) {
|
|
10
|
+
if (!ENV_NAME.test(name))
|
|
11
|
+
throw new Error(`Not an environment variable name: ${name}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The file and process surface of a Docker Sandbox: what a harness hands to its tools. Every call
|
|
16
|
+
* is an `sbx exec` into the sandbox's microVM: nothing runs on the host, and only the variables a
|
|
17
|
+
* command names are forwarded, never this process's environment.
|
|
18
|
+
*/
|
|
19
|
+
export class SbxSandboxSession {
|
|
20
|
+
handle;
|
|
21
|
+
constructor(handle) {
|
|
22
|
+
this.handle = handle;
|
|
23
|
+
}
|
|
24
|
+
get description() {
|
|
25
|
+
return [
|
|
26
|
+
`Docker Sandbox "${this.handle.name}": a microVM with a filesystem of its own, working directory ${this.handle.workingDirectory}.`,
|
|
27
|
+
'Outbound traffic goes through the Docker Sandboxes proxy and its network policy.',
|
|
28
|
+
].join('\n');
|
|
29
|
+
}
|
|
30
|
+
run = async (options) => {
|
|
31
|
+
const spawned = await this.spawn(options);
|
|
32
|
+
const [stdout, stderr, { exitCode }] = await Promise.all([
|
|
33
|
+
new Response(spawned.stdout).text(),
|
|
34
|
+
new Response(spawned.stderr).text(),
|
|
35
|
+
spawned.wait(),
|
|
36
|
+
]);
|
|
37
|
+
return { exitCode, stdout, stderr };
|
|
38
|
+
};
|
|
39
|
+
spawn = (options) => {
|
|
40
|
+
try {
|
|
41
|
+
return Promise.resolve(this.start(options));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/** Starts `command` in the sandbox, its pid recorded so it can be stopped from outside. */
|
|
48
|
+
start({ command, workingDirectory, env = {}, abortSignal, }) {
|
|
49
|
+
if (abortSignal?.aborted)
|
|
50
|
+
throw abortReason(abortSignal);
|
|
51
|
+
assertEnvNames(Object.keys(env));
|
|
52
|
+
const pidFile = `${PROCESS_DIR}/${randomUUID()}`;
|
|
53
|
+
const cleared = this.handle.clearEnv.filter((name) => !(name in env));
|
|
54
|
+
const script = cleared.length > 0 ? `unset ${cleared.join(' ')}; ${TRACKED}` : TRACKED;
|
|
55
|
+
const child = this.handle.cli.start([...this.execArgs({ workingDirectory, env }), 'sh', '-c', script, pidFile, command], { env });
|
|
56
|
+
this.handle.processes.add(pidFile);
|
|
57
|
+
let killed;
|
|
58
|
+
const kill = () => {
|
|
59
|
+
killed ??= this.killTree(pidFile).finally(() => child.kill());
|
|
60
|
+
return killed;
|
|
61
|
+
};
|
|
62
|
+
const onAbort = () => void kill();
|
|
63
|
+
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
64
|
+
const exited = new Promise((resolve, reject) => {
|
|
65
|
+
child.once('error', reject);
|
|
66
|
+
child.once('close', (code) => {
|
|
67
|
+
this.handle.processes.delete(pidFile);
|
|
68
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
69
|
+
if (abortSignal?.aborted) {
|
|
70
|
+
reject(abortReason(abortSignal));
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
resolve({ exitCode: code ?? 137 });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
// `wait()` may never be called; an unobserved rejection must not take the process down.
|
|
78
|
+
exited.catch(() => undefined);
|
|
79
|
+
return {
|
|
80
|
+
stdout: Readable.toWeb(child.stdout),
|
|
81
|
+
stderr: Readable.toWeb(child.stderr),
|
|
82
|
+
wait: () => exited,
|
|
83
|
+
kill,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
readBinaryFile = async ({ path, abortSignal }) => {
|
|
87
|
+
const target = this.resolve(path);
|
|
88
|
+
const result = await this.handle.cli.run([...this.execArgs({}), 'sh', '-c', READ, target], {
|
|
89
|
+
abortSignal,
|
|
90
|
+
});
|
|
91
|
+
if (result.exitCode === READ_MISSING)
|
|
92
|
+
return null;
|
|
93
|
+
if (result.exitCode === READ_DIRECTORY) {
|
|
94
|
+
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${target}'`), {
|
|
95
|
+
code: 'EISDIR',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (result.exitCode !== 0) {
|
|
99
|
+
throw new Error(`Could not read ${target} in the sandbox: ${result.stderr.trim()}`);
|
|
100
|
+
}
|
|
101
|
+
return new Uint8Array(result.stdout);
|
|
102
|
+
};
|
|
103
|
+
readFile = async (options) => {
|
|
104
|
+
const bytes = await this.readBinaryFile(options);
|
|
105
|
+
return bytes === null ? null : new Blob([bytes]).stream();
|
|
106
|
+
};
|
|
107
|
+
readTextFile = async ({ encoding = 'utf-8', startLine, endLine, ...options }) => {
|
|
108
|
+
const bytes = await this.readBinaryFile(options);
|
|
109
|
+
if (bytes === null)
|
|
110
|
+
return null;
|
|
111
|
+
const text = Buffer.from(bytes).toString(encoding);
|
|
112
|
+
if (startLine === undefined && endLine === undefined)
|
|
113
|
+
return text;
|
|
114
|
+
return text
|
|
115
|
+
.split('\n')
|
|
116
|
+
.slice((startLine ?? 1) - 1, endLine)
|
|
117
|
+
.join('\n');
|
|
118
|
+
};
|
|
119
|
+
writeFile = async ({ path, content, abortSignal }) => {
|
|
120
|
+
const target = this.resolve(path);
|
|
121
|
+
const result = await this.handle.cli.run([...this.execArgs({ stdin: true }), 'sh', '-c', WRITE, target], { stdin: content, abortSignal });
|
|
122
|
+
if (result.exitCode !== 0) {
|
|
123
|
+
throw new Error(`Could not write ${target} in the sandbox: ${result.stderr.trim()}`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
writeBinaryFile = ({ content, ...options }) => this.writeFile({
|
|
127
|
+
...options,
|
|
128
|
+
content: new Blob([content]).stream(),
|
|
129
|
+
});
|
|
130
|
+
writeTextFile = ({ content, encoding = 'utf-8', ...options }) => this.writeBinaryFile({
|
|
131
|
+
...options,
|
|
132
|
+
content: new Uint8Array(Buffer.from(content, encoding)),
|
|
133
|
+
});
|
|
134
|
+
/** Stops every process this view still runs in the sandbox. */
|
|
135
|
+
async killAll() {
|
|
136
|
+
await Promise.all([...this.handle.processes].map((pidFile) => this.killTree(pidFile)));
|
|
137
|
+
this.handle.processes.clear();
|
|
138
|
+
}
|
|
139
|
+
async killTree(pidFile) {
|
|
140
|
+
await this.handle.cli.run([...this.execArgs({}), 'sh', '-c', KILL_TREE, pidFile]);
|
|
141
|
+
}
|
|
142
|
+
resolve(path) {
|
|
143
|
+
return posix.resolve(this.handle.workingDirectory, path);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* `sbx exec` up to the sandbox name. Variables go as bare `-e NAME`: `sbx` then reads each value
|
|
147
|
+
* from its own environment, so none of them ever shows on a command line.
|
|
148
|
+
*/
|
|
149
|
+
execArgs({ workingDirectory, env = {}, stdin = false, }) {
|
|
150
|
+
return [
|
|
151
|
+
'exec',
|
|
152
|
+
...(stdin ? ['-i'] : []),
|
|
153
|
+
'-w',
|
|
154
|
+
this.resolve(workingDirectory ?? '.'),
|
|
155
|
+
...Object.keys(env).flatMap((name) => ['-e', name]),
|
|
156
|
+
this.handle.name,
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { HarnessV1SandboxSessionCreateOptions, HarnessV1SandboxSessionResumeOptions } from '@ai-sdk/harness';
|
|
2
|
+
/** How this process talks to a sandbox, the same whether the sandbox was just created or resumed. */
|
|
3
|
+
export interface SbxConnectionSettings {
|
|
4
|
+
/**
|
|
5
|
+
* Run in [Docker Sandboxes Cloud](https://docs.docker.com/ai/sandboxes/) rather than on this
|
|
6
|
+
* host: every `sbx` command goes out as `sbx --cloud …`. Needs a Docker Agentic Platform
|
|
7
|
+
* subscription and `sbx login`. Experimental: it may change in a minor release while it settles.
|
|
8
|
+
*
|
|
9
|
+
* @defaultValue `false`
|
|
10
|
+
*/
|
|
11
|
+
cloud?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Ports inside the sandbox the harness may reach. Each is published the first time it is asked
|
|
14
|
+
* for: on this host's loopback for a local sandbox, at a public URL the control plane assigns for
|
|
15
|
+
* a cloud one. Bridge-backed harnesses (Claude Code, Codex…) listen on the first.
|
|
16
|
+
*
|
|
17
|
+
* @defaultValue `[]`
|
|
18
|
+
*/
|
|
19
|
+
ports?: readonly number[];
|
|
20
|
+
/**
|
|
21
|
+
* Keep credentials out of the sandbox: the Docker Sandboxes proxy puts the real value in the
|
|
22
|
+
* requests on their way out (`sbx secret set-custom`). Turn it off
|
|
23
|
+
* only for an `sbx` without custom secrets: the harness then forwards the real credential into
|
|
24
|
+
* the sandbox environment.
|
|
25
|
+
*
|
|
26
|
+
* @defaultValue `true`
|
|
27
|
+
*/
|
|
28
|
+
brokerCredentials?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Variables of the sandbox's own environment to drop from every command that does not set them
|
|
31
|
+
* itself, on top of the `proxy-managed` ones (see {@link keepProxyManagedEnv}).
|
|
32
|
+
*
|
|
33
|
+
* @defaultValue `[]`
|
|
34
|
+
*/
|
|
35
|
+
clearEnv?: readonly string[];
|
|
36
|
+
/**
|
|
37
|
+
* Docker Sandboxes pre-sets credential variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
|
|
38
|
+
* `GH_TOKEN`…) to a `proxy-managed` placeholder for its own credential store. Left in place, one
|
|
39
|
+
* can take precedence over the credential a harness does pass (the `claude` CLI prefers
|
|
40
|
+
* `ANTHROPIC_API_KEY` to `CLAUDE_CODE_OAUTH_TOKEN`), so every `*_API_KEY` and `*_TOKEN` variable
|
|
41
|
+
* holding such a placeholder is dropped from the commands that do not set it. Set this to keep
|
|
42
|
+
* them, when you rely on `sbx secret set` instead.
|
|
43
|
+
*
|
|
44
|
+
* @defaultValue `false`
|
|
45
|
+
*/
|
|
46
|
+
keepProxyManagedEnv?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The `sbx` binary.
|
|
49
|
+
*
|
|
50
|
+
* @defaultValue `'sbx'`, looked up on the `PATH`
|
|
51
|
+
*/
|
|
52
|
+
binary?: string;
|
|
53
|
+
}
|
|
54
|
+
/** What a new sandbox is made of. Fixed at creation: `sbx` cannot change it afterwards. */
|
|
55
|
+
export interface SbxCreationSettings extends SbxConnectionSettings {
|
|
56
|
+
/**
|
|
57
|
+
* The Docker Sandboxes agent kit the sandbox is made from: its image and network rules. `shell`
|
|
58
|
+
* is a plain Linux userland with Node.js and git; the harness installs its own runtime in it.
|
|
59
|
+
*
|
|
60
|
+
* @defaultValue `'shell'`
|
|
61
|
+
*/
|
|
62
|
+
agent?: string;
|
|
63
|
+
/** Container image to use instead of the kit's own (`sbx create --template`). */
|
|
64
|
+
image?: string;
|
|
65
|
+
/**
|
|
66
|
+
* A directory of this host the sandbox works in, bind-mounted read-write: the agent edits your
|
|
67
|
+
* files. Without it the sandbox mounts nothing of the host and works on its own filesystem.
|
|
68
|
+
* Local sandboxes only.
|
|
69
|
+
*/
|
|
70
|
+
workspace?: string;
|
|
71
|
+
/**
|
|
72
|
+
* With {@link workspace} set to a Git repository: give the sandbox a private clone of it instead
|
|
73
|
+
* of the directory itself (`sbx create --clone`). The host repository is only mounted read-only,
|
|
74
|
+
* and the agent's commits come back through the `sandbox-<name>` git remote on the host. Local
|
|
75
|
+
* sandboxes only.
|
|
76
|
+
*
|
|
77
|
+
* @defaultValue `false`
|
|
78
|
+
*/
|
|
79
|
+
clone?: boolean;
|
|
80
|
+
/** More directories of this host, mounted read-only: documentation, a shared library… Local only. */
|
|
81
|
+
readOnlyWorkspaces?: readonly string[];
|
|
82
|
+
/**
|
|
83
|
+
* Hosts the sandbox may never reach, on top of the network policy (`sbx create --deny-network`).
|
|
84
|
+
* A deny can only narrow egress: it holds even if the policy allows the host.
|
|
85
|
+
*/
|
|
86
|
+
denyNetwork?: readonly string[];
|
|
87
|
+
/** Hosts a cloud sandbox may reach, on top of the account's policy. Cloud sandboxes only. */
|
|
88
|
+
allowNetwork?: readonly string[];
|
|
89
|
+
/**
|
|
90
|
+
* CPUs given to the microVM. Default: decided by `sbx`. In the cloud, `cpus` and `memory` must
|
|
91
|
+
* name a billable shape together: 1 / `2g`, 2 / `4g`, 4 / `8g`, 8 / `16g`…
|
|
92
|
+
*/
|
|
93
|
+
cpus?: number;
|
|
94
|
+
/** Memory limit of the microVM, in binary units (`4g`, `512m`). Default: decided by `sbx`. */
|
|
95
|
+
memory?: string;
|
|
96
|
+
/**
|
|
97
|
+
* How long a cloud sandbox lives (`30m`, `2h`) before it times out; at most 24 hours. Default:
|
|
98
|
+
* decided by the platform. Cloud sandboxes only.
|
|
99
|
+
*/
|
|
100
|
+
ttl?: string;
|
|
101
|
+
/**
|
|
102
|
+
* What happens to a cloud sandbox when its `ttl` lapses: `stop` it in place, `restart` it, or
|
|
103
|
+
* `delete` it. Default: decided by the platform. Cloud sandboxes only.
|
|
104
|
+
*/
|
|
105
|
+
onTimeout?: 'delete' | 'restart' | 'stop';
|
|
106
|
+
/** CPU architecture of a cloud sandbox. Default: the template's, or the platform's. Cloud only. */
|
|
107
|
+
platform?: 'linux/amd64' | 'linux/arm64';
|
|
108
|
+
/**
|
|
109
|
+
* Commands run once, right after the sandbox is created, to install a tool the harness needs,
|
|
110
|
+
* say. They run as the sandbox user, who has passwordless `sudo` in the Docker Sandboxes kits.
|
|
111
|
+
* With a `template`, they run before it is prepared and are baked into it.
|
|
112
|
+
*/
|
|
113
|
+
setup?: readonly string[];
|
|
114
|
+
}
|
|
115
|
+
/** Options of `createSbxNetworkSandboxSession()`. */
|
|
116
|
+
export type SbxNetworkSandboxSessionCreateOptions = HarnessV1SandboxSessionCreateOptions<SbxCreationSettings>;
|
|
117
|
+
/** Options of `resumeSbxNetworkSandboxSession()`. */
|
|
118
|
+
export type SbxNetworkSandboxSessionResumeOptions = HarnessV1SandboxSessionResumeOptions<SbxConnectionSettings>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|