@nylorun/runtime 0.52.0-beta → 0.53.1-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.
Files changed (40) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/adapters/sandbox/computer.d.ts +28 -0
  3. package/dist/adapters/sandbox/computer.js +184 -0
  4. package/dist/artifacts/disk.js +10 -2
  5. package/dist/harness/computer.d.ts +86 -0
  6. package/dist/harness/computer.js +273 -0
  7. package/dist/harness/executors.d.ts +5 -0
  8. package/dist/harness/executors.js +13 -5
  9. package/dist/harness/pod.d.ts +2 -0
  10. package/dist/harness/pod.js +1 -1
  11. package/dist/harness/service.js +58 -22
  12. package/dist/harness-api/server.js +17 -0
  13. package/dist/harness-api/start.js +2 -0
  14. package/dist/harness-api/workspace.d.ts +5 -0
  15. package/dist/harness-api/workspace.js +9 -5
  16. package/dist/host/harness-entry.js +7 -2
  17. package/dist/host/main.js +13 -0
  18. package/dist/host/stack-config.d.ts +13 -1
  19. package/dist/host/stack-config.js +42 -2
  20. package/dist/management-openapi.json +1 -1
  21. package/dist/openapi.json +1 -1
  22. package/dist/sandbox/computer.d.ts +53 -0
  23. package/dist/sandbox/computer.js +271 -0
  24. package/dist/sandbox/placement.d.ts +5 -2
  25. package/dist/sandbox/placement.js +6 -3
  26. package/dist/sandbox/pods/reconcile.js +1 -0
  27. package/dist/sandbox/pods/spec.d.ts +5 -0
  28. package/dist/sandbox/pods/spec.js +1 -1
  29. package/dist/sandbox/records.d.ts +2 -0
  30. package/dist/sandbox/records.js +11 -0
  31. package/dist/sandbox/types.d.ts +5 -2
  32. package/dist/tenant/advance.js +6 -3
  33. package/dist/tenant/context.d.ts +3 -0
  34. package/dist/tenant/runtime.js +34 -0
  35. package/dist/tenant/sessions.js +2 -1
  36. package/dist/tenant/sweep.js +2 -1
  37. package/dist/tenant/transcript.d.ts +8 -8
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.53.1-beta
4
+
5
+ ### Patch Changes
6
+
7
+ - 7c9a325: The Runtime's Helm chart, as tested on GKE with Cloud SQL and GCS: tokens and the admin key as 32
8
+ bytes of hex (a kept value in another form is replaced); the Restate worker and the Harness API
9
+ through `<name>-runtime-internal`, which publishes the runtime before it is ready; probes with a
10
+ loopback `Host`; Studio's image by its own version (`nylorun.com/studio-version`, kept by the
11
+ release); an external Postgres needs no C-collation database.
12
+
13
+ ## 0.53.0-beta
14
+
15
+ ### Minor Changes
16
+
17
+ - 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.
18
+
19
+ - `@nylorun/core`: Harness API 5.6 (`harness-api/v5.6.json`): `routing.sandbox.computer` and the request `computer.open`.
20
+ - `@nylorun/runtime`: `--service computer`; the `computer` sandbox backend; `NYLORUN_SANDBOX_GATEWAY_URL` and `NYLORUN_COMPUTER_TICKET_KEY_FILE`.
21
+
22
+ - 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.
23
+ - 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`.
24
+
25
+ ### Patch Changes
26
+
27
+ - 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.
28
+ - Pin core to the tested release.
29
+ - Pin harness to the tested release.
30
+ - Updated dependencies [5b49361]
31
+ - Updated dependencies
32
+ - @nylorun/core@0.46.0-beta
33
+ - @nylorun/harness@0.36.6-beta
34
+
3
35
  ## 0.52.0-beta
4
36
 
5
37
  ### 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
+ }
@@ -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.