@capybara-hq/plugin-sandbox-docker 0.0.0-stage → 0.1.0-rc.1

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.mjs ADDED
@@ -0,0 +1,957 @@
1
+ import { createRequire as __capybaraCreateRequire } from "node:module";
2
+ import { fileURLToPath as __capybaraFileURLToPath } from "node:url";
3
+ import { dirname as __capybaraPathDirname } from "node:path";
4
+ const require = __capybaraCreateRequire(import.meta.url);
5
+ const __capybara_filename = __capybaraFileURLToPath(import.meta.url);
6
+ const __capybara_dirname = __capybaraPathDirname(__capybara_filename);
7
+
8
+ // src/index.ts
9
+ import { defineExtension, SANDBOX_EXTENSION_POINT, ui } from "@capybara-hq/plugin-sdk";
10
+ import { z } from "zod";
11
+
12
+ // src/provider.ts
13
+ import { randomUUID } from "node:crypto";
14
+ import {
15
+ SANDBOX_TAG_RE,
16
+ SandboxGoneError,
17
+ validateAllowHost,
18
+ validateSandboxPath
19
+ } from "@capybara-hq/plugin-sdk";
20
+
21
+ // src/docker-api.ts
22
+ import { request } from "node:http";
23
+ var DOCKER_API_VERSION = "v1.44";
24
+ var MAX_BODY_BYTES = 4 * 1024 * 1024;
25
+ var DockerApiError = class extends Error {
26
+ constructor(status, message) {
27
+ super(message);
28
+ this.status = status;
29
+ this.name = "DockerApiError";
30
+ }
31
+ status;
32
+ };
33
+ var isStatus = (e, ...status) => e instanceof DockerApiError && status.includes(e.status);
34
+ function parseEndpoint(endpoint) {
35
+ if (endpoint.startsWith("unix://")) {
36
+ const socketPath = endpoint.slice("unix://".length);
37
+ if (!socketPath.startsWith("/")) throw new Error(`invalid unix endpoint: ${endpoint}`);
38
+ return { socketPath };
39
+ }
40
+ const m = endpoint.match(/^tcp:\/\/(\[[^\]]+\]|[^:/]+):(\d{1,5})\/?$/);
41
+ if (!m)
42
+ throw new Error(`invalid endpoint (expected tcp://host:port or unix:///path): ${endpoint}`);
43
+ return { host: m[1].replace(/^\[|\]$/g, ""), port: Number(m[2]) };
44
+ }
45
+ function readBody(res, signal) {
46
+ return new Promise((resolve, reject) => {
47
+ const chunks = [];
48
+ let size = 0;
49
+ const onAbort = () => {
50
+ res.destroy();
51
+ reject(abortReason(signal));
52
+ };
53
+ signal.addEventListener("abort", onAbort, { once: true });
54
+ res.on("data", (c) => {
55
+ size += c.byteLength;
56
+ if (size <= MAX_BODY_BYTES) chunks.push(c);
57
+ });
58
+ res.on("end", () => {
59
+ signal.removeEventListener("abort", onAbort);
60
+ resolve(Buffer.concat(chunks));
61
+ });
62
+ res.on("error", (e) => {
63
+ signal.removeEventListener("abort", onAbort);
64
+ reject(e);
65
+ });
66
+ });
67
+ }
68
+ function abortReason(signal) {
69
+ return signal.reason instanceof Error ? signal.reason : new Error("aborted");
70
+ }
71
+ function errorMessage(status, body) {
72
+ try {
73
+ const j = JSON.parse(body.toString("utf8"));
74
+ if (typeof j.message === "string") return j.message;
75
+ } catch {
76
+ }
77
+ return body.toString("utf8").trim() || `HTTP ${status}`;
78
+ }
79
+ function createDockerClient(endpoint) {
80
+ const target = parseEndpoint(endpoint);
81
+ const pathOf = (path, query) => {
82
+ const qs = new URLSearchParams();
83
+ for (const [k, v] of Object.entries(query ?? {})) if (v !== void 0) qs.set(k, String(v));
84
+ const s = qs.toString();
85
+ return `/${DOCKER_API_VERSION}${path}${s === "" ? "" : `?${s}`}`;
86
+ };
87
+ return {
88
+ json(method, path, opts) {
89
+ opts.signal.throwIfAborted();
90
+ const payload = opts.body === void 0 ? void 0 : Buffer.from(JSON.stringify(opts.body));
91
+ return new Promise((resolve, reject) => {
92
+ const req = request({
93
+ ...target,
94
+ method,
95
+ path: pathOf(path, opts.query),
96
+ headers: {
97
+ ...payload && {
98
+ "content-type": "application/json",
99
+ "content-length": payload.byteLength
100
+ }
101
+ },
102
+ signal: opts.signal
103
+ });
104
+ req.on("error", (e) => reject(opts.signal.aborted ? abortReason(opts.signal) : e));
105
+ req.on("response", (res) => {
106
+ readBody(res, opts.signal).then((body) => {
107
+ const status = res.statusCode ?? 0;
108
+ if (status < 200 || status >= 300) {
109
+ reject(new DockerApiError(status, errorMessage(status, body)));
110
+ return;
111
+ }
112
+ const text = body.toString("utf8").trim();
113
+ if (text === "") {
114
+ resolve(void 0);
115
+ return;
116
+ }
117
+ const lines = text.split("\n").filter((l) => l.trim() !== "");
118
+ try {
119
+ let last;
120
+ for (const l of lines) {
121
+ last = JSON.parse(l);
122
+ const err = last.error;
123
+ if (typeof err === "string") {
124
+ reject(new DockerApiError(500, err));
125
+ return;
126
+ }
127
+ }
128
+ resolve(last);
129
+ } catch {
130
+ resolve(text);
131
+ }
132
+ }, reject);
133
+ });
134
+ req.end(payload);
135
+ });
136
+ },
137
+ hijack(path, body, signal) {
138
+ signal.throwIfAborted();
139
+ const payload = Buffer.from(JSON.stringify(body));
140
+ return new Promise((resolve, reject) => {
141
+ const req = request({
142
+ ...target,
143
+ method: "POST",
144
+ path: pathOf(path),
145
+ headers: {
146
+ "content-type": "application/json",
147
+ "content-length": payload.byteLength,
148
+ connection: "Upgrade",
149
+ upgrade: "tcp"
150
+ }
151
+ });
152
+ const onAbort = () => req.destroy(abortReason(signal));
153
+ signal.addEventListener("abort", onAbort, { once: true });
154
+ const done = () => signal.removeEventListener("abort", onAbort);
155
+ req.on("error", (e) => {
156
+ done();
157
+ reject(signal.aborted ? abortReason(signal) : e);
158
+ });
159
+ req.on("upgrade", (_res, socket, head) => {
160
+ done();
161
+ if (head.byteLength > 0) socket.unshift(head);
162
+ resolve(socket);
163
+ });
164
+ req.on("response", (res) => {
165
+ done();
166
+ readBody(res, AbortSignal.timeout(1e4)).then((b) => {
167
+ const status = res.statusCode ?? 0;
168
+ reject(new DockerApiError(status, errorMessage(status, b)));
169
+ }, reject);
170
+ });
171
+ req.end(payload);
172
+ });
173
+ }
174
+ };
175
+ }
176
+ var StreamDemuxer = class {
177
+ constructor(onFrame) {
178
+ this.onFrame = onFrame;
179
+ }
180
+ onFrame;
181
+ buf = Buffer.alloc(0);
182
+ push(chunk) {
183
+ this.buf = this.buf.byteLength === 0 ? chunk : Buffer.concat([this.buf, chunk]);
184
+ while (this.buf.byteLength >= 8) {
185
+ const size = this.buf.readUInt32BE(4);
186
+ if (this.buf.byteLength < 8 + size) return;
187
+ const kind = this.buf[0];
188
+ const data = this.buf.subarray(8, 8 + size);
189
+ this.buf = this.buf.subarray(8 + size);
190
+ if (kind === 1 || kind === 2) this.onFrame(kind, data);
191
+ }
192
+ }
193
+ };
194
+
195
+ // src/provider.ts
196
+ var SANDBOX_USER = "1000:1000";
197
+ var ROOT_USER = "0:0";
198
+ var INIT_USER = ROOT_USER;
199
+ var PROXY_PORT = 3128;
200
+ var PIDS_LIMIT = 1024;
201
+ var MIB = 1024 * 1024;
202
+ var STOP_SECONDS = 5;
203
+ var KILL_GRACE_MS = 3e3;
204
+ var EXIT_WAIT_MS = 3e4;
205
+ var STDERR_ROOM = 64 * 1024;
206
+ var LABEL = "dev.capybara.sandbox";
207
+ var LABEL_NAME = "dev.capybara.sandbox.name";
208
+ var LABEL_ROLE = "dev.capybara.sandbox.role";
209
+ var LABEL_EGRESS = "dev.capybara.sandbox.egress";
210
+ var LABEL_ALLOW = "dev.capybara.sandbox.allow-hosts";
211
+ var NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/;
212
+ var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
213
+ function sandboxResourceNames(name) {
214
+ return {
215
+ container: name,
216
+ egress: `${name}-egress`,
217
+ network: `${name}-net`,
218
+ workspace: `${name}-workspace`,
219
+ skills: `${name}-skills`
220
+ };
221
+ }
222
+ var IN_SANDBOX_GRACE_SECONDS = 5;
223
+ var EXEC_WRAPPER = 'c=$1; echo $$ > "$c.pid" 2>/dev/null; if [ -e "$c.kill" ]; then exit 137; fi; timeout -s KILL "$3" /bin/bash -c "$2"; s=$?; rm -f "$c.pid"; exit $s';
224
+ function execArgv(ctl, command, timeoutMs) {
225
+ const seconds = Math.ceil(timeoutMs / 1e3) + IN_SANDBOX_GRACE_SECONDS;
226
+ return [
227
+ "setsid",
228
+ "-w",
229
+ "/bin/sh",
230
+ "-c",
231
+ EXEC_WRAPPER,
232
+ "capybara-exec",
233
+ ctl,
234
+ command,
235
+ String(seconds)
236
+ ];
237
+ }
238
+ var KILL_TREE = 'k() { for t in $(pgrep -P "$1"); do kill -9 -"$t" 2>/dev/null; done; kill -9 -"$1" 2>/dev/null; pkill -9 -s "$1" 2>/dev/null; }; ';
239
+ var KILL_ALL_SCRIPT = `${KILL_TREE}for q in $(pgrep -f -- "capybara-exec /tmp/[.]capybara-exec-"); do k "$q"; kill -9 "$q" 2>/dev/null; done; exit 0`;
240
+ var KILL_TAGGED_SCRIPT = `${KILL_TREE}u='[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; for t in "$@"; do for q in $(pgrep -f -- "capybara-exec /tmp/[.]capybara-exec-$t-$u( |$)"); do k "$q"; kill -9 "$q" 2>/dev/null; done; done; exit 0`;
241
+ function execControlPath(tag) {
242
+ if (tag !== void 0 && !SANDBOX_TAG_RE.test(tag)) throw new Error("invalid sandbox tag");
243
+ return tag === void 0 ? `/tmp/.capybara-exec-${randomUUID()}` : `/tmp/.capybara-exec-${tag}-${randomUUID()}`;
244
+ }
245
+ var KILL_SCRIPT = `${KILL_TREE}c=$1; : > "$c.kill" 2>/dev/null; i=0; while [ ! -s "$c.pid" ] && [ $i -lt 20 ]; do sleep 0.05; i=$((i+1)); done; p=$(cat "$c.pid" 2>/dev/null); if [ -n "$p" ]; then k "$p"; rm -f "$c.kill"; fi; for q in $(pgrep -f -- "capybara-exec $c"); do k "$q"; kill -9 "$q" 2>/dev/null; done; rm -f "$c.pid"; exit 0`;
246
+ var READ_SCRIPT = 'if [ ! -e "$1" ]; then exit 3; fi; if [ -d "$1" ]; then exit 4; fi; exec head -c "$2" -- "$1"';
247
+ var WRITE_SCRIPT = 'set -e; mkdir -p -- "$(dirname -- "$1")"; head -c "$2" > "$1"';
248
+ var STAT_SCRIPT = 'if [ ! -e "$1" ]; then exit 3; fi; exec stat -L -c "%s %F" -- "$1"';
249
+ function after(ms, fn) {
250
+ const t = AbortSignal.timeout(ms);
251
+ t.addEventListener("abort", fn, { once: true });
252
+ return { cancel: () => t.removeEventListener("abort", fn) };
253
+ }
254
+ function sleep(ms, signal) {
255
+ signal.throwIfAborted();
256
+ return new Promise((resolve, reject) => {
257
+ const onAbort = () => {
258
+ t.cancel();
259
+ reject(abortReason(signal));
260
+ };
261
+ const t = after(ms, () => {
262
+ signal.removeEventListener("abort", onAbort);
263
+ resolve();
264
+ });
265
+ signal.addEventListener("abort", onAbort, { once: true });
266
+ });
267
+ }
268
+ function checkPath(p) {
269
+ const errors = validateSandboxPath(p);
270
+ if (errors.length > 0)
271
+ throw new Error(`invalid sandbox path ${JSON.stringify(p)}: ${errors.join("; ")}`);
272
+ }
273
+ var NotRunningError = class extends Error {
274
+ constructor(container, opts) {
275
+ super(`sandbox container ${container} is not running`, opts);
276
+ this.name = "NotRunningError";
277
+ }
278
+ };
279
+ function createDockerSandbox(cfg, test = {}, log) {
280
+ const api = createDockerClient(cfg.endpoint);
281
+ const baseLabels = (name, role) => ({
282
+ ...test.labels,
283
+ [LABEL]: "1",
284
+ [LABEL_NAME]: name,
285
+ [LABEL_ROLE]: role
286
+ });
287
+ const enc = encodeURIComponent;
288
+ const postCreate = async (path, opts) => {
289
+ const text = JSON.stringify(opts.body ?? null);
290
+ if (/[^\x00-\x7f]/.test(text)) {
291
+ throw new Error(
292
+ `sandbox-docker: ${path} request contains non-ASCII characters (image, names, labels, environment and allowHosts must be ASCII; use punycode for hostnames)`
293
+ );
294
+ }
295
+ return api.json("POST", path, opts);
296
+ };
297
+ const ignore = (...status) => (e) => {
298
+ if (!isStatus(e, ...status)) throw e;
299
+ };
300
+ const removeWithRetry = async (path, signal) => {
301
+ for (let i = 0; ; i++) {
302
+ try {
303
+ await api.json("DELETE", path, { signal });
304
+ return;
305
+ } catch (e) {
306
+ if (isStatus(e, 404)) return;
307
+ if (i >= 20 || !isStatus(e, 403, 409, 500)) throw e;
308
+ await sleep(250, signal);
309
+ }
310
+ }
311
+ };
312
+ const destroyByName = async (name, signal) => {
313
+ signal.throwIfAborted();
314
+ const n = sandboxResourceNames(name);
315
+ await Promise.all(
316
+ [n.container, n.egress].map(
317
+ (c) => api.json("DELETE", `/containers/${enc(c)}`, { query: { force: true }, signal }).catch(ignore(404, 409))
318
+ )
319
+ );
320
+ await Promise.all([
321
+ removeWithRetry(`/networks/${enc(n.network)}`, signal),
322
+ removeWithRetry(`/volumes/${enc(n.workspace)}`, signal),
323
+ removeWithRetry(`/volumes/${enc(n.skills)}`, signal)
324
+ ]);
325
+ };
326
+ const pull = async (image, signal) => {
327
+ await api.json("POST", "/images/create", { query: { fromImage: image }, signal });
328
+ };
329
+ const createContainer = async (name, body, signal) => {
330
+ const go = () => postCreate("/containers/create", { query: { name }, body, signal });
331
+ try {
332
+ await go();
333
+ } catch (e) {
334
+ if (!(isStatus(e, 404) && /no such image/i.test(e.message))) throw e;
335
+ await pull(String(body.Image), signal);
336
+ await go();
337
+ }
338
+ };
339
+ const start = (container, signal) => api.json("POST", `/containers/${enc(container)}/start`, { signal }).catch(ignore(304));
340
+ const unpause = (container, signal) => api.json("POST", `/containers/${enc(container)}/unpause`, { signal }).catch(ignore(409));
341
+ const ensureSharedNetwork = async (signal) => {
342
+ try {
343
+ await api.json("GET", `/networks/${enc(cfg.network)}`, { signal });
344
+ } catch (e) {
345
+ if (!isStatus(e, 404)) throw e;
346
+ await postCreate("/networks/create", {
347
+ body: {
348
+ Name: cfg.network,
349
+ Driver: "bridge",
350
+ // 共用网络上的容器(open 的沙箱、各出站代理)互相不通
351
+ Options: { "com.docker.network.bridge.enable_icc": "false" },
352
+ Labels: { ...test.labels, [LABEL]: "1", [LABEL_ROLE]: "shared-network" }
353
+ },
354
+ signal
355
+ }).catch(ignore(409));
356
+ }
357
+ };
358
+ const ensureInternalNetwork = async (name, signal) => {
359
+ const n = sandboxResourceNames(name);
360
+ try {
361
+ await api.json("GET", `/networks/${enc(n.network)}`, { signal });
362
+ } catch (e) {
363
+ if (!isStatus(e, 404)) throw e;
364
+ await postCreate("/networks/create", {
365
+ body: {
366
+ Name: n.network,
367
+ Driver: "bridge",
368
+ Internal: true,
369
+ Labels: baseLabels(name, "network")
370
+ },
371
+ signal
372
+ }).catch(ignore(409));
373
+ }
374
+ };
375
+ const waitEgressReady = async (container, signal) => {
376
+ for (let i = 0; ; i++) {
377
+ const r = await run(container, {
378
+ cmd: ["wget", "-q", "-O", "-", `http://127.0.0.1:${PROXY_PORT}/healthz`],
379
+ user: "",
380
+ maxOutputBytes: 1024,
381
+ signal
382
+ }).catch((e) => {
383
+ if (signal.aborted) throw abortReason(signal);
384
+ if (i >= 50) throw e;
385
+ return null;
386
+ });
387
+ if (r?.exitCode === 0) return;
388
+ if (i >= 50) throw new Error("sandbox egress proxy did not become ready");
389
+ await sleep(100, signal);
390
+ }
391
+ };
392
+ const createEgress = async (name, allowHosts, signal, mayExist = false) => {
393
+ const n = sandboxResourceNames(name);
394
+ const env = [`ALLOW_HOSTS=${JSON.stringify(allowHosts)}`, `PORT=${PROXY_PORT}`];
395
+ if (test.egressTestHosts) {
396
+ env.push(`CAPYBARA_EGRESS_TEST_HOSTS=${JSON.stringify(test.egressTestHosts)}`);
397
+ }
398
+ const made = createContainer(
399
+ n.egress,
400
+ {
401
+ Image: cfg.egressProxyImage,
402
+ Env: env,
403
+ Labels: baseLabels(name, "egress"),
404
+ HostConfig: {
405
+ NetworkMode: cfg.network,
406
+ Init: true,
407
+ ReadonlyRootfs: true,
408
+ CapDrop: ["ALL"],
409
+ SecurityOpt: ["no-new-privileges:true"],
410
+ Memory: 128 * MIB,
411
+ MemorySwap: 128 * MIB,
412
+ NanoCpus: 5e8,
413
+ PidsLimit: 128
414
+ },
415
+ NetworkingConfig: { EndpointsConfig: { [cfg.network]: {}, [n.network]: {} } }
416
+ },
417
+ signal
418
+ );
419
+ await (mayExist ? made.catch(ignore(409)) : made);
420
+ await start(n.egress, signal);
421
+ await waitEgressReady(n.egress, signal);
422
+ };
423
+ const ensureEgressRunning = async (name, allowHosts, signal) => {
424
+ const n = sandboxResourceNames(name);
425
+ await ensureSharedNetwork(signal);
426
+ await ensureInternalNetwork(name, signal);
427
+ let running;
428
+ try {
429
+ const c = await api.json(
430
+ "GET",
431
+ `/containers/${enc(n.egress)}/json`,
432
+ { signal }
433
+ );
434
+ if (c.State.Paused) await unpause(n.egress, signal);
435
+ running = c.State.Running;
436
+ } catch (e) {
437
+ if (!isStatus(e, 404)) throw e;
438
+ await createEgress(name, allowHosts, signal, true);
439
+ return;
440
+ }
441
+ if (!running) {
442
+ await start(n.egress, signal);
443
+ await waitEgressReady(n.egress, signal);
444
+ }
445
+ };
446
+ async function run(container, o) {
447
+ o.signal.throwIfAborted();
448
+ const startedAt = Date.now();
449
+ let execId;
450
+ try {
451
+ const r = await api.json("POST", `/containers/${enc(container)}/exec`, {
452
+ body: {
453
+ AttachStdin: o.stdin !== void 0,
454
+ AttachStdout: true,
455
+ AttachStderr: true,
456
+ Tty: false,
457
+ Cmd: o.cmd,
458
+ ...o.user !== "" && { User: o.user },
459
+ ...o.env && { Env: o.env },
460
+ ...o.cwd !== void 0 && { WorkingDir: o.cwd }
461
+ },
462
+ signal: o.signal
463
+ });
464
+ execId = r.Id;
465
+ } catch (e) {
466
+ if (isStatus(e, 404)) {
467
+ throw new SandboxGoneError(`sandbox container ${container} no longer exists`, { cause: e });
468
+ }
469
+ if (isStatus(e, 409)) throw new NotRunningError(container, { cause: e });
470
+ throw e;
471
+ }
472
+ let socket;
473
+ try {
474
+ socket = await api.hijack(`/exec/${execId}/start`, { Detach: false, Tty: false }, o.signal);
475
+ } catch (e) {
476
+ if (isStatus(e, 404)) {
477
+ throw new SandboxGoneError(`sandbox container ${container} no longer exists`, { cause: e });
478
+ }
479
+ if (isStatus(e, 409)) throw new NotRunningError(container, { cause: e });
480
+ if (!o.signal.aborted) throw e;
481
+ await o.kill?.().catch(() => void 0);
482
+ return {
483
+ exitCode: null,
484
+ stdout: Buffer.alloc(0),
485
+ stderr: Buffer.alloc(0),
486
+ truncated: false,
487
+ timedOut: false,
488
+ aborted: true
489
+ };
490
+ }
491
+ const out = [];
492
+ const err = [];
493
+ let used = 0;
494
+ let truncated = false;
495
+ let timedOut = false;
496
+ let aborted = false;
497
+ let stopping = false;
498
+ const demux = new StreamDemuxer((kind, data) => {
499
+ const room = o.maxOutputBytes - used;
500
+ if (data.byteLength > room) truncated = true;
501
+ if (room <= 0) return;
502
+ const keep = data.byteLength > room ? data.subarray(0, room) : data;
503
+ used += keep.byteLength;
504
+ (kind === 1 ? out : err).push(Buffer.from(keep));
505
+ });
506
+ const ended = new Promise((resolve) => {
507
+ socket.on("data", (c) => demux.push(c));
508
+ socket.on("error", (e) => resolve(e));
509
+ socket.on("close", () => resolve(null));
510
+ socket.on("end", () => {
511
+ socket.destroy();
512
+ resolve(null);
513
+ });
514
+ });
515
+ let grace;
516
+ let killing;
517
+ const stop = () => {
518
+ if (stopping) return;
519
+ stopping = true;
520
+ const cut = () => {
521
+ grace = after(KILL_GRACE_MS, () => socket.destroy());
522
+ };
523
+ if (o.kill)
524
+ killing = o.kill().catch(() => void 0).finally(cut);
525
+ else socket.destroy();
526
+ };
527
+ const timer = o.timeoutMs === void 0 ? void 0 : after(o.timeoutMs, () => {
528
+ timedOut = true;
529
+ stop();
530
+ });
531
+ const onAbort = () => {
532
+ aborted = true;
533
+ stop();
534
+ };
535
+ o.signal.addEventListener("abort", onAbort, { once: true });
536
+ if (o.signal.aborted) onAbort();
537
+ if (o.stdin !== void 0 && o.stdin.byteLength > 0) socket.write(o.stdin);
538
+ const streamError = await ended;
539
+ await killing;
540
+ timer?.cancel();
541
+ grace?.cancel();
542
+ o.signal.removeEventListener("abort", onAbort);
543
+ const result = {
544
+ stdout: Buffer.concat(out),
545
+ stderr: Buffer.concat(err),
546
+ truncated,
547
+ timedOut,
548
+ aborted
549
+ };
550
+ if (timedOut || aborted) return { ...result, exitCode: null };
551
+ if (streamError) throw streamError;
552
+ const deadline = o.timeoutMs !== void 0 ? startedAt + o.timeoutMs : Date.now() + EXIT_WAIT_MS;
553
+ try {
554
+ for (; ; ) {
555
+ const s = await api.json(
556
+ "GET",
557
+ `/exec/${execId}/json`,
558
+ { signal: AbortSignal.any([o.signal, AbortSignal.timeout(1e4)]) }
559
+ );
560
+ if (!s.Running) return { ...result, exitCode: s.ExitCode };
561
+ if (Date.now() >= deadline) {
562
+ if (!o.kill) throw new Error("sandbox command did not report an exit status");
563
+ await o.kill().catch(() => void 0);
564
+ return { ...result, timedOut: true, exitCode: null };
565
+ }
566
+ await sleep(50, o.signal);
567
+ }
568
+ } catch (e) {
569
+ if (o.signal.aborted) {
570
+ await o.kill?.().catch(() => void 0);
571
+ return { ...result, aborted: true, exitCode: null };
572
+ }
573
+ throw e;
574
+ }
575
+ }
576
+ const killer = (container, ctl) => () => (
577
+ // 杀进程不受调用方 signal 影响(它往往正是已中止的那个)
578
+ killOrRestart(container, ["/bin/sh", "-c", KILL_SCRIPT, "capybara-kill", ctl])
579
+ );
580
+ const killOrRestart = async (container, cmd, signal) => {
581
+ const within = (ms) => signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
582
+ const ok = await run(container, {
583
+ cmd,
584
+ user: SANDBOX_USER,
585
+ maxOutputBytes: 4096,
586
+ signal: within(15e3)
587
+ }).then(
588
+ (r) => r.exitCode === 0 && !r.aborted,
589
+ () => false
590
+ );
591
+ if (ok) return;
592
+ signal?.throwIfAborted();
593
+ log?.warn(
594
+ "sandbox-docker: the kill command could not run; restarting the container, which also ends other commands in it",
595
+ { container }
596
+ );
597
+ await api.json("POST", `/containers/${enc(container)}/restart`, {
598
+ query: { t: 0 },
599
+ signal: within(6e4)
600
+ });
601
+ };
602
+ const fileOp = async (h, o, signal) => {
603
+ const r = await autoStart(h, signal, () => run(h.id, { ...o, signal }));
604
+ if (r.aborted) throw abortReason(signal);
605
+ return r;
606
+ };
607
+ async function autoStart(h, signal, fn) {
608
+ try {
609
+ return await fn();
610
+ } catch (e) {
611
+ if (!(e instanceof NotRunningError)) throw e;
612
+ if (await resume(h, signal) === "gone") {
613
+ throw new SandboxGoneError(`sandbox container ${h.id} no longer exists`, { cause: e });
614
+ }
615
+ return await fn();
616
+ }
617
+ }
618
+ async function resume(h, signal) {
619
+ signal.throwIfAborted();
620
+ let c;
621
+ try {
622
+ c = await api.json("GET", `/containers/${enc(h.id)}/json`, { signal });
623
+ } catch (e) {
624
+ if (isStatus(e, 404)) return "gone";
625
+ throw e;
626
+ }
627
+ const labels = c.Config.Labels ?? {};
628
+ if (labels[LABEL_EGRESS] === "open") await ensureSharedNetwork(signal);
629
+ if (labels[LABEL_EGRESS] === "allowlist") {
630
+ await ensureEgressRunning(h.id, JSON.parse(labels[LABEL_ALLOW] ?? "[]"), signal);
631
+ }
632
+ try {
633
+ if (c.State.Paused) await unpause(h.id, signal);
634
+ else if (!c.State.Running) await start(h.id, signal);
635
+ } catch (e) {
636
+ if (isStatus(e, 404)) return "gone";
637
+ throw e;
638
+ }
639
+ return "resumed";
640
+ }
641
+ const stderrOf = (r) => r.stderr.toString("utf8").trim().slice(0, 500);
642
+ return {
643
+ // docker exec instances are independent; auto-start tolerates concurrent starts (304 / 409)
644
+ concurrentExec: true,
645
+ async create({ name, image, limits, signal }) {
646
+ signal.throwIfAborted();
647
+ if (!NAME_RE.test(name)) throw new Error(`invalid sandbox name: ${name}`);
648
+ checkLimits(limits);
649
+ if (limits.egress === "open" && cfg.allowOpenEgress !== true) {
650
+ throw new Error(
651
+ 'Sandbox egress "open" (sandbox.limits.egress) is refused: the sandbox-docker plugin setting allowOpenEgress is false. Set extensionConfig."sandbox-docker".allowOpenEgress to true (trusted environments only), or set sandbox.limits.egress to "allowlist" or "none".'
652
+ );
653
+ }
654
+ const n = sandboxResourceNames(name);
655
+ await destroyByName(name, signal);
656
+ try {
657
+ await postCreate("/volumes/create", {
658
+ body: {
659
+ Name: n.workspace,
660
+ Labels: baseLabels(name, "workspace"),
661
+ ...cfg.volumeDriver !== void 0 && {
662
+ Driver: cfg.volumeDriver,
663
+ DriverOpts: { size: `${limits.diskMb}M` }
664
+ }
665
+ },
666
+ signal
667
+ });
668
+ await postCreate("/volumes/create", {
669
+ body: { Name: n.skills, Labels: baseLabels(name, "skills") },
670
+ signal
671
+ });
672
+ const env = ["HOME=/workspace", "LANG=C.UTF-8"];
673
+ let networkMode = "none";
674
+ if (limits.egress === "open") {
675
+ await ensureSharedNetwork(signal);
676
+ networkMode = cfg.network;
677
+ } else if (limits.egress === "allowlist") {
678
+ await ensureSharedNetwork(signal);
679
+ await ensureInternalNetwork(name, signal);
680
+ await createEgress(name, limits.allowHosts, signal);
681
+ networkMode = n.network;
682
+ const proxy = `http://${n.egress}:${PROXY_PORT}`;
683
+ env.push(
684
+ `HTTP_PROXY=${proxy}`,
685
+ `HTTPS_PROXY=${proxy}`,
686
+ `http_proxy=${proxy}`,
687
+ `https_proxy=${proxy}`,
688
+ "NO_PROXY=localhost,127.0.0.1,::1",
689
+ "no_proxy=localhost,127.0.0.1,::1"
690
+ );
691
+ }
692
+ const memory = limits.memoryMb * MIB;
693
+ const tmpMb = Math.max(16, Math.min(limits.diskMb, Math.floor(limits.memoryMb / 2)));
694
+ await createContainer(
695
+ n.container,
696
+ {
697
+ Image: image,
698
+ User: INIT_USER,
699
+ WorkingDir: "/workspace",
700
+ Cmd: ["sleep", "infinity"],
701
+ Env: env,
702
+ Labels: {
703
+ ...baseLabels(name, "sandbox"),
704
+ [LABEL_EGRESS]: limits.egress,
705
+ [LABEL_ALLOW]: JSON.stringify(limits.egress === "allowlist" ? limits.allowHosts : [])
706
+ },
707
+ HostConfig: {
708
+ Runtime: cfg.runtime,
709
+ Init: true,
710
+ NanoCpus: Math.round(limits.cpu * 1e9),
711
+ Memory: memory,
712
+ MemorySwap: memory,
713
+ PidsLimit: PIDS_LIMIT,
714
+ ReadonlyRootfs: true,
715
+ CapDrop: ["ALL"],
716
+ SecurityOpt: ["no-new-privileges:true"],
717
+ IpcMode: "private",
718
+ NetworkMode: networkMode,
719
+ Tmpfs: { "/tmp": `rw,nosuid,nodev,size=${tmpMb}m,mode=1777` },
720
+ Mounts: [
721
+ { Type: "volume", Source: n.workspace, Target: "/workspace" },
722
+ { Type: "volume", Source: n.skills, Target: "/skills", ReadOnly: true },
723
+ // 同一个卷的可写挂载:只有 root 进得去(/opt/capybara 是 0700),插件经它写技能
724
+ { Type: "volume", Source: n.skills, Target: "/opt/capybara/skills" }
725
+ ]
726
+ }
727
+ },
728
+ signal
729
+ );
730
+ await start(n.container, signal);
731
+ } catch (e) {
732
+ if (!signal.aborted) {
733
+ await destroyByName(name, AbortSignal.timeout(6e4)).catch(() => void 0);
734
+ }
735
+ throw e;
736
+ }
737
+ return { id: name };
738
+ },
739
+ resume,
740
+ async suspend(h, signal) {
741
+ signal.throwIfAborted();
742
+ const n = sandboxResourceNames(h.id);
743
+ for (const c of [n.container, n.egress]) {
744
+ await api.json("POST", `/containers/${enc(c)}/stop`, { query: { t: STOP_SECONDS }, signal }).catch(ignore(304, 404));
745
+ }
746
+ },
747
+ async destroy(h, signal) {
748
+ await destroyByName(h.id, signal);
749
+ },
750
+ destroyByName,
751
+ async killAll(h, signal) {
752
+ signal.throwIfAborted();
753
+ await autoStart(
754
+ h,
755
+ signal,
756
+ () => run(h.id, { cmd: ["true"], user: SANDBOX_USER, maxOutputBytes: 1024, signal })
757
+ );
758
+ await killOrRestart(h.id, ["/bin/sh", "-c", KILL_ALL_SCRIPT, "capybara-killall"], signal);
759
+ },
760
+ async killTagged(h, tags, signal) {
761
+ signal.throwIfAborted();
762
+ for (const t of tags) {
763
+ if (!SANDBOX_TAG_RE.test(t)) throw new Error("invalid sandbox tag");
764
+ }
765
+ if (tags.length === 0) return;
766
+ await autoStart(
767
+ h,
768
+ signal,
769
+ () => run(h.id, { cmd: ["true"], user: SANDBOX_USER, maxOutputBytes: 1024, signal })
770
+ );
771
+ await killOrRestart(
772
+ h.id,
773
+ ["/bin/sh", "-c", KILL_TAGGED_SCRIPT, "capybara-killtagged", ...tags],
774
+ signal
775
+ );
776
+ },
777
+ async exec(h, args) {
778
+ args.signal.throwIfAborted();
779
+ const ctl = execControlPath(args.tag);
780
+ const env = [];
781
+ for (const [k, v] of Object.entries(args.env ?? {})) {
782
+ if (!ENV_KEY_RE.test(k)) throw new Error(`invalid environment variable name: ${k}`);
783
+ env.push(`${k}=${v}`);
784
+ }
785
+ const r = await autoStart(
786
+ h,
787
+ args.signal,
788
+ () => run(h.id, {
789
+ cmd: execArgv(ctl, args.command, args.timeoutMs),
790
+ user: SANDBOX_USER,
791
+ env,
792
+ cwd: args.cwd ?? "/workspace",
793
+ maxOutputBytes: args.maxOutputBytes,
794
+ timeoutMs: args.timeoutMs,
795
+ signal: args.signal,
796
+ kill: killer(h.id, ctl)
797
+ })
798
+ );
799
+ return {
800
+ exitCode: r.exitCode,
801
+ stdout: decodeOutput(r.stdout, r.truncated),
802
+ stderr: decodeOutput(r.stderr, r.truncated),
803
+ truncated: r.truncated,
804
+ timedOut: r.timedOut
805
+ };
806
+ },
807
+ async readFile(h, path, opts) {
808
+ opts.signal.throwIfAborted();
809
+ checkPath(path);
810
+ const want = opts.maxBytes + 1;
811
+ const r = await fileOp(
812
+ h,
813
+ {
814
+ cmd: ["/bin/sh", "-c", READ_SCRIPT, "capybara-read", path, String(want)],
815
+ user: SANDBOX_USER,
816
+ maxOutputBytes: want + STDERR_ROOM
817
+ },
818
+ opts.signal
819
+ );
820
+ if (r.exitCode === 3 || r.exitCode === 4) return null;
821
+ if (r.exitCode !== 0) throw new Error(`cannot read ${path}: ${stderrOf(r)}`);
822
+ const truncated = r.stdout.byteLength > opts.maxBytes;
823
+ const data = truncated ? r.stdout.subarray(0, opts.maxBytes) : r.stdout;
824
+ return { data: new Uint8Array(data), truncated };
825
+ },
826
+ async writeFile(h, path, data, signal) {
827
+ signal.throwIfAborted();
828
+ checkPath(path);
829
+ const skills = path === "/skills" || path.startsWith("/skills/");
830
+ const r = await fileOp(
831
+ h,
832
+ {
833
+ cmd: [
834
+ "/bin/sh",
835
+ "-c",
836
+ WRITE_SCRIPT,
837
+ "capybara-write",
838
+ skills ? `/opt/capybara${path}` : path,
839
+ String(data.byteLength)
840
+ ],
841
+ user: skills ? ROOT_USER : SANDBOX_USER,
842
+ stdin: data,
843
+ maxOutputBytes: STDERR_ROOM
844
+ },
845
+ signal
846
+ );
847
+ if (r.exitCode !== 0) throw new Error(`cannot write ${path}: ${stderrOf(r)}`);
848
+ },
849
+ async stat(h, path, signal) {
850
+ signal.throwIfAborted();
851
+ checkPath(path);
852
+ const r = await fileOp(
853
+ h,
854
+ {
855
+ cmd: ["/bin/sh", "-c", STAT_SCRIPT, "capybara-stat", path],
856
+ user: SANDBOX_USER,
857
+ maxOutputBytes: STDERR_ROOM
858
+ },
859
+ signal
860
+ );
861
+ if (r.exitCode === 3) return null;
862
+ const m = r.stdout.toString("utf8").match(/^(\d+) (.+)\n?$/);
863
+ if (r.exitCode !== 0 || !m) throw new Error(`cannot stat ${path}: ${stderrOf(r)}`);
864
+ return { size: Number(m[1]), isDir: m[2] === "directory" };
865
+ }
866
+ };
867
+ }
868
+ function decodeOutput(buf, truncated) {
869
+ let end = buf.byteLength;
870
+ if (truncated) {
871
+ for (let i = end - 1; i >= 0 && i >= end - 4; i--) {
872
+ const b = buf[i];
873
+ if ((b & 192) === 128) continue;
874
+ const need = b >= 240 ? 4 : b >= 224 ? 3 : b >= 192 ? 2 : 1;
875
+ if (end - i < need) end = i;
876
+ break;
877
+ }
878
+ }
879
+ return buf.subarray(0, end).toString("utf8");
880
+ }
881
+ function checkLimits(l) {
882
+ if (!(l.cpu > 0) || !(l.memoryMb > 0) || !(l.diskMb > 0)) {
883
+ throw new Error("sandbox limits cpu, memoryMb and diskMb must be positive");
884
+ }
885
+ if (l.egress === "allowlist") {
886
+ for (const host of l.allowHosts) {
887
+ const errors = validateAllowHost(host);
888
+ if (errors.length > 0)
889
+ throw new Error(`invalid allowHosts entry ${host}: ${errors.join("; ")}`);
890
+ }
891
+ }
892
+ }
893
+
894
+ // src/index.ts
895
+ var NETWORK_NAME = /^cap[a-zA-Z0-9_.-]*$/;
896
+ var DeploymentConfig = z.object({
897
+ /** Docker socket 代理地址(docker/sandbox-proxy):`tcp://sandbox-proxy:2375` 或 `unix:///path`。 */
898
+ endpoint: ui(
899
+ z.string().regex(/^(tcp:\/\/(\[[^\]]+\]|[^:/]+):\d{1,5}\/?|unix:\/\/\/.+)$/, {
900
+ message: "endpoint must be tcp://host:port or unix:///path"
901
+ }),
902
+ { label: { zh: "Docker 代理地址", en: "Docker proxy endpoint" } }
903
+ ),
904
+ /** 容器运行时:runc(默认)或 gVisor 的 runsc(需在 Docker 守护进程上登记)。 */
905
+ runtime: ui(z.enum(["runc", "runsc"]).default("runc"), {
906
+ label: { zh: "容器运行时", en: "Container runtime" }
907
+ }),
908
+ /**
909
+ * 共用的沙箱网络名:egress 为 open 的沙箱与出站代理接在上面;不存在时自动创建(bridge、禁止容器互访)。
910
+ * 须以 `cap` 开头(socket 代理只放行这样的网络名)。
911
+ */
912
+ network: ui(
913
+ z.string().regex(NETWORK_NAME, {
914
+ message: "network must start with 'cap' (the socket proxy only allows cap* networks)"
915
+ }).default("capybara-sandbox"),
916
+ { label: { zh: "沙箱网络", en: "Sandbox network" } }
917
+ ),
918
+ /** 出站代理镜像(plugins/sandbox-docker/egress-proxy/Dockerfile)。 */
919
+ egressProxyImage: ui(z.string().min(1).default("capybara-sandbox-egress:latest"), {
920
+ label: { zh: "出站代理镜像", en: "Egress proxy image" }
921
+ }),
922
+ /** 工作区卷的驱动(支持 `size` 选项的驱动才能限制磁盘);不填用 local(不限大小)。 */
923
+ volumeDriver: ui(z.string().min(1).optional(), {
924
+ label: { zh: "卷驱动", en: "Volume driver" }
925
+ }),
926
+ /**
927
+ * 允许 `sandbox.limits.egress = "open"`(默认 false)。open 的沙箱能访问宿主机网络能访问的一切
928
+ * (宿主机上的服务、内网、云元数据地址),只在可信环境打开。
929
+ */
930
+ allowOpenEgress: ui(z.boolean().default(false), {
931
+ label: { zh: "允许开放出站", en: "Allow open egress" }
932
+ })
933
+ }).strict();
934
+ var index_default = defineExtension({
935
+ name: "sandbox-docker",
936
+ version: "0.1.0-rc.1",
937
+ sdk: "^0.1.0-rc.0",
938
+ provides: [SANDBOX_EXTENSION_POINT],
939
+ // 只用部署配置与 Docker endpoint,不用任何需要声明的平台能力
940
+ permissions: {},
941
+ config: { deployment: DeploymentConfig },
942
+ setup(ctx) {
943
+ ctx.provide(
944
+ SANDBOX_EXTENSION_POINT,
945
+ createDockerSandbox(ctx.config, {}, ctx.log)
946
+ );
947
+ }
948
+ });
949
+ export {
950
+ DeploymentConfig,
951
+ IN_SANDBOX_GRACE_SECONDS,
952
+ createDockerSandbox,
953
+ decodeOutput,
954
+ index_default as default,
955
+ execArgv,
956
+ sandboxResourceNames
957
+ };