@agentswf/sandbox 0.0.0-stage → 0.1.0-beta.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.
@@ -0,0 +1,865 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readFile, readlink, rename, writeFile } from "node:fs/promises";
3
+ import { createConnection } from "node:net";
4
+ import { dirname, join } from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { Readable } from "node:stream";
7
+ import type { DockerEnvironment } from "@agentswf/contract/workflow";
8
+ import { protectedPaths } from "../git";
9
+ import { KILL_GROUPS, RECORD_LEADER } from "../groups";
10
+ import { onceUnlessFailed } from "../once";
11
+ import { panePrelude } from "../pane";
12
+ import { contains } from "../resolve";
13
+ import type {
14
+ AgentContext,
15
+ Occupant,
16
+ OpenedSandbox,
17
+ PaneHerdr,
18
+ PaneTerminal,
19
+ ResolvedSandbox,
20
+ SandboxContext,
21
+ SandboxedCommand,
22
+ SandboxProvider,
23
+ } from "../seam";
24
+ import { secretsText } from "../secrets";
25
+ import { execArgs, mountArgs } from "./args";
26
+
27
+ /** The files the default image is built from, and the proxy and relay the provider runs. */
28
+ const IMAGE_DIRECTORY = join(import.meta.dir, "..", "..", "docker");
29
+
30
+ /** How long one docker command of the provider's own may take, beside an agent's. */
31
+ const COMMAND_MS = 60_000;
32
+ /** How long a daemon may take to answer before the provider says it did not. */
33
+ const DAEMON_MS = 5_000;
34
+ /** How long killing a box's recorded groups, or removing their files, may take. */
35
+ const CLEANUP_MS = 4_000;
36
+ /** How long a box outlives its run's deadline, should the engine die before closing it. */
37
+ const EXPIRY_GRACE_SECONDS = 600;
38
+
39
+ /**
40
+ * Moves each path among its arguments after the first into that directory, printing each one it
41
+ * moved: a link as a link, a directory whole. One gone, before or while it is moved, is skipped.
42
+ */
43
+ const QUARANTINE = `q=$1; shift; n=0; failed=0; for p in "$@"; do [ -e "$p" ] || [ -L "$p" ] || continue; n=$((n+1)); if mv -- "$p" "$q/$$-$n-$(basename "$p")"; then printf '%s\\n' "$p"; elif [ -e "$p" ] || [ -L "$p" ]; then failed=1; fi; done; exit $failed`;
44
+
45
+ /** Where the box's recorded groups are: one directory for each agent, and one for its Herdr. */
46
+ const BOX_PIDS = "/tmp/awf-pids";
47
+ /**
48
+ * The box's own Herdr keeps its socket and state inside the box, off every host mount, `homes/`
49
+ * included: nothing of it need outlive the box, and its panes' sessions land in their homes.
50
+ */
51
+ const HERDR_HOME = "/tmp/awf-herdr";
52
+ /** The proxy variables every process in a box has, and a pane's `env -i` sets again. */
53
+ const PROXY_VARIABLES = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
54
+ /** How long the box's Herdr may take to answer after it starts. */
55
+ const HERDR_START_MS = 20_000;
56
+
57
+ export type DockerResult = { stdout: string; stderr: string; exitCode: number };
58
+
59
+ /** How the provider reaches docker; a test replaces it to see the arguments. */
60
+ export type DockerClient = {
61
+ run(
62
+ args: readonly string[],
63
+ options?: { stdin?: string; timeoutMs?: number },
64
+ ): Promise<DockerResult>;
65
+ /** A long-lived `docker` process, for a door's relay. */
66
+ spawn(args: readonly string[]): {
67
+ stdin: { write(line: string): void; end(): void };
68
+ lines: AsyncIterable<string>;
69
+ stderr: AsyncIterable<string>;
70
+ kill(): void;
71
+ };
72
+ /**
73
+ * The `docker` command and the environment the client needs, never an agent's. On the client
74
+ * because an agent's launch is a `docker exec` that `runProcess` runs, not this client.
75
+ */
76
+ command: string;
77
+ environment: Readonly<Record<string, string>>;
78
+ };
79
+
80
+ export type DockerOptions = {
81
+ client: DockerClient;
82
+ /** Built from `packages/sandbox/docker`: the image a spec that names none runs, and the proxy. */
83
+ defaultImage: string;
84
+ /** The operator's uid and gid, which every process in a box runs as. */
85
+ user: string;
86
+ };
87
+
88
+ /**
89
+ * A container per sandbox, its agents processes in it, on an `--internal` network whose one way
90
+ * out is a CONNECT proxy holding the allowlist. Paths are mounted where they are on the host. The
91
+ * daemon is first asked at the first open, so a run with no box never waits on one.
92
+ */
93
+ export function createDockerProvider(options: DockerOptions): SandboxProvider<DockerEnvironment> {
94
+ const daemon = onceUnlessFailed(async () => {
95
+ const result = await options.client.run(["version", "--format", "{{.Server.Version}}"], {
96
+ timeoutMs: DAEMON_MS,
97
+ });
98
+ if (result.exitCode !== 0 || !result.stdout.trim()) {
99
+ throw new Error(`docker did not answer within ${DAEMON_MS / 1000}s: ${result.stderr.trim()}`);
100
+ }
101
+ });
102
+ return {
103
+ environment(raw) {
104
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
105
+ throw new Error("docker settings are an object: docker: { image? }");
106
+ }
107
+ const { image, ...rest } = raw as Record<string, unknown>;
108
+ if (Object.keys(rest).length > 0) {
109
+ throw new Error(`docker takes only an image, not ${Object.keys(rest).join(", ")}`);
110
+ }
111
+ if (image !== undefined && (typeof image !== "string" || image.trim() === "")) {
112
+ throw new Error("docker's image names an image");
113
+ }
114
+ return image === undefined ? {} : { image };
115
+ },
116
+ async open(spec, context) {
117
+ await daemon();
118
+ return openBox(spec, context, options);
119
+ },
120
+ };
121
+ }
122
+
123
+ async function openBox(
124
+ spec: ResolvedSandbox<DockerEnvironment>,
125
+ context: SandboxContext,
126
+ options: DockerOptions,
127
+ ): Promise<OpenedSandbox> {
128
+ const { client } = options;
129
+ const docker = async (args: readonly string[]) => {
130
+ const result = await client.run(args, { timeoutMs: COMMAND_MS });
131
+ if (result.exitCode !== 0) {
132
+ throw new Error(`docker ${args[0]} failed: ${result.stderr.trim().slice(0, 400)}`);
133
+ }
134
+ return result.stdout.trim();
135
+ };
136
+ if (spec.sockets.length > 0) {
137
+ throw new Error(
138
+ `docker: a box cannot reach host sockets (${spec.sockets.join(", ")}); use srt`,
139
+ );
140
+ }
141
+ const socket = daemonSocket(client.environment);
142
+ for (const path of [spec.cwd, ...spec.read, ...spec.write]) {
143
+ // A box that can reach the daemon can start a container with the host mounted.
144
+ if (socket && contains(path, socket)) throw new Error(`docker: ${path} holds docker's socket`);
145
+ }
146
+ const image = spec.environment.image ?? options.defaultImage;
147
+ for (const needed of new Set([options.defaultImage, image])) {
148
+ if (!(await imageExists(client, needed))) {
149
+ throw new Error(
150
+ needed === options.defaultImage
151
+ ? `docker: the sandbox image ${needed} is not built, and every box's proxy runs on it; build it with: ${imageBuildCommand(needed).join(" ")}`
152
+ : `docker: the image ${needed} is not here; pull it first: docker pull ${needed}`,
153
+ );
154
+ }
155
+ }
156
+ await sweepExpired(client);
157
+ const id = `awf-${randomUUID().slice(0, 12)}`;
158
+ const names = { box: id, proxy: `${id}-proxy`, network: `${id}-net` };
159
+ const expires = Math.ceil(context.deadline.unixMilliseconds / 1000) + EXPIRY_GRACE_SECONDS;
160
+ const labels = ["--label", `awf.sandbox=${id}`, "--label", `awf.expires=${expires}`];
161
+ // Both end on their own past the run's deadline, should the engine die before closing them.
162
+ const lifetime = String(Math.max(1, expires - Math.floor(Date.now() / 1000)));
163
+ const hardened = ["--cap-drop", "ALL", "--security-opt", "no-new-privileges"];
164
+ const allow = join(context.directory, "proxy", "allow");
165
+ let domains = new Set(spec.network);
166
+ await mkdir(dirname(allow), { recursive: true, mode: 0o755 });
167
+ await writeAllowlist(allow, domains);
168
+ const guarded = await protectedPaths(spec);
169
+ // A protected path that exists is mounted read-only over itself, unless it or a directory it
170
+ // is in is a link: a mount would follow it, and expose what it names. Such a link is kept as
171
+ // it is, and moved out if it changes. A mount holds only its own path, and a directory above it
172
+ // can be renamed and made again: a path whose directories are not those it had at open is
173
+ // moved out, as is one that did not exist and appears. The operator's own edit on the host,
174
+ // which replaces a file but not its directories, is left alone.
175
+ const roots = [...spec.write, ...spec.gitdirs.map((gitdir) => gitdir.path)];
176
+ const links = new Map<string, string>();
177
+ const unlinked = new Map<string, { exists: boolean; directories: string }>();
178
+ for (const path of guarded) {
179
+ const link = await linkAt(path, roots);
180
+ if (link) {
181
+ links.set(link, await readlink(link));
182
+ continue;
183
+ }
184
+ const exists = Boolean(await lstat(path).catch(() => undefined));
185
+ unlinked.set(path, { exists, directories: await directoriesOf(path, roots) });
186
+ }
187
+ const existing = [...unlinked].flatMap(([path, { exists }]) => (exists ? [path] : []));
188
+ const quarantine = join(context.directory, "quarantine");
189
+ await mkdir(quarantine, { recursive: true, mode: 0o700 });
190
+ const record: { image?: string; quarantined?: string[] } = {};
191
+ // What to move is found here; the move is made in the box, where a link an agent made can
192
+ // reach nothing its mounts do not, so a race cannot turn it on the operator's files.
193
+ const offending = async (): Promise<string[]> => {
194
+ const found = new Set<string>();
195
+ for (const [link, target] of links) {
196
+ // A higher link made since is moved instead; otherwise one no longer the same link is moved
197
+ // whole, whatever took its place.
198
+ const above = await linkAt(link, roots);
199
+ if (above && above !== link) {
200
+ found.add(above);
201
+ continue;
202
+ }
203
+ if ((await readlink(link).catch(() => undefined)) !== target) {
204
+ if (await lstat(link).catch(() => undefined)) found.add(link);
205
+ }
206
+ }
207
+ for (const [path, was] of unlinked) {
208
+ const link = await linkAt(path, roots);
209
+ if (link) {
210
+ if (!links.has(link)) found.add(link);
211
+ continue;
212
+ }
213
+ if (!(await lstat(path).catch(() => undefined))) continue;
214
+ if (!was.exists || (await directoriesOf(path, roots)) !== was.directories) found.add(path);
215
+ }
216
+ return [...found];
217
+ };
218
+ // One at a time: co-tenants' reaps would otherwise both move the same path, one failing.
219
+ let guarding: Promise<void> = Promise.resolve();
220
+ /**
221
+ * At `last`, everything of the box's uid is stopped first, so nothing writes after the look:
222
+ * stopped, not killed, as the box ends with its `sleep`, and the move with it.
223
+ */
224
+ const guard = (last = false) => {
225
+ const next = guarding.then(async () => {
226
+ const paths = await offending();
227
+ if (paths.length === 0) return;
228
+ const moved = await client.run(
229
+ [
230
+ "exec",
231
+ names.box,
232
+ "sh",
233
+ "-c",
234
+ `${last ? "kill -STOP -1 2>/dev/null; " : ""}${QUARANTINE}`,
235
+ "sh",
236
+ quarantine,
237
+ ...paths,
238
+ ],
239
+ { timeoutMs: COMMAND_MS },
240
+ );
241
+ const done = moved.stdout.split("\n").filter((line) => paths.includes(line));
242
+ if (done.length > 0) record.quarantined = [...(record.quarantined ?? []), ...done];
243
+ if (moved.exitCode !== 0) {
244
+ const left = paths.filter((path) => !done.includes(path));
245
+ throw new Error(
246
+ `docker: ${left.join(", ")}, which a box may not make, not moved out: ${moved.stderr.trim()}`,
247
+ );
248
+ }
249
+ });
250
+ guarding = next.catch(() => undefined);
251
+ return next;
252
+ };
253
+ // Each undo is registered before its step, as a step that fails or times out may have made it;
254
+ // removing what does not exist is harmless. They run last first, one at a time.
255
+ const undo: (() => Promise<unknown>)[] = [];
256
+ const proxy = `http://${names.proxy}:3128`;
257
+ const undoAll = async () => {
258
+ for (const step of undo.splice(0).reverse()) await step().catch(() => undefined);
259
+ };
260
+ try {
261
+ undo.push(() => client.run(["network", "rm", names.network], { timeoutMs: COMMAND_MS }));
262
+ await docker(["network", "create", "--internal", ...labels, names.network]);
263
+ undo.push(() => client.run(["rm", "-f", names.proxy], { timeoutMs: COMMAND_MS }));
264
+ // On the internal network first, where it listens, then the bridge it reaches out through.
265
+ // Its allowlist is in a mounted directory: a single-file mount goes stale when replaced (X8).
266
+ await docker([
267
+ "run",
268
+ "-d",
269
+ "--rm",
270
+ "--name",
271
+ names.proxy,
272
+ ...labels,
273
+ ...hardened,
274
+ "--user",
275
+ options.user,
276
+ "--network",
277
+ names.network,
278
+ "--mount",
279
+ `type=bind,source=${dirname(allow)},target=/proxy,readonly`,
280
+ options.defaultImage,
281
+ "timeout",
282
+ lifetime,
283
+ "node",
284
+ "-e",
285
+ await readFile(join(IMAGE_DIRECTORY, "proxy.js"), "utf8"),
286
+ "/proxy/allow",
287
+ "3128",
288
+ ]);
289
+ await docker(["network", "connect", "bridge", names.proxy]);
290
+ undo.push(() => client.run(["rm", "-f", names.box], { timeoutMs: COMMAND_MS }));
291
+ await docker([
292
+ "run",
293
+ "-d",
294
+ // Gone once it stops, `sleep` expired or killed, with any secret a pane never read.
295
+ "--rm",
296
+ "--init",
297
+ "--name",
298
+ names.box,
299
+ ...labels,
300
+ ...hardened,
301
+ "--user",
302
+ options.user,
303
+ "--network",
304
+ names.network,
305
+ ...PROXY_VARIABLES.flatMap((name) => ["-e", `${name}=${proxy}`]),
306
+ ...mountArgs(spec, context.directory, existing),
307
+ image,
308
+ "sleep",
309
+ lifetime,
310
+ ]);
311
+ // node and git need the operator's uid to have a name, which the image does not know.
312
+ const [uid = "", gid = ""] = options.user.split(":");
313
+ await docker([
314
+ "exec",
315
+ "-u",
316
+ "0",
317
+ names.box,
318
+ "sh",
319
+ "-c",
320
+ `getent passwd ${uid} >/dev/null || echo 'awf:x:${uid}:${gid}::/tmp:/bin/sh' >> /etc/passwd; getent group ${gid} >/dev/null || echo 'awf:x:${gid}:' >> /etc/group`,
321
+ ]);
322
+ } catch (error) {
323
+ await undoAll();
324
+ throw error;
325
+ }
326
+ const digest = (
327
+ await client.run(["inspect", "--format", "{{.Image}}", names.box], { timeoutMs: COMMAND_MS })
328
+ ).stdout.trim();
329
+ const occupants: Occupant[] = [];
330
+ const box = boxHerdr(names.box, client);
331
+ const panes: BoxPanes = {
332
+ herdr: box.herdr,
333
+ start: box.start,
334
+ proxy: Object.fromEntries(PROXY_VARIABLES.map((name) => [name, proxy])),
335
+ };
336
+ return {
337
+ record: Object.assign(record, { image: digest || image }),
338
+ panes: true,
339
+ async admit(agent) {
340
+ const occupant = await admitAgent(names.box, agent, options, guard, panes);
341
+ try {
342
+ // Kept only once written: an agent refused leaves nothing in the list.
343
+ const next = new Set([...domains, ...agent.harness.domains]);
344
+ await writeAllowlist(allow, next);
345
+ domains = next;
346
+ } catch (error) {
347
+ await occupant.release();
348
+ throw error;
349
+ }
350
+ occupants.push(occupant);
351
+ return occupant;
352
+ },
353
+ async close() {
354
+ await Promise.allSettled(occupants.map((occupant) => occupant.release()));
355
+ // Evidence of what the box asked for and was refused, kept before the proxy goes.
356
+ const log = await client.run(["logs", names.proxy], { timeoutMs: COMMAND_MS });
357
+ await writeFile(join(context.directory, "proxy.log"), log.stdout).catch(() => undefined);
358
+ // In the box, before it goes.
359
+ const guarded = await guard(true).catch((error: unknown) => error);
360
+ await undoAll();
361
+ if (guarded) throw guarded;
362
+ },
363
+ };
364
+ }
365
+
366
+ /** What an occupant needs to open a pane in its box. */
367
+ type BoxPanes = {
368
+ herdr: PaneHerdr;
369
+ /** Starts the box's Herdr, once, at the first pane; the box's `PATH`, which a pane sets again. */
370
+ start(): Promise<string>;
371
+ /** The proxy variables every process in the box has, which a pane's `env -i` must set again. */
372
+ proxy: Readonly<Record<string, string>>;
373
+ };
374
+
375
+ /**
376
+ * The box's own Herdr (H2): the host's cannot see an agent behind `docker exec` (H1). Each command
377
+ * is a `docker exec` whose pid is recorded, so a cancelled `agent prompt --wait` is ended in the
378
+ * box too, as killing the client alone would not (X7).
379
+ */
380
+ function boxHerdr(
381
+ box: string,
382
+ client: DockerClient,
383
+ ): { herdr: PaneHerdr; start(): Promise<string> } {
384
+ const pids = `${BOX_PIDS}/herdr`;
385
+ // By name, not the client's path: the operator copies it, or a tab of theirs types it.
386
+ const watch = ["docker", "exec", "-it", "-e", `HOME=${HERDR_HOME}`, box, "herdr"];
387
+ const herdr: PaneHerdr = {
388
+ key: box,
389
+ watch,
390
+ run(args, timeoutMs): SandboxedCommand {
391
+ const pidFile = `${pids}/${randomUUID()}`;
392
+ return {
393
+ argv: [
394
+ client.command,
395
+ ...execArgs({
396
+ box,
397
+ cwd: undefined,
398
+ env: { HOME: HERDR_HOME },
399
+ secrets: [],
400
+ pidFile,
401
+ argv: ["herdr", ...args],
402
+ }),
403
+ ],
404
+ env: { ...client.environment },
405
+ timeoutMs,
406
+ group: true,
407
+ async reap() {
408
+ await killIn(client, box, [pidFile]);
409
+ },
410
+ };
411
+ },
412
+ };
413
+ const start = onceUnlessFailed(async () => {
414
+ const found = await client.run(
415
+ ["exec", box, "sh", "-c", "command -v herdr && command -v zsh"],
416
+ {
417
+ timeoutMs: COMMAND_MS,
418
+ },
419
+ );
420
+ if (found.exitCode !== 0) throw new Error("docker: the image lacks herdr or zsh for panes");
421
+ const home = ["exec", "-e", `HOME=${HERDR_HOME}`, box];
422
+ // Its first-run screen would take the first pane's keys.
423
+ const seeded = await client.run(
424
+ [
425
+ ...home,
426
+ "sh",
427
+ "-c",
428
+ 'mkdir -p "$HOME/.config/herdr" && echo "onboarding = false" > "$HOME/.config/herdr/config.toml"',
429
+ ],
430
+ { timeoutMs: COMMAND_MS },
431
+ );
432
+ if (seeded.exitCode !== 0) throw new Error(`docker: seeding Herdr failed: ${seeded.stderr}`);
433
+ // `-d`: the server outlives this command, and ends with the box.
434
+ await client.run(["exec", "-d", "-e", `HOME=${HERDR_HOME}`, box, "herdr", "server"], {
435
+ timeoutMs: COMMAND_MS,
436
+ });
437
+ const by = Date.now() + HERDR_START_MS;
438
+ for (;;) {
439
+ const listed = await client.run([...home, "herdr", "workspace", "list"], {
440
+ timeoutMs: COMMAND_MS,
441
+ });
442
+ if (listed.exitCode === 0) {
443
+ const path = await client.run(["exec", box, "printenv", "PATH"], {
444
+ timeoutMs: COMMAND_MS,
445
+ });
446
+ return path.stdout.trim() || "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
447
+ }
448
+ if (Date.now() > by) {
449
+ throw new Error(`docker: the box's Herdr did not start: ${listed.stderr.trim()}`);
450
+ }
451
+ await Bun.sleep(250);
452
+ }
453
+ });
454
+ // One that failed is tried again at the next pane; a server that dies later is not started
455
+ // again: the box's panes fail, and the run with them.
456
+ return { herdr, start };
457
+ }
458
+
459
+ async function admitAgent(
460
+ box: string,
461
+ agent: AgentContext,
462
+ options: DockerOptions,
463
+ guard: () => Promise<void>,
464
+ panes: BoxPanes,
465
+ ): Promise<Occupant> {
466
+ const { client } = options;
467
+ const asRoot = async (args: readonly string[], stdin?: string) => {
468
+ const result = await client.run(["exec", "-i", "-u", "0", box, ...args], {
469
+ ...(stdin === undefined ? {} : { stdin }),
470
+ timeoutMs: COMMAND_MS,
471
+ });
472
+ if (result.exitCode !== 0) {
473
+ throw new Error(
474
+ `docker: making the door failed in the box: ${result.stderr.trim().slice(0, 300)}`,
475
+ );
476
+ }
477
+ };
478
+ for (const needed of [agent.harness.command, "bun", "node"]) {
479
+ const found = await client.run(["exec", box, "sh", "-c", 'command -v "$0"', needed], {
480
+ timeoutMs: COMMAND_MS,
481
+ });
482
+ if (found.exitCode !== 0) throw new Error(`docker: the image lacks ${needed}`);
483
+ }
484
+ // A box has no `/private`, and the agent must not write its door: root makes both.
485
+ const doorDirectory = dirname(agent.door.launcher);
486
+ await asRoot(["sh", "-c", 'mkdir -p "$0" && chmod 755 "$0"', doorDirectory]);
487
+ await asRoot(
488
+ ["sh", "-c", 'cat > "$0" && chmod 644 "$0"', agent.door.bundle],
489
+ await readFile(agent.door.bundle, "utf8"),
490
+ );
491
+ await asRoot(
492
+ ["sh", "-c", 'cat > "$0" && chmod 755 "$0"', agent.door.launcher],
493
+ agent.door.boxScript,
494
+ );
495
+ const agentId = randomUUID();
496
+ const relay = await startRelay(client, box, agent.door.endpoint, `/run/awf-relay/${agentId}`);
497
+ const pids = `${BOX_PIDS}/${agentId}`;
498
+ // A box's `/tmp` is its own; its co-tenants share the agent's uid, as they share everything.
499
+ const short = agent.harness.shortDirectory ? `/tmp/awf-${agentId.slice(0, 8)}` : undefined;
500
+ if (short) {
501
+ const made = await client.run(["exec", box, "mkdir", "-m", "700", short], {
502
+ timeoutMs: COMMAND_MS,
503
+ });
504
+ if (made.exitCode !== 0) {
505
+ throw new Error(`docker: making ${short} failed in the box: ${made.stderr.trim()}`);
506
+ }
507
+ }
508
+ const env: Record<string, string> = {
509
+ HOME: agent.home,
510
+ ...agent.harness.env,
511
+ ...(agent.harness.shortDirectory && short ? { [agent.harness.shortDirectory]: short } : {}),
512
+ };
513
+ const clientEnvironment = { ...client.environment, ...agent.harness.secrets };
514
+ let released: Promise<void> | undefined;
515
+ const unread: string[] = [];
516
+ return {
517
+ async pane(): Promise<PaneTerminal> {
518
+ if (released) throw new Error("docker: this agent was released");
519
+ const path = await panes.start();
520
+ // Written by the agent's own uid through stdin, so the token is in no argv: the box drops
521
+ // every capability, `chown` included. Its co-tenants share that uid (the trust boundary).
522
+ const secretsFile = `/tmp/awf-secrets/${randomUUID()}`;
523
+ const written = await client.run(
524
+ [
525
+ "exec",
526
+ "-i",
527
+ box,
528
+ "sh",
529
+ "-c",
530
+ 'umask 077 && mkdir -p "$(dirname "$0")" && cat > "$0"',
531
+ secretsFile,
532
+ ],
533
+ { stdin: secretsText(agent.harness.secrets), timeoutMs: COMMAND_MS },
534
+ );
535
+ if (written.exitCode !== 0) {
536
+ throw new Error(`docker: writing the pane's secret failed: ${written.stderr.trim()}`);
537
+ }
538
+ unread.push(secretsFile);
539
+ return {
540
+ herdr: panes.herdr,
541
+ ...panePrelude({
542
+ environment: { ...panes.proxy, PATH: path, SHELL: "/bin/bash", ...env },
543
+ pidFile: `${pids}/${randomUUID()}`,
544
+ secretsFile,
545
+ through: [],
546
+ }),
547
+ harness: agent.harness.command,
548
+ };
549
+ },
550
+ launch(root): SandboxedCommand {
551
+ if (released) throw new Error("docker: this agent was released");
552
+ const pidFile = `${pids}/${randomUUID()}`;
553
+ return {
554
+ ...root,
555
+ argv: [
556
+ client.command,
557
+ ...execArgs({
558
+ box,
559
+ cwd: root.cwd,
560
+ env: { ...env, ...root.env },
561
+ secrets: Object.keys(agent.harness.secrets),
562
+ pidFile,
563
+ argv: root.argv,
564
+ }),
565
+ ],
566
+ env: clientEnvironment,
567
+ group: true,
568
+ // Killing the `docker exec` client leaves its processes running in the box (X7).
569
+ async reap() {
570
+ await killIn(client, box, [pidFile]);
571
+ await guard();
572
+ },
573
+ };
574
+ },
575
+ release() {
576
+ released ??= (async () => {
577
+ await relay.stop();
578
+ await killIn(client, box, [pids]);
579
+ if (unread.length > 0) {
580
+ await client.run(["exec", box, "rm", "-f", ...unread], { timeoutMs: CLEANUP_MS });
581
+ }
582
+ if (short) await client.run(["exec", box, "rm", "-rf", short], { timeoutMs: CLEANUP_MS });
583
+ await guard();
584
+ })();
585
+ return released;
586
+ },
587
+ };
588
+ }
589
+
590
+ /**
591
+ * The door in a box: a listener at the endpoint's path inside, run by root over `docker exec -i`,
592
+ * whose connections this side carries to the engine's socket (X6: a host socket does not connect
593
+ * from a container). Its pid is recorded where only root writes, so stopping it ends it inside
594
+ * too, as killing the client alone would not (X7).
595
+ */
596
+ async function startRelay(
597
+ client: DockerClient,
598
+ box: string,
599
+ endpoint: string,
600
+ pidFile: string,
601
+ ): Promise<{ stop(): Promise<void> }> {
602
+ const relay = client.spawn([
603
+ "exec",
604
+ "-i",
605
+ "-u",
606
+ "0",
607
+ box,
608
+ "sh",
609
+ "-c",
610
+ RECORD_LEADER,
611
+ pidFile,
612
+ "node",
613
+ "-e",
614
+ await readFile(join(IMAGE_DIRECTORY, "relay.js"), "utf8"),
615
+ endpoint,
616
+ ]);
617
+ const ready = (async () => {
618
+ for await (const line of relay.stderr) if (line.trim() === "ready") return;
619
+ throw new Error("docker: the door's relay ended before it listened");
620
+ })();
621
+ void (async () => {
622
+ for await (const line of relay.lines) {
623
+ let request: { id: number; b64: string };
624
+ try {
625
+ request = JSON.parse(line);
626
+ } catch {
627
+ continue;
628
+ }
629
+ const reply: Buffer[] = [];
630
+ const socket = createConnection(endpoint, () =>
631
+ socket.end(Buffer.from(request.b64, "base64")),
632
+ );
633
+ socket.on("data", (chunk: Buffer) => reply.push(chunk));
634
+ const answer = () =>
635
+ relay.stdin.write(
636
+ `${JSON.stringify({ id: request.id, b64: Buffer.concat(reply).toString("base64") })}\n`,
637
+ );
638
+ socket.on("end", answer);
639
+ socket.on("error", answer);
640
+ }
641
+ })().catch(() => undefined);
642
+ const stop = async () => {
643
+ relay.stdin.end();
644
+ relay.kill();
645
+ await killIn(client, box, [pidFile], "0");
646
+ };
647
+ let timer: ReturnType<typeof setTimeout> | undefined;
648
+ try {
649
+ await Promise.race([
650
+ ready,
651
+ new Promise((_, reject) => {
652
+ timer = setTimeout(
653
+ () => reject(new Error("docker: the door's relay did not start")),
654
+ 20_000,
655
+ );
656
+ }),
657
+ ]);
658
+ } catch (error) {
659
+ await stop();
660
+ throw error;
661
+ } finally {
662
+ clearTimeout(timer);
663
+ }
664
+ return { stop };
665
+ }
666
+
667
+ /** Written beside and renamed over, so the proxy never reads half a list. */
668
+ async function writeAllowlist(path: string, domains: Iterable<string>): Promise<void> {
669
+ const temporary = `${path}.${randomUUID()}`;
670
+ await writeFile(temporary, [...domains].map((domain) => `${domain}\n`).join(""), {
671
+ mode: 0o644,
672
+ });
673
+ await rename(temporary, path);
674
+ }
675
+
676
+ async function imageExists(client: DockerClient, image: string): Promise<boolean> {
677
+ const result = await client.run(["image", "inspect", "--format", "{{.Id}}", image], {
678
+ timeoutMs: COMMAND_MS,
679
+ });
680
+ return result.exitCode === 0;
681
+ }
682
+
683
+ /**
684
+ * Removes what a run that died before closing left, once past its deadline and grace: only what
685
+ * carries both of a box's labels.
686
+ */
687
+ async function sweepExpired(client: DockerClient): Promise<void> {
688
+ const now = Math.floor(Date.now() / 1000);
689
+ // A container's name is `.Names`, a network's `.Name`.
690
+ const expired = async (list: readonly string[], name: string) => {
691
+ const listed = await client.run(
692
+ [
693
+ ...list,
694
+ "--filter",
695
+ "label=awf.sandbox",
696
+ "--filter",
697
+ "label=awf.expires",
698
+ "--format",
699
+ `{{${name}}} {{.Label "awf.expires"}}`,
700
+ ],
701
+ { timeoutMs: COMMAND_MS },
702
+ );
703
+ return listed.stdout
704
+ .split("\n")
705
+ .map((line) => line.trim().replace(/^\//, "").split(" "))
706
+ .filter(([name, at]) => name && Number(at) < now)
707
+ .map(([name]) => name!);
708
+ };
709
+ const containers = await expired(["ps", "-a"], ".Names");
710
+ if (containers.length > 0) {
711
+ await client.run(["rm", "-f", ...containers], { timeoutMs: COMMAND_MS });
712
+ }
713
+ const networks = await expired(["network", "ls"], ".Name");
714
+ if (networks.length > 0) {
715
+ await client.run(["network", "rm", ...networks], { timeoutMs: COMMAND_MS });
716
+ }
717
+ }
718
+
719
+ /** The daemon's socket on this host, which no box may mount. */
720
+ function daemonSocket(environment: Readonly<Record<string, string>>): string | undefined {
721
+ const host = environment.DOCKER_HOST;
722
+ if (host?.startsWith("unix://")) return host.slice("unix://".length);
723
+ return host === undefined ? "/var/run/docker.sock" : undefined;
724
+ }
725
+
726
+ /**
727
+ * The default image's tag: a hash of its Dockerfile, whose every input is pinned there, so a
728
+ * change to it is a new image rather than a stale one run under the old name.
729
+ */
730
+ export async function defaultImage(): Promise<string> {
731
+ const hash = createHash("sha256");
732
+ hash.update(await readFile(join(IMAGE_DIRECTORY, "Dockerfile")));
733
+ return `awf-agent:${hash.digest("hex").slice(0, 12)}`;
734
+ }
735
+
736
+ /** The command that builds the default image. */
737
+ export function imageBuildCommand(tag: string): string[] {
738
+ return ["docker", "build", "-t", tag, IMAGE_DIRECTORY];
739
+ }
740
+
741
+ /**
742
+ * docker on this machine: its CLI on `PATH`. The daemon is not asked here, so a run that opens no
743
+ * box never waits on one that hangs; the provider asks it at its first open.
744
+ */
745
+ export async function findDocker(
746
+ environment: Readonly<Record<string, string | undefined>>,
747
+ ): Promise<DockerOptions | undefined> {
748
+ const command = Bun.which("docker", { PATH: environment.PATH ?? "" });
749
+ if (!command) return undefined;
750
+ const uid = process.getuid?.() ?? 0;
751
+ const gid = process.getgid?.() ?? 0;
752
+ return {
753
+ client: dockerClient(command, environment),
754
+ defaultImage: await defaultImage(),
755
+ user: `${uid}:${gid}`,
756
+ };
757
+ }
758
+
759
+ /** The real client: `docker`, with only what it needs of the engine's environment. */
760
+ function dockerClient(
761
+ command: string,
762
+ environment: Readonly<Record<string, string | undefined>>,
763
+ ): DockerClient {
764
+ const own = Object.fromEntries(
765
+ Object.entries(environment).filter(
766
+ (entry): entry is [string, string] =>
767
+ entry[1] !== undefined &&
768
+ (entry[0] === "PATH" || entry[0] === "HOME" || entry[0].startsWith("DOCKER_")),
769
+ ),
770
+ );
771
+ return {
772
+ command,
773
+ environment: own,
774
+ async run(args, options = {}) {
775
+ // Its own group, killed whole: a hung daemon leaves the client's helpers holding its pipes.
776
+ const child = Bun.spawn({
777
+ cmd: [command, ...args],
778
+ env: own,
779
+ detached: true,
780
+ stdin: options.stdin === undefined ? "ignore" : new TextEncoder().encode(options.stdin),
781
+ stdout: "pipe",
782
+ stderr: "pipe",
783
+ });
784
+ const output = Promise.all([
785
+ new Response(child.stdout).text(),
786
+ new Response(child.stderr).text(),
787
+ ]);
788
+ let timer: ReturnType<typeof setTimeout> | undefined;
789
+ const expired = new Promise<"expired">((resolve) => {
790
+ timer = setTimeout(() => resolve("expired"), options.timeoutMs ?? COMMAND_MS);
791
+ });
792
+ const exited = await Promise.race([child.exited, expired]);
793
+ clearTimeout(timer);
794
+ if (exited === "expired") {
795
+ try {
796
+ process.kill(-child.pid, "SIGKILL");
797
+ } catch {}
798
+ return { stdout: "", stderr: `docker ${args[0]} did not answer in time`, exitCode: 124 };
799
+ }
800
+ const [stdout, stderr] = await output;
801
+ return { stdout, stderr, exitCode: exited };
802
+ },
803
+ spawn(args) {
804
+ const child = Bun.spawn({
805
+ cmd: [command, ...args],
806
+ env: own,
807
+ stdin: "pipe",
808
+ stdout: "pipe",
809
+ stderr: "pipe",
810
+ });
811
+ const linesOf = (stream: ReadableStream<Uint8Array>) =>
812
+ createInterface({ input: Readable.fromWeb(stream as never) });
813
+ return {
814
+ stdin: {
815
+ write: (line) => {
816
+ child.stdin.write(line);
817
+ child.stdin.flush();
818
+ },
819
+ end: () => void child.stdin.end(),
820
+ },
821
+ lines: linesOf(child.stdout),
822
+ stderr: linesOf(child.stderr),
823
+ kill: () => child.kill("SIGKILL"),
824
+ };
825
+ },
826
+ };
827
+ }
828
+
829
+ /** Kills the groups `files` name inside the box: pid files, or directories of them. */
830
+ function killIn(client: DockerClient, box: string, files: readonly string[], user?: string) {
831
+ return client.run(
832
+ ["exec", ...(user ? ["-u", user] : []), box, "sh", "-c", KILL_GROUPS, "sh", ...files],
833
+ { timeoutMs: CLEANUP_MS },
834
+ );
835
+ }
836
+
837
+ /**
838
+ * The highest link among `path` and the directories it is in below the deepest of `roots` holding
839
+ * it: where a host process following `path` would leave the reach. Undefined when there is none,
840
+ * or no root holds it.
841
+ */
842
+ async function linkAt(path: string, roots: readonly string[]): Promise<string | undefined> {
843
+ const root = roots.filter((root) => contains(root, path)).sort((a, b) => b.length - a.length)[0];
844
+ if (root === undefined) return undefined;
845
+ let highest: string | undefined;
846
+ for (let at = path; at !== root && contains(root, at); at = dirname(at)) {
847
+ if ((await lstat(at).catch(() => undefined))?.isSymbolicLink()) highest = at;
848
+ }
849
+ return highest;
850
+ }
851
+
852
+ /** Which directories `path` is in below the deepest of `roots` holding it, by their identity. */
853
+ async function directoriesOf(path: string, roots: readonly string[]): Promise<string> {
854
+ const root = roots.filter((root) => contains(root, path)).sort((a, b) => b.length - a.length)[0];
855
+ const identities: string[] = [];
856
+ for (
857
+ let at = dirname(path);
858
+ root !== undefined && at !== root && contains(root, at);
859
+ at = dirname(at)
860
+ ) {
861
+ const found = await lstat(at).catch(() => undefined);
862
+ identities.push(found ? `${found.dev}:${found.ino}` : "-");
863
+ }
864
+ return identities.join("/");
865
+ }