@nylorun/runtime 0.21.1-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 (44) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/adapters/sandbox/local.js +1 -1
  3. package/dist/adapters/sandbox/virtual.js +1 -1
  4. package/dist/api/components.d.ts +42 -2
  5. package/dist/gates/egress-credentials.d.ts +48 -0
  6. package/dist/gates/egress-credentials.js +147 -0
  7. package/dist/gates/egress.d.ts +3 -0
  8. package/dist/gates/egress.js +14 -1
  9. package/dist/gates/tenant-vaults.d.ts +4 -0
  10. package/dist/gates/tenant-vaults.js +2 -0
  11. package/dist/harness/calls.d.ts +2 -0
  12. package/dist/harness/calls.js +1 -0
  13. package/dist/harness/executors.js +1 -0
  14. package/dist/harness/main.js +4 -1
  15. package/dist/harness/pod.d.ts +2 -0
  16. package/dist/harness/pod.js +36 -1
  17. package/dist/harness-api/start.d.ts +2 -0
  18. package/dist/harness-api/start.js +4 -2
  19. package/dist/harness-api/ws-server.js +2 -2
  20. package/dist/host/gates.js +11 -0
  21. package/dist/keys/egress-ca.d.ts +15 -0
  22. package/dist/keys/egress-ca.js +66 -0
  23. package/dist/keys/keys.d.ts +8 -1
  24. package/dist/keys/keys.js +6 -0
  25. package/dist/keys/x509.d.ts +29 -0
  26. package/dist/keys/x509.js +160 -0
  27. package/dist/management-openapi.json +195 -9
  28. package/dist/openapi.json +2 -1
  29. package/dist/sandbox/join.d.ts +2 -0
  30. package/dist/sandbox/join.js +8 -3
  31. package/dist/sandbox/manager.d.ts +2 -0
  32. package/dist/sandbox/manager.js +1 -1
  33. package/dist/sandbox/tools.d.ts +3 -1
  34. package/dist/sandbox/tools.js +4 -2
  35. package/dist/sandbox/types.d.ts +5 -0
  36. package/dist/store/postgres/schema.d.ts +2 -2
  37. package/dist/store/postgres/schema.js +1 -1
  38. package/dist/tenant/sessions.js +26 -0
  39. package/dist/tenant/transcript.d.ts +8 -8
  40. package/dist/vault/service.d.ts +34 -0
  41. package/dist/vault/service.js +148 -15
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - 212364b: **Credentials for skills (R2c).** A skill's CLI in a pod sandbox authenticates with a vault credential whose value never enters the sandbox.
8
+
9
+ - `@nylorun/core`: vault credentials gain `environment_secret` (`secretName`, `secretValue`, `allowedHosts`, optional `inject: { header, format }`) and `environment_variable` (`variableName`, `variableValue`), in create, rotate and `CredentialInfo` (whose `binding.url` is now optional). `ENVIRONMENT_SECRET_SENTINEL` (`nylorun-managed`), `ENVIRONMENT_SECRET_DEFAULT_INJECT`, `isInjectFormat`, `renderInjectFormat`, `EnvironmentNameSchema` and `RESERVED_ENVIRONMENT_NAMES` are exported; `ERROR_CODES` gains `credential_conflict`; the Harness API's `RunRouting.sandbox` gains `environment`.
10
+ - `@nylorun/runtime`: every sandbox command gets each secret's name set to `nylorun-managed` and each variable's value. egress-gate terminates TLS for port 443 of a host an `environment_secret` of the sandbox's sessions is bound to, with a fresh leaf signed by the installation's egress CA (held by keys; its certificate reaches the pod in the join answer, and the engine points `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO` and `AWS_CA_BUNDLE` at a bundle with it), and sets the credential's header on every request from the vault, uncached. Every other host stays an opaque tunnel. Session open answers `409 credential_conflict` for two credentials with one variable name or one host, or two owners' secrets on one sandbox. No protocol change.
11
+ - `@nylorun/studio`: the Credentials page lists shell credentials (their variable and hosts) and deletes them; create and rotate them with the Management API.
12
+
13
+ ### Patch Changes
14
+
15
+ - Pin core to the tested release.
16
+ - Pin harness to the tested release.
17
+ - Updated dependencies [212364b]
18
+ - Updated dependencies
19
+ - @nylorun/core@0.19.0-beta
20
+ - @nylorun/harness@0.25.1-beta
21
+
3
22
  ## 0.21.1-beta
4
23
 
5
24
  ### Patch Changes
@@ -57,7 +57,7 @@ export function localBackend(options) {
57
57
  return reject(signal.reason ?? new Error("Turn cancelled"));
58
58
  const child = spawn("/bin/sh", ["-c", request.command], {
59
59
  cwd: request.cwd,
60
- env: { ...env, ...(options.proxyEnv?.() ?? {}) },
60
+ env: { ...env, ...(request.env ?? {}), ...(options.proxyEnv?.() ?? {}) },
61
61
  stdio: ["ignore", "pipe", "pipe"],
62
62
  // Its own process group, so a timeout or a cancel kills everything it started.
63
63
  detached: true,
@@ -81,7 +81,7 @@ export function virtualBackend(options) {
81
81
  try {
82
82
  const result = await bash.exec(request.command, {
83
83
  cwd: request.cwd,
84
- env: { ...ENV },
84
+ env: { ...(request.env ?? {}), ...ENV },
85
85
  replaceEnv: true,
86
86
  rawScript: true,
87
87
  signal: controller.signal,
@@ -10,6 +10,7 @@ export declare const Rejected: z.ZodObject<{
10
10
  status: z.ZodLiteral<"rejected">;
11
11
  code: z.ZodEnum<{
12
12
  connection_missing: "connection_missing";
13
+ credential_conflict: "credential_conflict";
13
14
  credential_invalid: "credential_invalid";
14
15
  credential_rejected: "credential_rejected";
15
16
  credential_required: "credential_required";
@@ -8376,6 +8377,19 @@ export declare const CreateCredentialRequest: z.ZodObject<{
8376
8377
  type: z.ZodLiteral<"headers">;
8377
8378
  url: z.ZodString;
8378
8379
  headers: z.ZodRecord<z.ZodString, z.ZodString>;
8380
+ }, z.core.$strict>, z.ZodObject<{
8381
+ type: z.ZodLiteral<"environment_secret">;
8382
+ secretName: z.ZodString;
8383
+ secretValue: z.ZodString;
8384
+ allowedHosts: z.ZodArray<z.ZodString>;
8385
+ inject: z.ZodOptional<z.ZodObject<{
8386
+ header: z.ZodString;
8387
+ format: z.ZodString;
8388
+ }, z.core.$strict>>;
8389
+ }, z.core.$strict>, z.ZodObject<{
8390
+ type: z.ZodLiteral<"environment_variable">;
8391
+ variableName: z.ZodString;
8392
+ variableValue: z.ZodString;
8379
8393
  }, z.core.$strict>], "type">;
8380
8394
  }, z.core.$strict>;
8381
8395
  export declare const CredentialInfo: z.ZodObject<{
@@ -8384,10 +8398,20 @@ export declare const CredentialInfo: z.ZodObject<{
8384
8398
  name: z.ZodString;
8385
8399
  type: z.ZodEnum<{
8386
8400
  bearer: "bearer";
8401
+ environment_secret: "environment_secret";
8402
+ environment_variable: "environment_variable";
8387
8403
  headers: "headers";
8388
8404
  }>;
8389
8405
  binding: z.ZodObject<{
8390
- url: z.ZodString;
8406
+ url: z.ZodOptional<z.ZodString>;
8407
+ secretName: z.ZodOptional<z.ZodString>;
8408
+ allowedHosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
8409
+ inject: z.ZodOptional<z.ZodObject<{
8410
+ header: z.ZodString;
8411
+ format: z.ZodString;
8412
+ }, z.core.$strict>>;
8413
+ variableName: z.ZodOptional<z.ZodString>;
8414
+ variableValue: z.ZodOptional<z.ZodString>;
8391
8415
  }, z.core.$strict>;
8392
8416
  headerNames: z.ZodOptional<z.ZodArray<z.ZodString>>;
8393
8417
  via: z.ZodOptional<z.ZodString>;
@@ -8404,10 +8428,20 @@ export declare const ListCredentialsResponse: z.ZodObject<{
8404
8428
  name: z.ZodString;
8405
8429
  type: z.ZodEnum<{
8406
8430
  bearer: "bearer";
8431
+ environment_secret: "environment_secret";
8432
+ environment_variable: "environment_variable";
8407
8433
  headers: "headers";
8408
8434
  }>;
8409
8435
  binding: z.ZodObject<{
8410
- url: z.ZodString;
8436
+ url: z.ZodOptional<z.ZodString>;
8437
+ secretName: z.ZodOptional<z.ZodString>;
8438
+ allowedHosts: z.ZodOptional<z.ZodArray<z.ZodString>>;
8439
+ inject: z.ZodOptional<z.ZodObject<{
8440
+ header: z.ZodString;
8441
+ format: z.ZodString;
8442
+ }, z.core.$strict>>;
8443
+ variableName: z.ZodOptional<z.ZodString>;
8444
+ variableValue: z.ZodOptional<z.ZodString>;
8411
8445
  }, z.core.$strict>;
8412
8446
  headerNames: z.ZodOptional<z.ZodArray<z.ZodString>>;
8413
8447
  via: z.ZodOptional<z.ZodString>;
@@ -8435,6 +8469,12 @@ export declare const RotateCredentialRequest: z.ZodObject<{
8435
8469
  }, z.core.$strict>>>;
8436
8470
  type: z.ZodLiteral<"headers">;
8437
8471
  headers: z.ZodRecord<z.ZodString, z.ZodString>;
8472
+ }, z.core.$strict>, z.ZodObject<{
8473
+ type: z.ZodLiteral<"environment_secret">;
8474
+ secretValue: z.ZodString;
8475
+ }, z.core.$strict>, z.ZodObject<{
8476
+ type: z.ZodLiteral<"environment_variable">;
8477
+ variableValue: z.ZodString;
8438
8478
  }, z.core.$strict>], "type">;
8439
8479
  }, z.core.$strict>;
8440
8480
  export declare const DeletedResponse: z.ZodObject<{
@@ -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";
@@ -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
  }
@@ -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
+ }