@nylorun/runtime 0.21.1-beta → 0.23.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 (84) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +17 -3
  3. package/dist/adapters/execution/restate.d.ts +15 -3
  4. package/dist/adapters/execution/restate.js +77 -83
  5. package/dist/adapters/sandbox/local.js +1 -1
  6. package/dist/adapters/sandbox/virtual.js +1 -1
  7. package/dist/adapters/streams/s2.js +1 -13
  8. package/dist/api/components.d.ts +42 -2
  9. package/dist/core/flow-host.d.ts +1 -19
  10. package/dist/core/flow-host.js +8 -37
  11. package/dist/execution/types.d.ts +10 -8
  12. package/dist/execution/types.js +5 -7
  13. package/dist/gates/egress-credentials.d.ts +48 -0
  14. package/dist/gates/egress-credentials.js +147 -0
  15. package/dist/gates/egress.d.ts +3 -0
  16. package/dist/gates/egress.js +14 -1
  17. package/dist/gates/tenant-vaults.d.ts +4 -0
  18. package/dist/gates/tenant-vaults.js +2 -0
  19. package/dist/harness/calls.d.ts +2 -0
  20. package/dist/harness/calls.js +1 -0
  21. package/dist/harness/executors.js +1 -0
  22. package/dist/harness/main.js +4 -1
  23. package/dist/harness/pod.d.ts +2 -0
  24. package/dist/harness/pod.js +36 -1
  25. package/dist/harness-api/start.d.ts +2 -0
  26. package/dist/harness-api/start.js +4 -2
  27. package/dist/harness-api/ws-server.js +2 -2
  28. package/dist/host/execution.d.ts +8 -6
  29. package/dist/host/gates.js +11 -0
  30. package/dist/host/main.js +10 -16
  31. package/dist/host/stack-config.d.ts +6 -0
  32. package/dist/host/stack-config.js +6 -0
  33. package/dist/infra/execution.d.ts +18 -1
  34. package/dist/infra/execution.js +28 -4
  35. package/dist/infra/index.d.ts +1 -1
  36. package/dist/keys/egress-ca.d.ts +15 -0
  37. package/dist/keys/egress-ca.js +66 -0
  38. package/dist/keys/keys.d.ts +8 -1
  39. package/dist/keys/keys.js +6 -0
  40. package/dist/keys/x509.d.ts +29 -0
  41. package/dist/keys/x509.js +160 -0
  42. package/dist/management-openapi.json +195 -9
  43. package/dist/openapi.json +2 -1
  44. package/dist/retry.d.ts +21 -0
  45. package/dist/retry.js +34 -0
  46. package/dist/sandbox/join.d.ts +2 -0
  47. package/dist/sandbox/join.js +14 -10
  48. package/dist/sandbox/manager.d.ts +2 -0
  49. package/dist/sandbox/manager.js +1 -1
  50. package/dist/sandbox/pods/reconcile.js +3 -3
  51. package/dist/sandbox/tools.d.ts +3 -1
  52. package/dist/sandbox/tools.js +4 -2
  53. package/dist/sandbox/types.d.ts +5 -0
  54. package/dist/store/postgres/control.js +8 -1
  55. package/dist/store/postgres/drizzle/0018_wake_outbox_retries.sql +3 -0
  56. package/dist/store/postgres/drizzle/0019_sandbox_signal_outbox.sql +18 -0
  57. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  58. package/dist/store/postgres/schema.d.ts +265 -4
  59. package/dist/store/postgres/schema.js +35 -4
  60. package/dist/store/postgres/store.js +165 -52
  61. package/dist/store/types.d.ts +96 -39
  62. package/dist/streams/relay/core.js +1 -13
  63. package/dist/tenant/advance.js +6 -7
  64. package/dist/tenant/commands.js +1 -2
  65. package/dist/tenant/context.d.ts +19 -11
  66. package/dist/tenant/control.d.ts +8 -1
  67. package/dist/tenant/control.js +2 -0
  68. package/dist/tenant/runtime.js +23 -3
  69. package/dist/tenant/sandboxes.d.ts +2 -1
  70. package/dist/tenant/sandboxes.js +23 -19
  71. package/dist/tenant/scheduler.d.ts +7 -0
  72. package/dist/tenant/scheduler.js +38 -7
  73. package/dist/tenant/session-streams.d.ts +0 -1
  74. package/dist/tenant/session-streams.js +1 -13
  75. package/dist/tenant/sessions.js +26 -0
  76. package/dist/tenant/streams.js +14 -29
  77. package/dist/tenant/sweep.d.ts +39 -12
  78. package/dist/tenant/sweep.js +157 -58
  79. package/dist/tenant/transcript.d.ts +8 -8
  80. package/dist/vault/service.d.ts +34 -0
  81. package/dist/vault/service.js +148 -15
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/package.json +4 -3
@@ -1,12 +1,10 @@
1
1
  /**
2
2
  * The Durable Session Execution seam (architecture §12.3).
3
3
  *
4
- * Durable Session Execution decides when to look at a session again. It never
5
- * holds session state: every outcome lives in the Session Store, and `advance`
6
- * is safe to repeat (§10.1). It owns the schedule (wake delivery, one advance per
7
- * key, retries, timers, the sweep chain); the Session Store owns the record (the
8
- * checkpoint, the effect journal, the events) and the wake outbox that bridges the
9
- * two. Restate is the supported implementation
4
+ * Durable Session Execution decides when to look at a session again; `advance` is
5
+ * safe to repeat (§10.1). What it owns and what the Session Store owns is said once,
6
+ * under "Durable Session Execution" in `src/CONTEXT.md`. Restate is the supported
7
+ * implementation
10
8
  * (`adapters/execution/restate.ts`); `execution/memory.ts` is the in-process
11
9
  * implementation used by unit tests, `startEphemeralRuntime` and a Host
12
10
  * started without Restate endpoints.
@@ -19,7 +17,7 @@
19
17
  * - **At least once after a wake.** When `wake` resolves, an `advance` for the
20
18
  * key will start after that point (possibly merged with other wakes). Wakes
21
19
  * are sent after the Session Store transaction that caused them commits, never
22
- * inside it: the transaction writes the wake to its outbox (`Tx.wake`), and the
20
+ * inside it: the transaction writes the wake to the outbox (`Tx.wake`), and the
23
21
  * wake is sent after commit, or by the Tenant sweep when that send failed or a
24
22
  * crash cut it off. A wake may therefore be sent more than once, always with the
25
23
  * same `dedupeKey`.
@@ -75,9 +73,13 @@ export type AdvanceResult = {
75
73
  };
76
74
  /** A pod sandbox's timers. */
77
75
  export type SandboxTimer = "idle" | "ttl";
78
- /** What `DurableExecution.sandbox` asks for: a reconcile, or a timer. */
76
+ /**
77
+ * What `DurableExecution.sandbox` asks for: a reconcile, or a timer. A reconcile's `key` merges
78
+ * repeated sends of one request (the outbox's `signal:<row id>`); without one, every send runs.
79
+ */
79
80
  export type SandboxSignal = {
80
81
  kind: "reconcile";
82
+ key?: string;
81
83
  } | {
82
84
  kind: "arm";
83
85
  timer: SandboxTimer;
@@ -1,12 +1,10 @@
1
1
  /**
2
2
  * The Durable Session Execution seam (architecture §12.3).
3
3
  *
4
- * Durable Session Execution decides when to look at a session again. It never
5
- * holds session state: every outcome lives in the Session Store, and `advance`
6
- * is safe to repeat (§10.1). It owns the schedule (wake delivery, one advance per
7
- * key, retries, timers, the sweep chain); the Session Store owns the record (the
8
- * checkpoint, the effect journal, the events) and the wake outbox that bridges the
9
- * two. Restate is the supported implementation
4
+ * Durable Session Execution decides when to look at a session again; `advance` is
5
+ * safe to repeat (§10.1). What it owns and what the Session Store owns is said once,
6
+ * under "Durable Session Execution" in `src/CONTEXT.md`. Restate is the supported
7
+ * implementation
10
8
  * (`adapters/execution/restate.ts`); `execution/memory.ts` is the in-process
11
9
  * implementation used by unit tests, `startEphemeralRuntime` and a Host
12
10
  * started without Restate endpoints.
@@ -19,7 +17,7 @@
19
17
  * - **At least once after a wake.** When `wake` resolves, an `advance` for the
20
18
  * key will start after that point (possibly merged with other wakes). Wakes
21
19
  * are sent after the Session Store transaction that caused them commits, never
22
- * inside it: the transaction writes the wake to its outbox (`Tx.wake`), and the
20
+ * inside it: the transaction writes the wake to the outbox (`Tx.wake`), and the
23
21
  * wake is sent after commit, or by the Tenant sweep when that send failed or a
24
22
  * crash cut it off. A wake may therefore be sent more than once, always with the
25
23
  * same `dedupeKey`.
@@ -0,0 +1,48 @@
1
+ import type { Duplex } from "node:stream";
2
+ import type { Logger } from "../tenant/types.js";
3
+ /** What the vault answers for one request to a host a credential may be bound to. */
4
+ export type EgressCredentialRelease = {
5
+ readonly status: "none";
6
+ } | {
7
+ readonly status: "released";
8
+ readonly header: string;
9
+ readonly value: string;
10
+ readonly credentialId: string;
11
+ } | {
12
+ readonly status: "refused";
13
+ readonly reason: string;
14
+ };
15
+ export interface EgressCredentials {
16
+ /** Whether an `environment_secret` of a live session on the sandbox is bound to `host`. */
17
+ bound(sandboxId: string, host: string): Promise<boolean>;
18
+ /** The header to set on one request, read from the vault each time. */
19
+ release(sandboxId: string, host: string): Promise<EgressCredentialRelease>;
20
+ /** A fresh leaf for `host`: its key, made here, and its certificate, signed by keys. */
21
+ leaf(host: string): Promise<{
22
+ readonly key: string;
23
+ readonly cert: string;
24
+ }>;
25
+ /** Tests: the CA upstream servers are verified against, instead of the public roots. */
26
+ readonly upstreamCa?: string;
27
+ /** Tests: the port requests are sent to, instead of 443. */
28
+ readonly upstreamPort?: number;
29
+ }
30
+ export interface CredentialTerminator {
31
+ /**
32
+ * Answers the CONNECT, terminates TLS for `host` and serves its requests, sending each to one
33
+ * of `addresses` (already checked) and nowhere else.
34
+ */
35
+ terminate(client: Duplex, target: {
36
+ readonly sandboxId: string;
37
+ readonly host: string;
38
+ readonly addresses: readonly {
39
+ address: string;
40
+ family: number;
41
+ }[];
42
+ }): Promise<void>;
43
+ }
44
+ export declare function credentialTerminator(options: {
45
+ readonly credentials: EgressCredentials;
46
+ readonly logger: Logger;
47
+ readonly idleMs: number;
48
+ }): CredentialTerminator;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * egress-gate's credential path (R2c, D50): how a skill's CLI authenticates while the secret
3
+ * stays out of the sandbox. A CONNECT to port 443 of a host that an `environment_secret` of a
4
+ * live session on the sandbox is bound to (`allowedHosts`) is not tunnelled: the gate answers
5
+ * 200, terminates TLS with a fresh leaf for that host signed by the installation's egress CA
6
+ * (keys holds its key; the pod trusts its certificate, `harness/pod.ts`), and reads HTTP/1.1
7
+ * requests. For each request it reads the vault (one Postgres query, no cache, so a rotation
8
+ * applies to the next request), sets the credential's header (replacing any of that name, such
9
+ * as the `nylorun-managed` sentinel the CLI sent), and forwards it over TLS to the address the
10
+ * gate already checked, verifying the host's certificate by name against the public roots.
11
+ *
12
+ * Nothing here knows a tool: the header name and its format are the credential's. A release the
13
+ * vault refuses (two secrets for one host, an unreadable value) answers 502 and is logged, never
14
+ * as an event; a value never is. Upgrades (WebSocket) and HTTP/2 are not served on this path.
15
+ */
16
+ import { createServer } from "node:http";
17
+ import { Agent, request } from "node:https";
18
+ import { createSecureContext, TLSSocket } from "node:tls";
19
+ /** Headers that describe one hop and are never forwarded. */
20
+ const HOP_BY_HOP = new Set([
21
+ "connection",
22
+ "keep-alive",
23
+ "proxy-authenticate",
24
+ "proxy-authorization",
25
+ "proxy-connection",
26
+ "te",
27
+ "trailer",
28
+ "transfer-encoding",
29
+ "upgrade",
30
+ ]);
31
+ /** `rawHeaders` without hop-by-hop headers and `drop`, as lowercase names to values. */
32
+ function forwardable(raw, drop) {
33
+ const headers = {};
34
+ for (let i = 0; i + 1 < raw.length; i += 2) {
35
+ const name = raw[i].toLowerCase();
36
+ if (HOP_BY_HOP.has(name) || name === drop)
37
+ continue;
38
+ (headers[name] ??= []).push(raw[i + 1]);
39
+ }
40
+ return headers;
41
+ }
42
+ function reply(res, status, message) {
43
+ if (res.headersSent) {
44
+ res.destroy();
45
+ return;
46
+ }
47
+ const body = `egress-gate: ${message}\n`;
48
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "content-length": Buffer.byteLength(body) });
49
+ res.end(body);
50
+ }
51
+ export function credentialTerminator(options) {
52
+ const { credentials, logger } = options;
53
+ const terminated = new WeakMap();
54
+ const http = createServer((req, res) => {
55
+ forward(req, res).catch((error) => {
56
+ logger.error("egress_forward_failed", { error: error instanceof Error ? error.message : String(error) });
57
+ reply(res, 502, "the request failed");
58
+ });
59
+ });
60
+ http.keepAliveTimeout = options.idleMs;
61
+ http.headersTimeout = 60_000;
62
+ // Uploads may be long; the client socket's idle timeout still applies.
63
+ http.requestTimeout = 0;
64
+ http.on("upgrade", (req, socket) => {
65
+ const at = terminated.get(req.socket);
66
+ logger.warn("egress_refused", { status: 501, reason: "upgrade_refused", sandboxId: at?.sandboxId, host: at?.host });
67
+ socket.destroy();
68
+ });
69
+ http.on("clientError", (_error, socket) => socket.destroy());
70
+ async function forward(req, res) {
71
+ const at = terminated.get(req.socket);
72
+ if (!at) {
73
+ res.destroy();
74
+ return;
75
+ }
76
+ const { sandboxId, host } = at;
77
+ const release = await credentials.release(sandboxId, host);
78
+ if (release.status === "refused") {
79
+ logger.warn("egress_credential_refused", { status: 502, reason: release.reason, sandboxId, host });
80
+ req.resume();
81
+ return reply(res, 502, `the credential for ${host} could not be used`);
82
+ }
83
+ const header = release.status === "released" ? release.header.toLowerCase() : undefined;
84
+ const headers = forwardable(req.rawHeaders, header);
85
+ if (release.status === "released")
86
+ headers[header] = [release.value];
87
+ const upstream = request({
88
+ host,
89
+ port: credentials.upstreamPort ?? 443,
90
+ servername: host,
91
+ method: req.method,
92
+ path: req.url,
93
+ headers,
94
+ agent: at.agent,
95
+ });
96
+ upstream.on("response", (response) => {
97
+ res.writeHead(response.statusCode ?? 502, response.statusMessage, forwardable(response.rawHeaders));
98
+ response.pipe(res);
99
+ response.on("error", () => res.destroy());
100
+ });
101
+ upstream.on("error", (error) => {
102
+ logger.warn("egress_upstream_failed", { status: 502, sandboxId, host, code: error.code });
103
+ reply(res, 502, `could not reach ${host}`);
104
+ });
105
+ req.on("error", () => upstream.destroy());
106
+ res.on("close", () => {
107
+ if (!res.writableFinished)
108
+ upstream.destroy();
109
+ });
110
+ req.pipe(upstream);
111
+ }
112
+ return {
113
+ async terminate(client, target) {
114
+ const { sandboxId, host, addresses } = target;
115
+ const leaf = await credentials.leaf(host);
116
+ if (client.destroyed)
117
+ return;
118
+ // Requests go to the addresses the gate checked, never to the name resolved again.
119
+ const lookup = (_name, lookupOptions, callback) => {
120
+ if (lookupOptions.all)
121
+ callback(null, addresses);
122
+ else
123
+ callback(null, addresses[0].address, addresses[0].family);
124
+ };
125
+ const agent = new Agent({
126
+ keepAlive: true,
127
+ maxSockets: 8,
128
+ lookup,
129
+ ...(credentials.upstreamCa ? { ca: credentials.upstreamCa } : {}),
130
+ });
131
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
132
+ const tls = new TLSSocket(client, {
133
+ isServer: true,
134
+ secureContext: createSecureContext({ key: leaf.key, cert: leaf.cert }),
135
+ ALPNProtocols: ["http/1.1"],
136
+ });
137
+ terminated.set(tls, { sandboxId, host, agent });
138
+ tls.on("error", () => tls.destroy());
139
+ tls.on("close", () => {
140
+ agent.destroy();
141
+ client.destroy();
142
+ });
143
+ logger.info("egress_terminated", { sandboxId, host });
144
+ http.emit("connection", tls);
145
+ },
146
+ };
147
+ }
@@ -3,6 +3,7 @@ import { type Socket } from "node:net";
3
3
  import type { EgressTokenVerdict } from "../sandbox/egress-token.js";
4
4
  import type { SessionStore } from "../store/types.js";
5
5
  import type { Logger } from "../tenant/types.js";
6
+ import { type EgressCredentials } from "./egress-credentials.js";
6
7
  /**
7
8
  * What egress-gate reads of a sandbox: its current host epoch, the pod that joined at it, and
8
9
  * its spec's `network.allow`; `undefined` when it is gone, deleted, lost, expired or not a pod.
@@ -46,6 +47,8 @@ export interface StartEgressGateOptions {
46
47
  readonly blocked?: (address: string) => boolean;
47
48
  /** Tests replace how the checked address is dialled. */
48
49
  readonly dial?: (address: string, port: number) => Socket;
50
+ /** The vault and keys, for hosts an `environment_secret` is bound to (R2c). None: tunnels only. */
51
+ readonly credentials?: EgressCredentials;
49
52
  }
50
53
  export interface EgressGate {
51
54
  /** Where the gate listens, e.g. `http://0.0.0.0:4200`. */
@@ -17,7 +17,10 @@
17
17
  * (cloud metadata included), multicast or reserved, else 502 or 403;
18
18
  * 6. the gate connects to that checked address (never to the name again) and pipes.
19
19
  *
20
- * No TLS interception and no credential injection: a tunnel is opaque bytes. Any other request
20
+ * A tunnel is opaque bytes, with one exception (R2c, D50): port 443 of a host that an
21
+ * `environment_secret` of a live session on the sandbox is bound to. There the gate terminates
22
+ * TLS and sets the credential's header on every request (`egress-credentials.ts`), still sending
23
+ * only to the checked address. Every other host gets the tunnel. Any other request
21
24
  * (an absolute-form plain HTTP request included) is answered 405. Refusals are logged, never as
22
25
  * events; the token never is. Tunnels close after `idleMs` without traffic in either direction.
23
26
  */
@@ -27,6 +30,7 @@ import { BlockList, connect, isIP } from "node:net";
27
30
  import { isSandboxHostPattern } from "@nylorun/core/define";
28
31
  import { isPrivateAddress } from "../tenant/outbound.js";
29
32
  import { bindListener } from "../host/http.js";
33
+ import { credentialTerminator } from "./egress-credentials.js";
30
34
  /** Ports a tunnel may open. */
31
35
  export const EGRESS_PORTS = [443, 80];
32
36
  /** Tunnels one sandbox may hold open at once. */
@@ -151,6 +155,9 @@ export async function startEgressGate(options) {
151
155
  cache.delete(cache.keys().next().value);
152
156
  return value;
153
157
  }
158
+ const terminator = options.credentials
159
+ ? credentialTerminator({ credentials: options.credentials, logger, idleMs })
160
+ : undefined;
154
161
  const open = new Map();
155
162
  const tunnels = new Set();
156
163
  async function onConnect(req, client, head) {
@@ -209,6 +216,12 @@ export async function startEgressGate(options) {
209
216
  return refuse(403, "address_refused", { sandboxId }, `${host} resolves only to addresses tunnels may not reach`);
210
217
  if (client.destroyed)
211
218
  return;
219
+ if (terminator && port === 443 && (await options.credentials.bound(sandboxId, host))) {
220
+ // A client that spoke before the 200 is not one this path serves.
221
+ if (head.length > 0)
222
+ return refuse(403, "early_data", { sandboxId }, "wait for the CONNECT answer before sending");
223
+ return terminator.terminate(client, { sandboxId, host, addresses: allowed });
224
+ }
212
225
  const upstream = await dialFirst(allowed.map((entry) => entry.address), port);
213
226
  if (!upstream)
214
227
  return refuse(502, "connect_failed", { sandboxId }, `could not connect to ${host}:${port}`);
@@ -34,6 +34,10 @@ export interface TenantVault {
34
34
  authorizeMcp(sessionId: string, request: McpCredentialRequest): Promise<AuthorizeResult>;
35
35
  /** Vault writes and token signing with the Tenant's vault key (the keys service, F4.2). */
36
36
  keys(): Keys;
37
+ /** Whether a live session on the sandbox has an `environment_secret` bound to `host` (R2c). */
38
+ environmentSecretBound(sandboxId: string, host: string): Promise<boolean>;
39
+ /** The header an `environment_secret` sets on one request to `host` (R2c). */
40
+ releaseEnvironmentSecret(sandboxId: string, host: string): ReturnType<VaultService["releaseEnvironmentSecret"]>;
37
41
  }
38
42
  export interface TenantVaults {
39
43
  /**
@@ -52,6 +52,8 @@ export function createTenantVaults(options) {
52
52
  session,
53
53
  authorizeMcp: (sessionId, request) => authorizeSessionMcp(credentials, session, sessionId, request),
54
54
  keys: () => keys,
55
+ environmentSecretBound: (sandboxId, host) => credentials.environmentSecretBound(sandboxId, host),
56
+ releaseEnvironmentSecret: (sandboxId, host) => credentials.releaseEnvironmentSecret({ sandboxId, host }),
55
57
  };
56
58
  }
57
59
  /** The database's Tenant, once its schema is at this build's version. */
@@ -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";
@@ -19,12 +19,14 @@
19
19
  * services, so the open would fail. A core-only process relies on a Worker having registered
20
20
  * them first.
21
21
  *
22
- * **One Worker URL.** `start` registers the Worker endpoint (`NYLORUN_WORKER_URL`) with
23
- * Restate once. Restate sends new invocations to the latest registered deployment, so
24
- * several Workers that each register their own URL do not share load: the last one to start
25
- * takes every new invocation. The local stack runs a single `runtime` container with
26
- * `--service core,loop`. A deployment with several Worker processes (Helm, Cloud) must advertise one
27
- * load-balanced `NYLORUN_WORKER_URL` shared by all of them.
22
+ * **One Worker URL per version.** `start` registers the Worker endpoint with Restate once, as
23
+ * `<NYLORUN_WORKER_URL>/nylorun/<version>` (`workerDeployment` in `infra/execution.ts`).
24
+ * Restate sends new invocations to the latest registered deployment, so several Workers that
25
+ * each register their own URL do not share load: the last one to start takes every new
26
+ * invocation. The local stack runs a single `runtime` container with `--service core,loop`. A
27
+ * deployment with several Worker processes (Helm, Cloud) must advertise one load-balanced
28
+ * `NYLORUN_WORKER_URL` shared by all of them; a rolling upgrade registers the new version's
29
+ * path next to the old one, which keeps the invocations it started.
28
30
  */
29
31
  import type { DurableExecution } from "../execution/types.js";
30
32
  import type { Logger } from "../tenant/types.js";
@@ -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
  }
package/dist/host/main.js CHANGED
@@ -56,6 +56,7 @@ import { createRunGrants } from "../tenant/run-grants.js";
56
56
  import { runHarness } from "../harness/main.js";
57
57
  import { startHarnessListener } from "../harness-api/ws-server.js";
58
58
  import { httpSandboxesClient } from "../sandbox/pods/client.js";
59
+ import { retry } from "../retry.js";
59
60
  const nodeRequire = createRequire(import.meta.url);
60
61
  function coreVersion() {
61
62
  try {
@@ -121,22 +122,15 @@ function objectStore(config) {
121
122
  * uploads and downloads fail meanwhile) and does not hold up the Host.
122
123
  */
123
124
  async function ensureBucket(store, bucket, logger) {
124
- for (let attempt = 1;; attempt += 1) {
125
- try {
126
- await store.ensureBucket();
127
- logger.info("object_store_ready", { bucket });
128
- return;
129
- }
130
- catch (error) {
131
- if (attempt >= 10) {
132
- logger.error("object_store_unavailable", {
133
- bucket,
134
- message: error instanceof Error ? error.message : String(error),
135
- });
136
- return;
137
- }
138
- await new Promise((resolve) => setTimeout(resolve, Math.min(1000 * attempt, 5000)));
139
- }
125
+ try {
126
+ await retry(() => store.ensureBucket(), { minMs: 1000, maxMs: 5000, attempts: 10 });
127
+ logger.info("object_store_ready", { bucket });
128
+ }
129
+ catch (error) {
130
+ logger.error("object_store_unavailable", {
131
+ bucket,
132
+ message: error instanceof Error ? error.message : String(error),
133
+ });
140
134
  }
141
135
  }
142
136
  /**
@@ -39,6 +39,12 @@ export interface StackEndpoints {
39
39
  restateIngressUrl?: string;
40
40
  restateAdminUrl?: string;
41
41
  workerUrl?: string;
42
+ /**
43
+ * The Worker deployment's version, from `NYLORUN_WORKER_VERSION` (default: the Runtime's
44
+ * version): the Worker registers `<NYLORUN_WORKER_URL>/nylorun/<version>` with Restate
45
+ * (`infra/execution.ts`, `workerDeployment`).
46
+ */
47
+ workerVersion?: string;
42
48
  s2Endpoint?: string;
43
49
  s2Token?: string;
44
50
  workspaceStoreUrl?: string;
@@ -281,6 +281,12 @@ export function parseStackConfig(env, argv) {
281
281
  const workerUrl = parseUrl(env, "NYLORUN_WORKER_URL", http);
282
282
  if (workerUrl)
283
283
  endpoints.workerUrl = workerUrl;
284
+ const workerVersion = read(env, "NYLORUN_WORKER_VERSION");
285
+ if (workerVersion !== undefined) {
286
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(workerVersion))
287
+ throw new StackConfigError("NYLORUN_WORKER_VERSION must be 1 to 64 letters, digits, `.`, `_` or `-` (it is a URL path segment)");
288
+ endpoints.workerVersion = workerVersion;
289
+ }
284
290
  const s2Endpoint = parseUrl(env, "NYLORUN_S2_ENDPOINT", http);
285
291
  if (s2Endpoint)
286
292
  endpoints.s2Endpoint = s2Endpoint;