@nylorun/runtime 0.48.0-beta → 0.49.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.49.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - c4758c0: Pods on Agent Substrate (preview): keys now reach commands through Substrate's own egress ([ADR-142](https://github.com/nylorun/factory/blob/main/decisions/platform/2026-11-13-substrate-egress-adds-keys.md)). A command sends its stand-in, with no proxy, and Substrate's egress replaces the header on each request to a host the Agent has a key for, so `curl`, `gh` and SDKs work against keyed hosts, over HTTP/2 too. A removed key now fails closed with 403 instead of sending the stand-in.
8
+
9
+ - Each host the Agent has an `environment_secret` or slot for, and each host its owner connected through the outside credential source, becomes an `https` rule in the actor's egress policy that names a reference, never a value.
10
+ - The keys provider (`sandboxes provider`, from the sandboxes image) runs beside Substrate's egress. For every keyed request it asks egress-gate's new decide route (`POST /nylorun/v1/egress/decide`, token `NYLORUN_EGRESS_KEYS_TOKEN`), which checks that the actor is the sandbox's current incarnation and returns the vault's header value. For a person's key it returns only who owns it, and the provider fetches the value from the outside source's new `/v1/fetch`.
11
+ - The outside credential source's `/v1/hosts` may name each host's header (`{ host, header }`); a plain host name still means `Authorization`.
12
+ - The engine on Substrate writes a trust bundle with Substrate's egress CA and names it in `SSL_CERT_FILE` and the other trust variables.
13
+ - `nylorun local sandbox enable --backend substrate` deploys the keys provider in `ate-system`, publishes the edge's keys port (8795 by default), and checks that Substrate's egress was installed asking it (`--credential-provider='[{"name":"keys.nylorun.dev","address":"nylorun-keys-provider.ate-system.svc:50051"}]'`). When it wasn't, keys stay off and `enable` says how to turn them on. `sandbox status` shows whether keys are on.
14
+
15
+ ## 0.48.1-beta
16
+
17
+ ### Patch Changes
18
+
19
+ - f581772: An Agent created again under a deleted Agent's id no longer takes that Agent's state. Before, it read `failing` or `input_required` from sessions whose last turn ran on the deleted Agent, before it existed. Its `state` and `attention` now count a session only once that session takes a turn on the new Agent. The sessions still keep the id and can go on with the new Agent, as before, and they still appear in its `sessions`.
20
+
3
21
  ## 0.48.0-beta
4
22
 
5
23
  ### Minor Changes
@@ -12,6 +12,11 @@ export declare const PROVIDER_HEADERS: {
12
12
  };
13
13
  /** Every header of this prefix a sandbox sends is dropped before the engine sets its own. */
14
14
  export declare const PROVIDER_HEADER_PREFIX = "nylorun-provider-";
15
+ /** A host an owner's key is bound to, and the header the key goes in. */
16
+ export interface ProviderHost {
17
+ readonly host: string;
18
+ readonly header: string;
19
+ }
15
20
  /** One variable a pod Agent's segments set: its name and the value the sandbox sees. */
16
21
  export interface ProviderVariable {
17
22
  readonly name: string;
@@ -38,6 +43,11 @@ export interface CredentialProvider {
38
43
  * match as `network.allow`'s do (`egressAllows`); none without an owner.
39
44
  */
40
45
  hosts(agentId: string, owner: string | undefined): Promise<readonly string[]>;
46
+ /**
47
+ * The same hosts, each with the header its key goes in (contract v2, ADR-142). Optional for a
48
+ * provider an embedder supplies: without it every host's key goes in `Authorization`.
49
+ */
50
+ keyedHosts?(agentId: string, owner: string | undefined): Promise<readonly ProviderHost[]>;
41
51
  /** Where one request to `path` (origin form, `/v2/projects?x=1`) is handed. */
42
52
  forwardUrl(path: string): URL;
43
53
  /** The headers that go with it, the provider's token among them. */
@@ -6,9 +6,9 @@
6
6
  * here runs.
7
7
  *
8
8
  * Three calls, each with `Authorization: Bearer <token>`:
9
- * - `POST <url>/v1/hosts` `{ agentId, owner }` → `{ hosts: string[] }`: the hosts the owner's keys
10
- * are bound to, each an exact host name or a `*.<suffix>` pattern (`*.googleapis.com`, v0.26
11
- * TM-E1). A pattern matches as `network.allow`'s do: `run.googleapis.com` and
9
+ * - `POST <url>/v1/hosts` `{ agentId, owner }` → `{ hosts: (string | { host, header?, format? })[] }`:
10
+ * the hosts the owner's keys are bound to, each an exact host name or a `*.<suffix>` pattern
11
+ * (`*.googleapis.com`, v0.26 TM-E1), with the header the key goes in (v2, ADR-142). A pattern matches as `network.allow`'s do: `run.googleapis.com` and
12
12
  * `a.b.googleapis.com`, never `googleapis.com` itself. An IP literal or a malformed entry is
13
13
  * dropped. egress-gate lets a pod Agent's sandbox reach them (inside the Tenant's egress
14
14
  * ceiling), terminates TLS for them on 443 and hands each request on;
@@ -23,7 +23,11 @@
23
23
  * sent them (its own `Authorization` or inject header included, which the provider replaces),
24
24
  * plus `nylorun-provider-host`, `-agent`, `-owner` and, in place of a bearer the sandbox's own
25
25
  * `Authorization` would clash with, `nylorun-provider-token`. The provider sends it on to
26
- * `https://<host>/<path>` and streams the answer back (`egress-credentials.ts`).
26
+ * `https://<host>/<path>` and streams the answer back (`egress-credentials.ts`). Retires with
27
+ * egress-gate (ADR-142);
28
+ * - `POST <url>/v1/fetch` `{ agentId, owner, host }` → `{ value }` (v2, ADR-142): the whole header
29
+ * value of the owner's key for `host`, asked by the keys provider beside Agent Substrate's
30
+ * egress after the Tenant allowed it, never by the Runtime. `404` when there is no such key.
27
31
  *
28
32
  * Only an Agent with an owner (its owner guard) is asked about; one without gets nothing and no
29
33
  * call. An answer is kept per Agent and owner for `credentialProvider.cacheMs` (15 s). A call
@@ -131,7 +135,8 @@ export function createCredentialProvider(settings, options) {
131
135
  return {
132
136
  url,
133
137
  environment: (agentId, owner) => answer("environment", agentId, owner),
134
- hosts: (agentId, owner) => answer("hosts", agentId, owner),
138
+ hosts: async (agentId, owner) => (await answer("hosts", agentId, owner)).map((entry) => entry.host),
139
+ keyedHosts: (agentId, owner) => answer("hosts", agentId, owner),
135
140
  forwardUrl: (path) => new URL(`${url}/v1/forward${path.startsWith("/") ? path : `/${path}`}`),
136
141
  forwardHeaders: ({ host, agentId, owner }) => ({
137
142
  [PROVIDER_HEADERS.host]: host,
@@ -141,13 +146,35 @@ export function createCredentialProvider(settings, options) {
141
146
  }),
142
147
  };
143
148
  }
149
+ /** An HTTP header name (RFC 9110 token). */
150
+ const HEADER = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/;
144
151
  /**
145
- * The hosts of a `/v1/hosts` answer: exact host names and `*.<suffix>` patterns, lowercase, without
146
- * a trailing root dot, once each; an IP literal or a malformed entry is dropped.
152
+ * The hosts of a `/v1/hosts` answer (contract v2, ADR-142): each entry is a host name or a
153
+ * `*.<suffix>` pattern, or `{ host, header?, format? }` naming the header the key goes in
154
+ * (`Authorization` by default; `format` is the source's own business). Lowercase, without a
155
+ * trailing root dot, once each, the first entry for a host winning; an IP literal or a malformed
156
+ * entry is dropped.
147
157
  */
148
158
  function hostsOf(list) {
149
- const items = list.filter((item) => typeof item === "string").slice(0, ANSWER_MAX);
150
- return [...new Set(items.map((host) => host.toLowerCase().replace(/\.$/, "")).filter(isSandboxHostPattern))];
159
+ const hosts = new Map();
160
+ for (const item of list) {
161
+ if (hosts.size >= ANSWER_MAX)
162
+ break;
163
+ const entry = typeof item === "string"
164
+ ? { host: item, header: undefined }
165
+ : item !== null && typeof item === "object" && typeof item.host === "string"
166
+ ? { host: item.host, header: item.header }
167
+ : undefined;
168
+ if (!entry)
169
+ continue;
170
+ const host = entry.host.toLowerCase().replace(/\.$/, "");
171
+ if (!isSandboxHostPattern(host) || hosts.has(host))
172
+ continue;
173
+ if (entry.header !== undefined && (typeof entry.header !== "string" || !HEADER.test(entry.header)))
174
+ continue;
175
+ hosts.set(host, { host, header: entry.header ?? "Authorization" });
176
+ }
177
+ return [...hosts.values()];
151
178
  }
152
179
  /**
153
180
  * The variables of a `/v1/environment` answer, each with the value the sandbox sees: a bare name is
@@ -0,0 +1,54 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { Logger } from "../tenant/types.js";
3
+ import type { CredentialProvider } from "./credential-provider.js";
4
+ import type { EgressCredentialRelease } from "./egress-credentials.js";
5
+ /** The route's path. */
6
+ export declare const EGRESS_DECIDE_PATH = "/nylorun/v1/egress/decide";
7
+ /** What the route reads of a sandbox: its incarnation, owner and the Tenant's egress ceiling. */
8
+ export interface DecideSandboxes {
9
+ live(sandboxId: string): Promise<{
10
+ /** The current incarnation's name: the actor's name on Agent Substrate. */
11
+ readonly name?: string;
12
+ readonly owner?: string;
13
+ readonly ceiling?: readonly string[];
14
+ } | undefined>;
15
+ }
16
+ export interface EgressDecideOptions {
17
+ /** NYLORUN_EGRESS_KEYS_TOKEN; none: the route is off. */
18
+ readonly token?: string;
19
+ readonly sandboxes: DecideSandboxes;
20
+ /** The vault's release of an `environment_secret` bound to a host (as egress-gate's). */
21
+ release(sandboxId: string, host: string): Promise<EgressCredentialRelease>;
22
+ /** The outside credential source, for a person's keys. */
23
+ readonly provider?: CredentialProvider;
24
+ readonly logger: Logger;
25
+ }
26
+ export type Decision = {
27
+ readonly status: 200;
28
+ readonly body: {
29
+ readonly value: string;
30
+ } | {
31
+ readonly fetch: {
32
+ readonly agentId: string;
33
+ readonly owner: string;
34
+ readonly host: string;
35
+ };
36
+ };
37
+ readonly outcome: string;
38
+ readonly credentialId?: string;
39
+ } | {
40
+ readonly status: 400 | 403 | 404 | 503;
41
+ readonly code: string;
42
+ readonly outcome: string;
43
+ };
44
+ /** The decision for one keyed request (steps 1–4 above). */
45
+ export declare function decide(options: Omit<EgressDecideOptions, "token" | "logger">, input: {
46
+ readonly actor: string;
47
+ readonly sandboxId: string;
48
+ readonly host: string;
49
+ }): Promise<Decision>;
50
+ /**
51
+ * The route's handler: `true` when it answered `req`, `false` for any other request, which the
52
+ * caller answers as before.
53
+ */
54
+ export declare function egressDecideHandler(options: EgressDecideOptions): (req: IncomingMessage, res: ServerResponse) => boolean;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The keys provider's decide route (ADR-142): on Agent Substrate, Substrate's egress adds a
3
+ * sandbox's keys, and the keys provider beside it asks the Tenant here, for every keyed request,
4
+ * what to add. It is served on egress-gate's listener (the gateway's `--service egress`, 4200),
5
+ * which the sandboxes service's edge forwards its keys port to; no actor's egress policy allows
6
+ * that port.
7
+ *
8
+ * `POST /nylorun/v1/egress/decide` with `Authorization: Bearer <NYLORUN_EGRESS_KEYS_TOKEN>` and
9
+ * `{ actor, sandboxId, host }`, where `actor` is the actor's name as Substrate's egress verified it
10
+ * and `sandboxId` and `host` come from the reference in the actor's rule
11
+ * (`ate-secret://keys.nylorun.dev/<sandboxId>/<host>`). In order:
12
+ *
13
+ * 1. the sandbox is a live pod whose current incarnation is `actor` (its `k8sName`), else 403:
14
+ * one actor can never use another sandbox's reference;
15
+ * 2. an `environment_secret` of its Environment bound to `host` wins, as on egress-gate: the vault
16
+ * releases it (one read, audited, unsealed by keys) and `200 { value }` carries the whole header
17
+ * value; a refusal is 403;
18
+ * 3. a host the outside credential source lists for the Agent's owner, inside the Tenant's egress
19
+ * ceiling, is allowed: `200 { fetch: { agentId, owner, host } }`, and the keys provider asks the
20
+ * source for the value. The Runtime never sees a person's key (ADR-82 §3, D49);
21
+ * 4. else `404 not_found`.
22
+ *
23
+ * Without the token setting the route is off (404). Every decision is logged, never a value.
24
+ */
25
+ import { timingSafeEqual, createHash } from "node:crypto";
26
+ import { isSandboxHostPattern } from "@nylorun/core/define";
27
+ import { withinCeiling } from "../sandbox/resolve.js";
28
+ import { egressAllows } from "./egress.js";
29
+ /** The route's path. */
30
+ export const EGRESS_DECIDE_PATH = "/nylorun/v1/egress/decide";
31
+ /** The largest body the route reads. */
32
+ const MAX_BODY = 4096;
33
+ function digest(value) {
34
+ return createHash("sha256").update(value).digest();
35
+ }
36
+ /** The decision for one keyed request (steps 1–4 above). */
37
+ export async function decide(options, input) {
38
+ const sandbox = await options.sandboxes.live(input.sandboxId);
39
+ if (!sandbox || sandbox.name === undefined || sandbox.name !== input.actor)
40
+ return { status: 403, code: "permission_denied", outcome: "not_this_sandbox" };
41
+ const released = await options.release(input.sandboxId, input.host);
42
+ if (released.status === "released")
43
+ return { status: 200, body: { value: released.value }, outcome: "vault", credentialId: released.credentialId };
44
+ if (released.status === "refused")
45
+ return { status: 403, code: "permission_denied", outcome: `vault_${released.reason}` };
46
+ const { provider } = options;
47
+ if (provider && sandbox.owner !== undefined) {
48
+ const hosts = await provider.hosts(input.sandboxId, sandbox.owner);
49
+ const listed = hosts.includes(input.host) || egressAllows(input.host, hosts);
50
+ if (listed && sandbox.ceiling && !withinCeiling(input.host, sandbox.ceiling))
51
+ return { status: 403, code: "permission_denied", outcome: "outside_ceiling" };
52
+ if (listed)
53
+ return {
54
+ status: 200,
55
+ body: { fetch: { agentId: input.sandboxId, owner: sandbox.owner, host: input.host } },
56
+ outcome: "outside",
57
+ };
58
+ }
59
+ return { status: 404, code: "not_found", outcome: "no_key" };
60
+ }
61
+ /**
62
+ * The route's handler: `true` when it answered `req`, `false` for any other request, which the
63
+ * caller answers as before.
64
+ */
65
+ export function egressDecideHandler(options) {
66
+ const want = options.token ? digest(options.token) : undefined;
67
+ const send = (res, status, body) => {
68
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
69
+ res.end(JSON.stringify(body));
70
+ };
71
+ return (req, res) => {
72
+ const path = (req.url ?? "").split("?")[0];
73
+ if (path !== EGRESS_DECIDE_PATH)
74
+ return false;
75
+ if (!want) {
76
+ send(res, 404, { error: { code: "not_found", message: "keys at Substrate's egress are off" } });
77
+ return true;
78
+ }
79
+ if (req.method !== "POST") {
80
+ send(res, 405, { error: { code: "method_not_allowed", message: "POST only" } });
81
+ return true;
82
+ }
83
+ const presented = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization ?? "")?.[1];
84
+ if (!presented || !timingSafeEqual(digest(presented), want)) {
85
+ options.logger.warn("egress_decide_refused", { reason: "token" });
86
+ send(res, 401, { error: { code: "unauthorized", message: "missing or wrong token" } });
87
+ return true;
88
+ }
89
+ const chunks = [];
90
+ let size = 0;
91
+ req.on("data", (chunk) => {
92
+ size += chunk.length;
93
+ if (size <= MAX_BODY)
94
+ chunks.push(chunk);
95
+ });
96
+ req.on("end", () => {
97
+ void (async () => {
98
+ let input;
99
+ try {
100
+ const body = size <= MAX_BODY ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : undefined;
101
+ if (body &&
102
+ typeof body.actor === "string" &&
103
+ typeof body.sandboxId === "string" &&
104
+ typeof body.host === "string" &&
105
+ body.actor !== "" &&
106
+ body.sandboxId !== "" &&
107
+ isSandboxHostPattern(body.host))
108
+ input = { actor: body.actor, sandboxId: body.sandboxId, host: body.host.toLowerCase() };
109
+ }
110
+ catch {
111
+ input = undefined;
112
+ }
113
+ if (!input) {
114
+ send(res, 400, { error: { code: "invalid_request", message: "the body is {actor, sandboxId, host}" } });
115
+ return;
116
+ }
117
+ let decision;
118
+ try {
119
+ decision = await decide(options, input);
120
+ }
121
+ catch (error) {
122
+ options.logger.warn("egress_decide_failed", {
123
+ sandboxId: input.sandboxId,
124
+ host: input.host,
125
+ error: error instanceof Error ? error.message : String(error),
126
+ });
127
+ send(res, 503, { error: { code: "unavailable", message: "the decision failed; retry" } });
128
+ return;
129
+ }
130
+ const fields = {
131
+ sandboxId: input.sandboxId,
132
+ host: input.host,
133
+ outcome: decision.outcome,
134
+ ...("credentialId" in decision && decision.credentialId ? { credentialId: decision.credentialId } : {}),
135
+ };
136
+ if (decision.status === 200) {
137
+ options.logger.info("egress_decided", fields);
138
+ send(res, 200, decision.body);
139
+ }
140
+ else {
141
+ options.logger.warn("egress_decide_refused", fields);
142
+ send(res, decision.status, { error: { code: decision.code, message: decision.outcome } });
143
+ }
144
+ })();
145
+ });
146
+ return true;
147
+ };
148
+ }
@@ -17,6 +17,8 @@ export interface EgressSandboxes {
17
17
  live(sandboxId: string): Promise<{
18
18
  readonly epoch: number;
19
19
  readonly podUid?: string;
20
+ /** The incarnation's name (`k8sName`): the actor's name on Agent Substrate. */
21
+ readonly name?: string;
20
22
  readonly allow: readonly string[];
21
23
  readonly owner?: string;
22
24
  readonly ceiling?: readonly string[];
@@ -76,6 +78,11 @@ export interface StartEgressGateOptions {
76
78
  * Agent's owner pass on 443 and are handed to it. Needs `credentials` (the leaf). None: off.
77
79
  */
78
80
  readonly provider?: CredentialProvider;
81
+ /**
82
+ * NYLORUN_EGRESS_KEYS_TOKEN: the keys provider's token for the decide route (ADR-142,
83
+ * `egress-decide.ts`). None: the route is off.
84
+ */
85
+ readonly keysToken?: string;
79
86
  }
80
87
  export interface EgressGate {
81
88
  /** Where the gate listens, e.g. `http://0.0.0.0:4200`. */
@@ -51,6 +51,7 @@ import { isSandboxHostPattern } from "@nylorun/core/define";
51
51
  import { isPrivateAddress } from "../tenant/outbound.js";
52
52
  import { readSandboxConfig } from "../sandbox/tenant-config.js";
53
53
  import { withinCeiling } from "../sandbox/resolve.js";
54
+ import { egressDecideHandler } from "./egress-decide.js";
54
55
  import { bindListener } from "../host/http.js";
55
56
  import { MAX_HELLO_BYTES, readClientHello } from "./client-hello.js";
56
57
  import { hostTuning } from "../tuning.js";
@@ -448,7 +449,17 @@ export async function startEgressGate(options) {
448
449
  }
449
450
  return undefined;
450
451
  }
452
+ // Agent Substrate's keys provider asks here what to add to a keyed request (ADR-142).
453
+ const decideRoute = egressDecideHandler({
454
+ ...(options.keysToken ? { token: options.keysToken } : {}),
455
+ sandboxes: { live: (sandboxId) => sandboxes.live(sandboxId) },
456
+ release: (sandboxId, host) => options.credentials ? options.credentials.release(sandboxId, host) : Promise.resolve({ status: "none" }),
457
+ ...(options.provider ? { provider: options.provider } : {}),
458
+ logger,
459
+ });
451
460
  const server = createServer((req, res) => {
461
+ if (decideRoute(req, res))
462
+ return;
452
463
  logger.warn("egress_refused", { status: 405, reason: "not_connect", method: req.method });
453
464
  res.writeHead(405, { allow: "CONNECT", "content-type": "text/plain; charset=utf-8", connection: "close" });
454
465
  res.end("egress-gate: only CONNECT is served; set HTTPS_PROXY and HTTP_PROXY to this address\n");
@@ -511,6 +522,7 @@ export function storeEgressSandboxes(store) {
511
522
  return {
512
523
  epoch: pod.hostEpoch,
513
524
  ...(pod.podUid ? { podUid: pod.podUid } : {}),
525
+ name: pod.k8sName,
514
526
  // The applied allowlist: a recipe change writes it at once (Q8).
515
527
  allow: sandbox.applied.network?.allowedHosts ?? [],
516
528
  ...(owner === undefined ? {} : { owner }),
@@ -55,6 +55,7 @@ export async function runHarness(stack, baseline) {
55
55
  pod = podHost(config.pod, logger, {
56
56
  volumeFile: join(config.root, "volume-id"),
57
57
  caBundleFile: join(config.root, "egress-ca-bundle.pem"),
58
+ ...(config.pod.egressCaFile ? { extraCaFile: config.pod.egressCaFile } : {}),
58
59
  });
59
60
  // Join, then make the disk ready (R2d step 2) before taking any turn.
60
61
  await pod.token();
@@ -62,6 +62,11 @@ export declare function podHost(config: PodHostConfig, logger: {
62
62
  volumeFile?: string;
63
63
  /** Where the engine writes the trust bundle: the public roots and the egress CA (R2c). */
64
64
  caBundleFile?: string;
65
+ /**
66
+ * Agent Substrate's egress CA (`NYLORUN_SANDBOX_EGRESS_CA_FILE`, ADR-142): Substrate's egress
67
+ * ends TLS for the hosts whose keys it adds, so commands trust it with no proxy in between.
68
+ */
69
+ extraCaFile?: string;
65
70
  /** Whether this engine runs from the workload image (default: from where its code is). */
66
71
  imageEngine?: boolean;
67
72
  }): PodHost;
@@ -57,8 +57,8 @@ export function podHost(config, logger, options = {}) {
57
57
  if (!response.ok)
58
58
  throw new Error(`host/${path} answered ${response.status}`);
59
59
  const body = JSON.parse(text);
60
- if (typeof body.caCertificate === "string")
61
- await writeBundle(body.caCertificate);
60
+ if (typeof body.caCertificate === "string" || options.extraCaFile)
61
+ await writeBundle(body.caCertificate ?? "");
62
62
  if (path === "join") {
63
63
  setup = body.setup;
64
64
  seeded = body.seeded === true;
@@ -74,14 +74,24 @@ export function podHost(config, logger, options = {}) {
74
74
  };
75
75
  const writeBundle = async (ca) => {
76
76
  const file = options.caBundleFile;
77
- if (!file || ca === bundled)
77
+ let extra = "";
78
+ if (options.extraCaFile) {
79
+ try {
80
+ extra = (await readFile(options.extraCaFile, "utf8")).trim();
81
+ }
82
+ catch {
83
+ // Not mounted (yet): the bundle holds the roots and the join's CA.
84
+ }
85
+ }
86
+ const content = [ca.trim(), extra].filter((part) => part !== "").join("\n");
87
+ if (!file || content === bundled)
78
88
  return;
79
89
  await mkdir(dirname(file), { recursive: true });
80
90
  // Written beside it and renamed, so a command never reads half a bundle.
81
91
  const next = `${file}.${randomUUID()}`;
82
- await writeFile(next, `${rootCertificates.join("\n")}\n${ca.trim()}\n`, { mode: 0o644 });
92
+ await writeFile(next, `${rootCertificates.join("\n")}\n${content}\n`, { mode: 0o644 });
83
93
  await rename(next, file);
84
- bundled = ca;
94
+ bundled = content;
85
95
  };
86
96
  /** The volume's id, created on a volume that has none. */
87
97
  const volumeId = async () => {
@@ -149,8 +159,15 @@ export function podHost(config, logger, options = {}) {
149
159
  return current.hostToken;
150
160
  },
151
161
  proxyEnv() {
152
- if (!config.egressProxy || !held)
162
+ if (!held)
153
163
  return {};
164
+ const trustOnly = bundled !== undefined && options.caBundleFile
165
+ ? Object.fromEntries(TRUST_VARIABLES.map((name) => [name, options.caBundleFile]))
166
+ : {};
167
+ // On Agent Substrate there is no proxy: Substrate's egress adds keys on its own (ADR-142),
168
+ // and commands only need to trust its CA.
169
+ if (!config.egressProxy)
170
+ return options.extraCaFile ? trustOnly : {};
154
171
  const url = new URL(config.egressProxy);
155
172
  url.username = "nylorun";
156
173
  url.password = held.egressToken;
@@ -199,6 +199,7 @@ export async function startEgress(options) {
199
199
  ...(options.credentialProvider
200
200
  ? { provider: createCredentialProvider(options.credentialProvider, { logger: options.logger }) }
201
201
  : {}),
202
+ ...(options.egress.keysToken ? { keysToken: options.egress.keysToken } : {}),
202
203
  ...options.gate,
203
204
  });
204
205
  }
@@ -118,6 +118,11 @@ export interface EgressConfig {
118
118
  host: string;
119
119
  port: number;
120
120
  };
121
+ /**
122
+ * NYLORUN_EGRESS_KEYS_TOKEN: the token Agent Substrate's keys provider presents to the decide
123
+ * route on this listener (ADR-142); none: the route is off. 32 bytes or more as hex.
124
+ */
125
+ keysToken?: string;
121
126
  }
122
127
  /** Where the loop reaches the gates service (`NYLORUN_GATES_URL`, `NYLORUN_GATES_TOKEN`). */
123
128
  export interface ModelGateEndpoint {
@@ -159,6 +164,11 @@ export interface PodHostConfig {
159
164
  httpUrl: string;
160
165
  /** `NYLORUN_EGRESS_PROXY`: egress-gate's address, or undefined while it is not published. */
161
166
  egressProxy?: string;
167
+ /**
168
+ * `NYLORUN_SANDBOX_EGRESS_CA_FILE`: Agent Substrate's egress CA (ADR-142), which commands trust
169
+ * for the hosts whose keys Substrate's egress adds.
170
+ */
171
+ egressCaFile?: string;
162
172
  /** Addresses the pod's NetworkPolicy must block, probed before the engine starts. */
163
173
  blocked: BlockedAddress[];
164
174
  }
@@ -637,6 +637,7 @@ function parsePodHost(env, url) {
637
637
  const secure = base.protocol === "https:" || base.protocol === "wss:";
638
638
  const origin = `${secure ? "https" : "http"}://${base.host}`;
639
639
  const egress = read(env, "NYLORUN_EGRESS_PROXY");
640
+ const egressCa = read(env, "NYLORUN_SANDBOX_EGRESS_CA_FILE");
640
641
  return {
641
642
  wsUrl: `${secure ? "wss" : "ws"}://${base.host}/nylorun/harness/v1`,
642
643
  config: {
@@ -645,6 +646,7 @@ function parsePodHost(env, url) {
645
646
  joinFile: need("NYLORUN_SANDBOX_JOIN_FILE"),
646
647
  httpUrl: origin,
647
648
  ...(egress ? { egressProxy: egress.replace(/\/+$/, "") } : {}),
649
+ ...(egressCa ? { egressCaFile: egressCa } : {}),
648
650
  blocked: blockedAddresses(env),
649
651
  },
650
652
  };
@@ -666,7 +668,13 @@ function parseGates(env) {
666
668
  const REMOVED_RESOLVER_PREFIX = "NYLORUN_RESOLVER_";
667
669
  /** `StackConfig.egress` from `NYLORUN_EGRESS_LISTEN_*`. CONNECT has no Host header to check. */
668
670
  function parseEgress(env) {
669
- return { listen: parseListener(env, "NYLORUN_EGRESS_", DEFAULT_EGRESS_LISTEN_PORT) };
671
+ const keysToken = read(env, "NYLORUN_EGRESS_KEYS_TOKEN");
672
+ if (keysToken !== undefined && !/^[0-9a-f]{64,}$/i.test(keysToken))
673
+ throw new StackConfigError("NYLORUN_EGRESS_KEYS_TOKEN must be 32 bytes or more as hex (nylorun local sandbox enable makes it)");
674
+ return {
675
+ listen: parseListener(env, "NYLORUN_EGRESS_", DEFAULT_EGRESS_LISTEN_PORT),
676
+ ...(keysToken === undefined ? {} : { keysToken }),
677
+ };
670
678
  }
671
679
  /** `StackConfig.modelGate` from `NYLORUN_GATES_URL` and `NYLORUN_GATES_TOKEN`. */
672
680
  function parseModelGate(env) {
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Management API",
5
- "version": "0.48.0-beta",
5
+ "version": "0.49.0-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
package/dist/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.2.0",
3
3
  "info": {
4
4
  "title": "Nylorun Runtime API",
5
- "version": "0.48.0-beta",
5
+ "version": "0.49.0-beta",
6
6
  "x-nylorun-protocol": {
7
7
  "min": 16,
8
8
  "max": 18,
@@ -33,6 +33,17 @@ export interface PodSpec {
33
33
  readonly hosts: readonly string[];
34
34
  readonly suffixes: readonly string[];
35
35
  };
36
+ /**
37
+ * The hosts the sandbox has a key for, each with its header (ADR-142, `keys.ts`). agent-sandbox
38
+ * ignores them; on Agent Substrate each becomes an https rule the keys provider fills.
39
+ */
40
+ readonly keys?: {
41
+ readonly sandbox: string;
42
+ readonly hosts: readonly {
43
+ readonly host: string;
44
+ readonly header: string;
45
+ }[];
46
+ };
36
47
  }
37
48
  /** A Sandbox as the service's informers see it (`driver.Status` in Go). */
38
49
  export interface PodStatus {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The hosts a pod sandbox has a key for (ADR-142), as the sandboxes service renders them on Agent
3
+ * Substrate: an https rule per host whose header Substrate's egress asks the keys provider for.
4
+ * agent-sandbox ignores them (egress-gate adds keys there).
5
+ *
6
+ * The hosts of the Environment's `environment_secret`s and slots (read from the clear binding,
7
+ * no value), then the hosts the outside credential source lists for the Agent's owner, inside the
8
+ * Tenant's egress ceiling; the vault's header wins for a host both name, as on egress-gate. A
9
+ * source that fails names none (it logs once), so its hosts are passed through until the next
10
+ * apply. Values are never read here: the keys provider asks for them per request.
11
+ */
12
+ import type { TenantContext } from "../../tenant/context.js";
13
+ import type { PodSpec } from "./client.js";
14
+ /** The most keyed hosts one sandbox's policy holds (the sandboxes service's bound). */
15
+ export declare const MAX_KEY_HOSTS = 200;
16
+ export declare function podKeysOf(ctx: Pick<TenantContext, "store" | "credentialProvider">, input: {
17
+ readonly sandboxId: string;
18
+ readonly owner?: string;
19
+ readonly ceiling?: readonly string[];
20
+ }): Promise<NonNullable<PodSpec["keys"]>>;
@@ -0,0 +1,27 @@
1
+ import { environmentSecretHosts } from "../../vault/service.js";
2
+ import { withinCeiling } from "../resolve.js";
3
+ /** The most keyed hosts one sandbox's policy holds (the sandboxes service's bound). */
4
+ export const MAX_KEY_HOSTS = 200;
5
+ export async function podKeysOf(ctx, input) {
6
+ const hosts = new Map();
7
+ for (const entry of await ctx.store.tx((t) => environmentSecretHosts(t, input.sandboxId)))
8
+ hosts.set(entry.host, entry.header);
9
+ if (ctx.credentialProvider && input.owner !== undefined) {
10
+ const provider = ctx.credentialProvider;
11
+ const listed = provider.keyedHosts
12
+ ? await provider.keyedHosts(input.sandboxId, input.owner)
13
+ : (await provider.hosts(input.sandboxId, input.owner)).map((host) => ({ host, header: "Authorization" }));
14
+ for (const entry of listed) {
15
+ if (hosts.has(entry.host))
16
+ continue;
17
+ if (input.ceiling && !withinCeiling(entry.host, input.ceiling))
18
+ continue;
19
+ hosts.set(entry.host, entry.header);
20
+ }
21
+ }
22
+ const list = [...hosts.entries()]
23
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
24
+ .slice(0, MAX_KEY_HOSTS)
25
+ .map(([host, header]) => ({ host, header }));
26
+ return { sandbox: input.sandboxId, hosts: list };
27
+ }
@@ -22,6 +22,7 @@ import { sha256Hex } from "../join.js";
22
22
  import { recoverLostVolume } from "../../artifacts/disk.js";
23
23
  import { decide } from "./lifecycle.js";
24
24
  import { podLifecycleConfig, podSpecOf } from "./spec.js";
25
+ import { podKeysOf } from "./keys.js";
25
26
  import { hostTuning } from "../../tuning.js";
26
27
  import { idleMsOf, revertStart } from "../../tenant/agent-versions.js";
27
28
  export async function reconcileSandbox(ctx, sandboxId, trigger) {
@@ -69,6 +70,25 @@ export async function reconcileSandbox(ctx, sandboxId, trigger) {
69
70
  current.rev === row.pod.rev &&
70
71
  current.k8sName === row.pod.k8sName &&
71
72
  current.desired === row.pod.desired;
73
+ let keys;
74
+ if (decision.apply) {
75
+ // The keyed hosts are read at every apply (ADR-142): a host bound or connected later reaches
76
+ // the sandbox's rules on its next start, resume or reset.
77
+ try {
78
+ keys = await podKeysOf(ctx, {
79
+ sandboxId,
80
+ ...(row.ownerUserId === undefined ? {} : { owner: row.ownerUserId }),
81
+ ...(config.limits?.network === undefined ? {} : { ceiling: config.limits.network }),
82
+ });
83
+ }
84
+ catch (error) {
85
+ ctx.config.logger.warn("sandbox keys unread", {
86
+ sandboxId,
87
+ message: error instanceof Error ? error.message : String(error),
88
+ });
89
+ return { retryAfterMs: decision.retryAfterMs ?? hostTuning().pods.reconcileRetryMs };
90
+ }
91
+ }
72
92
  // A new join token is recorded before the service writes it to the Sandbox's Secret.
73
93
  let joinToken;
74
94
  let rev = patch.rev ?? row.pod.rev;
@@ -110,6 +130,7 @@ export async function reconcileSandbox(ctx, sandboxId, trigger) {
110
130
  harnessImage: pods.harnessImage,
111
131
  harness: row.applied.harness,
112
132
  ...(joinToken ? { joinToken } : {}),
133
+ keys,
113
134
  });
114
135
  try {
115
136
  await pods.client.put(row.pod.k8sName, spec);
@@ -43,5 +43,7 @@ export interface PodSpecInput {
43
43
  readonly harness?: string;
44
44
  /** The join token, when this apply rotates it. */
45
45
  readonly joinToken?: string;
46
+ /** The keyed hosts (`keys.ts`), for Agent Substrate's egress. */
47
+ readonly keys?: PodSpec["keys"];
46
48
  }
47
49
  export declare function podSpecOf(input: PodSpecInput): PodSpec;
@@ -72,6 +72,7 @@ export function podSpecOf(input) {
72
72
  },
73
73
  ...(input.joinToken ? { joinToken: input.joinToken } : {}),
74
74
  network: networkOf(spec),
75
+ ...(input.keys ? { keys: input.keys } : {}),
75
76
  };
76
77
  }
77
78
  /** The spec's network as the sandboxes service reads it. */
@@ -25,6 +25,14 @@ export declare function stateOf(agent: Pick<Environment, "desiredState" | "pod">
25
25
  state: AgentState;
26
26
  attention: AgentAttention[];
27
27
  };
28
+ /**
29
+ * The sessions whose latest turn ran on this Agent. A deleted Agent's sessions keep its id
30
+ * (`deleteAgent`), so an Agent created again under that id would otherwise read `failing` or
31
+ * `input_required` from turns it never ran. A session whose latest turn started before this Agent
32
+ * was created is left out of its state until it takes a turn here; one with no turn in the turn
33
+ * index (Host feature `turns`) is kept.
34
+ */
35
+ export declare function ranOnThisAgent<T extends Pick<Session, "id" | "activeTurnId" | "lastTurnId">>(t: Tx, environment: Pick<Environment, "createdAt">, sessions: readonly T[]): Promise<T[]>;
28
36
  /** How an Agent names its manifest: `name` when it tracks it, `name@n` when pinned. */
29
37
  export declare function manifestRefOf(agent: Pick<Environment, "manifestName" | "manifestVersion" | "manifestRef">): string;
30
38
  /**
@@ -141,7 +149,8 @@ export declare function refuseDiskCopy(environment: Environment): void;
141
149
  /**
142
150
  * `DELETE /v1/agents/{id}`: deletes the Environment, its vault and its workspace. Refused
143
151
  * while a session that names it has an active turn. Sessions keep its id: their next turn is
144
- * refused (`agent_unavailable`) unless an Environment with this id exists again.
152
+ * refused (`agent_unavailable`) unless an Environment with this id exists again. An Agent created
153
+ * again under the id does not take their state until they take a turn on it (`ranOnThisAgent`).
145
154
  */
146
155
  export declare function deleteAgent(ctx: TenantContext, id: string, scope: AuthScope): Promise<{
147
156
  id: string;
@@ -150,6 +150,24 @@ export function stateOf(agent, sessions, unsetSecrets = []) {
150
150
  attention.sort((a, b) => STATE_RANK.indexOf(a.state) - STATE_RANK.indexOf(b.state));
151
151
  return { state: attention[0]?.state ?? "sleeping", attention };
152
152
  }
153
+ /**
154
+ * The sessions whose latest turn ran on this Agent. A deleted Agent's sessions keep its id
155
+ * (`deleteAgent`), so an Agent created again under that id would otherwise read `failing` or
156
+ * `input_required` from turns it never ran. A session whose latest turn started before this Agent
157
+ * was created is left out of its state until it takes a turn here; one with no turn in the turn
158
+ * index (Host feature `turns`) is kept.
159
+ */
160
+ export async function ranOnThisAgent(t, environment, sessions) {
161
+ const kept = [];
162
+ for (const session of sessions) {
163
+ const turnId = session.activeTurnId ?? session.lastTurnId;
164
+ const turn = turnId ? await t.turn(session.id, turnId) : undefined;
165
+ if (turn !== undefined && Date.parse(turn.startedAt) < Date.parse(environment.createdAt))
166
+ continue;
167
+ kept.push(session);
168
+ }
169
+ return kept;
170
+ }
153
171
  /** How an Agent names its manifest: `name` when it tracks it, `name@n` when pinned. */
154
172
  export function manifestRefOf(agent) {
155
173
  return agent.manifestRef === "tracking" ? agent.manifestName : `${agent.manifestName}@${agent.manifestVersion}`;
@@ -161,7 +179,7 @@ async function viewOf(ctx, t, environment, owner) {
161
179
  : undefined;
162
180
  const pod = environment.pod;
163
181
  const visible = sessions.filter((session) => owner === undefined || session.ownerUserId === owner);
164
- const { state, attention } = stateOf(environment, visible, await unsetRequiredSecrets(t, environment));
182
+ const { state, attention } = stateOf(environment, await ranOnThisAgent(t, environment, visible), await unsetRequiredSecrets(t, environment));
165
183
  return {
166
184
  id: environment.id,
167
185
  manifest: manifestRefOf(environment),
@@ -898,7 +916,8 @@ async function deleteKeys(ctx, keys) {
898
916
  /**
899
917
  * `DELETE /v1/agents/{id}`: deletes the Environment, its vault and its workspace. Refused
900
918
  * while a session that names it has an active turn. Sessions keep its id: their next turn is
901
- * refused (`agent_unavailable`) unless an Environment with this id exists again.
919
+ * refused (`agent_unavailable`) unless an Environment with this id exists again. An Agent created
920
+ * again under the id does not take their state until they take a turn on it (`ranOnThisAgent`).
902
921
  */
903
922
  export async function deleteAgent(ctx, id, scope) {
904
923
  reachable(agentGrantsOf(scope), id);
@@ -273,4 +273,14 @@ export declare function routingOf(binding: Pick<CredentialBinding, "via" | "viaF
273
273
  via?: string;
274
274
  toolPrefix?: string;
275
275
  };
276
+ /**
277
+ * The hosts Environment `environmentId`'s sandbox has a key for (ADR-142), each with the header
278
+ * the key goes in: the hosts of its live `environment_secret`s and of its applied
279
+ * `environment_secret` slots, filled or not, so a slot whose value is removed still has its rule
280
+ * and fails closed. Read in `t` from the clear binding: no value is read and no key is needed.
281
+ */
282
+ export declare function environmentSecretHosts(t: Tx, environmentId: string): Promise<{
283
+ host: string;
284
+ header: string;
285
+ }[]>;
276
286
  export {};
@@ -1195,3 +1195,35 @@ export function routingOf(binding, slot) {
1195
1195
  const toolPrefix = routed?.toolPrefix ?? binding.toolPrefix;
1196
1196
  return toolPrefix === undefined ? { via } : { via, toolPrefix };
1197
1197
  }
1198
+ /**
1199
+ * The hosts Environment `environmentId`'s sandbox has a key for (ADR-142), each with the header
1200
+ * the key goes in: the hosts of its live `environment_secret`s and of its applied
1201
+ * `environment_secret` slots, filled or not, so a slot whose value is removed still has its rule
1202
+ * and fails closed. Read in `t` from the clear binding: no value is read and no key is needed.
1203
+ */
1204
+ export async function environmentSecretHosts(t, environmentId) {
1205
+ const hosts = new Map();
1206
+ const add = (list, header) => {
1207
+ for (const host of list ?? []) {
1208
+ const name = host.toLowerCase();
1209
+ if (!hosts.has(name))
1210
+ hosts.set(name, header ?? ENVIRONMENT_SECRET_DEFAULT_INJECT.header);
1211
+ }
1212
+ };
1213
+ const slots = await appliedSlots(t, environmentId);
1214
+ const vault = await t.vaultOfEnvironment(environmentId);
1215
+ if (vault) {
1216
+ const live = liveFilterOf(slots);
1217
+ for (const row of await t.credentialsForVault(vault.id)) {
1218
+ if (row.type !== "environment_secret" || !live(row))
1219
+ continue;
1220
+ const binding = credentialBinding(row);
1221
+ add(binding.allowedHosts, binding.inject?.header);
1222
+ }
1223
+ }
1224
+ for (const slot of Object.values(slots ?? {})) {
1225
+ if (slot.type === "environment_secret")
1226
+ add(slot.allowedHosts, slot.inject?.header);
1227
+ }
1228
+ return [...hosts.entries()].map(([host, header]) => ({ host, header }));
1229
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const RUNTIME_VERSION = "0.48.0-beta";
1
+ export declare const RUNTIME_VERSION = "0.49.0-beta";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in step with package.json by runtime/server/scripts/check-package.mjs, which runs in npm run check.
2
2
  // release:prepare also rewrites this when it bumps @nylorun/runtime.
3
- export const RUNTIME_VERSION = "0.48.0-beta";
3
+ export const RUNTIME_VERSION = "0.49.0-beta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/runtime",
3
- "version": "0.48.0-beta",
3
+ "version": "0.49.0-beta",
4
4
  "description": "Nylorun Runtime: the agent execution Host on Postgres, Restate and S2, and model providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",