@jr2/cli 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.
@@ -6181,7 +6181,7 @@ spec:
6181
6181
  Repos is the standing report on the caches this Sandbox mounts
6182
6182
  (ADR-0053), one entry per key in spec.repos, in that order, computed on
6183
6183
  every reconcile once the Pod has a node. It is what a fetch inside the
6184
- pod waits on: the program asks the Adapter, the Orchestrator marks the
6184
+ pod waits on: the program asks the Custodian, the Orchestrator marks the
6185
6185
  ask on this CR, and the entry for that key says whether the node fetched
6186
6186
  since. `Ready` is untouched by it — that stays the gate it was, sticky
6187
6187
  and taken once per Pod life; this is the standing answer the gate's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "The jr2 CLI: init, up, run — the interface to a jr2 Instance.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -39,13 +39,14 @@
39
39
  "ts-blank-space": "^0.9.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@jr2/orchestrator": "0.2.1"
42
+ "@jr2/orchestrator": "0.3.0"
43
43
  },
44
44
  "devDependencies": {
45
+ "@anthropic-ai/sdk": "0.91.1",
45
46
  "@types/node": "^26.0.1",
46
47
  "xstate": "^5.18.0",
47
48
  "zod": "^4.4.3",
48
- "@jr2/orchestrator": "0.2.1"
49
+ "@jr2/orchestrator": "0.3.0"
49
50
  },
50
51
  "files": [
51
52
  "bin",
@@ -54,6 +55,7 @@
54
55
  ],
55
56
  "scripts": {
56
57
  "typecheck": "tsc --noEmit",
57
- "test": "node --test"
58
+ "test": "node --test 'test/**/*.test.ts'",
59
+ "test:custodian": "node --test --test-concurrency=1 test/custodian.docker.ts"
58
60
  }
59
61
  }
package/src/build.ts CHANGED
@@ -2,9 +2,10 @@
2
2
  // kinds — the instance's own (engine + this instance's workflows baked, ADR-0008), the instance's
3
3
  // Sandbox Images (the `file:` docker contexts its Machines carry, ADR-0037/0049), and — only when the
4
4
  // CLI is running out of
5
- // a kit CHECKOUT — the Harness, Adapter, and operator images. Installed from npm those kit sources
6
- // do not resolve, so a real instance takes the published-`<kitversion>` path and never needs docker
7
- // for them. The checkout IS the signal: no flag, no config key, no env.
5
+ // a kit CHECKOUT — the Harness and operator images. Installed from npm those kit sources do not
6
+ // resolve, so a real instance takes the published-`<kitversion>` path and never needs docker for
7
+ // them. The checkout IS the signal: no flag, no config key, no env. One image is deployed and never
8
+ // built, in either world: the Custodian's, upstream Envoy pinned by digest (ADR-0038, ADR-0059).
8
9
  //
9
10
  // Every tag is a content address of its own inputs. That is what makes `imagePullPolicy:
10
11
  // IfNotPresent` correct rather than lucky (a unique tag per content means "present" implies
@@ -34,7 +35,7 @@ import { createHash } from "node:crypto";
34
35
  import { execFile, spawn } from "node:child_process";
35
36
  import { cp, mkdir, mkdtemp, readdir, readFile, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
36
37
  import { tmpdir } from "node:os";
37
- import { basename, dirname, join, relative, resolve } from "node:path";
38
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
38
39
  import { fileURLToPath } from "node:url";
39
40
  import { promisify } from "node:util";
40
41
  import { KIT_VERSION } from "@jr2/orchestrator";
@@ -144,7 +145,7 @@ export type BuildPort = {
144
145
  * context and carries no `.dockerignore`, so docker copies `dist/` and `node_modules/` straight in.
145
146
  */
146
147
 
147
- /** For walks under `packages/*` (harness, adapter): their context is the KIT ROOT, so the root
148
+ /** For walks under `packages/*` (the Harness): its context is the KIT ROOT, so the root
148
149
  * `.dockerignore` governs, and the only entries it drops at any depth are `**\/node_modules` and
149
150
  * `**\/dist` (plus `*.log`/`.env` globs `contentHash`'s name-set cannot express — hashing a stray
150
151
  * one of those over-hashes, which is allowed). A `packages/harness/bin/` would be context-VISIBLE,
@@ -226,7 +227,7 @@ export const LABEL_IMAGE_KIND = "jr2.dev/kind";
226
227
  /** The three kinds ADR-0038 builds, and the whole value domain of {@link LABEL_IMAGE_KIND}. */
227
228
  export type ImageKind = "instance" | "sandbox" | "kit";
228
229
 
229
- /** The kit's own images (Harness, Adapter, operator). No instance label: every instance on the
230
+ /** The kit's own images (Harness, operator). No instance label: every instance on the
230
231
  * cluster shares one copy, and "kit images are never swept" is not a rule any more — a kit ref is
231
232
  * kept because some instance's map or pod names it, and collects with everything else when the
232
233
  * last instance leaves (ADR-0039). */
@@ -258,8 +259,8 @@ export function sandboxImageLabels(instance: string): Record<string, string> {
258
259
  * script's default and fails the gate when they drift.
259
260
  *
260
261
  * An arch outside this set is never built for. An instance image for `s390x` is dead weight: no Kit
261
- * image could sit beside it in the pod, so the Sandbox would fail at the Adapter or the Harness
262
- * instead of at the image nobody published.
262
+ * image could sit beside it in the pod, so the Sandbox would fail at the Harness instead of at
263
+ * the image nobody published.
263
264
  */
264
265
  export const SUPPORTED_PLATFORMS: readonly string[] = ["linux/amd64", "linux/arm64"];
265
266
 
@@ -365,8 +366,8 @@ export async function assertEmulation(port: BuildPort, platforms: readonly strin
365
366
 
366
367
  // --- the kit images (ADR-0038) --------------------------------------------------------------
367
368
 
368
- /** The three images the KIT owns. An instance deploys them but never authors them. */
369
- export type KitImageName = "harness" | "adapter" | "operator";
369
+ /** The two images the KIT owns and builds. An instance deploys them but never authors them. */
370
+ export type KitImageName = "harness" | "operator";
370
371
  export type KitImageRefs = Record<KitImageName, string>;
371
372
 
372
373
  type KitImage = {
@@ -415,13 +416,6 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
415
416
  ],
416
417
  exclude: KIT_PACKAGE_EXCLUDE,
417
418
  },
418
- adapter: {
419
- repo: "jr2-adapter",
420
- dockerfile: "deploy/adapter/Dockerfile",
421
- context: ".",
422
- sources: ["packages/adapter", "deploy/adapter/Dockerfile"],
423
- exclude: KIT_PACKAGE_EXCLUDE,
424
- },
425
419
  operator: {
426
420
  repo: "jr2-operator",
427
421
  dockerfile: "operator/Dockerfile",
@@ -451,14 +445,13 @@ export const KIT_IMAGE_HOME = "ghcr.io/snapwich";
451
445
  * home rather than nesting under it: a mirror holds the same tags under its own name, seeded
452
446
  * deliberately (`jr2 kit push`) and never by a converge. It is NOT `config.registry`: that key says
453
447
  * where images this converge BUILDS go, and prefixing both with one key would make every
454
- * private-registry user mirror three images they could have pulled from the home. The version is
448
+ * private-registry user mirror the Kit images they could have pulled from the home. The version is
455
449
  * still the CLI's own — re-homing says where the tags live, never which ones.
456
450
  */
457
451
  export function publishedKitRefs(kitRegistry?: string): KitImageRefs {
458
452
  const home = kitRegistry ?? KIT_IMAGE_HOME;
459
453
  return {
460
454
  harness: `${home}/jr2-harness:${KIT_VERSION}`,
461
- adapter: `${home}/jr2-adapter:${KIT_VERSION}`,
462
455
  operator: `${home}/jr2-operator:${KIT_VERSION}`,
463
456
  };
464
457
  }
@@ -468,12 +461,18 @@ export function publishedKitRefs(kitRegistry?: string): KitImageRefs {
468
461
  * requiring BOTH `deploy/harness/Dockerfile` and a `packages/harness/package.json` that names
469
462
  * `@jr2/harness`. Either marker alone matches an unrelated tree — someone else's `deploy/harness`,
470
463
  * or a vendored copy of one package — and a false positive means `jr2 up` tries to docker-build a
471
- * kit that is not there. Installed from npm neither resolves and the answer is `undefined`.
464
+ * kit that is not there. Installed from npm the answer is `undefined`.
465
+ *
466
+ * A module under `node_modules` came from a registry, so the walk stops there: an instance that
467
+ * lives INSIDE a checkout but installs `@jr2/*` from npm would otherwise reach the checkout root and
468
+ * build a Harness at HEAD beside an Orchestrator at the pinned version. The checkout's own instances
469
+ * link the workspace packages, and node resolves those links, so their CLI loads from `packages/`.
472
470
  */
473
471
  export async function detectKitCheckout(
474
472
  fromDir: string = fileURLToPath(new URL(".", import.meta.url)),
475
473
  ): Promise<string | undefined> {
476
474
  let dir = resolve(fromDir);
475
+ if (dir.split(sep).includes("node_modules")) return undefined;
477
476
  for (;;) {
478
477
  if (await isKitRoot(dir)) return dir;
479
478
  const parent = dirname(dir);
@@ -737,7 +736,7 @@ const emptySweep = (): SweepResult => ({ removed: [], kept: [], failed: [], byte
737
736
  * One report out of several — the host's and every node's, or several converges' (`mergeSweeps` is
738
737
  * associative, so a caller can fold as it goes). Refs are de-duplicated AFTER {@link normalizeRef},
739
738
  * because that is the only way the promise holds: the two stores spell one image differently
740
- * (`jr2-adapter:33a4` on the host, `docker.io/library/jr2-adapter:33a4` on a node), so a raw-string
739
+ * (`jr2-harness:33a4` on the host, `docker.io/library/jr2-harness:33a4` on a node), so a raw-string
741
740
  * set reports one image twice. The merged refs are the normalized spelling — the one the roots, and
742
741
  * the user, name an image by.
743
742
  *
@@ -2,16 +2,18 @@
2
2
  //
3
3
  // Kit images live at a canonical public home (`ghcr.io/snapwich`, baked into `publishedKitRefs()`).
4
4
  // A cluster that cannot reach it — air-gapped, mirror-only, or simply policy-bound to one registry —
5
- // needs the three refs at its own address, which is what `kitRegistry` names in the config. This
6
- // command is how the bytes get there.
5
+ // needs those refs at its own address, which is what `kitRegistry` names in the config. This
6
+ // command is how the bytes get there. It moves one more image the same way: the Custodian's, the
7
+ // pinned upstream Envoy every Harness pod runs (ADR-0059), which jr2 deploys and never builds — a
8
+ // mirror-only cluster needs it just as much, and `kitRegistry` re-homes it the same way.
7
9
  //
8
10
  // It is deliberately INSTANCE-LESS: no `jr2.config.ts`, no kube context, no namespace. Kit images are
9
11
  // shared by every instance on a cluster (and possibly by every instance in an org), so moving them
10
12
  // is its own deliberate act rather than a side effect of converging one instance — which is exactly
11
13
  // why ADR-0044 keeps this out of `jr2 up`.
12
14
  //
13
- // It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness or
14
- // Adapter source, and an installed CLI that could build Kit images would be the patched-Harness
15
+ // It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness
16
+ // source, and an installed CLI that could build Kit images would be the patched-Harness
15
17
  // eject hatch ADR-0027/ADR-0038 welded shut. The kit developer's arm is `just kit-push`, in the
16
18
  // checkout, where the sources actually are.
17
19
  //
@@ -23,7 +25,7 @@
23
25
 
24
26
  import { execFile } from "node:child_process";
25
27
  import { parseArgs, promisify } from "node:util";
26
- import { KIT_VERSION } from "@jr2/orchestrator";
28
+ import { custodianRef, KIT_VERSION } from "@jr2/orchestrator";
27
29
  import { KIT_IMAGES, publishedKitRefs, type KitImageName } from "../build.ts";
28
30
  import { activity, result, type Io } from "../output.ts";
29
31
 
@@ -31,8 +33,9 @@ const exec = promisify(execFile);
31
33
 
32
34
  const USAGE = `usage: jr2 kit push <registry>
33
35
 
34
- mirror the v${KIT_VERSION} kit images (harness, adapter, operator) from their canonical
35
- home into <registry>, for a cluster whose \`kitRegistry\` names it (ADR-0044)`;
36
+ mirror the v${KIT_VERSION} kit images (harness, operator) from their canonical home, and the
37
+ Custodian's pinned Envoy from its own, into <registry>, for a cluster whose \`kitRegistry\`
38
+ names it (ADR-0044, ADR-0059)`;
36
39
 
37
40
  /** Run one `docker` invocation, rejecting on a non-zero exit. The seam this command is tested
38
41
  * through: the claims are which argv each ref produces and what a failure means, and both are
@@ -45,7 +48,7 @@ const dockerCli: RunDocker = async (args) => {
45
48
  };
46
49
 
47
50
  /** What one image's mirror did — the per-image line, and the machine-readable result's rows. */
48
- type Mirrored = { image: KitImageName; source: string; target: string; copied: boolean };
51
+ type Mirrored = { image: KitImageName | "custodian"; source: string; target: string; copied: boolean };
49
52
 
50
53
  export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli): Promise<number> {
51
54
  const { positionals } = parseArgs({ args, allowPositionals: true, strict: false, options: {} });
@@ -77,6 +80,9 @@ export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli)
77
80
  for (const image of Object.keys(KIT_IMAGES) as KitImageName[]) {
78
81
  rows.push(await mirror(io, docker, image, sources[image], targets[image]));
79
82
  }
83
+ // The Custodian's: pinned by digest, and a registry-to-registry copy keeps the digest — so the
84
+ // ref a converge with this `kitRegistry` deploys (`custodianRef`) resolves in the mirror.
85
+ rows.push(await mirror(io, docker, "custodian", custodianRef(), custodianRef(target)));
80
86
 
81
87
  const copied = rows.filter((r) => r.copied).length;
82
88
  activity(io, `mirrored ${copied} image(s), ${rows.length - copied} already present`);
@@ -97,7 +103,7 @@ export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli)
97
103
  async function mirror(
98
104
  io: Io,
99
105
  docker: RunDocker,
100
- image: KitImageName,
106
+ image: KitImageName | "custodian",
101
107
  source: string,
102
108
  target: string,
103
109
  ): Promise<Mirrored> {
@@ -106,7 +112,8 @@ async function mirror(
106
112
  return { image, source, target, copied: false };
107
113
  }
108
114
  await requireSource(docker, image, source, target);
109
- await docker(["buildx", "imagetools", "create", "-t", target, source]);
115
+ // A digest-pinned target is pushed by its tag; the copy lands at that same digest.
116
+ await docker(["buildx", "imagetools", "create", "-t", target.replace(/@sha256:[0-9a-f]+$/, ""), source]);
110
117
  activity(io, `${image}: copied ${source} → ${target}`);
111
118
  return { image, source, target, copied: true };
112
119
  }
@@ -130,12 +137,17 @@ async function resolves(docker: RunDocker, ref: string): Promise<boolean> {
130
137
  * it points at is the other arm of ADR-0044: `just kit-push`, in the checkout, is what puts images
131
138
  * at a published name in the first place; this command only moves what is already there.
132
139
  */
133
- async function requireSource(docker: RunDocker, image: KitImageName, source: string, target: string): Promise<void> {
140
+ async function requireSource(
141
+ docker: RunDocker,
142
+ image: KitImageName | "custodian",
143
+ source: string,
144
+ target: string,
145
+ ): Promise<void> {
134
146
  if (await resolves(docker, source)) return;
135
147
  throw new Error(
136
148
  `${image}: ${source} is not there, so nothing can be mirrored to ${target}\n` +
137
149
  ` a kit image reaches ${target} only by being copied from its published home;\n` +
138
150
  ` if v${KIT_VERSION} was never published (a dev version never is), seed a registry from a kit\n` +
139
- ` checkout with \`just kit-push <registry>\`, which builds the three images from source`,
151
+ ` checkout with \`just kit-push <registry>\`, which builds the Kit images from source`,
140
152
  );
141
153
  }
@@ -24,7 +24,7 @@
24
24
  // `--platform` passed explicitly on every build. The cluster's schedulable nodes choose that set
25
25
  // (`platforms` in the config overrides absolutely), so the daemon default and
26
26
  // `DOCKER_DEFAULT_PLATFORM` steer nothing any more. In a kit CHECKOUT the built set includes the
27
- // Harness, Adapter, and operator; installed from npm those sources do not resolve and the published
27
+ // Harness and the operator; installed from npm those sources do not resolve and the published
28
28
  // `<kitversion>` refs are used with no docker at all (they are multi-arch manifest lists, so they
29
29
  // take no suffix and never had this hole). There is no `images` config block and no env escape
30
30
  // hatch — nobody gets to point a real cluster at a hand-picked Harness (ADR-0027's no-eject-hatch,
@@ -45,14 +45,22 @@
45
45
  // labeled objects found → converge silently (it's home); nothing → confirm first-time setup
46
46
  // (`--yes` for CI); objects labeled as a DIFFERENT instance → refuse.
47
47
 
48
- import { createHash, randomBytes } from "node:crypto";
48
+ import { createHash, createHmac, randomBytes } from "node:crypto";
49
49
  import { open, readdir, readFile } from "node:fs/promises";
50
50
  import { homedir } from "node:os";
51
51
  import { basename, join } from "node:path";
52
52
  import { parseArgs } from "node:util";
53
53
  import {
54
+ custodianBootstrap,
55
+ custodianRef,
56
+ custodianScript,
54
57
  customizeLine,
55
58
  defaultImageContext,
59
+ heldSecretsOf,
60
+ HELD_CA_SECRET,
61
+ HELD_SECRETS_SECRET,
62
+ HELD_TLS_SECRET,
63
+ ORCHESTRATOR_PORT,
56
64
  imageContextDigest,
57
65
  isSshUrl,
58
66
  loadConfig,
@@ -64,9 +72,11 @@ import {
64
72
  type CarriedAgent,
65
73
  type CarriedImage,
66
74
  type CarriedRepo,
75
+ type HeldManifest,
67
76
  type ImageRefs,
68
77
  type JR2Config,
69
78
  } from "@jr2/orchestrator";
79
+ import { caFits, fingerprint, issueCa, issueLeaf, leafFits, trustBundles, type Pem } from "../held-pki.ts";
70
80
  import {
71
81
  assertEmulation,
72
82
  buildSandboxImage,
@@ -102,6 +112,8 @@ import {
102
112
  OPERATOR_SELECTOR,
103
113
  operatorManifest,
104
114
  REPO_CACHE,
115
+ trustObject,
116
+ type HeldObjects,
105
117
  } from "../deploy.ts";
106
118
  import { resolveRoot } from "../instance.ts";
107
119
  import {
@@ -323,16 +335,20 @@ export async function up(args: string[], io: Io): Promise<number> {
323
335
  activity(
324
336
  io,
325
337
  kitRoot
326
- ? `images: kit checkout at ${kitRoot} — building the Harness, Adapter, and operator from source`
327
- : `images: installed kit — the published v${KIT_VERSION} Harness, Adapter, and operator, pulled from ` +
338
+ ? `images: kit checkout at ${kitRoot} — building the Harness and operator from source`
339
+ : `images: installed kit — the published v${KIT_VERSION} Harness and operator, pulled from ` +
328
340
  `${config.kitRegistry ? `${config.kitRegistry} (kitRegistry)` : KIT_IMAGE_HOME}`,
329
341
  );
342
+ // The Custodian's image is deployed and never built, in either world (ADR-0038, ADR-0059): the
343
+ // pinned upstream Envoy, re-homed onto `kitRegistry` like a Kit image when one is set.
344
+ const custodianImage = custodianRef(config.kitRegistry);
345
+ activity(io, `custodian image: ${custodianImage} (pinned, deployed — never built)`);
330
346
 
331
347
  // Read the Orchestrator Deployment ONCE: it carries both convergence records — the instance
332
348
  // image's content hash (a label) and the previous name→ref map (an annotation, ADR-0038).
333
349
  const orch = await kube.getJson({ kind: "deployment", name: ORCHESTRATOR_SERVICE, namespace, ...ctx });
334
350
  const previous = previousImages(orch);
335
- const converged: ConvergedImages = { harness: refs.harness, adapter: refs.adapter, sandbox: {}, sandboxUser: {} };
351
+ const converged: ConvergedImages = { harness: refs.harness, custodian: custodianImage, sandbox: {}, sandboxUser: {} };
336
352
 
337
353
  // ONE transport branch for every image (ADR-0038), so instance, kit, and Sandbox Images cannot
338
354
  // drift into three delivery stories.
@@ -474,13 +490,11 @@ export async function up(args: string[], io: Io): Promise<number> {
474
490
  }
475
491
 
476
492
  // --- the kit's own runtime images (ADR-0038) ---------------------------------------------------
477
- // Built here rather than lazily beside their consumers. The Harness is the injection source
493
+ // Built here rather than lazily beside its consumers. The Harness is the injection source
478
494
  // (ADR-0037): the pod's `runtime` init step copies `/opt/jr2` out of this exact ref onto the
479
- // volume the Sandbox Image mounts. The Adapter is deployed into every Sandbox this instance
480
- // provisions. Neither is a hash input for a Sandbox Image — the runtime rides the pod's volume,
481
- // so a kit edit re-images future pods and re-tags nothing of the user's.
495
+ // volume the Sandbox Image mounts. It is not a hash input for a Sandbox Image — the runtime rides
496
+ // the pod's volume, so a kit edit re-images future pods and re-tags nothing of the user's.
482
497
  await ensureKitImage("harness");
483
- await ensureKitImage("adapter");
484
498
 
485
499
  // --- instance image (content-addressed by the bundle) ------------------------------------------
486
500
  // Stage first, THEN decide: the hash is over the materialized bundle — the actual image inputs,
@@ -613,10 +627,10 @@ export async function up(args: string[], io: Io): Promise<number> {
613
627
  const secretData: Record<string, string> = {
614
628
  JR2_INSTANCE_TOKEN: instanceToken,
615
629
  JR2_SIGNING_KEY: signingKey,
616
- // The Instance Harness Adapter's credential (ADR-0013/0031): a sandbox-style token signed
630
+ // The Instance Harness Custodian's credential (ADR-0013/0031): a sandbox-style token signed
617
631
  // for the placement's name — it may deliver only to Turns hosted THERE (tokens.ts), so the
618
632
  // Instance token never enters that pod. Derived from the kept key, so re-runs converge to
619
- // the same value; live Adapters keep verifying.
633
+ // the same value; live Custodians keep verifying.
620
634
  JR2_INSTANCE_HARNESS_TOKEN: sandboxToken(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
621
635
  };
622
636
  // Git tokens (ADR-0019/0051): every env var a `git.credentials` entry names, materialized from
@@ -629,29 +643,49 @@ export async function up(args: string[], io: Io): Promise<number> {
629
643
 
630
644
  // The HARNESS containers' env — a separate Secret (ADR-0013): Agent code executes where these
631
645
  // land, so the Instance token/signing key above must be unreachable from it. Values declared in
632
- // config (usually read off process.env/.env) materialize here (ADR-0019); so does the provider
633
- // key — the ConfigMap'd harness config carries the provider MINUS this (ADR-0018).
646
+ // config (usually read off process.env/.env) materialize here (ADR-0019). The provider key does
647
+ // NOT: it is a held secret (ADR-0059), and the Agent can read everything in this Secret.
634
648
  const harnessEnvData: Record<string, string> = {};
635
649
  for (const v of config.harness?.env ?? []) if (v.value !== undefined) harnessEnvData[v.name] = v.value;
636
- if (config.harness?.provider?.apiKey) harnessEnvData.JR2_PROVIDER_API_KEY = config.harness.provider.apiKey;
637
650
 
638
651
  // Preflight referenced-but-unmanaged Secrets: turn the CreateContainerConfigError hang into an
639
- // immediate, named error (ADR-0019). Sealed/External Secrets ride this seam untouched.
652
+ // immediate, named error (ADR-0019). Sealed/External Secrets ride this seam untouched. The read
653
+ // keeps each one's key NAMES (never a value), which the held-secret rules check for a model key.
654
+ const envFromKeys = new Map<string, string[]>();
640
655
  for (const ref of config.harness?.envFrom ?? []) {
641
- const refName = ref.secretRef?.name;
656
+ const refName = ref.secretRef?.name ?? ref.configMapRef?.name;
642
657
  if (!refName) continue;
643
- if (!(await kube.getJson({ kind: "secret", name: refName, namespace, ...ctx }))) {
658
+ const kind = ref.secretRef ? "secret" : "configmap";
659
+ const found = await kube.getJson<KubeObject & { data?: Record<string, string> }>({
660
+ kind,
661
+ name: refName,
662
+ namespace,
663
+ ...ctx,
664
+ });
665
+ if (!found && kind === "secret") {
644
666
  throw new Error(
645
667
  `harness.envFrom references Secret "${refName}", which does not exist in namespace "${namespace}" — ` +
646
668
  `create it first: kubectl -n ${namespace} create secret generic ${refName} --from-literal=KEY=...`,
647
669
  );
648
670
  }
671
+ envFromKeys.set(`${kind}/${refName}`, Object.keys(found?.data ?? {}));
649
672
  }
650
673
 
651
674
  // --- private-CA bundle (ADR-0020): read HERE, host-side — the in-cluster config eval never
652
675
  // touches the file (the path may not exist there); consumers get the ConfigMap.
653
676
  const caPem = await readCaBundle(root, config);
654
677
 
678
+ // --- held secrets (ADR-0059): resolved HERE, host-side, where `.env` exists ------------------
679
+ const held = await resolveHeld(io, kube, {
680
+ config,
681
+ name,
682
+ namespace,
683
+ ctx,
684
+ envFromKeys,
685
+ models: agents.map((a) => a.definition.model).filter((m): m is string => typeof m === "string"),
686
+ caBundle: caPem,
687
+ });
688
+
655
689
  // --- git over ssh: where the key comes from (ADR-0019, ADR-0047) -------------------------------
656
690
  // Runs BEFORE the apply, because a missing key source is a converge that must not start, and the
657
691
  // notice it hands back is owed to the END of the converge — a generated key is dead until a human
@@ -662,6 +696,10 @@ export async function up(args: string[], io: Io): Promise<number> {
662
696
  await preflightProvider(io, kube, config, agents, namespace, ctx, caPem);
663
697
 
664
698
  // --- apply + rollout ---------------------------------------------------------------------------
699
+ // The trust bundles first, and server-side: every pod that mounts them must find them, and they
700
+ // carry every root certificate — past what client-side apply's annotation can hold.
701
+ const trust = trustObject({ name, namespace, caBundle: caPem, bundles: held.bundles });
702
+ if (trust) await kube.apply({ manifest: trust, serverSide: true, ...ctx });
665
703
  activity(io, `orchestrator: applying (image ${tag})`);
666
704
  await kube.apply({
667
705
  manifest: instanceObjects({
@@ -672,12 +710,21 @@ export async function up(args: string[], io: Io): Promise<number> {
672
710
  secretData,
673
711
  harnessEnvData,
674
712
  harness: config.harness,
675
- caBundle: caPem,
713
+ held: held.objects,
676
714
  imageRefs: converged,
677
715
  repoCache: carried.composesSandbox ? { image: operatorImage!, placement: config.sandbox } : undefined,
678
716
  }),
679
717
  ...ctx,
680
718
  });
719
+ // No secret held any more: the Custodian's Secrets go with it. The CA stays (`jr2-held-ca`), so a
720
+ // later converge that holds one again issues leaves under the same CA.
721
+ if (held.objects.manifest.secrets.length === 0) {
722
+ for (const secret of [HELD_SECRETS_SECRET, HELD_TLS_SECRET]) {
723
+ await kube.deleteObject({ kind: "secret", name: secret, namespace, ...ctx });
724
+ }
725
+ } else if (Object.keys(held.objects.values).length === 0) {
726
+ await kube.deleteObject({ kind: "secret", name: HELD_SECRETS_SECRET, namespace, ...ctx });
727
+ }
681
728
  activity(io, "orchestrator: waiting for rollout");
682
729
  await awaitRollout(kube, {
683
730
  name: ORCHESTRATOR_SERVICE,
@@ -748,7 +795,9 @@ export async function up(args: string[], io: Io): Promise<number> {
748
795
  name,
749
796
  namespace,
750
797
  harnessImage,
751
- adapterImage: refs.adapter,
798
+ custodianImage,
799
+ held: held.objects.manifest,
800
+ heldDigest: heldDigest(Buffer.from(signingKey, "base64"), held.objects),
752
801
  harness: config.harness,
753
802
  caBundle: caPem !== undefined,
754
803
  // The wire's gate (ADR-0058): the digest of the bearer the Orchestrator derives for this
@@ -787,7 +836,7 @@ export async function up(args: string[], io: Io): Promise<number> {
787
836
  }
788
837
 
789
838
  /**
790
- * Every image ref one converge resolved. The Orchestrator reads `harness`/`adapter`/`sandbox` from
839
+ * Every image ref one converge resolved. The Orchestrator reads `harness`/`custodian`/`sandbox` from
791
840
  * the mounted map (`ImageRefs`); `operator` rides the same JSON because the record `jr2 up` diffs
792
841
  * must cover every image it builds, and one map is what keeps the record it diffs and the map pods
793
842
  * read from ever disagreeing (ADR-0038).
@@ -820,7 +869,7 @@ function previousImages(orch?: KubeObject): Partial<ConvergedImages> | undefined
820
869
 
821
870
  /** Every ref one image map names, flattened — the kit's own plus each Sandbox Image. */
822
871
  function mapRefs(map: Partial<ConvergedImages>): string[] {
823
- return [map.harness, map.adapter, map.operator, ...Object.values(map.sandbox ?? {})].filter(
872
+ return [map.harness, map.custodian, map.operator, ...Object.values(map.sandbox ?? {})].filter(
824
873
  (ref): ref is string => typeof ref === "string",
825
874
  );
826
875
  }
@@ -872,6 +921,97 @@ async function sweepAfterConverge(
872
921
  }
873
922
  }
874
923
 
924
+ /**
925
+ * Held secrets, resolved (ADR-0059): the rules (`heldSecretsOf`, fed what only the cluster knows),
926
+ * the per-Instance CA (kept, or minted on first need), one leaf per bound target (reused while it
927
+ * still fits), the trust bundles, and the Custodian's rendered config. Narrated one line per secret
928
+ * — its name, its hosts, its source — and never a value.
929
+ */
930
+ async function resolveHeld(
931
+ io: Io,
932
+ kube: KubeAdmin,
933
+ opts: {
934
+ config: JR2Config;
935
+ name: string;
936
+ namespace: string;
937
+ ctx: { context?: string };
938
+ envFromKeys: Map<string, string[]>;
939
+ models: string[];
940
+ /** The user's `caBundle` (ADR-0020): trusted upstream, and by the Harness beside the jr2 CA. */
941
+ caBundle?: string;
942
+ },
943
+ ): Promise<{ objects: HeldObjects; bundles?: { extra: string; bundle: string; upstream: string } }> {
944
+ const { config, namespace, ctx } = opts;
945
+ type Data = KubeObject & { data?: Record<string, string> };
946
+ const decode = (data: Record<string, string> | undefined, key: string): string | undefined =>
947
+ data?.[key] === undefined ? undefined : Buffer.from(data[key], "base64").toString("utf8");
948
+
949
+ // R10: a referenced Secret and key must exist. Read once per Secret — key NAMES, never values.
950
+ const referenced = new Map<string, string[] | undefined>();
951
+ for (const s of config.harness?.heldSecrets ?? []) {
952
+ const ref = s.valueFrom?.secretKeyRef.name;
953
+ if (ref === undefined || referenced.has(ref)) continue;
954
+ const found = await kube.getJson<Data>({ kind: "secret", name: ref, namespace, ...ctx });
955
+ referenced.set(ref, found ? Object.keys(found.data ?? {}) : undefined);
956
+ }
957
+ const { manifest, values, warnings } = heldSecretsOf(config, {
958
+ envFromKeys: opts.envFromKeys,
959
+ secretKeys: (secret) => referenced.get(secret),
960
+ models: opts.models,
961
+ });
962
+ for (const warning of warnings) activity(io, `warning: ${warning}`);
963
+ const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
964
+ const rendered = {
965
+ bootstrap: JSON.stringify(custodianBootstrap(manifest, orchestrator)),
966
+ script: custodianScript(manifest),
967
+ };
968
+ if (manifest.secrets.length === 0) {
969
+ return { objects: { manifest, values, leaves: {}, ...rendered } };
970
+ }
971
+ for (const s of manifest.secrets) {
972
+ const where = s.hosts.map((h) => (h.port === 443 ? h.host : `${h.host}:${h.port}`)).join(", ");
973
+ const source = s.source.kind === "literal" ? "literal" : `secret ${s.source.secret}/${s.source.key}`;
974
+ activity(io, `held secret ${s.name} → ${where} (${source})`);
975
+ }
976
+
977
+ // The CA: kept whenever the cluster holds a usable one — a live pod's Harness trusts it from its
978
+ // start, so a leaf a restarted Custodian reads must still chain to it. A human rotates it by
979
+ // deleting the Secret.
980
+ const stored = await kube.getJson<Data>({ kind: "secret", name: HELD_CA_SECRET, namespace, ...ctx });
981
+ const kept = { cert: decode(stored?.data, "ca.crt") ?? "", key: decode(stored?.data, "ca.key") ?? "" };
982
+ const ca: Pem = stored && caFits(kept) ? kept : issueCa(opts.name);
983
+ if (ca !== kept) activity(io, `held secrets: minted the Instance's CA into ${HELD_CA_SECRET}`);
984
+
985
+ const tls = await kube.getJson<Data>({ kind: "secret", name: HELD_TLS_SECRET, namespace, ...ctx });
986
+ const leaves: Record<string, Pem> = {};
987
+ for (const s of manifest.secrets) {
988
+ for (const h of s.hosts) {
989
+ if (leaves[h.leaf]) continue;
990
+ const existing = {
991
+ cert: decode(tls?.data, `${h.leaf}.crt`) ?? "",
992
+ key: decode(tls?.data, `${h.leaf}.key`) ?? "",
993
+ };
994
+ leaves[h.leaf] = leafFits(existing, ca, h.host) ? existing : issueLeaf(ca, h.host);
995
+ }
996
+ }
997
+ return {
998
+ objects: { manifest, values, ca, leaves, ...rendered },
999
+ bundles: trustBundles(ca.cert, opts.caBundle),
1000
+ };
1001
+ }
1002
+
1003
+ /** The Instance Harness's `jr2.dev/held-digest` (ADR-0059): an HMAC over every held-secret input,
1004
+ * keyed with the signing key so the annotation reveals nothing about a value. */
1005
+ function heldDigest(key: Buffer, held: HeldObjects): string {
1006
+ const mac = createHmac("sha256", key);
1007
+ mac.update(JSON.stringify(held.manifest));
1008
+ for (const stem of Object.keys(held.leaves).sort()) mac.update(`${stem}:${fingerprint(held.leaves[stem]!.cert)}`);
1009
+ for (const name of Object.keys(held.values).sort()) mac.update(`${name}=${held.values[name]}`);
1010
+ mac.update(held.bootstrap);
1011
+ mac.update(held.script);
1012
+ return mac.digest("base64url").slice(0, 32);
1013
+ }
1014
+
875
1015
  /** The one delivery failure that is a configuration error rather than a docker one (ADR-0019). */
876
1016
  function undeliverable(context: string): Error {
877
1017
  return new Error(
@@ -955,7 +1095,7 @@ async function verifyRunningImage(
955
1095
  // thing serving — the question is what the cluster runs now, not what it is done running.
956
1096
  const live = pods.filter((p) => !p.metadata.deletionTimestamp);
957
1097
  // `container` narrows a multi-container pod to the one this layer's image claim is about
958
- // (the Instance Harness pod also carries the Adapter, which is not this check's business).
1098
+ // (the Instance Harness pod also carries the Custodian, which is not this check's business).
959
1099
  const stale = live.filter((p) =>
960
1100
  p.spec.containers.some((c) => (container === undefined || c.name === container) && c.image !== image),
961
1101
  );
@@ -301,7 +301,7 @@ function deployedRows(d: NonNullable<VersionReport["deployed"]>, r: VersionRepor
301
301
  }
302
302
  if (d.images) {
303
303
  if (d.images.harness) rows.push(["harness", d.images.harness]);
304
- if (d.images.adapter) rows.push(["adapter", d.images.adapter]);
304
+ if (d.images.custodian) rows.push(["custodian", d.images.custodian]);
305
305
  for (const [key, ref] of Object.entries(d.images.sandbox ?? {})) rows.push([`sandbox ${key}`, ref]);
306
306
  }
307
307
  const verdict = skewSentence(r);