@jr2/orchestrator 0.2.1 → 0.3.0

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.
@@ -10,7 +10,7 @@
10
10
  // socket-free through an injected `fetch`.
11
11
  //
12
12
  // Channel split (ADR-0002, refined by ADR-0016): the Harness wire carries **lifecycle only** —
13
- // domain events go up the MCP channel via the Adapter. The client's `send`/`wait` pair is exactly
13
+ // domain events go up the Menu, through the pod's Custodian. The client's `send`/`wait` pair is exactly
14
14
  // that lifecycle surface: `send` answers with a serializable Admission
15
15
  // (`{ streamUrl, offset, submissionId }` — jr2's durable re-attach handle, stored in the host
16
16
  // ledger), and `wait(admission)` follows the durable stream from the admission offset to the
@@ -477,7 +477,7 @@ async function errorDetail(res: Response): Promise<string> {
477
477
 
478
478
  /**
479
479
  * The marker the `@kind` tier greps for, and therefore a CONTRACT — duplicated verbatim in
480
- * `@jr2/adapter` (the two packages share no runtime dependency) and matched in
480
+ * `@jr2/harness`'s Menu (the two packages share no runtime dependency) and matched in
481
481
  * `features/steps/kind.steps.ts`. Renaming it on one side does not break a build; it silently turns
482
482
  * the tier's routability budget into a check that passes because it matches nothing.
483
483
  */
@@ -0,0 +1,587 @@
1
+ // Held secrets (ADR-0059): credentials the Harness uses and never holds.
2
+ //
3
+ // The Agent executes code in the Harness container (ADR-0005), so a key in that container's env is
4
+ // a key it can take out of the pod. A held secret is not there: the Harness's env carries a
5
+ // STAND-IN, and the Custodian — a container of the same pod that the Agent executes nothing in —
6
+ // puts the value into requests toward the hosts the secret is bound to, and nowhere else.
7
+ //
8
+ // This module is the pure half, shared by every seat that needs it:
9
+ //
10
+ // standIn(name) the value the Harness sees — deterministic and public
11
+ // heldSecretsOf(...) `jr2 up`'s normalization: every refusal (R1–R13), every warning, and the
12
+ // engine-neutral manifest (`held.json`) every composition reads
13
+ //
14
+ // It runs HOST-SIDE, in `jr2 up`, because only there does the config carry values: in-cluster,
15
+ // `process.env` has no `.env`, so a `value` reads `undefined` and a `hosts` entry built from `.env`
16
+ // reads `""`. The in-cluster Orchestrator never re-derives any of this. It reads the manifest
17
+ // `jr2 up` wrote, per provision, from a mount (the `jr2-images` pattern, ADR-0038).
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { readFile } from "node:fs/promises";
21
+ import { isIP } from "node:net";
22
+ import type { JR2Config, HarnessEnvVar } from "./config.ts";
23
+ import { MODEL_KEYS, MODEL_KEY_PROVIDERS, SIGNING_CREDENTIALS } from "./model-keys.ts";
24
+ import { HELD_CA_SECRET, HELD_SECRETS_SECRET, HELD_TLS_SECRET, INSTANCE_SECRET } from "./names.ts";
25
+
26
+ /** The kit's Secrets no Harness container may read (R13): the Instance token and signing key, the
27
+ * literal held values, the leaves, and the CA. */
28
+ const KIT_SECRETS: readonly string[] = [INSTANCE_SECRET, HELD_SECRETS_SECRET, HELD_TLS_SECRET, HELD_CA_SECRET];
29
+
30
+ /**
31
+ * The Stand-in for one held secret: what its env var holds in the Harness container.
32
+ *
33
+ * Deterministic and public — it is not a secret, the Agent reads it from its env, and it means
34
+ * something only to the Custodian and only toward a bound host. Unique per name, so one request can
35
+ * carry two secrets' Stand-ins and the Custodian can tell them apart. ASCII letters, digits, `-`
36
+ * and `_` only, so it is a valid header and env value; and it never contains `sk-ant-oat`, which pi
37
+ * reads as an Anthropic OAuth token and answers with different auth headers.
38
+ */
39
+ export function standIn(name: string): string {
40
+ return `jr2-held-${name}`;
41
+ }
42
+
43
+ /** The request headers a Stand-in is swapped in when a held secret names none — the four places a
44
+ * model API carries its key: `Authorization: Bearer` (OpenAI-compatible, `ANTHROPIC_AUTH_TOKEN`),
45
+ * `x-api-key` (Anthropic), `x-goog-api-key` (Gemini), `api-key` (Azure). */
46
+ export const DEFAULT_CREDENTIAL_HEADERS: readonly string[] = [
47
+ "authorization",
48
+ "x-api-key",
49
+ "x-goog-api-key",
50
+ "api-key",
51
+ ];
52
+
53
+ /** Headers the Custodian treats as a credential on every bound host, whether a secret names them or
54
+ * not: one that is not the product of a swap is stripped, and one sent twice is refused. */
55
+ export const CREDENTIAL_HEADERS: readonly string[] = [...DEFAULT_CREDENTIAL_HEADERS, "proxy-authorization"];
56
+
57
+ /** The env var the custom provider's key rides as (ADR-0018) — held since ADR-0059, and the kit's. */
58
+ export const PROVIDER_KEY_NAME = "JR2_PROVIDER_API_KEY";
59
+
60
+ /** The Sandbox token's env var (ADR-0013) — a held secret of the kit's own, bound to the
61
+ * Orchestrator (ADR-0059). The Harness container holds its Stand-in; the Custodian holds the token. */
62
+ export const SANDBOX_TOKEN_NAME = "JR2_SANDBOX_TOKEN";
63
+
64
+ /** The env names the mechanism owns on a Harness container (ADR-0059): the proxy, the trust
65
+ * bundles, and every `JR2_` name. A held secret may not choose them, and while a held secret exists,
66
+ * `harness.env` may not either — the Custodian cannot chain to a second proxy yet (R11). */
67
+ export const MECHANISM_ENV: readonly string[] = [
68
+ "HTTPS_PROXY",
69
+ "https_proxy",
70
+ "HTTP_PROXY",
71
+ "http_proxy",
72
+ "NO_PROXY",
73
+ "no_proxy",
74
+ "ALL_PROXY",
75
+ "NODE_USE_ENV_PROXY",
76
+ "NODE_EXTRA_CA_CERTS",
77
+ "SSL_CERT_FILE",
78
+ "REQUESTS_CA_BUNDLE",
79
+ "GIT_SSL_CAINFO",
80
+ ];
81
+
82
+ /** What `jr2 up` resolved; what every composition reads (`held.json` in the `jr2-held`
83
+ * ConfigMap). Values never appear here. */
84
+ export type HeldManifest = { version: 1; secrets: HeldBinding[] };
85
+
86
+ /** One held secret, normalized. */
87
+ export type HeldBinding = {
88
+ /** The env var; the Harness sees `standIn(name)`. */
89
+ name: string;
90
+ /** A literal lands in the `jr2-held-secrets` Secret under `name`; a `secret` is the user's own
91
+ * Secret, mounted by reference so a rotation reaches new pods without a `jr2 up`. */
92
+ source: { kind: "literal" } | { kind: "secret"; secret: string; key: string };
93
+ /** `leaf` is the file stem of this host's certificate in `jr2-held-tls` (`leafStem`). */
94
+ hosts: HeldHost[];
95
+ headers: string[];
96
+ paths?: string[];
97
+ };
98
+
99
+ export type HeldHost = { host: string; port: number; leaf: string };
100
+
101
+ /** The manifest with nothing held — what `jr2 up` writes when the config holds no secret. */
102
+ export const EMPTY_HELD: HeldManifest = { version: 1, secrets: [] };
103
+
104
+ /**
105
+ * The file stem of one bound `host:port`'s leaf certificate: 16 hex characters of its sha-256.
106
+ * One leaf per target, because the Custodian selects the leaf by CONNECT target — and a change to
107
+ * one host leaves the others' leaves alone.
108
+ */
109
+ export function leafStem(host: string, port: number): string {
110
+ return createHash("sha256").update(`${host}:${port}`).digest("hex").slice(0, 16);
111
+ }
112
+
113
+ /** The inputs `heldSecretsOf` cannot compute: what the cluster holds, read by `jr2 up`. */
114
+ export type HeldLookups = {
115
+ /** The key NAMES (never the values) of each Secret and ConfigMap `harness.envFrom` names, by
116
+ * `secret/<name>` or `configmap/<name>` — R1 refuses a known model key among them. */
117
+ envFromKeys?: ReadonlyMap<string, readonly string[]>;
118
+ /** The key names of a Secret a `valueFrom.secretKeyRef` names, or undefined when it does not
119
+ * exist in the namespace (R10). */
120
+ secretKeys?: (name: string) => readonly string[] | undefined;
121
+ /** Every model specifier a carried Agent definition names (`anthropic/claude-sonnet-4-6`) — R9's
122
+ * evidence that a provider's turns will reach its host. */
123
+ models?: readonly string[];
124
+ };
125
+
126
+ /** `heldSecretsOf`'s answer: the manifest, the literal values (host-side only — they go into the
127
+ * `jr2-held-secrets` Secret and nowhere else), and what `jr2 up` should warn about. */
128
+ export type HeldResolution = {
129
+ manifest: HeldManifest;
130
+ values: Record<string, string>;
131
+ warnings: string[];
132
+ };
133
+
134
+ const NAME = /^[A-Z_][A-Z0-9_]*$/;
135
+ const HEADER_TOKEN = /^[a-z0-9!#$%&'*+.^_`|~-]+$/;
136
+ const FRAMING_HEADERS = new Set([
137
+ "host",
138
+ "content-length",
139
+ "transfer-encoding",
140
+ "connection",
141
+ "upgrade",
142
+ "te",
143
+ "trailer",
144
+ "keep-alive",
145
+ "cookie",
146
+ ]);
147
+
148
+ /**
149
+ * Normalize `harness.heldSecrets` (plus the implicit `JR2_PROVIDER_API_KEY`) into the manifest,
150
+ * or throw the FIRST refusal. Every message names the config path and never a value, and says
151
+ * what to write instead — the `checkGit` style.
152
+ */
153
+ export function heldSecretsOf(config: JR2Config, lookups: HeldLookups = {}): HeldResolution {
154
+ const harness = config.harness ?? {};
155
+ const warnings: string[] = [];
156
+ const env = harness.env ?? [];
157
+
158
+ // R7 and R8 first: the catalog and the provider decide the effective hosts every default reads.
159
+ const catalogHosts = new Map<string, string>();
160
+ for (const [id, reach] of Object.entries(harness.catalog ?? {})) {
161
+ if (!MODEL_KEY_PROVIDERS.has(id)) {
162
+ throw new Error(
163
+ `harness.catalog.${id}: "${id}" is not a pi catalog provider jr2 knows a key for — the catalog ids are ` +
164
+ `${[...MODEL_KEY_PROVIDERS].sort().join(", ")} (ADR-0059)`,
165
+ );
166
+ }
167
+ if (harness.provider && harness.provider.id === id) {
168
+ throw new Error(
169
+ `harness.catalog.${id}: "${id}" is also harness.provider.id — a custom provider owns its own baseUrl; ` +
170
+ "rename the custom provider or drop the catalog entry (ADR-0059)",
171
+ );
172
+ }
173
+ const target = httpsTarget(reach.baseUrl);
174
+ if (!target) {
175
+ throw new Error(
176
+ `harness.catalog.${id}.baseUrl must be an https:// URL — the Custodian carries TLS only (ADR-0059). ` +
177
+ 'Read it from .env: catalog: { anthropic: { baseUrl: process.env.LITELLM_URL ?? "" } }',
178
+ );
179
+ }
180
+ catalogHosts.set(id, target.port === 443 ? target.host : `${target.host}:${target.port}`);
181
+ }
182
+
183
+ // R1: a known model key the Agent could read.
184
+ for (const [i, v] of env.entries()) {
185
+ if (!(v.name in MODEL_KEYS)) continue;
186
+ throw new Error(
187
+ `harness.env[${i}] sets ${v.name}, a model key the Agent could read and take out of the pod — hold it ` +
188
+ `instead: heldSecrets: [${heldLine(v)}] (ADR-0059)`,
189
+ );
190
+ }
191
+ for (const [i, ref] of (harness.envFrom ?? []).entries()) {
192
+ const lookup = ref.secretRef
193
+ ? `secret/${ref.secretRef.name}`
194
+ : ref.configMapRef
195
+ ? `configmap/${ref.configMapRef.name}`
196
+ : "";
197
+ // The env var names, as the kubelet writes them: `prefix` first.
198
+ const keys = (lookups.envFromKeys?.get(lookup) ?? []).map((key) => (ref.prefix ?? "") + key);
199
+ const known = keys.find((key) => key in MODEL_KEYS);
200
+ if (known === undefined) continue;
201
+ const where = ref.secretRef ? `Secret "${ref.secretRef.name}"` : `ConfigMap "${ref.configMapRef?.name}"`;
202
+ throw new Error(
203
+ `harness.envFrom[${i}] loads ${where}, which carries ${known} — a model key the Agent could read. Hold it ` +
204
+ `instead: heldSecrets: [{ name: "${known}", valueFrom: { secretKeyRef: { name: "${ref.secretRef?.name ?? "<secret>"}", key: "${known}" } } }], ` +
205
+ `and drop the key from that ${ref.secretRef ? "Secret" : "ConfigMap"} (ADR-0059)`,
206
+ );
207
+ }
208
+ for (const v of env) {
209
+ if (SIGNING_CREDENTIALS.includes(v.name)) {
210
+ warnings.push(
211
+ `harness.env sets ${v.name}: a signing credential cannot be held (a Stand-in cannot be swapped into a ` +
212
+ "signature), so the Agent can read it — route the provider through a gateway that holds it, and hold " +
213
+ "the gateway's key instead (ADR-0059)",
214
+ );
215
+ }
216
+ }
217
+
218
+ type Entry = {
219
+ at: string;
220
+ name: string;
221
+ value?: string;
222
+ valueFrom?: { secretKeyRef: { name: string; key: string } };
223
+ hosts?: readonly string[];
224
+ headers?: readonly string[];
225
+ paths?: readonly string[];
226
+ };
227
+ const entries: Entry[] = (harness.heldSecrets ?? []).map((s, i) => ({ at: `harness.heldSecrets[${i}]`, ...s }));
228
+
229
+ // R2, for the user's own entries — before the implicit one joins them.
230
+ for (const e of entries) {
231
+ if (!NAME.test(e.name)) {
232
+ throw new Error(`${e.at}.name "${e.name}" is not an env var name ([A-Z_][A-Z0-9_]*) (ADR-0059)`);
233
+ }
234
+ if (e.name.startsWith("JR2_") || MECHANISM_ENV.includes(e.name)) {
235
+ throw new Error(
236
+ `${e.at}.name ${e.name} is the kit's — JR2_ names and the proxy and trust variables belong to the ` +
237
+ "mechanism (ADR-0059). Name the env var your client reads.",
238
+ );
239
+ }
240
+ }
241
+
242
+ // The custom provider's key: held since ADR-0059, bound to its own endpoint.
243
+ const provider = harness.provider;
244
+ if (provider?.apiKey) {
245
+ const target = httpsTarget(provider.baseUrl);
246
+ if (!target) {
247
+ throw new Error(
248
+ "harness.provider.apiKey is set, and harness.provider.baseUrl is not https:// — the key is a held secret " +
249
+ "and the Custodian carries TLS only (ADR-0059). Serve the endpoint over TLS (and name its CA in " +
250
+ "harness.caBundle), or drop apiKey for an endpoint that needs none.",
251
+ );
252
+ }
253
+ entries.push({
254
+ at: "harness.provider.apiKey",
255
+ name: PROVIDER_KEY_NAME,
256
+ value: provider.apiKey,
257
+ hosts: [provider.baseUrl],
258
+ headers: DEFAULT_CREDENTIAL_HEADERS,
259
+ });
260
+ }
261
+
262
+ if (entries.length > 0) {
263
+ // R11: the mechanism's names are the mechanism's while it runs.
264
+ for (const [i, v] of env.entries()) {
265
+ if (MECHANISM_ENV.includes(v.name)) {
266
+ throw new Error(
267
+ `harness.env[${i}] sets ${v.name}, which the Custodian owns while a held secret exists — it cannot ` +
268
+ "chain to another proxy or trust store yet. Drop the entry; put a private CA in harness.caBundle (ADR-0059).",
269
+ );
270
+ }
271
+ }
272
+ }
273
+
274
+ const envNames = new Set(env.map((v) => v.name));
275
+ const seen = new Set<string>();
276
+ const values: Record<string, string> = {};
277
+ const secrets: HeldBinding[] = [];
278
+ for (const e of entries) {
279
+ // R3
280
+ if (seen.has(e.name)) throw new Error(`${e.at}.name ${e.name} is held twice — one env var, one meaning (ADR-0059)`);
281
+ seen.add(e.name);
282
+ if (envNames.has(e.name)) {
283
+ throw new Error(
284
+ `${e.at}.name ${e.name} is also set by harness.env — drop that entry; the Harness sees the Stand-in (ADR-0059)`,
285
+ );
286
+ }
287
+
288
+ // R4
289
+ const source = sourceOf(e, lookups);
290
+ if (source.kind === "literal") values[e.name] = e.value as string;
291
+
292
+ // R6
293
+ const headers = [...(e.headers ?? DEFAULT_CREDENTIAL_HEADERS)];
294
+ const seenHeader = new Set<string>();
295
+ for (const [j, header] of headers.entries()) {
296
+ if (!HEADER_TOKEN.test(header) || FRAMING_HEADERS.has(header) || header.startsWith("proxy-")) {
297
+ throw new Error(
298
+ `${e.at}.headers[${j}] "${header}" cannot carry a held secret — a lowercase header name that does not ` +
299
+ "frame or route the request (not host, content-length, transfer-encoding, connection, upgrade, te, " +
300
+ "trailer, keep-alive, cookie, proxy-*) (ADR-0059)",
301
+ );
302
+ }
303
+ if (seenHeader.has(header)) throw new Error(`${e.at}.headers lists "${header}" twice (ADR-0059)`);
304
+ seenHeader.add(header);
305
+ }
306
+
307
+ // R12
308
+ const paths = e.paths === undefined ? undefined : [...e.paths];
309
+ for (const [j, path] of (paths ?? []).entries()) {
310
+ if (!/^\/[A-Za-z0-9._~!$&'()*+,=:@%/-]*$/.test(path) || path.includes("..") || /%2e|%2f|%5c/i.test(path)) {
311
+ throw new Error(
312
+ `${e.at}.paths[${j}] "${path}" must start with "/" and hold no "..", ";", "%2e", "%2f" or "%5c" — a prefix the ` +
313
+ "request could step around is no prefix (ADR-0059)",
314
+ );
315
+ }
316
+ }
317
+
318
+ // R5 (+ the §3.4 default and R9)
319
+ const hosts = hostsOf(e, catalogHosts, lookups.models ?? []);
320
+ secrets.push({
321
+ name: e.name,
322
+ source,
323
+ hosts: hosts.map((t) => ({ ...t, leaf: leafStem(t.host, t.port) })),
324
+ headers,
325
+ ...(paths ? { paths } : {}),
326
+ });
327
+ }
328
+
329
+ // R13: a held value the Harness container is also handed, under any name, is not held — `env`
330
+ // wins only a same-name collision (R3), so the Stand-in cannot cover it under another name. The
331
+ // kit's own Secrets are never the Harness's, whatever is held.
332
+ const heldBy = (secret: string, key?: string): string | undefined =>
333
+ secrets.find(
334
+ (b) => b.source.kind === "secret" && b.source.secret === secret && (key === undefined || b.source.key === key),
335
+ )?.name;
336
+ for (const [i, v] of env.entries()) {
337
+ const ref = (v.valueFrom as { secretKeyRef?: { name?: string; key?: string } } | undefined)?.secretKeyRef;
338
+ if (ref?.name !== undefined && KIT_SECRETS.includes(ref.name)) {
339
+ throw new Error(
340
+ `harness.env[${i}] (${v.name}) reads Secret "${ref.name}", which is the kit's and never the Harness's — the ` +
341
+ "Agent would read it. Drop the entry (ADR-0059)",
342
+ );
343
+ }
344
+ const by = ref?.name !== undefined ? heldBy(ref.name, ref.key) : undefined;
345
+ if (by !== undefined) {
346
+ throw new Error(
347
+ `harness.env[${i}] reads key "${ref!.key}" of Secret "${ref!.name}", which ${by} holds — the Agent would ` +
348
+ `read it as ${v.name}. Drop the entry; the Harness sees the Stand-in as ${by} (ADR-0059)`,
349
+ );
350
+ }
351
+ const same = v.value !== undefined ? Object.keys(values).find((n) => values[n] === v.value) : undefined;
352
+ if (same !== undefined) {
353
+ throw new Error(
354
+ `harness.env[${i}] (${v.name}) is set to the value ${same} holds — the Agent would read it there. Drop the ` +
355
+ `entry; the Harness sees the Stand-in as ${same} (ADR-0059)`,
356
+ );
357
+ }
358
+ }
359
+ for (const [i, ref] of (harness.envFrom ?? []).entries()) {
360
+ const secret = ref.secretRef?.name;
361
+ if (secret === undefined) continue;
362
+ if (KIT_SECRETS.includes(secret)) {
363
+ throw new Error(
364
+ `harness.envFrom[${i}] loads Secret "${secret}", which is the kit's and never the Harness's — the Agent ` +
365
+ "would read every key of it. Drop the entry (ADR-0059)",
366
+ );
367
+ }
368
+ const by = heldBy(secret);
369
+ if (by !== undefined) {
370
+ throw new Error(
371
+ `harness.envFrom[${i}] loads Secret "${secret}", which ${by} holds — the Agent would read every key of it ` +
372
+ "from its env. Move the held key to a Secret harness.envFrom does not load (ADR-0059)",
373
+ );
374
+ }
375
+ }
376
+
377
+ // R5: one header on one host names one secret.
378
+ const byTarget = new Map<string, { name: string; headers: string[] }[]>();
379
+ for (const s of secrets) {
380
+ for (const h of s.hosts) {
381
+ const key = `${h.host}:${h.port}`;
382
+ for (const other of byTarget.get(key) ?? []) {
383
+ const shared = s.headers.filter((header) => other.headers.includes(header));
384
+ if (shared.length > 0) {
385
+ throw new Error(
386
+ `${s.name} and ${other.name} are both bound to ${key} in header ${shared.join(", ")} — one header on ` +
387
+ "one host carries one secret; give each its own `headers` (ADR-0059)",
388
+ );
389
+ }
390
+ }
391
+ byTarget.set(key, [...(byTarget.get(key) ?? []), { name: s.name, headers: s.headers }]);
392
+ }
393
+ }
394
+
395
+ return { manifest: { version: 1, secrets }, values, warnings };
396
+ }
397
+
398
+ /** R4 and R10: exactly one source, and a literal that can ride a header. */
399
+ function sourceOf(
400
+ e: { at: string; name: string; value?: string; valueFrom?: { secretKeyRef: { name: string; key: string } } },
401
+ lookups: HeldLookups,
402
+ ): HeldBinding["source"] {
403
+ const hasValue = e.value !== undefined;
404
+ if (hasValue === (e.valueFrom !== undefined)) {
405
+ throw new Error(
406
+ `${e.at} needs exactly one of value or valueFrom — ` +
407
+ (hasValue
408
+ ? "not both"
409
+ : `${e.name} has neither, which is what an unset .env variable reads as: set ${e.name} in .env, or ` +
410
+ "reference a Secret with valueFrom: { secretKeyRef: { name, key } }") +
411
+ " (ADR-0059)",
412
+ );
413
+ }
414
+ if (e.valueFrom) {
415
+ const { name, key } = e.valueFrom.secretKeyRef;
416
+ const keys = lookups.secretKeys?.(name);
417
+ if (keys === undefined) {
418
+ throw new Error(
419
+ `${e.at}.valueFrom names Secret "${name}", which does not exist in the instance's namespace — create it ` +
420
+ `first: kubectl create secret generic ${name} --from-literal=${key}=... (ADR-0019, ADR-0059)`,
421
+ );
422
+ }
423
+ if (!keys.includes(key)) {
424
+ throw new Error(`${e.at}.valueFrom names key "${key}" of Secret "${name}", which has no such key (ADR-0059)`);
425
+ }
426
+ return { kind: "secret", secret: name, key };
427
+ }
428
+ const value = e.value as string;
429
+ if (value === "") {
430
+ throw new Error(`${e.at}.value is empty — set ${e.name} in .env (ADR-0059)`);
431
+ }
432
+ if (!/^[\x20-\x7e]+$/.test(value) || value !== value.trim()) {
433
+ throw new Error(
434
+ `${e.at}.value holds a character a header cannot carry (outside printable ASCII) or leading or trailing ` +
435
+ "space — a pasted newline, most likely; the value itself is not shown (ADR-0059)",
436
+ );
437
+ }
438
+ return { kind: "literal" };
439
+ }
440
+
441
+ /** R5 and R9: where one secret may go. A known model key with no `hosts` goes where its provider's
442
+ * calls go — the catalog override's host, else the table's — so the key and the calls read one
443
+ * config and cannot drift. */
444
+ function hostsOf(
445
+ e: { at: string; name: string; hosts?: readonly string[] },
446
+ catalogHosts: ReadonlyMap<string, string>,
447
+ models: readonly string[],
448
+ ): Array<{ host: string; port: number }> {
449
+ const known = MODEL_KEYS[e.name];
450
+ const effective = new Map<string, string | undefined>();
451
+ for (const provider of known?.providers ?? []) effective.set(provider, catalogHosts.get(provider) ?? known?.host);
452
+
453
+ if (e.hosts === undefined || e.hosts.length === 0) {
454
+ const defaults = new Set(effective.values());
455
+ const only = defaults.size === 1 ? [...defaults][0] : undefined;
456
+ if (e.hosts === undefined && only !== undefined) return [parseTarget(`${e.at}.hosts`, only)];
457
+ throw new Error(
458
+ `${e.at}.hosts is ${e.hosts === undefined ? "absent" : "empty"}, and ${e.name} has no single default host — ` +
459
+ 'name where the value may go: hosts: ["gateway.example.com"] (ADR-0059)',
460
+ );
461
+ }
462
+
463
+ const targets = e.hosts.map((entry, j) => parseTarget(`${e.at}.hosts[${j}]`, entry));
464
+ for (const [provider, host] of effective) {
465
+ if (host === undefined) continue;
466
+ if (!models.some((m) => m.startsWith(`${provider}/`))) continue;
467
+ const want = parseTarget(`${e.at}.hosts`, host);
468
+ if (targets.some((t) => t.host === want.host && t.port === want.port)) continue;
469
+ throw new Error(
470
+ `${e.at}.hosts leaves out ${want.host}${want.port === 443 ? "" : `:${want.port}`}, where pi sends every ` +
471
+ `"${provider}/…" turn an Agent here names — each would arrive with the Stand-in and fail with 401. Add it, ` +
472
+ `or drop hosts to take that default (ADR-0059)`,
473
+ );
474
+ }
475
+ return targets;
476
+ }
477
+
478
+ /** An `https://` URL's host and port, or undefined for anything else. */
479
+ function httpsTarget(url: string): { host: string; port: number } | undefined {
480
+ let parsed: URL;
481
+ try {
482
+ parsed = new URL(url);
483
+ } catch {
484
+ return undefined;
485
+ }
486
+ if (parsed.protocol !== "https:" || !parsed.hostname) return undefined;
487
+ return { host: normalHost(parsed.hostname), port: parsed.port ? Number(parsed.port) : 443 };
488
+ }
489
+
490
+ /** One `hosts` entry: `host`, `host:port`, `[v6]:port`, or an `https://` URL (R5). */
491
+ export function parseTarget(at: string, entry: string): { host: string; port: number } {
492
+ const refuse = (why: string): never => {
493
+ throw new Error(`${at} "${entry}" ${why} — an entry is host, host:port, or an https:// URL (ADR-0059)`);
494
+ };
495
+ if (entry.includes("*")) refuse("holds a wildcard; a binding names exact hosts");
496
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(entry)) {
497
+ if (!/^https:\/\//i.test(entry)) refuse("is not https://; the Custodian carries TLS only");
498
+ let url: URL;
499
+ try {
500
+ url = new URL(entry);
501
+ } catch {
502
+ return refuse("is not a URL");
503
+ }
504
+ if (url.username || url.password) refuse("carries userinfo");
505
+ return checkedTarget(refuse, url.hostname, url.port ? Number(url.port) : 443);
506
+ }
507
+ if (/[/?#@\s]/.test(entry)) refuse("has a path, a query or userinfo on a bare host");
508
+ const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(entry);
509
+ if (bracketed) return checkedTarget(refuse, bracketed[1] as string, portOf(refuse, bracketed[2]));
510
+ if (isIP(entry) === 6) return checkedTarget(refuse, entry, 443);
511
+ const [host, port, extra] = entry.split(":");
512
+ if (extra !== undefined) refuse("is not host:port");
513
+ return checkedTarget(refuse, host as string, portOf(refuse, port));
514
+ }
515
+
516
+ function portOf(refuse: (why: string) => never, raw: string | undefined): number {
517
+ if (raw === undefined) return 443;
518
+ if (!/^\d+$/.test(raw)) return refuse("has a port that is not a number");
519
+ return Number(raw);
520
+ }
521
+
522
+ function checkedTarget(refuse: (why: string) => never, rawHost: string, port: number): { host: string; port: number } {
523
+ if (!Number.isInteger(port) || port < 1 || port > 65535) refuse("has a port outside 1–65535");
524
+ const host = normalHost(rawHost);
525
+ if (isIP(host) === 0 && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/.test(host)) {
526
+ refuse("is not a DNS name or an IP literal");
527
+ }
528
+ return { host, port };
529
+ }
530
+
531
+ /** One spelling per host: lowercase, no trailing dot, no IPv6 brackets, IPv6 compressed — the form
532
+ * the Custodian compares a CONNECT target in. */
533
+ export function normalHost(raw: string): string {
534
+ const bare = raw
535
+ .replace(/^\[|\]$/g, "")
536
+ .replace(/\.$/, "")
537
+ .toLowerCase();
538
+ if (isIP(bare) === 6) return new URL(`https://[${bare}]/`).hostname.replace(/^\[|\]$/g, "");
539
+ return bare;
540
+ }
541
+
542
+ /** The `heldSecrets` line R1 hands back, built from the entry it refuses. */
543
+ function heldLine(v: HarnessEnvVar): string {
544
+ const ref = (v.valueFrom as { secretKeyRef?: { name?: string; key?: string } } | undefined)?.secretKeyRef;
545
+ if (ref?.name && ref.key) {
546
+ return `{ name: "${v.name}", valueFrom: { secretKeyRef: { name: "${ref.name}", key: "${ref.key}" } } }`;
547
+ }
548
+ return `{ name: "${v.name}", value: process.env.${v.name} }`;
549
+ }
550
+
551
+ /**
552
+ * Read the manifest `jr2 up` wrote (`held.json`, mounted from the `jr2-held` ConfigMap), per call —
553
+ * the `readImageRefs` stance (ADR-0038): no cache, so a `jr2 up` that changes a held secret reaches
554
+ * the next provision without rolling the Orchestrator. Absent or malformed is a pointed error naming
555
+ * `jr2 up`, never "nothing held": a Sandbox composed on a guess would put a key where the converge
556
+ * did not, or leave out one it did.
557
+ */
558
+ export async function readHeldManifest(path: string): Promise<HeldManifest> {
559
+ let raw: string;
560
+ try {
561
+ raw = await readFile(path, "utf8");
562
+ } catch (err) {
563
+ throw new Error(
564
+ `no held-secret manifest at ${path} (${(err as NodeJS.ErrnoException).code ?? "read failed"}) — every ` +
565
+ "Harness pod's Custodian is composed from the `jr2-held` ConfigMap, which `jr2 up` writes (ADR-0059). " +
566
+ "Converge this instance with `jr2 up`.",
567
+ );
568
+ }
569
+ const bad = (why: string): never => {
570
+ throw new Error(`the held-secret manifest at ${path} is malformed: ${why} — re-run \`jr2 up\` (ADR-0059).`);
571
+ };
572
+ let parsed: unknown;
573
+ try {
574
+ parsed = JSON.parse(raw);
575
+ } catch (err) {
576
+ return bad(`not JSON (${err instanceof Error ? err.message : err})`);
577
+ }
578
+ const manifest = parsed as Partial<HeldManifest>;
579
+ if (manifest?.version !== 1 || !Array.isArray(manifest.secrets))
580
+ return bad("expected { version: 1, secrets: [...] }");
581
+ for (const s of manifest.secrets) {
582
+ if (typeof s?.name !== "string" || !Array.isArray(s.hosts) || !Array.isArray(s.headers)) {
583
+ bad(`an entry is not { name, source, hosts, headers } (got ${JSON.stringify(s)})`);
584
+ }
585
+ }
586
+ return manifest as HeldManifest;
587
+ }