@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +7 -5
  3. package/dist/adapters/sandbox/microsandbox.d.ts +2 -0
  4. package/dist/adapters/sandbox/microsandbox.js +238 -0
  5. package/dist/adapters/sandbox/virtual.d.ts +4 -0
  6. package/dist/adapters/sandbox/virtual.js +125 -0
  7. package/dist/configuration.d.ts +1 -1
  8. package/dist/configuration.js +1 -1
  9. package/dist/core/executors.d.ts +32 -0
  10. package/dist/core/executors.js +60 -0
  11. package/dist/core/main.js +7 -12
  12. package/dist/core/runtime.d.ts +24 -1
  13. package/dist/core/runtime.js +364 -43
  14. package/dist/core/store.d.ts +14 -1
  15. package/dist/core/store.js +59 -3
  16. package/dist/index.d.ts +0 -1
  17. package/dist/index.js +0 -1
  18. package/dist/mcp/pool.d.ts +13 -1
  19. package/dist/mcp/pool.js +53 -19
  20. package/dist/mcp/snapshot.d.ts +4 -0
  21. package/dist/model/catalog.d.ts +12 -0
  22. package/dist/model/catalog.js +29 -0
  23. package/dist/model/models.d.ts +3 -1
  24. package/dist/model/models.js +4 -2
  25. package/dist/model/pi-model.d.ts +9 -3
  26. package/dist/model/pi-model.js +47 -11
  27. package/dist/model/settings.d.ts +0 -2
  28. package/dist/model/settings.js +0 -47
  29. package/dist/node/index.d.ts +1 -1
  30. package/dist/node/index.js +1 -1
  31. package/dist/sandbox/manager.d.ts +61 -0
  32. package/dist/sandbox/manager.js +239 -0
  33. package/dist/sandbox/policy.d.ts +13 -0
  34. package/dist/sandbox/policy.js +67 -0
  35. package/dist/sandbox/select.d.ts +35 -0
  36. package/dist/sandbox/select.js +76 -0
  37. package/dist/sandbox/tools.d.ts +25 -0
  38. package/dist/sandbox/tools.js +165 -0
  39. package/dist/sandbox/types.d.ts +66 -0
  40. package/dist/vault/service.d.ts +41 -1
  41. package/dist/vault/service.js +291 -4
  42. package/dist/version.d.ts +1 -0
  43. package/dist/version.js +3 -0
  44. package/package.json +9 -4
  45. package/dist/config.d.ts +0 -20
  46. package/dist/model/defaults.d.ts +0 -17
  47. package/dist/model/defaults.js +0 -21
  48. package/dist/model/http-model.d.ts +0 -12
  49. package/dist/model/http-model.js +0 -299
  50. package/dist/node/local-sessions.d.ts +0 -5
  51. package/dist/node/local-sessions.js +0 -174
  52. package/dist/server/ag-ui.d.ts +0 -8
  53. package/dist/server/ag-ui.js +0 -76
  54. package/dist/server/delivery.d.ts +0 -24
  55. package/dist/server/delivery.js +0 -107
  56. package/dist/server/digests.d.ts +0 -13
  57. package/dist/server/digests.js +0 -39
  58. package/dist/server/host.d.ts +0 -70
  59. package/dist/server/host.js +0 -647
  60. package/dist/session/api.d.ts +0 -10
  61. package/dist/session/api.js +0 -15
  62. package/dist/session/default.d.ts +0 -5
  63. package/dist/session/default.js +0 -30
  64. package/dist/session/handle.d.ts +0 -27
  65. package/dist/session/handle.js +0 -199
  66. package/dist/session/index.d.ts +0 -2
  67. package/dist/session/index.js +0 -2
  68. package/dist/sessions/host.d.ts +0 -39
  69. package/dist/sessions/host.js +0 -359
  70. package/dist/sessions/store.d.ts +0 -41
  71. package/dist/sessions/store.js +0 -33
  72. /package/dist/{config.js → sandbox/types.js} +0 -0
@@ -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 { dirname } from "node:path";
5
- import { AgentManifestSchema, CreateCredentialRequestSchema, CreateVaultRequestSchema, PutAgentRequestSchema, PutSessionRequestSchema, RotateCredentialRequestSchema, SessionCommandSchema, ActionClaimRequestSchema, ActionHeartbeatRequestSchema, } from "@nylorun/core/contracts";
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, capabilityId, toolName) {
42
- return session.mcpSnapshot?.mcpTools.find((tool) => tool.capabilityId === capabilityId && tool.name === toolName);
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
- executors = new Set();
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
- if (options.executors.some((e) => e.token.length < 16 ||
132
- equals(e.token, options.serverToken) ||
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 response of this.executors)
222
- this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
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
- const mcpTool = request.kind === "tool"
384
- ? mcpToolOf(s, request.capabilityId, request.toolName)
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 ? "invoking" : "pending",
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(s.manifest, request.capabilityId, request.toolName)
550
+ ? pinnedTool(agentManifest, request.capabilityId, request.toolName)
396
551
  : undefined;
397
- const action = {
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
- toolName: action.toolName,
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
- : await (this.options.model ?? scriptedModel())(request, signal);
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 && equals(token, this.options.serverToken))
642
+ if (!token)
643
+ return fail(401, "Invalid credentials");
644
+ if (equals(token, this.options.serverToken))
472
645
  return "server";
473
- const executor = this.options.executors.find((e) => token && equals(token, e.token));
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
- toolName: action.toolName,
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({ status: "ok", service: "oss-runtime" });
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
- this.sse(request, response, this.executors);
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", { actionId, generation: action.generation });
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
- const declared = s.manifest.capabilities.some((capability) => capability.mcpServers !== undefined &&
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.capabilityId, request.toolName);
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 r of this.executors)
1096
- r.end();
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
+ }