@agentswf/sandbox 0.0.0-stage → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, realpathSync } from "node:fs";
3
+ import { chmod, mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
4
+ import { createServer, type Server } from "node:net";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { resolveSandbox } from "../resolve";
8
+ import {
9
+ type AgentDoor,
10
+ type HarnessSandboxNeeds,
11
+ type Occupant,
12
+ type OpenedSandbox,
13
+ REAP_GRACE_MS,
14
+ type SandboxedCommand,
15
+ type SandboxProvider,
16
+ } from "../seam";
17
+
18
+ export type ConformanceSetup = {
19
+ provider: SandboxProvider<unknown>;
20
+ /** The environment every sandbox names, as a spec would: `{ key: "docker", settings: {} }`. */
21
+ environment?: { key: "srt" | "docker"; settings: unknown };
22
+ /** Whether it enforces reach. A fake does not, so the reach and boundary tests are skipped. */
23
+ confines: boolean;
24
+ /**
25
+ * How many processes whose command line contains `pattern` still run where `occupant` runs
26
+ * them. By default, on the host.
27
+ */
28
+ running?(pattern: string, occupant: Occupant): Promise<number>;
29
+ /** What the suite's agents need beyond `sh`. */
30
+ needs?: Partial<HarnessSandboxNeeds>;
31
+ };
32
+
33
+ /** A real provider starts a process per launch; srt's takes most of a second. */
34
+ const TEST_TIMEOUT_MS = 30_000;
35
+
36
+ type Agent = { occupant: Occupant; home: string; door: AgentDoor };
37
+
38
+ /**
39
+ * The invariants every provider holds, run against `setup`'s provider with `sh` for an agent: the
40
+ * same paths inside, exactly the environment it sets, a door per agent, and nothing left running
41
+ * after a killed turn, `release` or `close`. With `confines`, also reach and the boundary between
42
+ * sandboxes. `setup` is `undefined` where the provider is not installed, and the suite is skipped.
43
+ */
44
+ export function sandboxConformance(name: string, setup: ConformanceSetup | undefined): void {
45
+ const suite = setup ? describe : describe.skip;
46
+ suite(`${name} sandbox conformance`, () => {
47
+ if (!setup) return;
48
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "wf-conformance-")));
49
+ const runRoot = join(root, "runs");
50
+ const cwd = join(root, "work");
51
+ const outside = join(root, "outside");
52
+ const shared = join(root, "shared");
53
+ // A project keeping its runs inside it, as `.awf/runs`: a sandbox writing it holds them.
54
+ const project = join(root, "project");
55
+ const projectRuns = join(project, ".awf", "runs");
56
+ const servers: Server[] = [];
57
+ const doors: string[] = [];
58
+ const sandboxes: OpenedSandbox[] = [];
59
+ const agents: Record<"a1" | "a2" | "a3" | "b1" | "p1", Agent> = {} as never;
60
+ const running = (pattern: string, agent: Agent) =>
61
+ setup.running ? setup.running(pattern, agent.occupant) : hostRunning(pattern);
62
+ const sh = (agent: Agent, script: string, extra: { timeoutMs?: number; cwd?: string } = {}) =>
63
+ runCommand(
64
+ agent.occupant.launch({
65
+ argv: ["/bin/sh", "-c", script],
66
+ cwd: extra.cwd ?? cwd,
67
+ env: { AWF_CONFORMANCE_OVERLAY: "overlay" },
68
+ timeoutMs: extra.timeoutMs ?? 30_000,
69
+ }),
70
+ );
71
+
72
+ beforeAll(async () => {
73
+ await mkdir(join(cwd, "out"), { recursive: true });
74
+ await writeFile(join(cwd, "in.txt"), "readable\n");
75
+ await mkdir(outside, { recursive: true });
76
+ await mkdir(shared, { recursive: true });
77
+ await writeFile(join(shared, "notes.txt"), "shared notes\n");
78
+ await writeFile(join(outside, "canary.txt"), "canary-outside\n");
79
+ await mkdir(join(runRoot, "other-run"), { recursive: true });
80
+ await writeFile(join(runRoot, "other-run", "canary.txt"), "canary-other-run\n");
81
+ await mkdir(join(projectRuns, "other-run"), { recursive: true });
82
+ await writeFile(join(projectRuns, "other-run", "canary.txt"), "canary-project-run\n");
83
+ const open = async (
84
+ key: string,
85
+ reach: { cwd: string; runRoot: string; read: string[]; write: string[] } = {
86
+ cwd,
87
+ runRoot,
88
+ read: [shared],
89
+ write: ["out"],
90
+ },
91
+ ) => {
92
+ const environment = setup.environment;
93
+ const { sandbox } = await resolveSandbox(
94
+ {
95
+ read: reach.read,
96
+ write: reach.write,
97
+ ...(environment ? { [environment.key]: environment.settings } : {}),
98
+ },
99
+ {
100
+ key,
101
+ cwd: reach.cwd,
102
+ runRoot: reach.runRoot,
103
+ machineRoot: join(homedir(), ".awf"),
104
+ harnessState: [],
105
+ providers: {
106
+ installed: { [environment?.key ?? "srt"]: setup.provider },
107
+ default: environment?.key ?? "srt",
108
+ },
109
+ },
110
+ );
111
+ // Outside every run root, as the engine keeps them under `~/.awf/sandboxes`.
112
+ const directory = join(root, "sandboxes", key);
113
+ await mkdir(join(directory, "homes"), { recursive: true });
114
+ const opened = await setup.provider.open(sandbox, {
115
+ runRoot: reach.runRoot,
116
+ directory,
117
+ deadline: { unixMilliseconds: Date.now() + 120_000 },
118
+ });
119
+ sandboxes.push(opened);
120
+ return { opened, directory };
121
+ };
122
+ const admit = async (
123
+ box: { opened: OpenedSandbox; directory: string },
124
+ name: string,
125
+ at = cwd,
126
+ ): Promise<Agent> => {
127
+ const home = join(box.directory, "homes", name);
128
+ await mkdir(home, { recursive: true });
129
+ const door = await openDoor(servers, doors);
130
+ const occupant = await box.opened.admit({
131
+ cwd: at,
132
+ home,
133
+ door,
134
+ harness: {
135
+ env: {},
136
+ seed: [],
137
+ secrets: {},
138
+ defaults: () => [],
139
+ domains: [],
140
+ command: "/bin/sh",
141
+ executable: "/bin/sh",
142
+ reads: [],
143
+ ...setup.needs,
144
+ },
145
+ });
146
+ return { occupant, home, door };
147
+ };
148
+ const a = await open("a");
149
+ const b = await open("b");
150
+ agents.a1 = await admit(a, "a1");
151
+ agents.a2 = await admit(a, "a2");
152
+ agents.a3 = await admit(a, "a3");
153
+ agents.b1 = await admit(b, "b1");
154
+ const p = await open("p", { cwd: project, runRoot: projectRuns, read: [], write: ["."] });
155
+ agents.p1 = await admit(p, "p1", project);
156
+ }, TEST_TIMEOUT_MS);
157
+
158
+ afterAll(async () => {
159
+ await Promise.allSettled(sandboxes.map((sandbox) => sandbox.close()));
160
+ for (const server of servers) server.close();
161
+ await Promise.all([root, ...doors].map((path) => rm(path, { recursive: true, force: true })));
162
+ });
163
+
164
+ test(
165
+ "runs at the same paths, with only the environment it sets",
166
+ async () => {
167
+ process.env.AWF_CONFORMANCE_LEAK = "leaked";
168
+ const agent = agents.a1;
169
+ try {
170
+ const result = await sh(
171
+ agent,
172
+ `pwd; echo "leak=$AWF_CONFORMANCE_LEAK overlay=$AWF_CONFORMANCE_OVERLAY"; cat in.txt; ` +
173
+ `cat "${shared}/notes.txt"; ` +
174
+ `echo home > "${agent.home}/written" && echo out > out/written`,
175
+ );
176
+ expect(result.stdout.split("\n").slice(0, 4)).toEqual([
177
+ cwd,
178
+ "leak= overlay=overlay",
179
+ "readable",
180
+ "shared notes",
181
+ ]);
182
+ expect(result.exitCode).toBe(0);
183
+ } finally {
184
+ delete process.env.AWF_CONFORMANCE_LEAK;
185
+ }
186
+ expect(await readFile(join(agent.home, "written"), "utf8")).toBe("home\n");
187
+ expect(await readFile(join(cwd, "out", "written"), "utf8")).toBe("out\n");
188
+ },
189
+ TEST_TIMEOUT_MS,
190
+ );
191
+
192
+ test(
193
+ "each agent's launcher reaches its own door",
194
+ async () => {
195
+ for (const agent of [agents.a1, agents.a2, agents.b1]) {
196
+ const result = await sh(agent, `"${agent.door.launcher}" hello`);
197
+ expect(result.stdout).toBe(`pong ${agent.door.endpoint} hello`);
198
+ }
199
+ },
200
+ TEST_TIMEOUT_MS,
201
+ );
202
+
203
+ test(
204
+ "a killed turn leaves nothing running",
205
+ async () => {
206
+ const seconds = marker();
207
+ const result = await sh(agents.a1, `sleep ${seconds} & sleep ${seconds}`, {
208
+ timeoutMs: 1_000,
209
+ });
210
+ expect(result.timedOut).toBe(true);
211
+ expect(await running(`sleep ${seconds}`, agents.a1)).toBe(0);
212
+ },
213
+ TEST_TIMEOUT_MS,
214
+ );
215
+
216
+ test(
217
+ "a cancelled turn leaves nothing running",
218
+ async () => {
219
+ const seconds = marker();
220
+ const controller = new AbortController();
221
+ const turn = runCommand(
222
+ agents.a2.occupant.launch({
223
+ argv: ["/bin/sh", "-c", `sleep ${seconds} & sleep ${seconds}`],
224
+ cwd,
225
+ timeoutMs: 30_000,
226
+ signal: controller.signal,
227
+ }),
228
+ );
229
+ await until(async () => (await running(`sleep ${seconds}`, agents.a2)) > 0);
230
+ controller.abort();
231
+ await turn;
232
+ expect(await running(`sleep ${seconds}`, agents.a2)).toBe(0);
233
+ },
234
+ TEST_TIMEOUT_MS,
235
+ );
236
+
237
+ test(
238
+ "release ends the agent's processes",
239
+ async () => {
240
+ const seconds = marker();
241
+ const turn = sh(agents.a3, `sleep ${seconds} & wait`);
242
+ await until(async () => (await running(`sleep ${seconds}`, agents.a3)) > 0);
243
+ await agents.a3.occupant.release();
244
+ await turn;
245
+ // Counted by a co-tenant: a released agent launches nothing.
246
+ expect(await running(`sleep ${seconds}`, agents.a1)).toBe(0);
247
+ },
248
+ TEST_TIMEOUT_MS,
249
+ );
250
+
251
+ test.skipIf(!setup.confines)(
252
+ "denies what lies outside reach",
253
+ async () => {
254
+ const result = await sh(
255
+ agents.a1,
256
+ `cat "${outside}/canary.txt" || echo denied-outside; ` +
257
+ `cat "${runRoot}/other-run/canary.txt" || echo denied-run; ` +
258
+ `echo x > in.txt || echo denied-write`,
259
+ );
260
+ expect(result.stdout).not.toContain("canary-outside");
261
+ expect(result.stdout).not.toContain("canary-other-run");
262
+ expect(result.stdout).toContain("denied-outside");
263
+ expect(result.stdout).toContain("denied-run");
264
+ expect(result.stdout).toContain("denied-write");
265
+ expect(await readFile(join(cwd, "in.txt"), "utf8")).toBe("readable\n");
266
+ },
267
+ TEST_TIMEOUT_MS,
268
+ );
269
+
270
+ test(
271
+ "hides the run root a writable project holds",
272
+ async () => {
273
+ const result = await sh(
274
+ agents.p1,
275
+ `echo written > written; ` +
276
+ `cat "${projectRuns}/other-run/canary.txt" || echo denied-read; ` +
277
+ `echo forged > "${projectRuns}/other-run/canary.txt" || echo denied-write; ` +
278
+ `echo planted > "${projectRuns}/planted" || echo denied-plant`,
279
+ { cwd: project },
280
+ );
281
+ expect(await readFile(join(project, "written"), "utf8")).toBe("written\n");
282
+ if (!setup.confines) return;
283
+ expect(result.stdout).not.toContain("canary-project-run");
284
+ expect(result.stdout).toContain("denied-read");
285
+ // Inside, docker's empty tmpfs may take the plant; the host's run root never sees it.
286
+ expect(await readFile(join(projectRuns, "other-run", "canary.txt"), "utf8")).toBe(
287
+ "canary-project-run\n",
288
+ );
289
+ expect(await stat(join(projectRuns, "planted")).catch(() => undefined)).toBeUndefined();
290
+ },
291
+ TEST_TIMEOUT_MS,
292
+ );
293
+
294
+ test.skipIf(!setup.confines)(
295
+ "trust stops at the sandbox's boundary",
296
+ async () => {
297
+ const { a1, a2, b1 } = agents;
298
+ await writeFile(join(a1.home, "secret.txt"), "a1 only\n");
299
+ // b1 runs its own bundle, which it may, at a1's socket, which it may not reach.
300
+ const [bun, bundle] = b1.door.reads;
301
+ const across = await sh(
302
+ b1,
303
+ `cat "${a1.home}/secret.txt" || echo denied-home; ` +
304
+ `"${a1.door.launcher}" hello || echo denied-door; ` +
305
+ `"${bun}" "${bundle}" --at "${a1.door.endpoint}" hello || echo denied-socket`,
306
+ );
307
+ expect(across.stdout).not.toContain("a1 only");
308
+ expect(across.stdout).not.toContain("pong");
309
+ expect(across.stdout).toContain("denied-home");
310
+ expect(across.stdout).toContain("denied-door");
311
+ expect(across.stdout).toContain("denied-socket");
312
+ const own = await sh(a2, `echo forged > "${a2.door.launcher}" || echo denied-launcher`);
313
+ expect(own.stdout).toContain("denied-launcher");
314
+ },
315
+ TEST_TIMEOUT_MS,
316
+ );
317
+
318
+ test(
319
+ "close ends everything and keeps the homes",
320
+ async () => {
321
+ const seconds = marker();
322
+ const turn = sh(agents.a2, `sleep ${seconds} & wait`);
323
+ await until(async () => (await running(`sleep ${seconds}`, agents.a2)) > 0);
324
+ const pattern = `sleep ${seconds}`;
325
+ await Promise.all(sandboxes.splice(0).map((sandbox) => sandbox.close()));
326
+ await turn;
327
+ // Only the host is left to ask: a provider's own place to look may have gone with it, so a
328
+ // provider whose processes the host cannot see (docker's) proves this in its own tests.
329
+ expect(await hostRunning(pattern)).toBe(0);
330
+ expect((await stat(agents.a1.home)).isDirectory()).toBe(true);
331
+ },
332
+ TEST_TIMEOUT_MS,
333
+ );
334
+ });
335
+ }
336
+
337
+ /** The door the engine would give an agent, answering `pong {endpoint} {args}`. */
338
+ async function openDoor(servers: Server[], directories: string[]): Promise<AgentDoor> {
339
+ // Under `/tmp`, as the control plane's are, to stay inside a socket path's length.
340
+ const directory = await realpath(await mkdtemp("/tmp/wf-door-"));
341
+ directories.push(directory);
342
+ const endpoint = join(directory, "s.sock");
343
+ const bundle = join(directory, "door.js");
344
+ const launcher = join(directory, "wf");
345
+ const server = createServer({ allowHalfOpen: true }, (socket) => {
346
+ let said = "";
347
+ socket.on("data", (data) => {
348
+ said += data;
349
+ });
350
+ socket.on("end", () => socket.end(`pong ${endpoint} ${said}`));
351
+ socket.on("error", () => undefined);
352
+ });
353
+ await new Promise<void>((resolve) => server.listen(endpoint, resolve));
354
+ servers.push(server);
355
+ await writeFile(
356
+ bundle,
357
+ `const net = require("node:net");
358
+ const at = process.argv.indexOf("--at");
359
+ const client = net.createConnection(process.argv[at + 1], () => client.end(process.argv.slice(at + 2).join(" ")));
360
+ client.on("data", (data) => process.stdout.write(data));
361
+ client.on("error", (error) => { console.error(error.message); process.exit(2); });
362
+ `,
363
+ );
364
+ const bun = await realpath(process.execPath);
365
+ await writeFile(launcher, `#!/bin/sh\nexec '${bun}' '${bundle}' --at '${endpoint}' "$@"\n`);
366
+ await chmod(launcher, 0o500);
367
+ return {
368
+ endpoint,
369
+ launcher,
370
+ boxScript: `#!/bin/sh\nexec bun '${bundle}' --at '${endpoint}' "$@"\n`,
371
+ bundle,
372
+ reads: [bun, bundle],
373
+ };
374
+ }
375
+
376
+ /**
377
+ * `runProcess`'s contract for a sandboxed command, which the seam cannot import from harness: its
378
+ * group killed however it ends, output drained for a moment after, and `reap` held to its grace.
379
+ */
380
+ export async function runCommand(
381
+ command: SandboxedCommand,
382
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
383
+ const child = Bun.spawn({
384
+ cmd: [...command.argv],
385
+ cwd: command.cwd,
386
+ env: { ...command.env },
387
+ detached: true,
388
+ stdin: command.stdin === undefined ? "ignore" : new TextEncoder().encode(command.stdin),
389
+ stdout: "pipe",
390
+ stderr: "pipe",
391
+ });
392
+ const kill = () => {
393
+ try {
394
+ process.kill(-child.pid, "SIGKILL");
395
+ } catch {}
396
+ };
397
+ let timedOut = false;
398
+ const timer = setTimeout(() => {
399
+ timedOut = true;
400
+ kill();
401
+ }, command.timeoutMs);
402
+ command.signal?.addEventListener("abort", kill, { once: true });
403
+ const output = Promise.all([
404
+ new Response(child.stdout).text(),
405
+ new Response(child.stderr).text(),
406
+ ]);
407
+ const exitCode = await child.exited;
408
+ kill();
409
+ clearTimeout(timer);
410
+ const [stdout, stderr] = (await Promise.race([output, Bun.sleep(1_000)])) ?? ["", ""];
411
+ if (command.reap) {
412
+ const started = Date.now();
413
+ await command.reap();
414
+ const took = Date.now() - started;
415
+ if (took > REAP_GRACE_MS)
416
+ throw new Error(`reap took ${took}ms, past its ${REAP_GRACE_MS}ms grace`);
417
+ }
418
+ return { stdout, stderr, exitCode, timedOut };
419
+ }
420
+
421
+ async function hostRunning(pattern: string): Promise<number> {
422
+ const listed = Bun.spawnSync(["pgrep", "-f", pattern]).stdout.toString().trim();
423
+ return listed === "" ? 0 : listed.split("\n").length;
424
+ }
425
+
426
+ async function until(condition: () => Promise<boolean>): Promise<void> {
427
+ for (let tries = 0; tries < 300; tries++) {
428
+ if (await condition()) return;
429
+ await Bun.sleep(50);
430
+ }
431
+ throw new Error("condition never held");
432
+ }
433
+
434
+ /** A sleep that ends on its own within the hour if a test leaks it; its fraction tells it apart. */
435
+ function marker(): string {
436
+ return `3599.${Math.floor(10_000 + Math.random() * 89_999)}`;
437
+ }
@@ -0,0 +1,134 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { LaunchedGroups } from "../groups";
5
+ import type {
6
+ AgentContext,
7
+ AgentDoor,
8
+ OpenedSandbox,
9
+ PaneHerdr,
10
+ ResolvedSandbox,
11
+ SandboxContext,
12
+ SandboxedCommand,
13
+ SandboxProvider,
14
+ } from "../seam";
15
+
16
+ export type FakeSandboxEvent =
17
+ | { kind: "open"; sandbox: string; runRoot: string }
18
+ | { kind: "admit"; sandbox: string; home: string; door: AgentDoor }
19
+ | { kind: "release"; sandbox: string; home: string }
20
+ | { kind: "launch" | "reap"; sandbox: string; home: string; argv: readonly string[] }
21
+ | { kind: "close"; sandbox: string };
22
+
23
+ export type FakeSandboxOptions = {
24
+ /** A reason to refuse this open, as a provider that cannot hold a spec does. */
25
+ refuseOpen?(spec: ResolvedSandbox<unknown>): string | undefined;
26
+ /** A reason to refuse this agent. */
27
+ refuseAdmit?(agent: AgentContext): string | undefined;
28
+ /** A reason every reap fails with, as a box gone from under it would. */
29
+ failReap?: string;
30
+ /** What the provider reports for the run's record. */
31
+ record?: OpenedSandbox["record"];
32
+ /** It hosts panes behind this prelude, each in the run's own Herdr unless it names another. */
33
+ panes?: { prelude: string; ready: string; herdr?: PaneHerdr };
34
+ };
35
+
36
+ /** The markers every launched process carries: its sandbox's key, and its agent's home. */
37
+ export const FAKE_SANDBOX_ENV = "AWF_FAKE_SANDBOX";
38
+ export const FAKE_OCCUPANT_ENV = "AWF_FAKE_OCCUPANT";
39
+
40
+ /**
41
+ * A provider that confines nothing, so engine and adapter tests can see what went through the
42
+ * seam: every call is an event, and every process it launches carries `AWF_FAKE_SANDBOX`. Its
43
+ * processes are real, run as a group from a pid file the way srt's are, so `reap`, `release`
44
+ * and `close` really end them.
45
+ */
46
+ export function createFakeSandboxProvider(options: FakeSandboxOptions = {}): {
47
+ provider: SandboxProvider<Record<string, unknown>>;
48
+ events: FakeSandboxEvent[];
49
+ } {
50
+ const events: FakeSandboxEvent[] = [];
51
+ const provider: SandboxProvider<Record<string, unknown>> = {
52
+ environment(raw) {
53
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
54
+ throw new Error("fake sandbox settings must be an object");
55
+ }
56
+ return raw as Record<string, unknown>;
57
+ },
58
+ async open(spec, context) {
59
+ const refused = options.refuseOpen?.(spec);
60
+ if (refused) throw new Error(refused);
61
+ events.push({ kind: "open", sandbox: spec.key, runRoot: context.runRoot });
62
+ return openFake(spec, context, options, events);
63
+ },
64
+ };
65
+ return { provider, events };
66
+ }
67
+
68
+ function openFake(
69
+ spec: ResolvedSandbox<unknown>,
70
+ context: SandboxContext,
71
+ options: FakeSandboxOptions,
72
+ events: FakeSandboxEvent[],
73
+ ): OpenedSandbox {
74
+ const pids = join(context.directory, "pids");
75
+ const agents: LaunchedGroups[] = [];
76
+ const { panes } = options;
77
+ return {
78
+ ...(options.record ? { record: options.record } : {}),
79
+ ...(panes ? { panes: true as const } : {}),
80
+ async admit(agent) {
81
+ const refused = options.refuseAdmit?.(agent);
82
+ if (refused) throw new Error(refused);
83
+ const { home } = agent;
84
+ events.push({ kind: "admit", sandbox: spec.key, home, door: agent.door });
85
+ const groups = new LaunchedGroups(join(pids, randomUUID()));
86
+ agents.push(groups);
87
+ return {
88
+ launch(root): SandboxedCommand {
89
+ const argv = [...root.argv];
90
+ events.push({ kind: "launch", sandbox: spec.key, home, argv });
91
+ const wrapped = groups.wrap(argv);
92
+ return {
93
+ ...root,
94
+ argv: wrapped.argv,
95
+ env: {
96
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
97
+ HOME: home,
98
+ ...agent.harness.env,
99
+ ...agent.harness.secrets,
100
+ ...root.env,
101
+ [FAKE_SANDBOX_ENV]: spec.key,
102
+ [FAKE_OCCUPANT_ENV]: home,
103
+ },
104
+ group: true,
105
+ async reap() {
106
+ events.push({ kind: "reap", sandbox: spec.key, home, argv });
107
+ await groups.kill(wrapped.pidFile);
108
+ if (options.failReap) throw new Error(options.failReap);
109
+ },
110
+ };
111
+ },
112
+ async release() {
113
+ events.push({ kind: "release", sandbox: spec.key, home });
114
+ await groups.killAll();
115
+ },
116
+ ...(panes
117
+ ? {
118
+ pane: async () => ({
119
+ herdr: panes.herdr ?? ("run" as const),
120
+ prelude: panes.prelude,
121
+ ready: panes.ready,
122
+ harness: agent.harness.command,
123
+ }),
124
+ }
125
+ : {}),
126
+ };
127
+ },
128
+ async close() {
129
+ events.push({ kind: "close", sandbox: spec.key });
130
+ await Promise.all(agents.map((groups) => groups.killAll()));
131
+ await rm(pids, { recursive: true, force: true });
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./conformance";
2
+ export * from "./fake";
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.