@nylorun/runtime 0.15.2-beta → 0.17.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 +134 -0
- package/README.md +68 -29
- package/dist/adapters/execution/restate.d.ts +9 -1
- package/dist/adapters/execution/restate.js +64 -5
- package/dist/adapters/sandbox/local.d.ts +10 -0
- package/dist/adapters/sandbox/local.js +195 -0
- package/dist/adapters/sandbox/virtual.js +36 -1
- package/dist/admin-openapi.json +341 -7
- package/dist/api/a2a/tasks.js +6 -1
- package/dist/api/ag-ui/endpoint.js +1 -2
- package/dist/api/ag-ui/history.js +6 -1
- package/dist/api/ag-ui/routes.d.ts +1 -1
- package/dist/api/ag-ui/routes.js +15 -13
- package/dist/api/components.d.ts +2667 -711
- package/dist/api/components.js +24 -11
- package/dist/api/gate/keys.d.ts +4 -3
- package/dist/api/gate/routes.d.ts +19 -3
- package/dist/api/gate/routes.js +162 -53
- package/dist/api/http/app.js +6 -0
- package/dist/api/http/define.d.ts +19 -12
- package/dist/api/http/define.js +13 -27
- package/dist/api/http/routes/access.d.ts +3 -19
- package/dist/api/http/routes/access.js +12 -172
- package/dist/api/http/routes/actions.js +0 -1
- package/dist/api/http/routes/artifacts.d.ts +11 -0
- package/dist/api/http/routes/artifacts.js +516 -0
- package/dist/api/http/routes/me.d.ts +13 -0
- package/dist/api/http/routes/me.js +45 -0
- package/dist/api/http/routes/sandboxes.d.ts +9 -0
- package/dist/api/http/routes/sandboxes.js +150 -0
- package/dist/api/http/routes/sessions.js +9 -13
- package/dist/api/http/routes/tenant.js +36 -2
- package/dist/api/http/routes/vaults.d.ts +12 -3
- package/dist/api/http/routes/vaults.js +122 -54
- package/dist/api/openapi.js +4 -10
- package/dist/artifacts/config.d.ts +23 -0
- package/dist/artifacts/config.js +27 -0
- package/dist/artifacts/export.d.ts +8 -0
- package/dist/artifacts/export.js +205 -0
- package/dist/artifacts/files.d.ts +36 -0
- package/dist/artifacts/files.js +45 -0
- package/dist/artifacts/folders.d.ts +60 -0
- package/dist/artifacts/folders.js +209 -0
- package/dist/artifacts/links.d.ts +28 -0
- package/dist/artifacts/links.js +92 -0
- package/dist/artifacts/media-types.d.ts +13 -0
- package/dist/artifacts/media-types.js +80 -0
- package/dist/artifacts/parts.d.ts +34 -0
- package/dist/artifacts/parts.js +38 -0
- package/dist/artifacts/service.d.ts +74 -0
- package/dist/artifacts/service.js +301 -0
- package/dist/artifacts/workspace.d.ts +60 -0
- package/dist/artifacts/workspace.js +57 -0
- package/dist/artifacts/zip.d.ts +10 -0
- package/dist/artifacts/zip.js +126 -0
- package/dist/blob/fs.d.ts +13 -0
- package/dist/blob/fs.js +194 -0
- package/dist/blob/index.d.ts +9 -0
- package/dist/blob/index.js +9 -0
- package/dist/blob/s3.d.ts +42 -0
- package/dist/blob/s3.js +366 -0
- package/dist/blob/types.d.ts +108 -0
- package/dist/blob/types.js +85 -0
- package/dist/core/sandbox-routes.d.ts +2 -2
- package/dist/core/sandbox-routes.js +4 -3
- package/dist/execution/memory.d.ts +11 -1
- package/dist/execution/memory.js +77 -3
- package/dist/execution/types.d.ts +54 -1
- package/dist/execution/types.js +31 -0
- package/dist/gates/contract.d.ts +14 -13
- package/dist/gates/contract.js +13 -12
- package/dist/gates/egress.d.ts +72 -0
- package/dist/gates/egress.js +319 -0
- package/dist/gates/handler.d.ts +3 -0
- package/dist/gates/handler.js +3 -2
- package/dist/gates/http-client.d.ts +3 -2
- package/dist/gates/http-client.js +17 -6
- package/dist/gates/in-process.d.ts +13 -1
- package/dist/gates/in-process.js +11 -0
- package/dist/gates/inflight.d.ts +23 -4
- package/dist/gates/inflight.js +18 -4
- package/dist/gates/mcp-handler.js +5 -1
- package/dist/gates/model-gate.d.ts +6 -2
- package/dist/gates/tenant-vaults.d.ts +18 -9
- package/dist/gates/tenant-vaults.js +17 -12
- package/dist/gates/tool-calls.d.ts +9 -5
- package/dist/gates/tool-calls.js +6 -6
- package/dist/gates/tool-client.d.ts +9 -2
- package/dist/gates/tool-client.js +56 -22
- package/dist/gates/tool-contract.d.ts +12 -7
- package/dist/gates/tool-contract.js +10 -4
- package/dist/gates/tool-gate.d.ts +1 -0
- package/dist/harness/calls.d.ts +52 -0
- package/dist/harness/calls.js +113 -0
- package/dist/harness/executors.d.ts +22 -0
- package/dist/harness/executors.js +96 -0
- package/dist/harness/main.d.ts +6 -0
- package/dist/harness/main.js +103 -0
- package/dist/harness/pod.d.ts +21 -0
- package/dist/harness/pod.js +119 -0
- package/dist/harness/run-tokens.d.ts +18 -0
- package/dist/harness/run-tokens.js +15 -0
- package/dist/harness/service.d.ts +46 -0
- package/dist/harness/service.js +179 -0
- package/dist/harness/ws-client.d.ts +43 -0
- package/dist/harness/ws-client.js +122 -0
- package/dist/harness/ws-port.d.ts +22 -0
- package/dist/harness/ws-port.js +78 -0
- package/dist/harness-api/in-process.d.ts +24 -0
- package/dist/harness-api/in-process.js +119 -0
- package/dist/harness-api/record.d.ts +44 -0
- package/dist/harness-api/record.js +250 -0
- package/dist/harness-api/renew.d.ts +37 -0
- package/dist/harness-api/renew.js +77 -0
- package/dist/harness-api/server.d.ts +104 -0
- package/dist/harness-api/server.js +408 -0
- package/dist/harness-api/start.d.ts +36 -0
- package/dist/harness-api/start.js +118 -0
- package/dist/harness-api/workspace.d.ts +53 -0
- package/dist/harness-api/workspace.js +201 -0
- package/dist/harness-api/ws-server.d.ts +34 -0
- package/dist/harness-api/ws-server.js +227 -0
- package/dist/host/admin-api.d.ts +12 -5
- package/dist/host/admin-api.js +72 -6
- package/dist/host/app.d.ts +0 -1
- package/dist/host/app.js +35 -24
- package/dist/host/config-for.d.ts +4 -0
- package/dist/host/config-for.js +2 -0
- package/dist/host/config.d.ts +0 -6
- package/dist/host/create-host.d.ts +3 -10
- package/dist/host/create-host.js +4 -1
- package/dist/host/gates.d.ts +27 -2
- package/dist/host/gates.js +52 -2
- package/dist/host/http.d.ts +4 -1
- package/dist/host/http.js +6 -1
- package/dist/host/main.js +167 -37
- package/dist/host/stack-config.d.ts +112 -9
- package/dist/host/stack-config.js +222 -20
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/keys/client.js +9 -1
- package/dist/keys/keys.d.ts +12 -2
- package/dist/keys/keys.js +8 -0
- package/dist/mcp/connect.js +2 -1
- package/dist/mcp/pool.d.ts +1 -0
- package/dist/mcp/pool.js +1 -0
- package/dist/mcp/snapshot.d.ts +8 -1
- package/dist/mcp/snapshot.js +28 -0
- package/dist/media.d.ts +5 -24
- package/dist/media.js +5 -0
- package/dist/model/pi-model.d.ts +6 -2
- package/dist/model/pi-model.js +39 -22
- package/dist/node/index.d.ts +1 -1
- package/dist/node/index.js +1 -1
- package/dist/openapi.json +14084 -9426
- package/dist/record/index.d.ts +1 -0
- package/dist/record/index.js +1 -0
- package/dist/record/sandbox.d.ts +25 -0
- package/dist/record/sandbox.js +43 -0
- package/dist/sandbox/capability.d.ts +3 -0
- package/dist/sandbox/capability.js +10 -0
- package/dist/sandbox/egress-token.d.ts +26 -0
- package/dist/sandbox/egress-token.js +8 -0
- package/dist/sandbox/join.d.ts +37 -0
- package/dist/sandbox/join.js +165 -0
- package/dist/sandbox/manager.d.ts +99 -34
- package/dist/sandbox/manager.js +216 -84
- package/dist/sandbox/placement.d.ts +27 -0
- package/dist/sandbox/placement.js +44 -0
- package/dist/sandbox/pods/client.d.ts +90 -0
- package/dist/sandbox/pods/client.js +79 -0
- package/dist/sandbox/pods/lifecycle.d.ts +91 -0
- package/dist/sandbox/pods/lifecycle.js +178 -0
- package/dist/sandbox/pods/name.d.ts +1 -0
- package/dist/sandbox/pods/name.js +28 -0
- package/dist/sandbox/pods/network-gate.d.ts +25 -0
- package/dist/sandbox/pods/network-gate.js +71 -0
- package/dist/sandbox/pods/reconcile.d.ts +11 -0
- package/dist/sandbox/pods/reconcile.js +152 -0
- package/dist/sandbox/pods/spec.d.ts +45 -0
- package/dist/sandbox/pods/spec.js +62 -0
- package/dist/sandbox/records.d.ts +29 -0
- package/dist/sandbox/records.js +79 -0
- package/dist/sandbox/resolve.d.ts +5 -0
- package/dist/sandbox/resolve.js +3 -2
- package/dist/sandbox/session-sandbox.js +7 -5
- package/dist/sandbox/share.d.ts +11 -0
- package/dist/sandbox/share.js +10 -0
- package/dist/sandbox/tenant-config.d.ts +16 -1
- package/dist/sandbox/tenant-config.js +9 -0
- package/dist/sandbox/types.d.ts +29 -1
- package/dist/sandbox/types.js +13 -1
- package/dist/store/postgres/drizzle/0004_sandbox_resources.sql +20 -0
- package/dist/store/postgres/drizzle/0005_artifacts.sql +27 -0
- package/dist/store/postgres/drizzle/0006_folder_artifacts.sql +12 -0
- package/dist/store/postgres/drizzle/0007_sandbox_pods.sql +14 -0
- package/dist/store/postgres/drizzle/0008_installation_vaults.sql +2 -0
- package/dist/store/postgres/drizzle/0009_oauth_pending.sql +19 -0
- package/dist/store/postgres/drizzle/0010_oss_auth_removals.sql +3 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +49 -0
- package/dist/store/postgres/record-writer.d.ts +6 -1
- package/dist/store/postgres/record-writer.js +20 -2
- package/dist/store/postgres/schema.d.ts +1168 -250
- package/dist/store/postgres/schema.js +174 -28
- package/dist/store/postgres/store.js +339 -103
- package/dist/store/postgres/tenant.d.ts +1 -1
- package/dist/store/postgres/tenant.js +2 -2
- package/dist/store/types.d.ts +163 -32
- package/dist/streams/control.d.ts +2 -5
- package/dist/streams/control.js +5 -8
- package/dist/streams/types.d.ts +8 -8
- package/dist/streams/types.js +1 -1
- package/dist/tenant/advance.js +218 -160
- package/dist/tenant/artifact-tool.d.ts +5 -0
- package/dist/tenant/artifact-tool.js +119 -0
- package/dist/tenant/auth.d.ts +13 -9
- package/dist/tenant/auth.js +25 -32
- package/dist/tenant/commands.js +31 -10
- package/dist/tenant/context.d.ts +83 -26
- package/dist/tenant/delivery-token.js +4 -4
- package/dist/tenant/effects.d.ts +24 -14
- package/dist/tenant/effects.js +10 -398
- package/dist/tenant/ephemeral.d.ts +14 -2
- package/dist/tenant/ephemeral.js +8 -1
- package/dist/tenant/history.d.ts +13 -23
- package/dist/tenant/history.js +14 -62
- package/dist/tenant/host-token.d.ts +92 -0
- package/dist/tenant/host-token.js +179 -0
- package/dist/tenant/identity-file.d.ts +75 -0
- package/dist/tenant/identity-file.js +202 -0
- package/dist/tenant/issuers.d.ts +48 -0
- package/dist/tenant/issuers.js +311 -0
- package/dist/tenant/jwt.js +3 -3
- package/dist/tenant/module.js +1 -0
- package/dist/tenant/operator-keys.d.ts +18 -0
- package/dist/tenant/operator-keys.js +55 -0
- package/dist/tenant/outbound.d.ts +30 -0
- package/dist/tenant/outbound.js +142 -10
- package/dist/tenant/paths.d.ts +2 -1
- package/dist/tenant/paths.js +3 -1
- package/dist/tenant/principals.d.ts +2 -11
- package/dist/tenant/principals.js +10 -30
- package/dist/tenant/reset.d.ts +5 -2
- package/dist/tenant/reset.js +22 -2
- package/dist/tenant/run-grants.d.ts +49 -0
- package/dist/tenant/run-grants.js +62 -0
- package/dist/tenant/run-token.d.ts +70 -0
- package/dist/tenant/run-token.js +138 -0
- package/dist/tenant/runtime.d.ts +42 -0
- package/dist/tenant/runtime.js +134 -33
- package/dist/tenant/sandboxes.d.ts +119 -0
- package/dist/tenant/sandboxes.js +551 -0
- package/dist/tenant/session-streams.d.ts +6 -10
- package/dist/tenant/session-streams.js +9 -27
- package/dist/tenant/session.d.ts +2 -13
- package/dist/tenant/session.js +4 -28
- package/dist/tenant/sessions.d.ts +6 -1
- package/dist/tenant/sessions.js +29 -2
- package/dist/tenant/signing-keys.d.ts +2 -1
- package/dist/tenant/signing-keys.js +2 -2
- package/dist/tenant/status.d.ts +8 -3
- package/dist/tenant/status.js +5 -0
- package/dist/tenant/streams.d.ts +8 -2
- package/dist/tenant/streams.js +25 -6
- package/dist/tenant/sweep.js +37 -4
- package/dist/tenant/types.d.ts +39 -1
- package/dist/tenant/worker.d.ts +11 -5
- package/dist/tenant/worker.js +12 -6
- package/dist/vault/crypto.d.ts +7 -0
- package/dist/vault/crypto.js +16 -0
- package/dist/vault/oauth.d.ts +55 -0
- package/dist/vault/oauth.js +186 -0
- package/dist/vault/service.d.ts +54 -7
- package/dist/vault/service.js +261 -17
- package/dist/vault/sources.d.ts +54 -0
- package/dist/vault/sources.js +185 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +14 -10
- package/dist/adapters/media.d.ts +0 -24
- package/dist/adapters/media.js +0 -111
- package/dist/host/cors.d.ts +0 -27
- package/dist/host/cors.js +0 -64
- package/dist/tenant/access-policy.d.ts +0 -30
- package/dist/tenant/access-policy.js +0 -52
- package/dist/tenant/browser.d.ts +0 -15
- package/dist/tenant/browser.js +0 -39
- package/dist/tenant/subject-limits.d.ts +0 -16
- package/dist/tenant/subject-limits.js +0 -45
- package/dist/tenant/tokens.d.ts +0 -14
- package/dist/tenant/tokens.js +0 -180
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type JWTPayload } from "jose";
|
|
2
|
+
import type { AuthScope, TenantContext } from "./context.js";
|
|
3
|
+
import { type TrustedIssuerConfig } from "./identity-file.js";
|
|
4
|
+
/** The byte cap of an issuer's token; Nylorun's own token families keep 4 KiB. */
|
|
5
|
+
export declare const MAX_ISSUER_TOKEN_BYTES: number;
|
|
6
|
+
export interface TrustedIssuersOptions {
|
|
7
|
+
/** Fetches JWKS URLs. Default: the global fetch. */
|
|
8
|
+
fetch?: typeof fetch;
|
|
9
|
+
/** The least time between two JWKS fetches of one issuer for an unknown `kid`. Default 60 s. */
|
|
10
|
+
refetchMs?: number;
|
|
11
|
+
/** How old cached JWKS keys get before a background refresh. Default 10 minutes. */
|
|
12
|
+
maxAgeMs?: number;
|
|
13
|
+
/** Milliseconds since the epoch. Default `Date.now`. */
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}
|
|
16
|
+
/** What verifying an issuer's token found. */
|
|
17
|
+
export type IssuerVerdict = {
|
|
18
|
+
readonly ok: true;
|
|
19
|
+
readonly payload: JWTPayload;
|
|
20
|
+
readonly kid?: string;
|
|
21
|
+
} | {
|
|
22
|
+
readonly ok: false;
|
|
23
|
+
readonly refused: "invalid" | "expired" | "unavailable";
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
};
|
|
26
|
+
/** One configured issuer, with its keys. */
|
|
27
|
+
export interface TrustedIssuer {
|
|
28
|
+
readonly config: TrustedIssuerConfig;
|
|
29
|
+
/** Checks `raw`'s header and signature, `iss`, `aud`, `exp`, `iat` and its lifetime. */
|
|
30
|
+
verify(raw: string): Promise<IssuerVerdict>;
|
|
31
|
+
}
|
|
32
|
+
/** The Host's trusted issuers (`TenantConfig.issuers`), built once from the identity file. */
|
|
33
|
+
export interface TrustedIssuers {
|
|
34
|
+
readonly issuers: readonly TrustedIssuer[];
|
|
35
|
+
/** The issuer a JWT-shaped bearer claims by its unverified `iss`, if it is one of these. */
|
|
36
|
+
claimed(raw: string): TrustedIssuer | undefined;
|
|
37
|
+
}
|
|
38
|
+
/** The trusted issuers of `configs` (`parseIdentityFile`). No key is fetched until needed. */
|
|
39
|
+
export declare function createTrustedIssuers(configs: readonly TrustedIssuerConfig[], options?: TrustedIssuersOptions): TrustedIssuers;
|
|
40
|
+
export type IssuerTokenScope = Extract<AuthScope, {
|
|
41
|
+
kind: "token";
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Verifies a bearer that `issuer` claims (`TrustedIssuers.claimed`). Returns the request's
|
|
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.
|
|
47
|
+
*/
|
|
48
|
+
export declare function verifyIssuerToken(ctx: TenantContext, issuer: TrustedIssuer, raw: string): Promise<IssuerTokenScope>;
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted issuers (Host feature `trusted-issuers`, F9 I2): JWTs from the operator's own
|
|
3
|
+
* identity provider, declared in the identity file (`identity-file.ts`), accepted as bearers.
|
|
4
|
+
* A token is one of theirs when its unverified `iss` names a configured issuer; it is then
|
|
5
|
+
* verified like this, and nothing else is tried:
|
|
6
|
+
*
|
|
7
|
+
* - at most 16 KiB (identity providers' tokens often pass Nylorun's 4 KiB), RS256, ES256 or
|
|
8
|
+
* EdDSA, and no header that could name another key (`jku`, `jwk`, `x5u`, `x5c`, `crit`);
|
|
9
|
+
* - signed by a key of the issuer: its static `keys`, or its JWKS, fetched only from the
|
|
10
|
+
* configured URL (`redirect: "error"`), cached by `kid`, refetched at most once a minute for
|
|
11
|
+
* a `kid` it has not seen, and refreshed in the background once the cache is 10 minutes old.
|
|
12
|
+
* When the JWKS cannot be fetched, cached keys keep working and a new `kid` is
|
|
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;
|
|
16
|
+
* - a subject the template renders from scalar claims, valid and not reserved.
|
|
17
|
+
*
|
|
18
|
+
* 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.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash, createPublicKey } from "node:crypto";
|
|
23
|
+
import { decodeJwt, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
24
|
+
import { isSubject } from "@nylorun/core/contracts";
|
|
25
|
+
import { failOpaque, HttpError } from "./http.js";
|
|
26
|
+
import { CLOCK_TOLERANCE_SECONDS, looksLikeToken } from "./jwt.js";
|
|
27
|
+
import { ISSUER_ALGORITHMS, issuerKey, renderTemplate, } from "./identity-file.js";
|
|
28
|
+
/** The byte cap of an issuer's token; Nylorun's own token families keep 4 KiB. */
|
|
29
|
+
export const MAX_ISSUER_TOKEN_BYTES = 16 * 1024;
|
|
30
|
+
/** Subjects the Runtime keeps for itself: never an issuer token's. */
|
|
31
|
+
const RESERVED = new Set(["host", "installation"]);
|
|
32
|
+
const FORBIDDEN_HEADERS = ["jku", "jwk", "x5u", "x5c", "crit"];
|
|
33
|
+
const JWKS_TIMEOUT_MS = 5000;
|
|
34
|
+
const JWKS_MAX_BYTES = 256 * 1024;
|
|
35
|
+
const JWKS_MAX_KEYS = 64;
|
|
36
|
+
const DEFAULT_REFETCH_MS = 60_000;
|
|
37
|
+
const DEFAULT_MAX_AGE_MS = 10 * 60_000;
|
|
38
|
+
/** A grant's claim value: one sandbox id segment, so a claim never widens the template. */
|
|
39
|
+
const GRANT_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
40
|
+
/** The issuer's public keys: static, or its JWKS's as last fetched. */
|
|
41
|
+
class IssuerKeys {
|
|
42
|
+
config;
|
|
43
|
+
options;
|
|
44
|
+
byKid = new Map();
|
|
45
|
+
unnamed = [];
|
|
46
|
+
fetchedAt = Number.NEGATIVE_INFINITY;
|
|
47
|
+
okAt = Number.NEGATIVE_INFINITY;
|
|
48
|
+
lastOk;
|
|
49
|
+
inflight;
|
|
50
|
+
constructor(config, options) {
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.options = options;
|
|
53
|
+
for (const key of config.keys ?? [])
|
|
54
|
+
this.unnamed.push(key);
|
|
55
|
+
}
|
|
56
|
+
/** The keys that may have signed a token with `kid` and `alg`, or `unavailable`. */
|
|
57
|
+
async candidates(kid, alg) {
|
|
58
|
+
const usable = (key) => key.alg === alg;
|
|
59
|
+
// Static keys have no `kid`; a JWKS token with a `kid` is checked against that key alone.
|
|
60
|
+
const pick = () => {
|
|
61
|
+
if (!this.config.jwks)
|
|
62
|
+
return this.unnamed.filter(usable);
|
|
63
|
+
if (kid !== undefined) {
|
|
64
|
+
const key = this.byKid.get(kid);
|
|
65
|
+
return key && usable(key) ? [key] : [];
|
|
66
|
+
}
|
|
67
|
+
return [...this.byKid.values(), ...this.unnamed].filter(usable);
|
|
68
|
+
};
|
|
69
|
+
if (!this.config.jwks)
|
|
70
|
+
return pick();
|
|
71
|
+
const now = this.options.now();
|
|
72
|
+
let found = pick();
|
|
73
|
+
if (found.length === 0) {
|
|
74
|
+
if (this.inflight)
|
|
75
|
+
await this.inflight;
|
|
76
|
+
else if (now - this.fetchedAt >= this.options.refetchMs)
|
|
77
|
+
await this.refresh();
|
|
78
|
+
found = pick();
|
|
79
|
+
if (found.length === 0 && this.lastOk === false)
|
|
80
|
+
return "unavailable";
|
|
81
|
+
}
|
|
82
|
+
else if (now - this.okAt >= this.options.maxAgeMs && now - this.fetchedAt >= this.options.refetchMs) {
|
|
83
|
+
// Old keys keep working while the refresh runs, and when it fails.
|
|
84
|
+
void this.refresh();
|
|
85
|
+
}
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
/** Fetches the JWKS once, joining a fetch already running. A failure keeps the cache. */
|
|
89
|
+
refresh() {
|
|
90
|
+
this.inflight ??= this.fetchKeys().finally(() => {
|
|
91
|
+
this.inflight = undefined;
|
|
92
|
+
});
|
|
93
|
+
return this.inflight;
|
|
94
|
+
}
|
|
95
|
+
async fetchKeys() {
|
|
96
|
+
const started = this.options.now();
|
|
97
|
+
this.fetchedAt = started;
|
|
98
|
+
try {
|
|
99
|
+
const response = await this.options.fetch(this.config.jwks, {
|
|
100
|
+
redirect: "error",
|
|
101
|
+
headers: { accept: "application/jwk-set+json, application/json" },
|
|
102
|
+
signal: AbortSignal.timeout(JWKS_TIMEOUT_MS),
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
throw new Error(`JWKS answered ${response.status}`);
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
if (Buffer.byteLength(text) > JWKS_MAX_BYTES)
|
|
108
|
+
throw new Error("JWKS is too large");
|
|
109
|
+
const body = JSON.parse(text);
|
|
110
|
+
if (!Array.isArray(body.keys))
|
|
111
|
+
throw new Error("JWKS has no keys array");
|
|
112
|
+
const byKid = new Map();
|
|
113
|
+
const unnamed = [];
|
|
114
|
+
for (const jwk of body.keys.slice(0, JWKS_MAX_KEYS)) {
|
|
115
|
+
const key = jwkKey(jwk);
|
|
116
|
+
if (!key)
|
|
117
|
+
continue;
|
|
118
|
+
if (key.kid !== undefined)
|
|
119
|
+
byKid.set(key.kid, key);
|
|
120
|
+
else
|
|
121
|
+
unnamed.push(key);
|
|
122
|
+
}
|
|
123
|
+
this.byKid = byKid;
|
|
124
|
+
this.unnamed = unnamed;
|
|
125
|
+
this.okAt = started;
|
|
126
|
+
this.lastOk = true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
this.lastOk = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** A JWK an issuer signs with, or undefined for one the Runtime does not use. */
|
|
134
|
+
function jwkKey(jwk) {
|
|
135
|
+
if (!jwk || typeof jwk !== "object")
|
|
136
|
+
return undefined;
|
|
137
|
+
const entry = jwk;
|
|
138
|
+
if (entry.use !== undefined && entry.use !== "sig")
|
|
139
|
+
return undefined;
|
|
140
|
+
if (entry.d !== undefined)
|
|
141
|
+
return undefined; // a private key: never trusted as published
|
|
142
|
+
if (entry.alg !== undefined && !ISSUER_ALGORITHMS.includes(entry.alg))
|
|
143
|
+
return undefined;
|
|
144
|
+
const kid = typeof entry.kid === "string" && entry.kid.length <= 256 ? entry.kid : undefined;
|
|
145
|
+
try {
|
|
146
|
+
const key = issuerKey(createPublicKey({ key: jwk, format: "jwk" }), kid);
|
|
147
|
+
return entry.alg === undefined || entry.alg === key.alg ? key : undefined;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function trustedIssuer(config, options) {
|
|
154
|
+
const keys = new IssuerKeys(config, options);
|
|
155
|
+
return {
|
|
156
|
+
config,
|
|
157
|
+
async verify(raw) {
|
|
158
|
+
const refuse = (refused, reason) => ({
|
|
159
|
+
ok: false,
|
|
160
|
+
refused,
|
|
161
|
+
reason,
|
|
162
|
+
});
|
|
163
|
+
let header;
|
|
164
|
+
try {
|
|
165
|
+
header = decodeProtectedHeader(raw);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return refuse("invalid", "issuer_token_malformed");
|
|
169
|
+
}
|
|
170
|
+
const alg = header.alg;
|
|
171
|
+
if (!ISSUER_ALGORITHMS.includes(alg) ||
|
|
172
|
+
(header.kid !== undefined && typeof header.kid !== "string") ||
|
|
173
|
+
FORBIDDEN_HEADERS.some((name) => name in header))
|
|
174
|
+
return refuse("invalid", "issuer_token_header");
|
|
175
|
+
const kid = header.kid;
|
|
176
|
+
const candidates = await keys.candidates(kid, alg);
|
|
177
|
+
if (candidates === "unavailable")
|
|
178
|
+
return refuse("unavailable", "issuer_jwks_unavailable");
|
|
179
|
+
if (candidates.length === 0)
|
|
180
|
+
return refuse("invalid", "issuer_key_unknown");
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
try {
|
|
183
|
+
const { payload } = await jwtVerify(raw, candidate.key, {
|
|
184
|
+
algorithms: [candidate.alg],
|
|
185
|
+
issuer: config.issuer,
|
|
186
|
+
audience: config.audience,
|
|
187
|
+
clockTolerance: CLOCK_TOLERANCE_SECONDS,
|
|
188
|
+
requiredClaims: ["iat", "exp"],
|
|
189
|
+
currentDate: new Date(options.now()),
|
|
190
|
+
});
|
|
191
|
+
const now = Math.floor(options.now() / 1000);
|
|
192
|
+
if (payload.iat > now + CLOCK_TOLERANCE_SECONDS)
|
|
193
|
+
return refuse("invalid", "issuer_token_future");
|
|
194
|
+
if (payload.exp - payload.iat > config.maxLifetimeSeconds)
|
|
195
|
+
return refuse("invalid", "issuer_token_lifetime");
|
|
196
|
+
return { ok: true, payload, ...(kid !== undefined ? { kid } : {}) };
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
// Signature checks come first: an expiry is only reported for a token the issuer signed.
|
|
200
|
+
if (error instanceof errors.JWSSignatureVerificationFailed)
|
|
201
|
+
continue;
|
|
202
|
+
if (error instanceof errors.JWTExpired)
|
|
203
|
+
return refuse("expired", "issuer_token_expired");
|
|
204
|
+
return refuse("invalid", "issuer_token_invalid");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return refuse("invalid", "issuer_token_signature");
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** The trusted issuers of `configs` (`parseIdentityFile`). No key is fetched until needed. */
|
|
212
|
+
export function createTrustedIssuers(configs, options = {}) {
|
|
213
|
+
const resolved = {
|
|
214
|
+
fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
215
|
+
refetchMs: options.refetchMs ?? DEFAULT_REFETCH_MS,
|
|
216
|
+
maxAgeMs: options.maxAgeMs ?? DEFAULT_MAX_AGE_MS,
|
|
217
|
+
now: options.now ?? Date.now,
|
|
218
|
+
};
|
|
219
|
+
const issuers = configs.map((config) => trustedIssuer(config, resolved));
|
|
220
|
+
const byIss = new Map(issuers.map((issuer) => [issuer.config.issuer, issuer]));
|
|
221
|
+
return {
|
|
222
|
+
issuers,
|
|
223
|
+
claimed(raw) {
|
|
224
|
+
if (byIss.size === 0 || Buffer.byteLength(raw) > MAX_ISSUER_TOKEN_BYTES || !looksLikeToken(raw))
|
|
225
|
+
return undefined;
|
|
226
|
+
try {
|
|
227
|
+
const iss = decodeJwt(raw).iss;
|
|
228
|
+
return typeof iss === "string" ? byIss.get(iss) : undefined;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** A claim's value as template text: strings and finite numbers only (scalar claims). */
|
|
237
|
+
function scalarClaim(payload, claim) {
|
|
238
|
+
if (!Object.hasOwn(payload, claim))
|
|
239
|
+
return undefined;
|
|
240
|
+
const value = payload[claim];
|
|
241
|
+
if (typeof value === "string" && value.length > 0)
|
|
242
|
+
return value;
|
|
243
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
244
|
+
return String(value);
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
function scopesOf(config, payload) {
|
|
248
|
+
let names = [];
|
|
249
|
+
if ("fixed" in config.scopes)
|
|
250
|
+
names = [...config.scopes.fixed];
|
|
251
|
+
else if (Object.hasOwn(payload, config.scopes.claim)) {
|
|
252
|
+
const value = payload[config.scopes.claim];
|
|
253
|
+
if (typeof value === "string")
|
|
254
|
+
names = value.split(" ");
|
|
255
|
+
else if (Array.isArray(value))
|
|
256
|
+
names = value;
|
|
257
|
+
}
|
|
258
|
+
return new Set(config.allowedScopes.filter((scope) => names.includes(scope)));
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Verifies a bearer that `issuer` claims (`TrustedIssuers.claimed`). Returns the request's
|
|
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.
|
|
264
|
+
*/
|
|
265
|
+
export async function verifyIssuerToken(ctx, issuer, raw) {
|
|
266
|
+
const { config } = issuer;
|
|
267
|
+
const rejected = (reason) => {
|
|
268
|
+
ctx.config.logger.warn("credential rejected", { reason, issuer: config.name });
|
|
269
|
+
return failOpaque();
|
|
270
|
+
};
|
|
271
|
+
const verdict = await issuer.verify(raw);
|
|
272
|
+
if (!verdict.ok) {
|
|
273
|
+
if (verdict.refused === "expired") {
|
|
274
|
+
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
|
+
}
|
|
277
|
+
if (verdict.refused === "unavailable") {
|
|
278
|
+
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
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return rejected(verdict.reason);
|
|
284
|
+
}
|
|
285
|
+
const { payload } = verdict;
|
|
286
|
+
const subject = renderTemplate(config.subject, (claim) => scalarClaim(payload, claim));
|
|
287
|
+
if (subject === undefined || !isSubject(subject) || RESERVED.has(subject))
|
|
288
|
+
return rejected("issuer_token_subject");
|
|
289
|
+
const sandboxes = config.sandboxes?.flatMap((grant) => {
|
|
290
|
+
const rendered = renderTemplate(grant, (claim) => {
|
|
291
|
+
const value = scalarClaim(payload, claim);
|
|
292
|
+
return value !== undefined && GRANT_SEGMENT.test(value) ? value : undefined;
|
|
293
|
+
});
|
|
294
|
+
// A grant whose claim is missing or not one id segment reaches nothing.
|
|
295
|
+
return rendered === undefined ? [] : [rendered];
|
|
296
|
+
});
|
|
297
|
+
const jti = typeof payload.jti === "string" && payload.jti.length > 0 && payload.jti.length <= 200
|
|
298
|
+
? payload.jti
|
|
299
|
+
: createHash("sha256").update(raw).digest("base64url");
|
|
300
|
+
return {
|
|
301
|
+
kind: "token",
|
|
302
|
+
issuer: config.name,
|
|
303
|
+
subject,
|
|
304
|
+
scopes: scopesOf(config, payload),
|
|
305
|
+
agents: config.agents ? new Set(config.agents) : "*",
|
|
306
|
+
sandboxes: [...new Set(sandboxes ?? [])],
|
|
307
|
+
expiresAt: payload.exp * 1000,
|
|
308
|
+
tokenId: jti,
|
|
309
|
+
...(verdict.kid !== undefined ? { keyId: verdict.kid } : {}),
|
|
310
|
+
};
|
|
311
|
+
}
|
package/dist/tenant/jwt.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What
|
|
3
|
-
* told apart by their `typ`, and refused before
|
|
4
|
-
* Runtime issues.
|
|
2
|
+
* What the Runtime's own tokens share (delivery tokens, capability links, run and host tokens):
|
|
3
|
+
* the Tenant's ES256 JWTs, recognised by shape, told apart by their `typ`, and refused before
|
|
4
|
+
* any key is read when the header is not one the Runtime issues.
|
|
5
5
|
*/
|
|
6
6
|
import { decodeProtectedHeader } from "jose";
|
|
7
7
|
import { SIGNING_KEY_ID_PATTERN } from "@nylorun/core/compatibility";
|
package/dist/tenant/module.js
CHANGED
|
@@ -158,6 +158,7 @@ export function createTenantModule(options) {
|
|
|
158
158
|
pendingActions: summary?.pendingActions ?? 0,
|
|
159
159
|
uncertainEffects: summary?.uncertainEffects ?? 0,
|
|
160
160
|
...(relay ? { relay } : {}),
|
|
161
|
+
...(summary?.harness ? { harness: summary.harness } : {}),
|
|
161
162
|
};
|
|
162
163
|
},
|
|
163
164
|
async close() {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OperatorKey, PutOperatorKeyResponse } from "@nylorun/core/contracts";
|
|
2
|
+
import type { SessionStore } from "../store/types.js";
|
|
3
|
+
/** Why a key id is refused: not a key name, or the reserved `studio`. */
|
|
4
|
+
export type OperatorKeyRefusal = {
|
|
5
|
+
reason: "invalid" | "reserved";
|
|
6
|
+
message: string;
|
|
7
|
+
};
|
|
8
|
+
/** The refusal for `id`, or undefined when operator keys may use it. */
|
|
9
|
+
export declare function refuseOperatorKeyId(id: string): OperatorKeyRefusal | undefined;
|
|
10
|
+
export interface OperatorKeys {
|
|
11
|
+
/** Every principal of the Tenant, by id (Studio's and derived ones included). */
|
|
12
|
+
list(): Promise<OperatorKey[]>;
|
|
13
|
+
/** Creates key `id` or rotates it; the key is returned this once. */
|
|
14
|
+
put(id: string): Promise<PutOperatorKeyResponse | OperatorKeyRefusal>;
|
|
15
|
+
/** Deletes key `id`: false when there is none. */
|
|
16
|
+
delete(id: string): Promise<boolean | OperatorKeyRefusal>;
|
|
17
|
+
}
|
|
18
|
+
export declare function operatorKeys(store: SessionStore, now?: () => string): OperatorKeys;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator keys (F9 I1, Host feature `operator-keys`): the Tenant's application keys, managed
|
|
3
|
+
* by name through the Admin API. A key is a principal (F9-D11): its id is the key's name
|
|
4
|
+
* (`APPLICATION_KEY_ID_PATTERN`), the key is 64 hex characters (`mintBearerToken`), and the
|
|
5
|
+
* Tenant keeps only its SHA-256. Putting a key creates the principal or rotates its key;
|
|
6
|
+
* deleting it removes the row. Either way the old key stops authenticating on its next request,
|
|
7
|
+
* since authentication reads the principal on every request (`auth.ts`).
|
|
8
|
+
*
|
|
9
|
+
* `studio` is derived from the admin key (F9-D9): it is never put or deleted here.
|
|
10
|
+
*/
|
|
11
|
+
import { APPLICATION_KEY_ID_PATTERN } from "@nylorun/core/compatibility";
|
|
12
|
+
import { hashToken, mintBearerToken } from "../core/bearer.js";
|
|
13
|
+
import { STUDIO_PRINCIPAL_ID } from "./principals.js";
|
|
14
|
+
/** The refusal for `id`, or undefined when operator keys may use it. */
|
|
15
|
+
export function refuseOperatorKeyId(id) {
|
|
16
|
+
if (!APPLICATION_KEY_ID_PATTERN.test(id))
|
|
17
|
+
return {
|
|
18
|
+
reason: "invalid",
|
|
19
|
+
message: `A key id must match ${APPLICATION_KEY_ID_PATTERN.source}`,
|
|
20
|
+
};
|
|
21
|
+
if (id === STUDIO_PRINCIPAL_ID)
|
|
22
|
+
return {
|
|
23
|
+
reason: "reserved",
|
|
24
|
+
message: "The studio key is derived from the admin key: it is not put or deleted here",
|
|
25
|
+
};
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export function operatorKeys(store, now = () => new Date().toISOString()) {
|
|
29
|
+
return {
|
|
30
|
+
async list() {
|
|
31
|
+
const rows = await store.tx((t) => t.listPrincipals());
|
|
32
|
+
return rows.map(({ id, role, createdAt }) => ({ id, role, createdAt }));
|
|
33
|
+
},
|
|
34
|
+
async put(id) {
|
|
35
|
+
const refused = refuseOperatorKeyId(id);
|
|
36
|
+
if (refused)
|
|
37
|
+
return refused;
|
|
38
|
+
const key = mintBearerToken();
|
|
39
|
+
const { row, rotated } = await store.tx(async (t) => {
|
|
40
|
+
const existing = await t.principalById(id);
|
|
41
|
+
return {
|
|
42
|
+
row: await t.putPrincipal(id, hashToken(key), now()),
|
|
43
|
+
rotated: existing !== undefined,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
return { id: row.id, role: row.role, createdAt: row.createdAt, key, rotated };
|
|
47
|
+
},
|
|
48
|
+
async delete(id) {
|
|
49
|
+
const refused = refuseOperatorKeyId(id);
|
|
50
|
+
if (refused)
|
|
51
|
+
return refused;
|
|
52
|
+
return await store.tx((t) => t.deletePrincipal(id));
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -42,3 +42,33 @@ export declare function post(target: string, body: string, headers: Record<strin
|
|
|
42
42
|
signal: AbortSignal;
|
|
43
43
|
maxResponseBytes?: number;
|
|
44
44
|
}): Promise<OutboundResult>;
|
|
45
|
+
/**
|
|
46
|
+
* A refusal of `guardedFetch`: the Host's policy forbids the URL or every address it resolves
|
|
47
|
+
* to. Not a `TypeError`, so callers that read a `TypeError` as a CORS failure (the MCP SDK's
|
|
48
|
+
* discovery) see the refusal instead of trying again.
|
|
49
|
+
*/
|
|
50
|
+
export declare class OutboundRefused extends Error {
|
|
51
|
+
readonly code = "ENDPOINT_ADDRESS_REFUSED";
|
|
52
|
+
constructor(message: string);
|
|
53
|
+
}
|
|
54
|
+
/** A request `guardedFetch` sent that failed: refused connection, reset, timeout, redirect. */
|
|
55
|
+
export declare class OutboundFailed extends Error {
|
|
56
|
+
readonly code: string;
|
|
57
|
+
constructor(message: string, code: string);
|
|
58
|
+
}
|
|
59
|
+
/** The largest answer `guardedFetch` reads. OAuth metadata and tokens are small. */
|
|
60
|
+
export declare const MAX_FETCH_RESPONSE_BYTES: number;
|
|
61
|
+
/** How long a `guardedFetch` request may take without a signal of its own. */
|
|
62
|
+
export declare const FETCH_TIMEOUT_MS = 30000;
|
|
63
|
+
/**
|
|
64
|
+
* A `fetch` under the Host's address policy (F9 C2): the literal-IP and DNS checks of `post`
|
|
65
|
+
* on the address actually connected to, `localhost` rewritten for the local stack, no
|
|
66
|
+
* redirects (`redirect: "error"`; a 3xx answer rejects), a bounded answer and a 30 s timeout
|
|
67
|
+
* unless the caller passes a signal. The gateway calls OAuth discovery, registration, the code
|
|
68
|
+
* exchange and refresh with it. A refused URL or address rejects with `OutboundRefused`, any
|
|
69
|
+
* other failure with `OutboundFailed`.
|
|
70
|
+
*/
|
|
71
|
+
export declare function guardedFetch(policy: OutboundPolicy, options?: {
|
|
72
|
+
maxResponseBytes?: number;
|
|
73
|
+
timeoutMs?: number;
|
|
74
|
+
}): typeof fetch;
|
package/dist/tenant/outbound.js
CHANGED
|
@@ -53,18 +53,14 @@ export function refusal(url, policy) {
|
|
|
53
53
|
return "This Runtime does not call private addresses; expose the endpoint through a public URL (a tunnel, for a local app)";
|
|
54
54
|
return undefined;
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
const { policy, signal } = options;
|
|
59
|
-
const limit = options.maxResponseBytes ?? MAX_RESPONSE_BYTES;
|
|
60
|
-
const url = new URL(target);
|
|
61
|
-
const refused = refusal(url, policy);
|
|
62
|
-
if (refused)
|
|
63
|
-
return { kind: "not_sent", code: "ENDPOINT_ADDRESS_REFUSED", message: refused };
|
|
64
|
-
const originalHost = url.host;
|
|
56
|
+
/** `localhost` means the machine that runs Docker (`OutboundPolicy.loopback`). */
|
|
57
|
+
function dockerHost(url, policy) {
|
|
65
58
|
if (policy.loopback === "docker-host" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
|
|
66
59
|
url.hostname = "host.docker.internal";
|
|
67
|
-
|
|
60
|
+
}
|
|
61
|
+
/** A DNS lookup that drops private answers when `policy` refuses them: checked on what is connected to. */
|
|
62
|
+
function guardedLookup(policy) {
|
|
63
|
+
return (hostname, lookupOptions, callback) => dnsLookup(hostname, { ...lookupOptions, all: true }, (error, addresses) => {
|
|
68
64
|
if (error)
|
|
69
65
|
return callback(error, []);
|
|
70
66
|
const allowed = policy.privateAddresses === "refuse"
|
|
@@ -76,6 +72,18 @@ export async function post(target, body, headers, options) {
|
|
|
76
72
|
return callback(null, allowed);
|
|
77
73
|
return callback(null, allowed[0].address, allowed[0].family);
|
|
78
74
|
});
|
|
75
|
+
}
|
|
76
|
+
/** POSTs `body` to `url` under `policy`. Never throws; `signal` aborts the request. */
|
|
77
|
+
export async function post(target, body, headers, options) {
|
|
78
|
+
const { policy, signal } = options;
|
|
79
|
+
const limit = options.maxResponseBytes ?? MAX_RESPONSE_BYTES;
|
|
80
|
+
const url = new URL(target);
|
|
81
|
+
const refused = refusal(url, policy);
|
|
82
|
+
if (refused)
|
|
83
|
+
return { kind: "not_sent", code: "ENDPOINT_ADDRESS_REFUSED", message: refused };
|
|
84
|
+
const originalHost = url.host;
|
|
85
|
+
dockerHost(url, policy);
|
|
86
|
+
const lookup = guardedLookup(policy);
|
|
79
87
|
return new Promise((resolve) => {
|
|
80
88
|
let sent = false;
|
|
81
89
|
let settled = false;
|
|
@@ -125,3 +133,127 @@ export async function post(target, body, headers, options) {
|
|
|
125
133
|
request.end(body);
|
|
126
134
|
});
|
|
127
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* A refusal of `guardedFetch`: the Host's policy forbids the URL or every address it resolves
|
|
138
|
+
* to. Not a `TypeError`, so callers that read a `TypeError` as a CORS failure (the MCP SDK's
|
|
139
|
+
* discovery) see the refusal instead of trying again.
|
|
140
|
+
*/
|
|
141
|
+
export class OutboundRefused extends Error {
|
|
142
|
+
code = "ENDPOINT_ADDRESS_REFUSED";
|
|
143
|
+
constructor(message) {
|
|
144
|
+
super(message);
|
|
145
|
+
this.name = "OutboundRefused";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** A request `guardedFetch` sent that failed: refused connection, reset, timeout, redirect. */
|
|
149
|
+
export class OutboundFailed extends Error {
|
|
150
|
+
code;
|
|
151
|
+
constructor(message, code) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.code = code;
|
|
154
|
+
this.name = "OutboundFailed";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The largest answer `guardedFetch` reads. OAuth metadata and tokens are small. */
|
|
158
|
+
export const MAX_FETCH_RESPONSE_BYTES = 1024 * 1024;
|
|
159
|
+
/** How long a `guardedFetch` request may take without a signal of its own. */
|
|
160
|
+
export const FETCH_TIMEOUT_MS = 30_000;
|
|
161
|
+
/**
|
|
162
|
+
* A `fetch` under the Host's address policy (F9 C2): the literal-IP and DNS checks of `post`
|
|
163
|
+
* on the address actually connected to, `localhost` rewritten for the local stack, no
|
|
164
|
+
* redirects (`redirect: "error"`; a 3xx answer rejects), a bounded answer and a 30 s timeout
|
|
165
|
+
* unless the caller passes a signal. The gateway calls OAuth discovery, registration, the code
|
|
166
|
+
* exchange and refresh with it. A refused URL or address rejects with `OutboundRefused`, any
|
|
167
|
+
* other failure with `OutboundFailed`.
|
|
168
|
+
*/
|
|
169
|
+
export function guardedFetch(policy, options = {}) {
|
|
170
|
+
const limit = options.maxResponseBytes ?? MAX_FETCH_RESPONSE_BYTES;
|
|
171
|
+
const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
172
|
+
const lookup = guardedLookup(policy);
|
|
173
|
+
const guarded = async (input, init = {}) => {
|
|
174
|
+
const request = input instanceof Request ? input : undefined;
|
|
175
|
+
const url = new URL(request ? request.url : String(input));
|
|
176
|
+
const refused = refusal(url, policy);
|
|
177
|
+
if (refused)
|
|
178
|
+
throw new OutboundRefused(refused);
|
|
179
|
+
const method = (init.method ?? request?.method ?? "GET").toUpperCase();
|
|
180
|
+
const headers = new Headers(request?.headers);
|
|
181
|
+
for (const [name, value] of new Headers(init.headers))
|
|
182
|
+
headers.set(name, value);
|
|
183
|
+
let body;
|
|
184
|
+
const source = init.body ?? (request && method !== "GET" && method !== "HEAD" ? await request.arrayBuffer() : undefined);
|
|
185
|
+
if (source !== undefined && source !== null) {
|
|
186
|
+
if (typeof source === "string")
|
|
187
|
+
body = Buffer.from(source, "utf8");
|
|
188
|
+
else if (source instanceof URLSearchParams) {
|
|
189
|
+
body = Buffer.from(source.toString(), "utf8");
|
|
190
|
+
if (!headers.has("content-type"))
|
|
191
|
+
headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
|
|
192
|
+
}
|
|
193
|
+
else if (source instanceof ArrayBuffer)
|
|
194
|
+
body = Buffer.from(source);
|
|
195
|
+
else if (ArrayBuffer.isView(source))
|
|
196
|
+
body = Buffer.from(source.buffer, source.byteOffset, source.byteLength);
|
|
197
|
+
else
|
|
198
|
+
throw new OutboundFailed("guardedFetch sends only string, form or byte bodies", "UNSUPPORTED_BODY");
|
|
199
|
+
}
|
|
200
|
+
const originalHost = url.host;
|
|
201
|
+
dockerHost(url, policy);
|
|
202
|
+
const signal = init.signal ?? request?.signal ?? AbortSignal.timeout(timeoutMs);
|
|
203
|
+
const outgoing = { host: originalHost };
|
|
204
|
+
for (const [name, value] of headers)
|
|
205
|
+
outgoing[name] = value;
|
|
206
|
+
if (body)
|
|
207
|
+
outgoing["content-length"] = String(body.byteLength);
|
|
208
|
+
return await new Promise((resolve, reject) => {
|
|
209
|
+
let settled = false;
|
|
210
|
+
const settle = (fn) => {
|
|
211
|
+
if (settled)
|
|
212
|
+
return;
|
|
213
|
+
settled = true;
|
|
214
|
+
fn();
|
|
215
|
+
};
|
|
216
|
+
const failed = (error) => settle(() => reject(error instanceof RefusedAddress
|
|
217
|
+
? new OutboundRefused(error.message)
|
|
218
|
+
: new OutboundFailed(signal.aborted ? `The request to ${originalHost} was aborted or timed out` : error.message, signal.aborted ? "ABORTED" : (error.code ?? error.name))));
|
|
219
|
+
const sent = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, { method, headers: outgoing, lookup: lookup, signal }, (response) => {
|
|
220
|
+
const status = response.statusCode ?? 0;
|
|
221
|
+
if (status >= 300 && status < 400 && response.headers.location !== undefined) {
|
|
222
|
+
response.resume();
|
|
223
|
+
return settle(() => reject(new OutboundFailed(`${originalHost} answered a redirect, which this Runtime does not follow`, "REDIRECT")));
|
|
224
|
+
}
|
|
225
|
+
const chunks = [];
|
|
226
|
+
let size = 0;
|
|
227
|
+
response.on("data", (chunk) => {
|
|
228
|
+
size += chunk.length;
|
|
229
|
+
if (size > limit) {
|
|
230
|
+
settle(() => reject(new OutboundFailed(`The answer from ${originalHost} is larger than ${limit} bytes`, "TOO_LARGE")));
|
|
231
|
+
response.destroy();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
chunks.push(chunk);
|
|
235
|
+
});
|
|
236
|
+
response.on("end", () => settle(() => {
|
|
237
|
+
const answer = new Headers();
|
|
238
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
239
|
+
if (value === undefined)
|
|
240
|
+
continue;
|
|
241
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
242
|
+
answer.append(name, item);
|
|
243
|
+
}
|
|
244
|
+
const noBody = status === 204 || status === 304 || method === "HEAD";
|
|
245
|
+
resolve(new Response(noBody ? null : Buffer.concat(chunks), {
|
|
246
|
+
status,
|
|
247
|
+
statusText: response.statusMessage ?? "",
|
|
248
|
+
headers: answer,
|
|
249
|
+
}));
|
|
250
|
+
}));
|
|
251
|
+
response.on("error", failed);
|
|
252
|
+
response.on("aborted", () => failed(Object.assign(new Error("The answer was cut off"), { code: "ECONNRESET" })));
|
|
253
|
+
});
|
|
254
|
+
sent.on("error", failed);
|
|
255
|
+
sent.end(body);
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
return guarded;
|
|
259
|
+
}
|
package/dist/tenant/paths.d.ts
CHANGED
|
@@ -16,7 +16,8 @@ export declare function hostPaths(hostRoot: string): HostPaths;
|
|
|
16
16
|
* root after realpath.
|
|
17
17
|
*
|
|
18
18
|
* The directory holds what stays on the Host (the Tenant's data is its database): `home/`,
|
|
19
|
-
* `tmp/`, `sandboxes/`, `plugin-data/`, `
|
|
19
|
+
* `tmp/`, `sandboxes/`, `plugin-data/`, `blobs/` (the `fs` BlobStore, when the Host has no
|
|
20
|
+
* Object store), `logs/tenant.log`. The vault key is the one exception:
|
|
20
21
|
* it lives beside the Tenant directory, in `<hostRoot>/keys/vault-kek`, which the local stack
|
|
21
22
|
* mounts only into the gateway (F4.2), never into the runtime container.
|
|
22
23
|
*/
|