@agentswf/engine 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.
- package/package.json +39 -4
- package/src/accounting/format.ts +252 -0
- package/src/accounting/plans.ts +38 -0
- package/src/accounting/prices.ts +98 -0
- package/src/accounting/summary.ts +385 -0
- package/src/accounting/tokens.ts +34 -0
- package/src/agent-launcher.ts +127 -0
- package/src/allowance-command.ts +105 -0
- package/src/attempt-ending.ts +198 -0
- package/src/attempt-output.ts +132 -0
- package/src/attempt-view.ts +362 -0
- package/src/canonical-json.ts +12 -0
- package/src/control-plane.ts +424 -0
- package/src/deadlines.ts +106 -0
- package/src/decisions/directory.ts +432 -0
- package/src/decisions/fake.ts +60 -0
- package/src/decisions/openrouter.ts +161 -0
- package/src/decisions/seam.ts +70 -0
- package/src/display-path.ts +16 -0
- package/src/duration.ts +12 -0
- package/src/errors.ts +4 -0
- package/src/files.ts +78 -0
- package/src/herdr-placement.ts +182 -0
- package/src/herdr-run-session.ts +530 -0
- package/src/herdr-workspace-marks.ts +194 -0
- package/src/here.ts +289 -0
- package/src/index.ts +5 -0
- package/src/init-command.ts +240 -0
- package/src/init-template/AGENTS.md.template +16 -0
- package/src/init-template/hello/workflow.test.ts.template +17 -0
- package/src/init-template/hello/workflow.ts.template +26 -0
- package/src/init-template/tsconfig.json.template +11 -0
- package/src/jsonl.ts +74 -0
- package/src/logs-command.ts +605 -0
- package/src/machine.ts +27 -0
- package/src/message-operation.ts +245 -0
- package/src/messaging/limits.ts +35 -0
- package/src/messaging/messaging.ts +1303 -0
- package/src/messaging/routes.ts +79 -0
- package/src/messaging/text.ts +251 -0
- package/src/operation-events.ts +182 -0
- package/src/operation-liveness.ts +674 -0
- package/src/operator-aliases.ts +10 -0
- package/src/operator-cli.ts +600 -0
- package/src/operator-runtime.ts +503 -0
- package/src/pane-layout.ts +125 -0
- package/src/progress-view.ts +568 -0
- package/src/result-slots.ts +325 -0
- package/src/result-validation.ts +47 -0
- package/src/run-command.ts +242 -0
- package/src/run-commands.ts +215 -0
- package/src/run-event-log.ts +72 -0
- package/src/run-prepare.ts +178 -0
- package/src/run-progress.ts +137 -0
- package/src/run-read.ts +425 -0
- package/src/run-state.ts +120 -0
- package/src/run-usage.ts +393 -0
- package/src/run-view.ts +225 -0
- package/src/runs-command.ts +356 -0
- package/src/runs.ts +645 -0
- package/src/sandbox-homes.ts +211 -0
- package/src/sandboxes.ts +521 -0
- package/src/skills/fetch.ts +305 -0
- package/src/skills/run-skills.ts +181 -0
- package/src/skills/sources.ts +87 -0
- package/src/skills/tree.ts +60 -0
- package/src/stage-ledger.ts +535 -0
- package/src/stage-plan.ts +127 -0
- package/src/stage-schema.ts +88 -0
- package/src/stopped.ts +36 -0
- package/src/test-command.ts +110 -0
- package/src/testing/operation-events.ts +89 -0
- package/src/testing.ts +164 -0
- package/src/workflow-loader.ts +113 -0
- package/src/workflow-runner.ts +3666 -0
- package/src/workflow-test-preload.ts +13 -0
- package/src/workflow-testing/decisions.ts +129 -0
- package/src/workflow-testing/host.ts +811 -0
- package/src/workflow-testing/index.ts +420 -0
- package/src/workflow-testing/script.ts +447 -0
- package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
- package/README.md +0 -3
package/src/sandboxes.ts
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, relative } from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import type { SandboxRecord } from "@agentswf/contract/records";
|
|
7
|
+
import type {
|
|
8
|
+
AbsoluteDeadline,
|
|
9
|
+
AgentExecution,
|
|
10
|
+
SandboxEnvironmentKey,
|
|
11
|
+
SandboxRef,
|
|
12
|
+
} from "@agentswf/contract/workflow";
|
|
13
|
+
import { type AgentSkills, harnessState, sandboxNeeds, skillsLayout } from "@agentswf/harness";
|
|
14
|
+
import {
|
|
15
|
+
type AgentDoor,
|
|
16
|
+
type HarnessSandboxNeeds,
|
|
17
|
+
type Occupant,
|
|
18
|
+
type OpenedSandbox,
|
|
19
|
+
type ResolvedSandbox,
|
|
20
|
+
repositoryOf,
|
|
21
|
+
resolveSandbox,
|
|
22
|
+
type SandboxProviders,
|
|
23
|
+
withinReach,
|
|
24
|
+
} from "@agentswf/sandbox";
|
|
25
|
+
import { CONTROL_PLANE_ROOT } from "./control-plane";
|
|
26
|
+
import { messageOf } from "./errors";
|
|
27
|
+
import { machinePaths } from "./machine";
|
|
28
|
+
import { type CredentialLocks, placeCarried, type SeededHome, seedHome } from "./sandbox-homes";
|
|
29
|
+
import { placeSkills, type ResolvedSkill } from "./skills/run-skills";
|
|
30
|
+
|
|
31
|
+
/** The providers a run may open sandboxes with, and where their directories are made. */
|
|
32
|
+
export type RunSandboxOptions = {
|
|
33
|
+
providers: SandboxProviders;
|
|
34
|
+
/**
|
|
35
|
+
* Where each sandbox's own directory, its homes and quarantine, is made. Outside the run root, so
|
|
36
|
+
* a provider that denies it hides none of a sandbox's own.
|
|
37
|
+
*/
|
|
38
|
+
sandboxesDir: string;
|
|
39
|
+
/** The operator's `~/.awf`, which no sandbox reaches. */
|
|
40
|
+
machineRoot: string;
|
|
41
|
+
/**
|
|
42
|
+
* The operator's environment, which says where each harness keeps its state, the `PATH` an
|
|
43
|
+
* agent's harness is found on and claude's token. Defaults to the engine's.
|
|
44
|
+
*/
|
|
45
|
+
environment?: Readonly<Record<string, string | undefined>>;
|
|
46
|
+
/**
|
|
47
|
+
* The operator's sandbox for the whole run (`awf run --sandbox`): every agent runs in it, at the
|
|
48
|
+
* run's working directory, and the workflow opens none of its own. Unresolved, as a file gave it.
|
|
49
|
+
*/
|
|
50
|
+
run?: unknown;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Why a workflow's own sandbox is refused in a run the operator gave one. */
|
|
54
|
+
export const RUN_SANDBOX_ONLY =
|
|
55
|
+
"every agent in this run runs in the sandbox awf run --sandbox gave it; a workflow cannot open its own";
|
|
56
|
+
|
|
57
|
+
/** Where one agent will run in a sandbox, settled before anything is opened for it. */
|
|
58
|
+
export type Seat = {
|
|
59
|
+
/** The agent's working directory, by its real path, which is the one every provider shares. */
|
|
60
|
+
cwd: string;
|
|
61
|
+
home: string;
|
|
62
|
+
/** Its skills, copied into its home as it is seeded; absent when the workflow named none. */
|
|
63
|
+
skills?: AgentSkills;
|
|
64
|
+
/**
|
|
65
|
+
* Admits the agent through `door`, once its home is seeded, with `carry`'s files where a fork's
|
|
66
|
+
* session comes from another home; serialized per sandbox.
|
|
67
|
+
*/
|
|
68
|
+
admit(door: AgentDoor, carry?: string): Promise<SeatedAgent>;
|
|
69
|
+
/**
|
|
70
|
+
* Undoes a seat whose agent never opened: its admission is released, and a private sandbox
|
|
71
|
+
* closes, so nothing outlives the failure. Never rejects: a failure is logged.
|
|
72
|
+
*/
|
|
73
|
+
abandon(): Promise<void>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type SeatedAgent = {
|
|
77
|
+
occupant: Occupant;
|
|
78
|
+
/** Its sandbox keeps the harness's model-side web tools. */
|
|
79
|
+
webSearch?: true;
|
|
80
|
+
/** Hands a refreshed credential back to the operator; after each operation and at release. */
|
|
81
|
+
writeBack(): Promise<void>;
|
|
82
|
+
/** Writes back, then releases the occupant; once, however often it is called. */
|
|
83
|
+
release(): Promise<void>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type RunSandbox = {
|
|
87
|
+
key: string;
|
|
88
|
+
provider: SandboxEnvironmentKey;
|
|
89
|
+
resolved: ResolvedSandbox<unknown>;
|
|
90
|
+
directory: string;
|
|
91
|
+
opened: OpenedSandbox;
|
|
92
|
+
/** The last admission in line; the next one waits for it. */
|
|
93
|
+
admitting: Promise<unknown>;
|
|
94
|
+
agents: { agent: string; home: string; domains: readonly string[] }[];
|
|
95
|
+
/** Its close, once started; a private sandbox's starts when its agent fails to open. */
|
|
96
|
+
closing?: Promise<void>;
|
|
97
|
+
/** Whether the operator was told how to watch its panes, which it says at its first. */
|
|
98
|
+
watched?: true;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One run's sandboxes: the ones the workflow opens, and the private ones inline specs make. The
|
|
103
|
+
* engine keeps them here, apart from any provider: it resolves a spec, opens it through the
|
|
104
|
+
* provider its environment names, admits agents one at a time, and closes each after its agents.
|
|
105
|
+
*/
|
|
106
|
+
export class RunSandboxes {
|
|
107
|
+
/** Each key taken, by an open landed or in flight; a failed open frees its key. */
|
|
108
|
+
readonly #keys = new Map<string, Promise<RunSandbox>>();
|
|
109
|
+
/** Every sandbox that opened, in order, closed early or not: what the record lists. */
|
|
110
|
+
readonly #opened: RunSandbox[] = [];
|
|
111
|
+
readonly #refs = new WeakMap<object, RunSandbox>();
|
|
112
|
+
/** Every admitted agent, released after its session closes and before its channel does. */
|
|
113
|
+
readonly #held: SeatedAgent[] = [];
|
|
114
|
+
readonly #providers: SandboxProviders;
|
|
115
|
+
readonly #environment: Readonly<Record<string, string | undefined>>;
|
|
116
|
+
#runRoot: Promise<string> | undefined;
|
|
117
|
+
/** The operator's sandbox, opened once before the workflow runs. */
|
|
118
|
+
#runSandbox: Promise<RunSandbox> | undefined;
|
|
119
|
+
#closed = false;
|
|
120
|
+
|
|
121
|
+
constructor(
|
|
122
|
+
private readonly options: {
|
|
123
|
+
sandboxes?: RunSandboxOptions;
|
|
124
|
+
runRoot: string;
|
|
125
|
+
cwd: string;
|
|
126
|
+
deadline: AbsoluteDeadline;
|
|
127
|
+
/** The run's credential write-backs, which a host agent's own home shares. */
|
|
128
|
+
locks: CredentialLocks;
|
|
129
|
+
log(message: string): void;
|
|
130
|
+
},
|
|
131
|
+
) {
|
|
132
|
+
this.#providers = options.sandboxes?.providers ?? { installed: {} };
|
|
133
|
+
this.#environment = options.sandboxes?.environment ?? process.env;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether the operator gave the run a sandbox, which every agent runs in. */
|
|
137
|
+
get hasRunSandbox(): boolean {
|
|
138
|
+
return this.options.sandboxes?.run !== undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `workflow.sandboxes.open`. */
|
|
142
|
+
async open(spec: unknown): Promise<SandboxRef> {
|
|
143
|
+
if (this.hasRunSandbox) throw new Error(RUN_SANDBOX_ONLY);
|
|
144
|
+
const key = (spec as { key?: unknown } | null)?.key;
|
|
145
|
+
if (typeof key !== "string" || key === "") throw new Error("a sandbox needs a key");
|
|
146
|
+
if (key.startsWith("agent:")) {
|
|
147
|
+
throw new Error(`sandbox key ${key}: agent: names the sandboxes agents are given`);
|
|
148
|
+
}
|
|
149
|
+
const sandbox = await this.#register(key, () => this.#open(key, spec, false));
|
|
150
|
+
const ref = Object.freeze({ key: sandbox.key, provider: sandbox.provider });
|
|
151
|
+
this.#refs.set(ref, sandbox);
|
|
152
|
+
return ref as unknown as SandboxRef;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Where `agent` runs, given its spec's `sandbox`: a ref this run returned, or an inline spec for
|
|
157
|
+
* a private sandbox, opened now. Rejects before anything is opened for the agent itself: an
|
|
158
|
+
* unknown ref, an agent outside the sandbox's reach, a harness no sandbox can run.
|
|
159
|
+
*/
|
|
160
|
+
async seat(agent: {
|
|
161
|
+
key: string;
|
|
162
|
+
sandbox: unknown;
|
|
163
|
+
cwd: string;
|
|
164
|
+
execution: AgentExecution;
|
|
165
|
+
skills?: readonly ResolvedSkill[];
|
|
166
|
+
/** A fork's parent, whose sandbox it shares, a private one too. */
|
|
167
|
+
joins?: string;
|
|
168
|
+
}): Promise<Seat> {
|
|
169
|
+
const shared = this.hasRunSandbox
|
|
170
|
+
? await this.#joinRunSandbox()
|
|
171
|
+
: (this.#shared(agent.sandbox) ??
|
|
172
|
+
(agent.joins === undefined ? undefined : await this.#parents(agent.joins)));
|
|
173
|
+
const pane = agent.execution.placement !== "headless";
|
|
174
|
+
const key = `agent:${agent.key}`;
|
|
175
|
+
const sandbox =
|
|
176
|
+
shared ?? (await this.#register(key, () => this.#open(key, agent.sandbox, true, agent.cwd)));
|
|
177
|
+
let seated: Promise<SeatedAgent> | undefined;
|
|
178
|
+
const abandon = async () => {
|
|
179
|
+
await seated?.then((agent) => agent.release()).catch(() => undefined);
|
|
180
|
+
if (shared) return;
|
|
181
|
+
await this.#close(sandbox).catch((error: unknown) =>
|
|
182
|
+
this.options.log(`sandbox ${key}: ${messageOf(error)}`),
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
if (pane && !sandbox.opened.panes) {
|
|
187
|
+
throw new Error(`agent ${agent.key}: sandbox ${sandbox.key} hosts no panes`);
|
|
188
|
+
}
|
|
189
|
+
const cwd = await realpath(agent.cwd);
|
|
190
|
+
assertWithin(sandbox, agent.key, cwd, await repositoryOf(cwd, await realpath(homedir())));
|
|
191
|
+
const id = randomUUID();
|
|
192
|
+
const home = join(sandbox.directory, "homes", id);
|
|
193
|
+
// Beside `homes/`, which every agent in the sandbox can write, and out of their reach.
|
|
194
|
+
const staging = join(sandbox.directory, "staging", id);
|
|
195
|
+
const needs = await sandboxNeeds(
|
|
196
|
+
agent.execution.harness,
|
|
197
|
+
home,
|
|
198
|
+
agent.execution.model,
|
|
199
|
+
this.#environment,
|
|
200
|
+
);
|
|
201
|
+
const given = agent.skills;
|
|
202
|
+
const skills = given
|
|
203
|
+
? skillsLayout(
|
|
204
|
+
agent.execution.harness,
|
|
205
|
+
given.map((skill) => skill.name),
|
|
206
|
+
{ sandboxHome: home },
|
|
207
|
+
)
|
|
208
|
+
: undefined;
|
|
209
|
+
// Into the staged home, before it moves where the sandbox's agents can write.
|
|
210
|
+
const populate = (carry?: string) => async (staged: string) => {
|
|
211
|
+
if (given && skills) {
|
|
212
|
+
await placeSkills(given, join(staged, relative(home, skills.directory)));
|
|
213
|
+
}
|
|
214
|
+
if (carry) await placeCarried(carry, staged);
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
cwd,
|
|
218
|
+
home,
|
|
219
|
+
...(skills ? { skills } : {}),
|
|
220
|
+
abandon,
|
|
221
|
+
admit: (door, carry) => {
|
|
222
|
+
seated = this.#admit(
|
|
223
|
+
sandbox,
|
|
224
|
+
agent.key,
|
|
225
|
+
cwd,
|
|
226
|
+
{ home, staging },
|
|
227
|
+
needs,
|
|
228
|
+
door,
|
|
229
|
+
populate(carry),
|
|
230
|
+
);
|
|
231
|
+
return seated;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
} catch (error) {
|
|
235
|
+
await abandon();
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A fork's parent's private sandbox, which must still be open: a fork never opens its own. */
|
|
241
|
+
async #parents(parent: string): Promise<RunSandbox> {
|
|
242
|
+
const sandbox = await this.#keys.get(`agent:${parent}`)?.catch(() => undefined);
|
|
243
|
+
if (!sandbox || sandbox.closing) {
|
|
244
|
+
throw new Error(`agent ${parent}'s sandbox is closed, so its fork has nowhere to run`);
|
|
245
|
+
}
|
|
246
|
+
return sandbox;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Whether a reopened agent names the sandbox it runs in: the same ref, or an inline spec that
|
|
251
|
+
* resolves as its private one did. When its private sandbox never opened or was closed after a
|
|
252
|
+
* failure, the agent's own failure is the answer, so this says yes and lets it speak.
|
|
253
|
+
*/
|
|
254
|
+
async same(agentKey: string, first: unknown, next: unknown, cwd: string): Promise<boolean> {
|
|
255
|
+
if (first === undefined) return false;
|
|
256
|
+
if (this.#isRef(first) || this.#isRef(next)) return first === next;
|
|
257
|
+
const key = `agent:${agentKey}`;
|
|
258
|
+
// Still opening counts: the first open's spec is the one to compare against.
|
|
259
|
+
const current = await this.#keys.get(key)?.catch(() => undefined);
|
|
260
|
+
if (!current || current.closing) return true;
|
|
261
|
+
const { sandbox } = await resolveSandbox(next, await this.#resolveOptions(key, cwd, true));
|
|
262
|
+
// What the spec says, not the gitdirs found: an agent's own clone or worktree adds one.
|
|
263
|
+
const said = ({ gitdirs: _, ...rest }: ResolvedSandbox<unknown>) => rest;
|
|
264
|
+
return isDeepStrictEqual(said(sandbox), said(current.resolved));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Releases every seated agent: after the host closed their sessions, before their channels. The
|
|
269
|
+
* run is ending, so nothing opens or is admitted after it.
|
|
270
|
+
*/
|
|
271
|
+
async release(): Promise<unknown[]> {
|
|
272
|
+
this.#closed = true;
|
|
273
|
+
await Promise.allSettled(
|
|
274
|
+
[...this.#keys.values()].map((sandbox) => sandbox.then((s) => s.admitting)),
|
|
275
|
+
);
|
|
276
|
+
const settled = await Promise.allSettled(this.#held.map((agent) => agent.release()));
|
|
277
|
+
return settled.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Closes every sandbox, after its agents. One still opening is waited for, and closed as it
|
|
282
|
+
* lands, so the record lists it and nothing it made outlives the run.
|
|
283
|
+
*/
|
|
284
|
+
async close(): Promise<unknown[]> {
|
|
285
|
+
this.#closed = true;
|
|
286
|
+
await Promise.allSettled(this.#keys.values());
|
|
287
|
+
const settled = await Promise.allSettled(this.#opened.map((sandbox) => this.#close(sandbox)));
|
|
288
|
+
return settled.flatMap((result) => (result.status === "rejected" ? [result.reason] : []));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Each sandbox the run opened, a private one closed after its agent failed too, with its agents. */
|
|
292
|
+
records(): SandboxRecord[] {
|
|
293
|
+
return this.#opened.map(({ key, provider, resolved, directory, opened, agents }) => {
|
|
294
|
+
const domains = new Set([...resolved.network, ...agents.flatMap((agent) => agent.domains)]);
|
|
295
|
+
return {
|
|
296
|
+
callPath: [],
|
|
297
|
+
key,
|
|
298
|
+
provider,
|
|
299
|
+
spec: {
|
|
300
|
+
cwd: resolved.cwd,
|
|
301
|
+
read: [...resolved.read],
|
|
302
|
+
write: [...resolved.write],
|
|
303
|
+
network: [...resolved.network],
|
|
304
|
+
...(resolved.sockets.length > 0 ? { sockets: [...resolved.sockets] } : {}),
|
|
305
|
+
...(resolved.webSearch ? { webSearch: true } : {}),
|
|
306
|
+
[provider]: resolved.environment,
|
|
307
|
+
},
|
|
308
|
+
directory,
|
|
309
|
+
gitdirs: resolved.gitdirs.map((gitdir) => ({ ...gitdir })),
|
|
310
|
+
domains: [...domains].sort(),
|
|
311
|
+
...(opened.record ? { provided: structuredClone(opened.record) } : {}),
|
|
312
|
+
agents: agents.map(({ agent, home }) => ({ callPath: [], agent, home })),
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Opens the operator's sandbox before the workflow runs, so one that can't open fails the run
|
|
319
|
+
* before it starts rather than as the workflow's failure.
|
|
320
|
+
*/
|
|
321
|
+
async openRunSandbox(): Promise<void> {
|
|
322
|
+
await this.#joinRunSandbox();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#joinRunSandbox(): Promise<RunSandbox> {
|
|
326
|
+
// Opened once: a spec that fails to open fails every agent the same way, and is not retried.
|
|
327
|
+
this.#runSandbox ??= this.#register("run", () =>
|
|
328
|
+
this.#open("run", this.options.sandboxes?.run, true),
|
|
329
|
+
);
|
|
330
|
+
return this.#runSandbox;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#isRef(sandbox: unknown): boolean {
|
|
334
|
+
return typeof sandbox === "object" && sandbox !== null && this.#refs.has(sandbox);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#close(sandbox: RunSandbox): Promise<void> {
|
|
338
|
+
sandbox.closing ??= sandbox.opened.close();
|
|
339
|
+
return sandbox.closing;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#shared(sandbox: unknown): RunSandbox | undefined {
|
|
343
|
+
if (typeof sandbox !== "object" || sandbox === null) {
|
|
344
|
+
throw new Error("an agent's sandbox is a ref from sandboxes.open or an inline spec");
|
|
345
|
+
}
|
|
346
|
+
const shared = this.#refs.get(sandbox);
|
|
347
|
+
if (shared) return shared;
|
|
348
|
+
// A ref-shaped object the engine did not make is not a spec either.
|
|
349
|
+
if ("provider" in sandbox) {
|
|
350
|
+
throw new Error("not a sandbox this run opened; pass the ref sandboxes.open returned");
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async #register(key: string, open: () => Promise<RunSandbox>): Promise<RunSandbox> {
|
|
356
|
+
if (this.#closed) throw new Error("the run's sandboxes are closed");
|
|
357
|
+
if (this.#keys.has(key)) throw new Error(`sandbox ${key} is already open`);
|
|
358
|
+
const opening = open();
|
|
359
|
+
this.#keys.set(key, opening);
|
|
360
|
+
let sandbox: RunSandbox;
|
|
361
|
+
try {
|
|
362
|
+
sandbox = await opening;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
// So a retry can use the key again.
|
|
365
|
+
this.#keys.delete(key);
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
this.#opened.push(sandbox);
|
|
369
|
+
// Landed after the run's sandboxes closed: it closes at once, and is still recorded.
|
|
370
|
+
if (this.#closed) {
|
|
371
|
+
await this.#close(sandbox).catch(() => undefined);
|
|
372
|
+
throw new Error("the run's sandboxes are closed");
|
|
373
|
+
}
|
|
374
|
+
return sandbox;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async #open(
|
|
378
|
+
key: string,
|
|
379
|
+
spec: unknown,
|
|
380
|
+
inline: boolean,
|
|
381
|
+
cwd = this.options.cwd,
|
|
382
|
+
): Promise<RunSandbox> {
|
|
383
|
+
const options = await this.#resolveOptions(key, cwd, inline);
|
|
384
|
+
const { provider, sandbox: resolved } = await resolveSandbox(spec, options);
|
|
385
|
+
// Resolving found the provider installed, which only the operator's sandboxes install.
|
|
386
|
+
const setup = this.options.sandboxes;
|
|
387
|
+
const installed = setup?.providers.installed[provider];
|
|
388
|
+
if (!setup || !installed) throw new Error(`the ${provider} sandbox provider is not installed`);
|
|
389
|
+
// Minted, not derived from the key, which may hold `/` or `..`; real, as every path a
|
|
390
|
+
// provider is handed is, so a path rule it writes matches what the system checks.
|
|
391
|
+
const parent = setup.sandboxesDir;
|
|
392
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
393
|
+
const directory = join(await realpath(parent), randomUUID());
|
|
394
|
+
// Before the provider opens: a box mounts `homes/` once, and later agents' homes appear in it.
|
|
395
|
+
await mkdir(join(directory, "homes"), { recursive: true, mode: 0o700 });
|
|
396
|
+
const opened = await installed.open(resolved, {
|
|
397
|
+
runRoot: options.runRoot,
|
|
398
|
+
directory,
|
|
399
|
+
deadline: this.options.deadline,
|
|
400
|
+
});
|
|
401
|
+
return { key, provider, resolved, directory, opened, admitting: Promise.resolve(), agents: [] };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async #resolveOptions(key: string, cwd: string, inline: boolean) {
|
|
405
|
+
this.#runRoot ??= realpath(this.options.runRoot);
|
|
406
|
+
return {
|
|
407
|
+
key,
|
|
408
|
+
cwd,
|
|
409
|
+
runRoot: await this.#runRoot,
|
|
410
|
+
machineRoot: this.options.sandboxes?.machineRoot ?? machinePaths(homedir()).root,
|
|
411
|
+
providers: this.#providers,
|
|
412
|
+
harnessState: Object.values(harnessState(this.#environment)),
|
|
413
|
+
controlRoot: CONTROL_PLANE_ROOT,
|
|
414
|
+
inline,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#admit(
|
|
419
|
+
sandbox: RunSandbox,
|
|
420
|
+
agent: string,
|
|
421
|
+
cwd: string,
|
|
422
|
+
{ home, staging }: { home: string; staging: string },
|
|
423
|
+
needs: HarnessSandboxNeeds,
|
|
424
|
+
door: AgentDoor,
|
|
425
|
+
populate?: (staged: string) => Promise<void>,
|
|
426
|
+
): Promise<SeatedAgent> {
|
|
427
|
+
const admission = sandbox.admitting.then(async (): Promise<SeatedAgent> => {
|
|
428
|
+
if (this.#closed) throw new Error("the run's sandboxes are closed");
|
|
429
|
+
const seeded: SeededHome = await seedHome(
|
|
430
|
+
home,
|
|
431
|
+
staging,
|
|
432
|
+
needs,
|
|
433
|
+
cwd,
|
|
434
|
+
this.options.locks,
|
|
435
|
+
populate,
|
|
436
|
+
);
|
|
437
|
+
const occupant = await sandbox.opened.admit({ cwd, home, harness: needs, door });
|
|
438
|
+
sandbox.agents.push({ agent, home, domains: needs.domains });
|
|
439
|
+
// Admitted as the run released its agents: released now, before its sandbox closes.
|
|
440
|
+
if (this.#closed) {
|
|
441
|
+
await occupant.release().catch(() => undefined);
|
|
442
|
+
throw new Error("the run's sandboxes are closed");
|
|
443
|
+
}
|
|
444
|
+
const who = `sandbox ${sandbox.key}: ${agent}`;
|
|
445
|
+
let released: Promise<void> | undefined;
|
|
446
|
+
const seated: SeatedAgent = {
|
|
447
|
+
occupant: this.#logged(sandbox, occupant, who),
|
|
448
|
+
...(sandbox.resolved.webSearch ? { webSearch: true as const } : {}),
|
|
449
|
+
writeBack: () => seeded.writeBack(),
|
|
450
|
+
release: () => {
|
|
451
|
+
released ??= seeded
|
|
452
|
+
.writeBack()
|
|
453
|
+
.catch((error) => this.options.log(`${who}: ${messageOf(error)}`))
|
|
454
|
+
.then(() => occupant.release());
|
|
455
|
+
return released;
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
this.#held.push(seated);
|
|
459
|
+
return seated;
|
|
460
|
+
});
|
|
461
|
+
sandbox.admitting = admission.catch(() => undefined);
|
|
462
|
+
return admission;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The occupant, with a reap that fails logged where it happens, on every path, and the operator
|
|
467
|
+
* told how to watch a sandbox's own Herdr at its first pane.
|
|
468
|
+
*/
|
|
469
|
+
#logged(sandbox: RunSandbox, occupant: Occupant, who: string): Occupant {
|
|
470
|
+
const { pane } = occupant;
|
|
471
|
+
return {
|
|
472
|
+
...occupant,
|
|
473
|
+
...(pane
|
|
474
|
+
? {
|
|
475
|
+
pane: async () => {
|
|
476
|
+
const terminal = await pane();
|
|
477
|
+
const { herdr } = terminal;
|
|
478
|
+
if (herdr !== "run" && herdr.watch && !sandbox.watched) {
|
|
479
|
+
sandbox.watched = true;
|
|
480
|
+
this.options.log(
|
|
481
|
+
`sandbox ${sandbox.key}: watch its panes with ${herdr.watch.join(" ")}`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return terminal;
|
|
485
|
+
},
|
|
486
|
+
}
|
|
487
|
+
: {}),
|
|
488
|
+
launch: (root) => {
|
|
489
|
+
const command = occupant.launch(root);
|
|
490
|
+
const { reap } = command;
|
|
491
|
+
if (!reap) return command;
|
|
492
|
+
return {
|
|
493
|
+
...command,
|
|
494
|
+
reap: () =>
|
|
495
|
+
reap().catch((error: unknown) =>
|
|
496
|
+
this.options.log(`${who}: a turn's leftovers may still run: ${messageOf(error)}`),
|
|
497
|
+
),
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function assertWithin(
|
|
505
|
+
sandbox: RunSandbox,
|
|
506
|
+
agent: string,
|
|
507
|
+
cwd: string,
|
|
508
|
+
repository: { gitdirs: string[] } | undefined,
|
|
509
|
+
): void {
|
|
510
|
+
if (!withinReach(sandbox.resolved, cwd)) {
|
|
511
|
+
throw new Error(`agent ${agent}: ${cwd} is outside sandbox ${sandbox.key}'s reach`);
|
|
512
|
+
}
|
|
513
|
+
const known = new Set(sandbox.resolved.gitdirs.map((gitdir) => gitdir.path));
|
|
514
|
+
const missing = repository?.gitdirs.find((gitdir) => !known.has(gitdir));
|
|
515
|
+
// A running box cannot gain a mount, so the gitdir must have been resolved when it opened.
|
|
516
|
+
if (missing) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`agent ${agent}: its gitdir ${missing} is outside sandbox ${sandbox.key}'s reach`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|