@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.
- package/CHANGELOG.md +43 -0
- package/dist/adapters/sandbox/computer.d.ts +28 -0
- package/dist/adapters/sandbox/computer.js +184 -0
- package/dist/api/components.d.ts +48 -0
- package/dist/api/components.js +3 -1
- package/dist/api/http/routes/triggers.js +38 -6
- package/dist/api/openapi.js +1 -0
- package/dist/artifacts/disk.js +10 -2
- package/dist/harness/computer.d.ts +86 -0
- package/dist/harness/computer.js +273 -0
- package/dist/harness/executors.d.ts +5 -0
- package/dist/harness/executors.js +13 -5
- package/dist/harness/pod.d.ts +2 -0
- package/dist/harness/pod.js +1 -1
- package/dist/harness/service.js +58 -22
- package/dist/harness-api/server.js +17 -0
- package/dist/harness-api/start.js +2 -0
- package/dist/harness-api/workspace.d.ts +5 -0
- package/dist/harness-api/workspace.js +9 -5
- package/dist/host/harness-entry.js +7 -2
- package/dist/host/main.js +13 -0
- package/dist/host/stack-config.d.ts +13 -1
- package/dist/host/stack-config.js +42 -2
- package/dist/management-openapi.json +2 -1
- package/dist/openapi.json +348 -44
- package/dist/sandbox/computer.d.ts +53 -0
- package/dist/sandbox/computer.js +271 -0
- package/dist/sandbox/placement.d.ts +5 -2
- package/dist/sandbox/placement.js +6 -3
- package/dist/sandbox/pods/reconcile.js +1 -0
- package/dist/sandbox/pods/spec.d.ts +5 -0
- package/dist/sandbox/pods/spec.js +1 -1
- package/dist/sandbox/records.d.ts +2 -0
- package/dist/sandbox/records.js +11 -0
- package/dist/sandbox/types.d.ts +5 -2
- package/dist/store/postgres/drizzle/0049_agent_triggers_api.sql +3 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +21 -4
- package/dist/store/postgres/schema.js +10 -7
- package/dist/tenant/advance.js +6 -3
- package/dist/tenant/agent-triggers.js +5 -2
- package/dist/tenant/api-triggers.d.ts +33 -0
- package/dist/tenant/api-triggers.js +140 -0
- package/dist/tenant/context.d.ts +3 -0
- package/dist/tenant/runtime.js +34 -0
- package/dist/tenant/sessions.js +2 -1
- package/dist/tenant/sweep.js +2 -1
- package/dist/tenant/transcript.d.ts +8 -8
- package/dist/tenant/triggers.d.ts +6 -4
- package/dist/tenant/triggers.js +22 -11
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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(
|
|
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) =>
|
|
62
|
-
write: (run, path, body, signal) => withSandbox(run, (session, spec) =>
|
|
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
|
|
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) =>
|
|
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
|
}
|
package/dist/harness/pod.d.ts
CHANGED
|
@@ -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,
|
package/dist/harness/pod.js
CHANGED
|
@@ -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",
|
package/dist/harness/service.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
?
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|