@jr2/orchestrator 0.2.1 → 0.4.1

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