@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.
- package/CHANGELOG.md +90 -0
- package/README.md +22 -13
- package/dist/adapters/streams/s2.js +5 -11
- package/dist/api/components.d.ts +326 -205
- package/dist/api/components.js +3 -3
- package/dist/api/http/app.js +2 -0
- package/dist/api/http/define.d.ts +3 -2
- package/dist/api/http/define.js +10 -11
- package/dist/api/http/routes/mcp.d.ts +9 -0
- package/dist/api/http/routes/mcp.js +32 -0
- package/dist/api/http/routes/vaults.d.ts +1 -9
- package/dist/api/http/routes/vaults.js +3 -103
- package/dist/api/openapi.js +37 -6
- package/dist/artifacts/media-types.d.ts +2 -0
- package/dist/artifacts/media-types.js +5 -0
- package/dist/execution/types.d.ts +1 -1
- package/dist/gates/http-tool.d.ts +7 -0
- package/dist/gates/http-tool.js +34 -3
- package/dist/gates/mcp-handler.d.ts +4 -1
- package/dist/gates/mcp-handler.js +64 -8
- package/dist/gates/tenant-vaults.d.ts +9 -15
- package/dist/gates/tenant-vaults.js +9 -17
- package/dist/gates/tool-client.js +36 -17
- package/dist/gates/tool-contract.d.ts +19 -3
- package/dist/gates/tool-contract.js +9 -3
- package/dist/gates/tool-gate.d.ts +2 -2
- package/dist/harness/calls.d.ts +2 -0
- package/dist/harness/calls.js +13 -3
- package/dist/harness-api/record.d.ts +7 -6
- package/dist/harness-api/record.js +99 -17
- package/dist/harness-api/server.js +35 -27
- package/dist/harness-api/start.js +2 -1
- package/dist/host/app.d.ts +3 -0
- package/dist/host/app.js +33 -4
- package/dist/host/config-for.d.ts +0 -2
- package/dist/host/config-for.js +0 -1
- package/dist/host/create-host.d.ts +6 -0
- package/dist/host/create-host.js +1 -0
- package/dist/host/gates.js +0 -1
- package/dist/host/main.js +24 -7
- package/dist/host/stack-config.d.ts +5 -8
- package/dist/host/stack-config.js +9 -20
- package/dist/index.d.ts +0 -1
- package/dist/infra/index.d.ts +2 -1
- package/dist/infra/index.js +0 -1
- package/dist/infra/readiness.d.ts +6 -6
- package/dist/infra/readiness.js +2 -5
- package/dist/keys/client.js +1 -9
- package/dist/keys/keys.d.ts +11 -13
- package/dist/keys/keys.js +5 -7
- package/dist/management-openapi.json +671 -378
- package/dist/mcp/connect.d.ts +159 -13
- package/dist/mcp/connect.js +538 -55
- package/dist/mcp/pool.d.ts +11 -2
- package/dist/mcp/pool.js +74 -27
- package/dist/mcp/preview.d.ts +35 -0
- package/dist/mcp/preview.js +215 -0
- package/dist/mcp/snapshot.d.ts +79 -5
- package/dist/mcp/snapshot.js +145 -27
- package/dist/model/catalog.d.ts +14 -0
- package/dist/model/catalog.js +14 -1
- package/dist/model/pi-model.js +36 -2
- package/dist/openapi.json +644 -266
- package/dist/redact.d.ts +19 -1
- package/dist/redact.js +42 -13
- package/dist/sandbox/session-sandbox.d.ts +7 -0
- package/dist/sandbox/session-sandbox.js +89 -28
- package/dist/store/postgres/connect.d.ts +10 -0
- package/dist/store/postgres/connect.js +30 -3
- package/dist/store/postgres/control.d.ts +14 -0
- package/dist/store/postgres/control.js +187 -0
- package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
- package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
- package/dist/store/postgres/schema.d.ts +122 -261
- package/dist/store/postgres/schema.js +22 -36
- package/dist/store/postgres/store.js +16 -18
- package/dist/store/postgres/tenant.d.ts +14 -0
- package/dist/store/postgres/tenant.js +22 -2
- package/dist/store/types.d.ts +42 -8
- package/dist/streams/types.d.ts +6 -29
- package/dist/streams/types.js +5 -4
- package/dist/tenant/artifact-tool.d.ts +9 -1
- package/dist/tenant/artifact-tool.js +113 -9
- package/dist/tenant/auth.d.ts +16 -6
- package/dist/tenant/auth.js +41 -17
- package/dist/tenant/commands.js +10 -6
- package/dist/tenant/context.d.ts +9 -6
- package/dist/tenant/control.d.ts +26 -0
- package/dist/tenant/control.js +35 -0
- package/dist/tenant/effects.d.ts +2 -2
- package/dist/tenant/effects.js +20 -5
- package/dist/tenant/ephemeral.d.ts +0 -6
- package/dist/tenant/ephemeral.js +1 -1
- package/dist/tenant/http.d.ts +4 -2
- package/dist/tenant/http.js +4 -2
- package/dist/tenant/identity-file.d.ts +16 -12
- package/dist/tenant/identity-file.js +41 -29
- package/dist/tenant/issuers.d.ts +4 -3
- package/dist/tenant/issuers.js +22 -16
- package/dist/tenant/outbound.d.ts +14 -5
- package/dist/tenant/outbound.js +21 -9
- package/dist/tenant/reset.d.ts +2 -2
- package/dist/tenant/reset.js +8 -2
- package/dist/tenant/resource-server.d.ts +56 -0
- package/dist/tenant/resource-server.js +62 -0
- package/dist/tenant/runtime.d.ts +1 -1
- package/dist/tenant/runtime.js +16 -15
- package/dist/tenant/scheduler.d.ts +1 -1
- package/dist/tenant/scheduler.js +1 -1
- package/dist/tenant/sessions.js +20 -3
- package/dist/tenant/store-pg.js +9 -0
- package/dist/tenant/streams.d.ts +16 -22
- package/dist/tenant/streams.js +7 -87
- package/dist/tenant/sweep.js +9 -1
- package/dist/tenant/tool-results.d.ts +57 -0
- package/dist/tenant/tool-results.js +298 -0
- package/dist/tenant/tool-search.d.ts +43 -0
- package/dist/tenant/tool-search.js +100 -0
- package/dist/tenant/transcript.d.ts +108 -0
- package/dist/tenant/transcript.js +13 -0
- package/dist/tenant/types.d.ts +0 -14
- package/dist/vault/crypto.d.ts +0 -7
- package/dist/vault/crypto.js +0 -16
- package/dist/vault/headers.d.ts +17 -0
- package/dist/vault/headers.js +61 -0
- package/dist/vault/service.d.ts +11 -68
- package/dist/vault/service.js +83 -438
- package/dist/vault/sources.d.ts +20 -48
- package/dist/vault/sources.js +25 -189
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/dist/streams/control.d.ts +0 -13
- package/dist/streams/control.js +0 -25
- package/dist/vault/oauth.d.ts +0 -55
- package/dist/vault/oauth.js +0 -186
package/dist/tenant/effects.js
CHANGED
|
@@ -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:
|
|
158
|
-
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
|
|
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.
|
|
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
|
package/dist/tenant/ephemeral.js
CHANGED
|
@@ -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();
|
package/dist/tenant/http.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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";
|
package/dist/tenant/http.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
13
|
+
* audience: https://agents.acme.dev
|
|
12
14
|
* jwks: https://sso.acme.dev/realms/eng/protocol/openid-connect/certs # or keys: [<PEM>]
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* agents:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 `
|
|
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:
|
|
13
|
+
* audience: https://agents.acme.dev
|
|
12
14
|
* jwks: https://sso.acme.dev/realms/eng/protocol/openid-connect/certs # or keys: [<PEM>]
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* agents:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
/**
|
|
29
|
-
const
|
|
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")
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
package/dist/tenant/issuers.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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>;
|
package/dist/tenant/issuers.js
CHANGED
|
@@ -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,
|
|
15
|
-
*
|
|
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
|
|
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 {
|
|
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: ["
|
|
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
|
|
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
|
|
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" }, {
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
72
|
-
*
|
|
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
|
package/dist/tenant/outbound.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
171
|
-
*
|
|
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
|
});
|
package/dist/tenant/reset.d.ts
CHANGED
|
@@ -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`),
|
|
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>;
|
package/dist/tenant/reset.js
CHANGED
|
@@ -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`),
|
|
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))
|