@bunny-agent/sandbox-sandock 0.9.28
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/LICENSE +201 -0
- package/README.md +143 -0
- package/dist/__tests__/sandock-sandbox.test.d.ts +2 -0
- package/dist/__tests__/sandock-sandbox.test.d.ts.map +1 -0
- package/dist/__tests__/sandock-sandbox.test.js +531 -0
- package/dist/__tests__/sandock-sandbox.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/sandock-sandbox.d.ts +132 -0
- package/dist/sandock-sandbox.d.ts.map +1 -0
- package/dist/sandock-sandbox.js +537 -0
- package/dist/sandock-sandbox.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { SandboxAdapter, SandboxHandle } from "@bunny-agent/manager";
|
|
2
|
+
/** Single volume mount configuration (name → get/create by name; mountPath inside container) */
|
|
3
|
+
export interface SandockVolumeConfig {
|
|
4
|
+
/** Volume name for persistence (will be created if not exists) */
|
|
5
|
+
volumeName: string;
|
|
6
|
+
/** Mount path inside the sandbox */
|
|
7
|
+
volumeMountPath: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Options for creating a SandockSandbox instance
|
|
11
|
+
*/
|
|
12
|
+
export interface SandockSandboxOptions {
|
|
13
|
+
/** Sandock API base URL (defaults to https://sandock.ai) */
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
/** Sandock API key for authentication */
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
/** Docker image to use for the sandbox */
|
|
18
|
+
image?: string;
|
|
19
|
+
/** Working directory inside the sandbox */
|
|
20
|
+
workdir?: string;
|
|
21
|
+
/** Memory limit in MB */
|
|
22
|
+
memoryLimitMb?: number;
|
|
23
|
+
/** CPU shares */
|
|
24
|
+
cpuShares?: number;
|
|
25
|
+
/**
|
|
26
|
+
* If true (default), keep sandbox running after execution (platform may retain it for e.g. 30 minutes).
|
|
27
|
+
* If false, sandbox is stopped and deleted after the command finishes.
|
|
28
|
+
*/
|
|
29
|
+
keep?: boolean;
|
|
30
|
+
/** Timeout for sandbox operations in milliseconds (default: 1800000 = 30 min) */
|
|
31
|
+
timeout?: number;
|
|
32
|
+
/** Path to template directory to upload */
|
|
33
|
+
templatesPath?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Volume mounts for persistence (e.g. workspace + Claude SDK session storage).
|
|
36
|
+
* Each volume is created/fetched by name and mounted at the given path.
|
|
37
|
+
*/
|
|
38
|
+
volumes?: SandockVolumeConfig[];
|
|
39
|
+
/** Sandbox name/title for the Sandock API (e.g. for display in dashboard) */
|
|
40
|
+
name?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Existing sandbox ID to attach to. When set, attach() will first try to use this sandbox
|
|
43
|
+
* (get + start); on failure (e.g. not found, deleted), falls back to creating a new sandbox.
|
|
44
|
+
*/
|
|
45
|
+
sandboxId?: string;
|
|
46
|
+
/**
|
|
47
|
+
* If true, skip installing SDK and runner (image already has them).
|
|
48
|
+
* Only upload template files and use `bunny-agent run`. Use with pre-built images like vikadata/bunny-agent.
|
|
49
|
+
*/
|
|
50
|
+
skipBootstrap?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Environment variables to set in the sandbox.
|
|
53
|
+
* These will be available to all commands executed in the sandbox.
|
|
54
|
+
*/
|
|
55
|
+
env?: Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Maximum lifetime of the sandbox in seconds.
|
|
58
|
+
* After this duration, the sandbox will be automatically terminated.
|
|
59
|
+
*/
|
|
60
|
+
maxLifetimeSeconds?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Auto-delete interval in minutes. -1 = never auto-delete.
|
|
63
|
+
*/
|
|
64
|
+
autoDeleteInterval?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Optional command to run when creating the sandbox.
|
|
67
|
+
* If provided, this command will be passed to the Sandock API during sandbox creation.
|
|
68
|
+
* If omitted, the default creation behavior is preserved.
|
|
69
|
+
*/
|
|
70
|
+
command?: string[];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Sandock-based sandbox implementation.
|
|
74
|
+
*
|
|
75
|
+
* Uses the official Sandock SDK (https://sandock.ai) for cloud-based
|
|
76
|
+
* Docker sandbox execution with persistent filesystems.
|
|
77
|
+
*/
|
|
78
|
+
export declare class SandockSandbox implements SandboxAdapter {
|
|
79
|
+
private readonly client;
|
|
80
|
+
private readonly image;
|
|
81
|
+
private readonly workdir;
|
|
82
|
+
private readonly memoryLimitMb?;
|
|
83
|
+
private readonly cpuShares?;
|
|
84
|
+
private readonly keep;
|
|
85
|
+
private readonly timeout;
|
|
86
|
+
private readonly templatesPath?;
|
|
87
|
+
private readonly volumeConfigs;
|
|
88
|
+
private readonly skipBootstrap;
|
|
89
|
+
private readonly env;
|
|
90
|
+
private readonly name?;
|
|
91
|
+
private readonly maxLifetimeSeconds?;
|
|
92
|
+
private readonly autoDeleteInterval?;
|
|
93
|
+
private readonly command?;
|
|
94
|
+
/** Current handle for the sandbox instance; also holds optional existing sandbox id to attach to (before attach) */
|
|
95
|
+
private currentHandle;
|
|
96
|
+
private _sandboxId;
|
|
97
|
+
constructor(options?: SandockSandboxOptions);
|
|
98
|
+
/**
|
|
99
|
+
* Get the environment variables configured for this sandbox.
|
|
100
|
+
*/
|
|
101
|
+
getEnv(): Record<string, string>;
|
|
102
|
+
/**
|
|
103
|
+
* Get the working directory configured for this sandbox.
|
|
104
|
+
*/
|
|
105
|
+
getWorkdir(): string;
|
|
106
|
+
/**
|
|
107
|
+
* Get the runner command to execute in the sandbox.
|
|
108
|
+
* When skipBootstrap is true, use image's bunny-agent; otherwise use npm-installed runner.
|
|
109
|
+
*/
|
|
110
|
+
getRunnerCommand(): string[];
|
|
111
|
+
/**
|
|
112
|
+
* Get the current handle if already attached, or null if not attached yet.
|
|
113
|
+
*/
|
|
114
|
+
getHandle(): SandboxHandle | null;
|
|
115
|
+
/**
|
|
116
|
+
* Attach to or create a sandbox. When _sandboxId is set (from options.sandboxId), tries to
|
|
117
|
+
* attach to that sandbox first (get + start); on failure, falls back to creating a new sandbox.
|
|
118
|
+
*/
|
|
119
|
+
attach(): Promise<SandboxHandle>;
|
|
120
|
+
/** Try to attach to existing sandbox by _sandboxId; on failure clear id and return null. */
|
|
121
|
+
private tryAttachExisting;
|
|
122
|
+
/** Create a new sandbox, initialize it, and set as current handle. */
|
|
123
|
+
private createAndAttachNew;
|
|
124
|
+
/** Resolve volume configs to Volume[] (get/create by name, wait for ready). */
|
|
125
|
+
private resolveVolumeMounts;
|
|
126
|
+
private waitVolumeReady;
|
|
127
|
+
/** Create sandbox and start it; returns sandbox id and volume mounts. */
|
|
128
|
+
private createAndStartSandbox;
|
|
129
|
+
private initializeSandbox;
|
|
130
|
+
private collectFiles;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=sandock-sandbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandock-sandbox.d.ts","sourceRoot":"","sources":["../src/sandock-sandbox.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createSandockClient } from "sandock";
|
|
4
|
+
/**
|
|
5
|
+
* Sandock-based sandbox implementation.
|
|
6
|
+
*
|
|
7
|
+
* Uses the official Sandock SDK (https://sandock.ai) for cloud-based
|
|
8
|
+
* Docker sandbox execution with persistent filesystems.
|
|
9
|
+
*/
|
|
10
|
+
export class SandockSandbox {
|
|
11
|
+
client;
|
|
12
|
+
image;
|
|
13
|
+
workdir;
|
|
14
|
+
memoryLimitMb;
|
|
15
|
+
cpuShares;
|
|
16
|
+
keep;
|
|
17
|
+
timeout;
|
|
18
|
+
templatesPath;
|
|
19
|
+
volumeConfigs;
|
|
20
|
+
skipBootstrap;
|
|
21
|
+
env;
|
|
22
|
+
name;
|
|
23
|
+
maxLifetimeSeconds;
|
|
24
|
+
autoDeleteInterval;
|
|
25
|
+
command;
|
|
26
|
+
/** Current handle for the sandbox instance; also holds optional existing sandbox id to attach to (before attach) */
|
|
27
|
+
currentHandle = null;
|
|
28
|
+
_sandboxId = null;
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
const apiKey = options.apiKey ?? process.env.SANDOCK_API_KEY;
|
|
31
|
+
if (!apiKey) {
|
|
32
|
+
console.warn("SANDOCK_API_KEY not set. Sandock API calls will fail.\n" +
|
|
33
|
+
"Get your API key at https://sandock.ai");
|
|
34
|
+
}
|
|
35
|
+
this.client = createSandockClient({
|
|
36
|
+
baseUrl: options.baseUrl ?? "https://sandock.ai",
|
|
37
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
|
38
|
+
});
|
|
39
|
+
this.image = options.image ?? "sandockai/sandock-code:latest";
|
|
40
|
+
this.workdir = options.workdir ?? "/workspace";
|
|
41
|
+
this.memoryLimitMb = options.memoryLimitMb;
|
|
42
|
+
this.cpuShares = options.cpuShares;
|
|
43
|
+
this.keep = options.keep ?? true;
|
|
44
|
+
this.timeout = options.timeout ?? 1_800_000;
|
|
45
|
+
this.templatesPath = options.templatesPath;
|
|
46
|
+
this.volumeConfigs = options.volumes ?? [];
|
|
47
|
+
this.skipBootstrap = options.skipBootstrap ?? false;
|
|
48
|
+
this.env = options.env ?? {};
|
|
49
|
+
this.name = options.name;
|
|
50
|
+
this._sandboxId = options.sandboxId ?? null;
|
|
51
|
+
this.maxLifetimeSeconds = options.maxLifetimeSeconds;
|
|
52
|
+
this.autoDeleteInterval = options.autoDeleteInterval;
|
|
53
|
+
this.command = options.command;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get the environment variables configured for this sandbox.
|
|
57
|
+
*/
|
|
58
|
+
getEnv() {
|
|
59
|
+
return this.env;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Get the working directory configured for this sandbox.
|
|
63
|
+
*/
|
|
64
|
+
getWorkdir() {
|
|
65
|
+
return this.workdir;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get the runner command to execute in the sandbox.
|
|
69
|
+
* When skipBootstrap is true, use image's bunny-agent; otherwise use npm-installed runner.
|
|
70
|
+
*/
|
|
71
|
+
getRunnerCommand() {
|
|
72
|
+
if (this.skipBootstrap) {
|
|
73
|
+
return ["bunny-agent", "run"];
|
|
74
|
+
}
|
|
75
|
+
return [`${this.workdir}/node_modules/.bin/bunny-agent`, "run"];
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Get the current handle if already attached, or null if not attached yet.
|
|
79
|
+
*/
|
|
80
|
+
getHandle() {
|
|
81
|
+
return this.currentHandle;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Attach to or create a sandbox. When _sandboxId is set (from options.sandboxId), tries to
|
|
85
|
+
* attach to that sandbox first (get + start); on failure, falls back to creating a new sandbox.
|
|
86
|
+
*/
|
|
87
|
+
async attach() {
|
|
88
|
+
if (this.currentHandle)
|
|
89
|
+
return this.currentHandle;
|
|
90
|
+
const existing = await this.tryAttachExisting();
|
|
91
|
+
if (existing) {
|
|
92
|
+
return existing;
|
|
93
|
+
}
|
|
94
|
+
return await this.createAndAttachNew();
|
|
95
|
+
}
|
|
96
|
+
/** Try to attach to existing sandbox by _sandboxId; on failure clear id and return null. */
|
|
97
|
+
async tryAttachExisting() {
|
|
98
|
+
const id = this._sandboxId;
|
|
99
|
+
if (!id)
|
|
100
|
+
return null;
|
|
101
|
+
try {
|
|
102
|
+
const { data } = await this.client.sandbox.get(id);
|
|
103
|
+
const status = data.status;
|
|
104
|
+
if (status === "STOPPED" || status === "PAUSED") {
|
|
105
|
+
console.log(`[Sandock] Restarting existing sandbox ${id} (status: ${status})`);
|
|
106
|
+
const startResult = await this.client.sandbox.start(id);
|
|
107
|
+
if (!startResult.data.started) {
|
|
108
|
+
console.warn(`[Sandock] start() did not report started for ${id}, creating new`);
|
|
109
|
+
this._sandboxId = null;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (status !== "RUNNING") {
|
|
114
|
+
console.warn(`[Sandock] Sandbox ${id} is not reusable (status: ${status}), creating new`);
|
|
115
|
+
this._sandboxId = null;
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const volumeMounts = await this.resolveVolumeMounts();
|
|
119
|
+
const handle = new SandockHandle(this.client, id, this.workdir, this.timeout, () => { }, this.keep, this.env, volumeMounts);
|
|
120
|
+
this.currentHandle = handle;
|
|
121
|
+
console.log(`[Sandock] Attached to existing sandbox: ${id}`);
|
|
122
|
+
return handle;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
console.warn(`[Sandock] Failed to attach to sandbox ${id}, creating new:`, err instanceof Error ? err.message : err);
|
|
126
|
+
this._sandboxId = null;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Create a new sandbox, initialize it, and set as current handle. */
|
|
131
|
+
async createAndAttachNew() {
|
|
132
|
+
const volumeMounts = await this.resolveVolumeMounts();
|
|
133
|
+
const { sandboxId } = await this.createAndStartSandbox(volumeMounts);
|
|
134
|
+
const handle = new SandockHandle(this.client, sandboxId, this.workdir, this.timeout, () => { }, this.keep, this.env, volumeMounts);
|
|
135
|
+
this._sandboxId = sandboxId;
|
|
136
|
+
await this.initializeSandbox(handle);
|
|
137
|
+
this.currentHandle = handle;
|
|
138
|
+
return handle;
|
|
139
|
+
}
|
|
140
|
+
/** Resolve volume configs to Volume[] (get/create by name, wait for ready). */
|
|
141
|
+
async resolveVolumeMounts() {
|
|
142
|
+
const volumeMounts = [];
|
|
143
|
+
for (const v of this.volumeConfigs) {
|
|
144
|
+
console.log(`[Sandock] Getting/creating volume: ${v.volumeName}`);
|
|
145
|
+
const volume = await this.client.volume.getByName(v.volumeName, true);
|
|
146
|
+
const mountPath = v.volumeMountPath;
|
|
147
|
+
if (volume.data.status && volume.data.status !== "ready") {
|
|
148
|
+
const ready = await this.waitVolumeReady(v.volumeName, 30000);
|
|
149
|
+
if (!ready) {
|
|
150
|
+
throw new Error(`Volume '${v.volumeName}' failed to become ready. Status: ${volume.data.status}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
volumeMounts.push({
|
|
154
|
+
volumeId: volume.data.id,
|
|
155
|
+
...(volume.data.spaceId ? { spaceId: volume.data.spaceId } : {}),
|
|
156
|
+
mountPath,
|
|
157
|
+
name: v.volumeName,
|
|
158
|
+
});
|
|
159
|
+
console.log(`[Sandock] Using volume ${volume.data.id} (${v.volumeName}) at ${mountPath}`);
|
|
160
|
+
}
|
|
161
|
+
return volumeMounts;
|
|
162
|
+
}
|
|
163
|
+
async waitVolumeReady(volumeName, maxWaitMs) {
|
|
164
|
+
const startTime = Date.now();
|
|
165
|
+
let current = await this.client.volume.getByName(volumeName, false);
|
|
166
|
+
while (current.data.status !== "ready" &&
|
|
167
|
+
Date.now() - startTime < maxWaitMs) {
|
|
168
|
+
console.log(`[Sandock] Volume ${volumeName} status: ${current.data.status}, waiting...`);
|
|
169
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
170
|
+
current = await this.client.volume.getByName(volumeName, false);
|
|
171
|
+
}
|
|
172
|
+
return current.data.status === "ready";
|
|
173
|
+
}
|
|
174
|
+
/** Create sandbox and start it; returns sandbox id and volume mounts. */
|
|
175
|
+
async createAndStartSandbox(volumeMounts) {
|
|
176
|
+
const createOptions = {
|
|
177
|
+
image: this.image,
|
|
178
|
+
memory: this.memoryLimitMb,
|
|
179
|
+
cpu: this.cpuShares,
|
|
180
|
+
title: this.name,
|
|
181
|
+
activeDeadlineSeconds: this.maxLifetimeSeconds,
|
|
182
|
+
autoDeleteInterval: this.autoDeleteInterval,
|
|
183
|
+
command: this.command,
|
|
184
|
+
};
|
|
185
|
+
if (volumeMounts.length > 0) {
|
|
186
|
+
createOptions.volumes = volumeMounts.map((v) => ({
|
|
187
|
+
volumeId: v.volumeId,
|
|
188
|
+
mountPath: v.mountPath,
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
const createResult = await this.client.sandbox.create(createOptions);
|
|
192
|
+
const sandboxId = createResult.data.id;
|
|
193
|
+
if (!sandboxId) {
|
|
194
|
+
throw new Error("No sandbox ID returned from Sandock API");
|
|
195
|
+
}
|
|
196
|
+
console.log(`[Sandock] Created new sandbox: ${sandboxId} ${this.name ? `, title: ${this.name}` : ""}`);
|
|
197
|
+
await this.client.sandbox.start(sandboxId);
|
|
198
|
+
return { sandboxId, volumeMounts };
|
|
199
|
+
}
|
|
200
|
+
async initializeSandbox(handle) {
|
|
201
|
+
// Step 0: Create workspace directory
|
|
202
|
+
console.log(`[Sandock] Creating workspace directory: ${this.workdir}`);
|
|
203
|
+
const mkdirResult = await handle.runCommand(`mkdir -p ${this.workdir}`);
|
|
204
|
+
if (mkdirResult.exitCode !== 0) {
|
|
205
|
+
console.warn(`[Sandock] mkdir warning: ${mkdirResult.stderr}`);
|
|
206
|
+
}
|
|
207
|
+
if (this.skipBootstrap) {
|
|
208
|
+
console.log(`[Sandock] skipBootstrap=true, skipping SDK and runner install`);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
// Install runner-cli from npm (brings in @anthropic-ai/claude-agent-sdk as dependency)
|
|
212
|
+
console.log(`[Sandock] Installing @bunny-agent/runner-cli@latest to ${this.workdir}`);
|
|
213
|
+
const installResult = await handle.runCommand(`cd ${this.workdir} && npm install --no-audit --no-fund --prefer-offline @bunny-agent/runner-cli@latest 2>&1`);
|
|
214
|
+
if (installResult.exitCode !== 0) {
|
|
215
|
+
console.error(`[Sandock] Failed to install runner-cli: ${installResult.stdout}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Step 3: Upload template (user-selected template; always apply)
|
|
219
|
+
if (this.templatesPath && fs.existsSync(this.templatesPath)) {
|
|
220
|
+
const templateFiles = this.collectFiles(this.templatesPath, "");
|
|
221
|
+
console.log(`[Sandock] Uploading ${templateFiles.length} template files to ${this.workdir}`);
|
|
222
|
+
await handle.upload(templateFiles, this.workdir);
|
|
223
|
+
}
|
|
224
|
+
else if (this.templatesPath) {
|
|
225
|
+
console.warn(`[Sandock] Template path not found: ${this.templatesPath}, skipping`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
collectFiles(dir, prefix) {
|
|
229
|
+
const files = [];
|
|
230
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
const fullPath = path.join(dir, entry.name);
|
|
233
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
234
|
+
if (entry.isDirectory()) {
|
|
235
|
+
// Skip node_modules and .git only
|
|
236
|
+
if (entry.name === "node_modules" || entry.name === ".git") {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
files.push(...this.collectFiles(fullPath, relativePath));
|
|
240
|
+
}
|
|
241
|
+
else if (entry.isFile()) {
|
|
242
|
+
files.push({
|
|
243
|
+
path: relativePath,
|
|
244
|
+
content: fs.readFileSync(fullPath),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return files;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Handle for an active Sandock sandbox
|
|
253
|
+
*/
|
|
254
|
+
class SandockHandle {
|
|
255
|
+
client;
|
|
256
|
+
sandboxId;
|
|
257
|
+
defaultWorkdir;
|
|
258
|
+
timeout;
|
|
259
|
+
onDestroy;
|
|
260
|
+
keep;
|
|
261
|
+
sandboxEnv;
|
|
262
|
+
volumes;
|
|
263
|
+
constructor(client, sandboxId, defaultWorkdir, timeout, onDestroy, keep, sandboxEnv = {}, volumes = null) {
|
|
264
|
+
this.client = client;
|
|
265
|
+
this.sandboxId = sandboxId;
|
|
266
|
+
this.defaultWorkdir = defaultWorkdir;
|
|
267
|
+
this.timeout = timeout;
|
|
268
|
+
this.onDestroy = onDestroy;
|
|
269
|
+
this.keep = keep;
|
|
270
|
+
this.sandboxEnv = sandboxEnv;
|
|
271
|
+
this.volumes = volumes;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Get the sandbox instance ID.
|
|
275
|
+
*/
|
|
276
|
+
getSandboxId() {
|
|
277
|
+
return this.sandboxId;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Get the volume mounts for this sandbox.
|
|
281
|
+
*/
|
|
282
|
+
getVolumes() {
|
|
283
|
+
return this.volumes;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Get the working directory for this sandbox handle
|
|
287
|
+
*/
|
|
288
|
+
getWorkdir() {
|
|
289
|
+
return this.defaultWorkdir;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Run a command and wait for completion (used internally)
|
|
293
|
+
*/
|
|
294
|
+
async runCommand(cmd) {
|
|
295
|
+
let stdout = "";
|
|
296
|
+
let stderr = "";
|
|
297
|
+
const result = await this.client.sandbox.shell(this.sandboxId, { cmd, timeoutMs: this.timeout }, {
|
|
298
|
+
onStdout: (chunk) => {
|
|
299
|
+
stdout += chunk;
|
|
300
|
+
},
|
|
301
|
+
onStderr: (chunk) => {
|
|
302
|
+
stderr += chunk;
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
return {
|
|
306
|
+
exitCode: result.data.exitCode ?? 0,
|
|
307
|
+
stdout,
|
|
308
|
+
stderr,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Execute a command in the sandbox and stream the output
|
|
313
|
+
*/
|
|
314
|
+
exec(command, opts) {
|
|
315
|
+
const self = this;
|
|
316
|
+
const signal = opts?.signal;
|
|
317
|
+
// Merge sandbox-level env with call-level env (call-level takes precedence)
|
|
318
|
+
const envWithNodePath = {
|
|
319
|
+
...this.sandboxEnv,
|
|
320
|
+
...opts?.env,
|
|
321
|
+
IS_SANDBOX: "1",
|
|
322
|
+
};
|
|
323
|
+
// Debug: log environment variables being passed to sandbox
|
|
324
|
+
console.log("[Sandock] Executing command:", command.join(" "));
|
|
325
|
+
return {
|
|
326
|
+
async *[Symbol.asyncIterator]() {
|
|
327
|
+
// Build command string with proper shell escaping (single-quote wrapping)
|
|
328
|
+
// Each argument is wrapped in single quotes with internal quotes escaped
|
|
329
|
+
const baseCmd = command.length === 1
|
|
330
|
+
? command[0]
|
|
331
|
+
: command
|
|
332
|
+
.map((arg) => "'" + arg.replace(/'/g, "'\\''") + "'")
|
|
333
|
+
.join(" ");
|
|
334
|
+
// Build full command with cwd and env support
|
|
335
|
+
const parts = [];
|
|
336
|
+
// Add working directory change (escape single quotes in path)
|
|
337
|
+
const workdir = opts?.cwd ?? self.defaultWorkdir;
|
|
338
|
+
if (workdir) {
|
|
339
|
+
const escapedWorkdir = workdir.replace(/'/g, "'\\''");
|
|
340
|
+
parts.push(`cd '${escapedWorkdir}'`);
|
|
341
|
+
}
|
|
342
|
+
// Add environment variables (validate keys and escape values)
|
|
343
|
+
const validKeyPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
344
|
+
const envParts = Object.entries(envWithNodePath)
|
|
345
|
+
.filter(([key]) => validKeyPattern.test(key))
|
|
346
|
+
.map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
|
|
347
|
+
.join(" && ");
|
|
348
|
+
if (envParts) {
|
|
349
|
+
parts.push(envParts);
|
|
350
|
+
}
|
|
351
|
+
// Wrap the command to capture its PID and handle signals
|
|
352
|
+
// We write the PID to a file so we can kill it if needed
|
|
353
|
+
const pidFile = `/tmp/bunny-agent-${Date.now()}-${Math.random().toString(36).substring(7)}.pid`;
|
|
354
|
+
const wrappedCmd = `(${baseCmd}) & echo $! > ${pidFile}; wait $!; rm -f ${pidFile}`;
|
|
355
|
+
parts.push(wrappedCmd);
|
|
356
|
+
const cmd = parts.join(" && ");
|
|
357
|
+
// Queue for streaming chunks
|
|
358
|
+
const queue = [];
|
|
359
|
+
let done = false;
|
|
360
|
+
let error = null;
|
|
361
|
+
let resolveWait = null;
|
|
362
|
+
// Monitor abort signal and kill the process
|
|
363
|
+
const abortHandler = async () => {
|
|
364
|
+
console.log("[Sandock] Abort signal received, terminating process...");
|
|
365
|
+
console.log("[Sandock] PID file:", pidFile);
|
|
366
|
+
// Try to kill the process using the PID file
|
|
367
|
+
try {
|
|
368
|
+
// First check if PID file exists and read it
|
|
369
|
+
const checkCmd = `if [ -f ${pidFile} ]; then cat ${pidFile}; else echo "PID file not found"; fi`;
|
|
370
|
+
const checkResult = await self.client.sandbox.shell(self.sandboxId, { cmd: checkCmd, timeoutMs: 2000 }, {});
|
|
371
|
+
console.log("[Sandock] PID check result:", checkResult.data.stdout);
|
|
372
|
+
// Now try to kill the process
|
|
373
|
+
const killCmd = `if [ -f ${pidFile} ]; then PID=$(cat ${pidFile}); echo "Killing PID: $PID"; kill -TERM $PID 2>&1 || echo "Kill failed"; rm -f ${pidFile}; else echo "No PID file to kill"; fi`;
|
|
374
|
+
const killResult = await self.client.sandbox.shell(self.sandboxId, { cmd: killCmd, timeoutMs: 5000 }, {});
|
|
375
|
+
console.log("[Sandock] Kill command result:", killResult.data.stdout);
|
|
376
|
+
console.log("[Sandock] Kill command stderr:", killResult.data.stderr);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
console.error("[Sandock] Failed to send termination signal:", err);
|
|
380
|
+
}
|
|
381
|
+
done = true;
|
|
382
|
+
error = new Error("Operation aborted");
|
|
383
|
+
error.name = "AbortError";
|
|
384
|
+
resolveWait?.();
|
|
385
|
+
};
|
|
386
|
+
if (signal) {
|
|
387
|
+
console.log("[Sandock] Adding abort signal listener");
|
|
388
|
+
signal.addEventListener("abort", abortHandler);
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
console.log("[Sandock] No signal provided");
|
|
392
|
+
}
|
|
393
|
+
// Track if we've received any output (indicates proper stream completion)
|
|
394
|
+
let hasReceivedOutput = false;
|
|
395
|
+
// Start shell command with streaming callbacks
|
|
396
|
+
const shellPromise = self.client.sandbox.shell(self.sandboxId, { cmd, timeoutMs: self.timeout }, {
|
|
397
|
+
onStdout: (chunk) => {
|
|
398
|
+
// Stop producing stdout chunks if signal is aborted
|
|
399
|
+
if (signal?.aborted)
|
|
400
|
+
return;
|
|
401
|
+
hasReceivedOutput = true;
|
|
402
|
+
queue.push(new TextEncoder().encode(chunk));
|
|
403
|
+
resolveWait?.();
|
|
404
|
+
},
|
|
405
|
+
onStderr: (chunk) => {
|
|
406
|
+
hasReceivedOutput = true;
|
|
407
|
+
queue.push(new TextEncoder().encode(chunk));
|
|
408
|
+
resolveWait?.();
|
|
409
|
+
},
|
|
410
|
+
onError: (err) => {
|
|
411
|
+
console.log("[Sandock] SHELL ERROR:", err);
|
|
412
|
+
// Only set error if:
|
|
413
|
+
// 1. We haven't received any output (process failed before communicating)
|
|
414
|
+
// 2. The stream isn't already done
|
|
415
|
+
// If we received output, the process communicated its error properly
|
|
416
|
+
// and we shouldn't override it with a generic "process exited" error
|
|
417
|
+
if (!hasReceivedOutput && !done) {
|
|
418
|
+
error = err instanceof Error ? err : new Error(String(err));
|
|
419
|
+
}
|
|
420
|
+
resolveWait?.();
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
// Handle completion
|
|
424
|
+
shellPromise
|
|
425
|
+
.then((result) => {
|
|
426
|
+
// Check for errors in the result
|
|
427
|
+
if (result.data.timedOut) {
|
|
428
|
+
error = new Error(`Command timed out after ${result.data.durationMs}ms`);
|
|
429
|
+
}
|
|
430
|
+
else if (result.data.exitCode !== 0 &&
|
|
431
|
+
result.data.exitCode !== null) {
|
|
432
|
+
console.warn(`Command exited with code ${result.data.exitCode}`);
|
|
433
|
+
}
|
|
434
|
+
done = true;
|
|
435
|
+
resolveWait?.();
|
|
436
|
+
})
|
|
437
|
+
.catch((err) => {
|
|
438
|
+
// Only set error if we haven't received any output
|
|
439
|
+
// If we received output, the process communicated its error properly
|
|
440
|
+
if (!hasReceivedOutput) {
|
|
441
|
+
error = err instanceof Error ? err : new Error(String(err));
|
|
442
|
+
}
|
|
443
|
+
// Log AbortError appropriately
|
|
444
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
445
|
+
console.log("[Sandock] Command execution aborted by user");
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
console.error("[Sandock] Shell promise rejected:", err);
|
|
449
|
+
}
|
|
450
|
+
done = true;
|
|
451
|
+
resolveWait?.();
|
|
452
|
+
})
|
|
453
|
+
.finally(() => {
|
|
454
|
+
if (signal) {
|
|
455
|
+
signal.removeEventListener("abort", abortHandler);
|
|
456
|
+
}
|
|
457
|
+
// When keep is false, stop and delete sandbox after execution (default keep=true ~30 min retention)
|
|
458
|
+
if (!self.keep) {
|
|
459
|
+
self.client.sandbox
|
|
460
|
+
.stop(self.sandboxId)
|
|
461
|
+
.then(() => self.client.sandbox.delete(self.sandboxId))
|
|
462
|
+
.catch((e) => console.error("[Sandock] Failed to stop/delete sandbox after execution:", e));
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
// Yield chunks as they arrive
|
|
466
|
+
while (true) {
|
|
467
|
+
// Check if signal is aborted
|
|
468
|
+
if (signal?.aborted) {
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
// Yield all queued chunks
|
|
472
|
+
while (queue.length > 0) {
|
|
473
|
+
const chunk = queue.shift();
|
|
474
|
+
if (chunk) {
|
|
475
|
+
yield chunk;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Check for errors
|
|
479
|
+
if (error) {
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
// Check if done
|
|
483
|
+
if (done) {
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
// Wait for more data
|
|
487
|
+
await new Promise((resolve) => {
|
|
488
|
+
resolveWait = resolve;
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Upload files to the sandbox
|
|
496
|
+
*/
|
|
497
|
+
async upload(files, targetDir) {
|
|
498
|
+
if (files.length === 0)
|
|
499
|
+
return;
|
|
500
|
+
// Ensure target directory exists (fs.write may not create parent dirs on the volume)
|
|
501
|
+
const escapedDir = targetDir.replace(/'/g, "'\\''");
|
|
502
|
+
const mkdirResult = await this.runCommand(`mkdir -p '${escapedDir}'`);
|
|
503
|
+
if (mkdirResult.exitCode !== 0) {
|
|
504
|
+
console.warn(`[Sandock] mkdir -p ${targetDir} failed: ${mkdirResult.stderr}`);
|
|
505
|
+
}
|
|
506
|
+
for (const file of files) {
|
|
507
|
+
const fullPath = `${targetDir}/${file.path}`;
|
|
508
|
+
// Convert content to string
|
|
509
|
+
const content = typeof file.content === "string"
|
|
510
|
+
? file.content
|
|
511
|
+
: new TextDecoder().decode(file.content);
|
|
512
|
+
// Use high-level fs.write API
|
|
513
|
+
await this.client.fs.write(this.sandboxId, fullPath, content);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
async readFile(filePath) {
|
|
517
|
+
const result = await this.client.fs.read(this.sandboxId, filePath);
|
|
518
|
+
// Sandock fs.read returns { success: true, data: { path: string, content: string } }
|
|
519
|
+
if (result.success && result.data) {
|
|
520
|
+
return typeof result.data === "string"
|
|
521
|
+
? result.data
|
|
522
|
+
: result.data.content || "";
|
|
523
|
+
}
|
|
524
|
+
throw new Error(`Failed to read file ${filePath}`);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Destroy the sandbox and release resources
|
|
528
|
+
*/
|
|
529
|
+
async destroy() {
|
|
530
|
+
// Stop the sandbox using high-level API
|
|
531
|
+
await this.client.sandbox.stop(this.sandboxId);
|
|
532
|
+
// Delete sandbox using high-level API
|
|
533
|
+
await this.client.sandbox.delete(this.sandboxId);
|
|
534
|
+
this.onDestroy();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
//# sourceMappingURL=sandock-sandbox.js.map
|