@nylorun/runtime 0.52.0-beta → 0.53.0-beta
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 +22 -0
- package/dist/adapters/sandbox/computer.d.ts +28 -0
- package/dist/adapters/sandbox/computer.js +184 -0
- package/dist/artifacts/disk.js +10 -2
- package/dist/harness/computer.d.ts +86 -0
- package/dist/harness/computer.js +273 -0
- package/dist/harness/executors.d.ts +5 -0
- package/dist/harness/executors.js +13 -5
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/pod.js +1 -1
- package/dist/harness/service.js +58 -22
- package/dist/harness-api/server.js +17 -0
- package/dist/harness-api/start.js +2 -0
- package/dist/harness-api/workspace.d.ts +5 -0
- package/dist/harness-api/workspace.js +9 -5
- package/dist/host/harness-entry.js +7 -2
- package/dist/host/main.js +13 -0
- package/dist/host/stack-config.d.ts +13 -1
- package/dist/host/stack-config.js +42 -2
- package/dist/management-openapi.json +1 -1
- package/dist/openapi.json +1 -1
- package/dist/sandbox/computer.d.ts +53 -0
- package/dist/sandbox/computer.js +271 -0
- package/dist/sandbox/placement.d.ts +5 -2
- package/dist/sandbox/placement.js +6 -3
- package/dist/sandbox/pods/reconcile.js +1 -0
- package/dist/sandbox/pods/spec.d.ts +5 -0
- package/dist/sandbox/pods/spec.js +1 -1
- package/dist/sandbox/records.d.ts +2 -0
- package/dist/sandbox/records.js +11 -0
- package/dist/sandbox/types.d.ts +5 -2
- package/dist/tenant/advance.js +6 -3
- package/dist/tenant/context.d.ts +3 -0
- package/dist/tenant/runtime.js +34 -0
- package/dist/tenant/sessions.js +2 -1
- package/dist/tenant/sweep.js +2 -1
- package/dist/tenant/transcript.d.ts +8 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.53.0-beta
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5b49361: A pod Agent on an Agent Substrate cluster can be a computer (ADR-149, ADR-150): with `NYLORUN_SANDBOX_GATEWAY_URL` and `NYLORUN_COMPUTER_TICKET_KEY_FILE` set (the Runtime's Helm chart sets both), its sessions' runs go to the harness container, and their sandbox tools, file reads and writes, skill folders and disk versions reach the Agent's actor through the sandbox gateway and `atenet-router`, with a ticket core signs (Ed25519). The engine's `--service computer` runs no loop: it serves commands (a start and attaches, each under 50 s) and files on port 8080, checks each ticket against the key its template carries, and answers the wake-up probe on `/ready`. A harness opens a computer with `computer.open`; core wakes it, waits until it is ready, writes a seed onto a new disk, runs the disk's setup through it and passes a version's start check. The tools, `runtime.json` and every other backend are unchanged.
|
|
8
|
+
|
|
9
|
+
- `@nylorun/core`: Harness API 5.6 (`harness-api/v5.6.json`): `routing.sandbox.computer` and the request `computer.open`.
|
|
10
|
+
- `@nylorun/runtime`: `--service computer`; the `computer` sandbox backend; `NYLORUN_SANDBOX_GATEWAY_URL` and `NYLORUN_COMPUTER_TICKET_KEY_FILE`.
|
|
11
|
+
|
|
12
|
+
- 0cb752d: The Runtime's Helm chart, `charts/nylorun-runtime` (ADR-147, the Runtime on Kubernetes plan): the Runtime on an Agent Substrate cluster, with computers. It installs core, the gateway, the harness, Studio, the sandboxes service in the cluster, the sandbox gateway (atenet-router's client), Restate on a disk and s2-lite with its streams in the object store; Postgres and the object store are an external URL and S3 endpoint, or bundled (Postgres, RustFS). It makes the Runtime's secrets once and keeps them, and optionally pre-pulls images on worker nodes and makes the Tenant's atespace. Its README says what Substrate needs (the router's allowlist, the atespace) and has a GCP example.
|
|
13
|
+
- f2ef233: The sandboxes service runs in the cluster it drives, and Agent Substrate sandboxes can be computers (ADR-149, the Runtime on Kubernetes plan). `cluster.json`'s `inCluster: true` uses the pod's ServiceAccount and reaches Substrate's API by its Service with a projected token; `substrate.computer` renders every sandbox as a computer: its engine runs `--service computer`, with no join token, edge or edge rule, a wake-up probe on `/ready`, and the ticket key's public half. `sandboxes gateway` is the sandbox gateway (ADR-148): it checks a computer ticket and proxies to the computer through an mTLS CONNECT tunnel to `atenet-router`.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- becf6a2: A computer runs our engine whatever harness drives its Agent: an Agent with a custom harness runs it through the Harness API, and its image is its computer. The chart sets numeric users for `runAsNonRoot`, uses the sandboxes service's `/health` for readiness (its `/ready` waits for the atespace the post-install Job makes), and says that Substrate's API admits only namespaces labelled `ate.dev/api-access=true`. Docs: Harness API §9b (computers), Kubernetes in SELF_HOSTING.md, and a MIGRATION entry.
|
|
18
|
+
- Pin core to the tested release.
|
|
19
|
+
- Pin harness to the tested release.
|
|
20
|
+
- Updated dependencies [5b49361]
|
|
21
|
+
- Updated dependencies
|
|
22
|
+
- @nylorun/core@0.46.0-beta
|
|
23
|
+
- @nylorun/harness@0.36.6-beta
|
|
24
|
+
|
|
3
25
|
## 0.52.0-beta
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type SandboxBackend } from "../../sandbox/types.js";
|
|
2
|
+
/** What `open` answers: where this computer is, and the ticket for it. */
|
|
3
|
+
export interface ComputerAccess {
|
|
4
|
+
/** The gateway's URL for this computer, `<gateway>/<sandbox>`. */
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly ticket: string;
|
|
7
|
+
/** When the ticket expires (ms since the epoch). */
|
|
8
|
+
readonly expiresAt: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ComputerBackendOptions {
|
|
11
|
+
readonly open: (signal?: AbortSignal) => Promise<ComputerAccess>;
|
|
12
|
+
readonly fetch?: typeof fetch;
|
|
13
|
+
readonly now?: () => number;
|
|
14
|
+
/** How long a dropped request is asked again for, past the command's own timeout. */
|
|
15
|
+
readonly retryMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/** A computer refused or failed an operation. */
|
|
18
|
+
export declare class ComputerError extends Error {
|
|
19
|
+
readonly status: number;
|
|
20
|
+
readonly code: string;
|
|
21
|
+
constructor(status: number, code: string, message: string);
|
|
22
|
+
}
|
|
23
|
+
/** The disk roots of every computer (R9, ADR-150). */
|
|
24
|
+
export declare const COMPUTER_DISK_ROOTS: {
|
|
25
|
+
readonly workspace: "/workspace";
|
|
26
|
+
readonly harness: "/harness";
|
|
27
|
+
};
|
|
28
|
+
export declare function computerBackend(options: ComputerBackendOptions): SandboxBackend;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computer backend (ADR-149, ADR-150): a pod Agent's sandbox on Agent Substrate, reached through
|
|
3
|
+
* the sandbox gateway and Substrate's router (ADR-148). Its handle sends each operation to the
|
|
4
|
+
* computer's engine (`harness/computer.ts`) with a ticket core signs: `open` (a harness asks core
|
|
5
|
+
* with `computer.open`; core in process) wakes the computer, runs its disk's setup, and answers
|
|
6
|
+
* the gateway URL for this computer, the ticket and when it expires. A ticket is asked for again
|
|
7
|
+
* a minute before it expires.
|
|
8
|
+
*
|
|
9
|
+
* A command is a start and attaches, each answered within 50 s, under the command's own timeout:
|
|
10
|
+
* a gateway or router that drops a request (a rollout, a drain) is asked again with the same id,
|
|
11
|
+
* and the computer joins the command it already runs. One the computer no longer knows (lost with
|
|
12
|
+
* a crash or a revert) fails the call, saying so.
|
|
13
|
+
*
|
|
14
|
+
* One computer holds one sandbox: every key opens the same handle. Removing it is the Agent's reset
|
|
15
|
+
* or delete (core's pod lifecycle), so `remove` and `list` do nothing here; `stop` does nothing
|
|
16
|
+
* either, as core suspends an idle computer.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import { SANDBOX_WORKSPACE } from "@nylorun/core/define";
|
|
20
|
+
import { SandboxFileTooLargeError, wholeBytes, } from "../../sandbox/types.js";
|
|
21
|
+
/** A computer refused or failed an operation. */
|
|
22
|
+
export class ComputerError extends Error {
|
|
23
|
+
status;
|
|
24
|
+
code;
|
|
25
|
+
constructor(status, code, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.name = "ComputerError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const WAIT_MS = 50_000;
|
|
33
|
+
const RENEW_MS = 60_000;
|
|
34
|
+
/** The disk roots of every computer (R9, ADR-150). */
|
|
35
|
+
export const COMPUTER_DISK_ROOTS = { workspace: SANDBOX_WORKSPACE, harness: "/harness" };
|
|
36
|
+
export function computerBackend(options) {
|
|
37
|
+
const http = options.fetch ?? fetch;
|
|
38
|
+
const now = options.now ?? Date.now;
|
|
39
|
+
const retryMs = options.retryMs ?? 3 * 60_000;
|
|
40
|
+
let access;
|
|
41
|
+
let opening;
|
|
42
|
+
const current = async (signal) => {
|
|
43
|
+
if (access && access.expiresAt - now() > RENEW_MS)
|
|
44
|
+
return access;
|
|
45
|
+
opening ??= options.open(signal).finally(() => (opening = undefined));
|
|
46
|
+
access = await opening;
|
|
47
|
+
return access;
|
|
48
|
+
};
|
|
49
|
+
const call = async (method, path, init = {}) => {
|
|
50
|
+
const at = await current(init.signal);
|
|
51
|
+
const headers = { "x-nylorun-ticket": at.ticket };
|
|
52
|
+
let body = init.body;
|
|
53
|
+
if (init.json !== undefined) {
|
|
54
|
+
headers["content-type"] = "application/json";
|
|
55
|
+
body = JSON.stringify(init.json);
|
|
56
|
+
}
|
|
57
|
+
const response = await http(`${at.url}${path}`, {
|
|
58
|
+
method,
|
|
59
|
+
headers,
|
|
60
|
+
...(body === undefined ? {} : { body }),
|
|
61
|
+
...(body !== undefined && typeof body !== "string" && !(body instanceof Uint8Array) ? { duplex: "half" } : {}),
|
|
62
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
63
|
+
});
|
|
64
|
+
if (response.status === 401)
|
|
65
|
+
access = undefined; // a new ticket next time
|
|
66
|
+
return response;
|
|
67
|
+
};
|
|
68
|
+
const failed = async (response) => {
|
|
69
|
+
let code = "computer_error";
|
|
70
|
+
let message = `The computer answered ${response.status}`;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = (await response.json());
|
|
73
|
+
code = parsed.error?.code ?? code;
|
|
74
|
+
message = parsed.error?.message ?? message;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// not JSON
|
|
78
|
+
}
|
|
79
|
+
return new ComputerError(response.status, code, message);
|
|
80
|
+
};
|
|
81
|
+
/** Whether a failure is the way's (gateway, router, a computer moving), worth asking again. */
|
|
82
|
+
const transient = (error) => error instanceof ComputerError ? error.status === 401 || error.status >= 500 : !(error instanceof DOMException && error.name === "AbortError");
|
|
83
|
+
const exec = async (request, signal) => {
|
|
84
|
+
const id = randomUUID();
|
|
85
|
+
const deadline = now() + request.timeoutMs + retryMs;
|
|
86
|
+
let started = false;
|
|
87
|
+
const cancel = () => void call("POST", `/v1/exec/${encodeURIComponent(id)}/cancel`).catch(() => undefined);
|
|
88
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
89
|
+
try {
|
|
90
|
+
for (let attempt = 0;; attempt++) {
|
|
91
|
+
if (signal.aborted)
|
|
92
|
+
throw signal.reason ?? new Error("Turn cancelled");
|
|
93
|
+
try {
|
|
94
|
+
const response = started
|
|
95
|
+
? await call("POST", `/v1/exec/${encodeURIComponent(id)}/attach`, { json: { waitMs: WAIT_MS }, signal })
|
|
96
|
+
: await call("POST", "/v1/exec", {
|
|
97
|
+
json: { id, command: request.command, cwd: request.cwd, timeoutMs: request.timeoutMs, env: request.env ?? {}, waitMs: WAIT_MS },
|
|
98
|
+
signal,
|
|
99
|
+
});
|
|
100
|
+
if (response.status === 404 && started)
|
|
101
|
+
return {
|
|
102
|
+
exitCode: 1,
|
|
103
|
+
stdout: "",
|
|
104
|
+
stderr: "The computer lost this command (it was restarted from its last snapshot).",
|
|
105
|
+
killed: true,
|
|
106
|
+
timedOut: false,
|
|
107
|
+
};
|
|
108
|
+
if (!response.ok)
|
|
109
|
+
throw await failed(response);
|
|
110
|
+
started = true;
|
|
111
|
+
attempt = 0;
|
|
112
|
+
const answer = (await response.json());
|
|
113
|
+
if (answer.state === "done")
|
|
114
|
+
return answer.result;
|
|
115
|
+
if (answer.state === "failed")
|
|
116
|
+
throw new ComputerError(500, "command_failed", answer.message);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (!transient(error) || now() > deadline)
|
|
120
|
+
throw error;
|
|
121
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(5_000, 250 * 2 ** attempt)));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
signal.removeEventListener("abort", cancel);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const readBytes = async (path, maxBytes) => {
|
|
130
|
+
const response = await call("GET", `/v1/files?path=${encodeURIComponent(path)}&maxBytes=${maxBytes}`);
|
|
131
|
+
if (response.status === 404)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (response.status === 413) {
|
|
134
|
+
const error = await failed(response);
|
|
135
|
+
const size = Number(/is (\d+) bytes/.exec(error.message)?.[1] ?? maxBytes + 1);
|
|
136
|
+
throw new SandboxFileTooLargeError(path, size, maxBytes);
|
|
137
|
+
}
|
|
138
|
+
if (!response.ok)
|
|
139
|
+
throw await failed(response);
|
|
140
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
141
|
+
};
|
|
142
|
+
const handle = {
|
|
143
|
+
workspace: SANDBOX_WORKSPACE,
|
|
144
|
+
diskRoots: COMPUTER_DISK_ROOTS,
|
|
145
|
+
exec,
|
|
146
|
+
async readFile(path) {
|
|
147
|
+
const bytes = await readBytes(path, Number.MAX_SAFE_INTEGER);
|
|
148
|
+
return bytes === undefined ? undefined : Buffer.from(bytes).toString("utf8");
|
|
149
|
+
},
|
|
150
|
+
readBytes,
|
|
151
|
+
async listFiles(dir, maxEntries) {
|
|
152
|
+
const response = await call("GET", `/v1/list?dir=${encodeURIComponent(dir)}&maxEntries=${maxEntries}`);
|
|
153
|
+
if (response.status === 404)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (!response.ok)
|
|
156
|
+
throw await failed(response);
|
|
157
|
+
return (await response.json());
|
|
158
|
+
},
|
|
159
|
+
async writeFile(path, content) {
|
|
160
|
+
// A file's chunks are gathered: a skill's file is at most a few megabytes.
|
|
161
|
+
const body = (typeof content === "string" ? content : Buffer.from(await wholeBytes(content)));
|
|
162
|
+
const response = await call("PUT", `/v1/files?path=${encodeURIComponent(path)}`, { body });
|
|
163
|
+
if (!response.ok)
|
|
164
|
+
throw await failed(response);
|
|
165
|
+
},
|
|
166
|
+
async stop() { },
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
name: "computer",
|
|
170
|
+
isolation: "container",
|
|
171
|
+
async probe() {
|
|
172
|
+
return { name: "computer", isolation: "container", available: true, reason: "an Agent Substrate computer" };
|
|
173
|
+
},
|
|
174
|
+
unmet: () => undefined,
|
|
175
|
+
open: async () => {
|
|
176
|
+
await current();
|
|
177
|
+
return handle;
|
|
178
|
+
},
|
|
179
|
+
async remove() { },
|
|
180
|
+
async list() {
|
|
181
|
+
return [];
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
package/dist/artifacts/disk.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* Nothing here runs inside a transaction but the lease and the commit. No event type is new:
|
|
21
21
|
* a restore or recovery is `agent.reset` with `reason` and `from`.
|
|
22
22
|
*/
|
|
23
|
+
import { computerAccess } from "../sandbox/computer.js";
|
|
23
24
|
import { createHash, randomUUID } from "node:crypto";
|
|
24
25
|
import { newArtifactId } from "@nylorun/core/compatibility";
|
|
25
26
|
import { FOLDER_CONTENT_TYPE, FOLDER_MANIFEST_FORMAT_V2, } from "@nylorun/core/contracts";
|
|
@@ -137,6 +138,11 @@ function heartbeat(ctx, id, limits, ms = limits.leaseMs) {
|
|
|
137
138
|
}
|
|
138
139
|
/** Waits until the engine of pod Environment `id` is connected, starting the pod when it is stopped. */
|
|
139
140
|
async function awaitEngine(ctx, id, waitMs) {
|
|
141
|
+
// A computer (ADR-149) has no engine connection: it is ready once it answers through the gateway.
|
|
142
|
+
if (ctx.pods?.computer) {
|
|
143
|
+
await computerAccess(ctx, id);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
140
146
|
await wakePod(ctx, id);
|
|
141
147
|
const deadline = Date.now() + waitMs;
|
|
142
148
|
while (!ctx.harness.hosting(id)) {
|
|
@@ -459,7 +465,9 @@ export async function runPendingSeed(ctx, id) {
|
|
|
459
465
|
if (!environment || !seed || seed.written)
|
|
460
466
|
return;
|
|
461
467
|
const limits = await copyLimits(ctx, environment);
|
|
462
|
-
if (environment.kind === "pod")
|
|
468
|
+
if (environment.kind === "pod" && ctx.pods?.computer)
|
|
469
|
+
await computerAccess(ctx, id);
|
|
470
|
+
else if (environment.kind === "pod") {
|
|
463
471
|
const deadline = Date.now() + limits.podWaitMs;
|
|
464
472
|
// The engine on the new volume: the old one's connection may still be open.
|
|
465
473
|
while (!ctx.harness.hosting(id, environment.pod?.hostEpoch)) {
|
|
@@ -494,7 +502,7 @@ export async function runPendingSeed(ctx, id) {
|
|
|
494
502
|
export async function sweepPendingSeeds(ctx) {
|
|
495
503
|
const pending = (await ctx.store.tx((t) => t.listEnvironments())).filter((environment) => environment.diskSeed !== undefined && !environment.diskSeed.written && environment.pod?.desired !== "deleted");
|
|
496
504
|
for (const environment of pending)
|
|
497
|
-
if (environment.kind !== "pod" || ctx.harness.hosting(environment.id, environment.pod?.hostEpoch))
|
|
505
|
+
if (environment.kind !== "pod" || ctx.pods?.computer || ctx.harness.hosting(environment.id, environment.pod?.hostEpoch))
|
|
498
506
|
void runPendingSeed(ctx, environment.id);
|
|
499
507
|
}
|
|
500
508
|
/**
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The computer (`--service computer`, ADR-149): the engine in an Agent Substrate actor when every
|
|
3
|
+
* harness runs outside the sandbox. It runs no loop, joins nothing and dials nothing to the
|
|
4
|
+
* Runtime. It serves the sandbox handle (`SandboxHandle`: commands, files, listings) on
|
|
5
|
+
* `NYLORUN_COMPUTER_PORT` (8080), which the sandbox gateway reaches through Substrate's router
|
|
6
|
+
* (ADR-148), and runs each command as the `local` backend does: the image's shell in `/workspace`,
|
|
7
|
+
* its own process group, the trust variables pointing at a bundle with Substrate's egress CA.
|
|
8
|
+
*
|
|
9
|
+
* Every route but `GET /ready` (the template's wake-up probe) needs `x-nylorun-ticket`: a compact
|
|
10
|
+
* JWS core signs with Ed25519, checked against `NYLORUN_COMPUTER_TICKET_KEY` (the public half the
|
|
11
|
+
* template carries), with `aud: "nylorun-computer"`, `exp` ahead, and `sub` this actor's name
|
|
12
|
+
* (`NYLORUN_COMPUTER_NAME_FILE`), so a router client other than the gateway can't command it.
|
|
13
|
+
*
|
|
14
|
+
* Commands are a start and attaches (ADR-148 decision 5): `POST /v1/exec` starts command `id`, or
|
|
15
|
+
* joins it while it runs, and answers within `waitMs` (at most 50 s) with `{state: "running"}` or
|
|
16
|
+
* `{state: "done", result}`; `POST /v1/exec/{id}/attach` waits again. A command's result is kept 10
|
|
17
|
+
* minutes after it ends. After a drain, the next attach reaches this process on the new worker, its
|
|
18
|
+
* commands moved with the memory snapshot. SIGTERM on Substrate means the worker is stopping: the
|
|
19
|
+
* engine keeps running, so the sandboxes service can suspend it.
|
|
20
|
+
*/
|
|
21
|
+
import { type KeyObject } from "node:crypto";
|
|
22
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
23
|
+
import { type ExecResult, type SandboxHandle } from "../sandbox/types.js";
|
|
24
|
+
export interface ComputerServiceConfig {
|
|
25
|
+
readonly port: number;
|
|
26
|
+
/** The ticket key's public half, SPKI PEM. */
|
|
27
|
+
readonly ticketKey: string;
|
|
28
|
+
/** The file holding this actor's name (Substrate's actorMetadata). */
|
|
29
|
+
readonly nameFile: string;
|
|
30
|
+
/** The engine's own dir on the durable disk (`/harness`). */
|
|
31
|
+
readonly root: string;
|
|
32
|
+
/** Substrate's egress CA, which commands trust for hosts whose keys the egress adds. */
|
|
33
|
+
readonly egressCaFile?: string;
|
|
34
|
+
/** On Agent Substrate: SIGTERM means the worker is stopping, not that the engine should. */
|
|
35
|
+
readonly substrate: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** The ticket audience. */
|
|
38
|
+
export declare const COMPUTER_AUDIENCE = "nylorun-computer";
|
|
39
|
+
/** The longest a start or an attach waits before it answers `running`. */
|
|
40
|
+
export declare const MAX_WAIT_MS = 50000;
|
|
41
|
+
/** Checks a ticket; the claims, or why it fails. */
|
|
42
|
+
export declare function checkTicket(token: string | undefined, key: KeyObject, subject: string, now?: number): {
|
|
43
|
+
ok: true;
|
|
44
|
+
} | {
|
|
45
|
+
ok: false;
|
|
46
|
+
reason: string;
|
|
47
|
+
};
|
|
48
|
+
export interface ComputerExecRequest {
|
|
49
|
+
readonly id: string;
|
|
50
|
+
readonly command: string;
|
|
51
|
+
readonly cwd: string;
|
|
52
|
+
readonly timeoutMs: number;
|
|
53
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
54
|
+
readonly waitMs?: number;
|
|
55
|
+
}
|
|
56
|
+
export type ComputerExecAnswer = {
|
|
57
|
+
readonly state: "running";
|
|
58
|
+
} | {
|
|
59
|
+
readonly state: "done";
|
|
60
|
+
readonly result: ExecResult;
|
|
61
|
+
} | {
|
|
62
|
+
readonly state: "failed";
|
|
63
|
+
readonly message: string;
|
|
64
|
+
};
|
|
65
|
+
/** The commands a computer runs, by id: start, join, attach, cancel. */
|
|
66
|
+
export declare class ComputerCommands {
|
|
67
|
+
private readonly handle;
|
|
68
|
+
private readonly commands;
|
|
69
|
+
constructor(handle: Pick<SandboxHandle, "exec">);
|
|
70
|
+
start(request: ComputerExecRequest): Promise<ComputerExecAnswer>;
|
|
71
|
+
/** Undefined when the computer doesn't know the command. */
|
|
72
|
+
attach(id: string, waitMs?: number): Promise<ComputerExecAnswer> | undefined;
|
|
73
|
+
cancel(id: string): boolean;
|
|
74
|
+
private wait;
|
|
75
|
+
private prune;
|
|
76
|
+
}
|
|
77
|
+
/** The routes, on a handle (tests give their own). */
|
|
78
|
+
export declare function computerHandler(options: {
|
|
79
|
+
readonly handle: SandboxHandle;
|
|
80
|
+
readonly key: KeyObject;
|
|
81
|
+
readonly name: string;
|
|
82
|
+
readonly ready: () => boolean;
|
|
83
|
+
readonly now?: () => number;
|
|
84
|
+
}): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
|
85
|
+
/** Runs the computer until the process ends. */
|
|
86
|
+
export declare function runComputer(config: ComputerServiceConfig, baseline: Readonly<Record<string, string>>): Promise<void>;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The computer (`--service computer`, ADR-149): the engine in an Agent Substrate actor when every
|
|
3
|
+
* harness runs outside the sandbox. It runs no loop, joins nothing and dials nothing to the
|
|
4
|
+
* Runtime. It serves the sandbox handle (`SandboxHandle`: commands, files, listings) on
|
|
5
|
+
* `NYLORUN_COMPUTER_PORT` (8080), which the sandbox gateway reaches through Substrate's router
|
|
6
|
+
* (ADR-148), and runs each command as the `local` backend does: the image's shell in `/workspace`,
|
|
7
|
+
* its own process group, the trust variables pointing at a bundle with Substrate's egress CA.
|
|
8
|
+
*
|
|
9
|
+
* Every route but `GET /ready` (the template's wake-up probe) needs `x-nylorun-ticket`: a compact
|
|
10
|
+
* JWS core signs with Ed25519, checked against `NYLORUN_COMPUTER_TICKET_KEY` (the public half the
|
|
11
|
+
* template carries), with `aud: "nylorun-computer"`, `exp` ahead, and `sub` this actor's name
|
|
12
|
+
* (`NYLORUN_COMPUTER_NAME_FILE`), so a router client other than the gateway can't command it.
|
|
13
|
+
*
|
|
14
|
+
* Commands are a start and attaches (ADR-148 decision 5): `POST /v1/exec` starts command `id`, or
|
|
15
|
+
* joins it while it runs, and answers within `waitMs` (at most 50 s) with `{state: "running"}` or
|
|
16
|
+
* `{state: "done", result}`; `POST /v1/exec/{id}/attach` waits again. A command's result is kept 10
|
|
17
|
+
* minutes after it ends. After a drain, the next attach reaches this process on the new worker, its
|
|
18
|
+
* commands moved with the memory snapshot. SIGTERM on Substrate means the worker is stopping: the
|
|
19
|
+
* engine keeps running, so the sandboxes service can suspend it.
|
|
20
|
+
*/
|
|
21
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
22
|
+
import { accessSync, constants, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { createServer } from "node:http";
|
|
24
|
+
import { rootCertificates } from "node:tls";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { SANDBOX_WORKSPACE } from "@nylorun/core/define";
|
|
27
|
+
import { createHostLogger } from "../host/logger.js";
|
|
28
|
+
import { localBackend } from "../adapters/sandbox/local.js";
|
|
29
|
+
import { SandboxFileTooLargeError } from "../sandbox/types.js";
|
|
30
|
+
import { TRUST_VARIABLES } from "./pod.js";
|
|
31
|
+
import { RUNTIME_VERSION } from "../version.js";
|
|
32
|
+
/** The ticket audience. */
|
|
33
|
+
export const COMPUTER_AUDIENCE = "nylorun-computer";
|
|
34
|
+
/** The longest a start or an attach waits before it answers `running`. */
|
|
35
|
+
export const MAX_WAIT_MS = 50_000;
|
|
36
|
+
/** How long a command's result is kept after it ends. */
|
|
37
|
+
const KEEP_MS = 10 * 60_000;
|
|
38
|
+
/** Checks a ticket; the claims, or why it fails. */
|
|
39
|
+
export function checkTicket(token, key, subject, now = Date.now()) {
|
|
40
|
+
const parts = (token ?? "").split(".");
|
|
41
|
+
if (parts.length !== 3)
|
|
42
|
+
return { ok: false, reason: "no ticket" };
|
|
43
|
+
try {
|
|
44
|
+
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
|
|
45
|
+
if (header.alg !== "EdDSA")
|
|
46
|
+
return { ok: false, reason: "alg" };
|
|
47
|
+
const signed = verify(null, Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"));
|
|
48
|
+
if (!signed)
|
|
49
|
+
return { ok: false, reason: "signature" };
|
|
50
|
+
const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
51
|
+
if (claims.aud !== COMPUTER_AUDIENCE)
|
|
52
|
+
return { ok: false, reason: "audience" };
|
|
53
|
+
if (claims.sub !== subject)
|
|
54
|
+
return { ok: false, reason: "subject" };
|
|
55
|
+
if (typeof claims.exp !== "number" || now / 1000 >= claims.exp)
|
|
56
|
+
return { ok: false, reason: "expired" };
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { ok: false, reason: "malformed" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The commands a computer runs, by id: start, join, attach, cancel. */
|
|
64
|
+
export class ComputerCommands {
|
|
65
|
+
handle;
|
|
66
|
+
commands = new Map();
|
|
67
|
+
constructor(handle) {
|
|
68
|
+
this.handle = handle;
|
|
69
|
+
}
|
|
70
|
+
start(request) {
|
|
71
|
+
this.prune();
|
|
72
|
+
let command = this.commands.get(request.id);
|
|
73
|
+
if (!command) {
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const done = this.handle.exec({
|
|
76
|
+
command: request.command,
|
|
77
|
+
cwd: request.cwd,
|
|
78
|
+
timeoutMs: request.timeoutMs,
|
|
79
|
+
...(request.env ? { env: request.env } : {}),
|
|
80
|
+
}, controller.signal);
|
|
81
|
+
const created = { done, controller };
|
|
82
|
+
done.then((result) => {
|
|
83
|
+
created.result = result;
|
|
84
|
+
created.endedAt = Date.now();
|
|
85
|
+
}, (error) => {
|
|
86
|
+
created.error = error instanceof Error ? error.message : String(error);
|
|
87
|
+
created.endedAt = Date.now();
|
|
88
|
+
});
|
|
89
|
+
this.commands.set(request.id, created);
|
|
90
|
+
command = created;
|
|
91
|
+
}
|
|
92
|
+
return this.wait(command, request.waitMs);
|
|
93
|
+
}
|
|
94
|
+
/** Undefined when the computer doesn't know the command. */
|
|
95
|
+
attach(id, waitMs) {
|
|
96
|
+
const command = this.commands.get(id);
|
|
97
|
+
return command ? this.wait(command, waitMs) : undefined;
|
|
98
|
+
}
|
|
99
|
+
cancel(id) {
|
|
100
|
+
const command = this.commands.get(id);
|
|
101
|
+
command?.controller.abort(new Error("Cancelled"));
|
|
102
|
+
return command !== undefined;
|
|
103
|
+
}
|
|
104
|
+
async wait(command, waitMs = MAX_WAIT_MS) {
|
|
105
|
+
const ms = Math.max(0, Math.min(waitMs, MAX_WAIT_MS));
|
|
106
|
+
let timer;
|
|
107
|
+
const late = new Promise((resolve) => (timer = setTimeout(() => resolve("late"), ms)));
|
|
108
|
+
try {
|
|
109
|
+
await Promise.race([command.done.catch(() => undefined), late]);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
if (command.result)
|
|
115
|
+
return { state: "done", result: command.result };
|
|
116
|
+
if (command.error !== undefined)
|
|
117
|
+
return { state: "failed", message: command.error };
|
|
118
|
+
return { state: "running" };
|
|
119
|
+
}
|
|
120
|
+
prune(now = Date.now()) {
|
|
121
|
+
for (const [id, command] of this.commands)
|
|
122
|
+
if (command.endedAt !== undefined && now - command.endedAt > KEEP_MS)
|
|
123
|
+
this.commands.delete(id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Writes the trust bundle commands use (the public roots, then Substrate's egress CA). */
|
|
127
|
+
function trustEnvironment(root, egressCaFile) {
|
|
128
|
+
if (!egressCaFile)
|
|
129
|
+
return {};
|
|
130
|
+
let ca;
|
|
131
|
+
try {
|
|
132
|
+
ca = readFileSync(egressCaFile, "utf8");
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
const bundle = join(root, "egress-ca-bundle.pem");
|
|
138
|
+
writeFileSync(bundle, `${rootCertificates.join("\n")}\n${ca}\n`, { mode: 0o644 });
|
|
139
|
+
return Object.fromEntries(TRUST_VARIABLES.map((name) => [name, bundle]));
|
|
140
|
+
}
|
|
141
|
+
async function body(request) {
|
|
142
|
+
const chunks = [];
|
|
143
|
+
for await (const chunk of request)
|
|
144
|
+
chunks.push(chunk);
|
|
145
|
+
return chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
146
|
+
}
|
|
147
|
+
function send(response, status, value) {
|
|
148
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
149
|
+
response.end(JSON.stringify(value));
|
|
150
|
+
}
|
|
151
|
+
function failure(response, status, code, message) {
|
|
152
|
+
send(response, status, { error: { code, message } });
|
|
153
|
+
}
|
|
154
|
+
/** The routes, on a handle (tests give their own). */
|
|
155
|
+
export function computerHandler(options) {
|
|
156
|
+
const commands = new ComputerCommands(options.handle);
|
|
157
|
+
const workspace = options.handle.workspace ?? SANDBOX_WORKSPACE;
|
|
158
|
+
return async (request, response) => {
|
|
159
|
+
const url = new URL(request.url ?? "/", "http://computer");
|
|
160
|
+
try {
|
|
161
|
+
if (request.method === "GET" && url.pathname === "/ready") {
|
|
162
|
+
if (options.ready())
|
|
163
|
+
send(response, 200, { status: "ok", version: RUNTIME_VERSION });
|
|
164
|
+
else
|
|
165
|
+
failure(response, 503, "not_ready", `${workspace} is not writable`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const ticket = request.headers["x-nylorun-ticket"];
|
|
169
|
+
const checked = checkTicket(Array.isArray(ticket) ? ticket[0] : ticket, options.key, options.name, options.now?.() ?? Date.now());
|
|
170
|
+
if (!checked.ok)
|
|
171
|
+
return failure(response, 401, "unauthorized", `Ticket refused: ${checked.reason}`);
|
|
172
|
+
const path = url.searchParams.get("path") ?? "";
|
|
173
|
+
if (request.method === "GET" && url.pathname === "/v1/info")
|
|
174
|
+
return send(response, 200, { workspace, diskRoots: options.handle.diskRoots ?? { workspace }, version: RUNTIME_VERSION });
|
|
175
|
+
if (request.method === "POST" && url.pathname === "/v1/exec") {
|
|
176
|
+
const exec = (await body(request));
|
|
177
|
+
if (typeof exec.id !== "string" || typeof exec.command !== "string" || typeof exec.timeoutMs !== "number")
|
|
178
|
+
return failure(response, 400, "invalid_request", "id, command and timeoutMs are required");
|
|
179
|
+
return send(response, 200, await commands.start({ ...exec, cwd: exec.cwd || workspace }));
|
|
180
|
+
}
|
|
181
|
+
const attach = /^\/v1\/exec\/([^/]+)\/(attach|cancel)$/.exec(url.pathname);
|
|
182
|
+
if (request.method === "POST" && attach) {
|
|
183
|
+
const id = decodeURIComponent(attach[1]);
|
|
184
|
+
if (attach[2] === "cancel")
|
|
185
|
+
return send(response, commands.cancel(id) ? 200 : 404, { cancelled: true });
|
|
186
|
+
const { waitMs } = (await body(request));
|
|
187
|
+
const answer = commands.attach(id, waitMs);
|
|
188
|
+
if (!answer)
|
|
189
|
+
return failure(response, 404, "unknown_command", `This computer doesn't know command ${id}`);
|
|
190
|
+
return send(response, 200, await answer);
|
|
191
|
+
}
|
|
192
|
+
if (url.pathname === "/v1/files") {
|
|
193
|
+
if (!path.startsWith("/"))
|
|
194
|
+
return failure(response, 400, "invalid_request", "path must be absolute");
|
|
195
|
+
if (request.method === "GET") {
|
|
196
|
+
const max = Number(url.searchParams.get("maxBytes") ?? "0") || Number.MAX_SAFE_INTEGER;
|
|
197
|
+
const bytes = await options.handle.readBytes(path, max);
|
|
198
|
+
if (bytes === undefined)
|
|
199
|
+
return failure(response, 404, "not_found", `${path} does not exist`);
|
|
200
|
+
response.writeHead(200, { "content-type": "application/octet-stream", "content-length": bytes.length });
|
|
201
|
+
response.end(bytes);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (request.method === "PUT") {
|
|
205
|
+
await options.handle.writeFile(path, request);
|
|
206
|
+
return send(response, 200, { written: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (request.method === "GET" && url.pathname === "/v1/list") {
|
|
210
|
+
const dir = url.searchParams.get("dir") ?? "";
|
|
211
|
+
const max = Number(url.searchParams.get("maxEntries") ?? "10000");
|
|
212
|
+
const listed = await options.handle.listFiles(dir, max);
|
|
213
|
+
if (!listed)
|
|
214
|
+
return failure(response, 404, "not_found", `${dir} is not a directory`);
|
|
215
|
+
return send(response, 200, listed);
|
|
216
|
+
}
|
|
217
|
+
failure(response, 404, "not_found", `No route ${request.method} ${url.pathname}`);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (error instanceof SandboxFileTooLargeError)
|
|
221
|
+
return failure(response, 413, "too_large", error.message);
|
|
222
|
+
if (response.headersSent)
|
|
223
|
+
return void response.destroy();
|
|
224
|
+
failure(response, 500, "computer_error", error instanceof Error ? error.message : String(error));
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** Runs the computer until the process ends. */
|
|
229
|
+
export async function runComputer(config, baseline) {
|
|
230
|
+
const logger = createHostLogger();
|
|
231
|
+
const trust = trustEnvironment(config.root, config.egressCaFile);
|
|
232
|
+
const backend = localBackend({ env: baseline, proxyEnv: () => trust, harnessRoot: config.root });
|
|
233
|
+
const handle = await backend.open({
|
|
234
|
+
key: "computer",
|
|
235
|
+
cpus: 1,
|
|
236
|
+
memoryMiB: 1024,
|
|
237
|
+
network: { preset: "none", hosts: [], suffixes: [] },
|
|
238
|
+
});
|
|
239
|
+
const name = readFileSync(config.nameFile, "utf8").trim();
|
|
240
|
+
const key = createPublicKey(config.ticketKey);
|
|
241
|
+
const ready = () => {
|
|
242
|
+
try {
|
|
243
|
+
accessSync(handle.workspace ?? SANDBOX_WORKSPACE, constants.W_OK);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const server = createServer((request, response) => void computerHandler({ handle, key, name, ready })(request, response));
|
|
251
|
+
server.requestTimeout = 0;
|
|
252
|
+
server.headersTimeout = 30_000;
|
|
253
|
+
await new Promise((resolve, reject) => {
|
|
254
|
+
server.once("error", reject);
|
|
255
|
+
server.listen(config.port, "0.0.0.0", () => resolve());
|
|
256
|
+
});
|
|
257
|
+
logger.info("computer_ready", { name, port: config.port, version: RUNTIME_VERSION });
|
|
258
|
+
let stopping = false;
|
|
259
|
+
for (const signal of ["SIGINT", "SIGTERM"])
|
|
260
|
+
process.on(signal, () => {
|
|
261
|
+
// On Agent Substrate, SIGTERM means the worker pod is stopping: the actor has 30 minutes to
|
|
262
|
+
// be suspended, and the sandboxes service suspends it (Substrate's eviction contract).
|
|
263
|
+
if (signal === "SIGTERM" && config.substrate) {
|
|
264
|
+
logger.info("computer_worker_stopping", { name });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (stopping)
|
|
268
|
+
return;
|
|
269
|
+
stopping = true;
|
|
270
|
+
server.close();
|
|
271
|
+
process.exit(0);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
@@ -11,6 +11,11 @@ export interface HarnessExecutorsOptions {
|
|
|
11
11
|
readonly mcp: McpPool;
|
|
12
12
|
/** The SandboxManager; a harness process creates it once `hello` names the Tenant. */
|
|
13
13
|
readonly sandbox: () => SandboxManager;
|
|
14
|
+
/**
|
|
15
|
+
* The manager of a run whose sandbox is a computer (`routing.sandbox.computer`, ADR-149): one on
|
|
16
|
+
* the computer backend, opened with `computer.open`. Absent: such a run's sandbox calls fail.
|
|
17
|
+
*/
|
|
18
|
+
readonly computer?: (run: HarnessRun) => SandboxManager;
|
|
14
19
|
/**
|
|
15
20
|
* The sandbox tools' limits the calls run with: the harness's configuration, the same it gives
|
|
16
21
|
* `createHarness({ sandboxTools })`. Each left out is its default.
|