@parcha/agentrun-kernel 0.0.0-bootstrap.0 → 0.1.0-beta.29
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/CHANGELOG.md +135 -0
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +53 -0
- package/dist/delegate.d.ts +87 -0
- package/dist/delegate.js +143 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +37 -0
- package/dist/host/alive.d.ts +2 -0
- package/dist/host/alive.js +28 -0
- package/dist/host/box-slot.d.ts +26 -0
- package/dist/host/box-slot.js +112 -0
- package/dist/host/box.d.ts +104 -0
- package/dist/host/box.js +16 -0
- package/dist/host/egress-proxy.d.ts +45 -0
- package/dist/host/egress-proxy.js +416 -0
- package/dist/host/egress-table.d.ts +24 -0
- package/dist/host/egress-table.js +165 -0
- package/dist/host/index.d.ts +16 -0
- package/dist/host/index.js +12 -0
- package/dist/host/overhead.d.ts +53 -0
- package/dist/host/overhead.js +75 -0
- package/dist/host/parent-death.d.ts +15 -0
- package/dist/host/parent-death.js +42 -0
- package/dist/host/parent-watch.d.ts +1 -0
- package/dist/host/parent-watch.js +38 -0
- package/dist/host/pinned-binary.d.ts +6 -0
- package/dist/host/pinned-binary.js +48 -0
- package/dist/host/proxy.d.ts +179 -0
- package/dist/host/proxy.js +629 -0
- package/dist/host/serial.d.ts +3 -0
- package/dist/host/serial.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/kernel/blobs.d.ts +16 -0
- package/dist/kernel/blobs.js +59 -0
- package/dist/kernel/drive.d.ts +35 -0
- package/dist/kernel/drive.js +202 -0
- package/dist/kernel/errors.d.ts +27 -0
- package/dist/kernel/errors.js +42 -0
- package/dist/kernel/follow-up.d.ts +67 -0
- package/dist/kernel/follow-up.js +146 -0
- package/dist/kernel/halt.d.ts +11 -0
- package/dist/kernel/halt.js +17 -0
- package/dist/kernel/index.d.ts +8 -0
- package/dist/kernel/index.js +5 -0
- package/dist/kernel/types.d.ts +120 -0
- package/dist/kernel/types.js +1 -0
- package/dist/process/builtins.d.ts +142 -0
- package/dist/process/builtins.js +207 -0
- package/dist/process/inbox.d.ts +29 -0
- package/dist/process/inbox.js +52 -0
- package/dist/process/index.d.ts +9 -0
- package/dist/process/index.js +11 -0
- package/dist/process/journal.d.ts +111 -0
- package/dist/process/journal.js +232 -0
- package/dist/process/records.d.ts +143 -0
- package/dist/process/records.js +142 -0
- package/dist/process/recovery.d.ts +76 -0
- package/dist/process/recovery.js +139 -0
- package/dist/process/settle.d.ts +11 -0
- package/dist/process/settle.js +20 -0
- package/dist/process/tail.d.ts +20 -0
- package/dist/process/tail.js +53 -0
- package/dist/process/tools.d.ts +105 -0
- package/dist/process/tools.js +120 -0
- package/dist/process/zod.d.ts +32 -0
- package/dist/process/zod.js +109 -0
- package/dist/runner.d.ts +154 -0
- package/dist/runner.js +547 -0
- package/dist/schema-check-cases.d.ts +7 -0
- package/dist/schema-check-cases.js +73 -0
- package/dist/schema-check.d.ts +3 -0
- package/dist/schema-check.js +88 -0
- package/dist/skill-files.d.ts +23 -0
- package/dist/skill-files.js +136 -0
- package/dist/testing.d.ts +91 -0
- package/dist/testing.js +196 -0
- package/dist/types.d.ts +237 -0
- package/dist/types.js +10 -0
- package/package.json +76 -1
- package/vendor/durable-harness/UPSTREAM.json +67 -0
package/dist/delegate.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// A lead hands one task to a named sub-agent, which runs as a child node on the same kernel. Written for AgentRun.
|
|
2
|
+
//
|
|
3
|
+
// `delegateTool` is an ordinary host tool, `delegate({ agent, task, context? })`, for a lead on any harness. Each call runs
|
|
4
|
+
// one child: an `agent` node, through `createHarnessRunner`, with the agent's own system prompt (as the host gave it) and
|
|
5
|
+
// its own subset of the lead's tools, answering in a fixed shape (`DELEGATE_RESULT_SCHEMA`). The child is durable under its
|
|
6
|
+
// own key, `step:<the lead's key>/delegate:<call id>`, so the call is replay-safe: a delegate that finished answers again
|
|
7
|
+
// from its recorded result, and one a crash cut off resumes from its own store (its finished paid requests are never sent
|
|
8
|
+
// again). Its paid requests are booked under that key and go through the lead's `beforeRequest`, so one budget covers the
|
|
9
|
+
// lead and every child; its deadline is the lead's, or the agent's share of what is left. Children run one at a time, and
|
|
10
|
+
// a child has no delegate tool (depth 1).
|
|
11
|
+
import { Mutex } from "./process/tools.js";
|
|
12
|
+
import { createHarnessRunner, RUNNER_RECORDS, SUBMIT_TOOL } from "./runner.js";
|
|
13
|
+
/** The tool's name, as the lead calls it. */
|
|
14
|
+
export const DELEGATE_TOOL = "delegate";
|
|
15
|
+
/** What every delegate answers: its finding, the sources it rests on, and what it could not settle. */
|
|
16
|
+
export const DELEGATE_RESULT_SCHEMA = Object.freeze({
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
answer: { type: "string" },
|
|
20
|
+
citations: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: { type: "object", properties: { url: { type: "string" }, title: { type: "string" }, quote: { type: "string" } }, required: ["url"], additionalProperties: false },
|
|
23
|
+
},
|
|
24
|
+
gaps: { type: "array", items: { type: "string" } },
|
|
25
|
+
},
|
|
26
|
+
required: ["answer", "citations"],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
});
|
|
29
|
+
const AGENT_NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
|
30
|
+
/** What is wrong with the delegate's definition, or nothing: checked when the tool is made. */
|
|
31
|
+
export function delegateProblems(options) {
|
|
32
|
+
const problems = [];
|
|
33
|
+
const lead = new Set(options.tools.map((t) => t.name));
|
|
34
|
+
const names = Object.keys(options.agents);
|
|
35
|
+
if (names.length === 0)
|
|
36
|
+
problems.push("no agents to delegate to");
|
|
37
|
+
if (lead.has(DELEGATE_TOOL))
|
|
38
|
+
problems.push(`the lead already has a tool named ${DELEGATE_TOOL}`);
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
const agent = options.agents[name];
|
|
41
|
+
if (!AGENT_NAME.test(name))
|
|
42
|
+
problems.push(`the agent name ${JSON.stringify(name.slice(0, 80))} is not lowercase letters, digits and dashes`);
|
|
43
|
+
if (typeof agent.system !== "string" || agent.system.length === 0)
|
|
44
|
+
problems.push(`the agent ${name} has no system prompt`);
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const tool of agent.tools) {
|
|
47
|
+
if (seen.has(tool))
|
|
48
|
+
problems.push(`the agent ${name} names the tool ${tool} twice`);
|
|
49
|
+
seen.add(tool);
|
|
50
|
+
if (tool === DELEGATE_TOOL)
|
|
51
|
+
problems.push(`the agent ${name} may not delegate further (depth 1)`);
|
|
52
|
+
else if (tool === SUBMIT_TOOL)
|
|
53
|
+
problems.push(`the agent ${name} names ${SUBMIT_TOOL}, which the runner gives every child itself`);
|
|
54
|
+
else if (!lead.has(tool))
|
|
55
|
+
problems.push(`the agent ${name} names the tool ${tool}, which the lead does not have`);
|
|
56
|
+
}
|
|
57
|
+
const share = agent.deadlineShare;
|
|
58
|
+
if (share !== undefined && !(share > 0 && share <= 1))
|
|
59
|
+
problems.push(`the agent ${name}'s deadline share ${share} is not in (0, 1]`);
|
|
60
|
+
}
|
|
61
|
+
return problems;
|
|
62
|
+
}
|
|
63
|
+
/** The `delegate` host tool: refuses a bad definition when made. */
|
|
64
|
+
export function delegateTool(options) {
|
|
65
|
+
const problems = delegateProblems(options);
|
|
66
|
+
if (problems.length)
|
|
67
|
+
throw new Error(`agentrun-kernel: delegate: ${problems.join("; ")}`);
|
|
68
|
+
const byName = new Map(options.tools.map((t) => [t.name, t]));
|
|
69
|
+
const names = Object.keys(options.agents);
|
|
70
|
+
// One child at a time, whatever the harness's own tool calls do.
|
|
71
|
+
const serial = new Mutex();
|
|
72
|
+
return {
|
|
73
|
+
name: DELEGATE_TOOL,
|
|
74
|
+
description: `Hand one task to a sub-agent, which works on it with its own tools and answers with its finding, its sources and its gaps. One at a time. The agents:\n${names.map((n) => `- ${n}: ${options.agents[n].description}`).join("\n")}`,
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
agent: { type: "string", enum: names },
|
|
79
|
+
task: { type: "string", description: "What the sub-agent is to find out or do, complete in itself." },
|
|
80
|
+
context: { type: "string", description: "What it should know from your work so far." },
|
|
81
|
+
},
|
|
82
|
+
required: ["agent", "task"],
|
|
83
|
+
additionalProperties: false,
|
|
84
|
+
},
|
|
85
|
+
// A child is durable under its own key: run again after a crash, it answers from its record or resumes its session.
|
|
86
|
+
replay: "safe",
|
|
87
|
+
call: (args, { callId, signal, sessionDir }) => serial.run(async () => {
|
|
88
|
+
const { agent: name, task, context } = (args ?? {});
|
|
89
|
+
// An own entry only: a name like `constructor` is not an agent.
|
|
90
|
+
const agent = typeof name === "string" && Object.hasOwn(options.agents, name) ? options.agents[name] : undefined;
|
|
91
|
+
if (!agent)
|
|
92
|
+
return { text: `Not delegated: ${JSON.stringify(String(name).slice(0, 80))} is not one of the agents (${names.join(", ")}).`, isError: true };
|
|
93
|
+
if (typeof task !== "string" || task.trim() === "")
|
|
94
|
+
return { text: "Not delegated: give the task as text.", isError: true };
|
|
95
|
+
const tag = { agent: name, callId, parentKey: options.parentKey };
|
|
96
|
+
// The child's session: its step's id, so its records are the runner's under `step:<id>`.
|
|
97
|
+
const sessionId = `${options.parentKey}/delegate:${callId}`;
|
|
98
|
+
const deadlineAt = options.deadlineAt?.();
|
|
99
|
+
const left = deadlineAt === undefined ? undefined : deadlineAt - Date.now();
|
|
100
|
+
if (left !== undefined && left <= 0) {
|
|
101
|
+
// A child that delivered before a crash answers from its record, deadline or not; only new work is refused. Read,
|
|
102
|
+
// not opened: opening would fence the session for nothing.
|
|
103
|
+
const saved = (await options.store.read(`step:${sessionId}`)).findLast((r) => r.kind === RUNNER_RECORDS.accepted);
|
|
104
|
+
if (saved)
|
|
105
|
+
return { text: JSON.stringify(saved.data.value) };
|
|
106
|
+
return { text: "Not delegated: the lead has no time left.", isError: true };
|
|
107
|
+
}
|
|
108
|
+
const timeoutMs = left === undefined ? options.timeoutMs : Math.max(1, Math.floor(left * (agent.deadlineShare ?? 1)));
|
|
109
|
+
const runNode = createHarnessRunner(typeof options.adapter === "function" ? options.adapter(name) : options.adapter, {
|
|
110
|
+
store: options.store,
|
|
111
|
+
tools: agent.tools.map((t) => byName.get(t)),
|
|
112
|
+
signal,
|
|
113
|
+
// The lead's runner's private directories, if it has them: the child's is its own, by its own key.
|
|
114
|
+
...(sessionDir ? { sessionDir } : {}),
|
|
115
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
116
|
+
...(options.maxTurns === undefined ? {} : { maxTurns: options.maxTurns }),
|
|
117
|
+
...(options.beforeRequest ? { beforeRequest: options.beforeRequest } : {}),
|
|
118
|
+
...(options.onEvent ? { onEvent: (event) => options.onEvent({ ...event, delegate: tag }) } : {}),
|
|
119
|
+
});
|
|
120
|
+
try {
|
|
121
|
+
const answer = await runNode({
|
|
122
|
+
kind: "agent",
|
|
123
|
+
label: `delegate:${name}`,
|
|
124
|
+
system: [agent.system],
|
|
125
|
+
user: typeof context === "string" && context.trim() ? `${task}\n\nContext from the lead:\n${context}` : task,
|
|
126
|
+
schema: DELEGATE_RESULT_SCHEMA,
|
|
127
|
+
tools: [...agent.tools],
|
|
128
|
+
...(agent.tier === undefined ? {} : { tier: agent.tier }),
|
|
129
|
+
step: { sessionId, attempt: 0, earlierSessionIds: [] },
|
|
130
|
+
});
|
|
131
|
+
return { text: JSON.stringify(answer) };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// Stopped (the lead paused, the process is draining): not an answer. Rethrown, the lead's harness records the call
|
|
135
|
+
// as interrupted and runs the same call id again on resume, which resumes this child. A child that failed on its
|
|
136
|
+
// own is an answer the lead reads.
|
|
137
|
+
if (signal?.aborted)
|
|
138
|
+
throw signal.reason ?? error;
|
|
139
|
+
return { text: `The delegate ${name} stopped without an answer: ${String(error?.message ?? error).slice(0, 400)}`, isError: true };
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Why a harness node delivered no record. */
|
|
2
|
+
export type HarnessRunReason = "aborted" | "timeout" | "turn_limit" | "submission_limit" | "no_submission" | "model_error"
|
|
3
|
+
/** Turns kept ending with no paid request and no accepted submission (`NO_PROGRESS_TURNS` in a row), which the turn
|
|
4
|
+
* limit never counts: the harness is not reaching its model. */
|
|
5
|
+
| "no_progress"
|
|
6
|
+
/** The runner or the host refused the node before or while it ran: an unknown tool, a fork, a turn limit above the
|
|
7
|
+
* host's cap, a session the adapter refused, or the host's `beforeRequest` (a budget). */
|
|
8
|
+
| "refused"
|
|
9
|
+
/** A newer process opened this node's session, so this attempt may no longer write it: it stops and delivers nothing.
|
|
10
|
+
* Not final: whether the step is delivered is the newer attempt's to say. */
|
|
11
|
+
| "fenced";
|
|
12
|
+
/** An append through a writer that a newer `open` of its session has fenced. Nothing of the append was written. */
|
|
13
|
+
export declare class SessionFenced extends Error {
|
|
14
|
+
readonly key: string;
|
|
15
|
+
readonly code = "SESSION_FENCED";
|
|
16
|
+
constructor(key: string);
|
|
17
|
+
}
|
|
18
|
+
/** A node that delivered no record. `final` says a second attempt would end the same way, so a recovery driver gives
|
|
19
|
+
* it none: the runner's own bounds (the turn cap, no progress, a refusal) are final; an abort, a timeout or a model
|
|
20
|
+
* error is not. */
|
|
21
|
+
export declare class HarnessRunError extends Error {
|
|
22
|
+
readonly reason: HarnessRunReason;
|
|
23
|
+
readonly turns: number;
|
|
24
|
+
readonly submissions: number;
|
|
25
|
+
readonly code = "HARNESS_NODE_NOT_DELIVERED";
|
|
26
|
+
readonly final?: true;
|
|
27
|
+
constructor(reason: HarnessRunReason, turns: number, submissions: number, detail?: {
|
|
28
|
+
message?: string;
|
|
29
|
+
maxTurns?: number | null;
|
|
30
|
+
lastTool?: string;
|
|
31
|
+
});
|
|
32
|
+
/** The node's turn limit for this attempt, or null when it had none. */
|
|
33
|
+
readonly maxTurns?: number | null;
|
|
34
|
+
/** The name of the last tool the node called, never its arguments. */
|
|
35
|
+
readonly lastTool?: string;
|
|
36
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** An append through a writer that a newer `open` of its session has fenced. Nothing of the append was written. */
|
|
2
|
+
export class SessionFenced extends Error {
|
|
3
|
+
key;
|
|
4
|
+
code = "SESSION_FENCED";
|
|
5
|
+
constructor(key) {
|
|
6
|
+
super(`session ${key}: a newer writer holds it; this append was refused`);
|
|
7
|
+
this.key = key;
|
|
8
|
+
this.name = "SessionFenced";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** A node that delivered no record. `final` says a second attempt would end the same way, so a recovery driver gives
|
|
12
|
+
* it none: the runner's own bounds (the turn cap, no progress, a refusal) are final; an abort, a timeout or a model
|
|
13
|
+
* error is not. */
|
|
14
|
+
export class HarnessRunError extends Error {
|
|
15
|
+
reason;
|
|
16
|
+
turns;
|
|
17
|
+
submissions;
|
|
18
|
+
code = "HARNESS_NODE_NOT_DELIVERED";
|
|
19
|
+
final;
|
|
20
|
+
constructor(reason, turns, submissions, detail = {}) {
|
|
21
|
+
super(detail.message ?? `harness node stopped: ${reason} after ${turns} turns and ${submissions} submissions`);
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
this.turns = turns;
|
|
24
|
+
this.submissions = submissions;
|
|
25
|
+
this.name = "HarnessRunError";
|
|
26
|
+
if (detail.maxTurns !== undefined)
|
|
27
|
+
this.maxTurns = detail.maxTurns;
|
|
28
|
+
if (detail.lastTool !== undefined)
|
|
29
|
+
this.lastTool = detail.lastTool;
|
|
30
|
+
if (reason === "turn_limit" || reason === "no_progress" || reason === "refused")
|
|
31
|
+
this.final = true;
|
|
32
|
+
}
|
|
33
|
+
/** The node's turn limit for this attempt, or null when it had none. */
|
|
34
|
+
maxTurns;
|
|
35
|
+
/** The name of the last tool the node called, never its arguments. */
|
|
36
|
+
lastTool;
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Whether a process runs. A zombie (it exited, and its parent has not reaped it) does not, though kill(pid, 0) still
|
|
2
|
+
// finds it: under a PID 1 that does not reap (a container started without an init), an orphan that exits stays one.
|
|
3
|
+
// On Linux, /proc/<pid>/stat says so (its state, after the command name's last ")"); elsewhere, and where that entry
|
|
4
|
+
// cannot be read, kill(pid, 0) is all there is.
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
/** `readStat`: how /proc/<pid>/stat is read (a test's seam). */
|
|
7
|
+
export function processAlive(pid, readStat = (id) => readFileSync(`/proc/${id}/stat`, "utf8")) {
|
|
8
|
+
if (process.platform === "linux") {
|
|
9
|
+
try {
|
|
10
|
+
const stat = readStat(pid);
|
|
11
|
+
const state = stat.charAt(stat.lastIndexOf(")") + 2);
|
|
12
|
+
return state !== "Z" && state !== "X";
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
// Only a missing entry, with /proc mounted, proves it gone. One this process may not read (/proc mounted with
|
|
16
|
+
// hidepid, another user's process) says nothing: the signal check below decides.
|
|
17
|
+
if (error.code === "ENOENT" && existsSync("/proc/self/stat"))
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return error.code === "EPERM";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type BoxedChild, type BoxRecord, type BoxRecorder, type Reaped } from "./box.js";
|
|
2
|
+
/** `empty`: a reap counted the last box empty, or there was none. `full`: a reap found it not empty, or could not count. */
|
|
3
|
+
export type BoxSlotState = "empty" | "live" | "reaping" | "full";
|
|
4
|
+
/** What the slot needs of a started box: when it is let go, and when it exited. Its exit status is never read. */
|
|
5
|
+
export type SlotChild = Pick<BoxedChild, "ready" | "exited">;
|
|
6
|
+
export interface BoxStart<P> {
|
|
7
|
+
/** The box's process, as its start returned it. */
|
|
8
|
+
process: P;
|
|
9
|
+
/** Settles once the box's process exited (or never got going) and the reap that follows gave its verdict: resolves
|
|
10
|
+
* only when the box holds nothing, else rejects with BoxError box_not_empty. */
|
|
11
|
+
reaped: Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export interface BoxSlot<P extends SlotChild> {
|
|
14
|
+
readonly state: BoxSlotState;
|
|
15
|
+
/** Starts the session's next box, synchronously end to end. Refused (BoxError box_busy) unless the slot is empty.
|
|
16
|
+
* `start` gets the recorder wrapped so the slot keeps the latest record, and calls `ahead` itself before it spawns
|
|
17
|
+
* anything. A start that throws after `ahead` has its box reaped (the slot shows it), and the error goes up. */
|
|
18
|
+
start(start: (recorder: BoxRecorder) => P, recorder: BoxRecorder): BoxStart<P>;
|
|
19
|
+
/** At open: the record the session's last box left, reaped before anything starts. The slot is empty only if that
|
|
20
|
+
* reap counts it empty. */
|
|
21
|
+
adopt(previous: BoxRecord): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** One session's box slot, reaping with `reap`. */
|
|
24
|
+
export declare function boxSlot<P extends SlotChild>(options: {
|
|
25
|
+
reap: (record: BoxRecord) => Promise<Reaped>;
|
|
26
|
+
}): BoxSlot<P>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// A session's box slot: one box at a time, and what decides it is empty. Written for AgentRun.
|
|
2
|
+
//
|
|
3
|
+
// Whatever the isolation (a bwrap box, a cgroup, a provider sandbox), everything inside the box is the agent's: it can
|
|
4
|
+
// kill the box's first process, write any line and pick any exit status. So nothing here reads the harness's exit status
|
|
5
|
+
// or output to decide anything. The box's record is written durably before anything runs in it (`ahead`, before the
|
|
6
|
+
// spawn, and `started`, before its first process is let go). The box is reaped as soon as its process exits, never when
|
|
7
|
+
// its stdout closes (a stray holding it would keep that from ever happening), and only the reap's own count says it is
|
|
8
|
+
// empty. A session starts one box at a time: a start while its last box is live, being reaped, or not counted empty is
|
|
9
|
+
// refused at once, before anything else happens.
|
|
10
|
+
//
|
|
11
|
+
// This is isolation-agnostic: how a box starts and how it is reaped come in as arguments (box.ts's Box.start and
|
|
12
|
+
// Box.reap, or anything with their shape).
|
|
13
|
+
import { BoxError } from "./box.js";
|
|
14
|
+
const busy = (state) => new BoxError("box_busy", `box_busy: the session's box is ${state}; a new one starts only once a reap has counted the last one empty`);
|
|
15
|
+
/** One session's box slot, reaping with `reap`. */
|
|
16
|
+
export function boxSlot(options) {
|
|
17
|
+
let state = "empty";
|
|
18
|
+
// The box's latest record: the one its reap is given.
|
|
19
|
+
let latest;
|
|
20
|
+
// Each box's start counts one up: a record of an earlier box never lands as this one's.
|
|
21
|
+
let generation = 0;
|
|
22
|
+
const settle = async (record) => {
|
|
23
|
+
state = "reaping";
|
|
24
|
+
let verdict;
|
|
25
|
+
try {
|
|
26
|
+
verdict = await options.reap(record);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
state = "full";
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
if (!verdict.empty) {
|
|
33
|
+
state = "full";
|
|
34
|
+
throw new BoxError("box_not_empty", `box_not_empty: the box still holds ${verdict.left} process${verdict.left === 1 ? "" : "es"} after its reap (counted by ${verdict.by}); nothing more starts for its session`, { record, left: verdict.left });
|
|
35
|
+
}
|
|
36
|
+
state = "empty";
|
|
37
|
+
latest = undefined;
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
get state() {
|
|
41
|
+
return state;
|
|
42
|
+
},
|
|
43
|
+
start(start, recorder) {
|
|
44
|
+
if (state !== "empty")
|
|
45
|
+
throw busy(state);
|
|
46
|
+
state = "live";
|
|
47
|
+
latest = undefined;
|
|
48
|
+
const box = ++generation;
|
|
49
|
+
// Once the box has ended (its reap is on its way), it records nothing more: a late record would replace the next
|
|
50
|
+
// box's on disk. Its reap waits for every record write already in flight, and counts by the newest.
|
|
51
|
+
let ended = false;
|
|
52
|
+
// The seam's order: the ahead record before the spawn, the started one after. A started record with no ahead one
|
|
53
|
+
// before it is refused, so no write can be on its way for a box that was never recorded.
|
|
54
|
+
let recorded = false;
|
|
55
|
+
let writing = Promise.resolve();
|
|
56
|
+
const refused = () => new BoxError("box_record_failed", "box_record_failed: the box has ended; its record is not written");
|
|
57
|
+
const wrapped = {
|
|
58
|
+
ahead: (record) => {
|
|
59
|
+
if (ended || box !== generation)
|
|
60
|
+
throw refused();
|
|
61
|
+
recorder.ahead(record);
|
|
62
|
+
latest = record;
|
|
63
|
+
recorded = true;
|
|
64
|
+
},
|
|
65
|
+
started: (record) => {
|
|
66
|
+
if (!recorded)
|
|
67
|
+
return Promise.reject(new BoxError("box_record_failed", "box_record_failed: a started record before the box's ahead record; it is not written"));
|
|
68
|
+
if (ended || box !== generation)
|
|
69
|
+
return Promise.reject(refused());
|
|
70
|
+
const write = recorder.started(record).then(() => {
|
|
71
|
+
if (box === generation)
|
|
72
|
+
latest = record;
|
|
73
|
+
});
|
|
74
|
+
writing = Promise.all([writing, write.catch(() => { })]);
|
|
75
|
+
return write;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
let boxed;
|
|
79
|
+
try {
|
|
80
|
+
boxed = start(wrapped);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// Recorded, so something may run in it: reaped, and the slot says how that went. Unrecorded: nothing spawned.
|
|
84
|
+
ended = true;
|
|
85
|
+
if (latest !== undefined)
|
|
86
|
+
writing.then(() => settle(latest)).catch(() => { });
|
|
87
|
+
else
|
|
88
|
+
state = "empty";
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
if (latest === undefined) {
|
|
92
|
+
// A box that runs and was never recorded cannot be counted: the session starts nothing more.
|
|
93
|
+
state = "full";
|
|
94
|
+
throw new BoxError("box_record_failed", "box_record_failed: the box started without a record of itself");
|
|
95
|
+
}
|
|
96
|
+
// The first of these ends the box: its process exited, or it was never let go. Either way it is reaped, once,
|
|
97
|
+
// by its latest record, and its exit status is not read.
|
|
98
|
+
let once;
|
|
99
|
+
const end = () => (once ??= ((ended = true), writing.then(() => settle(latest))));
|
|
100
|
+
const reaped = Promise.race([boxed.exited, boxed.ready.then(() => boxed.exited)]).then(end, end);
|
|
101
|
+
// Unhandled until the caller asks: a box left full is the caller's to see, not the process's to crash on.
|
|
102
|
+
reaped.catch(() => { });
|
|
103
|
+
return { process: boxed, reaped };
|
|
104
|
+
},
|
|
105
|
+
async adopt(previous) {
|
|
106
|
+
if (state !== "empty")
|
|
107
|
+
throw busy(state);
|
|
108
|
+
latest = previous;
|
|
109
|
+
await settle(previous);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
/** What a box may reach. Everything else of the host is absent inside it. */
|
|
3
|
+
export interface BoxSpec {
|
|
4
|
+
/** Bound read-write at its own host path, which is the box's working directory (--chdir), and at /workspace too. */
|
|
5
|
+
workspace: string;
|
|
6
|
+
/** The harness's homes (per-run directories, never the server's $HOME), each bound read-write at its own path. */
|
|
7
|
+
homes: Record<string, string>;
|
|
8
|
+
/** Read-only, at their own paths, beside the system allowlist: node and the pinned CLIs. */
|
|
9
|
+
readOnly: readonly string[];
|
|
10
|
+
/** The model front's Unix socket, bound at /run/agentrun/model.sock. */
|
|
11
|
+
modelSocket: string;
|
|
12
|
+
/** The forwarder's loopback port in the box (the forwarder is the caller's to start). */
|
|
13
|
+
modelPort: number;
|
|
14
|
+
/** The egress proxy's Unix socket, bound at /run/agentrun/egress.sock; absent when the box has no egress. */
|
|
15
|
+
egressSocket?: string;
|
|
16
|
+
egressPort?: number;
|
|
17
|
+
/** The box's whole environment: --clearenv, then one --setenv each. Nothing is inherited. */
|
|
18
|
+
env: Record<string, string>;
|
|
19
|
+
/** On a shared server: the run's cgroup v2, a path under the delegated subtree. */
|
|
20
|
+
cgroup?: string;
|
|
21
|
+
}
|
|
22
|
+
/** A box as it is recorded on disk: its id and cgroup before it starts, then its pid 1, that pid's start time (clock
|
|
23
|
+
* ticks since boot, /proc/<pid>/stat field 22) and its pid namespace (`pid:[<inode>]`). */
|
|
24
|
+
export interface BoxRecord {
|
|
25
|
+
id: string;
|
|
26
|
+
cgroup?: string;
|
|
27
|
+
pid?: number;
|
|
28
|
+
startTime?: string;
|
|
29
|
+
pidns?: string;
|
|
30
|
+
/** The run process that wrote the record (its pid and start time): a record nobody could count clears only once its
|
|
31
|
+
* writer is gone. */
|
|
32
|
+
writer: {
|
|
33
|
+
pid: number;
|
|
34
|
+
startTime: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Where a box's record goes. `ahead` is synchronous (the file and its directory are synced before it returns) and runs
|
|
38
|
+
* before the spawn; `started` runs while bwrap waits, and no process of the harness runs until it resolves. */
|
|
39
|
+
export interface BoxRecorder {
|
|
40
|
+
ahead(record: BoxRecord): void;
|
|
41
|
+
started(record: BoxRecord): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/** A started box: the bwrap process (its stdio is the harness's), and its record, complete once `ready` resolves. */
|
|
44
|
+
export interface BoxedChild {
|
|
45
|
+
readonly child: ChildProcess;
|
|
46
|
+
readonly exited: Promise<{
|
|
47
|
+
code: number | null;
|
|
48
|
+
signal: NodeJS.Signals | null;
|
|
49
|
+
}>;
|
|
50
|
+
stderrTail(): string;
|
|
51
|
+
alive(): boolean;
|
|
52
|
+
/** Ends the box: SIGKILL to its pid 1 (start time checked), which takes every process of its namespace with it. */
|
|
53
|
+
kill(): void;
|
|
54
|
+
/** The record so far: the id and cgroup at once, the pid, start time and pid namespace once `ready` resolves. */
|
|
55
|
+
readonly record: BoxRecord;
|
|
56
|
+
/** Resolves once the pid is recorded and the box released; rejects (bwrap killed, the harness never ran) when bwrap
|
|
57
|
+
* could not start or `started` failed. */
|
|
58
|
+
readonly ready: Promise<BoxRecord>;
|
|
59
|
+
}
|
|
60
|
+
/** What a reap found, by the kernel's own count: the processes left in the recorded pid namespace, or the cgroup's.
|
|
61
|
+
* `none`: the record names neither a pid nor a cgroup, so there was nothing to count (a crash before the pid was
|
|
62
|
+
* known; --die-with-parent ends such a box with its parent). */
|
|
63
|
+
export interface Reaped {
|
|
64
|
+
empty: boolean;
|
|
65
|
+
left: number;
|
|
66
|
+
by: "pidns" | "cgroup" | "none";
|
|
67
|
+
}
|
|
68
|
+
export type Isolation = {
|
|
69
|
+
mode: "bwrap" | "seatbelt" | "provider";
|
|
70
|
+
cgroup: boolean;
|
|
71
|
+
}
|
|
72
|
+
/** Only from AGENTRUN_BOX=unsandboxed, the operator's setting. */
|
|
73
|
+
| {
|
|
74
|
+
mode: "unsandboxed";
|
|
75
|
+
} | {
|
|
76
|
+
mode: "unavailable";
|
|
77
|
+
error: BoxErrorCode;
|
|
78
|
+
detail: string;
|
|
79
|
+
};
|
|
80
|
+
export type BoxErrorCode = "bwrap_missing" | "bwrap_setuid" | "bwrap_userns_denied" | "box_probe_failed" | "box_refused" | "box_record_failed" | "box_start_failed"
|
|
81
|
+
/** A start while the session's last box is live, being reaped, or was not confirmed empty. */
|
|
82
|
+
| "box_busy"
|
|
83
|
+
/** A reap whose own count was not 0. */
|
|
84
|
+
| "box_not_empty";
|
|
85
|
+
export interface Box {
|
|
86
|
+
readonly isolation: Isolation;
|
|
87
|
+
start(spec: BoxSpec, child: {
|
|
88
|
+
command: string;
|
|
89
|
+
args: string[];
|
|
90
|
+
}, record: BoxRecorder): BoxedChild;
|
|
91
|
+
reap(record: BoxRecord): Promise<Reaped>;
|
|
92
|
+
}
|
|
93
|
+
/** A box refused or failed, with its named cause. */
|
|
94
|
+
export declare class BoxError extends Error {
|
|
95
|
+
readonly code: BoxErrorCode;
|
|
96
|
+
/** For `box_not_empty`: the box, and what its reap left. */
|
|
97
|
+
readonly record?: BoxRecord;
|
|
98
|
+
readonly left?: number;
|
|
99
|
+
constructor(code: BoxErrorCode, message: string, options?: {
|
|
100
|
+
cause?: unknown;
|
|
101
|
+
record?: BoxRecord;
|
|
102
|
+
left?: number;
|
|
103
|
+
});
|
|
104
|
+
}
|
package/dist/host/box.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** A box refused or failed, with its named cause. */
|
|
2
|
+
export class BoxError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
/** For `box_not_empty`: the box, and what its reap left. */
|
|
5
|
+
record;
|
|
6
|
+
left;
|
|
7
|
+
constructor(code, message, options) {
|
|
8
|
+
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
|
9
|
+
this.name = "BoxError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
if (options?.record)
|
|
12
|
+
this.record = options.record;
|
|
13
|
+
if (options?.left !== undefined)
|
|
14
|
+
this.left = options.left;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type Socket } from "node:net";
|
|
2
|
+
/** `dns.lookup` with `{ all: true }`: every address a name resolves to. */
|
|
3
|
+
export type EgressLookup = (hostname: string, options: {
|
|
4
|
+
all: true;
|
|
5
|
+
verbatim: true;
|
|
6
|
+
}, callback: (error: NodeJS.ErrnoException | null, addresses: {
|
|
7
|
+
address: string;
|
|
8
|
+
family: number;
|
|
9
|
+
}[]) => void) => void;
|
|
10
|
+
export interface EgressProxyOptions {
|
|
11
|
+
/** Where the socket is made: a path in the session's LOCAL runtime directory (the machine's runtimeDir), never the
|
|
12
|
+
* run's directory, which may sit on a disk other hosts mount. A socket there that refuses a connection (its proxy died
|
|
13
|
+
* without removing it) is replaced; one that answers is a live proxy's, and the start is refused
|
|
14
|
+
* (`egress_socket_in_use`); anything else there is refused and left as it is (`egress_socket_path_taken`). */
|
|
15
|
+
socketPath: string;
|
|
16
|
+
/** Connections open at once (tunnels and forwarded requests), past which a new one is refused. */
|
|
17
|
+
maxTunnels: number;
|
|
18
|
+
/** Connections in all, for the proxy's life, past which a new one is refused. */
|
|
19
|
+
maxConnections: number;
|
|
20
|
+
/** One line per decision: the host, the port, the address and the class. */
|
|
21
|
+
log: (line: string) => void;
|
|
22
|
+
/** How a name is resolved (a test's seam). Default: `dns.lookup`. */
|
|
23
|
+
lookup?: EgressLookup;
|
|
24
|
+
/** How a connection is opened to an address the table passed, in its canonical text (a test's seam). Default:
|
|
25
|
+
* `net.connect`. Only an address that was checked ever reaches it. */
|
|
26
|
+
dial?: (address: string, port: number) => Socket;
|
|
27
|
+
/** More addresses to refuse as this host's, beside the interfaces read at each dial (a test's seam: it can only refuse
|
|
28
|
+
* more). */
|
|
29
|
+
alsoLocal?: () => readonly string[];
|
|
30
|
+
/** The only hosts reached, as parseAllow takes them: exact names, or `*.suffix` for the names below one. Set, every
|
|
31
|
+
* other host, any IP literal and any port but 80 and 443 are refused 403 (SOCKS reply 2, class `not-listed`) before
|
|
32
|
+
* any lookup. Absent, every public host is reached. */
|
|
33
|
+
allow?: string | readonly string[];
|
|
34
|
+
/** What a refusal for a host off the list says to do, after its words (the caller's, which knows its settings): printable
|
|
35
|
+
* ASCII, since it goes into the status line; anything else refuses the start. */
|
|
36
|
+
allowHint?: string;
|
|
37
|
+
}
|
|
38
|
+
/** A host list from its entries, or a comma list of them: each lowercased, its trailing dot dropped, the repeats gone.
|
|
39
|
+
* Throws for an entry that is not a name of two labels or more, or `*.` and one (an address, a port, a URL, a bare `*`). */
|
|
40
|
+
export declare function parseAllow(entries: string | readonly string[]): string[];
|
|
41
|
+
export interface EgressProxy {
|
|
42
|
+
readonly socketPath: string;
|
|
43
|
+
close(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export declare function startEgressProxy(options: EgressProxyOptions): Promise<EgressProxy>;
|