@jr2/cli 0.2.0 → 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.0",
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.0"
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.0"
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
  *
@@ -1004,8 +1003,8 @@ const execCommand: RunCommand = async (command, args, cwd) => {
1004
1003
  * dependency graph, wherever the folder sits — so it is staged as a copy
1005
1004
  * ({@link BUNDLE_STAGE_EXCLUDE}) and installed frozen and production-only by the lockfile's own
1006
1005
  * package manager ({@link lockfileInstall}). This is how a standalone instance committed INSIDE
1007
- * a pnpm monorepo bundles: the kit's own `docs/intro` sits under the checkout's
1008
- * `pnpm-workspace.yaml` and is deliberately not one of its packages.
1006
+ * a pnpm monorepo bundles: it sits under a `pnpm-workspace.yaml` that does not list it, so the
1007
+ * workspace above it says nothing about how it installs.
1009
1008
  * 2. A `pnpm-workspace.yaml` above it. A workspace member carries no lockfile of its own — the
1010
1009
  * workspace root holds it — so it is the ABSENCE of one that sends the instance to `pnpm deploy
1011
1010
  * --legacy`, unchanged. pnpm is a contributor prerequisite, like go for the operator, never a
@@ -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,27 +45,38 @@
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,
59
67
  loadWorkflows,
60
68
  matchCredential,
61
69
  partsOf,
70
+ harnessTokenDigest,
62
71
  sandboxToken,
63
72
  type CarriedAgent,
64
73
  type CarriedImage,
65
74
  type CarriedRepo,
75
+ type HeldManifest,
66
76
  type ImageRefs,
67
77
  type JR2Config,
68
78
  } from "@jr2/orchestrator";
79
+ import { caFits, fingerprint, issueCa, issueLeaf, leafFits, trustBundles, type Pem } from "../held-pki.ts";
69
80
  import {
70
81
  assertEmulation,
71
82
  buildSandboxImage,
@@ -101,6 +112,8 @@ import {
101
112
  OPERATOR_SELECTOR,
102
113
  operatorManifest,
103
114
  REPO_CACHE,
115
+ trustObject,
116
+ type HeldObjects,
104
117
  } from "../deploy.ts";
105
118
  import { resolveRoot } from "../instance.ts";
106
119
  import {
@@ -322,16 +335,20 @@ export async function up(args: string[], io: Io): Promise<number> {
322
335
  activity(
323
336
  io,
324
337
  kitRoot
325
- ? `images: kit checkout at ${kitRoot} — building the Harness, Adapter, and operator from source`
326
- : `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 ` +
327
340
  `${config.kitRegistry ? `${config.kitRegistry} (kitRegistry)` : KIT_IMAGE_HOME}`,
328
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)`);
329
346
 
330
347
  // Read the Orchestrator Deployment ONCE: it carries both convergence records — the instance
331
348
  // image's content hash (a label) and the previous name→ref map (an annotation, ADR-0038).
332
349
  const orch = await kube.getJson({ kind: "deployment", name: ORCHESTRATOR_SERVICE, namespace, ...ctx });
333
350
  const previous = previousImages(orch);
334
- const converged: ConvergedImages = { harness: refs.harness, adapter: refs.adapter, sandbox: {}, sandboxUser: {} };
351
+ const converged: ConvergedImages = { harness: refs.harness, custodian: custodianImage, sandbox: {}, sandboxUser: {} };
335
352
 
336
353
  // ONE transport branch for every image (ADR-0038), so instance, kit, and Sandbox Images cannot
337
354
  // drift into three delivery stories.
@@ -473,13 +490,11 @@ export async function up(args: string[], io: Io): Promise<number> {
473
490
  }
474
491
 
475
492
  // --- the kit's own runtime images (ADR-0038) ---------------------------------------------------
476
- // 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
477
494
  // (ADR-0037): the pod's `runtime` init step copies `/opt/jr2` out of this exact ref onto the
478
- // volume the Sandbox Image mounts. The Adapter is deployed into every Sandbox this instance
479
- // provisions. Neither is a hash input for a Sandbox Image — the runtime rides the pod's volume,
480
- // 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.
481
497
  await ensureKitImage("harness");
482
- await ensureKitImage("adapter");
483
498
 
484
499
  // --- instance image (content-addressed by the bundle) ------------------------------------------
485
500
  // Stage first, THEN decide: the hash is over the materialized bundle — the actual image inputs,
@@ -612,10 +627,10 @@ export async function up(args: string[], io: Io): Promise<number> {
612
627
  const secretData: Record<string, string> = {
613
628
  JR2_INSTANCE_TOKEN: instanceToken,
614
629
  JR2_SIGNING_KEY: signingKey,
615
- // 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
616
631
  // for the placement's name — it may deliver only to Turns hosted THERE (tokens.ts), so the
617
632
  // Instance token never enters that pod. Derived from the kept key, so re-runs converge to
618
- // the same value; live Adapters keep verifying.
633
+ // the same value; live Custodians keep verifying.
619
634
  JR2_INSTANCE_HARNESS_TOKEN: sandboxToken(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
620
635
  };
621
636
  // Git tokens (ADR-0019/0051): every env var a `git.credentials` entry names, materialized from
@@ -628,29 +643,49 @@ export async function up(args: string[], io: Io): Promise<number> {
628
643
 
629
644
  // The HARNESS containers' env — a separate Secret (ADR-0013): Agent code executes where these
630
645
  // land, so the Instance token/signing key above must be unreachable from it. Values declared in
631
- // config (usually read off process.env/.env) materialize here (ADR-0019); so does the provider
632
- // 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.
633
648
  const harnessEnvData: Record<string, string> = {};
634
649
  for (const v of config.harness?.env ?? []) if (v.value !== undefined) harnessEnvData[v.name] = v.value;
635
- if (config.harness?.provider?.apiKey) harnessEnvData.JR2_PROVIDER_API_KEY = config.harness.provider.apiKey;
636
650
 
637
651
  // Preflight referenced-but-unmanaged Secrets: turn the CreateContainerConfigError hang into an
638
- // 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[]>();
639
655
  for (const ref of config.harness?.envFrom ?? []) {
640
- const refName = ref.secretRef?.name;
656
+ const refName = ref.secretRef?.name ?? ref.configMapRef?.name;
641
657
  if (!refName) continue;
642
- 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") {
643
666
  throw new Error(
644
667
  `harness.envFrom references Secret "${refName}", which does not exist in namespace "${namespace}" — ` +
645
668
  `create it first: kubectl -n ${namespace} create secret generic ${refName} --from-literal=KEY=...`,
646
669
  );
647
670
  }
671
+ envFromKeys.set(`${kind}/${refName}`, Object.keys(found?.data ?? {}));
648
672
  }
649
673
 
650
674
  // --- private-CA bundle (ADR-0020): read HERE, host-side — the in-cluster config eval never
651
675
  // touches the file (the path may not exist there); consumers get the ConfigMap.
652
676
  const caPem = await readCaBundle(root, config);
653
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
+
654
689
  // --- git over ssh: where the key comes from (ADR-0019, ADR-0047) -------------------------------
655
690
  // Runs BEFORE the apply, because a missing key source is a converge that must not start, and the
656
691
  // notice it hands back is owed to the END of the converge — a generated key is dead until a human
@@ -661,6 +696,10 @@ export async function up(args: string[], io: Io): Promise<number> {
661
696
  await preflightProvider(io, kube, config, agents, namespace, ctx, caPem);
662
697
 
663
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 });
664
703
  activity(io, `orchestrator: applying (image ${tag})`);
665
704
  await kube.apply({
666
705
  manifest: instanceObjects({
@@ -671,12 +710,21 @@ export async function up(args: string[], io: Io): Promise<number> {
671
710
  secretData,
672
711
  harnessEnvData,
673
712
  harness: config.harness,
674
- caBundle: caPem,
713
+ held: held.objects,
675
714
  imageRefs: converged,
676
715
  repoCache: carried.composesSandbox ? { image: operatorImage!, placement: config.sandbox } : undefined,
677
716
  }),
678
717
  ...ctx,
679
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
+ }
680
728
  activity(io, "orchestrator: waiting for rollout");
681
729
  await awaitRollout(kube, {
682
730
  name: ORCHESTRATOR_SERVICE,
@@ -747,12 +795,14 @@ export async function up(args: string[], io: Io): Promise<number> {
747
795
  name,
748
796
  namespace,
749
797
  harnessImage,
750
- adapterImage: refs.adapter,
798
+ custodianImage,
799
+ held: held.objects.manifest,
800
+ heldDigest: heldDigest(Buffer.from(signingKey, "base64"), held.objects),
751
801
  harness: config.harness,
752
802
  caBundle: caPem !== undefined,
753
- // The echo gate (ADR-0023): the digest of the token materialized above — the same env
754
- // the orchestrator stamps onto every Sandbox Harness at provision.
755
- echoTokenSha256: createHash("sha256").update(instanceToken).digest("base64url"),
803
+ // The wire's gate (ADR-0058): the digest of the bearer the Orchestrator derives for this
804
+ // placement from the kept key — the same shape it stamps onto every Sandbox at provision.
805
+ bearerSha256: harnessTokenDigest(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
756
806
  }),
757
807
  ...ctx,
758
808
  });
@@ -786,7 +836,7 @@ export async function up(args: string[], io: Io): Promise<number> {
786
836
  }
787
837
 
788
838
  /**
789
- * 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
790
840
  * the mounted map (`ImageRefs`); `operator` rides the same JSON because the record `jr2 up` diffs
791
841
  * must cover every image it builds, and one map is what keeps the record it diffs and the map pods
792
842
  * read from ever disagreeing (ADR-0038).
@@ -819,7 +869,7 @@ function previousImages(orch?: KubeObject): Partial<ConvergedImages> | undefined
819
869
 
820
870
  /** Every ref one image map names, flattened — the kit's own plus each Sandbox Image. */
821
871
  function mapRefs(map: Partial<ConvergedImages>): string[] {
822
- 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(
823
873
  (ref): ref is string => typeof ref === "string",
824
874
  );
825
875
  }
@@ -871,6 +921,97 @@ async function sweepAfterConverge(
871
921
  }
872
922
  }
873
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
+
874
1015
  /** The one delivery failure that is a configuration error rather than a docker one (ADR-0019). */
875
1016
  function undeliverable(context: string): Error {
876
1017
  return new Error(
@@ -954,7 +1095,7 @@ async function verifyRunningImage(
954
1095
  // thing serving — the question is what the cluster runs now, not what it is done running.
955
1096
  const live = pods.filter((p) => !p.metadata.deletionTimestamp);
956
1097
  // `container` narrows a multi-container pod to the one this layer's image claim is about
957
- // (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).
958
1099
  const stale = live.filter((p) =>
959
1100
  p.spec.containers.some((c) => (container === undefined || c.name === container) && c.image !== image),
960
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);