@nylorun/runtime 0.51.2-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.
Files changed (53) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/adapters/sandbox/computer.d.ts +28 -0
  3. package/dist/adapters/sandbox/computer.js +184 -0
  4. package/dist/api/components.d.ts +48 -0
  5. package/dist/api/components.js +3 -1
  6. package/dist/api/http/routes/triggers.js +38 -6
  7. package/dist/api/openapi.js +1 -0
  8. package/dist/artifacts/disk.js +10 -2
  9. package/dist/harness/computer.d.ts +86 -0
  10. package/dist/harness/computer.js +273 -0
  11. package/dist/harness/executors.d.ts +5 -0
  12. package/dist/harness/executors.js +13 -5
  13. package/dist/harness/pod.d.ts +2 -0
  14. package/dist/harness/pod.js +1 -1
  15. package/dist/harness/service.js +58 -22
  16. package/dist/harness-api/server.js +17 -0
  17. package/dist/harness-api/start.js +2 -0
  18. package/dist/harness-api/workspace.d.ts +5 -0
  19. package/dist/harness-api/workspace.js +9 -5
  20. package/dist/host/harness-entry.js +7 -2
  21. package/dist/host/main.js +13 -0
  22. package/dist/host/stack-config.d.ts +13 -1
  23. package/dist/host/stack-config.js +42 -2
  24. package/dist/management-openapi.json +2 -1
  25. package/dist/openapi.json +348 -44
  26. package/dist/sandbox/computer.d.ts +53 -0
  27. package/dist/sandbox/computer.js +271 -0
  28. package/dist/sandbox/placement.d.ts +5 -2
  29. package/dist/sandbox/placement.js +6 -3
  30. package/dist/sandbox/pods/reconcile.js +1 -0
  31. package/dist/sandbox/pods/spec.d.ts +5 -0
  32. package/dist/sandbox/pods/spec.js +1 -1
  33. package/dist/sandbox/records.d.ts +2 -0
  34. package/dist/sandbox/records.js +11 -0
  35. package/dist/sandbox/types.d.ts +5 -2
  36. package/dist/store/postgres/drizzle/0049_agent_triggers_api.sql +3 -0
  37. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  38. package/dist/store/postgres/schema.d.ts +21 -4
  39. package/dist/store/postgres/schema.js +10 -7
  40. package/dist/tenant/advance.js +6 -3
  41. package/dist/tenant/agent-triggers.js +5 -2
  42. package/dist/tenant/api-triggers.d.ts +33 -0
  43. package/dist/tenant/api-triggers.js +140 -0
  44. package/dist/tenant/context.d.ts +3 -0
  45. package/dist/tenant/runtime.js +34 -0
  46. package/dist/tenant/sessions.js +2 -1
  47. package/dist/tenant/sweep.js +2 -1
  48. package/dist/tenant/transcript.d.ts +8 -8
  49. package/dist/tenant/triggers.d.ts +6 -4
  50. package/dist/tenant/triggers.js +22 -11
  51. package/dist/version.d.ts +1 -1
  52. package/dist/version.js +1 -1
  53. package/package.json +3 -3
@@ -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.
@@ -14,6 +14,14 @@ import { serversOf } from "../mcp/pool.js";
14
14
  import { isHttpToolCall } from "../gates/http-tool.js";
15
15
  import { abortOn, callHttpTool, callMcpTool, callSandboxTool, invokeModel, isRemoteMcpCall, } from "./calls.js";
16
16
  export function harnessExecutors(options) {
17
+ /** The run's SandboxManager: its computer's, or the harness's own. */
18
+ const managerOf = (run) => {
19
+ if (run.start.routing.sandbox?.computer !== true)
20
+ return options.sandbox();
21
+ if (!options.computer)
22
+ throw new Error("This harness can't reach computers: the run's sandbox is one");
23
+ return options.computer(run);
24
+ };
17
25
  const recoversTools = options.toolGate.recovers === true;
18
26
  const sandboxTools = sandboxToolLimitsOf(options.sandboxTools);
19
27
  const atGate = (effect, run) => {
@@ -36,7 +44,7 @@ export function harnessExecutors(options) {
36
44
  return callHttpTool(options.toolGate, effect, abortOn(signal, kinds), traceparent);
37
45
  // A sandbox command joins its call's span (R7.4): a CLI that reads TRACEPARENT traces under it.
38
46
  if (effect.target?.kind === "sandbox")
39
- return callSandboxTool(options.sandbox(), traceparent ? { ...routing, environment: { ...routing.environment, TRACEPARENT: traceparent } } : routing, effect, signal, sandboxTools);
47
+ return callSandboxTool(managerOf(run), traceparent ? { ...routing, environment: { ...routing.environment, TRACEPARENT: traceparent } } : routing, effect, signal, sandboxTools);
40
48
  if (effect.target?.kind !== "mcp")
41
49
  throw new Error(`This harness runs no ${effect.target?.kind ?? "untargeted"} call of '${effect.toolName ?? ""}'`);
42
50
  return callMcpTool(options.mcp, routing, effect, abortOn(signal, kinds), run.start.limits.toolAnswerBytes, traceparent);
@@ -58,17 +66,17 @@ export function harnessExecutors(options) {
58
66
  },
59
67
  prepare: (run, record) => prepareMcp(options.mcp, run, record),
60
68
  files: {
61
- read: (run, path, maxBytes, signal) => withSandbox(run, (session, spec) => options.sandbox().readBytes(session, spec, path, maxBytes, signal)),
62
- write: (run, path, body, signal) => withSandbox(run, (session, spec) => options.sandbox().writeBytes(session, spec, path, body, signal)),
69
+ read: (run, path, maxBytes, signal) => withSandbox(run, (session, spec) => managerOf(run).readBytes(session, spec, path, maxBytes, signal)),
70
+ write: (run, path, body, signal) => withSandbox(run, (session, spec) => managerOf(run).writeBytes(session, spec, path, body, signal)),
63
71
  list: (run, dir, maxEntries, signal) => withSandbox(run, async (session, spec) => {
64
- const listed = await options.sandbox().listFiles(session, spec, dir, maxEntries, signal);
72
+ const listed = await managerOf(run).listFiles(session, spec, dir, maxEntries, signal);
65
73
  return listed.kind === "listed"
66
74
  ? { kind: "listed", entries: listed.listing.entries, truncated: listed.listing.truncated }
67
75
  : listed.kind === "missing"
68
76
  ? { kind: "missing" }
69
77
  : listed;
70
78
  }),
71
- mount: (run, folder, files, read, signal) => withSandbox(run, (session, spec) => options.sandbox().mountFolder(session, spec, folder, files, read, signal)),
79
+ mount: (run, folder, files, read, signal) => withSandbox(run, (session, spec) => managerOf(run).mountFolder(session, spec, folder, files, read, signal)),
72
80
  },
73
81
  };
74
82
  }
@@ -22,6 +22,8 @@ export interface DiskSetupReport {
22
22
  readonly exitCode?: number;
23
23
  readonly output?: string;
24
24
  }
25
+ /** The variables CLIs read their trust bundle from: OpenSSL, Node, Python, curl, git, AWS. */
26
+ export declare const TRUST_VARIABLES: readonly ["SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "AWS_CA_BUNDLE"];
25
27
  export interface PodHost {
26
28
  /**
27
29
  * A current host token, for the next connection. Throws when neither renewal nor join works,
@@ -19,7 +19,7 @@ import { hostTuning } from "../tuning.js";
19
19
  import { RUNTIME_VERSION } from "../version.js";
20
20
  import { IMAGE_ENGINE_HEADER, isBuiltinEngine } from "./image-engine.js";
21
21
  /** The variables CLIs read their trust bundle from: OpenSSL, Node, Python, curl, git, AWS. */
22
- const TRUST_VARIABLES = [
22
+ export const TRUST_VARIABLES = [
23
23
  "SSL_CERT_FILE",
24
24
  "NODE_EXTRA_CA_CERTS",
25
25
  "REQUESTS_CA_BUNDLE",
@@ -15,7 +15,8 @@ import { DEFAULT_HARNESS, HarnessApiError, } from "@nylorun/core/harness-api";
15
15
  import { openMcpThrough } from "../gates/tool-gate.js";
16
16
  import { McpPool } from "../mcp/pool.js";
17
17
  import { SandboxManager } from "../sandbox/manager.js";
18
- import { fileSandboxRecords, workspacePrefix } from "../sandbox/records.js";
18
+ import { computerBackend } from "../adapters/sandbox/computer.js";
19
+ import { fileSandboxRecords, memorySandboxRecords, workspacePrefix } from "../sandbox/records.js";
19
20
  import { defaultSandboxBackends } from "../sandbox/select.js";
20
21
  import { harnessExecutors } from "./executors.js";
21
22
  import { connectHarness } from "./ws-client.js";
@@ -57,6 +58,60 @@ export function startHarnessService(options) {
57
58
  throw new HarnessApiError("unavailable", "The harness has not said hello yet");
58
59
  return manager;
59
60
  };
61
+ // A claim core refuses (its run ended, the session is gone) is dropped, as core's own
62
+ // SandboxManager drops an event it cannot write.
63
+ const emit = async (sessionId, turnId, type, payload, meta) => {
64
+ const channel = client.channel();
65
+ if (!channel)
66
+ return;
67
+ await channel
68
+ .request("event", {
69
+ ...(meta.claim === undefined ? {} : { runId: meta.claim }),
70
+ sessionId,
71
+ turnId,
72
+ type,
73
+ payload,
74
+ ...(meta.record ? { record: meta.record } : {}),
75
+ })
76
+ .catch((error) => logger.warn("harness sandbox event not claimed", {
77
+ sessionId,
78
+ type,
79
+ message: error instanceof Error ? error.message : String(error),
80
+ }));
81
+ };
82
+ // Computers (ADR-149): one manager per computer, on the computer backend, opened with
83
+ // `computer.open` for the latest run that used it.
84
+ const computers = new Map();
85
+ const computerOf = (run) => {
86
+ const environmentId = run.start.routing.sandbox?.environmentId;
87
+ if (environmentId === undefined)
88
+ throw new HarnessApiError("invalid", "A computer's run names its Environment");
89
+ const known = computers.get(environmentId);
90
+ if (known) {
91
+ known.runId = run.runId;
92
+ return known.manager;
93
+ }
94
+ const entry = { runId: run.runId };
95
+ entry.manager = new SandboxManager({
96
+ scope: tenantId,
97
+ records: memorySandboxRecords(),
98
+ backends: [
99
+ computerBackend({
100
+ open: async (signal) => {
101
+ const channel = client.channel();
102
+ if (!channel)
103
+ throw new HarnessApiError("unavailable", "Not connected to core");
104
+ return channel.request("computer.open", { runId: entry.runId, environmentId }, signal);
105
+ },
106
+ }),
107
+ ],
108
+ preference: "auto",
109
+ ephemeral: false,
110
+ emit,
111
+ });
112
+ computers.set(environmentId, entry);
113
+ return entry.manager;
114
+ };
60
115
  const pool = new McpPool({ openRemote: openMcpThrough(options.toolGate) });
61
116
  // How often the harness closes MCP connections idle past their timeout.
62
117
  const sweep = setInterval(() => void pool.sweep().catch(() => undefined), hostTuning().harness.mcpSweepMs);
@@ -75,6 +130,7 @@ export function startHarnessService(options) {
75
130
  toolGate: options.toolGate,
76
131
  mcp: pool,
77
132
  sandbox,
133
+ computer: (run) => computerOf(run),
78
134
  ...(sandboxTools ? { sandboxTools } : {}),
79
135
  });
80
136
  const serve = async (method, params, signal) => {
@@ -142,27 +198,7 @@ export function startHarnessService(options) {
142
198
  backends,
143
199
  preference: answer.sandbox.backend ?? "auto",
144
200
  ephemeral: options.ephemeral === true,
145
- // A claim core refuses (its run ended, the session is gone) is dropped, as core's own
146
- // SandboxManager drops an event it cannot write.
147
- emit: async (sessionId, turnId, type, payload, meta) => {
148
- const channel = client.channel();
149
- if (!channel)
150
- return;
151
- await channel
152
- .request("event", {
153
- ...(meta.claim === undefined ? {} : { runId: meta.claim }),
154
- sessionId,
155
- turnId,
156
- type,
157
- payload,
158
- ...(meta.record ? { record: meta.record } : {}),
159
- })
160
- .catch((error) => logger.warn("harness sandbox event not claimed", {
161
- sessionId,
162
- type,
163
- message: error instanceof Error ? error.message : String(error),
164
- }));
165
- },
201
+ emit,
166
202
  });
167
203
  },
168
204
  ...(options.onGrant ? { onGrant: options.onGrant } : {}),
@@ -27,6 +27,8 @@
27
27
  * the sandbox's (`revokeHost`: a new join, a stop, a loss) is closed; its runs end as
28
28
  * `connection.lost`.
29
29
  */
30
+ import { openComputer } from "../sandbox/computer.js";
31
+ import { HttpError } from "../tenant/http.js";
30
32
  import { randomUUID } from "node:crypto";
31
33
  import { DEFAULT_HARNESS, HARNESS_API_VERSION, HARNESS_CLAIMS, isHarnessId, HarnessApiError, } from "@nylorun/core/harness-api";
32
34
  import { definitionFilesOf, limitExceededMessage } from "@nylorun/core/contracts";
@@ -186,6 +188,21 @@ export function createHarnessApiServer(ctx, options = {}) {
186
188
  return recordMcp(run, p);
187
189
  case "definition.file":
188
190
  return definitionFile(run, p, signal);
191
+ case "computer.open": {
192
+ // Only the computer of the run's own session (ADR-149).
193
+ const { environmentId } = p;
194
+ const sandbox = run.offer.start.routing.sandbox;
195
+ if (sandbox?.computer !== true || sandbox.environmentId !== environmentId)
196
+ throw new HarnessApiError("invalid", "This run has no such computer");
197
+ try {
198
+ return await openComputer(ctx, environmentId, signal);
199
+ }
200
+ catch (error) {
201
+ if (error instanceof HttpError)
202
+ throw new HarnessApiError(error.status === 409 || error.status === 503 ? "unavailable" : "invalid", error.message);
203
+ throw error;
204
+ }
205
+ }
189
206
  case "artifact.read":
190
207
  return artifacts.read(artifactScope(run), p, signal);
191
208
  case "artifact.write":
@@ -74,6 +74,8 @@ options = {}) {
74
74
  ...workspace,
75
75
  ...(Object.keys(environment).length > 0 ? { environment } : {}),
76
76
  ...(current.sandbox === undefined ? {} : { spec: current.sandbox }),
77
+ // A pod Agent's sandbox is a computer when the Tenant's are (ADR-149).
78
+ ...(resource?.kind === "pod" && ctx.pods?.computer ? { computer: true } : {}),
77
79
  };
78
80
  const turnId = current.activeTurnId;
79
81
  const noted = current.memoryIndex;
@@ -50,5 +50,10 @@ export declare function remoteWorkspace(options: RemoteWorkspaceOptions): Worksp
50
50
  */
51
51
  export declare function withPodWorkspaces(base: WorkspacePort, options: Omit<RemoteWorkspaceOptions, "pod"> & {
52
52
  readonly isPod: (environmentId: string) => Promise<boolean>;
53
+ /**
54
+ * Computers (ADR-149): a pod Agent's workspace, reached through the sandbox gateway rather
55
+ * than a pod's host connection.
56
+ */
57
+ readonly computer?: (environmentId: string) => WorkspacePort;
53
58
  }): WorkspacePort;
54
59
  export {};
@@ -12,6 +12,8 @@
12
12
  * stop idle workspaces and list them (`workspace.sweep`), decides which owners are gone, and
13
13
  * removes theirs (`workspace.remove`); it also keeps the `sandboxes` table in step with the
14
14
  * list.
15
+ * - **Computers** (ADR-149): a pod Agent's workspace when the Tenant's pod Agents are computers,
16
+ * through a SandboxManager on the computer backend (`sandbox/computer.ts`).
15
17
  * - **Pod sandboxes** (F7.2, `withPodWorkspaces`): a session attached to a pod sandbox has its
16
18
  * workspace in the pod, served by the sandbox's engine (its host connection). Its tool calls
17
19
  * and disk operations go there whatever serves the other workspaces; with the pod stopped or
@@ -165,11 +167,13 @@ export function remoteWorkspace(options) {
165
167
  */
166
168
  export function withPodWorkspaces(base, options) {
167
169
  const routed = async (session) => session.environmentId !== undefined && (await options.isPod(session.environmentId))
168
- ? remoteWorkspace({
169
- ...options,
170
- pod: session.environmentId,
171
- ...(session.hostEpoch === undefined ? {} : { minEpoch: session.hostEpoch }),
172
- })
170
+ ? options.computer
171
+ ? options.computer(session.environmentId)
172
+ : remoteWorkspace({
173
+ ...options,
174
+ pod: session.environmentId,
175
+ ...(session.hostEpoch === undefined ? {} : { minEpoch: session.hostEpoch }),
176
+ })
173
177
  : base;
174
178
  return {
175
179
  ...base,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The pod's engine (`--service harness` in a sandbox pod), on its own: what `main()` does for that
2
+ * The pod's engine (`--service harness` in a sandbox pod, or `--service computer` in a computer), on its own: what `main()` does for that
3
3
  * service, without importing the rest of the Host. `scripts/bundle-harness.mjs` bundles this file
4
4
  * into one script, which the Runtime image ships as `/engine/harness.mjs` and the sandboxes service
5
5
  * copies into each pod instead of `/app` (ADR-92 §5: 12 files instead of about 30,000).
@@ -7,13 +7,18 @@
7
7
  import { baselineEnvironment } from "./environment.js";
8
8
  import { parseStackConfig } from "./stack-config.js";
9
9
  import { runHarness } from "../harness/main.js";
10
+ import { runComputer } from "../harness/computer.js";
10
11
  import { useHostTuning } from "../tuning.js";
11
12
  import { useHostIdentity } from "../vault/host-identity.js";
12
13
  import { useWorkloadToken } from "../vault/workload-token.js";
13
14
  async function start() {
14
15
  const stack = parseStackConfig(process.env, process.argv.slice(2));
16
+ if (stack.computer) {
17
+ await runComputer(stack.computer, baselineEnvironment(process.env));
18
+ return;
19
+ }
15
20
  if (!stack.services.has("harness"))
16
- throw new Error("This entry runs only --service harness");
21
+ throw new Error("This entry runs only --service harness or computer");
17
22
  if (stack.tuning)
18
23
  useHostTuning(stack.tuning.values, stack.tuning.overrides);
19
24
  useWorkloadToken(stack.workloadToken ?? {});
package/dist/host/main.js CHANGED
@@ -61,6 +61,8 @@ import { httpModelGate } from "../gates/http-client.js";
61
61
  import { createS3BlobStore } from "../blob/index.js";
62
62
  import { createRunGrants } from "../tenant/run-grants.js";
63
63
  import { runHarness } from "../harness/main.js";
64
+ import { runComputer } from "../harness/computer.js";
65
+ import { loadTicketKey, TICKET_TTL_MS } from "../sandbox/computer.js";
64
66
  import { startHarnessListener } from "../harness-api/ws-server.js";
65
67
  import { httpSandboxesClient } from "../sandbox/pods/client.js";
66
68
  import { createCredentialProvider } from "../gates/credential-provider.js";
@@ -234,6 +236,8 @@ export async function main() {
234
236
  return runGates(stack);
235
237
  if (stack.services.has("harness"))
236
238
  return runHarness(stack, baselineEnvironment(process.env));
239
+ if (stack.computer)
240
+ return runComputer(stack.computer, baselineEnvironment(process.env));
237
241
  const hostRoot = resolveHostRoot();
238
242
  const paths = hostPaths(hostRoot);
239
243
  mkdirSync(paths.home, { recursive: true });
@@ -385,6 +389,15 @@ export async function main() {
385
389
  ? {
386
390
  client: httpSandboxesClient({ url: stack.sandboxes.url, token: stack.sandboxes.token }),
387
391
  harnessImage: stack.sandboxes.harnessImage,
392
+ ...(stack.sandboxes.computer
393
+ ? {
394
+ computer: {
395
+ gatewayUrl: stack.sandboxes.computer.gatewayUrl,
396
+ ticketKey: loadTicketKey(stack.sandboxes.computer.ticketKeyFile),
397
+ ttlMs: TICKET_TTL_MS,
398
+ },
399
+ }
400
+ : {}),
388
401
  }
389
402
  : undefined;
390
403
  // The outside credential provider: each pod Agent segment's variables it names (CP-E).
@@ -50,8 +50,9 @@ import { type TraceResource } from "../traces/otlp.js";
50
50
  import { type TraceContentKind } from "../traces/map.js";
51
51
  import { type BlockedAddress } from "../sandbox/pods/network-gate.js";
52
52
  import { type HostTuning } from "../tuning.js";
53
+ import type { ComputerServiceConfig } from "../harness/computer.js";
53
54
  /** A Runtime service this release has. */
54
- export type RuntimeService = "core" | "loop" | "gates" | "keys" | "egress" | "harness";
55
+ export type RuntimeService = "core" | "loop" | "gates" | "keys" | "egress" | "harness" | "computer";
55
56
  export type RuntimeServices = ReadonlySet<RuntimeService>;
56
57
  export declare const RUNTIME_SERVICES: readonly RuntimeService[];
57
58
  /** Where the gates service listens by default. */
@@ -148,6 +149,15 @@ export interface SandboxesConfig {
148
149
  token: string;
149
150
  /** `NYLORUN_SANDBOX_HARNESS_IMAGE` (the stack sets it to the Runtime image). */
150
151
  harnessImage: string;
152
+ /**
153
+ * Computers (ADR-149): `NYLORUN_SANDBOX_GATEWAY_URL`, the sandbox gateway, and
154
+ * `NYLORUN_COMPUTER_TICKET_KEY_FILE`, the key core signs tickets with. With them, every pod
155
+ * Agent is a computer: its runs go to the harness container.
156
+ */
157
+ computer?: {
158
+ gatewayUrl: string;
159
+ ticketKeyFile: string;
160
+ };
151
161
  }
152
162
  /**
153
163
  * The engine in a pod sandbox (`--service harness` with `NYLORUN_SANDBOX_KIND=pod`, F7.2): no
@@ -226,6 +236,8 @@ export interface StackConfig {
226
236
  egress?: EgressConfig;
227
237
  /** Present when the process runs the harness service (`--service harness`). */
228
238
  harness?: HarnessServiceConfig;
239
+ /** `--service computer` (ADR-149): the engine in a computer. */
240
+ computer?: ComputerServiceConfig;
229
241
  /**
230
242
  * `NYLORUN_HARNESS` (`in-process` or `remote`; absent means `in-process`), for a process running
231
243
  * core or loop: whether the Tenant runs its own harness, or harnesses connect to