@nylorun/runtime 0.16.0-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.
Files changed (120) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +39 -27
  3. package/dist/admin-openapi.json +307 -7
  4. package/dist/api/ag-ui/endpoint.js +1 -2
  5. package/dist/api/ag-ui/routes.d.ts +1 -1
  6. package/dist/api/ag-ui/routes.js +10 -12
  7. package/dist/api/components.d.ts +54 -116
  8. package/dist/api/components.js +8 -11
  9. package/dist/api/http/app.js +2 -0
  10. package/dist/api/http/define.d.ts +9 -12
  11. package/dist/api/http/define.js +11 -26
  12. package/dist/api/http/routes/access.d.ts +3 -19
  13. package/dist/api/http/routes/access.js +12 -172
  14. package/dist/api/http/routes/actions.js +0 -1
  15. package/dist/api/http/routes/artifacts.js +1 -3
  16. package/dist/api/http/routes/me.d.ts +13 -0
  17. package/dist/api/http/routes/me.js +45 -0
  18. package/dist/api/http/routes/sandboxes.d.ts +2 -2
  19. package/dist/api/http/routes/sandboxes.js +1 -3
  20. package/dist/api/http/routes/sessions.js +6 -12
  21. package/dist/api/http/routes/vaults.d.ts +12 -3
  22. package/dist/api/http/routes/vaults.js +122 -54
  23. package/dist/api/openapi.js +4 -10
  24. package/dist/artifacts/links.js +6 -6
  25. package/dist/artifacts/service.js +1 -1
  26. package/dist/gates/mcp-handler.js +5 -1
  27. package/dist/gates/tenant-vaults.d.ts +18 -9
  28. package/dist/gates/tenant-vaults.js +17 -12
  29. package/dist/host/admin-api.d.ts +12 -5
  30. package/dist/host/admin-api.js +72 -6
  31. package/dist/host/app.d.ts +0 -1
  32. package/dist/host/app.js +13 -23
  33. package/dist/host/config-for.d.ts +4 -0
  34. package/dist/host/config-for.js +2 -0
  35. package/dist/host/config.d.ts +0 -6
  36. package/dist/host/create-host.d.ts +3 -10
  37. package/dist/host/create-host.js +4 -1
  38. package/dist/host/gates.js +6 -1
  39. package/dist/host/main.js +32 -10
  40. package/dist/host/stack-config.d.ts +11 -8
  41. package/dist/host/stack-config.js +23 -12
  42. package/dist/index.d.ts +3 -0
  43. package/dist/index.js +2 -0
  44. package/dist/keys/client.js +9 -1
  45. package/dist/keys/keys.d.ts +12 -2
  46. package/dist/keys/keys.js +8 -0
  47. package/dist/mcp/connect.js +2 -1
  48. package/dist/mcp/pool.d.ts +1 -0
  49. package/dist/mcp/pool.js +1 -0
  50. package/dist/openapi.json +2359 -4096
  51. package/dist/store/postgres/drizzle/0008_installation_vaults.sql +2 -0
  52. package/dist/store/postgres/drizzle/0009_oauth_pending.sql +19 -0
  53. package/dist/store/postgres/drizzle/0010_oss_auth_removals.sql +3 -0
  54. package/dist/store/postgres/drizzle/meta/_journal.json +21 -0
  55. package/dist/store/postgres/schema.d.ts +194 -170
  56. package/dist/store/postgres/schema.js +46 -28
  57. package/dist/store/postgres/store.js +51 -100
  58. package/dist/store/postgres/tenant.d.ts +1 -1
  59. package/dist/store/postgres/tenant.js +2 -2
  60. package/dist/store/types.d.ts +24 -30
  61. package/dist/streams/control.d.ts +0 -5
  62. package/dist/streams/control.js +0 -8
  63. package/dist/streams/types.d.ts +2 -11
  64. package/dist/streams/types.js +1 -1
  65. package/dist/tenant/auth.d.ts +13 -9
  66. package/dist/tenant/auth.js +25 -32
  67. package/dist/tenant/commands.js +3 -7
  68. package/dist/tenant/context.d.ts +20 -22
  69. package/dist/tenant/delivery-token.js +4 -4
  70. package/dist/tenant/effects.d.ts +5 -1
  71. package/dist/tenant/effects.js +5 -16
  72. package/dist/tenant/ephemeral.d.ts +14 -2
  73. package/dist/tenant/ephemeral.js +8 -1
  74. package/dist/tenant/host-token.js +5 -5
  75. package/dist/tenant/identity-file.d.ts +75 -0
  76. package/dist/tenant/identity-file.js +202 -0
  77. package/dist/tenant/issuers.d.ts +48 -0
  78. package/dist/tenant/issuers.js +311 -0
  79. package/dist/tenant/jwt.js +3 -3
  80. package/dist/tenant/operator-keys.d.ts +18 -0
  81. package/dist/tenant/operator-keys.js +55 -0
  82. package/dist/tenant/outbound.d.ts +30 -0
  83. package/dist/tenant/outbound.js +142 -10
  84. package/dist/tenant/principals.d.ts +2 -11
  85. package/dist/tenant/principals.js +10 -30
  86. package/dist/tenant/run-token.js +3 -3
  87. package/dist/tenant/runtime.d.ts +4 -0
  88. package/dist/tenant/runtime.js +14 -1
  89. package/dist/tenant/sandboxes.d.ts +5 -5
  90. package/dist/tenant/sandboxes.js +5 -5
  91. package/dist/tenant/session-streams.d.ts +6 -10
  92. package/dist/tenant/session-streams.js +9 -27
  93. package/dist/tenant/sessions.d.ts +2 -2
  94. package/dist/tenant/sessions.js +1 -1
  95. package/dist/tenant/signing-keys.d.ts +2 -1
  96. package/dist/tenant/signing-keys.js +2 -2
  97. package/dist/tenant/streams.d.ts +2 -3
  98. package/dist/tenant/streams.js +1 -5
  99. package/dist/tenant/types.d.ts +21 -0
  100. package/dist/vault/crypto.d.ts +7 -0
  101. package/dist/vault/crypto.js +16 -0
  102. package/dist/vault/oauth.d.ts +55 -0
  103. package/dist/vault/oauth.js +186 -0
  104. package/dist/vault/service.d.ts +54 -7
  105. package/dist/vault/service.js +261 -17
  106. package/dist/vault/sources.d.ts +54 -0
  107. package/dist/vault/sources.js +185 -0
  108. package/dist/version.d.ts +1 -1
  109. package/dist/version.js +1 -1
  110. package/package.json +4 -3
  111. package/dist/host/cors.d.ts +0 -27
  112. package/dist/host/cors.js +0 -64
  113. package/dist/tenant/access-policy.d.ts +0 -30
  114. package/dist/tenant/access-policy.js +0 -52
  115. package/dist/tenant/browser.d.ts +0 -15
  116. package/dist/tenant/browser.js +0 -39
  117. package/dist/tenant/subject-limits.d.ts +0 -16
  118. package/dist/tenant/subject-limits.js +0 -45
  119. package/dist/tenant/tokens.d.ts +0 -14
  120. package/dist/tenant/tokens.js +0 -187
@@ -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;
@@ -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
- /** POSTs `body` to `url` under `policy`. Never throws; `signal` aborts the request. */
57
- export async function post(target, body, headers, options) {
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
- const lookup = (hostname, lookupOptions, callback) => dnsLookup(hostname, { ...lookupOptions, all: true }, (error, addresses) => {
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
+ }
@@ -2,23 +2,14 @@ import type { InitialPrincipal } from "../store/postgres/tenant.js";
2
2
  export type { PrincipalRow } from "../store/types.js";
3
3
  /** Principal id of the Studio key derived from the admin key. */
4
4
  export declare const STUDIO_PRINCIPAL_ID = "studio";
5
- /** The derived principal Projects use (`PROJECT_PRINCIPAL_ID` in `@nylorun/admin`). */
6
- export declare const PROJECT_PRINCIPAL_ID = "project";
7
- /** The derived principals a Host registers when not told otherwise. */
8
- export declare const DEFAULT_DERIVED_PRINCIPALS: readonly string[];
9
5
  /** The Studio key of `tenantId`: HMAC-SHA256 of the admin key. */
10
6
  export declare function deriveStudioKey(adminKey: string, tenantId: string): string;
11
- /** The key of derived principal `principalId` on `tenantId`: HMAC-SHA256 of the admin key. */
12
- export declare function deriveTenantKey(adminKey: string, tenantId: string, principalId: string): string;
13
- /** Whether `id` may name a derived principal: the client's name, never `studio`. */
14
- export declare function isDerivedPrincipalId(id: string): boolean;
15
7
  /**
16
- * The principals a Host's Tenant is created with: `studio`, each derived principal, and an
17
- * application principal when a key is given (the ephemeral Runtime). Keys are hashed here.
8
+ * The principals a Host's Tenant is created with: `studio`, and an application principal when
9
+ * a key is given (the ephemeral Runtime). Keys are hashed here.
18
10
  */
19
11
  export declare function hostPrincipals(options: {
20
12
  adminKey: string;
21
- derived?: readonly string[];
22
13
  application?: {
23
14
  principalId: string;
24
15
  key: string;
@@ -1,43 +1,27 @@
1
1
  /**
2
- * The principals the Host gives its Tenant when it creates it (tenancy.md §4, plan P9): the
3
- * Studio principal and the derived principals, whose keys the admin key derives, so the
4
- * clients holding the admin key (Studio, `nylorun start`, Babai) store none. The Tenant keeps
5
- * only their hashes.
2
+ * The principals the Host gives its Tenant when it creates it (tenancy.md §4): the Studio
3
+ * principal, whose key the admin key derives, so Studio (and an app embedding it) stores none,
4
+ * and, for the ephemeral Runtime, one application principal with a key of its own. The Tenant
5
+ * keeps only their hashes. Every other application key is an operator key
6
+ * (`PUT /v1/admin/keys/{id}`, `operator-keys.ts`); no other key is derived.
6
7
  *
7
- * The derivations are the ones `@nylorun/admin` computes on the client side
8
- * (`admin/src/derived-credentials.ts`: `deriveStudioToken`, `deriveTenantKey`); both sides
9
- * must stay byte for byte the same.
8
+ * The derivation is the one `@nylorun/admin` computes on the client side
9
+ * (`admin/src/derived-credentials.ts`: `deriveStudioToken`); both sides must stay byte for byte
10
+ * the same.
10
11
  */
11
12
  import { createHmac } from "node:crypto";
12
- import { DERIVED_PRINCIPAL_ID_PATTERN } from "@nylorun/core/compatibility";
13
13
  import { hashToken } from "../core/bearer.js";
14
14
  /** Principal id of the Studio key derived from the admin key. */
15
15
  export const STUDIO_PRINCIPAL_ID = "studio";
16
- /** The derived principal Projects use (`PROJECT_PRINCIPAL_ID` in `@nylorun/admin`). */
17
- export const PROJECT_PRINCIPAL_ID = "project";
18
- /** The derived principals a Host registers when not told otherwise. */
19
- export const DEFAULT_DERIVED_PRINCIPALS = [PROJECT_PRINCIPAL_ID];
20
16
  /** The Studio key of `tenantId`: HMAC-SHA256 of the admin key. */
21
17
  export function deriveStudioKey(adminKey, tenantId) {
22
18
  return hmac(adminKey, ["nylorun/studio/v1", tenantId]);
23
19
  }
24
- /** The key of derived principal `principalId` on `tenantId`: HMAC-SHA256 of the admin key. */
25
- export function deriveTenantKey(adminKey, tenantId, principalId) {
26
- return hmac(adminKey, ["nylorun/principal/v1", principalId, tenantId]);
27
- }
28
- /** Whether `id` may name a derived principal: the client's name, never `studio`. */
29
- export function isDerivedPrincipalId(id) {
30
- return DERIVED_PRINCIPAL_ID_PATTERN.test(id) && id !== STUDIO_PRINCIPAL_ID;
31
- }
32
20
  /**
33
- * The principals a Host's Tenant is created with: `studio`, each derived principal, and an
34
- * application principal when a key is given (the ephemeral Runtime). Keys are hashed here.
21
+ * The principals a Host's Tenant is created with: `studio`, and an application principal when
22
+ * a key is given (the ephemeral Runtime). Keys are hashed here.
35
23
  */
36
24
  export function hostPrincipals(options) {
37
- const derived = options.derived ?? DEFAULT_DERIVED_PRINCIPALS;
38
- for (const id of derived)
39
- if (!isDerivedPrincipalId(id))
40
- throw new Error(`Derived principal id '${id}' must match ${DERIVED_PRINCIPAL_ID_PATTERN} and not be '${STUDIO_PRINCIPAL_ID}'`);
41
25
  return (tenantId) => [
42
26
  ...(options.application
43
27
  ? [
@@ -52,10 +36,6 @@ export function hostPrincipals(options) {
52
36
  credentialHash: options.studioCredentialHash ??
53
37
  hashToken(deriveStudioKey(options.adminKey, tenantId)),
54
38
  },
55
- ...[...new Set(derived)].map((id) => ({
56
- id,
57
- credentialHash: hashToken(deriveTenantKey(options.adminKey, tenantId, id)),
58
- })),
59
39
  ];
60
40
  }
61
41
  /** HMAC-SHA256 (hex) of `parts` joined by NUL bytes, keyed with the admin key. */
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { errors, importJWK, jwtVerify } from "jose";
20
- import { subjectTokenIssuer } from "@nylorun/core/contracts";
20
+ import { tenantTokenIssuer } from "@nylorun/core/contracts";
21
21
  import { CLOCK_TOLERANCE_SECONDS, tokenKeyId } from "./jwt.js";
22
22
  export const RUN_TOKEN_TYP = "nylorun-run+jwt";
23
23
  export const RUN_TOKEN_AUD = "nylorun-gates";
@@ -42,7 +42,7 @@ export async function mintRunToken(signer, lease, session) {
42
42
  const { token } = await signer.keys.sign({
43
43
  typ: RUN_TOKEN_TYP,
44
44
  claims: {
45
- iss: subjectTokenIssuer(tenantId),
45
+ iss: tenantTokenIssuer(tenantId),
46
46
  aud: RUN_TOKEN_AUD,
47
47
  sub: lease.sessionId,
48
48
  trn: turnId,
@@ -89,7 +89,7 @@ export async function verifyRunToken(store, tenantId, raw, cache = new Map()) {
89
89
  try {
90
90
  const verified = await jwtVerify(raw, key, {
91
91
  algorithms: ["ES256"],
92
- issuer: subjectTokenIssuer(tenantId),
92
+ issuer: tenantTokenIssuer(tenantId),
93
93
  audience: RUN_TOKEN_AUD,
94
94
  typ: RUN_TOKEN_TYP,
95
95
  clockTolerance: CLOCK_TOLERANCE_SECONDS,
@@ -14,6 +14,7 @@ import type { HarnessChannel, MemoryPortsOptions } from "@nylorun/core/harness-a
14
14
  import { type HarnessPeer } from "../harness-api/server.js";
15
15
  import { type HostAuthority } from "../sandbox/join.js";
16
16
  import type { TenantPods } from "./context.js";
17
+ import { type OperatorKeys } from "./operator-keys.js";
17
18
  /** TENANTS-CCR: test/injection hooks until TenantConfig gains them. */
18
19
  export type TenantOpenHooks = {
19
20
  modelProvider?: ModelProvider;
@@ -102,6 +103,7 @@ export declare class TenantRuntime implements TenantHandle {
102
103
  authorize(sessionId: string, request: {
103
104
  url: string;
104
105
  serverName?: string;
106
+ agentId?: string;
105
107
  }): Promise<AuthorizeResult>;
106
108
  summary(): Promise<TenantSummary>;
107
109
  drain(activeWork: "drain" | "cancel", timeoutMs?: number): Promise<void>;
@@ -118,6 +120,8 @@ export declare class TenantRuntime implements TenantHandle {
118
120
  /** Adds a callback to the Tenant sweep. Returns a function that removes it. */
119
121
  onSweep(hook: () => Promise<void>): () => void;
120
122
  attachHarness(channel: HarnessChannel, peer: HarnessPeer): () => void;
123
+ /** The Tenant's operator keys (F9 I1), over its store. */
124
+ operatorKeys(): OperatorKeys;
121
125
  close(): Promise<void>;
122
126
  }
123
127
  /**
@@ -5,6 +5,7 @@ import { tenantModelGate } from "../gates/in-process.js";
5
5
  import { createKekFile, readVaultKek } from "../vault/kek.js";
6
6
  import { SigningKeys } from "./signing-keys.js";
7
7
  import { VaultService } from "../vault/service.js";
8
+ import { CredentialSources } from "../vault/sources.js";
8
9
  import { McpPool } from "../mcp/pool.js";
9
10
  import { SandboxManager } from "../sandbox/manager.js";
10
11
  import { storeSandboxRecords } from "../sandbox/records.js";
@@ -21,6 +22,7 @@ import { deliverAction } from "./delivery.js";
21
22
  import { TenantWorkers, WORKER_ID, } from "./worker.js";
22
23
  import { authorize } from "./effects.js";
23
24
  import { inProcessToolGate } from "../gates/tool-gate.js";
25
+ import { guardedFetch } from "./outbound.js";
24
26
  import { inProcessKeys } from "../keys/keys.js";
25
27
  import { tenantApi } from "../api/http/app.js";
26
28
  import { createFsBlobStore } from "../blob/index.js";
@@ -31,6 +33,7 @@ import { localWorkspace, remoteWorkspace, withPodWorkspaces, } from "../harness-
31
33
  import { hostAuthority } from "../sandbox/join.js";
32
34
  import { reconcileSandbox } from "../sandbox/pods/reconcile.js";
33
35
  import { sandboxWorkspaceReader } from "../artifacts/workspace.js";
36
+ import { operatorKeys } from "./operator-keys.js";
34
37
  /** Default ownership lease of an advance; the heartbeat renews it every third. */
35
38
  const DEFAULT_OWNER_LEASE_MS = 30_000;
36
39
  function validateConfig(config) {
@@ -119,7 +122,12 @@ export class TenantRuntime {
119
122
  const vault = new VaultService({
120
123
  store: opened,
121
124
  kek: ensureKek,
122
- fetch: config.vaultFetch ?? globalThis.fetch,
125
+ // OAuth refresh and connect follow the Host's address policy (F9 C2).
126
+ fetch: config.vaultFetch ?? guardedFetch(config.delivery ?? {}),
127
+ });
128
+ const credentials = new CredentialSources({
129
+ vault,
130
+ ...(config.resolver ? { resolver: config.resolver } : {}),
123
131
  });
124
132
  // `ctx` is assigned below; these callbacks only run once the Tenant is open.
125
133
  let ctx;
@@ -224,6 +232,7 @@ export class TenantRuntime {
224
232
  envelope,
225
233
  store: opened,
226
234
  vault,
235
+ credentials,
227
236
  ...(mcp ? { mcp } : {}),
228
237
  sandbox,
229
238
  flowLimits,
@@ -359,6 +368,10 @@ export class TenantRuntime {
359
368
  attachHarness(channel, peer) {
360
369
  return this.ctx.harness.attach(channel, peer);
361
370
  }
371
+ /** The Tenant's operator keys (F9 I1), over its store. */
372
+ operatorKeys() {
373
+ return operatorKeys(this.ctx.store);
374
+ }
362
375
  async close() {
363
376
  const ctx = this.ctx;
364
377
  ctx.closing = true;
@@ -7,9 +7,9 @@
7
7
  * sandbox's id, kind, spec and labels, and the session body names the sandbox it is attached
8
8
  * to (`Session.sandboxId`); deleting a session (a sessions reset) only detaches it.
9
9
  * - **Access.** An application key reaches every sandbox, and so does an application key acting
10
- * for a subject (`Nylorun-Subject`), which needs `sandboxes:write` to change one. A subject
11
- * token reaches only the ids its `sbx` grants match, and changes them only with
12
- * `sandboxes:write`; any other id is the 404 of a missing one.
10
+ * for a subject (`Nylorun-Subject`), which needs `sandboxes:write` to change one. A token
11
+ * caller (a trusted issuer's token) reaches only the ids its grants match, and changes them
12
+ * only with `sandboxes:write`; any other id is the 404 of a missing one.
13
13
  * - **Serial turns.** One turn at a time per sandbox: a session whose sandbox another attached
14
14
  * session's turn holds is refused (`409 sandbox_busy`), as the session sandbox route is
15
15
  * refused while its session's turn runs. Commands within the workspace keep queueing in the
@@ -31,7 +31,7 @@
31
31
  import { type PutSandboxRequest, type SandboxView } from "@nylorun/core/contracts";
32
32
  import type { SandboxResource, Tx } from "../store/types.js";
33
33
  import type { AuthScope, Session, TenantContext } from "./context.js";
34
- /** The `sbx` grants that limit `scope`, or undefined when it reaches every sandbox. */
34
+ /** The sandbox grants that limit `scope`, or undefined when it reaches every sandbox. */
35
35
  export declare function sandboxGrantsOf(scope: AuthScope): readonly string[] | undefined;
36
36
  /** A sandbox resource, unless it is gone or being deleted. */
37
37
  export declare function liveSandbox(t: Tx, id: string, options?: {
@@ -95,7 +95,7 @@ export declare function attachSandbox(t: Tx, id: string, grants: readonly string
95
95
  export declare function recordAttachment(t: Tx, sessionId: string, sandboxId: string): Promise<void>;
96
96
  /**
97
97
  * Checks a turn start on a session attached to a sandbox, in the command's transaction after
98
- * the session row lock: the sandbox still exists, a subject token's `sbx` grants reach it, and
98
+ * the session row lock: the sandbox still exists, a token caller's grants reach it, and
99
99
  * no other session attached to it has a turn running. Holds the sandbox row's lock until the
100
100
  * transaction ends, so two turn starts on one sandbox are decided one after the other.
101
101
  *
@@ -7,9 +7,9 @@
7
7
  * sandbox's id, kind, spec and labels, and the session body names the sandbox it is attached
8
8
  * to (`Session.sandboxId`); deleting a session (a sessions reset) only detaches it.
9
9
  * - **Access.** An application key reaches every sandbox, and so does an application key acting
10
- * for a subject (`Nylorun-Subject`), which needs `sandboxes:write` to change one. A subject
11
- * token reaches only the ids its `sbx` grants match, and changes them only with
12
- * `sandboxes:write`; any other id is the 404 of a missing one.
10
+ * for a subject (`Nylorun-Subject`), which needs `sandboxes:write` to change one. A token
11
+ * caller (a trusted issuer's token) reaches only the ids its grants match, and changes them
12
+ * only with `sandboxes:write`; any other id is the 404 of a missing one.
13
13
  * - **Serial turns.** One turn at a time per sandbox: a session whose sandbox another attached
14
14
  * session's turn holds is refused (`409 sandbox_busy`), as the session sandbox route is
15
15
  * refused while its session's turn runs. Commands within the workspace keep queueing in the
@@ -38,7 +38,7 @@ import { expiresAtOf, podLifecycleConfig } from "../sandbox/pods/spec.js";
38
38
  import { requirePods } from "../sandbox/placement.js";
39
39
  import { parseSandboxDuration } from "@nylorun/core/define";
40
40
  import { fail } from "./http.js";
41
- /** The `sbx` grants that limit `scope`, or undefined when it reaches every sandbox. */
41
+ /** The sandbox grants that limit `scope`, or undefined when it reaches every sandbox. */
42
42
  export function sandboxGrantsOf(scope) {
43
43
  return scope.kind === "token" ? (scope.sandboxes ?? []) : undefined;
44
44
  }
@@ -435,7 +435,7 @@ export async function recordAttachment(t, sessionId, sandboxId) {
435
435
  }
436
436
  /**
437
437
  * Checks a turn start on a session attached to a sandbox, in the command's transaction after
438
- * the session row lock: the sandbox still exists, a subject token's `sbx` grants reach it, and
438
+ * the session row lock: the sandbox still exists, a token caller's grants reach it, and
439
439
  * no other session attached to it has a turn running. Holds the sandbox row's lock until the
440
440
  * transaction ends, so two turn starts on one sandbox are decided one after the other.
441
441
  *
@@ -20,23 +20,24 @@
20
20
  */
21
21
  import type { IncomingMessage, ServerResponse } from "node:http";
22
22
  import type { LiveEvent } from "@nylorun/core/contracts";
23
- import type { TenantContext } from "./context.js";
23
+ import type { AuthScope, TenantContext } from "./context.js";
24
24
  import type { StreamsWiring } from "./streams.js";
25
- /** Why the Runtime ended a stream a subject token opened. */
26
- export type StreamEndReason = "token_expired" | "revoked";
25
+ /** Why the Runtime ended a stream a token opened. */
26
+ export type StreamEndReason = "token_expired";
27
27
  /** Where an observer's events go: an SSE response, or an in-process reader. */
28
28
  export interface ObserverSink {
29
29
  write(event: LiveEvent): void;
30
30
  /** Ends the stream; with a reason, the client is told why before it ends. */
31
31
  end(reason?: StreamEndReason): void;
32
32
  }
33
- /** The subject token a stream was opened with: it ends at expiry or revocation. */
33
+ /** The token a stream was opened with (a trusted issuer's): the stream ends at its expiry. */
34
34
  export interface StreamHolder {
35
35
  readonly subject: string;
36
- readonly epoch: number;
37
36
  /** Epoch ms when the token expires. */
38
37
  readonly expiresAt: number;
39
38
  }
39
+ /** The holder a stream opened with `scope` records: token callers only. */
40
+ export declare function streamHolderOf(scope: AuthScope): StreamHolder | undefined;
40
41
  /** One client of a session: the next sequence it needs. */
41
42
  interface Observer {
42
43
  readonly sink: ObserverSink;
@@ -114,11 +115,6 @@ export declare function served(event: LiveEvent): boolean;
114
115
  * the Tenant end too.
115
116
  */
116
117
  export declare function checkSessionStreams(ctx: TenantContext): Promise<void>;
117
- /**
118
- * Ends the streams of `subject` opened with a token older than `epoch` (a revocation), on
119
- * this process. Other processes do the same on the `subject.revoked` signal.
120
- */
121
- export declare function endSubjectStreams(hub: SessionStreams, subject: string, epoch: number): void;
122
118
  /** Reset or close: end every session observer and stop every shared read. */
123
119
  export declare function clearObservers(hub: SessionStreams): void;
124
120
  /** Close: end every session observer. */
@@ -2,6 +2,12 @@ import { decodeCursor, encodeCursor } from "../record/index.js";
2
2
  import { basinOf } from "../streams/basin.js";
3
3
  import { sessionStream, } from "../streams/types.js";
4
4
  import { fail } from "./http.js";
5
+ /** The holder a stream opened with `scope` records: token callers only. */
6
+ export function streamHolderOf(scope) {
7
+ if (scope.kind !== "token")
8
+ return undefined;
9
+ return { subject: scope.subject, expiresAt: scope.expiresAt };
10
+ }
5
11
  export function createSessionStreams() {
6
12
  return { wiring: undefined, generation: 0, sessions: new Map() };
7
13
  }
@@ -233,7 +239,7 @@ async function join(ctx, sessionId, observer) {
233
239
  feed.observers.add(observer);
234
240
  return feed;
235
241
  }
236
- /** Ends one observer early (its token expired or was revoked). */
242
+ /** Ends one observer early (its token expired). */
237
243
  function endObserver(hub, feed, observer, reason) {
238
244
  if (!feed.observers.has(observer))
239
245
  return;
@@ -328,24 +334,12 @@ async function stillCurrent(ctx, feed) {
328
334
  */
329
335
  export async function checkSessionStreams(ctx) {
330
336
  const feeds = [...ctx.sessionStreams.sessions.values()];
331
- // Also a backstop for lost `subject.revoked` signals: the holders' current epochs.
332
- const subjects = new Set();
333
- for (const feed of feeds)
334
- for (const observer of feed.observers)
335
- if (observer.holder)
336
- subjects.add(observer.holder.subject);
337
- const { exists, epochs, generation } = await ctx.store.tx(async (t) => {
337
+ const { exists, generation } = await ctx.store.tx(async (t) => {
338
338
  const exists = new Set();
339
339
  for (const feed of feeds)
340
340
  if (await t.get("sessions", feed.sessionId))
341
341
  exists.add(feed.sessionId);
342
- return {
343
- exists,
344
- generation: (await t.basinGenerations()).current,
345
- epochs: subjects.size === 0
346
- ? new Map()
347
- : await t.subjectEpochs([...subjects]),
348
- };
342
+ return { exists, generation: (await t.basinGenerations()).current };
349
343
  });
350
344
  if (generation !== ctx.sessionStreams.generation)
351
345
  ctx.sessionStreams.wiring?.moveTo(generation);
@@ -355,18 +349,6 @@ export async function checkSessionStreams(ctx) {
355
349
  ? { basin: currentBasin(ctx), stream: sessionStream(feed.sessionId) }
356
350
  : undefined))
357
351
  endFeed(ctx.sessionStreams, feed);
358
- for (const [subject, epoch] of epochs)
359
- endSubjectStreams(ctx.sessionStreams, subject, epoch);
360
- }
361
- /**
362
- * Ends the streams of `subject` opened with a token older than `epoch` (a revocation), on
363
- * this process. Other processes do the same on the `subject.revoked` signal.
364
- */
365
- export function endSubjectStreams(hub, subject, epoch) {
366
- for (const feed of [...hub.sessions.values()])
367
- for (const observer of [...feed.observers])
368
- if (observer.holder?.subject === subject && observer.holder.epoch < epoch)
369
- endObserver(hub, feed, observer, "revoked");
370
352
  }
371
353
  function endFeed(hub, feed) {
372
354
  feed.read.abort();
@@ -15,7 +15,7 @@ export declare function listDefinitions(ctx: TenantContext): Promise<{
15
15
  }[];
16
16
  }>;
17
17
  /**
18
- * The agents a subject token or publishable key may see: id, name and description only, never
18
+ * The agents a token caller may see: id, name and description only, never
19
19
  * instructions, tools or MCP servers, and only the agents allowed.
20
20
  */
21
21
  export declare function listAgentsPublic(ctx: TenantContext, agents: ReadonlySet<string> | "*"): Promise<{
@@ -60,7 +60,7 @@ export declare function putSession(ctx: TenantContext, id: string, body: PutSess
60
60
  */
61
61
  createOnly?: boolean;
62
62
  /**
63
- * The sandboxes the caller reaches (a subject token's `sbx`, `sandboxGrantsOf`); undefined
63
+ * The sandboxes the caller reaches (a token caller's grants, `sandboxGrantsOf`); undefined
64
64
  * reaches every one. `sandbox: { id }` must name one of them.
65
65
  */
66
66
  sandboxGrants?: readonly string[];
@@ -33,7 +33,7 @@ export async function listDefinitions(ctx) {
33
33
  };
34
34
  }
35
35
  /**
36
- * The agents a subject token or publishable key may see: id, name and description only, never
36
+ * The agents a token caller may see: id, name and description only, never
37
37
  * instructions, tools or MCP servers, and only the agents allowed.
38
38
  */
39
39
  export async function listAgentsPublic(ctx, agents) {
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Tenant signing keys for subject tokens. Each key is an ES256 pair: the public JWK is stored
2
+ * Tenant signing keys for the tokens the Runtime signs itself (delivery tokens, capability
3
+ * links, run and host tokens). Each key is an ES256 pair: the public JWK is stored
3
4
  * as is, the PKCS#8 private key is sealed with the vault KEK (AAD bound to the Tenant and key
4
5
  * id), like a credential.
5
6
  *
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Tenant signing keys for subject tokens. Each key is an ES256 pair: the public JWK is stored
2
+ * Tenant signing keys for the tokens the Runtime signs itself (delivery tokens, capability
3
+ * links, run and host tokens). Each key is an ES256 pair: the public JWK is stored
3
4
  * as is, the PKCS#8 private key is sealed with the vault KEK (AAD bound to the Tenant and key
4
5
  * id), like a credential.
5
6
  *
@@ -117,7 +118,6 @@ export class SigningKeys {
117
118
  const [previous] = await t.signingKeys(["previous"]);
118
119
  if (previous && !force) {
119
120
  const retired = Date.parse(previous.retiredAt ?? previous.createdAt);
120
- // Delivery tokens may outlive the policy's subject tokens.
121
121
  const longest = Math.max(maxTtlSeconds, DELIVERY_TOKEN_MAX_TTL_SECONDS);
122
122
  const until = retired + longest * 1000 + ROTATION_GRACE_MS;
123
123
  if (until > now.getTime())
@@ -17,9 +17,8 @@
17
17
  * - **Readers.** History and session SSE read the session's stream
18
18
  * (`tenant/session-streams.ts`). This module runs one `tenant/control` reader per Tenant per
19
19
  * process, on the current basin, which calls `ctx.abortLocal` for each `session.cancel`,
20
- * passes an Action's outcome to a run held here for each `action.resolved` (F6.2),
21
- * checks the session streams for each `sessions.reset`, and ends a subject's streams for
22
- * each `subject.revoked`.
20
+ * passes an Action's outcome to a run held here for each `action.resolved` (F6.2), and
21
+ * checks the session streams for each `sessions.reset`.
23
22
  *
24
23
  * The caller passes the streams: the Host's S2 streams, or `MemoryStreams` for tests and a
25
24
  * local development Host (not durable).