@nylorun/runtime 0.20.0-beta → 0.21.1-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 (109) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +19 -12
  3. package/dist/adapters/execution/restate.js +26 -1
  4. package/dist/api/components.d.ts +155 -36
  5. package/dist/api/components.js +3 -3
  6. package/dist/api/http/app.js +2 -0
  7. package/dist/api/http/routes/mcp.d.ts +9 -0
  8. package/dist/api/http/routes/mcp.js +32 -0
  9. package/dist/api/http/routes/vaults.d.ts +1 -9
  10. package/dist/api/http/routes/vaults.js +3 -103
  11. package/dist/api/openapi.js +6 -3
  12. package/dist/artifacts/media-types.d.ts +2 -0
  13. package/dist/artifacts/media-types.js +5 -0
  14. package/dist/core/flow-host.d.ts +2 -3
  15. package/dist/core/flow-host.js +10 -7
  16. package/dist/execution/types.d.ts +13 -5
  17. package/dist/execution/types.js +13 -5
  18. package/dist/gates/http-tool.d.ts +7 -0
  19. package/dist/gates/http-tool.js +34 -3
  20. package/dist/gates/mcp-handler.d.ts +4 -1
  21. package/dist/gates/mcp-handler.js +64 -8
  22. package/dist/gates/tenant-vaults.d.ts +9 -15
  23. package/dist/gates/tenant-vaults.js +9 -17
  24. package/dist/gates/tool-client.js +36 -17
  25. package/dist/gates/tool-contract.d.ts +19 -3
  26. package/dist/gates/tool-contract.js +9 -3
  27. package/dist/gates/tool-gate.d.ts +2 -2
  28. package/dist/harness/calls.d.ts +2 -0
  29. package/dist/harness/calls.js +13 -3
  30. package/dist/harness-api/record.d.ts +7 -6
  31. package/dist/harness-api/record.js +99 -17
  32. package/dist/harness-api/server.js +35 -27
  33. package/dist/harness-api/start.js +2 -1
  34. package/dist/host/config-for.d.ts +0 -2
  35. package/dist/host/config-for.js +0 -1
  36. package/dist/host/create-host.d.ts +1 -1
  37. package/dist/host/create-host.js +2 -1
  38. package/dist/host/gates.js +0 -1
  39. package/dist/host/http.d.ts +0 -1
  40. package/dist/host/http.js +0 -6
  41. package/dist/host/main.js +13 -3
  42. package/dist/host/stack-config.d.ts +7 -8
  43. package/dist/host/stack-config.js +50 -92
  44. package/dist/index.d.ts +0 -1
  45. package/dist/keys/client.js +1 -9
  46. package/dist/keys/keys.d.ts +11 -13
  47. package/dist/keys/keys.js +5 -7
  48. package/dist/management-openapi.json +315 -345
  49. package/dist/mcp/connect.d.ts +159 -13
  50. package/dist/mcp/connect.js +538 -55
  51. package/dist/mcp/pool.d.ts +11 -2
  52. package/dist/mcp/pool.js +74 -27
  53. package/dist/mcp/preview.d.ts +35 -0
  54. package/dist/mcp/preview.js +215 -0
  55. package/dist/mcp/snapshot.d.ts +79 -5
  56. package/dist/mcp/snapshot.js +145 -27
  57. package/dist/model/catalog.d.ts +14 -0
  58. package/dist/model/catalog.js +14 -1
  59. package/dist/model/pi-model.js +36 -2
  60. package/dist/openapi.json +362 -123
  61. package/dist/redact.d.ts +19 -1
  62. package/dist/redact.js +42 -13
  63. package/dist/sandbox/session-sandbox.d.ts +7 -0
  64. package/dist/sandbox/session-sandbox.js +89 -28
  65. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  66. package/dist/store/postgres/drizzle/0017_wake_outbox.sql +9 -0
  67. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  68. package/dist/store/postgres/schema.d.ts +105 -261
  69. package/dist/store/postgres/schema.js +18 -36
  70. package/dist/store/postgres/store.js +54 -21
  71. package/dist/store/postgres/tenant.d.ts +14 -0
  72. package/dist/store/postgres/tenant.js +22 -2
  73. package/dist/store/types.d.ts +48 -13
  74. package/dist/tenant/advance.js +1 -4
  75. package/dist/tenant/artifact-tool.d.ts +9 -1
  76. package/dist/tenant/artifact-tool.js +113 -9
  77. package/dist/tenant/commands.js +5 -5
  78. package/dist/tenant/context.d.ts +15 -13
  79. package/dist/tenant/effects.d.ts +2 -2
  80. package/dist/tenant/effects.js +20 -5
  81. package/dist/tenant/ephemeral.d.ts +0 -6
  82. package/dist/tenant/ephemeral.js +0 -1
  83. package/dist/tenant/outbound.d.ts +14 -5
  84. package/dist/tenant/outbound.js +21 -9
  85. package/dist/tenant/runtime.js +11 -15
  86. package/dist/tenant/sessions.js +20 -3
  87. package/dist/tenant/store-pg.js +9 -0
  88. package/dist/tenant/sweep.d.ts +14 -0
  89. package/dist/tenant/sweep.js +69 -10
  90. package/dist/tenant/tool-results.d.ts +57 -0
  91. package/dist/tenant/tool-results.js +298 -0
  92. package/dist/tenant/tool-search.d.ts +43 -0
  93. package/dist/tenant/tool-search.js +100 -0
  94. package/dist/tenant/transcript.d.ts +108 -0
  95. package/dist/tenant/transcript.js +13 -0
  96. package/dist/tenant/types.d.ts +0 -14
  97. package/dist/vault/crypto.d.ts +0 -7
  98. package/dist/vault/crypto.js +0 -16
  99. package/dist/vault/headers.d.ts +17 -0
  100. package/dist/vault/headers.js +61 -0
  101. package/dist/vault/service.d.ts +11 -68
  102. package/dist/vault/service.js +83 -438
  103. package/dist/vault/sources.d.ts +20 -48
  104. package/dist/vault/sources.js +25 -189
  105. package/dist/version.d.ts +1 -1
  106. package/dist/version.js +1 -1
  107. package/package.json +3 -3
  108. package/dist/vault/oauth.d.ts +0 -55
  109. package/dist/vault/oauth.js +0 -186
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+ import { McpPreviewRequestSchema } from "@nylorun/core/contracts";
3
+ import { McpPreview, McpPreviewRequest, Rejected } from "../../components.js";
4
+ import { readJson } from "../body.js";
5
+ import { tenantRoute } from "../define.js";
6
+ import { jsonResponse } from "../respond.js";
7
+ /** The vault routes' access: a management key. */
8
+ const MANAGEMENT = { credentials: ["management"], scopes: "never" };
9
+ const json = (schema, description) => ({
10
+ description,
11
+ content: { "application/json": { schema } },
12
+ });
13
+ export function mcpRoutes(api) {
14
+ tenantRoute(api, MANAGEMENT, {
15
+ method: "post",
16
+ path: "/v1/tenant/mcp/preview",
17
+ tags: ["MCP servers"],
18
+ summary: "Preview an MCP server's tools",
19
+ description: "Connects to the remote MCP server at `url` with the installation vault's credential for that URL (its headers and `via`; never an identity header), under the Host's address policy, lists its tools within 15 s and closes the connection. It never calls a tool. Each tool comes with the name the model would call it (`modelName`, made with `name`), its annotations and its input schema's size. A server that answers `401` is `authRequired`, with its RFC 9728 protected-resource metadata when it publishes any: it needs a person's sign-in, so reach it through a gateway (`via`), or with a key. No credential value is in the answer.",
20
+ request: {
21
+ body: { required: true, content: { "application/json": { schema: McpPreviewRequest } } },
22
+ },
23
+ responses: {
24
+ 200: json(McpPreview, "The server's tools, or that it needs a credential (`authRequired`)"),
25
+ 409: json(Rejected, "Several installation vaults hold a credential for the URL (name one with `vaultId`), or the one there cannot be used"),
26
+ 502: json(Rejected, "The server could not be listed (`mcp_preview_failed`): `details.failure` is the code a tool call would have failed with, such as `mcp.unreachable` for an address the Host refuses"),
27
+ },
28
+ }, async (c) => {
29
+ const request = McpPreviewRequestSchema.parse(await readJson(c.req.raw));
30
+ return jsonResponse(200, await c.env.tenant.keys.previewMcp(request));
31
+ });
32
+ }
@@ -3,16 +3,8 @@
3
3
  * a session's tools use. Only a management key (or Studio's key, acting as itself) reaches these
4
4
  * routes; an application key is `403 key_role_mismatch`, and nothing acts for a subject here. The
5
5
  * installation's own vaults (`scope: "installation"`) attach to any session (`vaultIds`); a vault
6
- * of one person (`ownerUserId`) attaches only to that person's sessions. A person's own
7
- * credentials come from the operator's credential resolver (`vault/sources.ts`), not from these
8
- * routes.
9
- *
10
- * MCP OAuth connect: a management key starts one into an installation vault (`/oauth/start`);
11
- * the authorization server sends the browser back to the anonymous, unversioned
12
- * `GET /v1/oauth/callback`. Both only route: the gateway's keys module does the OAuth.
6
+ * of one person (`ownerUserId`) attaches only to that person's sessions.
13
7
  */
14
8
  import type { OpenAPIHono } from "@hono/zod-openapi";
15
9
  import type { TenantEnv } from "../app.js";
16
- /** Where the authorization server sends the browser back. */
17
- export declare const OAUTH_CALLBACK_PATH = "/v1/oauth/callback";
18
10
  export declare function vaultRoutes(api: OpenAPIHono<TenantEnv>): void;
@@ -1,25 +1,11 @@
1
1
  import { z } from "zod";
2
- import { CreateCredentialRequestSchema, CreateVaultRequestSchema, RotateCredentialRequestSchema, StartOAuthRequestSchema, } from "@nylorun/core/contracts";
3
- import { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, DeletedResponse, ListCredentialsResponse, ListVaultsResponse, RotateCredentialRequest, StartOAuthRequest, StartOAuthResponse, VaultInfo, } from "../../components.js";
4
- import { HttpError } from "../../../tenant/http.js";
5
- import { VaultError } from "../../../vault/error.js";
2
+ import { CreateCredentialRequestSchema, CreateVaultRequestSchema, RotateCredentialRequestSchema, } from "@nylorun/core/contracts";
3
+ import { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, DeletedResponse, ListCredentialsResponse, ListVaultsResponse, RotateCredentialRequest, VaultInfo, } from "../../components.js";
6
4
  import { readJson } from "../body.js";
7
5
  import { tenantRoute } from "../define.js";
8
6
  import { jsonResponse } from "../respond.js";
9
7
  /** The Management API's vault routes (`/v1/tenant/vaults`): a management key. */
10
8
  const MANAGEMENT = { credentials: ["management"], scopes: "never" };
11
- /**
12
- * The OAuth callback: a browser sent back by the authorization server, with no credential and no
13
- * protocol header. The `state` it carries is the grant.
14
- */
15
- const CALLBACK = {
16
- credentials: ["application", "subject", "token"],
17
- scopes: "any",
18
- anonymous: true,
19
- unversioned: true,
20
- };
21
- /** Where the authorization server sends the browser back. */
22
- export const OAUTH_CALLBACK_PATH = "/v1/oauth/callback";
23
9
  const json = (schema, description) => ({
24
10
  description,
25
11
  content: { "application/json": { schema } },
@@ -94,7 +80,7 @@ function vaultRoutesAt(api, base, access) {
94
80
  path: `${base}/{vaultId}/credentials`,
95
81
  tags: ["Vaults"],
96
82
  summary: "Add a credential",
97
- description: "A bearer token or OAuth tokens, bound to the URLs a tool may send them to.",
83
+ description: "A bearer token, bound to the URL a tool may send it to.",
98
84
  request: { params: vaultId, body: body(CreateCredentialRequest) },
99
85
  responses: { 200: json(CredentialInfo, "The credential, without its secret") },
100
86
  }, async (c) => {
@@ -147,93 +133,7 @@ function vaultRoutesAt(api, base, access) {
147
133
  }, async (c) => {
148
134
  return jsonResponse(200, await c.env.tenant.vault.deleteCredential(c.req.param("vaultId"), c.req.param("credentialId")));
149
135
  });
150
- tenantRoute(api, access, {
151
- method: "post",
152
- path: `${base}/{vaultId}/oauth/start`,
153
- tags: ["Vaults"],
154
- summary: "Start an MCP OAuth connect",
155
- description: "Signs the installation in to the remote MCP server at `url` (declared as `server`) and stores its OAuth credential, bound to `url`, in this installation vault. The Runtime discovers the server's authorization server (RFC 9728, RFC 8414), registers itself (RFC 7591) unless `clientId` names a registered client, and answers the URL to open in a browser; the sign-in returns to `GET /v1/oauth/callback` within `expiresAt`. The callback URL is `NYLORUN_PUBLIC_URL` + `/v1/oauth/callback`, or this request's own origin when the Host has no public URL. Application keys acting for no one only. `oauth_client_required` when the server offers no registration and no `clientId` was given.",
156
- request: { params: vaultId, body: body(StartOAuthRequest) },
157
- responses: {
158
- 200: json(StartOAuthResponse, "Where to send the browser"),
159
- 502: {
160
- description: "The authorization server, or its discovery, failed or refused (`oauth_failed`)",
161
- },
162
- },
163
- }, async (c) => {
164
- const request = StartOAuthRequestSchema.parse(await readJson(c.req.raw));
165
- const tenant = c.env.tenant;
166
- const base = tenant.config.publicUrl ?? new URL(c.req.url).origin;
167
- return jsonResponse(200, await tenant.keys.startOAuth({
168
- vaultId: c.req.param("vaultId"),
169
- server: request.server,
170
- url: request.url,
171
- ...(request.clientId === undefined ? {} : { clientId: request.clientId }),
172
- redirectUri: `${base}${OAUTH_CALLBACK_PATH}`,
173
- }));
174
- });
175
136
  }
176
137
  export function vaultRoutes(api) {
177
138
  vaultRoutesAt(api, "/v1/tenant/vaults", MANAGEMENT);
178
- tenantRoute(api, CALLBACK, {
179
- method: "get",
180
- path: OAUTH_CALLBACK_PATH,
181
- tags: ["Vaults"],
182
- summary: "Finish an MCP OAuth connect",
183
- description: "Where the authorization server sends the browser back with `code` and `state` (or `error`). Exchanges the code once and stores the credential; answers a small HTML page. Needs no credential and no `Nylorun-Protocol`. A `state` is used once, for ten minutes (`oauth_state_invalid`).",
184
- request: {
185
- query: z.object({
186
- code: z.string().optional(),
187
- state: z.string().optional(),
188
- error: z.string().optional(),
189
- }),
190
- },
191
- responses: {
192
- 200: { description: "Connected", content: { "text/html": { schema: z.string() } } },
193
- 502: { description: "The authorization server refused the code (`oauth_failed`)" },
194
- },
195
- }, async (c) => {
196
- const state = c.req.query("state");
197
- const code = c.req.query("code");
198
- const error = c.req.query("error");
199
- if (!state)
200
- return page(400, "Sign-in failed", "The authorization server sent no state. Start the connect again.");
201
- try {
202
- await c.env.tenant.keys.finishOAuth({
203
- state,
204
- ...(code === undefined ? {} : { code }),
205
- ...(error === undefined ? {} : { error }),
206
- });
207
- }
208
- catch (failure) {
209
- if (failure instanceof HttpError || failure instanceof VaultError)
210
- return page(failure.status, "Sign-in failed", failure.message);
211
- c.env.tenant.config.logger.error("oauth_callback_failed", {
212
- error: failure instanceof Error ? failure.name : "unknown",
213
- });
214
- return page(500, "Sign-in failed", "The connect failed. Start it again.");
215
- }
216
- return page(200, "Connected", "Connected. You can close this tab.");
217
- });
218
- }
219
- const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
220
- const escape = (text) => text.replace(/[&<>"']/g, (char) => ESCAPES[char]);
221
- /** The callback's answer: a small page that loads nothing, is never cached and leaks no referrer. */
222
- function page(status, title, message) {
223
- const html = `<!doctype html>
224
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
225
- <title>${escape(title)} · Nylorun</title>
226
- <style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem;color:#1a1a1a;background:#fff}@media (prefers-color-scheme:dark){body{color:#eee;background:#111}}h1{font-size:1.25rem}</style>
227
- </head><body><h1>${escape(title)}</h1><p>${escape(message)}</p></body></html>
228
- `;
229
- return new Response(html, {
230
- status,
231
- headers: {
232
- "content-type": "text/html; charset=utf-8",
233
- "cache-control": "no-store",
234
- "referrer-policy": "no-referrer",
235
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
236
- "x-content-type-options": "nosniff",
237
- },
238
- });
239
139
  }
@@ -236,7 +236,7 @@ const MANAGEMENT_TAGS = [
236
236
  },
237
237
  {
238
238
  name: "Vaults",
239
- description: "The installation's credentials for agents' tools: API keys and MCP OAuth connections. Sessions attach a vault by id. `admin.vaults`.",
239
+ description: "The installation's and each person's credentials for agents' tools: API keys, bound to the URL they are sent to. Sessions attach a vault by id. `admin.vaults`.",
240
240
  operations: [
241
241
  "POST /v1/tenant/vaults",
242
242
  "GET /v1/tenant/vaults",
@@ -247,10 +247,13 @@ const MANAGEMENT_TAGS = [
247
247
  "GET /v1/tenant/vaults/{vaultId}/credentials/{credentialId}",
248
248
  "POST /v1/tenant/vaults/{vaultId}/credentials/{credentialId}",
249
249
  "DELETE /v1/tenant/vaults/{vaultId}/credentials/{credentialId}",
250
- "POST /v1/tenant/vaults/{vaultId}/oauth/start",
251
- "GET /v1/oauth/callback",
252
250
  ],
253
251
  },
252
+ {
253
+ name: "MCP servers",
254
+ description: "See a remote MCP server's tools before an agent names it, with the installation vault's credential for its URL: their model names, sizes and annotations, or that it needs a person's sign-in. `admin.mcp`.",
255
+ operations: ["POST /v1/tenant/mcp/preview"],
256
+ },
254
257
  {
255
258
  name: "Signing keys",
256
259
  description: "The keys the Runtime signs capability links and run tokens with; their public halves are the Runtime API's JWKS. `admin.signingKeys`.",
@@ -11,3 +11,5 @@ export declare function contentTypeFor(name: string): string;
11
11
  export declare function isModelImage(contentType: string): boolean;
12
12
  /** Files the model reads as text: `text/*`, JSON, XML, YAML and their `+json`/`+xml` kin. */
13
13
  export declare function isText(contentType: string): boolean;
14
+ /** The usual extension of a media type, for a name the Runtime gives a file; undefined if none. */
15
+ export declare function extensionFor(contentType: string): string | undefined;
@@ -78,3 +78,8 @@ export function isText(contentType) {
78
78
  type.endsWith("+json") ||
79
79
  type.endsWith("+xml"));
80
80
  }
81
+ /** The usual extension of a media type, for a name the Runtime gives a file; undefined if none. */
82
+ export function extensionFor(contentType) {
83
+ const type = essence(contentType);
84
+ return Object.keys(BY_EXTENSION).find((extension) => BY_EXTENSION[extension] === type);
85
+ }
@@ -1,8 +1,7 @@
1
1
  import type { EffectOutcome } from "@nylorun/core/contracts";
2
2
  import { type JsonValue, type WorkflowManifest } from "@nylorun/core/define";
3
3
  import type { HostEffect } from "@nylorun/harness/run";
4
- import type { Wake } from "../execution/types.js";
5
- import type { EffectDoc, Tx } from "../store/types.js";
4
+ import type { EffectDoc, Tx, WakeDelivery } from "../store/types.js";
6
5
  import { type FlowLimits } from "./limits.js";
7
6
  /** Deterministic agent session id: derive(workflowSessionId, path, …parts). */
8
7
  export declare function deriveSessionId(workflowSessionId: string, path: string, ...parts: string[]): string;
@@ -99,7 +98,7 @@ export type CascadeCancelPlan = {
99
98
  readonly agentSessionIds: string[];
100
99
  };
101
100
  /** Wakes a session after commit (`DurableExecution.wake` through the Tenant context). */
102
- type Schedule = (sessionId: string, wake: Wake) => void | Promise<void>;
101
+ type Schedule = WakeDelivery;
103
102
  /** Effect documents as the flow host writes them. */
104
103
  export type FlowEffect = EffectDoc & {
105
104
  agentSessionId?: string;
@@ -4,7 +4,8 @@
4
4
  * Every function that touches state takes the caller's transaction `t: Tx` and
5
5
  * is async. Nothing here publishes, notifies or schedules directly (seam rule
6
6
  * 1): events go through `t.event(...)`, which the store publishes after
7
- * commit, and wakes go through `t.afterCommit(() => schedule(id, wake))`. Functions
7
+ * commit, and wakes go through `t.wake(id, wake, schedule)`, the wake outbox, which
8
+ * calls `schedule` after commit. Functions
8
9
  * that rewrite a session-scoped document lock that session first with
9
10
  * `t.lockSession` (a no-op when the caller already holds it).
10
11
  *
@@ -35,8 +36,9 @@
35
36
  * - `foreignInteractionConflict({ t, workflowSessionId, interactionId }): Promise<{ status: 409; message; ownerSessionId } | undefined>`
36
37
  * - `wakeForQueuedEffects({ t, workflowSessionId, turnId, limits, schedule }): Promise<boolean>`
37
38
  *
38
- * `schedule: (sessionId, wake) => void | Promise<void>` runs after commit, never inside `t`;
39
- * `wake` carries the reason (`linked`, `flow`) and, where the cause has one, a dedupe key.
39
+ * `schedule` (a `WakeDelivery`, the Tenant's `ctx.wake`) runs after commit, never inside `t`;
40
+ * `wake` carries the reason (`linked`, `flow`) and, where the cause has one, a dedupe key. A
41
+ * wake `schedule` does not deliver stays in the outbox for the Tenant sweep (`Tx.wake`).
40
42
  *
41
43
  * An `agent` effect settles only from the linked turn it started. A linked agent session is
42
44
  * reused by every iteration of a Loop (its id derives from the workflow and the path), so its
@@ -138,8 +140,9 @@ export async function recordVerdict(t, request, value) {
138
140
  ...(verdict.data !== undefined ? { data: verdict.data } : {}),
139
141
  });
140
142
  }
141
- function scheduleAfterCommit(t, schedule, id, wake) {
142
- t.afterCommit(() => schedule(id, wake));
143
+ /** Writes the wake to the outbox; `schedule` delivers it after commit. */
144
+ function requestWake(t, schedule, id, wake) {
145
+ return t.wake(id, wake, schedule);
143
146
  }
144
147
  /** Active agent turns + tool nodes in flight for one workflow turn. */
145
148
  export async function countActiveFlowWork(t, workflowSessionId, turnId) {
@@ -242,7 +245,7 @@ export async function wakeLinkedWorkflow(input) {
242
245
  await settleAgentEffect({ t, effect, outcome });
243
246
  workflow.status = "runnable";
244
247
  await t.put("sessions", workflow.id, workflow);
245
- scheduleAfterCommit(t, input.schedule, workflow.id, {
248
+ await requestWake(t, input.schedule, workflow.id, {
246
249
  reason: "linked",
247
250
  dedupeKey: `linked:${link.turnId}:${link.effectId}`,
248
251
  });
@@ -471,7 +474,7 @@ export async function wakeForQueuedEffects(input) {
471
474
  workflow.status = "runnable";
472
475
  await t.put("sessions", workflow.id, workflow);
473
476
  }
474
- scheduleAfterCommit(t, input.schedule, input.workflowSessionId, {
477
+ await requestWake(t, input.schedule, input.workflowSessionId, {
475
478
  reason: "flow",
476
479
  });
477
480
  return true;
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * Durable Session Execution decides when to look at a session again. It never
5
5
  * holds session state: every outcome lives in the Session Store, and `advance`
6
- * is safe to repeat (§10.1). Restate is the supported implementation
6
+ * is safe to repeat (§10.1). It owns the schedule (wake delivery, one advance per
7
+ * key, retries, timers, the sweep chain); the Session Store owns the record (the
8
+ * checkpoint, the effect journal, the events) and the wake outbox that bridges the
9
+ * two. Restate is the supported implementation
7
10
  * (`adapters/execution/restate.ts`); `execution/memory.ts` is the in-process
8
11
  * implementation used by unit tests, `startEphemeralRuntime` and a Host
9
12
  * started without Restate endpoints.
@@ -15,11 +18,14 @@
15
18
  * Different keys may run concurrently.
16
19
  * - **At least once after a wake.** When `wake` resolves, an `advance` for the
17
20
  * key will start after that point (possibly merged with other wakes). Wakes
18
- * are sent after the Session Store transaction that caused them commits
19
- * (`Tx.afterCommit`), never inside it.
21
+ * are sent after the Session Store transaction that caused them commits, never
22
+ * inside it: the transaction writes the wake to its outbox (`Tx.wake`), and the
23
+ * wake is sent after commit, or by the Tenant sweep when that send failed or a
24
+ * crash cut it off. A wake may therefore be sent more than once, always with the
25
+ * same `dedupeKey`.
20
26
  * - **Dedupe.** Wakes carrying the same `dedupeKey` for the same key cause at
21
27
  * most one advance for that cause (within the implementation's retention
22
- * window, at least 24 hours for Restate).
28
+ * window, at least 24 hours for Restate). Every wake from the outbox has one.
23
29
  * - **Busy re-wake.** An advance that returns `busy` is re-run for the same key
24
30
  * after `retryAfterMs`.
25
31
  * - **Retries on infrastructure errors.** A handler that throws is retried with
@@ -27,7 +33,9 @@
27
33
  * becomes `uncertain` is settled in the Session Store and `advance` returns
28
34
  * `done`.
29
35
  * - **Sweeps.** `armSweep` arms one self-re-arming sweep per Tenant; arming an
30
- * armed Tenant is a no-op. The sweep re-wakes orphaned `runnable` sessions.
36
+ * armed Tenant is a no-op. The sweep sends the outbox's undelivered wakes and,
37
+ * as a backstop for what an implementation lost, re-wakes orphaned sessions
38
+ * (`tenant/sweep.ts`).
31
39
  * - **One reconcile per sandbox at a time** (F7.2). `sandbox` runs
32
40
  * `WorkerHandlers.sandbox` for the key `<tenantId>:<sandboxId>` at least once
33
41
  * after a `reconcile` resolves, never overlapping another run for the key. A
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * Durable Session Execution decides when to look at a session again. It never
5
5
  * holds session state: every outcome lives in the Session Store, and `advance`
6
- * is safe to repeat (§10.1). Restate is the supported implementation
6
+ * is safe to repeat (§10.1). It owns the schedule (wake delivery, one advance per
7
+ * key, retries, timers, the sweep chain); the Session Store owns the record (the
8
+ * checkpoint, the effect journal, the events) and the wake outbox that bridges the
9
+ * two. Restate is the supported implementation
7
10
  * (`adapters/execution/restate.ts`); `execution/memory.ts` is the in-process
8
11
  * implementation used by unit tests, `startEphemeralRuntime` and a Host
9
12
  * started without Restate endpoints.
@@ -15,11 +18,14 @@
15
18
  * Different keys may run concurrently.
16
19
  * - **At least once after a wake.** When `wake` resolves, an `advance` for the
17
20
  * key will start after that point (possibly merged with other wakes). Wakes
18
- * are sent after the Session Store transaction that caused them commits
19
- * (`Tx.afterCommit`), never inside it.
21
+ * are sent after the Session Store transaction that caused them commits, never
22
+ * inside it: the transaction writes the wake to its outbox (`Tx.wake`), and the
23
+ * wake is sent after commit, or by the Tenant sweep when that send failed or a
24
+ * crash cut it off. A wake may therefore be sent more than once, always with the
25
+ * same `dedupeKey`.
20
26
  * - **Dedupe.** Wakes carrying the same `dedupeKey` for the same key cause at
21
27
  * most one advance for that cause (within the implementation's retention
22
- * window, at least 24 hours for Restate).
28
+ * window, at least 24 hours for Restate). Every wake from the outbox has one.
23
29
  * - **Busy re-wake.** An advance that returns `busy` is re-run for the same key
24
30
  * after `retryAfterMs`.
25
31
  * - **Retries on infrastructure errors.** A handler that throws is retried with
@@ -27,7 +33,9 @@
27
33
  * becomes `uncertain` is settled in the Session Store and `advance` returns
28
34
  * `done`.
29
35
  * - **Sweeps.** `armSweep` arms one self-re-arming sweep per Tenant; arming an
30
- * armed Tenant is a no-op. The sweep re-wakes orphaned `runnable` sessions.
36
+ * armed Tenant is a no-op. The sweep sends the outbox's undelivered wakes and,
37
+ * as a backstop for what an implementation lost, re-wakes orphaned sessions
38
+ * (`tenant/sweep.ts`).
31
39
  * - **One reconcile per sandbox at a time** (F7.2). `sandbox` runs
32
40
  * `WorkerHandlers.sandbox` for the key `<tenantId>:<sandboxId>` at least once
33
41
  * after a `reconcile` resolves, never overlapping another run for the key. A
@@ -11,10 +11,17 @@ export declare const HTTP_ERROR_BODY_CHARS = 2000;
11
11
  export type HttpOutcome = {
12
12
  readonly kind: "completed";
13
13
  readonly output: unknown;
14
+ /** How many credential values the answer echoed, replaced with `[redacted]` (R2b C8). */
15
+ readonly redacted?: number;
14
16
  } | {
15
17
  readonly kind: "failed";
16
18
  readonly code: string;
17
19
  readonly message: string;
20
+ /** With `credential_rejected`: the tool's `credential`, and the scope of its vault. */
21
+ readonly server?: string;
22
+ readonly vault?: "installation" | "user";
23
+ /** How many credential values the message had in it, replaced (R2b C8). */
24
+ readonly redacted?: number;
18
25
  };
19
26
  /**
20
27
  * One declared HTTP tool of a session: what the gate finds it by in the pinned manifest. An
@@ -15,7 +15,9 @@
15
15
  */
16
16
  import { AGENT_ID_HEADER, SESSION_ID_HEADER, TURN_ID_HEADER, } from "@nylorun/core/compatibility";
17
17
  import { HTTP_TOOL_DEFAULT_TIMEOUT_MS, delegateManifest, flowHttpTarget, isWorkflowManifest, schemaFromJSON, } from "@nylorun/core/define";
18
+ import { scrubValues } from "../redact.js";
18
19
  import { MAX_RESPONSE_BYTES, post } from "../tenant/outbound.js";
20
+ import { credentialSecrets } from "../vault/headers.js";
19
21
  /** How much of a failed answer's body the model sees. */
20
22
  export const HTTP_ERROR_BODY_CHARS = 2_000;
21
23
  /** The HTTP tool `capabilityId`/`toolName` of the agent `agentId` (the root when absent). */
@@ -83,6 +85,9 @@ export async function callHttpTarget(target, input, options) {
83
85
  case "too_large":
84
86
  return failed("http.too-large", `The answer (HTTP ${result.status}) is larger than ${MAX_RESPONSE_BYTES} bytes`);
85
87
  }
88
+ // The service answered, so the outcome is known; never retried, and its body is not shown (R2b C1).
89
+ if (result.status === 401)
90
+ return failed("credential_rejected", "The service answered HTTP 401: it rejected the credential, or needs one");
86
91
  const text = result.body.toString("utf8");
87
92
  if (result.status < 200 || result.status > 299) {
88
93
  const body = text.length > HTTP_ERROR_BODY_CHARS ? `${text.slice(0, HTTP_ERROR_BODY_CHARS)}…` : text;
@@ -156,20 +161,26 @@ export async function runHttpTool(tenant, call, options) {
156
161
  : `'${tool.toolName}' is not an HTTP tool of the session`);
157
162
  const { http } = declared;
158
163
  let credential = {};
164
+ let identity;
165
+ let target = http;
166
+ let vault;
159
167
  if (http.credential !== undefined) {
160
168
  const authorized = await tenant.authorize(tool.sessionId, {
161
- kind: "http",
162
169
  url: http.url,
163
170
  serverName: http.credential,
164
- ...(agentId === undefined ? {} : { agentId }),
165
171
  });
166
172
  if (authorized.status === "refused")
167
173
  return failed("http.credential", `The credential '${http.credential}' was refused: ${authorized.reason}`);
168
174
  if (authorized.status === "unauthenticated")
169
175
  return failed("http.credential", `The session's vaults hold no credential '${http.credential}' for ${authorized.url}`);
170
176
  credential = authorized.headers;
177
+ identity = authorized.identity;
178
+ vault = authorized.vault;
179
+ // A credential with `via` sends the call there, a gateway (R2b C2, Q9).
180
+ if (authorized.via !== undefined)
181
+ target = { ...http, url: authorized.via };
171
182
  }
172
- return callHttpTarget(http, call.input, {
183
+ const answered = await callHttpTarget(target, call.input, {
173
184
  headers: {
174
185
  ...credential,
175
186
  [SESSION_ID_HEADER]: tool.sessionId,
@@ -181,6 +192,17 @@ export async function runHttpTool(tenant, call, options) {
181
192
  signal: options.signal,
182
193
  ...(declared.outputSchema === undefined ? {} : { outputSchema: declared.outputSchema }),
183
194
  });
195
+ // Nothing the service echoes of the credential reaches the model, an event or the gate's
196
+ // record of the call: its answer and the start of an error body alike (R2b C8).
197
+ const outcome = scrubbedOutcome(answered, credentialSecrets(credential, identity));
198
+ if (outcome.kind !== "failed" || outcome.code !== "credential_rejected" || http.credential === undefined)
199
+ return outcome;
200
+ return {
201
+ ...outcome,
202
+ message: `The service answered HTTP 401: it rejected the credential '${http.credential}' from the ${vault} vault`,
203
+ server: http.credential,
204
+ ...(vault === undefined ? {} : { vault }),
205
+ };
184
206
  }
185
207
  }
186
208
  /** The gates service's HTTP tool calls, with the Tenant's vault (`host/gates.ts`). */
@@ -195,3 +217,12 @@ export function gateHttpTools(vaults, policy, logger) {
195
217
  function failed(code, message) {
196
218
  return { kind: "failed", code, message };
197
219
  }
220
+ /** `outcome` with each of `secrets` replaced by `[redacted]`, and how many were. */
221
+ function scrubbedOutcome(outcome, secrets) {
222
+ if (outcome.kind === "completed") {
223
+ const output = scrubValues(outcome.output, secrets);
224
+ return output.redacted === 0 ? outcome : { ...outcome, output: output.value, redacted: output.redacted };
225
+ }
226
+ const message = scrubValues(outcome.message, secrets);
227
+ return message.redacted === 0 ? outcome : { ...outcome, message: message.value, redacted: message.redacted };
228
+ }
@@ -18,7 +18,10 @@ export interface McpHandlerOptions {
18
18
  readonly delivery?: OutboundPolicy;
19
19
  }
20
20
  export interface McpHandler {
21
- connect(tenantId: string | undefined, server: McpServerRef): Promise<McpAnswer<null>>;
21
+ /** Opens the connection; answers the server's instructions when it gave any (R2b C10). */
22
+ connect(tenantId: string | undefined, server: McpServerRef): Promise<McpAnswer<{
23
+ instructions: string;
24
+ } | null>>;
22
25
  list(tenantId: string | undefined, server: McpServerRef, cursor: string | undefined, signal: AbortSignal): Promise<McpAnswer<McpToolPage>>;
23
26
  /** One `tools/call`; the raw `CallToolResult` on success. */
24
27
  call(tenantId: string | undefined, request: {
@@ -1,4 +1,4 @@
1
- import { openMcpServer, } from "../mcp/connect.js";
1
+ import { McpCallFailed, McpConnectionStale, openMcpServer, unreachable, } from "../mcp/connect.js";
2
2
  import { findServer } from "../mcp/pool.js";
3
3
  import { GateRefusal } from "./tenant-vaults.js";
4
4
  /** A connection unused this long is closed; the next request opens it again. */
@@ -65,29 +65,79 @@ export function createMcpHandler(options) {
65
65
  ...fields(server),
66
66
  ...extra,
67
67
  ms: now() - started,
68
- outcome: failed.code === undefined ? "failed" : String(failed.code),
68
+ outcome: failed.failure?.code ?? (failed.code === undefined ? "failed" : String(failed.code)),
69
69
  });
70
70
  return { ok: false, error: failed };
71
71
  }
72
72
  }
73
- async function use(tenantId, server, run) {
74
- const entry = await connection(tenantId, server);
73
+ /** The server's connection; for a tool call, one that cannot be opened is `mcp.unreachable`. */
74
+ async function opened(tenantId, server, call) {
75
+ try {
76
+ return await connection(tenantId, server);
77
+ }
78
+ catch (error) {
79
+ // The call was never sent, so the model may try again (R2b C7). A gate that cannot serve
80
+ // the session is not the server's failure, and stays as it was.
81
+ if (!call || error instanceof GateRefusal || error instanceof NotDeclared)
82
+ throw error;
83
+ throw unreachable(server.serverName, error);
84
+ }
85
+ }
86
+ async function use(tenantId, server, run, call = false) {
87
+ const entry = await opened(tenantId, server, call);
75
88
  entry.active += 1;
76
89
  try {
77
90
  return await run(entry);
78
91
  }
92
+ catch (error) {
93
+ // The connection can no longer make requests: the next one opens a new connection, and
94
+ // the calls still running on this one finish first (the last closes it).
95
+ if (error instanceof McpConnectionStale) {
96
+ const key = keyOf(server);
97
+ if (live.get(key) === entry)
98
+ live.delete(key);
99
+ entry.evicted = true;
100
+ }
101
+ throw error;
102
+ }
79
103
  finally {
80
104
  entry.active -= 1;
81
105
  entry.lastUsedAt = now();
106
+ if (entry.evicted && entry.active === 0)
107
+ void entry.connection.close().catch(() => { });
108
+ }
109
+ }
110
+ /**
111
+ * One `tools/call`. A connection found stale before the tool saw the call is opened again, and
112
+ * the call sent once more (R2b C7).
113
+ */
114
+ async function callOnce(tenantId, request, signal) {
115
+ for (let attempt = 0;; attempt += 1) {
116
+ try {
117
+ return await use(tenantId, request.server, (entry) => entry.connection.client.callTool({ name: request.name, arguments: request.arguments }, { signal }), true);
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof McpConnectionStale) || attempt > 0)
121
+ throw error;
122
+ }
82
123
  }
83
124
  }
84
125
  return {
85
126
  connect: (tenantId, server) => answer("connect", server, async () => {
86
- await connection(tenantId, server);
87
- return null;
127
+ const { instructions } = (await connection(tenantId, server)).connection;
128
+ // The server's instructions, for the note on its deferred tools (R2b C10).
129
+ return instructions === undefined ? null : { instructions };
88
130
  }),
89
131
  list: (tenantId, server, cursor, signal) => answer("list", server, () => use(tenantId, server, (entry) => entry.connection.client.listTools(cursor ? { cursor } : undefined, { signal }))),
90
- call: (tenantId, request, signal) => answer("call", request.server, () => use(tenantId, request.server, (entry) => entry.connection.client.callTool({ name: request.name, arguments: request.arguments }, { signal })), { effect: request.effectId }),
132
+ async call(tenantId, request, signal) {
133
+ const answered = await answer("call", request.server, () => callOnce(tenantId, request, signal), {
134
+ effect: request.effectId,
135
+ });
136
+ if (!answered.ok)
137
+ return answered;
138
+ const { result, redacted } = answered.result;
139
+ return { ok: true, result, ...(redacted ? { redacted } : {}) };
140
+ },
91
141
  async close(server) {
92
142
  const key = keyOf(server);
93
143
  const entry = live.get(key);
@@ -125,10 +175,16 @@ function fields(server) {
125
175
  ...(server.agentId === undefined ? {} : { agent: server.agentId }),
126
176
  };
127
177
  }
128
- /** An error as the loop's `diagnosticFromError` reads it. */
178
+ /** An error as the loop's `diagnosticFromError` reads it, with a coded failure's code (R2b C7). */
129
179
  function errorOf(error) {
130
180
  if (error instanceof GateRefusal)
131
181
  return { message: error.outcome.message };
182
+ if (error instanceof McpCallFailed)
183
+ return {
184
+ message: error.message,
185
+ ...(error.code === "credential_rejected" ? { code: 401 } : {}),
186
+ failure: error.failure,
187
+ };
132
188
  const message = error instanceof Error ? error.message.slice(0, 2000) : String(error);
133
189
  const code = error && typeof error === "object" && "code" in error && typeof error.code === "number"
134
190
  ? error.code