@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -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
@@ -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
@@ -53,7 +53,7 @@ import { LOOPBACK_HOSTS } from "@nylorun/core/contracts";
53
53
  import { blockedAddresses } from "../sandbox/pods/network-gate.js";
54
54
  import { HostTuningError, parseHostTuning } from "../tuning.js";
55
55
  import { HOST_IDENTITY_PROVIDERS } from "../vault/host-identity.js";
56
- export const RUNTIME_SERVICES = ["core", "loop", "gates", "keys", "egress", "harness"];
56
+ export const RUNTIME_SERVICES = ["core", "loop", "gates", "keys", "egress", "harness", "computer"];
57
57
  /**
58
58
  * Services that may share a process (D12): they hold the same secrets and parse the same
59
59
  * trust class of input. keys joins gates (F4.2), and egress joins them (F7.2). The harness
@@ -63,6 +63,7 @@ const SERVICE_GROUPS = [
63
63
  ["core", "loop"],
64
64
  ["gates", "keys", "egress"],
65
65
  ["harness"],
66
+ ["computer"],
66
67
  ];
67
68
  /** Where the gates service listens by default. */
68
69
  export const DEFAULT_GATES_LISTEN_PORT = 4100;
@@ -264,6 +265,8 @@ export function parseStackConfig(env, argv) {
264
265
  const tuning = parseTuning(env);
265
266
  if (services.has("harness"))
266
267
  return { services, harness: parseHarnessService(env), endpoints: {}, ...(tuning ? { tuning } : {}) };
268
+ if (services.has("computer"))
269
+ return { services, computer: parseComputerService(env), endpoints: {}, ...(tuning ? { tuning } : {}) };
267
270
  // The image sets NYLORUN_LISTEN_*: only the API's processes read them.
268
271
  const servesApi = services.has("core") || services.has("loop");
269
272
  const listen = servesApi ? parseListen(env) : undefined;
@@ -567,7 +570,16 @@ function parseSandboxes(env) {
567
570
  const harnessImage = read(env, "NYLORUN_SANDBOX_HARNESS_IMAGE");
568
571
  if (harnessImage === undefined || /\s/.test(harnessImage))
569
572
  throw new StackConfigError("NYLORUN_SANDBOX_HARNESS_IMAGE is required with NYLORUN_SANDBOXES_URL: the Runtime image sandbox pods copy the engine from (`nylorun start` sets it)");
570
- return { url: url.replace(/\/+$/, ""), token, harnessImage };
573
+ const gatewayUrl = parseUrl(env, "NYLORUN_SANDBOX_GATEWAY_URL", ["http:", "https:"]);
574
+ const ticketKeyFile = read(env, "NYLORUN_COMPUTER_TICKET_KEY_FILE");
575
+ if ((gatewayUrl === undefined) !== (ticketKeyFile === undefined))
576
+ throw new StackConfigError("NYLORUN_SANDBOX_GATEWAY_URL and NYLORUN_COMPUTER_TICKET_KEY_FILE go together: computers need the sandbox gateway and the key core signs their tickets with");
577
+ return {
578
+ url: url.replace(/\/+$/, ""),
579
+ token,
580
+ harnessImage,
581
+ ...(gatewayUrl && ticketKeyFile ? { computer: { gatewayUrl: gatewayUrl.replace(/\/+$/, ""), ticketKeyFile } } : {}),
582
+ };
571
583
  }
572
584
  /**
573
585
  * `StackConfig.harnessListener` from `NYLORUN_HARNESS_LISTEN_*` and `NYLORUN_HARNESS_TOKEN`
@@ -585,6 +597,34 @@ function parseHarnessListener(env, tokenRequired = true) {
585
597
  }
586
598
  /** What a harness process must not hold: it runs next to agent code. */
587
599
  const HARNESS_REFUSED = ["NYLORUN_DATABASE_URL", "NYLORUN_GATES_TOKEN", "NYLORUN_KEYS_URL"];
600
+ /**
601
+ * The engine in a computer (ADR-149), from the variables its template carries: the port, the
602
+ * ticket key's public half and the file with the actor's name. It holds no Runtime credential.
603
+ */
604
+ function parseComputerService(env) {
605
+ const held = [...HARNESS_REFUSED, "NYLORUN_HARNESS_TOKEN", "NYLORUN_JOIN_TOKEN"].filter((name) => read(env, name) !== undefined);
606
+ if (held.length > 0)
607
+ throw new StackConfigError(`--service computer refuses to start with ${held.join(", ")} set: a computer holds no Runtime credential`);
608
+ const ticketKey = read(env, "NYLORUN_COMPUTER_TICKET_KEY");
609
+ if (ticketKey === undefined || !ticketKey.includes("PUBLIC KEY"))
610
+ throw new StackConfigError("NYLORUN_COMPUTER_TICKET_KEY is required for --service computer: the PEM public key tickets are checked with");
611
+ const nameFile = read(env, "NYLORUN_COMPUTER_NAME_FILE");
612
+ if (nameFile === undefined)
613
+ throw new StackConfigError("NYLORUN_COMPUTER_NAME_FILE is required for --service computer: the file holding the actor's name");
614
+ const rawPort = read(env, "NYLORUN_COMPUTER_PORT");
615
+ const root = read(env, "NYLORUN_HARNESS_ROOT") ?? "/harness";
616
+ if (!root.startsWith("/"))
617
+ throw new StackConfigError(`NYLORUN_HARNESS_ROOT must be an absolute path, not ${root}`);
618
+ const egressCaFile = read(env, "NYLORUN_SANDBOX_EGRESS_CA_FILE");
619
+ return {
620
+ port: rawPort === undefined ? 8080 : parsePort("NYLORUN_COMPUTER_PORT", rawPort),
621
+ ticketKey,
622
+ nameFile,
623
+ root,
624
+ ...(egressCaFile ? { egressCaFile } : {}),
625
+ substrate: read(env, "NYLORUN_SANDBOX_SUBSTRATE") === "1",
626
+ };
627
+ }
588
628
  /** `StackConfig.harness` from `NYLORUN_HARNESS_*` and `NYLORUN_GATES_URL`. */
589
629
  function parseHarnessService(env) {
590
630
  const held = [
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.52.0-beta",
5
+ "version": "0.53.0-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
package/dist/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Runtime API",
5
- "version": "0.52.0-beta",
5
+ "version": "0.53.0-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Computers in core (ADR-149, ADR-150): a pod Agent on an Agent Substrate cluster whose sandboxes
3
+ * service renders computers (`NYLORUN_SANDBOX_GATEWAY_URL` set). Its engine runs no loop: its
4
+ * sessions' runs go to the harness container, and its commands and files go through the sandbox
5
+ * gateway with a ticket core signs here (`computer-ticket.key`, Ed25519; ADR-148).
6
+ *
7
+ * - `computerAccess` wakes the computer when it isn't running (as a turn start does), waits until
8
+ * the sandboxes service says it is ready (its wake-up probe answered), records its first contact
9
+ * (`agent.running` or `agent.relaunched`, the pod's UID for the lost check), and answers where it
10
+ * is and a ticket. Core's own uses (the sandbox route, disk versions, artifacts) open it so.
11
+ * - `openComputer` is what a harness gets with `computer.open`: the same, once a seed being copied
12
+ * onto a new volume is written (R2d step 3), with the disk's setup run through the computer when
13
+ * its marker isn't the recipe's (R2d step 2; `prepareComputerDisk`), and a version's start check
14
+ * passed (T3).
15
+ */
16
+ import { type KeyObject } from "node:crypto";
17
+ import type { TenantContext } from "../tenant/context.js";
18
+ import { type ComputerAccess } from "../adapters/sandbox/computer.js";
19
+ import { SandboxManager } from "./manager.js";
20
+ import type { SandboxHandle } from "./types.js";
21
+ /** How a Tenant reaches its computers. */
22
+ export interface TenantComputers {
23
+ /** The sandbox gateway's base URL (`http://sandbox-gateway:4400`). */
24
+ readonly gatewayUrl: string;
25
+ /** The ticket key's private half. */
26
+ readonly ticketKey: KeyObject;
27
+ /** How long a ticket holds (10 minutes). */
28
+ readonly ttlMs: number;
29
+ }
30
+ export declare const TICKET_TTL_MS: number;
31
+ /** Reads `NYLORUN_COMPUTER_TICKET_KEY_FILE`'s key (PKCS #8 PEM, Ed25519). */
32
+ export declare function loadTicketKey(path: string): KeyObject;
33
+ /** A computer ticket for sandbox `subject`, signed with `key`: a compact JWS (EdDSA). */
34
+ export declare function signTicket(key: KeyObject, subject: string, expiresAt: number): string;
35
+ /** Whether pod Environments of this Tenant are computers. */
36
+ export declare function computersOf(ctx: Pick<TenantContext, "pods">): TenantComputers | undefined;
37
+ /**
38
+ * Wakes computer `id` if needed, waits until it is ready, and answers where it is with a ticket.
39
+ * Refused for a lost, deleted or failed one.
40
+ */
41
+ export declare function computerAccess(ctx: TenantContext, id: string, signal?: AbortSignal): Promise<ComputerAccess>;
42
+ /**
43
+ * What `computer.open` answers a harness: access to computer `id`, once a seed is written, its
44
+ * setup ran, and a version's start check passed. Concurrent opens of one computer share one.
45
+ */
46
+ export declare function openComputer(ctx: TenantContext, id: string, signal?: AbortSignal): Promise<ComputerAccess>;
47
+ /** The disk's setup, run through the computer when its marker isn't the recipe's (R2d step 2). */
48
+ export declare function prepareComputerDisk(ctx: TenantContext, id: string, handle: SandboxHandle, signal?: AbortSignal): Promise<void>;
49
+ /**
50
+ * A SandboxManager per computer, for core's own uses: its backend opens the computer with
51
+ * `computerAccess` (no setup run: the harness's `computer.open` does that before a turn's tools).
52
+ */
53
+ export declare function computerManagers(ctx: () => TenantContext, emit: ConstructorParameters<typeof SandboxManager>[0]["emit"]): (environmentId: string) => SandboxManager;