@nylorun/runtime 0.7.0-beta → 0.9.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 +50 -0
- package/README.md +7 -5
- package/dist/adapters/sandbox/microsandbox.d.ts +2 -0
- package/dist/adapters/sandbox/microsandbox.js +238 -0
- package/dist/adapters/sandbox/virtual.d.ts +4 -0
- package/dist/adapters/sandbox/virtual.js +125 -0
- package/dist/configuration.d.ts +1 -1
- package/dist/configuration.js +1 -1
- package/dist/core/executors.d.ts +32 -0
- package/dist/core/executors.js +60 -0
- package/dist/core/main.js +7 -12
- package/dist/core/runtime.d.ts +24 -1
- package/dist/core/runtime.js +364 -43
- package/dist/core/store.d.ts +14 -1
- package/dist/core/store.js +59 -3
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/mcp/pool.d.ts +13 -1
- package/dist/mcp/pool.js +53 -19
- package/dist/mcp/snapshot.d.ts +4 -0
- package/dist/model/catalog.d.ts +12 -0
- package/dist/model/catalog.js +29 -0
- package/dist/model/models.d.ts +3 -1
- package/dist/model/models.js +4 -2
- package/dist/model/pi-model.d.ts +9 -3
- package/dist/model/pi-model.js +47 -11
- package/dist/model/settings.d.ts +0 -2
- package/dist/model/settings.js +0 -47
- package/dist/node/index.d.ts +1 -1
- package/dist/node/index.js +1 -1
- package/dist/sandbox/manager.d.ts +61 -0
- package/dist/sandbox/manager.js +239 -0
- package/dist/sandbox/policy.d.ts +13 -0
- package/dist/sandbox/policy.js +67 -0
- package/dist/sandbox/select.d.ts +35 -0
- package/dist/sandbox/select.js +76 -0
- package/dist/sandbox/tools.d.ts +25 -0
- package/dist/sandbox/tools.js +165 -0
- package/dist/sandbox/types.d.ts +66 -0
- package/dist/vault/service.d.ts +41 -1
- package/dist/vault/service.js +291 -4
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/package.json +9 -4
- package/dist/config.d.ts +0 -20
- package/dist/model/defaults.d.ts +0 -17
- package/dist/model/defaults.js +0 -21
- package/dist/model/http-model.d.ts +0 -12
- package/dist/model/http-model.js +0 -299
- package/dist/node/local-sessions.d.ts +0 -5
- package/dist/node/local-sessions.js +0 -174
- package/dist/server/ag-ui.d.ts +0 -8
- package/dist/server/ag-ui.js +0 -76
- package/dist/server/delivery.d.ts +0 -24
- package/dist/server/delivery.js +0 -107
- package/dist/server/digests.d.ts +0 -13
- package/dist/server/digests.js +0 -39
- package/dist/server/host.d.ts +0 -70
- package/dist/server/host.js +0 -647
- package/dist/session/api.d.ts +0 -10
- package/dist/session/api.js +0 -15
- package/dist/session/default.d.ts +0 -5
- package/dist/session/default.js +0 -30
- package/dist/session/handle.d.ts +0 -27
- package/dist/session/handle.js +0 -199
- package/dist/session/index.d.ts +0 -2
- package/dist/session/index.js +0 -2
- package/dist/sessions/host.d.ts +0 -39
- package/dist/sessions/host.js +0 -359
- package/dist/sessions/store.d.ts +0 -41
- package/dist/sessions/store.js +0 -33
- /package/dist/{config.js → sandbox/types.js} +0 -0
package/dist/core/runtime.js
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, readFileSync, } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, readFileSync, realpathSync, mkdtempSync, } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { AgentManifestSchema, CreateCredentialRequestSchema, CreateVaultRequestSchema, PutAgentRequestSchema, PutHostModelRequestSchema, SelectHostModelRequestSchema, PutSessionRequestSchema, RegisterExecutorsRequestSchema, RotateCredentialRequestSchema, SessionCommandSchema, ActionClaimRequestSchema, ActionHeartbeatRequestSchema, } from "@nylorun/core/contracts";
|
|
6
8
|
import { createDurableCheckpoint, runDurable, } from "@nylorun/harness/run";
|
|
7
9
|
import { hashManifest } from "@nylorun/core/compatibility";
|
|
8
|
-
import { schemaFromJSON, } from "@nylorun/core/define";
|
|
10
|
+
import { delegateManifest, schemaFromJSON, } from "@nylorun/core/define";
|
|
9
11
|
import { Store, canonical } from "./store.js";
|
|
12
|
+
import { ExecutorRegistry, assertExecutorCredential, hashToken, } from "./executors.js";
|
|
13
|
+
import { RUNTIME_VERSION } from "../version.js";
|
|
14
|
+
import { hostModelCatalog } from "../model/catalog.js";
|
|
15
|
+
import { piModel } from "../model/pi-model.js";
|
|
10
16
|
import { scriptedModel } from "./provider.js";
|
|
11
17
|
import { scrub } from "../redact.js";
|
|
12
18
|
import { createKekFile, defaultKekPath, readVaultKek } from "../vault/kek.js";
|
|
13
19
|
import { VaultError } from "../vault/error.js";
|
|
14
20
|
import { VaultService } from "../vault/service.js";
|
|
15
|
-
import { McpPool } from "../mcp/pool.js";
|
|
21
|
+
import { McpPool, serversOf } from "../mcp/pool.js";
|
|
22
|
+
import { SandboxManager, sandboxCapabilityOf } from "../sandbox/manager.js";
|
|
23
|
+
import { defaultSandboxBackends } from "../sandbox/select.js";
|
|
16
24
|
class HttpError extends Error {
|
|
17
25
|
status;
|
|
18
26
|
constructor(status, message) {
|
|
@@ -27,6 +35,7 @@ function sessionToolsOf(snapshot) {
|
|
|
27
35
|
if (!snapshot?.mcpTools.length)
|
|
28
36
|
return undefined;
|
|
29
37
|
return snapshot.mcpTools.map((tool) => ({
|
|
38
|
+
...(tool.agentId === undefined ? {} : { agentId: tool.agentId }),
|
|
30
39
|
capabilityId: tool.capabilityId,
|
|
31
40
|
name: tool.name,
|
|
32
41
|
...(tool.description === undefined
|
|
@@ -38,8 +47,14 @@ function sessionToolsOf(snapshot) {
|
|
|
38
47
|
: { outputSchema: tool.outputSchema }),
|
|
39
48
|
}));
|
|
40
49
|
}
|
|
41
|
-
function mcpToolOf(session,
|
|
42
|
-
return session.mcpSnapshot?.mcpTools.find((tool) => tool.
|
|
50
|
+
function mcpToolOf(session, request) {
|
|
51
|
+
return session.mcpSnapshot?.mcpTools.find((tool) => tool.agentId === request.agent?.id &&
|
|
52
|
+
tool.capabilityId === request.capabilityId &&
|
|
53
|
+
tool.name === request.toolName);
|
|
54
|
+
}
|
|
55
|
+
/** The manifest of the agent an effect belongs to: the root, or an agent it uses as a tool. */
|
|
56
|
+
function manifestFor(manifest, agent) {
|
|
57
|
+
return agent ? delegateManifest(manifest, agent.id) : manifest;
|
|
43
58
|
}
|
|
44
59
|
function pinnedTool(manifest, capabilityId, toolName) {
|
|
45
60
|
const capability = manifest.capabilities.find((item) => item.id === capabilityId);
|
|
@@ -112,10 +127,15 @@ export class CoreRuntime {
|
|
|
112
127
|
running = new Map();
|
|
113
128
|
pending = new Set();
|
|
114
129
|
observers = new Map();
|
|
115
|
-
|
|
130
|
+
// Keyed by token hash so a rotation can end exactly the streams that the replaced token owns.
|
|
131
|
+
executorStreams = new Map();
|
|
132
|
+
registry = new ExecutorRegistry();
|
|
133
|
+
scopeId;
|
|
116
134
|
mcp;
|
|
135
|
+
sandbox;
|
|
117
136
|
server;
|
|
118
137
|
closing = false;
|
|
138
|
+
closed = false;
|
|
119
139
|
lockPath;
|
|
120
140
|
timer;
|
|
121
141
|
constructor(options) {
|
|
@@ -128,11 +148,8 @@ export class CoreRuntime {
|
|
|
128
148
|
throw new Error("Executor tokens must be unique");
|
|
129
149
|
if (!options.serverToken || options.serverToken.length < 16)
|
|
130
150
|
throw new Error("A server token of at least 16 characters is required");
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
!e.agentId ||
|
|
134
|
-
!e.implementationVersion))
|
|
135
|
-
throw new Error("Executor tokens require independent credentials and an agent id");
|
|
151
|
+
for (const executor of options.executors)
|
|
152
|
+
assertExecutorCredential(executor, options.serverToken);
|
|
136
153
|
if (options.sqlitePath !== ":memory:") {
|
|
137
154
|
mkdirSync(dirname(options.sqlitePath), { recursive: true });
|
|
138
155
|
this.lockPath = options.sqlitePath + ".runtime-lock";
|
|
@@ -177,11 +194,75 @@ export class CoreRuntime {
|
|
|
177
194
|
unlinkSync(this.lockPath);
|
|
178
195
|
throw e;
|
|
179
196
|
}
|
|
197
|
+
// A short digest of the resolved database path identifies this scope to the CLI without
|
|
198
|
+
// disclosing the path itself over the unauthenticated health route.
|
|
199
|
+
this.scopeId =
|
|
200
|
+
options.sqlitePath === ":memory:"
|
|
201
|
+
? "memory"
|
|
202
|
+
: createHash("sha256")
|
|
203
|
+
.update(realpathSync(options.sqlitePath))
|
|
204
|
+
.digest("hex")
|
|
205
|
+
.slice(0, 16);
|
|
206
|
+
// Registrations persisted by PUT /v1/executors load first; scopes supplied through
|
|
207
|
+
// NYLORUN_EXECUTORS_JSON then overlay them for this process only and are never written back.
|
|
208
|
+
this.registry.seed(this.store.allExecutors().map((row) => ({
|
|
209
|
+
agentId: row.agentId,
|
|
210
|
+
implementationVersion: row.implementationVersion,
|
|
211
|
+
...(row.manifestHash === undefined
|
|
212
|
+
? {}
|
|
213
|
+
: { manifestHash: row.manifestHash }),
|
|
214
|
+
tokenHash: row.tokenHash,
|
|
215
|
+
persisted: true,
|
|
216
|
+
updatedAt: row.updatedAt,
|
|
217
|
+
})));
|
|
218
|
+
const now = new Date().toISOString();
|
|
219
|
+
for (const executor of options.executors) {
|
|
220
|
+
const tokenHash = hashToken(executor.token);
|
|
221
|
+
const collision = this.registry.find(tokenHash);
|
|
222
|
+
if (collision && collision.agentId !== executor.agentId) {
|
|
223
|
+
this.store.db.close();
|
|
224
|
+
if (this.lockPath)
|
|
225
|
+
unlinkSync(this.lockPath);
|
|
226
|
+
throw new Error("Executor tokens must be unique");
|
|
227
|
+
}
|
|
228
|
+
this.registry.upsert({
|
|
229
|
+
agentId: executor.agentId,
|
|
230
|
+
implementationVersion: executor.implementationVersion,
|
|
231
|
+
...(executor.manifestHash === undefined
|
|
232
|
+
? {}
|
|
233
|
+
: { manifestHash: executor.manifestHash }),
|
|
234
|
+
tokenHash,
|
|
235
|
+
persisted: false,
|
|
236
|
+
updatedAt: now,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
180
239
|
this.vault = new VaultService(this.store.db, (fn) => this.store.tx(fn), () => this.ensureKek(), options.vaultFetch ?? globalThis.fetch);
|
|
181
240
|
this.mcp = new McpPool({
|
|
182
241
|
dataDir: dirname(options.sqlitePath),
|
|
183
242
|
authorize: (sessionId, request) => this.authorize(sessionId, request),
|
|
184
243
|
});
|
|
244
|
+
const ephemeral = options.sqlitePath === ":memory:";
|
|
245
|
+
const sandboxRoot = options.sandbox?.root ??
|
|
246
|
+
(ephemeral
|
|
247
|
+
? mkdtempSync(join(tmpdir(), "nylorun-sandboxes-"))
|
|
248
|
+
: join(dirname(options.sqlitePath), "sandboxes"));
|
|
249
|
+
this.sandbox = new SandboxManager({
|
|
250
|
+
scope: ephemeral ? `m${randomUUID().slice(0, 8)}` : this.scopeId,
|
|
251
|
+
store: this.store,
|
|
252
|
+
backends: options.sandbox?.backends ?? defaultSandboxBackends({ root: sandboxRoot }),
|
|
253
|
+
preference: options.sandbox?.backend ?? process.env.NYLORUN_SANDBOX,
|
|
254
|
+
ephemeral,
|
|
255
|
+
emit: (sessionId, turnId, type, payload) => {
|
|
256
|
+
if (this.closed)
|
|
257
|
+
return;
|
|
258
|
+
const event = this.store.tx(() => this.store.event(sessionId, turnId, type, payload));
|
|
259
|
+
this.publish(event);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
if (!ephemeral && this.sandbox.hasRecords())
|
|
263
|
+
void this.sandbox
|
|
264
|
+
.reconcile((id) => !this.closed && !!this.store.get("sessions", id))
|
|
265
|
+
.catch(() => undefined);
|
|
185
266
|
this.store.tx(() => {
|
|
186
267
|
for (const effect of this.store.all("effects"))
|
|
187
268
|
if (effect.status === "invoking") {
|
|
@@ -199,6 +280,7 @@ export class CoreRuntime {
|
|
|
199
280
|
}
|
|
200
281
|
}
|
|
201
282
|
});
|
|
283
|
+
this.retireLegacyHooks();
|
|
202
284
|
this.expireClaims();
|
|
203
285
|
this.timer = setInterval(() => this.expireClaims(), Math.min(options.leaseMs ?? 30000, 5000));
|
|
204
286
|
this.timer.unref();
|
|
@@ -218,15 +300,53 @@ export class CoreRuntime {
|
|
|
218
300
|
response.destroy();
|
|
219
301
|
}
|
|
220
302
|
notify() {
|
|
221
|
-
for (const
|
|
222
|
-
|
|
303
|
+
for (const streams of this.executorStreams.values())
|
|
304
|
+
for (const response of streams)
|
|
305
|
+
this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Manifest v4 replaced beforeModelCall/afterModelCall with scoped hooks. Turns started under
|
|
309
|
+
* an older manifest cannot continue, and their hook actions no longer parse, so end them.
|
|
310
|
+
*/
|
|
311
|
+
retireLegacyHooks() {
|
|
312
|
+
this.store.tx(() => {
|
|
313
|
+
for (const action of this.store.all("actions"))
|
|
314
|
+
if ((action.kind === "beforeModelCall" || action.kind === "afterModelCall") &&
|
|
315
|
+
(action.status === "pending" || action.status === "claimed")) {
|
|
316
|
+
action.status = "cancelled";
|
|
317
|
+
this.store.put("actions", action.actionId, action);
|
|
318
|
+
}
|
|
319
|
+
for (const s of this.store.all("sessions"))
|
|
320
|
+
if (s.activeTurnId && s.manifest?.manifestSchemaVersion !== 4) {
|
|
321
|
+
this.store.event(s.id, s.activeTurnId, "turn.failed", {
|
|
322
|
+
error: {
|
|
323
|
+
code: "execution.incompatible",
|
|
324
|
+
message: "This turn started under manifest schema 3; see MIGRATION.md for before/after hooks",
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
s.status = "failed";
|
|
328
|
+
s.activeTurnId = null;
|
|
329
|
+
this.store.put("sessions", s.id, s);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
223
332
|
}
|
|
224
333
|
expireClaims() {
|
|
225
334
|
const events = [];
|
|
335
|
+
let redeliver = false;
|
|
226
336
|
this.store.tx(() => {
|
|
227
337
|
for (const action of this.store.all("actions"))
|
|
228
338
|
if (action.status === "claimed" &&
|
|
229
339
|
Date.parse(action.leaseExpiresAt) <= Date.now()) {
|
|
340
|
+
if (action.kind === "hook") {
|
|
341
|
+
// Hooks are pure by contract, so a lost claim is simply delivered again.
|
|
342
|
+
// The next claim bumps the generation, which fences any late result.
|
|
343
|
+
action.status = "pending";
|
|
344
|
+
action.claimId = null;
|
|
345
|
+
action.leaseExpiresAt = null;
|
|
346
|
+
this.store.put("actions", action.actionId, action);
|
|
347
|
+
redeliver = true;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
230
350
|
action.status = "uncertain";
|
|
231
351
|
this.store.put("actions", action.actionId, action);
|
|
232
352
|
const effect = this.store.get("effects", action.actionId);
|
|
@@ -238,11 +358,14 @@ export class CoreRuntime {
|
|
|
238
358
|
this.store.put("sessions", s.id, s);
|
|
239
359
|
events.push(this.store.event(s.id, s.activeTurnId, "action.uncertain", {
|
|
240
360
|
actionId: action.actionId,
|
|
361
|
+
...(action.agent ? { agent: action.agent } : {}),
|
|
241
362
|
}));
|
|
242
363
|
}
|
|
243
364
|
}
|
|
244
365
|
});
|
|
245
366
|
events.forEach((e) => this.publish(e));
|
|
367
|
+
if (redeliver)
|
|
368
|
+
this.notify();
|
|
246
369
|
}
|
|
247
370
|
schedule(id) {
|
|
248
371
|
if (this.closing)
|
|
@@ -357,6 +480,20 @@ export class CoreRuntime {
|
|
|
357
480
|
this.schedule(id);
|
|
358
481
|
}
|
|
359
482
|
}
|
|
483
|
+
invokeModel(request, signal) {
|
|
484
|
+
if (!this.options.useHostModel)
|
|
485
|
+
return (this.options.model ?? scriptedModel())(request, signal);
|
|
486
|
+
const adapter = piModel({
|
|
487
|
+
readHostModel: () => this.vault.readHostModel(),
|
|
488
|
+
writeHostCredential: (credential) => this.vault.updateHostCredential(credential),
|
|
489
|
+
});
|
|
490
|
+
return adapter(request.input, {
|
|
491
|
+
request: request.context.request,
|
|
492
|
+
invocationId: String(request.context.invocationId),
|
|
493
|
+
signal,
|
|
494
|
+
reportPreparedCall() { },
|
|
495
|
+
});
|
|
496
|
+
}
|
|
360
497
|
async resolveEffect(request, signal) {
|
|
361
498
|
let invoke;
|
|
362
499
|
let notify = false;
|
|
@@ -380,44 +517,76 @@ export class CoreRuntime {
|
|
|
380
517
|
: "pending",
|
|
381
518
|
};
|
|
382
519
|
}
|
|
383
|
-
|
|
384
|
-
|
|
520
|
+
if (request.kind === "delegation") {
|
|
521
|
+
// Lifecycle points of an agent used as a tool: journaled once, so replays never re-emit.
|
|
522
|
+
const outcome = { value: null };
|
|
523
|
+
this.store.put("effects", request.effectId, {
|
|
524
|
+
request,
|
|
525
|
+
status: "completed",
|
|
526
|
+
outcome,
|
|
527
|
+
});
|
|
528
|
+
const settled = request.effectId.endsWith(":settled");
|
|
529
|
+
event = this.store.event(s.id, s.activeTurnId, settled ? "delegation.completed" : "delegation.started", { agent: request.agent, ...request.input });
|
|
530
|
+
return { status: "completed", outcome };
|
|
531
|
+
}
|
|
532
|
+
const agentManifest = manifestFor(s.manifest, request.agent);
|
|
533
|
+
if (!agentManifest)
|
|
534
|
+
throw new Error(`Agent '${request.agent?.id ?? ""}' is not used as a tool`);
|
|
535
|
+
const mcpTool = request.kind === "tool" ? mcpToolOf(s, request) : undefined;
|
|
536
|
+
const sandboxTool = request.kind === "tool" && !mcpTool
|
|
537
|
+
? sandboxCapabilityOf(agentManifest, request.capabilityId, request.toolName)
|
|
385
538
|
: undefined;
|
|
386
539
|
this.store.put("effects", request.effectId, {
|
|
387
540
|
request,
|
|
388
|
-
status: request.kind === "model" || mcpTool
|
|
541
|
+
status: request.kind === "model" || mcpTool || sandboxTool
|
|
542
|
+
? "invoking"
|
|
543
|
+
: "pending",
|
|
389
544
|
});
|
|
390
|
-
if (request.kind === "model" || mcpTool) {
|
|
391
|
-
invoke = mcpTool ? "mcp" : "model";
|
|
545
|
+
if (request.kind === "model" || mcpTool || sandboxTool) {
|
|
546
|
+
invoke = mcpTool ? "mcp" : sandboxTool ? "sandbox" : "model";
|
|
392
547
|
return undefined;
|
|
393
548
|
}
|
|
394
549
|
const tool = request.kind === "tool"
|
|
395
|
-
? pinnedTool(
|
|
550
|
+
? pinnedTool(agentManifest, request.capabilityId, request.toolName)
|
|
396
551
|
: undefined;
|
|
397
|
-
const
|
|
552
|
+
const base = {
|
|
398
553
|
actionId: request.effectId,
|
|
399
554
|
sessionId: request.sessionId,
|
|
400
555
|
turnId: request.turnId,
|
|
401
556
|
agentId: request.agentId,
|
|
402
557
|
manifestHash: request.manifestHash,
|
|
403
558
|
implementationVersion: s.implementationVersion,
|
|
404
|
-
kind: request.kind,
|
|
405
|
-
capabilityId: request.capabilityId,
|
|
406
|
-
...(request.toolName ? { toolName: request.toolName } : {}),
|
|
407
|
-
...(tool?.inputSchema ? { inputSchema: tool.inputSchema } : {}),
|
|
408
|
-
...(tool?.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
|
409
559
|
input: request.input,
|
|
410
560
|
context: request.context,
|
|
411
561
|
status: "pending",
|
|
412
562
|
generation: 0,
|
|
413
563
|
claimId: null,
|
|
414
564
|
leaseExpiresAt: null,
|
|
565
|
+
...(request.agent ? { agent: request.agent } : {}),
|
|
415
566
|
};
|
|
567
|
+
const action = request.kind === "hook"
|
|
568
|
+
? {
|
|
569
|
+
...base,
|
|
570
|
+
kind: "hook",
|
|
571
|
+
hook: {
|
|
572
|
+
at: request.hook.at,
|
|
573
|
+
scope: request.hook.scope,
|
|
574
|
+
capabilityIds: [...request.hook.capabilityIds],
|
|
575
|
+
},
|
|
576
|
+
}
|
|
577
|
+
: {
|
|
578
|
+
...base,
|
|
579
|
+
kind: "tool",
|
|
580
|
+
capabilityId: request.capabilityId,
|
|
581
|
+
toolName: request.toolName,
|
|
582
|
+
...(tool?.inputSchema ? { inputSchema: tool.inputSchema } : {}),
|
|
583
|
+
...(tool?.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
|
584
|
+
};
|
|
416
585
|
this.store.put("actions", action.actionId, action);
|
|
417
586
|
event = this.store.event(s.id, s.activeTurnId, "action.pending", {
|
|
418
587
|
actionId: action.actionId,
|
|
419
588
|
kind: action.kind,
|
|
420
|
-
|
|
589
|
+
...actionTarget(action),
|
|
421
590
|
input: action.input,
|
|
422
591
|
});
|
|
423
592
|
notify = true;
|
|
@@ -432,7 +601,9 @@ export class CoreRuntime {
|
|
|
432
601
|
try {
|
|
433
602
|
const value = invoke === "mcp"
|
|
434
603
|
? await this.callMcpTool(request)
|
|
435
|
-
:
|
|
604
|
+
: invoke === "sandbox"
|
|
605
|
+
? await this.callSandboxTool(request, signal)
|
|
606
|
+
: await this.invokeModel(request, signal);
|
|
436
607
|
return this.store.tx(() => {
|
|
437
608
|
const s = this.session(request.sessionId);
|
|
438
609
|
if (s.status === "cancelled" ||
|
|
@@ -468,9 +639,13 @@ export class CoreRuntime {
|
|
|
468
639
|
scope(request) {
|
|
469
640
|
const header = request.headers.authorization;
|
|
470
641
|
const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
|
|
471
|
-
if (token
|
|
642
|
+
if (!token)
|
|
643
|
+
return fail(401, "Invalid credentials");
|
|
644
|
+
if (equals(token, this.options.serverToken))
|
|
472
645
|
return "server";
|
|
473
|
-
|
|
646
|
+
// The map is keyed by a digest of the presented token, so the lookup reveals nothing the
|
|
647
|
+
// caller does not already hold; the server token keeps its constant-time comparison.
|
|
648
|
+
const executor = this.registry.find(hashToken(token));
|
|
474
649
|
if (!executor)
|
|
475
650
|
return fail(401, "Invalid credentials");
|
|
476
651
|
return executor;
|
|
@@ -542,7 +717,7 @@ export class CoreRuntime {
|
|
|
542
717
|
schedule = true;
|
|
543
718
|
event = this.store.event(id, s.activeTurnId, "action.completed", {
|
|
544
719
|
actionId: action.actionId,
|
|
545
|
-
|
|
720
|
+
...actionTarget(action),
|
|
546
721
|
kind: action.kind,
|
|
547
722
|
result: command.outcome.value,
|
|
548
723
|
});
|
|
@@ -658,7 +833,7 @@ export class CoreRuntime {
|
|
|
658
833
|
this.schedule(id);
|
|
659
834
|
return response;
|
|
660
835
|
}
|
|
661
|
-
sse(request, response, set) {
|
|
836
|
+
sse(request, response, set, whenEmpty) {
|
|
662
837
|
response.writeHead(200, {
|
|
663
838
|
"content-type": "text/event-stream",
|
|
664
839
|
"cache-control": "no-cache",
|
|
@@ -672,6 +847,8 @@ export class CoreRuntime {
|
|
|
672
847
|
request.on("close", () => {
|
|
673
848
|
clearInterval(timer);
|
|
674
849
|
set.delete(response);
|
|
850
|
+
if (!set.size)
|
|
851
|
+
whenEmpty?.();
|
|
675
852
|
});
|
|
676
853
|
}
|
|
677
854
|
async handle(request, response) {
|
|
@@ -687,7 +864,13 @@ export class CoreRuntime {
|
|
|
687
864
|
.map(decodeURIComponent);
|
|
688
865
|
const method = request.method;
|
|
689
866
|
if (url.pathname === "/health")
|
|
690
|
-
return json({
|
|
867
|
+
return json({
|
|
868
|
+
status: "ok",
|
|
869
|
+
service: "oss-runtime",
|
|
870
|
+
version: RUNTIME_VERSION,
|
|
871
|
+
scopeId: this.scopeId,
|
|
872
|
+
pid: process.pid,
|
|
873
|
+
});
|
|
691
874
|
if (url.pathname === "/ready") {
|
|
692
875
|
this.store.db.prepare("SELECT 1").get();
|
|
693
876
|
return json({
|
|
@@ -708,7 +891,11 @@ export class CoreRuntime {
|
|
|
708
891
|
method === "GET") {
|
|
709
892
|
if (scope === "server")
|
|
710
893
|
fail(403, "Executor credential required");
|
|
711
|
-
|
|
894
|
+
const tokenHash = scope.tokenHash;
|
|
895
|
+
let streams = this.executorStreams.get(tokenHash);
|
|
896
|
+
if (!streams)
|
|
897
|
+
this.executorStreams.set(tokenHash, (streams = new Set()));
|
|
898
|
+
this.sse(request, response, streams, () => this.executorStreams.delete(tokenHash));
|
|
712
899
|
this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
|
|
713
900
|
return;
|
|
714
901
|
}
|
|
@@ -742,7 +929,11 @@ export class CoreRuntime {
|
|
|
742
929
|
action.claimId = randomUUID();
|
|
743
930
|
action.leaseExpiresAt = new Date(Date.now() + (this.options.leaseMs ?? 30000)).toISOString();
|
|
744
931
|
this.store.put("actions", actionId, action);
|
|
745
|
-
event = this.store.event(action.sessionId, action.turnId, "action.claimed", {
|
|
932
|
+
event = this.store.event(action.sessionId, action.turnId, "action.claimed", {
|
|
933
|
+
actionId,
|
|
934
|
+
generation: action.generation,
|
|
935
|
+
...(action.agent ? { agent: action.agent } : {}),
|
|
936
|
+
});
|
|
746
937
|
return {
|
|
747
938
|
action,
|
|
748
939
|
claimId: action.claimId,
|
|
@@ -775,8 +966,99 @@ export class CoreRuntime {
|
|
|
775
966
|
return json(this.command(path[2], SessionCommandSchema.parse(await this.body(request)), scope));
|
|
776
967
|
if (path[1] === "vaults")
|
|
777
968
|
return json(await this.dispatchVault(scope, method, path, url, request));
|
|
969
|
+
if (path[1] === "host")
|
|
970
|
+
return json(await this.dispatchHost(scope, method, path, request));
|
|
778
971
|
if (scope !== "server")
|
|
779
972
|
fail(403, "Application credential required");
|
|
973
|
+
if (path[1] === "executors" && path.length === 2 && method === "GET")
|
|
974
|
+
return json({
|
|
975
|
+
executors: this.registry.list().map((e) => ({
|
|
976
|
+
agentId: e.agentId,
|
|
977
|
+
implementationVersion: e.implementationVersion,
|
|
978
|
+
...(e.manifestHash === undefined
|
|
979
|
+
? {}
|
|
980
|
+
: { manifestHash: e.manifestHash }),
|
|
981
|
+
connected: (this.executorStreams.get(e.tokenHash)?.size ?? 0) > 0,
|
|
982
|
+
updatedAt: e.updatedAt,
|
|
983
|
+
})),
|
|
984
|
+
});
|
|
985
|
+
// The length guard matters: the connect branch above only matches GET, so without it a
|
|
986
|
+
// PUT to /v1/executors/connect would register an agent literally named "connect".
|
|
987
|
+
if (path[1] === "executors" && path.length === 2 && method === "PUT") {
|
|
988
|
+
const body = RegisterExecutorsRequestSchema.parse(await this.body(request));
|
|
989
|
+
// Credential rules are shared with startup validation, which throws plainly; on the
|
|
990
|
+
// wire a rejected registration is a bad request, not a server fault.
|
|
991
|
+
try {
|
|
992
|
+
for (const executor of body.executors)
|
|
993
|
+
assertExecutorCredential(executor, this.options.serverToken);
|
|
994
|
+
}
|
|
995
|
+
catch (error) {
|
|
996
|
+
fail(400, error instanceof Error ? error.message : String(error));
|
|
997
|
+
}
|
|
998
|
+
if (new Set(body.executors.map((e) => e.agentId)).size !==
|
|
999
|
+
body.executors.length ||
|
|
1000
|
+
new Set(body.executors.map((e) => e.token)).size !==
|
|
1001
|
+
body.executors.length)
|
|
1002
|
+
fail(400, "Executor registrations must be unique per agent and token");
|
|
1003
|
+
const updatedAt = new Date().toISOString();
|
|
1004
|
+
const records = body.executors.map((executor) => ({
|
|
1005
|
+
agentId: executor.agentId,
|
|
1006
|
+
implementationVersion: executor.implementationVersion,
|
|
1007
|
+
...(executor.manifestHash === undefined
|
|
1008
|
+
? {}
|
|
1009
|
+
: { manifestHash: executor.manifestHash }),
|
|
1010
|
+
tokenHash: hashToken(executor.token),
|
|
1011
|
+
persisted: true,
|
|
1012
|
+
updatedAt,
|
|
1013
|
+
}));
|
|
1014
|
+
for (const record of records) {
|
|
1015
|
+
const collision = this.registry.find(record.tokenHash);
|
|
1016
|
+
if (collision && collision.agentId !== record.agentId)
|
|
1017
|
+
fail(409, "Executor tokens must be unique");
|
|
1018
|
+
}
|
|
1019
|
+
// Persist the whole batch first; the in-memory registry must never run ahead of SQLite.
|
|
1020
|
+
this.store.tx(() => {
|
|
1021
|
+
for (const record of records)
|
|
1022
|
+
this.store.putExecutor({
|
|
1023
|
+
agentId: record.agentId,
|
|
1024
|
+
tokenHash: record.tokenHash,
|
|
1025
|
+
implementationVersion: record.implementationVersion,
|
|
1026
|
+
...(record.manifestHash === undefined
|
|
1027
|
+
? {}
|
|
1028
|
+
: { manifestHash: record.manifestHash }),
|
|
1029
|
+
updatedAt,
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
const results = records.map((record) => {
|
|
1033
|
+
const { rotated, previousHash } = this.registry.upsert(record);
|
|
1034
|
+
if (rotated && previousHash) {
|
|
1035
|
+
for (const r of this.executorStreams.get(previousHash) ?? [])
|
|
1036
|
+
r.end();
|
|
1037
|
+
this.executorStreams.delete(previousHash);
|
|
1038
|
+
}
|
|
1039
|
+
return {
|
|
1040
|
+
agentId: record.agentId,
|
|
1041
|
+
implementationVersion: record.implementationVersion,
|
|
1042
|
+
rotated,
|
|
1043
|
+
};
|
|
1044
|
+
});
|
|
1045
|
+
return json({ executors: results });
|
|
1046
|
+
}
|
|
1047
|
+
if (path[1] === "executors" &&
|
|
1048
|
+
path.length === 3 &&
|
|
1049
|
+
path[2] &&
|
|
1050
|
+
method === "DELETE") {
|
|
1051
|
+
const agentId = path[2];
|
|
1052
|
+
const existing = this.registry.get(agentId) ?? fail(404, "Executor not found");
|
|
1053
|
+
if (!existing.persisted)
|
|
1054
|
+
fail(409, "Executor is provided by NYLORUN_EXECUTORS_JSON and cannot be deleted");
|
|
1055
|
+
this.store.tx(() => this.store.deleteExecutor(agentId));
|
|
1056
|
+
this.registry.remove(agentId);
|
|
1057
|
+
for (const r of this.executorStreams.get(existing.tokenHash) ?? [])
|
|
1058
|
+
r.end();
|
|
1059
|
+
this.executorStreams.delete(existing.tokenHash);
|
|
1060
|
+
return json({ agentId, deleted: true });
|
|
1061
|
+
}
|
|
780
1062
|
if (path[1] === "agents" && path.length === 2 && method === "GET")
|
|
781
1063
|
return json({
|
|
782
1064
|
agents: this.store.all("definitions").map((d) => ({
|
|
@@ -871,7 +1153,7 @@ export class CoreRuntime {
|
|
|
871
1153
|
? request.headers["last-event-id"]
|
|
872
1154
|
: undefined);
|
|
873
1155
|
if (method === "GET" && path[3] === "items")
|
|
874
|
-
return json(this.store.history(id, cursor));
|
|
1156
|
+
return json(this.store.history(id, cursor, url.searchParams.get("agent") ?? undefined));
|
|
875
1157
|
if (method === "GET" && path[3] === "events") {
|
|
876
1158
|
const history = this.store.history(id, cursor);
|
|
877
1159
|
const set = this.observers.get(id) ?? new Set();
|
|
@@ -943,9 +1225,7 @@ export class CoreRuntime {
|
|
|
943
1225
|
}
|
|
944
1226
|
async prepareMcp(id, signal) {
|
|
945
1227
|
const s = this.session(id);
|
|
946
|
-
|
|
947
|
-
Object.keys(capability.mcpServers).length > 0);
|
|
948
|
-
if (!declared)
|
|
1228
|
+
if (serversOf(s.manifest).length === 0)
|
|
949
1229
|
return;
|
|
950
1230
|
if (!s.mcpSnapshot) {
|
|
951
1231
|
const found = await this.mcp.discover({
|
|
@@ -991,11 +1271,12 @@ export class CoreRuntime {
|
|
|
991
1271
|
}
|
|
992
1272
|
async callMcpTool(request) {
|
|
993
1273
|
const s = this.session(request.sessionId);
|
|
994
|
-
const tool = mcpToolOf(s, request
|
|
1274
|
+
const tool = mcpToolOf(s, request);
|
|
995
1275
|
if (!tool)
|
|
996
1276
|
throw new Error(`MCP tool '${request.toolName ?? ""}' is not in the session snapshot`);
|
|
997
1277
|
return this.mcp.call({
|
|
998
1278
|
sessionId: s.id,
|
|
1279
|
+
...(tool.agentId === undefined ? {} : { agentId: tool.agentId }),
|
|
999
1280
|
capabilityId: tool.capabilityId,
|
|
1000
1281
|
serverName: tool.serverName,
|
|
1001
1282
|
serverToolName: tool.serverToolName,
|
|
@@ -1004,6 +1285,14 @@ export class CoreRuntime {
|
|
|
1004
1285
|
pluginRoots: s.pluginRoots ?? {},
|
|
1005
1286
|
});
|
|
1006
1287
|
}
|
|
1288
|
+
async callSandboxTool(request, signal) {
|
|
1289
|
+
const s = this.session(request.sessionId);
|
|
1290
|
+
// Agents used as tools share the session's sandbox; the tree declares one sandbox spec.
|
|
1291
|
+
const capability = sandboxCapabilityOf(manifestFor(s.manifest, request.agent), request.capabilityId, request.toolName);
|
|
1292
|
+
if (!capability)
|
|
1293
|
+
throw new Error(`'${request.toolName ?? ""}' is not a sandbox tool`);
|
|
1294
|
+
return this.sandbox.run({ id: s.id, activeTurnId: s.activeTurnId, manifest: s.manifest }, capability, request.toolName, request.input, signal);
|
|
1295
|
+
}
|
|
1007
1296
|
async authorize(sessionId, request) {
|
|
1008
1297
|
const s = this.session(sessionId);
|
|
1009
1298
|
const result = await this.vault.authorize({
|
|
@@ -1029,6 +1318,28 @@ export class CoreRuntime {
|
|
|
1029
1318
|
this.kek = createKekFile(this.kekPath);
|
|
1030
1319
|
return this.kek;
|
|
1031
1320
|
}
|
|
1321
|
+
async dispatchHost(scope, method, path, request) {
|
|
1322
|
+
if (scope !== "server") {
|
|
1323
|
+
this.vault.reject(path.join("/"));
|
|
1324
|
+
fail(403, "Application credential required");
|
|
1325
|
+
}
|
|
1326
|
+
if (path[2] === "models" && path.length === 3 && method === "GET")
|
|
1327
|
+
return hostModelCatalog();
|
|
1328
|
+
if (path[2] === "sandbox" && path.length === 3 && method === "GET")
|
|
1329
|
+
return this.sandbox.report();
|
|
1330
|
+
if (path[2] === "providers" && path.length === 3 && method === "GET")
|
|
1331
|
+
return this.vault.listHostProviders();
|
|
1332
|
+
if (path[2] === "model" && path.length === 3 && method === "GET")
|
|
1333
|
+
return this.vault.getHostModel();
|
|
1334
|
+
if (path[2] === "model" && path.length === 3 && method === "PUT")
|
|
1335
|
+
return this.vault.putHostModel(PutHostModelRequestSchema.parse(await this.body(request)));
|
|
1336
|
+
if (path[2] === "model" &&
|
|
1337
|
+
path[3] === "selection" &&
|
|
1338
|
+
path.length === 4 &&
|
|
1339
|
+
method === "PUT")
|
|
1340
|
+
return this.vault.selectHostModel(SelectHostModelRequestSchema.parse(await this.body(request)));
|
|
1341
|
+
fail(404, "Route not found");
|
|
1342
|
+
}
|
|
1032
1343
|
async dispatchVault(scope, method, path, url, request) {
|
|
1033
1344
|
if (scope !== "server") {
|
|
1034
1345
|
this.vault.reject(path.join("/"));
|
|
@@ -1092,8 +1403,9 @@ export class CoreRuntime {
|
|
|
1092
1403
|
for (const c of this.running.values())
|
|
1093
1404
|
c.abort();
|
|
1094
1405
|
await this.mcp.close();
|
|
1095
|
-
for (const
|
|
1096
|
-
r
|
|
1406
|
+
for (const streams of this.executorStreams.values())
|
|
1407
|
+
for (const r of streams)
|
|
1408
|
+
r.end();
|
|
1097
1409
|
for (const set of this.observers.values())
|
|
1098
1410
|
for (const r of set)
|
|
1099
1411
|
r.end();
|
|
@@ -1105,6 +1417,8 @@ export class CoreRuntime {
|
|
|
1105
1417
|
// Providers must honor AbortSignal. Keep storage open until any in-flight journal writes have drained.
|
|
1106
1418
|
while (this.running.size)
|
|
1107
1419
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1420
|
+
await this.sandbox.close();
|
|
1421
|
+
this.closed = true;
|
|
1108
1422
|
this.store.db.close();
|
|
1109
1423
|
if (this.lockPath)
|
|
1110
1424
|
unlinkSync(this.lockPath);
|
|
@@ -1124,3 +1438,10 @@ export async function startRuntime(options) {
|
|
|
1124
1438
|
throw error;
|
|
1125
1439
|
}
|
|
1126
1440
|
}
|
|
1441
|
+
/** What an action runs, for events: a tool name, or a hook point and its capabilities. */
|
|
1442
|
+
function actionTarget(action) {
|
|
1443
|
+
return {
|
|
1444
|
+
...(action.kind === "hook" ? { hook: action.hook } : { toolName: action.toolName }),
|
|
1445
|
+
...(action.agent ? { agent: action.agent } : {}),
|
|
1446
|
+
};
|
|
1447
|
+
}
|