@nylorun/runtime 0.6.0-beta → 0.8.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 (85) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +9 -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 +41 -1
  13. package/dist/core/runtime.js +594 -53
  14. package/dist/core/store.d.ts +13 -0
  15. package/dist/core/store.js +95 -1
  16. package/dist/index.d.ts +0 -1
  17. package/dist/index.js +0 -1
  18. package/dist/mcp/connect.d.ts +31 -0
  19. package/dist/mcp/connect.js +190 -0
  20. package/dist/mcp/pool.d.ts +45 -0
  21. package/dist/mcp/pool.js +143 -0
  22. package/dist/mcp/snapshot.d.ts +25 -0
  23. package/dist/mcp/snapshot.js +10 -0
  24. package/dist/mcp/stdio.d.ts +15 -0
  25. package/dist/mcp/stdio.js +80 -0
  26. package/dist/model/catalog.d.ts +12 -0
  27. package/dist/model/catalog.js +29 -0
  28. package/dist/model/models.d.ts +3 -1
  29. package/dist/model/models.js +4 -2
  30. package/dist/model/pi-model.d.ts +9 -3
  31. package/dist/model/pi-model.js +47 -11
  32. package/dist/model/settings.d.ts +0 -2
  33. package/dist/model/settings.js +0 -47
  34. package/dist/node/index.d.ts +1 -1
  35. package/dist/node/index.js +1 -1
  36. package/dist/sandbox/manager.d.ts +61 -0
  37. package/dist/sandbox/manager.js +239 -0
  38. package/dist/sandbox/policy.d.ts +13 -0
  39. package/dist/sandbox/policy.js +67 -0
  40. package/dist/sandbox/select.d.ts +35 -0
  41. package/dist/sandbox/select.js +76 -0
  42. package/dist/sandbox/tools.d.ts +25 -0
  43. package/dist/sandbox/tools.js +165 -0
  44. package/dist/sandbox/types.d.ts +66 -0
  45. package/dist/vault/crypto.d.ts +12 -0
  46. package/dist/vault/crypto.js +80 -0
  47. package/dist/vault/error.d.ts +4 -0
  48. package/dist/vault/error.js +7 -0
  49. package/dist/vault/kek.d.ts +6 -0
  50. package/dist/vault/kek.js +26 -0
  51. package/dist/vault/service.d.ts +99 -0
  52. package/dist/vault/service.js +825 -0
  53. package/dist/vault/url.d.ts +3 -0
  54. package/dist/vault/url.js +27 -0
  55. package/dist/version.d.ts +1 -0
  56. package/dist/version.js +3 -0
  57. package/package.json +12 -6
  58. package/dist/config.d.ts +0 -20
  59. package/dist/model/defaults.d.ts +0 -17
  60. package/dist/model/defaults.js +0 -21
  61. package/dist/model/http-model.d.ts +0 -12
  62. package/dist/model/http-model.js +0 -299
  63. package/dist/node/local-sessions.d.ts +0 -5
  64. package/dist/node/local-sessions.js +0 -174
  65. package/dist/server/ag-ui.d.ts +0 -8
  66. package/dist/server/ag-ui.js +0 -76
  67. package/dist/server/delivery.d.ts +0 -24
  68. package/dist/server/delivery.js +0 -107
  69. package/dist/server/digests.d.ts +0 -13
  70. package/dist/server/digests.js +0 -39
  71. package/dist/server/host.d.ts +0 -70
  72. package/dist/server/host.js +0 -647
  73. package/dist/session/api.d.ts +0 -10
  74. package/dist/session/api.js +0 -15
  75. package/dist/session/default.d.ts +0 -5
  76. package/dist/session/default.js +0 -30
  77. package/dist/session/handle.d.ts +0 -27
  78. package/dist/session/handle.js +0 -199
  79. package/dist/session/index.d.ts +0 -2
  80. package/dist/session/index.js +0 -2
  81. package/dist/sessions/host.d.ts +0 -39
  82. package/dist/sessions/host.js +0 -359
  83. package/dist/sessions/store.d.ts +0 -41
  84. package/dist/sessions/store.js +0 -33
  85. /package/dist/{config.js → sandbox/types.js} +0 -0
@@ -1,12 +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, PutAgentRequestSchema, PutSessionRequestSchema, 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";
10
+ import { schemaFromJSON, } from "@nylorun/core/define";
8
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";
9
16
  import { scriptedModel } from "./provider.js";
17
+ import { scrub } from "../redact.js";
18
+ import { createKekFile, defaultKekPath, readVaultKek } from "../vault/kek.js";
19
+ import { VaultError } from "../vault/error.js";
20
+ import { VaultService } from "../vault/service.js";
21
+ import { McpPool } from "../mcp/pool.js";
22
+ import { SandboxManager, sandboxCapabilityOf } from "../sandbox/manager.js";
23
+ import { defaultSandboxBackends } from "../sandbox/select.js";
10
24
  class HttpError extends Error {
11
25
  status;
12
26
  constructor(status, message) {
@@ -17,10 +31,82 @@ class HttpError extends Error {
17
31
  const fail = (status, message) => {
18
32
  throw new HttpError(status, message);
19
33
  };
34
+ function sessionToolsOf(snapshot) {
35
+ if (!snapshot?.mcpTools.length)
36
+ return undefined;
37
+ return snapshot.mcpTools.map((tool) => ({
38
+ capabilityId: tool.capabilityId,
39
+ name: tool.name,
40
+ ...(tool.description === undefined
41
+ ? {}
42
+ : { description: tool.description }),
43
+ inputSchema: tool.inputSchema,
44
+ ...(tool.outputSchema === undefined
45
+ ? {}
46
+ : { outputSchema: tool.outputSchema }),
47
+ }));
48
+ }
49
+ function mcpToolOf(session, capabilityId, toolName) {
50
+ return session.mcpSnapshot?.mcpTools.find((tool) => tool.capabilityId === capabilityId && tool.name === toolName);
51
+ }
52
+ function pinnedTool(manifest, capabilityId, toolName) {
53
+ const capability = manifest.capabilities.find((item) => item.id === capabilityId);
54
+ return capability?.tools?.find((tool) => tool.name === toolName);
55
+ }
56
+ function acceptedToolResult(action, command) {
57
+ if (action.kind !== "tool" || !action.outputSchema)
58
+ return command;
59
+ const value = command.outcome.value;
60
+ if (isFailedToolValue(value))
61
+ return command;
62
+ // Executors wrap successful tool output as `{ kind: "completed", output }`.
63
+ // Validate the tool payload, not the outcome envelope.
64
+ const candidate = completedToolOutput(value);
65
+ let matches = false;
66
+ try {
67
+ matches = schemaFromJSON(action.outputSchema).validate(candidate).ok;
68
+ }
69
+ catch {
70
+ matches = false;
71
+ }
72
+ if (matches)
73
+ return command;
74
+ return {
75
+ ...command,
76
+ outcome: {
77
+ ...command.outcome,
78
+ value: {
79
+ kind: "failed",
80
+ code: "tool.invalid-output",
81
+ message: "Tool result does not match the output schema stored on the action",
82
+ },
83
+ },
84
+ };
85
+ }
86
+ function completedToolOutput(value) {
87
+ if (value &&
88
+ typeof value === "object" &&
89
+ !Array.isArray(value) &&
90
+ value.kind === "completed" &&
91
+ "output" in value) {
92
+ return value.output;
93
+ }
94
+ return value;
95
+ }
96
+ function isFailedToolValue(value) {
97
+ return (!!value &&
98
+ typeof value === "object" &&
99
+ !Array.isArray(value) &&
100
+ value.kind === "failed");
101
+ }
20
102
  const semantic = (value) => {
21
103
  const { requestId: _, ...body } = value;
22
104
  return canonical(body);
23
105
  };
106
+ const sessionIdentity = (value) => {
107
+ const { requestId: _requestId, vaultIds: _vaultIds, credentialSelections: _selections, ...body } = value ?? {};
108
+ return canonical(body);
109
+ };
24
110
  const equals = (a, b) => {
25
111
  const aa = Buffer.from(a), bb = Buffer.from(b);
26
112
  return aa.length === bb.length && timingSafeEqual(aa, bb);
@@ -28,12 +114,21 @@ const equals = (a, b) => {
28
114
  export class CoreRuntime {
29
115
  options;
30
116
  store;
117
+ vault;
118
+ kek;
119
+ kekPath;
31
120
  running = new Map();
32
121
  pending = new Set();
33
122
  observers = new Map();
34
- executors = new Set();
123
+ // Keyed by token hash so a rotation can end exactly the streams that the replaced token owns.
124
+ executorStreams = new Map();
125
+ registry = new ExecutorRegistry();
126
+ scopeId;
127
+ mcp;
128
+ sandbox;
35
129
  server;
36
130
  closing = false;
131
+ closed = false;
37
132
  lockPath;
38
133
  timer;
39
134
  constructor(options) {
@@ -46,12 +141,8 @@ export class CoreRuntime {
46
141
  throw new Error("Executor tokens must be unique");
47
142
  if (!options.serverToken || options.serverToken.length < 16)
48
143
  throw new Error("A server token of at least 16 characters is required");
49
- if (options.executors.some((e) => e.token.length < 16 ||
50
- equals(e.token, options.serverToken) ||
51
- !e.agentId ||
52
- !e.manifestHash ||
53
- !e.implementationVersion))
54
- throw new Error("Executor tokens require independent credentials and exact definition scope");
144
+ for (const executor of options.executors)
145
+ assertExecutorCredential(executor, options.serverToken);
55
146
  if (options.sqlitePath !== ":memory:") {
56
147
  mkdirSync(dirname(options.sqlitePath), { recursive: true });
57
148
  this.lockPath = options.sqlitePath + ".runtime-lock";
@@ -78,14 +169,93 @@ export class CoreRuntime {
78
169
  writeFileSync(fd, String(process.pid));
79
170
  closeSync(fd);
80
171
  }
172
+ this.kekPath = options.vaultKekPath ?? defaultKekPath();
173
+ let store;
81
174
  try {
82
- this.store = new Store(options.sqlitePath);
175
+ store = new Store(options.sqlitePath);
176
+ this.kek = readVaultKek({
177
+ vaultKek: options.vaultKek,
178
+ vaultKekPath: this.kekPath,
179
+ });
180
+ if (store.credentialCount() > 0 && !this.kek)
181
+ throw new Error("Vault key-encryption key is required to open this database");
182
+ this.store = store;
83
183
  }
84
184
  catch (e) {
185
+ store?.db.close();
85
186
  if (this.lockPath)
86
187
  unlinkSync(this.lockPath);
87
188
  throw e;
88
189
  }
190
+ // A short digest of the resolved database path identifies this scope to the CLI without
191
+ // disclosing the path itself over the unauthenticated health route.
192
+ this.scopeId =
193
+ options.sqlitePath === ":memory:"
194
+ ? "memory"
195
+ : createHash("sha256")
196
+ .update(realpathSync(options.sqlitePath))
197
+ .digest("hex")
198
+ .slice(0, 16);
199
+ // Registrations persisted by PUT /v1/executors load first; scopes supplied through
200
+ // NYLORUN_EXECUTORS_JSON then overlay them for this process only and are never written back.
201
+ this.registry.seed(this.store.allExecutors().map((row) => ({
202
+ agentId: row.agentId,
203
+ implementationVersion: row.implementationVersion,
204
+ ...(row.manifestHash === undefined
205
+ ? {}
206
+ : { manifestHash: row.manifestHash }),
207
+ tokenHash: row.tokenHash,
208
+ persisted: true,
209
+ updatedAt: row.updatedAt,
210
+ })));
211
+ const now = new Date().toISOString();
212
+ for (const executor of options.executors) {
213
+ const tokenHash = hashToken(executor.token);
214
+ const collision = this.registry.find(tokenHash);
215
+ if (collision && collision.agentId !== executor.agentId) {
216
+ this.store.db.close();
217
+ if (this.lockPath)
218
+ unlinkSync(this.lockPath);
219
+ throw new Error("Executor tokens must be unique");
220
+ }
221
+ this.registry.upsert({
222
+ agentId: executor.agentId,
223
+ implementationVersion: executor.implementationVersion,
224
+ ...(executor.manifestHash === undefined
225
+ ? {}
226
+ : { manifestHash: executor.manifestHash }),
227
+ tokenHash,
228
+ persisted: false,
229
+ updatedAt: now,
230
+ });
231
+ }
232
+ this.vault = new VaultService(this.store.db, (fn) => this.store.tx(fn), () => this.ensureKek(), options.vaultFetch ?? globalThis.fetch);
233
+ this.mcp = new McpPool({
234
+ dataDir: dirname(options.sqlitePath),
235
+ authorize: (sessionId, request) => this.authorize(sessionId, request),
236
+ });
237
+ const ephemeral = options.sqlitePath === ":memory:";
238
+ const sandboxRoot = options.sandbox?.root ??
239
+ (ephemeral
240
+ ? mkdtempSync(join(tmpdir(), "nylorun-sandboxes-"))
241
+ : join(dirname(options.sqlitePath), "sandboxes"));
242
+ this.sandbox = new SandboxManager({
243
+ scope: ephemeral ? `m${randomUUID().slice(0, 8)}` : this.scopeId,
244
+ store: this.store,
245
+ backends: options.sandbox?.backends ?? defaultSandboxBackends({ root: sandboxRoot }),
246
+ preference: options.sandbox?.backend ?? process.env.NYLORUN_SANDBOX,
247
+ ephemeral,
248
+ emit: (sessionId, turnId, type, payload) => {
249
+ if (this.closed)
250
+ return;
251
+ const event = this.store.tx(() => this.store.event(sessionId, turnId, type, payload));
252
+ this.publish(event);
253
+ },
254
+ });
255
+ if (!ephemeral && this.sandbox.hasRecords())
256
+ void this.sandbox
257
+ .reconcile((id) => !this.closed && !!this.store.get("sessions", id))
258
+ .catch(() => undefined);
89
259
  this.store.tx(() => {
90
260
  for (const effect of this.store.all("effects"))
91
261
  if (effect.status === "invoking") {
@@ -103,6 +273,7 @@ export class CoreRuntime {
103
273
  }
104
274
  }
105
275
  });
276
+ this.retireLegacyHooks();
106
277
  this.expireClaims();
107
278
  this.timer = setInterval(() => this.expireClaims(), Math.min(options.leaseMs ?? 30000, 5000));
108
279
  this.timer.unref();
@@ -122,15 +293,53 @@ export class CoreRuntime {
122
293
  response.destroy();
123
294
  }
124
295
  notify() {
125
- for (const response of this.executors)
126
- this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
296
+ for (const streams of this.executorStreams.values())
297
+ for (const response of streams)
298
+ this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
299
+ }
300
+ /**
301
+ * Manifest v4 replaced beforeModelCall/afterModelCall with scoped hooks. Turns started under
302
+ * an older manifest cannot continue, and their hook actions no longer parse, so end them.
303
+ */
304
+ retireLegacyHooks() {
305
+ this.store.tx(() => {
306
+ for (const action of this.store.all("actions"))
307
+ if ((action.kind === "beforeModelCall" || action.kind === "afterModelCall") &&
308
+ (action.status === "pending" || action.status === "claimed")) {
309
+ action.status = "cancelled";
310
+ this.store.put("actions", action.actionId, action);
311
+ }
312
+ for (const s of this.store.all("sessions"))
313
+ if (s.activeTurnId && s.manifest?.manifestSchemaVersion !== 4) {
314
+ this.store.event(s.id, s.activeTurnId, "turn.failed", {
315
+ error: {
316
+ code: "execution.incompatible",
317
+ message: "This turn started under manifest schema 3; see MIGRATION.md for before/after hooks",
318
+ },
319
+ });
320
+ s.status = "failed";
321
+ s.activeTurnId = null;
322
+ this.store.put("sessions", s.id, s);
323
+ }
324
+ });
127
325
  }
128
326
  expireClaims() {
129
327
  const events = [];
328
+ let redeliver = false;
130
329
  this.store.tx(() => {
131
330
  for (const action of this.store.all("actions"))
132
331
  if (action.status === "claimed" &&
133
332
  Date.parse(action.leaseExpiresAt) <= Date.now()) {
333
+ if (action.kind === "hook") {
334
+ // Hooks are pure by contract, so a lost claim is simply delivered again.
335
+ // The next claim bumps the generation, which fences any late result.
336
+ action.status = "pending";
337
+ action.claimId = null;
338
+ action.leaseExpiresAt = null;
339
+ this.store.put("actions", action.actionId, action);
340
+ redeliver = true;
341
+ continue;
342
+ }
134
343
  action.status = "uncertain";
135
344
  this.store.put("actions", action.actionId, action);
136
345
  const effect = this.store.get("effects", action.actionId);
@@ -147,6 +356,8 @@ export class CoreRuntime {
147
356
  }
148
357
  });
149
358
  events.forEach((e) => this.publish(e));
359
+ if (redeliver)
360
+ this.notify();
150
361
  }
151
362
  schedule(id) {
152
363
  if (this.closing)
@@ -166,10 +377,13 @@ export class CoreRuntime {
166
377
  const controller = new AbortController();
167
378
  this.running.set(id, controller);
168
379
  try {
380
+ await this.prepareMcp(id, controller.signal);
381
+ const current = this.session(id);
169
382
  const result = await runDurable({
170
- manifest: s.manifest,
171
- checkpoint: s.checkpoint,
383
+ manifest: current.manifest,
384
+ checkpoint: current.checkpoint,
172
385
  signal: controller.signal,
386
+ sessionTools: sessionToolsOf(current.mcpSnapshot),
173
387
  host: {
174
388
  resolveEffect: (e) => this.resolveEffect(e, controller.signal),
175
389
  },
@@ -258,8 +472,22 @@ export class CoreRuntime {
258
472
  this.schedule(id);
259
473
  }
260
474
  }
475
+ invokeModel(request, signal) {
476
+ if (!this.options.useHostModel)
477
+ return (this.options.model ?? scriptedModel())(request, signal);
478
+ const adapter = piModel({
479
+ readHostModel: () => this.vault.readHostModel(),
480
+ writeHostCredential: (credential) => this.vault.updateHostCredential(credential),
481
+ });
482
+ return adapter(request.input, {
483
+ request: request.context.request,
484
+ invocationId: String(request.context.invocationId),
485
+ signal,
486
+ reportPreparedCall() { },
487
+ });
488
+ }
261
489
  async resolveEffect(request, signal) {
262
- let invoke = false;
490
+ let invoke;
263
491
  let notify = false;
264
492
  let event;
265
493
  const resolution = this.store.tx(() => {
@@ -281,24 +509,32 @@ export class CoreRuntime {
281
509
  : "pending",
282
510
  };
283
511
  }
512
+ const mcpTool = request.kind === "tool"
513
+ ? mcpToolOf(s, request.capabilityId, request.toolName)
514
+ : undefined;
515
+ const sandboxTool = request.kind === "tool" && !mcpTool
516
+ ? sandboxCapabilityOf(s.manifest, request.capabilityId, request.toolName)
517
+ : undefined;
284
518
  this.store.put("effects", request.effectId, {
285
519
  request,
286
- status: request.kind === "model" ? "invoking" : "pending",
520
+ status: request.kind === "model" || mcpTool || sandboxTool
521
+ ? "invoking"
522
+ : "pending",
287
523
  });
288
- if (request.kind === "model") {
289
- invoke = true;
524
+ if (request.kind === "model" || mcpTool || sandboxTool) {
525
+ invoke = mcpTool ? "mcp" : sandboxTool ? "sandbox" : "model";
290
526
  return undefined;
291
527
  }
292
- const action = {
528
+ const tool = request.kind === "tool"
529
+ ? pinnedTool(s.manifest, request.capabilityId, request.toolName)
530
+ : undefined;
531
+ const base = {
293
532
  actionId: request.effectId,
294
533
  sessionId: request.sessionId,
295
534
  turnId: request.turnId,
296
535
  agentId: request.agentId,
297
536
  manifestHash: request.manifestHash,
298
537
  implementationVersion: s.implementationVersion,
299
- kind: request.kind,
300
- capabilityId: request.capabilityId,
301
- ...(request.toolName ? { toolName: request.toolName } : {}),
302
538
  input: request.input,
303
539
  context: request.context,
304
540
  status: "pending",
@@ -306,11 +542,29 @@ export class CoreRuntime {
306
542
  claimId: null,
307
543
  leaseExpiresAt: null,
308
544
  };
545
+ const action = request.kind === "hook"
546
+ ? {
547
+ ...base,
548
+ kind: "hook",
549
+ hook: {
550
+ at: request.hook.at,
551
+ scope: request.hook.scope,
552
+ capabilityIds: [...request.hook.capabilityIds],
553
+ },
554
+ }
555
+ : {
556
+ ...base,
557
+ kind: "tool",
558
+ capabilityId: request.capabilityId,
559
+ toolName: request.toolName,
560
+ ...(tool?.inputSchema ? { inputSchema: tool.inputSchema } : {}),
561
+ ...(tool?.outputSchema ? { outputSchema: tool.outputSchema } : {}),
562
+ };
309
563
  this.store.put("actions", action.actionId, action);
310
564
  event = this.store.event(s.id, s.activeTurnId, "action.pending", {
311
565
  actionId: action.actionId,
312
566
  kind: action.kind,
313
- toolName: action.toolName,
567
+ ...actionTarget(action),
314
568
  input: action.input,
315
569
  });
316
570
  notify = true;
@@ -323,7 +577,11 @@ export class CoreRuntime {
323
577
  if (!invoke)
324
578
  return resolution;
325
579
  try {
326
- const value = await (this.options.model ?? scriptedModel())(request, signal);
580
+ const value = invoke === "mcp"
581
+ ? await this.callMcpTool(request)
582
+ : invoke === "sandbox"
583
+ ? await this.callSandboxTool(request, signal)
584
+ : await this.invokeModel(request, signal);
327
585
  return this.store.tx(() => {
328
586
  const s = this.session(request.sessionId);
329
587
  if (s.status === "cancelled" ||
@@ -359,18 +617,19 @@ export class CoreRuntime {
359
617
  scope(request) {
360
618
  const header = request.headers.authorization;
361
619
  const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
362
- if (token && equals(token, this.options.serverToken))
620
+ if (!token)
621
+ return fail(401, "Invalid credentials");
622
+ if (equals(token, this.options.serverToken))
363
623
  return "server";
364
- const executor = this.options.executors.find((e) => token && equals(token, e.token));
624
+ // The map is keyed by a digest of the presented token, so the lookup reveals nothing the
625
+ // caller does not already hold; the server token keeps its constant-time comparison.
626
+ const executor = this.registry.find(hashToken(token));
365
627
  if (!executor)
366
628
  return fail(401, "Invalid credentials");
367
629
  return executor;
368
630
  }
369
631
  scoped(scope, action) {
370
- if (scope === "server" ||
371
- scope.agentId !== action.agentId ||
372
- scope.manifestHash !== action.manifestHash ||
373
- scope.implementationVersion !== action.implementationVersion)
632
+ if (scope === "server" || scope.agentId !== action.agentId)
374
633
  fail(403, "Executor scope does not authorize this action");
375
634
  }
376
635
  async body(request) {
@@ -387,21 +646,23 @@ export class CoreRuntime {
387
646
  return fail(400, "Invalid JSON");
388
647
  }
389
648
  }
390
- command(id, command, scope) {
649
+ command(id, input, scope) {
391
650
  let event;
392
651
  let schedule = false;
393
652
  const response = this.store.tx(() => {
394
653
  const s = this.session(id);
395
- const key = JSON.stringify([id, command.idempotencyKey]);
654
+ let command = input;
396
655
  if (command.type === "action_result") {
397
656
  const a = this.store.get("actions", command.actionId) ??
398
657
  fail(404, "Action not found");
399
658
  if (a.sessionId !== id)
400
659
  fail(403, "Action belongs to another session");
401
660
  this.scoped(scope, a);
661
+ command = acceptedToolResult(a, command);
402
662
  }
403
663
  else if (scope !== "server")
404
664
  fail(403, "Application credential required");
665
+ const key = JSON.stringify([id, command.idempotencyKey]);
405
666
  const existing = this.store.get("commands", key);
406
667
  if (existing) {
407
668
  if (semantic(existing.command) !== semantic(command))
@@ -434,7 +695,7 @@ export class CoreRuntime {
434
695
  schedule = true;
435
696
  event = this.store.event(id, s.activeTurnId, "action.completed", {
436
697
  actionId: action.actionId,
437
- toolName: action.toolName,
698
+ ...actionTarget(action),
438
699
  kind: action.kind,
439
700
  result: command.outcome.value,
440
701
  });
@@ -544,13 +805,13 @@ export class CoreRuntime {
544
805
  });
545
806
  if (event)
546
807
  this.publish(event);
547
- if (command.type === "cancel")
808
+ if (input.type === "cancel")
548
809
  this.running.get(id)?.abort();
549
810
  if (schedule)
550
811
  this.schedule(id);
551
812
  return response;
552
813
  }
553
- sse(request, response, set) {
814
+ sse(request, response, set, whenEmpty) {
554
815
  response.writeHead(200, {
555
816
  "content-type": "text/event-stream",
556
817
  "cache-control": "no-cache",
@@ -564,6 +825,8 @@ export class CoreRuntime {
564
825
  request.on("close", () => {
565
826
  clearInterval(timer);
566
827
  set.delete(response);
828
+ if (!set.size)
829
+ whenEmpty?.();
567
830
  });
568
831
  }
569
832
  async handle(request, response) {
@@ -579,7 +842,13 @@ export class CoreRuntime {
579
842
  .map(decodeURIComponent);
580
843
  const method = request.method;
581
844
  if (url.pathname === "/health")
582
- return json({ status: "ok", service: "oss-runtime" });
845
+ return json({
846
+ status: "ok",
847
+ service: "oss-runtime",
848
+ version: RUNTIME_VERSION,
849
+ scopeId: this.scopeId,
850
+ pid: process.pid,
851
+ });
583
852
  if (url.pathname === "/ready") {
584
853
  this.store.db.prepare("SELECT 1").get();
585
854
  return json({
@@ -600,7 +869,11 @@ export class CoreRuntime {
600
869
  method === "GET") {
601
870
  if (scope === "server")
602
871
  fail(403, "Executor credential required");
603
- this.sse(request, response, this.executors);
872
+ const tokenHash = scope.tokenHash;
873
+ let streams = this.executorStreams.get(tokenHash);
874
+ if (!streams)
875
+ this.executorStreams.set(tokenHash, (streams = new Set()));
876
+ this.sse(request, response, streams, () => this.executorStreams.delete(tokenHash));
604
877
  this.send(response, 'event: work_available\ndata: {"type":"work_available"}\n\n');
605
878
  return;
606
879
  }
@@ -612,10 +885,7 @@ export class CoreRuntime {
612
885
  return json({
613
886
  actions: this.store
614
887
  .all("actions")
615
- .filter((a) => a.status === "pending" &&
616
- a.agentId === scope.agentId &&
617
- a.manifestHash === scope.manifestHash &&
618
- a.implementationVersion === scope.implementationVersion),
888
+ .filter((a) => a.status === "pending" && a.agentId === scope.agentId),
619
889
  });
620
890
  const actionId = path[2];
621
891
  if (!actionId)
@@ -627,10 +897,7 @@ export class CoreRuntime {
627
897
  fail(404, "Action not found");
628
898
  this.scoped(scope, action);
629
899
  if (method === "POST" && path[3] === "claim") {
630
- const claim = ActionClaimRequestSchema.parse(body);
631
- if (claim.manifestHash !== action.manifestHash ||
632
- claim.implementationVersion !== action.implementationVersion)
633
- fail(409, "Definition mismatch");
900
+ ActionClaimRequestSchema.parse(body);
634
901
  if (action.status !== "pending" ||
635
902
  this.session(action.sessionId).status === "cancelled" ||
636
903
  this.session(action.sessionId).activeTurnId !== action.turnId)
@@ -671,13 +938,104 @@ export class CoreRuntime {
671
938
  path[3] === "commands" &&
672
939
  method === "POST")
673
940
  return json(this.command(path[2], SessionCommandSchema.parse(await this.body(request)), scope));
941
+ if (path[1] === "vaults")
942
+ return json(await this.dispatchVault(scope, method, path, url, request));
943
+ if (path[1] === "host")
944
+ return json(await this.dispatchHost(scope, method, path, request));
674
945
  if (scope !== "server")
675
946
  fail(403, "Application credential required");
947
+ if (path[1] === "executors" && path.length === 2 && method === "GET")
948
+ return json({
949
+ executors: this.registry.list().map((e) => ({
950
+ agentId: e.agentId,
951
+ implementationVersion: e.implementationVersion,
952
+ ...(e.manifestHash === undefined
953
+ ? {}
954
+ : { manifestHash: e.manifestHash }),
955
+ connected: (this.executorStreams.get(e.tokenHash)?.size ?? 0) > 0,
956
+ updatedAt: e.updatedAt,
957
+ })),
958
+ });
959
+ // The length guard matters: the connect branch above only matches GET, so without it a
960
+ // PUT to /v1/executors/connect would register an agent literally named "connect".
961
+ if (path[1] === "executors" && path.length === 2 && method === "PUT") {
962
+ const body = RegisterExecutorsRequestSchema.parse(await this.body(request));
963
+ // Credential rules are shared with startup validation, which throws plainly; on the
964
+ // wire a rejected registration is a bad request, not a server fault.
965
+ try {
966
+ for (const executor of body.executors)
967
+ assertExecutorCredential(executor, this.options.serverToken);
968
+ }
969
+ catch (error) {
970
+ fail(400, error instanceof Error ? error.message : String(error));
971
+ }
972
+ if (new Set(body.executors.map((e) => e.agentId)).size !==
973
+ body.executors.length ||
974
+ new Set(body.executors.map((e) => e.token)).size !==
975
+ body.executors.length)
976
+ fail(400, "Executor registrations must be unique per agent and token");
977
+ const updatedAt = new Date().toISOString();
978
+ const records = body.executors.map((executor) => ({
979
+ agentId: executor.agentId,
980
+ implementationVersion: executor.implementationVersion,
981
+ ...(executor.manifestHash === undefined
982
+ ? {}
983
+ : { manifestHash: executor.manifestHash }),
984
+ tokenHash: hashToken(executor.token),
985
+ persisted: true,
986
+ updatedAt,
987
+ }));
988
+ for (const record of records) {
989
+ const collision = this.registry.find(record.tokenHash);
990
+ if (collision && collision.agentId !== record.agentId)
991
+ fail(409, "Executor tokens must be unique");
992
+ }
993
+ // Persist the whole batch first; the in-memory registry must never run ahead of SQLite.
994
+ this.store.tx(() => {
995
+ for (const record of records)
996
+ this.store.putExecutor({
997
+ agentId: record.agentId,
998
+ tokenHash: record.tokenHash,
999
+ implementationVersion: record.implementationVersion,
1000
+ ...(record.manifestHash === undefined
1001
+ ? {}
1002
+ : { manifestHash: record.manifestHash }),
1003
+ updatedAt,
1004
+ });
1005
+ });
1006
+ const results = records.map((record) => {
1007
+ const { rotated, previousHash } = this.registry.upsert(record);
1008
+ if (rotated && previousHash) {
1009
+ for (const r of this.executorStreams.get(previousHash) ?? [])
1010
+ r.end();
1011
+ this.executorStreams.delete(previousHash);
1012
+ }
1013
+ return {
1014
+ agentId: record.agentId,
1015
+ implementationVersion: record.implementationVersion,
1016
+ rotated,
1017
+ };
1018
+ });
1019
+ return json({ executors: results });
1020
+ }
1021
+ if (path[1] === "executors" &&
1022
+ path.length === 3 &&
1023
+ path[2] &&
1024
+ method === "DELETE") {
1025
+ const agentId = path[2];
1026
+ const existing = this.registry.get(agentId) ?? fail(404, "Executor not found");
1027
+ if (!existing.persisted)
1028
+ fail(409, "Executor is provided by NYLORUN_EXECUTORS_JSON and cannot be deleted");
1029
+ this.store.tx(() => this.store.deleteExecutor(agentId));
1030
+ this.registry.remove(agentId);
1031
+ for (const r of this.executorStreams.get(existing.tokenHash) ?? [])
1032
+ r.end();
1033
+ this.executorStreams.delete(existing.tokenHash);
1034
+ return json({ agentId, deleted: true });
1035
+ }
676
1036
  if (path[1] === "agents" && path.length === 2 && method === "GET")
677
1037
  return json({
678
- agents: this.store
679
- .all("definitions")
680
- .map((d) => ({
1038
+ agents: this.store.all("definitions").map((d) => ({
681
1039
  agentId: d.manifest.id,
682
1040
  manifest: d.manifest,
683
1041
  manifestHash: d.manifestHash,
@@ -723,11 +1081,19 @@ export class CoreRuntime {
723
1081
  const id = path[2];
724
1082
  if (method === "PUT" && path.length === 3) {
725
1083
  const body = PutSessionRequestSchema.parse(await this.body(request));
1084
+ const vaultIds = body.vaultIds ?? [];
1085
+ const credentialSelections = body.credentialSelections ?? [];
726
1086
  const result = this.store.tx(() => {
1087
+ this.vault.assertAttachment(body.ownerUserId, vaultIds, credentialSelections);
727
1088
  const prior = this.store.get("sessions", id);
728
1089
  if (prior) {
729
- if (semantic(prior.creation) !== semantic(body))
1090
+ if (sessionIdentity(prior.creation) !== sessionIdentity(body))
730
1091
  fail(409, "Session already exists with different creation parameters");
1092
+ prior.vaultIds = vaultIds;
1093
+ prior.credentialSelections = credentialSelections;
1094
+ prior.creation = body;
1095
+ this.store.put("sessions", id, prior);
1096
+ this.vault.recordAttachment(id, vaultIds);
731
1097
  return prior;
732
1098
  }
733
1099
  const definition = this.store.get("definitions", body.agentId) ??
@@ -743,8 +1109,12 @@ export class CoreRuntime {
743
1109
  status: "idle",
744
1110
  activeTurnId: null,
745
1111
  creation: body,
1112
+ vaultIds,
1113
+ credentialSelections,
1114
+ pluginRoots: definition.pluginRoots ?? {},
746
1115
  };
747
1116
  this.store.put("sessions", id, s);
1117
+ this.vault.recordAttachment(id, vaultIds);
748
1118
  return s;
749
1119
  });
750
1120
  return json(this.view(result));
@@ -775,7 +1145,7 @@ export class CoreRuntime {
775
1145
  response.end();
776
1146
  return;
777
1147
  }
778
- const status = error instanceof HttpError
1148
+ const status = error instanceof HttpError || error instanceof VaultError
779
1149
  ? error.status
780
1150
  : error?.name === "ZodError" ||
781
1151
  error?.message === "Invalid cursor"
@@ -799,6 +1169,10 @@ export class CoreRuntime {
799
1169
  implementationVersion: s.implementationVersion,
800
1170
  status: s.status,
801
1171
  activeTurnId: s.activeTurnId,
1172
+ vaultIds: s.vaultIds ?? [],
1173
+ credentialSelections: s.credentialSelections ?? [],
1174
+ mcpSnapshot: s.mcpSnapshot ?? null,
1175
+ mcpDiagnostics: s.mcpDiagnostics ?? [],
802
1176
  waits: Array.isArray(s.waits)
803
1177
  ? s.waits.map((call) => ({
804
1178
  invocationId: call.invocationId,
@@ -823,6 +1197,165 @@ export class CoreRuntime {
823
1197
  })),
824
1198
  };
825
1199
  }
1200
+ async prepareMcp(id, signal) {
1201
+ const s = this.session(id);
1202
+ const declared = s.manifest.capabilities.some((capability) => capability.mcpServers !== undefined &&
1203
+ Object.keys(capability.mcpServers).length > 0);
1204
+ if (!declared)
1205
+ return;
1206
+ if (!s.mcpSnapshot) {
1207
+ const found = await this.mcp.discover({
1208
+ sessionId: id,
1209
+ manifest: s.manifest,
1210
+ manifestHash: s.manifestHash,
1211
+ pluginRoots: s.pluginRoots ?? {},
1212
+ signal,
1213
+ });
1214
+ this.store.tx(() => {
1215
+ const current = this.session(id);
1216
+ if (current.mcpSnapshot)
1217
+ return;
1218
+ current.mcpSnapshot = found.snapshot;
1219
+ current.mcpDiagnostics = found.diagnostics;
1220
+ this.store.put("sessions", id, current);
1221
+ });
1222
+ return;
1223
+ }
1224
+ const diagnostics = await this.mcp.reconnect({
1225
+ sessionId: id,
1226
+ manifest: s.manifest,
1227
+ pluginRoots: s.pluginRoots ?? {},
1228
+ tools: s.mcpSnapshot.mcpTools,
1229
+ signal,
1230
+ });
1231
+ if (diagnostics.length === 0)
1232
+ return;
1233
+ this.store.tx(() => {
1234
+ const current = this.session(id);
1235
+ const prior = [...(current.mcpDiagnostics ?? [])];
1236
+ for (const item of diagnostics) {
1237
+ const index = prior.findIndex((existing) => existing.capabilityId === item.capabilityId &&
1238
+ existing.serverName === item.serverName);
1239
+ if (index >= 0)
1240
+ prior[index] = item;
1241
+ else
1242
+ prior.push(item);
1243
+ }
1244
+ current.mcpDiagnostics = prior;
1245
+ this.store.put("sessions", id, current);
1246
+ });
1247
+ }
1248
+ async callMcpTool(request) {
1249
+ const s = this.session(request.sessionId);
1250
+ const tool = mcpToolOf(s, request.capabilityId, request.toolName);
1251
+ if (!tool)
1252
+ throw new Error(`MCP tool '${request.toolName ?? ""}' is not in the session snapshot`);
1253
+ return this.mcp.call({
1254
+ sessionId: s.id,
1255
+ capabilityId: tool.capabilityId,
1256
+ serverName: tool.serverName,
1257
+ serverToolName: tool.serverToolName,
1258
+ args: request.input,
1259
+ manifest: s.manifest,
1260
+ pluginRoots: s.pluginRoots ?? {},
1261
+ });
1262
+ }
1263
+ async callSandboxTool(request, signal) {
1264
+ const s = this.session(request.sessionId);
1265
+ const capability = sandboxCapabilityOf(s.manifest, request.capabilityId, request.toolName);
1266
+ if (!capability)
1267
+ throw new Error(`'${request.toolName ?? ""}' is not a sandbox tool`);
1268
+ return this.sandbox.run({ id: s.id, activeTurnId: s.activeTurnId, manifest: s.manifest }, capability, request.toolName, request.input, signal);
1269
+ }
1270
+ async authorize(sessionId, request) {
1271
+ const s = this.session(sessionId);
1272
+ const result = await this.vault.authorize({
1273
+ sessionId,
1274
+ vaultIds: s.vaultIds ?? [],
1275
+ credentialSelections: s.credentialSelections ?? [],
1276
+ url: request.url,
1277
+ serverName: request.serverName,
1278
+ });
1279
+ if (result.status === "authorized") {
1280
+ const token = result.headers.authorization.slice("Bearer ".length);
1281
+ scrub({ authorization: result.headers.authorization, url: result.url }, [
1282
+ token,
1283
+ ]);
1284
+ }
1285
+ return result;
1286
+ }
1287
+ ensureKek() {
1288
+ if (this.kek)
1289
+ return this.kek;
1290
+ if (this.options.vaultKek === null)
1291
+ throw new Error("Vault key-encryption key is required");
1292
+ this.kek = createKekFile(this.kekPath);
1293
+ return this.kek;
1294
+ }
1295
+ async dispatchHost(scope, method, path, request) {
1296
+ if (scope !== "server") {
1297
+ this.vault.reject(path.join("/"));
1298
+ fail(403, "Application credential required");
1299
+ }
1300
+ if (path[2] === "models" && path.length === 3 && method === "GET")
1301
+ return hostModelCatalog();
1302
+ if (path[2] === "sandbox" && path.length === 3 && method === "GET")
1303
+ return this.sandbox.report();
1304
+ if (path[2] === "providers" && path.length === 3 && method === "GET")
1305
+ return this.vault.listHostProviders();
1306
+ if (path[2] === "model" && path.length === 3 && method === "GET")
1307
+ return this.vault.getHostModel();
1308
+ if (path[2] === "model" && path.length === 3 && method === "PUT")
1309
+ return this.vault.putHostModel(PutHostModelRequestSchema.parse(await this.body(request)));
1310
+ if (path[2] === "model" &&
1311
+ path[3] === "selection" &&
1312
+ path.length === 4 &&
1313
+ method === "PUT")
1314
+ return this.vault.selectHostModel(SelectHostModelRequestSchema.parse(await this.body(request)));
1315
+ fail(404, "Route not found");
1316
+ }
1317
+ async dispatchVault(scope, method, path, url, request) {
1318
+ if (scope !== "server") {
1319
+ this.vault.reject(path.join("/"));
1320
+ fail(403, "Application credential required");
1321
+ }
1322
+ if (path.length === 2 && method === "POST") {
1323
+ const body = CreateVaultRequestSchema.parse(await this.body(request));
1324
+ return this.vault.createVault(body);
1325
+ }
1326
+ if (path.length === 2 && method === "GET") {
1327
+ const ownerUserId = url.searchParams.get("ownerUserId") ??
1328
+ fail(400, "ownerUserId is required");
1329
+ return { vaults: this.vault.listVaults(ownerUserId) };
1330
+ }
1331
+ const vaultId = path[2];
1332
+ if (!vaultId)
1333
+ fail(404, "Vault not found");
1334
+ if (path.length === 3 && method === "GET")
1335
+ return this.vault.getVault(vaultId);
1336
+ if (path.length === 3 && method === "DELETE")
1337
+ return this.vault.deleteVault(vaultId);
1338
+ if (path[3] !== "credentials")
1339
+ fail(404, "Route not found");
1340
+ if (path.length === 4 && method === "POST") {
1341
+ const body = CreateCredentialRequestSchema.parse(await this.body(request));
1342
+ return this.vault.createCredential(vaultId, body);
1343
+ }
1344
+ if (path.length === 4 && method === "GET")
1345
+ return { credentials: this.vault.listCredentials(vaultId) };
1346
+ const credentialId = path[4];
1347
+ if (!credentialId)
1348
+ fail(404, "Credential not found");
1349
+ if (path.length === 5 && method === "GET")
1350
+ return this.vault.getCredential(vaultId, credentialId);
1351
+ if (path.length === 5 && method === "POST") {
1352
+ const body = RotateCredentialRequestSchema.parse(await this.body(request));
1353
+ return this.vault.rotateCredential(vaultId, credentialId, body);
1354
+ }
1355
+ if (path.length === 5 && method === "DELETE")
1356
+ return this.vault.deleteCredential(vaultId, credentialId);
1357
+ fail(404, "Route not found");
1358
+ }
826
1359
  async listen(port = 8787, hostname = "127.0.0.1") {
827
1360
  if (this.server)
828
1361
  throw new Error("Already listening");
@@ -843,8 +1376,10 @@ export class CoreRuntime {
843
1376
  clearInterval(this.timer);
844
1377
  for (const c of this.running.values())
845
1378
  c.abort();
846
- for (const r of this.executors)
847
- r.end();
1379
+ await this.mcp.close();
1380
+ for (const streams of this.executorStreams.values())
1381
+ for (const r of streams)
1382
+ r.end();
848
1383
  for (const set of this.observers.values())
849
1384
  for (const r of set)
850
1385
  r.end();
@@ -856,6 +1391,8 @@ export class CoreRuntime {
856
1391
  // Providers must honor AbortSignal. Keep storage open until any in-flight journal writes have drained.
857
1392
  while (this.running.size)
858
1393
  await new Promise((resolve) => setTimeout(resolve, 10));
1394
+ await this.sandbox.close();
1395
+ this.closed = true;
859
1396
  this.store.db.close();
860
1397
  if (this.lockPath)
861
1398
  unlinkSync(this.lockPath);
@@ -875,3 +1412,7 @@ export async function startRuntime(options) {
875
1412
  throw error;
876
1413
  }
877
1414
  }
1415
+ /** What an action runs, for events: a tool name, or a hook point and its capabilities. */
1416
+ function actionTarget(action) {
1417
+ return action.kind === "hook" ? { hook: action.hook } : { toolName: action.toolName };
1418
+ }