@nylorun/runtime 0.21.0-beta → 0.22.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 (66) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +4 -2
  3. package/dist/adapters/execution/restate.js +26 -1
  4. package/dist/adapters/sandbox/local.js +1 -1
  5. package/dist/adapters/sandbox/virtual.js +1 -1
  6. package/dist/api/components.d.ts +42 -2
  7. package/dist/core/flow-host.d.ts +2 -3
  8. package/dist/core/flow-host.js +10 -7
  9. package/dist/execution/types.d.ts +13 -5
  10. package/dist/execution/types.js +13 -5
  11. package/dist/gates/egress-credentials.d.ts +48 -0
  12. package/dist/gates/egress-credentials.js +147 -0
  13. package/dist/gates/egress.d.ts +3 -0
  14. package/dist/gates/egress.js +14 -1
  15. package/dist/gates/tenant-vaults.d.ts +4 -0
  16. package/dist/gates/tenant-vaults.js +2 -0
  17. package/dist/harness/calls.d.ts +2 -0
  18. package/dist/harness/calls.js +1 -0
  19. package/dist/harness/executors.js +1 -0
  20. package/dist/harness/main.js +4 -1
  21. package/dist/harness/pod.d.ts +2 -0
  22. package/dist/harness/pod.js +36 -1
  23. package/dist/harness-api/start.d.ts +2 -0
  24. package/dist/harness-api/start.js +4 -2
  25. package/dist/harness-api/ws-server.js +2 -2
  26. package/dist/host/create-host.d.ts +1 -1
  27. package/dist/host/create-host.js +2 -1
  28. package/dist/host/gates.js +11 -0
  29. package/dist/host/http.d.ts +0 -1
  30. package/dist/host/http.js +0 -6
  31. package/dist/host/stack-config.d.ts +2 -0
  32. package/dist/host/stack-config.js +42 -73
  33. package/dist/keys/egress-ca.d.ts +15 -0
  34. package/dist/keys/egress-ca.js +66 -0
  35. package/dist/keys/keys.d.ts +8 -1
  36. package/dist/keys/keys.js +6 -0
  37. package/dist/keys/x509.d.ts +29 -0
  38. package/dist/keys/x509.js +160 -0
  39. package/dist/management-openapi.json +195 -9
  40. package/dist/openapi.json +2 -1
  41. package/dist/sandbox/join.d.ts +2 -0
  42. package/dist/sandbox/join.js +8 -3
  43. package/dist/sandbox/manager.d.ts +2 -0
  44. package/dist/sandbox/manager.js +1 -1
  45. package/dist/sandbox/tools.d.ts +3 -1
  46. package/dist/sandbox/tools.js +4 -2
  47. package/dist/sandbox/types.d.ts +5 -0
  48. package/dist/store/postgres/drizzle/0017_wake_outbox.sql +9 -0
  49. package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
  50. package/dist/store/postgres/schema.d.ts +105 -2
  51. package/dist/store/postgres/schema.js +17 -1
  52. package/dist/store/postgres/store.js +54 -4
  53. package/dist/store/types.d.ts +46 -5
  54. package/dist/tenant/advance.js +1 -4
  55. package/dist/tenant/commands.js +5 -5
  56. package/dist/tenant/context.d.ts +10 -7
  57. package/dist/tenant/runtime.js +2 -1
  58. package/dist/tenant/sessions.js +26 -0
  59. package/dist/tenant/sweep.d.ts +14 -0
  60. package/dist/tenant/sweep.js +69 -10
  61. package/dist/tenant/transcript.d.ts +8 -8
  62. package/dist/vault/service.d.ts +34 -0
  63. package/dist/vault/service.js +148 -15
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/package.json +3 -3
@@ -27,6 +27,8 @@ export interface ToolRouting {
27
27
  /** The session that owns the tree's sandbox, and the sandbox resource it is attached to. */
28
28
  readonly sandboxOwnerId: string;
29
29
  readonly sandboxId?: string;
30
+ /** Variables every sandbox command gets (R2c): secret names set to the sentinel, plain values. */
31
+ readonly environment?: Readonly<Record<string, string>>;
30
32
  readonly activeTurnId: string | null;
31
33
  /** Who the sandbox call's events are claimed for: the run's id. */
32
34
  readonly claim?: string;
@@ -127,6 +127,7 @@ export async function callSandboxTool(sandbox, routing, request, signal) {
127
127
  activeTurnId: routing.activeTurnId,
128
128
  manifest: routing.rootManifest,
129
129
  ...(routing.sandboxId === undefined ? {} : { sandboxId: routing.sandboxId }),
130
+ ...(routing.environment === undefined ? {} : { environment: routing.environment }),
130
131
  ...(routing.claim === undefined ? {} : { claim: routing.claim }),
131
132
  }, capability, request.toolName, request.input, signal);
132
133
  }
@@ -95,6 +95,7 @@ export function routingOf(run) {
95
95
  ...(routing.mcpSnapshot ? { mcpSnapshot: routing.mcpSnapshot } : {}),
96
96
  sandboxOwnerId: routing.sandbox?.ownerId ?? run.grant.sessionId,
97
97
  ...(routing.sandbox?.sandboxId === undefined ? {} : { sandboxId: routing.sandbox.sandboxId }),
98
+ ...(routing.sandbox?.environment === undefined ? {} : { environment: routing.sandbox.environment }),
98
99
  activeTurnId: run.grant.turnId,
99
100
  claim: run.runId,
100
101
  };
@@ -44,7 +44,10 @@ export async function runHarness(stack, baseline) {
44
44
  log: (message, fields) => logger.info(message, fields),
45
45
  });
46
46
  logger.info("sandbox_network_policy_in_force", { sandboxId: config.pod.sandboxId, waitedMs });
47
- pod = podHost(config.pod, logger, { volumeFile: join(config.root, "volume-id") });
47
+ pod = podHost(config.pod, logger, {
48
+ volumeFile: join(config.root, "volume-id"),
49
+ caBundleFile: join(config.root, "egress-ca-bundle.pem"),
50
+ });
48
51
  }
49
52
  const token = pod ? () => pod.token() : config.token;
50
53
  if (token === undefined)
@@ -18,4 +18,6 @@ export declare function podHost(config: PodHostConfig, logger: {
18
18
  renewMs?: number;
19
19
  /** Where the engine keeps the volume's id (on the volume): a new volume gets a new one. */
20
20
  volumeFile?: string;
21
+ /** Where the engine writes the trust bundle: the public roots and the egress CA (R2c). */
22
+ caBundleFile?: string;
21
23
  }): PodHost;
@@ -5,17 +5,34 @@
5
5
  * read afresh each time: a reset writes a new one). The egress token minted with the host token
6
6
  * is the password of the proxy every command gets (`proxyEnv`); both are renewed every
7
7
  * `RENEW_MS`, well inside their 15-minute life.
8
+ *
9
+ * The join answer also carries the installation's egress CA (R2c, D50). The engine writes it,
10
+ * after the public roots, to a trust bundle on the pod (`caBundleFile`) and points every
11
+ * command's trust variables at it, so a CLI trusts the leaf egress-gate presents for a host an
12
+ * `environment_secret` is bound to. The bundle is rewritten only when the CA changes.
8
13
  */
9
14
  import { randomUUID } from "node:crypto";
10
- import { mkdir, readFile, writeFile } from "node:fs/promises";
15
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
16
+ import { rootCertificates } from "node:tls";
11
17
  import { dirname } from "node:path";
12
18
  /** How often the host and egress tokens are renewed. */
13
19
  export const RENEW_MS = 5 * 60_000;
20
+ /** The variables CLIs read their trust bundle from: OpenSSL, Node, Python, curl, git, AWS. */
21
+ const TRUST_VARIABLES = [
22
+ "SSL_CERT_FILE",
23
+ "NODE_EXTRA_CA_CERTS",
24
+ "REQUESTS_CA_BUNDLE",
25
+ "CURL_CA_BUNDLE",
26
+ "GIT_SSL_CAINFO",
27
+ "AWS_CA_BUNDLE",
28
+ ];
14
29
  class Refused extends Error {
15
30
  }
16
31
  export function podHost(config, logger, options = {}) {
17
32
  const call = options.fetch ?? fetch;
18
33
  let held;
34
+ /** The CA in the bundle on disk, once one is written. */
35
+ let bundled;
19
36
  const post = async (path, init) => {
20
37
  const response = await call(`${config.httpUrl}/nylorun/harness/v1/host/${path}`, {
21
38
  method: "POST",
@@ -32,13 +49,27 @@ export function podHost(config, logger, options = {}) {
32
49
  if (!response.ok)
33
50
  throw new Error(`host/${path} answered ${response.status}`);
34
51
  const body = JSON.parse(text);
52
+ if (typeof body.caCertificate === "string")
53
+ await writeBundle(body.caCertificate);
35
54
  return {
36
55
  hostToken: body.hostToken,
37
56
  egressToken: body.egressToken,
38
57
  epoch: body.epoch,
39
58
  expiresAt: Date.parse(body.expiresAt),
59
+ ...(typeof body.caCertificate === "string" ? { caCertificate: body.caCertificate } : {}),
40
60
  };
41
61
  };
62
+ const writeBundle = async (ca) => {
63
+ const file = options.caBundleFile;
64
+ if (!file || ca === bundled)
65
+ return;
66
+ await mkdir(dirname(file), { recursive: true });
67
+ // Written beside it and renamed, so a command never reads half a bundle.
68
+ const next = `${file}.${randomUUID()}`;
69
+ await writeFile(next, `${rootCertificates.join("\n")}\n${ca.trim()}\n`, { mode: 0o644 });
70
+ await rename(next, file);
71
+ bundled = ca;
72
+ };
42
73
  /** The volume's id, created on a volume that has none. */
43
74
  const volumeId = async () => {
44
75
  const file = options.volumeFile;
@@ -100,7 +131,11 @@ export function podHost(config, logger, options = {}) {
100
131
  url.username = "nylorun";
101
132
  url.password = held.egressToken;
102
133
  const proxy = url.toString().replace(/\/$/, "");
134
+ const trust = bundled !== undefined && options.caBundleFile
135
+ ? Object.fromEntries(TRUST_VARIABLES.map((name) => [name, options.caBundleFile]))
136
+ : {};
103
137
  return {
138
+ ...trust,
104
139
  HTTPS_PROXY: proxy,
105
140
  HTTP_PROXY: proxy,
106
141
  https_proxy: proxy,
@@ -14,6 +14,8 @@ export interface SegmentStart {
14
14
  readonly sandbox: {
15
15
  readonly ownerId: string;
16
16
  readonly sandboxId?: string;
17
+ /** Variables every sandbox command gets (R2c): secret names set to the sentinel, plain values. */
18
+ readonly environment?: Readonly<Record<string, string>>;
17
19
  };
18
20
  /** The sandbox is a pod sandbox (F7.2): its engine runs the segment. Its lifecycle state. */
19
21
  readonly pod?: {
@@ -25,8 +25,10 @@ options = {}) {
25
25
  const fixtureModel = await usesFixtureModel(t);
26
26
  if (!options.harness)
27
27
  return { current, fixtureModel, outcomes: [], sandbox: { ownerId: id } };
28
- const sandbox = sandboxWorkspaceOf(current, await sandboxLookup(t, current.id));
29
- const resource = sandbox.sandboxId === undefined ? undefined : await t.sandboxResource(sandbox.sandboxId);
28
+ const workspace = sandboxWorkspaceOf(current, await sandboxLookup(t, current.id));
29
+ const resource = workspace.sandboxId === undefined ? undefined : await t.sandboxResource(workspace.sandboxId);
30
+ const { environment } = await ctx.vault.sessionEnvironment(t, current.vaultIds ?? []);
31
+ const sandbox = Object.keys(environment).length > 0 ? { ...workspace, environment } : workspace;
30
32
  return {
31
33
  current,
32
34
  fixtureModel,
@@ -14,8 +14,8 @@
14
14
  * /nylorun/harness/v1/host/join` (`{ sandboxId, podUid, joinToken }`), renews it with `POST
15
15
  * .../host/renew` (`Authorization: Bearer <host token>`), and upgrades with the host token as
16
16
  * its bearer: that connection hosts its sandbox alone (`HarnessPeer.host`). Both answer
17
- * `{ hostToken, egressToken, sandboxId, epoch, expiresAt }`; every refusal is the same 401. The
18
- * harness token is never accepted as a host token, nor a host token anywhere but here.
17
+ * `{ hostToken, egressToken, sandboxId, epoch, expiresAt, caCertificate }` (the egress CA, R2c);
18
+ * every refusal is the same 401. The harness token is never accepted as a host token, nor a host token anywhere but here.
19
19
  */
20
20
  import { createHash, timingSafeEqual } from "node:crypto";
21
21
  import { createServer } from "node:http";
@@ -1,7 +1,7 @@
1
1
  import type { ResourceServerConfig } from "../tenant/resource-server.js";
2
2
  import type { Logger, TenantModule } from "../tenant/types.js";
3
3
  import type { HostConfigFile } from "./config.js";
4
- import type { ContainerListen } from "./stack-config.js";
4
+ import { type ContainerListen } from "./stack-config.js";
5
5
  export interface CreateHostOptions {
6
6
  hostRoot: string;
7
7
  module: TenantModule;
@@ -1,7 +1,8 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { getRequestListener, RequestError } from "@hono/node-server";
3
3
  import { createHostApp } from "./app.js";
4
- import { bindListener as bind, EXIT_NON_LOOPBACK, headerValue, HostListenError, isAllowedRequestHost, isLoopbackHost, pathnameIsLogged, redactRoutePath, rejectedResponse, sendRejected, } from "./http.js";
4
+ import { isLoopbackHost } from "./stack-config.js";
5
+ import { bindListener as bind, EXIT_NON_LOOPBACK, headerValue, HostListenError, isAllowedRequestHost, pathnameIsLogged, redactRoutePath, rejectedResponse, sendRejected, } from "./http.js";
5
6
  export function createHost(options) {
6
7
  const { module, config, logger, coreVersion } = options;
7
8
  const pid = options.pid ?? process.pid;
@@ -27,6 +27,7 @@ import { probeDatabase } from "../infra/database.js";
27
27
  import { bindListener, headerValue, isAllowedRequestHost, sendRejected } from "./http.js";
28
28
  import { startEgressGate, storeEgressSandboxes } from "../gates/egress.js";
29
29
  import { verifyEgressToken } from "../sandbox/egress-token.js";
30
+ import { leafKeyPair } from "../keys/x509.js";
30
31
  /** Above the gate's 600 s provider request timeout and the loop's 630 s client timeout. */
31
32
  export const GATES_REQUEST_TIMEOUT_MS = 660_000;
32
33
  export async function startGates(options) {
@@ -172,6 +173,16 @@ export async function startEgress(options) {
172
173
  return verifyEgressToken(vault.store, vault.tenantId, raw, publicKeys);
173
174
  },
174
175
  sandboxes: { live: async (sandboxId) => storeEgressSandboxes((await tenant.open()).store).live(sandboxId) },
176
+ credentials: {
177
+ bound: async (sandboxId, host) => (await tenant.open()).environmentSecretBound(sandboxId, host),
178
+ release: async (sandboxId, host) => (await tenant.open()).releaseEnvironmentSecret(sandboxId, host),
179
+ // A fresh key per connection, made here: keys signs its public half and never sees it.
180
+ async leaf(host) {
181
+ const pair = leafKeyPair();
182
+ const cert = await (await tenant.open()).keys().signEgressLeaf(host, pair.publicKey);
183
+ return { key: pair.privateKey, cert };
184
+ },
185
+ },
175
186
  ...options.gate,
176
187
  });
177
188
  }
@@ -54,4 +54,3 @@ export declare function adminKeyMatches(presented: string | undefined, adminKey:
54
54
  export declare function headerValue(incoming: IncomingMessage, name: string): string | undefined;
55
55
  /** A JSON request body, capped at `limit` bytes (413); `undefined` when there is none. */
56
56
  export declare function readJsonBody(request: Request, limit?: number): Promise<unknown>;
57
- export declare function isLoopbackHost(host: string): boolean;
package/dist/host/http.js CHANGED
@@ -191,9 +191,3 @@ export async function readJsonBody(request, limit = 1024 * 1024) {
191
191
  throw Object.assign(new Error("Invalid JSON"), { status: 400 });
192
192
  }
193
193
  }
194
- export function isLoopbackHost(host) {
195
- const normalized = host.trim().toLowerCase();
196
- return (normalized === "127.0.0.1" ||
197
- normalized === "::1" ||
198
- normalized === "localhost");
199
- }
@@ -237,6 +237,8 @@ export declare function parseServices(argv: readonly string[]): ServiceSelection
237
237
  * IPv6 literals must be bracketed (`[::1]:4000`).
238
238
  */
239
239
  export declare function normalizeAllowedHost(name: string, entry: string): string;
240
+ /** A loopback bind address: `127.0.0.1`, `::1` or `localhost`. */
241
+ export declare function isLoopbackHost(host: string): boolean;
240
242
  /** Parse the stack configuration; throws `StackConfigError` naming the variable. */
241
243
  export declare function parseStackConfig(env: EnvSnapshot, argv: readonly string[]): StackConfig;
242
244
  /** Endpoint summary for logs: which endpoints are set, never their values. */
@@ -179,9 +179,10 @@ export function normalizeAllowedHost(name, entry) {
179
179
  function loopbackForms(port) {
180
180
  return [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
181
181
  }
182
- function isLoopbackAddress(host) {
183
- const value = host.toLowerCase();
184
- return value === "127.0.0.1" || value === "::1" || value === "localhost";
182
+ /** A loopback bind address: `127.0.0.1`, `::1` or `localhost`. */
183
+ export function isLoopbackHost(host) {
184
+ const normalized = host.trim().toLowerCase();
185
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
185
186
  }
186
187
  function parseUrl(env, name, protocols) {
187
188
  const raw = read(env, name);
@@ -198,32 +199,37 @@ function parseUrl(env, name, protocols) {
198
199
  throw new StackConfigError(`${name} must use ${protocols.map((p) => p.replace(/:$/, "")).join(" or ")}; got ${url.protocol.replace(/:$/, "")}`);
199
200
  return raw;
200
201
  }
201
- function parseListen(env) {
202
- const rawHost = read(env, "NYLORUN_LISTEN_HOST");
203
- const rawPort = read(env, "NYLORUN_LISTEN_PORT");
204
- const rawAllowed = read(env, "NYLORUN_ALLOWED_HOSTS");
205
- if (rawHost === undefined && rawPort === undefined && rawAllowed === undefined)
206
- return undefined;
207
- const host = rawHost ?? DEFAULT_CONTAINER_LISTEN_HOST;
202
+ function parseListener(env, prefix, defaultPort, hosts) {
203
+ const hostName = `${prefix}LISTEN_HOST`;
204
+ const host = read(env, hostName) ?? DEFAULT_CONTAINER_LISTEN_HOST;
208
205
  if (/\s|\//.test(host))
209
- throw new StackConfigError(`NYLORUN_LISTEN_HOST is not an address: ${host}`);
210
- const port = rawPort === undefined
211
- ? DEFAULT_CONTAINER_LISTEN_PORT
212
- : parsePort("NYLORUN_LISTEN_PORT", rawPort);
213
- const explicit = rawAllowed === undefined
214
- ? []
215
- : rawAllowed
216
- .split(",")
217
- .map((entry) => entry.trim())
218
- .filter((entry) => entry !== "")
219
- .map((entry) => normalizeAllowedHost("NYLORUN_ALLOWED_HOSTS", entry));
220
- if (explicit.length === 0 && !isLoopbackAddress(host))
221
- throw new StackConfigError(`NYLORUN_ALLOWED_HOSTS is required when NYLORUN_LISTEN_HOST is ${host}: list the Host headers clients send, e.g. runtime:${port},localhost:<published port>`);
206
+ throw new StackConfigError(`${hostName} is not an address: ${host}`);
207
+ const portName = `${prefix}LISTEN_PORT`;
208
+ const rawPort = read(env, portName);
209
+ const port = rawPort === undefined ? defaultPort : parsePort(portName, rawPort);
210
+ if (!hosts)
211
+ return { host, port };
212
+ const allowedName = `${prefix}ALLOWED_HOSTS`;
213
+ const explicit = (read(env, allowedName) ?? "")
214
+ .split(",")
215
+ .map((entry) => entry.trim())
216
+ .filter((entry) => entry !== "")
217
+ .map((entry) => normalizeAllowedHost(allowedName, entry));
218
+ if (explicit.length === 0 && !isLoopbackHost(host))
219
+ throw new StackConfigError(`${allowedName} is required when ${hostName} is ${host}: list the Host headers ${hosts.senders}, e.g. ${hosts.example(port)}`);
222
220
  // Loopback forms of the listen port serve probes from inside the container
223
221
  // (health checks). A browser on the Docker host never sends them: it sends
224
222
  // the published port.
225
- const allowedHosts = [...new Set([...explicit, ...loopbackForms(port)])];
226
- return { host, port, allowedHosts };
223
+ return { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] };
224
+ }
225
+ /** The API's listener (`NYLORUN_LISTEN_*`, `NYLORUN_ALLOWED_HOSTS`): none when none is set. */
226
+ function parseListen(env) {
227
+ if (["NYLORUN_LISTEN_HOST", "NYLORUN_LISTEN_PORT", "NYLORUN_ALLOWED_HOSTS"].every((name) => read(env, name) === undefined))
228
+ return undefined;
229
+ return parseListener(env, "NYLORUN_", DEFAULT_CONTAINER_LISTEN_PORT, {
230
+ senders: "clients send",
231
+ example: (port) => `runtime:${port},localhost:<published port>`,
232
+ });
227
233
  }
228
234
  const IDENTITY_KEY = /^publickeyv1_[1-9A-HJ-NP-Za-km-z]{32,64}$/;
229
235
  function parseIdentityKeys(env) {
@@ -398,30 +404,14 @@ function parseSandboxes(env) {
398
404
  * (required with `NYLORUN_HARNESS=remote`; optional when only sandbox pods connect).
399
405
  */
400
406
  function parseHarnessListener(env, tokenRequired = true) {
401
- const host = read(env, "NYLORUN_HARNESS_LISTEN_HOST") ?? DEFAULT_CONTAINER_LISTEN_HOST;
402
- if (/\s|\//.test(host))
403
- throw new StackConfigError(`NYLORUN_HARNESS_LISTEN_HOST is not an address: ${host}`);
404
- const rawPort = read(env, "NYLORUN_HARNESS_LISTEN_PORT");
405
- const port = rawPort === undefined
406
- ? DEFAULT_HARNESS_LISTEN_PORT
407
- : parsePort("NYLORUN_HARNESS_LISTEN_PORT", rawPort);
408
- const rawAllowed = read(env, "NYLORUN_HARNESS_ALLOWED_HOSTS");
409
- const explicit = rawAllowed === undefined
410
- ? []
411
- : rawAllowed
412
- .split(",")
413
- .map((entry) => entry.trim())
414
- .filter((entry) => entry !== "")
415
- .map((entry) => normalizeAllowedHost("NYLORUN_HARNESS_ALLOWED_HOSTS", entry));
416
- if (explicit.length === 0 && !isLoopbackAddress(host))
417
- throw new StackConfigError(`NYLORUN_HARNESS_ALLOWED_HOSTS is required when NYLORUN_HARNESS_LISTEN_HOST is ${host}: list the Host headers harnesses send, e.g. runtime:${port}`);
407
+ const listen = parseListener(env, "NYLORUN_HARNESS_", DEFAULT_HARNESS_LISTEN_PORT, {
408
+ senders: "harnesses send",
409
+ example: (port) => `runtime:${port}`,
410
+ });
418
411
  const token = tokenRequired || read(env, "NYLORUN_HARNESS_TOKEN") !== undefined
419
412
  ? parseHarnessToken(env, "with NYLORUN_HARNESS=remote: the credential harnesses present")
420
413
  : undefined;
421
- return {
422
- listen: { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] },
423
- ...(token ? { token } : {}),
424
- };
414
+ return { listen, ...(token ? { token } : {}) };
425
415
  }
426
416
  /** What a harness process must not hold: it runs next to agent code. */
427
417
  const HARNESS_REFUSED = ["NYLORUN_DATABASE_URL", "NYLORUN_GATES_TOKEN", "NYLORUN_KEYS_URL"];
@@ -491,43 +481,22 @@ function parsePodHost(env, url) {
491
481
  }
492
482
  /** `StackConfig.gates` from `NYLORUN_GATES_*`. */
493
483
  function parseGates(env) {
494
- const host = read(env, "NYLORUN_GATES_LISTEN_HOST") ?? DEFAULT_CONTAINER_LISTEN_HOST;
495
- if (/\s|\//.test(host))
496
- throw new StackConfigError(`NYLORUN_GATES_LISTEN_HOST is not an address: ${host}`);
497
- const rawPort = read(env, "NYLORUN_GATES_LISTEN_PORT");
498
- const port = rawPort === undefined
499
- ? DEFAULT_GATES_LISTEN_PORT
500
- : parsePort("NYLORUN_GATES_LISTEN_PORT", rawPort);
501
- const rawAllowed = read(env, "NYLORUN_GATES_ALLOWED_HOSTS");
502
- const explicit = rawAllowed === undefined
503
- ? []
504
- : rawAllowed
505
- .split(",")
506
- .map((entry) => entry.trim())
507
- .filter((entry) => entry !== "")
508
- .map((entry) => normalizeAllowedHost("NYLORUN_GATES_ALLOWED_HOSTS", entry));
509
- if (explicit.length === 0 && !isLoopbackAddress(host))
510
- throw new StackConfigError(`NYLORUN_GATES_ALLOWED_HOSTS is required when NYLORUN_GATES_LISTEN_HOST is ${host}: list the Host headers the loop sends, e.g. gateway:${port}`);
484
+ const listen = parseListener(env, "NYLORUN_GATES_", DEFAULT_GATES_LISTEN_PORT, {
485
+ senders: "the loop sends",
486
+ example: (port) => `gateway:${port}`,
487
+ });
511
488
  const token = read(env, "NYLORUN_GATES_TOKEN");
512
489
  if (token === undefined)
513
490
  throw new StackConfigError("NYLORUN_GATES_TOKEN is required for --service gates: the token the loop presents (`nylorun start` sets it)");
514
491
  if (!GATES_TOKEN.test(token))
515
492
  throw new StackConfigError("NYLORUN_GATES_TOKEN must be at least 32 bytes as hex");
516
- return {
517
- listen: { host, port, allowedHosts: [...new Set([...explicit, ...loopbackForms(port)])] },
518
- token,
519
- };
493
+ return { listen, token };
520
494
  }
521
495
  /** The prefix of the credential resolver's variables (removed in protocol 10). */
522
496
  const REMOVED_RESOLVER_PREFIX = "NYLORUN_RESOLVER_";
523
497
  /** `StackConfig.egress` from `NYLORUN_EGRESS_LISTEN_*`. CONNECT has no Host header to check. */
524
498
  function parseEgress(env) {
525
- const host = read(env, "NYLORUN_EGRESS_LISTEN_HOST") ?? DEFAULT_CONTAINER_LISTEN_HOST;
526
- if (/\s|\//.test(host))
527
- throw new StackConfigError(`NYLORUN_EGRESS_LISTEN_HOST is not an address: ${host}`);
528
- const rawPort = read(env, "NYLORUN_EGRESS_LISTEN_PORT");
529
- const port = rawPort === undefined ? DEFAULT_EGRESS_LISTEN_PORT : parsePort("NYLORUN_EGRESS_LISTEN_PORT", rawPort);
530
- return { listen: { host, port } };
499
+ return { listen: parseListener(env, "NYLORUN_EGRESS_", DEFAULT_EGRESS_LISTEN_PORT) };
531
500
  }
532
501
  /** `StackConfig.modelGate` from `NYLORUN_GATES_URL` and `NYLORUN_GATES_TOKEN`. */
533
502
  function parseModelGate(env) {
@@ -0,0 +1,15 @@
1
+ import type { SessionStore } from "../store/types.js";
2
+ /** How long a leaf egress-gate presents is valid: one connection rarely lasts this long. */
3
+ export declare const EGRESS_LEAF_MINUTES = 60;
4
+ export declare class EgressAuthority {
5
+ private readonly store;
6
+ private readonly kek;
7
+ /** The CA with its key, decrypted once per process, as signing keys are. */
8
+ private authority;
9
+ constructor(store: SessionStore, kek: () => Buffer);
10
+ /** The CA certificate (PEM), creating the CA when the Tenant has none. */
11
+ certificate(): Promise<string>;
12
+ /** A leaf for `host` and the caller's P-256 public key (SPKI PEM); the caller keeps its key. */
13
+ signLeaf(host: string, publicKey: string): Promise<string>;
14
+ private load;
15
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The installation's egress CA (R2c, D50, E12): one long-lived certificate authority whose
3
+ * certificate every pod sandbox trusts, and whose key signs the short-lived leaf egress-gate
4
+ * presents when it terminates TLS for a host an `environment_secret` is bound to. Only keys holds
5
+ * the key: it lives in the Tenant setting `egress.ca`, sealed under the vault KEK like a signing
6
+ * key, beside the certificate in the clear. Created on first use under the signing keys' lock.
7
+ * No rotation yet (R8, with the KeyRoot seam).
8
+ */
9
+ import { X509Certificate } from "node:crypto";
10
+ import { decryptSecret, encryptSecret } from "../vault/crypto.js";
11
+ import { createCertificateAuthority, issueLeafCertificate } from "./x509.js";
12
+ const SETTING = "egress.ca";
13
+ /** How long a leaf egress-gate presents is valid: one connection rarely lasts this long. */
14
+ export const EGRESS_LEAF_MINUTES = 60;
15
+ function aad(certificate) {
16
+ return Buffer.from(`nylorun.egress-ca:${new X509Certificate(certificate).fingerprint256}`);
17
+ }
18
+ export class EgressAuthority {
19
+ store;
20
+ kek;
21
+ /** The CA with its key, decrypted once per process, as signing keys are. */
22
+ authority;
23
+ constructor(store, kek) {
24
+ this.store = store;
25
+ this.kek = kek;
26
+ }
27
+ /** The CA certificate (PEM), creating the CA when the Tenant has none. */
28
+ async certificate() {
29
+ return (await this.load()).certificate;
30
+ }
31
+ /** A leaf for `host` and the caller's P-256 public key (SPKI PEM); the caller keeps its key. */
32
+ async signLeaf(host, publicKey) {
33
+ return issueLeafCertificate({ authority: await this.load(), host, publicKey, minutes: EGRESS_LEAF_MINUTES });
34
+ }
35
+ async load() {
36
+ if (this.authority)
37
+ return this.authority;
38
+ const kek = this.kek();
39
+ const stored = await this.store.tx(async (t) => (await read(t)) ?? (await create(t, kek)));
40
+ const privateKey = decryptSecret(kek, aad(stored.certificate), Buffer.from(stored.nonce, "base64"), Buffer.from(stored.ciphertext, "base64"), Buffer.from(stored.wrappedDek, "base64"), stored.kekId).toString("utf8");
41
+ this.authority = { certificate: stored.certificate, privateKey };
42
+ return this.authority;
43
+ }
44
+ }
45
+ async function read(t) {
46
+ const value = await t.getSetting(SETTING);
47
+ return value === undefined ? undefined : JSON.parse(value);
48
+ }
49
+ async function create(t, kek) {
50
+ // Concurrent first uses would both create a CA: lock, then read what the winner made.
51
+ await t.lockSigningKeys();
52
+ const existing = await read(t);
53
+ if (existing)
54
+ return existing;
55
+ const authority = createCertificateAuthority({ commonName: "Nylorun egress CA" });
56
+ const sealed = encryptSecret(kek, aad(authority.certificate), Buffer.from(authority.privateKey, "utf8"));
57
+ const stored = {
58
+ certificate: authority.certificate,
59
+ kekId: sealed.kekId,
60
+ nonce: sealed.nonce.toString("base64"),
61
+ ciphertext: sealed.ciphertext.toString("base64"),
62
+ wrappedDek: sealed.wrappedDek.toString("base64"),
63
+ };
64
+ await t.putSetting(SETTING, JSON.stringify(stored));
65
+ return stored;
66
+ }
@@ -32,9 +32,16 @@ export interface Keys {
32
32
  * (R2b C12, `mcp/preview.ts`); never calls a tool.
33
33
  */
34
34
  previewMcp(body: McpPreviewRequest): Promise<McpPreview>;
35
+ /** The egress CA's certificate (PEM), creating the CA when the Tenant has none (R2c, D50). */
36
+ egressCa(): Promise<string>;
37
+ /**
38
+ * A short-lived leaf for `host` and the caller's P-256 public key (SPKI PEM), signed by the
39
+ * egress CA (R2c). The leaf's private key never reaches keys; the CA's never leaves it.
40
+ */
41
+ signEgressLeaf(host: string, publicKey: string): Promise<string>;
35
42
  }
36
43
  /** The operations, by the name the HTTP route carries. */
37
- export declare const KEYS_OPERATIONS: readonly ["createCredential", "rotateCredential", "putHostModel", "selectHostModel", "sign", "rotateSigningKeys", "ensureSigningKeys", "previewMcp"];
44
+ export declare const KEYS_OPERATIONS: readonly ["createCredential", "rotateCredential", "putHostModel", "selectHostModel", "sign", "rotateSigningKeys", "ensureSigningKeys", "previewMcp", "egressCa", "signEgressLeaf"];
38
45
  export type KeysOperation = (typeof KEYS_OPERATIONS)[number];
39
46
  export interface InProcessKeysOptions {
40
47
  readonly store: SessionStore;
package/dist/keys/keys.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * service itself, which serves the HTTP routes with it), and `httpKeys` (`client.ts`), the
13
13
  * runtime container's client of the `keys` service in the gateway.
14
14
  */
15
+ import { EgressAuthority } from "./egress-ca.js";
15
16
  import { SignJWT } from "jose";
16
17
  import { previewMcpServer } from "../mcp/preview.js";
17
18
  import { signingKeyView } from "../tenant/signing-keys.js";
@@ -25,9 +26,12 @@ export const KEYS_OPERATIONS = [
25
26
  "rotateSigningKeys",
26
27
  "ensureSigningKeys",
27
28
  "previewMcp",
29
+ "egressCa",
30
+ "signEgressLeaf",
28
31
  ];
29
32
  export function inProcessKeys(options) {
30
33
  const { store, vault, signingKeys, kek } = options;
34
+ const egress = new EgressAuthority(store, kek);
31
35
  return {
32
36
  createCredential: (vaultId, body) => vault.createCredential(vaultId, body),
33
37
  rotateCredential: (vaultId, id, body) => vault.rotateCredential(vaultId, id, body),
@@ -52,5 +56,7 @@ export function inProcessKeys(options) {
52
56
  await store.tx((t) => signingKeys.ensure(t, key));
53
57
  },
54
58
  previewMcp: (body) => previewMcpServer(body, { vault, policy: options.policy ?? {} }),
59
+ egressCa: () => egress.certificate(),
60
+ signEgressLeaf: (host, publicKey) => egress.signLeaf(host, publicKey),
55
61
  };
56
62
  }
@@ -0,0 +1,29 @@
1
+ export interface CertificateAuthority {
2
+ /** PEM of the CA certificate: the only part a sandbox ever receives. */
3
+ readonly certificate: string;
4
+ /** PKCS#8 PEM of the CA key: sealed in the vault, used only by keys. */
5
+ readonly privateKey: string;
6
+ }
7
+ /** A new P-256 CA, valid from an hour ago for `days` days. */
8
+ export declare function createCertificateAuthority(options: {
9
+ readonly commonName: string;
10
+ readonly days?: number;
11
+ readonly now?: Date;
12
+ }): CertificateAuthority;
13
+ /**
14
+ * A leaf for `host` and the caller's `publicKey` (SPKI PEM), signed by the CA, valid from five
15
+ * minutes ago for `minutes` minutes. The caller keeps the leaf's private key: only the
16
+ * certificate comes back, so no private key leaves whoever holds it.
17
+ */
18
+ export declare function issueLeafCertificate(options: {
19
+ readonly authority: CertificateAuthority;
20
+ readonly host: string;
21
+ readonly publicKey: string;
22
+ readonly minutes?: number;
23
+ readonly now?: Date;
24
+ }): string;
25
+ /** A P-256 key pair for a leaf, as PEM: SPKI for the public key, PKCS#8 for the private key. */
26
+ export declare function leafKeyPair(): {
27
+ readonly publicKey: string;
28
+ readonly privateKey: string;
29
+ };