@nylorun/runtime 0.19.1-beta → 0.21.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 (137) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +22 -13
  3. package/dist/adapters/streams/s2.js +5 -11
  4. package/dist/api/components.d.ts +326 -205
  5. package/dist/api/components.js +3 -3
  6. package/dist/api/http/app.js +2 -0
  7. package/dist/api/http/define.d.ts +3 -2
  8. package/dist/api/http/define.js +10 -11
  9. package/dist/api/http/routes/mcp.d.ts +9 -0
  10. package/dist/api/http/routes/mcp.js +32 -0
  11. package/dist/api/http/routes/vaults.d.ts +1 -9
  12. package/dist/api/http/routes/vaults.js +3 -103
  13. package/dist/api/openapi.js +37 -6
  14. package/dist/artifacts/media-types.d.ts +2 -0
  15. package/dist/artifacts/media-types.js +5 -0
  16. package/dist/execution/types.d.ts +1 -1
  17. package/dist/gates/http-tool.d.ts +7 -0
  18. package/dist/gates/http-tool.js +34 -3
  19. package/dist/gates/mcp-handler.d.ts +4 -1
  20. package/dist/gates/mcp-handler.js +64 -8
  21. package/dist/gates/tenant-vaults.d.ts +9 -15
  22. package/dist/gates/tenant-vaults.js +9 -17
  23. package/dist/gates/tool-client.js +36 -17
  24. package/dist/gates/tool-contract.d.ts +19 -3
  25. package/dist/gates/tool-contract.js +9 -3
  26. package/dist/gates/tool-gate.d.ts +2 -2
  27. package/dist/harness/calls.d.ts +2 -0
  28. package/dist/harness/calls.js +13 -3
  29. package/dist/harness-api/record.d.ts +7 -6
  30. package/dist/harness-api/record.js +99 -17
  31. package/dist/harness-api/server.js +35 -27
  32. package/dist/harness-api/start.js +2 -1
  33. package/dist/host/app.d.ts +3 -0
  34. package/dist/host/app.js +33 -4
  35. package/dist/host/config-for.d.ts +0 -2
  36. package/dist/host/config-for.js +0 -1
  37. package/dist/host/create-host.d.ts +6 -0
  38. package/dist/host/create-host.js +1 -0
  39. package/dist/host/gates.js +0 -1
  40. package/dist/host/main.js +24 -7
  41. package/dist/host/stack-config.d.ts +5 -8
  42. package/dist/host/stack-config.js +9 -20
  43. package/dist/index.d.ts +0 -1
  44. package/dist/infra/index.d.ts +2 -1
  45. package/dist/infra/index.js +0 -1
  46. package/dist/infra/readiness.d.ts +6 -6
  47. package/dist/infra/readiness.js +2 -5
  48. package/dist/keys/client.js +1 -9
  49. package/dist/keys/keys.d.ts +11 -13
  50. package/dist/keys/keys.js +5 -7
  51. package/dist/management-openapi.json +671 -378
  52. package/dist/mcp/connect.d.ts +159 -13
  53. package/dist/mcp/connect.js +538 -55
  54. package/dist/mcp/pool.d.ts +11 -2
  55. package/dist/mcp/pool.js +74 -27
  56. package/dist/mcp/preview.d.ts +35 -0
  57. package/dist/mcp/preview.js +215 -0
  58. package/dist/mcp/snapshot.d.ts +79 -5
  59. package/dist/mcp/snapshot.js +145 -27
  60. package/dist/model/catalog.d.ts +14 -0
  61. package/dist/model/catalog.js +14 -1
  62. package/dist/model/pi-model.js +36 -2
  63. package/dist/openapi.json +644 -266
  64. package/dist/redact.d.ts +19 -1
  65. package/dist/redact.js +42 -13
  66. package/dist/sandbox/session-sandbox.d.ts +7 -0
  67. package/dist/sandbox/session-sandbox.js +89 -28
  68. package/dist/store/postgres/connect.d.ts +10 -0
  69. package/dist/store/postgres/connect.js +30 -3
  70. package/dist/store/postgres/control.d.ts +14 -0
  71. package/dist/store/postgres/control.js +187 -0
  72. package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
  73. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  74. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  75. package/dist/store/postgres/schema.d.ts +122 -261
  76. package/dist/store/postgres/schema.js +22 -36
  77. package/dist/store/postgres/store.js +16 -18
  78. package/dist/store/postgres/tenant.d.ts +14 -0
  79. package/dist/store/postgres/tenant.js +22 -2
  80. package/dist/store/types.d.ts +42 -8
  81. package/dist/streams/types.d.ts +6 -29
  82. package/dist/streams/types.js +5 -4
  83. package/dist/tenant/artifact-tool.d.ts +9 -1
  84. package/dist/tenant/artifact-tool.js +113 -9
  85. package/dist/tenant/auth.d.ts +16 -6
  86. package/dist/tenant/auth.js +41 -17
  87. package/dist/tenant/commands.js +10 -6
  88. package/dist/tenant/context.d.ts +9 -6
  89. package/dist/tenant/control.d.ts +26 -0
  90. package/dist/tenant/control.js +35 -0
  91. package/dist/tenant/effects.d.ts +2 -2
  92. package/dist/tenant/effects.js +20 -5
  93. package/dist/tenant/ephemeral.d.ts +0 -6
  94. package/dist/tenant/ephemeral.js +1 -1
  95. package/dist/tenant/http.d.ts +4 -2
  96. package/dist/tenant/http.js +4 -2
  97. package/dist/tenant/identity-file.d.ts +16 -12
  98. package/dist/tenant/identity-file.js +41 -29
  99. package/dist/tenant/issuers.d.ts +4 -3
  100. package/dist/tenant/issuers.js +22 -16
  101. package/dist/tenant/outbound.d.ts +14 -5
  102. package/dist/tenant/outbound.js +21 -9
  103. package/dist/tenant/reset.d.ts +2 -2
  104. package/dist/tenant/reset.js +8 -2
  105. package/dist/tenant/resource-server.d.ts +56 -0
  106. package/dist/tenant/resource-server.js +62 -0
  107. package/dist/tenant/runtime.d.ts +1 -1
  108. package/dist/tenant/runtime.js +16 -15
  109. package/dist/tenant/scheduler.d.ts +1 -1
  110. package/dist/tenant/scheduler.js +1 -1
  111. package/dist/tenant/sessions.js +20 -3
  112. package/dist/tenant/store-pg.js +9 -0
  113. package/dist/tenant/streams.d.ts +16 -22
  114. package/dist/tenant/streams.js +7 -87
  115. package/dist/tenant/sweep.js +9 -1
  116. package/dist/tenant/tool-results.d.ts +57 -0
  117. package/dist/tenant/tool-results.js +298 -0
  118. package/dist/tenant/tool-search.d.ts +43 -0
  119. package/dist/tenant/tool-search.js +100 -0
  120. package/dist/tenant/transcript.d.ts +108 -0
  121. package/dist/tenant/transcript.js +13 -0
  122. package/dist/tenant/types.d.ts +0 -14
  123. package/dist/vault/crypto.d.ts +0 -7
  124. package/dist/vault/crypto.js +0 -16
  125. package/dist/vault/headers.d.ts +17 -0
  126. package/dist/vault/headers.js +61 -0
  127. package/dist/vault/service.d.ts +11 -68
  128. package/dist/vault/service.js +83 -438
  129. package/dist/vault/sources.d.ts +20 -48
  130. package/dist/vault/sources.js +25 -189
  131. package/dist/version.d.ts +1 -1
  132. package/dist/version.js +1 -1
  133. package/package.json +3 -3
  134. package/dist/streams/control.d.ts +0 -13
  135. package/dist/streams/control.js +0 -25
  136. package/dist/vault/oauth.d.ts +0 -55
  137. package/dist/vault/oauth.js +0 -186
@@ -1,10 +1,11 @@
1
1
  import { embeddedAgent, flowDelegateManifest, hashManifest, } from "@nylorun/core/define";
2
2
  import { countActiveFlowWork, deriveAgentEffectSessionId, isFlowToolEffect, isWorkflowManifest, linkedMessageInput, linkedMessageKey, linkedTurnEnd, settleAgentEffect, } from "../core/flow-host.js";
3
3
  import { mayDispatchMore } from "../core/limits.js";
4
+ import { sessionCredentials } from "../vault/sources.js";
4
5
  import { isRemoteMcpCall } from "../harness/calls.js";
5
6
  import { isHttpToolCall } from "../gates/http-tool.js";
6
7
  import { owningSandboxSessionId, sandboxSpecOf, sessionSandboxSpec, } from "../sandbox/share.js";
7
- import { withSandboxCapability } from "../sandbox/session-sandbox.js";
8
+ import { withPlatformTools, withSandboxCapability } from "../sandbox/session-sandbox.js";
8
9
  import { loadSession, ownedSession, sandboxLookup, sessionOf, } from "./context.js";
9
10
  import { fail } from "./http.js";
10
11
  import { turnManifestOf } from "./session.js";
@@ -150,12 +151,15 @@ export async function resolveNewFlowEffect(ctx, request, signal, lease) {
150
151
  : undefined;
151
152
  // A sandbox chosen when the tree was opened reaches every agent in it, with its tools.
152
153
  const inherited = inheritedSandbox(sandboxOwnerId === undefined ? undefined : lookup(sandboxOwnerId) ?? workflow, definition);
154
+ // Without one, an agent with MCP or HTTP tools still reads its stored results (R2b C11).
155
+ const pinned = inherited?.manifest ? inherited : platformTools(definition.manifest);
153
156
  const created = {
154
157
  id: agentSessionId,
155
158
  agentId: body.agentId,
156
159
  ownerUserId: workflow.ownerUserId,
157
- manifest: inherited?.manifest ?? definition.manifest,
158
- manifestHash: inherited?.manifestHash ?? definition.manifestHash,
160
+ manifest: pinned?.manifest ?? definition.manifest,
161
+ manifestHash: pinned?.manifestHash ?? definition.manifestHash,
162
+ definitionHash: definition.manifestHash,
159
163
  implementationVersion: definition.implementationVersion,
160
164
  status: "idle",
161
165
  activeTurnId: null,
@@ -241,13 +245,24 @@ function inheritedSandbox(owner, definition) {
241
245
  throw new Error(sandboxed.message);
242
246
  return { spec, manifest: sandboxed.manifest, manifestHash: sandboxed.manifestHash };
243
247
  }
248
+ /** A linked agent session's `read_artifact` without a sandbox (R2b C11); undefined for none. */
249
+ function platformTools(manifest) {
250
+ if (isWorkflowManifest(manifest))
251
+ return undefined;
252
+ const pinned = withPlatformTools(manifest);
253
+ if (pinned === undefined)
254
+ return undefined;
255
+ if (!pinned.ok)
256
+ throw new Error(pinned.message);
257
+ return { manifest: pinned.manifest, manifestHash: pinned.manifestHash };
258
+ }
244
259
  /**
245
260
  * The credential of an MCP server or HTTP tool request made on behalf of a session: from its
246
- * attached vaults, else the operator's credential resolver (`vault/sources.ts`).
261
+ * attached vaults (`vault/sources.ts`).
247
262
  */
248
263
  export async function authorize(ctx, sessionId, request) {
249
264
  const s = await loadSession(ctx, sessionId);
250
- return ctx.credentials.authorize({ ...s, id: sessionId }, request);
265
+ return sessionCredentials(ctx.vault, { ...s, id: sessionId }, request);
251
266
  }
252
267
  /**
253
268
  * The definition a flow leaf's session runs. A flow agent embeds its leaves, so they come
@@ -2,7 +2,6 @@ import { type PostgresClient } from "../store/postgres/connect.js";
2
2
  import type { TrustedIssuerConfig } from "./identity-file.js";
3
3
  import { type TrustedIssuers } from "./issuers.js";
4
4
  import type { Logger, TenantModelConfig } from "./types.js";
5
- import type { ResolverConfig } from "../vault/sources.js";
6
5
  export interface StartEphemeralRuntimeOptions {
7
6
  /**
8
7
  * Absolute Host root for `host.json`, the admin key and the Tenant's files (vault key,
@@ -37,11 +36,6 @@ export interface StartEphemeralRuntimeOptions {
37
36
  * `createTrustedIssuers`. Default none.
38
37
  */
39
38
  issuers?: readonly TrustedIssuerConfig[] | TrustedIssuers;
40
- /**
41
- * The operator's credential resolver (F9 C1): asked for a person's MCP credential when the
42
- * session's vaults hold none. Default: none (vaults only).
43
- */
44
- resolver?: ResolverConfig;
45
39
  logger?: Logger;
46
40
  /**
47
41
  * The Postgres database of the Tenant (one Tenant per database): a pool, which the caller
@@ -82,7 +82,6 @@ export async function startEphemeralRuntime(options) {
82
82
  sandbox: { backend: sandboxBackend },
83
83
  model,
84
84
  ...(issuers ? { issuers } : {}),
85
- ...(options.resolver ? { resolver: options.resolver } : {}),
86
85
  logger: createTenantLogger({
87
86
  tenantId,
88
87
  logPath: tenant.log,
@@ -145,6 +144,7 @@ export async function startEphemeralRuntime(options) {
145
144
  config: hostConfig,
146
145
  logger,
147
146
  coreVersion: coreVersion(),
147
+ ...(issuers ? { resourceServer: { issuers } } : {}),
148
148
  });
149
149
  // Opens the Tenant: creates it in the database first when it holds none.
150
150
  await host.listen();
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The Tenant's typed rejections: what a route or the code it calls throws for the client to
3
- * see (`api/http/respond.ts` answers it), and the opaque 404 for unknown credentials (D5).
3
+ * see (`api/http/respond.ts` answers it), and the opaque 404 (D5) for what must not be told
4
+ * apart from nothing: a capability link the Runtime did not sign, a Tenant that is not this
5
+ * Host's. Credentials are `401` with a challenge since protocol 9 (`resource-server.ts`).
4
6
  *
5
7
  * Later waves: stable; the streams seam (Wave 2 / Y) keeps using these helpers.
6
8
  */
7
- /** D5 opaque failure for unknown/rejected credentials on Tenant routes. */
9
+ /** D5's opaque failure: a 404 that says nothing of why. */
8
10
  export declare const OPAQUE_NOT_FOUND: {
9
11
  status: "rejected";
10
12
  code: "not_found";
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The Tenant's typed rejections: what a route or the code it calls throws for the client to
3
- * see (`api/http/respond.ts` answers it), and the opaque 404 for unknown credentials (D5).
3
+ * see (`api/http/respond.ts` answers it), and the opaque 404 (D5) for what must not be told
4
+ * apart from nothing: a capability link the Runtime did not sign, a Tenant that is not this
5
+ * Host's. Credentials are `401` with a challenge since protocol 9 (`resource-server.ts`).
4
6
  *
5
7
  * Later waves: stable; the streams seam (Wave 2 / Y) keeps using these helpers.
6
8
  */
7
- /** D5 opaque failure for unknown/rejected credentials on Tenant routes. */
9
+ /** D5's opaque failure: a 404 that says nothing of why. */
8
10
  export const OPAQUE_NOT_FOUND = {
9
11
  status: "rejected",
10
12
  code: "not_found",
@@ -2,20 +2,22 @@
2
2
  * The identity file (Host feature `trusted-issuers`, F9 I2): the YAML file
3
3
  * `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Tenant API accepts as
4
4
  * bearers. Read once at boot (`host/main.ts`); a change takes a restart. A malformed file stops
5
- * the boot with a message naming the issuer and the field.
5
+ * the boot with a message naming the issuer and the field. A key the file does not define is
6
+ * ignored with a warning (`maxLifetime`, gone in protocol 9, among them), so an older file
7
+ * still boots.
6
8
  *
7
9
  * ```yaml
8
10
  * issuers:
9
11
  * - name: keycloak
10
12
  * issuer: https://sso.acme.dev/realms/eng
11
- * audience: nylorun
13
+ * audience: https://agents.acme.dev
12
14
  * jwks: https://sso.acme.dev/realms/eng/protocol/openid-connect/certs # or keys: [<PEM>]
13
- * subject: "u:{sub}" # scalar claims only
14
- * scopes: { claim: nylorun_scopes } # or { fixed: [sessions:own, agents:read] }
15
- * allowedScopes: [agents:read, sessions:own, sandboxes:write, studio]
16
- * agents: [support] # optional allowlist; absent means all
17
- * sandboxes: ["{org_id}/*"] # optional grants; absent reaches none
18
- * maxLifetime: 15m
15
+ * # Optional, with their defaults:
16
+ * subject: "{sub}" # scalar claims only
17
+ * scopes: { claim: scope } # or { fixed: [sessions:own, agents:read] }
18
+ * allowedScopes: [agents:read, sessions:own, sandboxes:write] # add studio to grant it
19
+ * agents: [support] # absent reaches every agent
20
+ * sandboxes: ["{org_id}/*"] # absent reaches no sandbox
19
21
  * ```
20
22
  */
21
23
  import { type KeyObject } from "node:crypto";
@@ -41,7 +43,7 @@ export interface TrustedIssuerConfig {
41
43
  readonly jwks?: string;
42
44
  /** Its public keys, given in the file (PEM); or `jwks`. */
43
45
  readonly keys?: readonly IssuerKey[];
44
- /** The subject template, such as `u:{sub}`: at least one claim reference. */
46
+ /** The subject template, such as `{sub}` (the default): at least one claim reference. */
45
47
  readonly subject: string;
46
48
  /** Where its tokens' scopes come from: a claim, or a fixed list. */
47
49
  readonly scopes: {
@@ -55,8 +57,6 @@ export interface TrustedIssuerConfig {
55
57
  readonly agents?: readonly string[];
56
58
  /** Sandbox grant templates, such as `{org_id}/*`; absent reaches no sandbox. */
57
59
  readonly sandboxes?: readonly string[];
58
- /** The longest `exp - iat` it may sign, in seconds. */
59
- readonly maxLifetimeSeconds: number;
60
60
  }
61
61
  /** A malformed identity file: the message names the issuer and the field. */
62
62
  export declare class IdentityFileError extends Error {
@@ -68,8 +68,12 @@ export declare function claimReferences(template: string): string[];
68
68
  export declare function renderTemplate(template: string, value: (claim: string) => string | undefined): string | undefined;
69
69
  /** A PEM public key an issuer may sign with, and its algorithm; throws a message otherwise. */
70
70
  export declare function issuerKey(key: KeyObject, kid?: string): IssuerKey;
71
+ export interface ParseIdentityFileOptions {
72
+ /** Told of every key the file has but does not define, which is ignored. */
73
+ readonly warn?: (message: string) => void;
74
+ }
71
75
  /**
72
76
  * The issuers of an identity file's text. Throws `IdentityFileError` naming the file, the issuer
73
77
  * and the field when the file is malformed.
74
78
  */
75
- export declare function parseIdentityFile(text: string, source?: string): TrustedIssuerConfig[];
79
+ export declare function parseIdentityFile(text: string, source?: string, options?: ParseIdentityFileOptions): TrustedIssuerConfig[];
@@ -2,31 +2,34 @@
2
2
  * The identity file (Host feature `trusted-issuers`, F9 I2): the YAML file
3
3
  * `NYLORUN_IDENTITY_FILE` names, listing the issuers whose JWTs the Tenant API accepts as
4
4
  * bearers. Read once at boot (`host/main.ts`); a change takes a restart. A malformed file stops
5
- * the boot with a message naming the issuer and the field.
5
+ * the boot with a message naming the issuer and the field. A key the file does not define is
6
+ * ignored with a warning (`maxLifetime`, gone in protocol 9, among them), so an older file
7
+ * still boots.
6
8
  *
7
9
  * ```yaml
8
10
  * issuers:
9
11
  * - name: keycloak
10
12
  * issuer: https://sso.acme.dev/realms/eng
11
- * audience: nylorun
13
+ * audience: https://agents.acme.dev
12
14
  * jwks: https://sso.acme.dev/realms/eng/protocol/openid-connect/certs # or keys: [<PEM>]
13
- * subject: "u:{sub}" # scalar claims only
14
- * scopes: { claim: nylorun_scopes } # or { fixed: [sessions:own, agents:read] }
15
- * allowedScopes: [agents:read, sessions:own, sandboxes:write, studio]
16
- * agents: [support] # optional allowlist; absent means all
17
- * sandboxes: ["{org_id}/*"] # optional grants; absent reaches none
18
- * maxLifetime: 15m
15
+ * # Optional, with their defaults:
16
+ * subject: "{sub}" # scalar claims only
17
+ * scopes: { claim: scope } # or { fixed: [sessions:own, agents:read] }
18
+ * allowedScopes: [agents:read, sessions:own, sandboxes:write] # add studio to grant it
19
+ * agents: [support] # absent reaches every agent
20
+ * sandboxes: ["{org_id}/*"] # absent reaches no sandbox
19
21
  * ```
20
22
  */
21
23
  import { createPublicKey } from "node:crypto";
22
24
  import { parse as parseYaml } from "yaml";
23
25
  import { z } from "zod";
24
- import { ISSUER_SCOPES, isSandboxGrant, TOKEN_SANDBOX_GRANTS_MAX, } from "@nylorun/core/contracts";
25
- import { parseSandboxDuration } from "@nylorun/core/define";
26
+ import { ISSUER_SCOPES, isSandboxGrant, TOKEN_SANDBOX_GRANTS_MAX, TOKEN_SCOPES, } from "@nylorun/core/contracts";
26
27
  /** The JWT algorithms an issuer may sign with. */
27
28
  export const ISSUER_ALGORITHMS = ["RS256", "ES256", "EdDSA"];
28
- /** The longest `maxLifetime` an issuer may set. */
29
- const MAX_LIFETIME_SECONDS = 24 * 60 * 60;
29
+ /** An entry's defaults: the `sub` claim, the RFC 9068 `scope` claim, every token scope but `studio`. */
30
+ const DEFAULT_SUBJECT = "{sub}";
31
+ const DEFAULT_SCOPES = { claim: "scope" };
32
+ const DEFAULT_ALLOWED_SCOPES = TOKEN_SCOPES;
30
33
  /** At most this many static `keys` per issuer. */
31
34
  const MAX_STATIC_KEYS = 16;
32
35
  const ISSUER_NAME = /^[a-z][a-z0-9-]{0,31}$/;
@@ -105,12 +108,16 @@ const IssuerEntrySchema = z
105
108
  }, "must be an http(s) URL without credentials")
106
109
  .optional(),
107
110
  keys: z.array(z.string().min(1)).min(1).max(MAX_STATIC_KEYS).optional(),
108
- subject: template("subject").refine((value) => claimReferences(value).length > 0, "must reference a claim, as u:{sub}: without one every token is the same person"),
109
- scopes: z.union([
111
+ subject: template("subject")
112
+ .refine((value) => claimReferences(value).length > 0, "must reference a claim, as {sub}: without one every token is the same person")
113
+ .default(DEFAULT_SUBJECT),
114
+ scopes: z
115
+ .union([
110
116
  z.object({ claim: z.string().min(1) }).strict(),
111
117
  z.object({ fixed: z.array(IssuerScopeSchema) }).strict(),
112
- ], { error: "must be { claim: <name> } or { fixed: [<scope>, ...] }" }),
113
- allowedScopes: z.array(IssuerScopeSchema).min(1),
118
+ ], { error: "must be { claim: <name> } or { fixed: [<scope>, ...] }" })
119
+ .default(DEFAULT_SCOPES),
120
+ allowedScopes: z.array(IssuerScopeSchema).min(1).default([...DEFAULT_ALLOWED_SCOPES]),
114
121
  agents: z.array(z.string().min(1)).optional(),
115
122
  sandboxes: z
116
123
  .array(template("a sandbox grant").refine(
@@ -118,17 +125,17 @@ const IssuerEntrySchema = z
118
125
  (value) => isSandboxGrant(renderTemplate(value, () => "x")), "must render to a sandbox id or a prefix ending in /*, as {org_id}/*"))
119
126
  .max(TOKEN_SANDBOX_GRANTS_MAX)
120
127
  .optional(),
121
- maxLifetime: z
122
- .string()
123
- .refine((value) => {
124
- const ms = parseSandboxDuration(value);
125
- return ms !== undefined && ms >= 1000 && ms <= MAX_LIFETIME_SECONDS * 1000;
126
- }, "must be a duration from 1s to 24h, as 15m"),
127
- })
128
- .strict();
129
- const IdentityFileSchema = z
130
- .object({ issuers: z.array(z.unknown()) })
131
- .strict();
128
+ });
129
+ const IdentityFileSchema = z.object({ issuers: z.array(z.unknown()) });
130
+ /** The keys an entry and the file define; any other is ignored with a warning. */
131
+ const ENTRY_KEYS = new Set(Object.keys(IssuerEntrySchema.shape));
132
+ const FILE_KEYS = new Set(Object.keys(IdentityFileSchema.shape));
133
+ /** The keys of `value`, an object, that `known` does not define. */
134
+ function unknownKeys(value, known) {
135
+ return value && typeof value === "object" && !Array.isArray(value)
136
+ ? Object.keys(value).filter((key) => !known.has(key))
137
+ : [];
138
+ }
132
139
  function issueText(issues) {
133
140
  return issues
134
141
  .map((issue) => {
@@ -141,7 +148,11 @@ function issueText(issues) {
141
148
  * The issuers of an identity file's text. Throws `IdentityFileError` naming the file, the issuer
142
149
  * and the field when the file is malformed.
143
150
  */
144
- export function parseIdentityFile(text, source = "identity file") {
151
+ export function parseIdentityFile(text, source = "identity file", options = {}) {
152
+ const ignored = (where, keys) => {
153
+ if (keys.length > 0)
154
+ options.warn?.(`${source}: ${where}: ignored ${keys.map((key) => `\`${key}\``).join(", ")}, which the identity file does not define`);
155
+ };
145
156
  const fail = (message) => {
146
157
  throw new IdentityFileError(`${source}: ${message}`);
147
158
  };
@@ -155,6 +166,7 @@ export function parseIdentityFile(text, source = "identity file") {
155
166
  const file = IdentityFileSchema.safeParse(raw ?? {});
156
167
  if (!file.success)
157
168
  return fail(issueText(file.error.issues));
169
+ ignored("the file", unknownKeys(raw, FILE_KEYS));
158
170
  const issuers = [];
159
171
  file.data.issuers.forEach((entry, index) => {
160
172
  const named = entry && typeof entry === "object" && typeof entry.name === "string"
@@ -164,6 +176,7 @@ export function parseIdentityFile(text, source = "identity file") {
164
176
  const parsed = IssuerEntrySchema.safeParse(entry);
165
177
  if (!parsed.success)
166
178
  return fail(`${where}: ${issueText(parsed.error.issues)}`);
179
+ ignored(where, unknownKeys(entry, ENTRY_KEYS));
167
180
  const value = parsed.data;
168
181
  if ((value.jwks === undefined) === (value.keys === undefined))
169
182
  return fail(`${where}: jwks or keys: give exactly one of them`);
@@ -195,7 +208,6 @@ export function parseIdentityFile(text, source = "identity file") {
195
208
  allowedScopes: [...new Set(value.allowedScopes)],
196
209
  ...(value.agents ? { agents: [...new Set(value.agents)] } : {}),
197
210
  ...(value.sandboxes ? { sandboxes: value.sandboxes } : {}),
198
- maxLifetimeSeconds: Math.floor(parseSandboxDuration(value.maxLifetime) / 1000),
199
211
  });
200
212
  });
201
213
  return issuers;
@@ -26,7 +26,7 @@ export type IssuerVerdict = {
26
26
  /** One configured issuer, with its keys. */
27
27
  export interface TrustedIssuer {
28
28
  readonly config: TrustedIssuerConfig;
29
- /** Checks `raw`'s header and signature, `iss`, `aud`, `exp`, `iat` and its lifetime. */
29
+ /** Checks `raw`'s header and signature, `iss`, `aud`, `exp` and `iat`. */
30
30
  verify(raw: string): Promise<IssuerVerdict>;
31
31
  }
32
32
  /** The Host's trusted issuers (`TenantConfig.issuers`), built once from the identity file. */
@@ -43,6 +43,7 @@ export type IssuerTokenScope = Extract<AuthScope, {
43
43
  /**
44
44
  * Verifies a bearer that `issuer` claims (`TrustedIssuers.claimed`). Returns the request's
45
45
  * scope; answers `401 token_expired` for an expired token, `401 issuer_unavailable` for a key
46
- * the Runtime cannot fetch now, and the opaque 404 for everything else.
46
+ * the Runtime cannot fetch now, and `401 credential_invalid` for everything else. Each carries
47
+ * a `Bearer` challenge naming `resourceMetadata`, the route's protected resource metadata.
47
48
  */
48
- export declare function verifyIssuerToken(ctx: TenantContext, issuer: TrustedIssuer, raw: string): Promise<IssuerTokenScope>;
49
+ export declare function verifyIssuerToken(ctx: TenantContext, issuer: TrustedIssuer, raw: string, resourceMetadata?: string): Promise<IssuerTokenScope>;
@@ -11,19 +11,21 @@
11
11
  * a `kid` it has not seen, and refreshed in the background once the cache is 10 minutes old.
12
12
  * When the JWKS cannot be fetched, cached keys keep working and a new `kid` is
13
13
  * `401 issuer_unavailable`;
14
- * - `iss` and `aud` as configured, `exp` and `iat` present, `exp - iat` at most `maxLifetime`,
15
- * with the 30 s clock tolerance of every Runtime token;
14
+ * - `iss` and `aud` as configured, and `exp` present, with the 30 s clock tolerance of every
15
+ * Runtime token. How long a token lives is the issuer's to decide (protocol 9 dropped
16
+ * `maxLifetime`); an `iat` in the future is still refused;
16
17
  * - a subject the template renders from scalar claims, valid and not reserved.
17
18
  *
18
19
  * The result is the `token` AuthScope (F9-D12) with `issuer`: only its expiry ends it. An
19
- * expired token is `401 token_expired`; every other refusal is the opaque 404, its reason
20
- * logged.
20
+ * expired token is `401 token_expired`; every other refusal is `401 credential_invalid`
21
+ * (`resource-server.ts`), its reason logged.
21
22
  */
22
23
  import { createHash, createPublicKey } from "node:crypto";
23
24
  import { decodeJwt, decodeProtectedHeader, errors, jwtVerify } from "jose";
24
25
  import { isSubject } from "@nylorun/core/contracts";
25
- import { failOpaque, HttpError } from "./http.js";
26
+ import { HttpError } from "./http.js";
26
27
  import { CLOCK_TOLERANCE_SECONDS, looksLikeToken } from "./jwt.js";
28
+ import { bearerChallenge, failCredential } from "./resource-server.js";
27
29
  import { ISSUER_ALGORITHMS, issuerKey, renderTemplate, } from "./identity-file.js";
28
30
  /** The byte cap of an issuer's token; Nylorun's own token families keep 4 KiB. */
29
31
  export const MAX_ISSUER_TOKEN_BYTES = 16 * 1024;
@@ -185,14 +187,12 @@ function trustedIssuer(config, options) {
185
187
  issuer: config.issuer,
186
188
  audience: config.audience,
187
189
  clockTolerance: CLOCK_TOLERANCE_SECONDS,
188
- requiredClaims: ["iat", "exp"],
190
+ requiredClaims: ["exp"],
189
191
  currentDate: new Date(options.now()),
190
192
  });
191
193
  const now = Math.floor(options.now() / 1000);
192
- if (payload.iat > now + CLOCK_TOLERANCE_SECONDS)
194
+ if (payload.iat !== undefined && payload.iat > now + CLOCK_TOLERANCE_SECONDS)
193
195
  return refuse("invalid", "issuer_token_future");
194
- if (payload.exp - payload.iat > config.maxLifetimeSeconds)
195
- return refuse("invalid", "issuer_token_lifetime");
196
196
  return { ok: true, payload, ...(kid !== undefined ? { kid } : {}) };
197
197
  }
198
198
  catch (error) {
@@ -260,25 +260,31 @@ function scopesOf(config, payload) {
260
260
  /**
261
261
  * Verifies a bearer that `issuer` claims (`TrustedIssuers.claimed`). Returns the request's
262
262
  * scope; answers `401 token_expired` for an expired token, `401 issuer_unavailable` for a key
263
- * the Runtime cannot fetch now, and the opaque 404 for everything else.
263
+ * the Runtime cannot fetch now, and `401 credential_invalid` for everything else. Each carries
264
+ * a `Bearer` challenge naming `resourceMetadata`, the route's protected resource metadata.
264
265
  */
265
- export async function verifyIssuerToken(ctx, issuer, raw) {
266
+ export async function verifyIssuerToken(ctx, issuer, raw, resourceMetadata) {
266
267
  const { config } = issuer;
267
268
  const rejected = (reason) => {
268
269
  ctx.config.logger.warn("credential rejected", { reason, issuer: config.name });
269
- return failOpaque();
270
+ return failCredential("invalid", resourceMetadata);
270
271
  };
271
272
  const verdict = await issuer.verify(raw);
272
273
  if (!verdict.ok) {
273
274
  if (verdict.refused === "expired") {
274
275
  ctx.config.logger.warn("issuer token refused", { reason: verdict.reason, issuer: config.name });
275
- throw new HttpError(401, "The token has expired", { code: "token_expired" }, { "www-authenticate": 'Bearer error="invalid_token"' });
276
+ throw new HttpError(401, "The token has expired", { code: "token_expired" }, {
277
+ "www-authenticate": bearerChallenge({
278
+ error: "invalid_token",
279
+ description: "The access token expired",
280
+ resourceMetadata,
281
+ }),
282
+ });
276
283
  }
277
284
  if (verdict.refused === "unavailable") {
278
285
  ctx.config.logger.warn("issuer token refused", { reason: verdict.reason, issuer: config.name });
279
- throw new HttpError(401, `The keys of issuer ${config.name} cannot be fetched now; try again later`, {
280
- code: "issuer_unavailable",
281
- });
286
+ // The token may be fine: no error code, so a client retries rather than signs in again.
287
+ throw new HttpError(401, `The keys of issuer ${config.name} cannot be fetched now; try again later`, { code: "issuer_unavailable" }, { "www-authenticate": bearerChallenge({ resourceMetadata }) });
282
288
  }
283
289
  return rejected(verdict.reason);
284
290
  }
@@ -58,9 +58,19 @@ export declare class OutboundRefused extends Error {
58
58
  /** A request `guardedFetch` sent that failed: refused connection, reset, timeout, redirect. */
59
59
  export declare class OutboundFailed extends Error {
60
60
  readonly code: string;
61
- constructor(message: string, code: string);
61
+ /**
62
+ * The request was sent in full before it failed, as `post()`'s `lost` (R2b C7): the server
63
+ * may have acted on it. False for a failure to resolve or connect.
64
+ */
65
+ readonly sent: boolean;
66
+ constructor(message: string, code: string,
67
+ /**
68
+ * The request was sent in full before it failed, as `post()`'s `lost` (R2b C7): the server
69
+ * may have acted on it. False for a failure to resolve or connect.
70
+ */
71
+ sent?: boolean);
62
72
  }
63
- /** The largest answer `guardedFetch` reads. OAuth metadata and tokens are small. */
73
+ /** The largest answer `guardedFetch` reads. */
64
74
  export declare const MAX_FETCH_RESPONSE_BYTES: number;
65
75
  /** How long a `guardedFetch` request may take without a signal of its own. */
66
76
  export declare const FETCH_TIMEOUT_MS = 30000;
@@ -68,9 +78,8 @@ export declare const FETCH_TIMEOUT_MS = 30000;
68
78
  * A `fetch` under the Host's address policy (F9 C2): the literal-IP and DNS checks of `post`
69
79
  * on the address actually connected to, `localhost` rewritten for the local stack, no
70
80
  * redirects (`redirect: "error"`; a 3xx answer rejects), a bounded answer and a 30 s timeout
71
- * unless the caller passes a signal. The gateway calls OAuth discovery, registration, the code
72
- * exchange and refresh with it. A refused URL or address rejects with `OutboundRefused`, any
73
- * other failure with `OutboundFailed`.
81
+ * unless the caller passes a signal. A refused URL or address rejects with `OutboundRefused`,
82
+ * any other failure with `OutboundFailed`.
74
83
  *
75
84
  * `stream`: the Response resolves once the headers arrive and its body streams, unbounded and
76
85
  * with no timeout but the caller's signal: remote MCP servers, whose answers and event streams
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The Runtime's requests to developer URLs: HTTP tools (`gates/http-tool.ts`) and, through
3
- * `guardedFetch`, remote MCP servers and OAuth: one request, no redirects, a bounded answer, and
3
+ * `guardedFetch`, remote MCP servers: one request, no redirects, a bounded answer, and
4
4
  * the Host's address policy checked on the address actually connected to, so a DNS answer cannot
5
5
  * steer a request somewhere the Host forbids.
6
6
  *
@@ -153,13 +153,20 @@ export class OutboundRefused extends Error {
153
153
  /** A request `guardedFetch` sent that failed: refused connection, reset, timeout, redirect. */
154
154
  export class OutboundFailed extends Error {
155
155
  code;
156
- constructor(message, code) {
156
+ sent;
157
+ constructor(message, code,
158
+ /**
159
+ * The request was sent in full before it failed, as `post()`'s `lost` (R2b C7): the server
160
+ * may have acted on it. False for a failure to resolve or connect.
161
+ */
162
+ sent = false) {
157
163
  super(message);
158
164
  this.code = code;
165
+ this.sent = sent;
159
166
  this.name = "OutboundFailed";
160
167
  }
161
168
  }
162
- /** The largest answer `guardedFetch` reads. OAuth metadata and tokens are small. */
169
+ /** The largest answer `guardedFetch` reads. */
163
170
  export const MAX_FETCH_RESPONSE_BYTES = 1024 * 1024;
164
171
  /** How long a `guardedFetch` request may take without a signal of its own. */
165
172
  export const FETCH_TIMEOUT_MS = 30_000;
@@ -167,9 +174,8 @@ export const FETCH_TIMEOUT_MS = 30_000;
167
174
  * A `fetch` under the Host's address policy (F9 C2): the literal-IP and DNS checks of `post`
168
175
  * on the address actually connected to, `localhost` rewritten for the local stack, no
169
176
  * redirects (`redirect: "error"`; a 3xx answer rejects), a bounded answer and a 30 s timeout
170
- * unless the caller passes a signal. The gateway calls OAuth discovery, registration, the code
171
- * exchange and refresh with it. A refused URL or address rejects with `OutboundRefused`, any
172
- * other failure with `OutboundFailed`.
177
+ * unless the caller passes a signal. A refused URL or address rejects with `OutboundRefused`,
178
+ * any other failure with `OutboundFailed`.
173
179
  *
174
180
  * `stream`: the Response resolves once the headers arrive and its body streams, unbounded and
175
181
  * with no timeout but the caller's signal: remote MCP servers, whose answers and event streams
@@ -217,6 +223,9 @@ export function guardedFetch(policy, options = {}) {
217
223
  outgoing["content-length"] = String(body.byteLength);
218
224
  return await new Promise((resolve, reject) => {
219
225
  let settled = false;
226
+ // Set when the request is fully sent, as `post()` does: a failure after it may follow
227
+ // the server acting on the request.
228
+ let delivered = false;
220
229
  const settle = (fn) => {
221
230
  if (settled)
222
231
  return;
@@ -225,12 +234,12 @@ export function guardedFetch(policy, options = {}) {
225
234
  };
226
235
  const failed = (error) => settle(() => reject(error instanceof RefusedAddress
227
236
  ? new OutboundRefused(error.message)
228
- : new OutboundFailed(signal?.aborted ? `The request to ${originalHost} was aborted or timed out` : error.message, signal?.aborted ? "ABORTED" : (error.code ?? error.name))));
237
+ : new OutboundFailed(signal?.aborted ? `The request to ${originalHost} was aborted or timed out` : error.message, signal?.aborted ? "ABORTED" : (error.code ?? error.name), delivered)));
229
238
  const sent = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, { method, headers: outgoing, lookup: lookup, ...(signal ? { signal } : {}) }, (response) => {
230
239
  const status = response.statusCode ?? 0;
231
240
  if (status >= 300 && status < 400 && response.headers.location !== undefined) {
232
241
  response.resume();
233
- return settle(() => reject(new OutboundFailed(`${originalHost} answered a redirect, which this Runtime does not follow`, "REDIRECT")));
242
+ return settle(() => reject(new OutboundFailed(`${originalHost} answered a redirect, which this Runtime does not follow`, "REDIRECT", true)));
234
243
  }
235
244
  const noBody = status === 204 || status === 304 || method === "HEAD";
236
245
  const answer = (body) => {
@@ -258,7 +267,7 @@ export function guardedFetch(policy, options = {}) {
258
267
  response.on("data", (chunk) => {
259
268
  size += chunk.length;
260
269
  if (size > limit) {
261
- settle(() => reject(new OutboundFailed(`The answer from ${originalHost} is larger than ${limit} bytes`, "TOO_LARGE")));
270
+ settle(() => reject(new OutboundFailed(`The answer from ${originalHost} is larger than ${limit} bytes`, "TOO_LARGE", true)));
262
271
  response.destroy();
263
272
  return;
264
273
  }
@@ -268,6 +277,9 @@ export function guardedFetch(policy, options = {}) {
268
277
  response.on("error", failed);
269
278
  response.on("aborted", () => failed(Object.assign(new Error("The answer was cut off"), { code: "ECONNRESET" })));
270
279
  });
280
+ sent.on("finish", () => {
281
+ delivered = true;
282
+ });
271
283
  sent.on("error", failed);
272
284
  sent.end(body);
273
285
  });
@@ -15,7 +15,7 @@ export interface ResetTenantContext {
15
15
  /**
16
16
  * Reset Tenant durable state for `scope` (A17).
17
17
  * Caller must have already drained (`activeWork`). Deletes store state in one
18
- * transaction (`Tx.reset`), then does the filesystem rename+delete for sandboxes /
19
- * log as needed. The host vault (model credentials), principals and settings stay.
18
+ * transaction (`Tx.reset`), which also signals a sessions reset to every process on the
19
+ * control bus, then does the filesystem rename+delete for sandboxes / log as needed. The host vault (model credentials), principals and settings stay.
20
20
  */
21
21
  export declare function resetTenant(ctx: ResetTenantContext, scope: ResetScope): Promise<void>;
@@ -33,8 +33,8 @@ function replaceDirectory(dir) {
33
33
  /**
34
34
  * Reset Tenant durable state for `scope` (A17).
35
35
  * Caller must have already drained (`activeWork`). Deletes store state in one
36
- * transaction (`Tx.reset`), then does the filesystem rename+delete for sandboxes /
37
- * log as needed. The host vault (model credentials), principals and settings stay.
36
+ * transaction (`Tx.reset`), which also signals a sessions reset to every process on the
37
+ * control bus, then does the filesystem rename+delete for sandboxes / log as needed. The host vault (model credentials), principals and settings stay.
38
38
  */
39
39
  export async function resetTenant(ctx, scope) {
40
40
  const clearSessions = scope === "sessions" || scope === "all";
@@ -53,6 +53,12 @@ export async function resetTenant(ctx, scope) {
53
53
  // Folders' content-addressed files: those no remaining folder names (F8.2).
54
54
  const content = clearSessions ? await t.artifactContentShas(scoped) : [];
55
55
  await t.reset(scope);
56
+ // Every process moves its session streams to the new basin generation (control.ts).
57
+ if (clearSessions)
58
+ await t.signal({
59
+ type: "sessions.reset",
60
+ generation: (await t.basinGenerations()).current,
61
+ });
56
62
  const kept = await t.referencedArtifactContent(content);
57
63
  for (const sha of content)
58
64
  if (!kept.has(sha))