@jr2/cli 0.5.1 → 0.7.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.
package/src/deploy.ts CHANGED
@@ -3,10 +3,15 @@
3
3
  // namespace are constants (namespace is the identity); every object carries the instance label so
4
4
  // ownership is derivable from the cluster — there is no local target state.
5
5
 
6
- import { createHmac } from "node:crypto";
6
+ import { createHash, createHmac, randomBytes } from "node:crypto";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import {
10
+ CALLER_DIGESTS_KEY,
11
+ CALLER_DIGESTS_MOUNT,
12
+ CALLER_DIGESTS_SECRET,
13
+ callerTokenDigest,
14
+ callerTokenSecret,
10
15
  CA_CONFIGMAP,
11
16
  CA_MOUNT,
12
17
  EFFECTOR_PORT,
@@ -19,6 +24,14 @@ import {
19
24
  CUSTODIAN_PROGRAM,
20
25
  CUSTODIAN_PROGRAM_KEY,
21
26
  CUSTODIAN_SCRIPT_KEY,
27
+ CREDENTIAL_KEYS,
28
+ controlStandIn,
29
+ controlToken,
30
+ heldStandIn,
31
+ heldStandInKey,
32
+ HOOK_DIGESTS_KEY,
33
+ HOOK_DIGESTS_MOUNT,
34
+ HOOK_DIGESTS_SECRET,
22
35
  INSTANCE_CUSTODIAN_CONFIGMAP,
23
36
  GIT_SSH_SECRET,
24
37
  HELD_CA_SECRET,
@@ -47,6 +60,8 @@ import {
47
60
  REPO_CACHE_HOSTPATH,
48
61
  SERVICE_ACCOUNT_CA,
49
62
  STATE_PVC,
63
+ STATE_VOLUME_DEFAULT,
64
+ memoryBytes,
50
65
  type EffectorPlan,
51
66
  type SandboxEffectorPlan,
52
67
  effectorManifestKey,
@@ -59,7 +74,11 @@ import {
59
74
  type GitCloneSecret,
60
75
  type GitPushManifest,
61
76
  type HarnessConfig,
77
+ type JR2Config,
78
+ isStdioServer,
79
+ mcpTimeoutMs,
62
80
  type HeldManifest,
81
+ markHarnessEnv,
63
82
  type PriorityClasses,
64
83
  type SandboxPlacement,
65
84
  } from "@jr2/orchestrator/internal";
@@ -86,13 +105,122 @@ export const ANNOTATION_IMAGES = "jr2.dev/images";
86
105
 
87
106
  export const ORCHESTRATOR_SA = "jr2-orchestrator";
88
107
 
108
+ /** The label on each named Caller token's Secret (ADR-0078), with the token's name as its value.
109
+ * `jr2 up` finds the Secrets of the names that the config no longer declares by it, and
110
+ * `jr2 caller rotate` finds every value whose digest the Orchestrator reads. */
111
+ export const LABEL_CALLER_TOKEN = "jr2.dev/caller-token";
112
+
113
+ /** The key of a named Caller token's value in its Secret (`callerTokenSecret`). */
114
+ export const CALLER_TOKEN_KEY = "token";
115
+
116
+ /**
117
+ * The `jr2-caller-digests` file (ADR-0078): one JSON object from each named Caller token's name to
118
+ * the sha-256 of its value, in hex, names in order. The Orchestrator reads it through a mount, and
119
+ * never a value.
120
+ */
121
+ export function callerDigestsJson(values: Readonly<Record<string, string>>): string {
122
+ const names = Object.keys(values).sort();
123
+ return JSON.stringify(Object.fromEntries(names.map((n) => [n, callerTokenDigest(values[n]!)])));
124
+ }
125
+
126
+ /**
127
+ * The Secrets of the named Caller tokens (ADR-0078): one per name, which holds its value and which
128
+ * no pod mounts (`jr2 caller token` reads it over the kube API), and `jr2-caller-digests`, which
129
+ * the Orchestrator mounts. The digests Secret is applied always, empty with no token, so the
130
+ * mount always has its Secret.
131
+ */
132
+ export function callerTokenObjects(
133
+ values: Readonly<Record<string, string>>,
134
+ meta: (name: string, extra?: Record<string, string>) => KubeManifest,
135
+ ): KubeManifest[] {
136
+ return [
137
+ ...Object.keys(values)
138
+ .sort()
139
+ .map(
140
+ (name): KubeManifest => ({
141
+ apiVersion: "v1",
142
+ kind: "Secret",
143
+ metadata: meta(callerTokenSecret(name), { [LABEL_CALLER_TOKEN]: name }),
144
+ type: "Opaque",
145
+ stringData: { [CALLER_TOKEN_KEY]: values[name]! },
146
+ }),
147
+ ),
148
+ {
149
+ apiVersion: "v1",
150
+ kind: "Secret",
151
+ metadata: meta(CALLER_DIGESTS_SECRET),
152
+ type: "Opaque",
153
+ stringData: { [CALLER_DIGESTS_KEY]: callerDigestsJson(values) },
154
+ },
155
+ ];
156
+ }
157
+
158
+ /**
159
+ * `jr2-hook-digests` (ADR-0078): one JSON object from the secret name of each Hook whose verifier is
160
+ * `token` to the sha-256 of its value, in hex, names in order. The Orchestrator mounts it, reads it
161
+ * again when it changes, and never holds a value. Applied always, empty with no `token` Hook, so the
162
+ * mount always has its Secret.
163
+ */
164
+ export function hookDigestsObject(
165
+ digests: Readonly<Record<string, string>>,
166
+ meta: (name: string) => KubeManifest,
167
+ ): KubeManifest {
168
+ const names = Object.keys(digests).sort();
169
+ return {
170
+ apiVersion: "v1",
171
+ kind: "Secret",
172
+ metadata: meta(HOOK_DIGESTS_SECRET),
173
+ type: "Opaque",
174
+ stringData: { [HOOK_DIGESTS_KEY]: JSON.stringify(Object.fromEntries(names.map((n) => [n, digests[n]!]))) },
175
+ };
176
+ }
177
+
89
178
  /** The HMAC over the held-secret inputs, on the Instance Harness's pod template (ADR-0059). */
90
179
  export const ANNOTATION_HELD_DIGEST = "jr2.dev/held-digest";
91
180
 
181
+ /** The digest of the `jr2-harness` ConfigMap's text (`harnessConfigJson`), on the Instance
182
+ * Harness's pod template: the Harness reads the text once, at start, so an edit to the custom
183
+ * provider, a gateway or an MCP server (ADR-0079) rolls the pod. The text holds no value, so the
184
+ * digest is not keyed. */
185
+ export const ANNOTATION_HARNESS_CONFIG_DIGEST = "jr2.dev/harness-config-digest";
186
+
92
187
  /** The Instance Harness pod's `fsGroup` — a Sandbox pod's default work group (ADR-0005), for the
93
188
  * same reason: a group every container is granted, which the Custodian's values are readable by. */
94
189
  const INSTANCE_HARNESS_FS_GROUP = 2000;
95
190
 
191
+ /**
192
+ * The Instance Harness's credentials Secret (ADR-0076, ADR-0059): what a Sandbox's `<sandbox>-token`
193
+ * is to a Sandbox. It holds the pod's control token (`controlToken`, for the placement's name), its
194
+ * secret control Stand-in and one secret Stand-in per held secret of the pod, under
195
+ * `CREDENTIAL_KEYS` and `heldStandInKey`. `jr2 up` mints it, as the pod's maker. The Custodian
196
+ * mounts the token and the Stand-ins; the Harness container mounts the Stand-ins alone, as files
197
+ * under `/run/jr2/` (`custodianComposition`). A Secret of its own, never `jr2-instance`: the
198
+ * Instance token and the signing key share no Secret with anything the Harness container mounts.
199
+ */
200
+ export const INSTANCE_HARNESS_CREDENTIALS = `${INSTANCE_HARNESS_SERVICE}-token`;
201
+
202
+ /**
203
+ * One pod's credentials Secret data (ADR-0076, ADR-0059), minted by `jr2 up` as that pod's maker:
204
+ * the control token, the secret control Stand-in, and one secret Stand-in per held secret of
205
+ * `manifest` (`heldStandIn`, under the pod key `heldPod`). The Instance Harness's control token is
206
+ * signed for the placement's name, so it speaks only for the Turns hosted there; a check pod's
207
+ * (`controlToken: "unsigned"`) is a value the Orchestrator never signed — it reads no Menu
208
+ * (ADR-0019). No pod here serves a fetch, so neither holds a fetch token. Never logged: the values
209
+ * reach a Secret and nothing else.
210
+ */
211
+ export function podCredentials(
212
+ key: Buffer,
213
+ opts: { placement: string; heldPod: string; manifest: HeldManifest; controlToken?: "signed" | "unsigned" },
214
+ ): Record<string, string> {
215
+ const data: Record<string, string> = {
216
+ [CREDENTIAL_KEYS.controlToken]:
217
+ opts.controlToken === "unsigned" ? randomBytes(24).toString("hex") : controlToken(key, opts.placement),
218
+ [CREDENTIAL_KEYS.controlStandIn]: controlStandIn(key, opts.placement),
219
+ };
220
+ for (const s of opts.manifest.secrets) data[heldStandInKey(s.name)] = heldStandIn(key, opts.heldPod, s.name);
221
+ return data;
222
+ }
223
+
96
224
  /** How long the Instance Harness pod is given to stop (ADR-0031's drain): on SIGTERM it stops
97
225
  * admitting and finishes the Turns it holds, so the grace is a Turn's worst case. `jr2 up` waits
98
226
  * out a rollout that long, and no longer. */
@@ -110,6 +238,48 @@ export const INSTANCE_HARNESS_PODS_SERVICE = `${INSTANCE_HARNESS_SERVICE}-pods`;
110
238
  /** The two ingress NetworkPolicies (ADR-0058) — see `harnessIngressPolicies`. */
111
239
  export const SANDBOX_INGRESS_POLICY = "jr2-sandbox-ingress";
112
240
  export const INSTANCE_HARNESS_INGRESS_POLICY = "jr2-instance-harness-ingress";
241
+ /** The two egress NetworkPolicies (ADR-0076) — see `metadataEgress`. */
242
+ export const SANDBOX_EGRESS_POLICY = "jr2-sandbox-egress";
243
+ export const INSTANCE_HARNESS_EGRESS_POLICY = "jr2-instance-harness-egress";
244
+
245
+ /**
246
+ * The cloud metadata addresses that a Harness pod does not reach (ADR-0076): where a node's
247
+ * metadata service answers pods, it gives the node's own cloud credential, which no `workspace()`
248
+ * grants. AWS's IMDS (IPv4 and IPv6), ECS's and EKS Pod Identity's credential agents, the
249
+ * metadata service of GCP and Azure (the same 169.254.169.254), and Alibaba's. Each is a single
250
+ * address: all of 169.254.0.0/16 would cut NodeLocal DNSCache (169.254.20.10). The list is not
251
+ * complete for every provider, so the shared-cluster guide tells the admin to check theirs.
252
+ */
253
+ export const METADATA_ADDRESSES_V4 = [
254
+ "169.254.169.254/32",
255
+ "169.254.170.2/32",
256
+ "169.254.170.23/32",
257
+ "100.100.100.200/32",
258
+ ] as const;
259
+ export const METADATA_ADDRESSES_V6 = ["fd00:ec2::254/128"] as const;
260
+
261
+ /**
262
+ * The egress of a Harness pod (ADR-0076): everything, except the cloud metadata addresses.
263
+ * NetworkPolicy has no deny rule, so the rule allows every address but those, and every pod of the
264
+ * cluster as a peer of its own. A CNI that matches an `ipBlock` against a pod's address too (Cilium)
265
+ * would otherwise cut DNS, the Orchestrator and each Service. The policy needs a CNI that enforces
266
+ * NetworkPolicy; on one that does not, nothing is blocked.
267
+ */
268
+ export function metadataEgress(podSelector: Record<string, unknown>): Record<string, unknown> {
269
+ return {
270
+ podSelector,
271
+ policyTypes: ["Egress"],
272
+ egress: [
273
+ {
274
+ to: [
275
+ { ipBlock: { cidr: "0.0.0.0/0", except: [...METADATA_ADDRESSES_V4] } },
276
+ { ipBlock: { cidr: "::/0", except: [...METADATA_ADDRESSES_V6] } },
277
+ { namespaceSelector: {} },
278
+ ],
279
+ },
280
+ ],
281
+ };
282
+ }
113
283
 
114
284
  /** The operator's install location — per-cluster, shared by every instance (ADR-0019). */
115
285
  export const OPERATOR_NAMESPACE = "jr2-system";
@@ -265,21 +435,77 @@ export type HeldObjects = {
265
435
  /** Does any pod hold a secret? */
266
436
  export const holdsAny = (held: HeldObjects): boolean => held.pods.some((p) => p.manifest.secrets.length > 0);
267
437
 
438
+ /** What `jr2 up` does with the state volume claim (ADR-0065): the size to apply and the one line
439
+ * that says what changed, or the refusal's text. */
440
+ export type StateVolumePlan = { size: string; line?: string } | { refused: string };
441
+
442
+ /**
443
+ * The size of the Orchestrator's state volume claim (ADR-0065). `configured` is `runs.volume`;
444
+ * `claim` the claim that exists, with its StorageClass and whether the class allows expansion
445
+ * (`undefined` when `jr2 up` did not read it). A new claim takes `runs.volume` or the default. A
446
+ * claim that exists keeps its size when the config sets none, grows when the config sets a larger
447
+ * size on a class that expands, and refuses a smaller size, or a larger one on a class that does
448
+ * not expand: Kubernetes never makes a claim smaller, and grows it only where the class allows.
449
+ */
450
+ export function stateVolumePlan(
451
+ configured: string | undefined,
452
+ claim?: { size: string; storageClass?: string; expandable?: boolean },
453
+ ): StateVolumePlan {
454
+ if (!claim) return { size: configured ?? STATE_VOLUME_DEFAULT };
455
+ if (configured === undefined) {
456
+ if (memoryBytes(claim.size) === memoryBytes(STATE_VOLUME_DEFAULT)) return { size: claim.size };
457
+ return {
458
+ size: claim.size,
459
+ line: `state volume: keeping the claim ${STATE_PVC} at ${claim.size} — runs.volume is not set, and the default ${STATE_VOLUME_DEFAULT} is for a new claim`,
460
+ };
461
+ }
462
+ const want = memoryBytes(configured);
463
+ const have = memoryBytes(claim.size);
464
+ if (want === have) return { size: claim.size };
465
+ if (want < have) {
466
+ return {
467
+ refused:
468
+ `runs.volume is ${configured}, smaller than the state volume claim ${STATE_PVC} (${claim.size}) — Kubernetes never ` +
469
+ `makes a claim smaller. Set runs.volume to ${claim.size} or more (ADR-0065)`,
470
+ };
471
+ }
472
+ const named = claim.storageClass ? `StorageClass "${claim.storageClass}"` : "claim's StorageClass";
473
+ if (claim.expandable !== true) {
474
+ return {
475
+ refused:
476
+ `runs.volume is ${configured}, larger than the state volume claim ${STATE_PVC} (${claim.size}), and its ${named} ` +
477
+ `does not allow expansion (allowVolumeExpansion is not true). Keep runs.volume at ${claim.size}, or use a ` +
478
+ `StorageClass that allows expansion (ADR-0065)`,
479
+ };
480
+ }
481
+ return { size: configured, line: `state volume: growing the claim ${STATE_PVC} from ${claim.size} to ${configured}` };
482
+ }
483
+
268
484
  /** Everything `jr2 up` converges inside the instance's namespace, as one apply-able List. */
269
485
  export function instanceObjects(opts: {
270
486
  name: string;
271
487
  namespace: string;
488
+ /** The size of the state volume claim (`stateVolumePlan`, ADR-0065). Default: `5Gi`. */
489
+ stateVolume?: string;
272
490
  /** The Orchestrator Kit image's ref (ADR-0069). The Deployment carries no content hash: the
273
491
  * Assembly's hash is an Orchestrator fact, read from `GET /assembly`. */
274
492
  image: string;
275
493
  /** Secret data (the Instance token, the publish token, the signing key) — written stringData,
276
494
  * kube encodes. The Orchestrator reads it through `envFrom`. */
277
495
  secretData: Record<string, string>;
496
+ /** The values of the named Caller tokens, by name (ADR-0078): each goes into a Secret of its own,
497
+ * and the Orchestrator mounts only their digests (`callerTokenObjects`). Absent: none. */
498
+ callerTokens?: Readonly<Record<string, string>>;
499
+ /** The sha-256 digests of the `token` Hooks' secrets, by secret name (ADR-0078): the Orchestrator
500
+ * mounts them in `jr2-hook-digests` (`hookDigestsObject`). Absent: none. */
501
+ hookDigests?: Readonly<Record<string, string>>;
278
502
  /** The HARNESS containers' literal `harness.env` values — a SEPARATE Secret from `secretData` by
279
503
  * doctrine (ADR-0013): Agent code executes where this lands, so the Instance token and signing
280
504
  * key must never share a Secret with it, and no model key rides it either (ADR-0059). */
281
505
  harnessEnvData: Record<string, string>;
282
506
  harness?: HarnessConfig;
507
+ /** The config's `mcp` block (ADR-0079), for the `jr2-harness` ConfigMap. */
508
+ mcp?: JR2Config["mcp"];
283
509
  /** Held secrets (ADR-0059): the Custodian's objects. The `jr2-held` ConfigMap is applied always —
284
510
  * every Harness pod runs a Custodian — and the rest only while a secret is held. */
285
511
  held: HeldObjects;
@@ -323,7 +549,7 @@ export function instanceObjects(opts: {
323
549
  metadata: meta(STATE_PVC),
324
550
  spec: {
325
551
  accessModes: ["ReadWriteOnce"],
326
- resources: { requests: { storage: "1Gi" } },
552
+ resources: { requests: { storage: opts.stateVolume ?? STATE_VOLUME_DEFAULT } },
327
553
  },
328
554
  },
329
555
  { apiVersion: "v1", kind: "ServiceAccount", metadata: meta(ORCHESTRATOR_SA) },
@@ -368,7 +594,7 @@ export function instanceObjects(opts: {
368
594
  kind: "ConfigMap",
369
595
  metadata: meta(HARNESS_CONFIGMAP),
370
596
  data: {
371
- [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
597
+ [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness, opts.mcp),
372
598
  },
373
599
  },
374
600
  {
@@ -460,6 +686,13 @@ export function instanceObjects(opts: {
460
686
  type: "Opaque",
461
687
  stringData: opts.secretData,
462
688
  },
689
+ // The named Caller tokens (ADR-0078): a value in a Secret of its own, and the digests in the
690
+ // one Secret the Orchestrator mounts below. Not in `jr2-instance`: that one is `envFrom`, which
691
+ // a running pod reads once, and a new token or a rotation must roll no pod.
692
+ ...callerTokenObjects(opts.callerTokens ?? {}, meta),
693
+ // The `token` Hooks' digests (ADR-0078): a Secret of their own, as the door reads them, and
694
+ // never `jr2-caller-digests`, which `jr2 caller rotate` writes whole. Never a value.
695
+ hookDigestsObject(opts.hookDigests ?? {}, meta),
463
696
  {
464
697
  // What the HARNESS containers envFrom (see `harnessEnvData` above): Agent creds, never the
465
698
  // Instance token. Always applied (possibly empty) so the Sandbox spec can reference it
@@ -513,6 +746,9 @@ export function instanceObjects(opts: {
513
746
  // built-in `fetch`, which trusts the ServiceAccount's CA through this and nothing
514
747
  // else — no TLS code, no dependency.
515
748
  { name: "NODE_EXTRA_CA_CERTS", value: SERVICE_ACCOUNT_CA },
749
+ // The claim's size as this converge applied it (ADR-0065): the space guard
750
+ // measures against it, because a hostPath class's `statfs` gives the node's disk.
751
+ { name: "JR2_STATE_VOLUME", value: opts.stateVolume ?? STATE_VOLUME_DEFAULT },
516
752
  ],
517
753
  // The Orchestrator creates Repo resources and never clones (ADR-0051) — the cache
518
754
  // agent on each node does, reading the credential Secret a Repo's `secretRef`
@@ -527,6 +763,13 @@ export function instanceObjects(opts: {
527
763
  // (ADR-0059): names and hosts, which this process could not derive — its `.env`
528
764
  // values are absent.
529
765
  { name: "held", mountPath: HELD_MOUNT, readOnly: true },
766
+ // The digests of the named Caller tokens (ADR-0078), read again when they
767
+ // change: a new token or a rotation costs one kubelet propagation window, about
768
+ // a minute, and no rollout. Never a value.
769
+ { name: "callers", mountPath: CALLER_DIGESTS_MOUNT, readOnly: true },
770
+ // The digests of the `token` Hooks' secrets (ADR-0078), read again in the same
771
+ // way: a new or changed secret costs one kubelet propagation window. Never a value.
772
+ { name: "hooks", mountPath: HOOK_DIGESTS_MOUNT, readOnly: true },
530
773
  ],
531
774
  resources: sized(KIT_POD_SIZES.orchestrator),
532
775
  // The period, not the boot, is what `up`'s rollout wait measures. Measured: the
@@ -564,6 +807,8 @@ export function instanceObjects(opts: {
564
807
  { name: "state", persistentVolumeClaim: { claimName: STATE_PVC } },
565
808
  { name: "images", configMap: { name: IMAGES_CONFIGMAP } },
566
809
  { name: "held", configMap: { name: HELD_CONFIGMAP } },
810
+ { name: "callers", secret: { secretName: CALLER_DIGESTS_SECRET } },
811
+ { name: "hooks", secret: { secretName: HOOK_DIGESTS_SECRET } },
567
812
  ],
568
813
  },
569
814
  },
@@ -579,6 +824,7 @@ export function instanceObjects(opts: {
579
824
  },
580
825
  },
581
826
  ...harnessIngressPolicies(opts.name, meta),
827
+ ...harnessEgressPolicies(meta),
582
828
  ...(opts.repoCache
583
829
  ? repoCacheObjects({
584
830
  ...opts.repoCache,
@@ -610,7 +856,7 @@ const SANDBOX_POD_LABEL = "sandbox.jr2.dev/name";
610
856
  *
611
857
  * - The Orchestrator is not selected. Every route there is token-gated, and a Custodian in any
612
858
  * Harness pod must reach it; a policy could not tell the Custodian's packets from the Agent's.
613
- * - No egress. What a Sandbox may reach is its own decision (ADR-0058 records it open).
859
+ * - No egress. That is `harnessEgressPolicies`: open, except the cloud metadata addresses (ADR-0076).
614
860
  * - No port. The Orchestrator speaks only the Harness wire to these pods, and a port here would
615
861
  * have to track the CR's `port`; `kubectl port-forward` (the CLI, a human's shell into the User
616
862
  * Container — ADR-0005) and kubelet probes never cross a policy at all.
@@ -631,6 +877,26 @@ function harnessIngressPolicies(instance: string, meta: (name: string) => KubeMa
631
877
  ];
632
878
  }
633
879
 
880
+ /**
881
+ * No Harness pod reaches a cloud metadata address (ADR-0076, `metadataEgress`). A Sandbox runs
882
+ * Workspace code, and the node's credential is one that no `workspace()` grants. The Instance
883
+ * Harness runs no Workspace code, but it gets the same rule: a check pod of the provider preflight
884
+ * stands for every Harness pod (ADR-0019), so a model that only the node's credential reaches must
885
+ * fail the check, not a Turn. Converged unconditionally, for the reason the ingress policies are.
886
+ */
887
+ function harnessEgressPolicies(meta: (name: string) => KubeManifest): KubeManifest[] {
888
+ const policy = (name: string, podSelector: Record<string, unknown>): KubeManifest => ({
889
+ apiVersion: "networking.k8s.io/v1",
890
+ kind: "NetworkPolicy",
891
+ metadata: meta(name),
892
+ spec: metadataEgress(podSelector),
893
+ });
894
+ return [
895
+ policy(SANDBOX_EGRESS_POLICY, { matchExpressions: [{ key: SANDBOX_POD_LABEL, operator: "Exists" }] }),
896
+ policy(INSTANCE_HARNESS_EGRESS_POLICY, { matchLabels: { app: INSTANCE_HARNESS_SERVICE } }),
897
+ ];
898
+ }
899
+
634
900
  /** Where the cache agent's pod sees the node's directory: `--cache-dir`'s default. */
635
901
  const REPO_CACHE_MOUNT = "/cache";
636
902
  /** The agent's `$HOME` — an emptyDir, where it writes a deploy key for the life of the pod. */
@@ -651,6 +917,10 @@ const REPO_CACHE_HOME = "/home/jr2";
651
917
  * (ADR-0052), so an agent lands on exactly the nodes a Sandbox can be placed on. The ServiceAccount token IS mounted: the agent is a client of the Repo resources and
652
918
  * of the pods on its node (never of Sandboxes — demand is a pod's mount), unlike a Sandbox, whose
653
919
  * north star is never reaching the API.
920
+ *
921
+ * Because it lands on exactly the Sandbox nodes, it is also the Landlock detector (ADR-0077): it
922
+ * probes at start and, on a node without Landlock, exits with the reason, and `jr2 up` refuses to
923
+ * converge (`landlock.ts`).
654
924
  */
655
925
  function repoCacheObjects(opts: {
656
926
  image: string;
@@ -713,6 +983,11 @@ function repoCacheObjects(opts: {
713
983
  image,
714
984
  imagePullPolicy: "IfNotPresent",
715
985
  command: ["/manager", "repo-cache"],
986
+ // On a node without Landlock the agent exits at start with a message that names
987
+ // Landlock, the node and the reason (ADR-0077). The kubelet keeps it in the
988
+ // container's status, where `jr2 up` reads it; the log line serves if the file
989
+ // write fails.
990
+ terminationMessagePolicy: "FallbackToLogsOnError",
716
991
  env: [
717
992
  // The downward API names the node this pod is the writer for, and the namespace
718
993
  // whose Repos and pods it watches (the agent's `--node` / `--namespace`).
@@ -844,7 +1119,8 @@ export function instanceHarnessObjects(opts: {
844
1119
  * Agent cannot execute code — forking the path for one pod buys a divergence ADR-0031 declines. */
845
1120
  custodianImage: string;
846
1121
  /** What this converge placed in the Instance Harness (ADR-0059): its own manifest — the model
847
- * keys its `"none"` Agents' providers read, and nothing else. */
1122
+ * keys its `"none"` Agents' providers read and the secrets of the remote MCP servers they name
1123
+ * (ADR-0079), and nothing else. */
848
1124
  held: HeldManifest;
849
1125
  /** Its Custodian's two files, rendered from `held` — applied here, with the pod they are for
850
1126
  * (`INSTANCE_CUSTODIAN_CONFIGMAP`, ADR-0059). */
@@ -853,7 +1129,13 @@ export function instanceHarnessObjects(opts: {
853
1129
  * Custodian's config), keyed with the signing key so it reveals nothing — on the pod template, so
854
1130
  * a change to any of them rolls this pod (ADR-0059). */
855
1131
  heldDigest: string;
1132
+ /** The pod's credentials Secret data (`podCredentials`): its control token and its secret
1133
+ * Stand-ins, applied here as {@link INSTANCE_HARNESS_CREDENTIALS} with the pod they are for. */
1134
+ credentials: Record<string, string>;
856
1135
  harness?: HarnessConfig;
1136
+ /** The config's `mcp` block (ADR-0079): with `harness`, the text of the `jr2-harness` ConfigMap,
1137
+ * whose digest is on the pod template. */
1138
+ mcp?: JR2Config["mcp"];
857
1139
  /** The instance ships a private-CA bundle (ADR-0020). */
858
1140
  caBundle?: boolean;
859
1141
  /** The digest of this placement's Harness bearer (ADR-0058; `harnessTokenDigest(key,
@@ -880,15 +1162,16 @@ export function instanceHarnessObjects(opts: {
880
1162
  seccompProfile: { type: "RuntimeDefault" },
881
1163
  });
882
1164
 
883
- // The Custodian (ADR-0013, ADR-0059): the placement's Sandbox token — signed for this
884
- // placement's name (`up.ts` mints it into the instance Secret), so it speaks only for the Turns
885
- // hosted HERE (tokens.ts) — plus the held secrets placed here. Mounted into the Custodian alone;
886
- // the Instance token itself never enters this pod.
1165
+ // The Custodian (ADR-0013, ADR-0059, ADR-0076): the placement's control token — signed for this
1166
+ // placement's name, so it speaks only for the Turns hosted HERE (tokens.ts) — and the held secrets
1167
+ // placed here, from the pod's own credentials Secret (`podCredentials`). The tokens are mounted
1168
+ // into the Custodian alone, the Stand-ins into the Custodian and, as files, the Harness; the
1169
+ // Instance token itself never enters this pod. The pod serves no fetch: no Repo is mounted here.
887
1170
  const custodian = custodianComposition(opts.held, {
888
1171
  image: opts.custodianImage,
889
1172
  pod: INSTANCE_HARNESS_HELD_POD,
890
1173
  config: INSTANCE_CUSTODIAN_CONFIGMAP,
891
- token: { secret: INSTANCE_SECRET, key: "JR2_INSTANCE_HARNESS_TOKEN" },
1174
+ credentials: { secret: INSTANCE_HARNESS_CREDENTIALS },
892
1175
  caBundle: opts.caBundle === true,
893
1176
  resources: sized(KIT_POD_SIZES.instanceCustodian),
894
1177
  });
@@ -901,24 +1184,28 @@ export function instanceHarnessObjects(opts: {
901
1184
  // The same asymmetry the Sandbox pod builds (ADR-0013/0020/0059): the mounted harness config
902
1185
  // and the instance's valueFrom entries ride `env` (literal values live in the jr2-harness-env
903
1186
  // Secret), then the Custodian's address and every Stand-in, and no credential ever does.
904
- env: [
905
- {
906
- name: "JR2_HARNESS_JSON",
907
- valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
908
- },
909
- // The placement gate (ADR-0031): every admission carries its own definition (ADR-0049), so
910
- // the Harness itself refuses any admission whose definition declares Workspace access —
911
- // Menu-only Agents alone run here, which is what makes "no code execution in this pod" true
912
- // rather than asserted. The bearer (below) narrows who may try; this decides what.
913
- { name: "JR2_MENU_ONLY", value: "1" },
914
- ...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
915
- ...custodian.harnessEnv,
916
- // Where the conversations persist (ADR-0031): the claim below. Mechanism, as on a Sandbox.
917
- { name: CONVERSATIONS_DIR_ENV, value: CONVERSATIONS_DIR },
918
- // The wire's gate (ADR-0058), last — as on a Sandbox — so no `harness.env` entry chooses it.
919
- { name: "JR2_HARNESS_TOKEN_SHA256", value: opts.bearerSha256 },
920
- ],
921
- envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(opts.harness?.envFrom ?? [])],
1187
+ // Marked (ADR-0076, `harness-env.ts`): the stock image's command starts the Harness with
1188
+ // `effector nocore --harness-env`, which gives the Harness process jr2's names alone.
1189
+ ...markHarnessEnv(
1190
+ [
1191
+ {
1192
+ name: "JR2_HARNESS_JSON",
1193
+ valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
1194
+ },
1195
+ // The placement gate (ADR-0031): every admission carries its own definition (ADR-0049), so
1196
+ // the Harness itself refuses any admission whose definition declares Workspace access —
1197
+ // Menu-only Agents alone run here, which is what makes "no code execution in this pod" true
1198
+ // rather than asserted. The bearer (below) narrows who may try; this decides what.
1199
+ { name: "JR2_MENU_ONLY", value: "1" },
1200
+ ...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
1201
+ ...custodian.harnessEnv,
1202
+ // Where the conversations persist (ADR-0031): the claim below. Mechanism, as on a Sandbox.
1203
+ { name: CONVERSATIONS_DIR_ENV, value: CONVERSATIONS_DIR },
1204
+ // The wire's gate (ADR-0058), last — as on a Sandbox — so no `harness.env` entry chooses it.
1205
+ { name: "JR2_HARNESS_TOKEN_SHA256", value: opts.bearerSha256 },
1206
+ ],
1207
+ [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(opts.harness?.envFrom ?? [])],
1208
+ ),
922
1209
  // The Harness container's alone: the Custodian has no business with an Agent's history.
923
1210
  volumeMounts: [{ name: CONVERSATIONS_VOLUME, mountPath: CONVERSATIONS_DIR }, ...custodian.harnessMounts],
924
1211
  // The operator probes a Sandbox's Harness the same way: serving = the socket accepts.
@@ -938,6 +1225,14 @@ export function instanceHarnessObjects(opts: {
938
1225
  };
939
1226
 
940
1227
  const items: KubeManifest[] = [
1228
+ // The pod's credentials (ADR-0076): a Secret, never a ConfigMap, env or the pod template.
1229
+ {
1230
+ apiVersion: "v1",
1231
+ kind: "Secret",
1232
+ metadata: { ...meta(), name: INSTANCE_HARNESS_CREDENTIALS },
1233
+ type: "Opaque",
1234
+ stringData: opts.credentials,
1235
+ },
941
1236
  // The one pod `jr2 up` makes, so the one whose Custodian files it renders: its bootstrap and
942
1237
  // its HELD table, from its own manifest, beside the program its volume projects from `jr2-held`.
943
1238
  {
@@ -982,7 +1277,16 @@ export function instanceHarnessObjects(opts: {
982
1277
  // start (ADR-0059, the ADR-0037 stance). A move costs a drain, not the conversations
983
1278
  // (the volume keeps them), yet no autoscaler may choose one (ADR-0060) — a drain still
984
1279
  // can.
985
- annotations: { [ANNOTATION_HELD_DIGEST]: opts.heldDigest, ...NO_DISRUPT_ANNOTATIONS },
1280
+ // The same for the `jr2-harness` text, which the Harness reads once at start: a server
1281
+ // added to `mcp`, or a url, an argv or a timeout changed, reaches the pod (ADR-0079).
1282
+ annotations: {
1283
+ [ANNOTATION_HELD_DIGEST]: opts.heldDigest,
1284
+ [ANNOTATION_HARNESS_CONFIG_DIGEST]: createHash("sha256")
1285
+ .update(harnessConfigJson(opts.harness, opts.mcp))
1286
+ .digest("base64url")
1287
+ .slice(0, 32),
1288
+ ...NO_DISRUPT_ANNOTATIONS,
1289
+ },
986
1290
  },
987
1291
  spec: {
988
1292
  // The drain (ADR-0031): on SIGTERM the Harness stops admitting and finishes the Turns
@@ -1044,9 +1348,10 @@ export function instanceHarnessObjects(opts: {
1044
1348
  }
1045
1349
 
1046
1350
  /** What the Instance can REACH (ADR-0018), as the `jr2-harness` ConfigMap carries it to every
1047
- * Harness container: the custom provider without its key and the catalog gateways. The check pod
1048
- * carries the same text in its own ConfigMap (ADR-0019). */
1049
- export function harnessConfigJson(harness: HarnessConfig | undefined): string {
1351
+ * Harness container: the custom provider without its key, the catalog gateways, and the MCP servers
1352
+ * (ADR-0079) — a remote one's url and the NAME of its secret, never a value. The check pod carries
1353
+ * the same text in its own ConfigMap (ADR-0019). */
1354
+ export function harnessConfigJson(harness: HarnessConfig | undefined, mcp?: JR2Config["mcp"]): string {
1050
1355
  return JSON.stringify(
1051
1356
  {
1052
1357
  // apiKey is deliberately dropped: it is a held secret (ADR-0059) — the Custodian holds
@@ -1065,12 +1370,35 @@ export function harnessConfigJson(harness: HarnessConfig | undefined): string {
1065
1370
  : undefined,
1066
1371
  // Where pi's catalog providers send their calls (ADR-0059): endpoints, not keys.
1067
1372
  catalog: harness?.catalog,
1373
+ // The MCP servers (ADR-0079), by the config's name: the Harness joins each Agent's `mcp` list
1374
+ // to them. In-cluster the config has no `.env`, so the url rides from here, as baseUrl does.
1375
+ mcp: mcp === undefined ? undefined : mcpSpecOf(mcp),
1068
1376
  },
1069
1377
  null,
1070
1378
  2,
1071
1379
  );
1072
1380
  }
1073
1381
 
1382
+ /** The `mcp` block as the Harness reads it (`McpServerSpec` in `@jr2/harness`): the timeout in
1383
+ * milliseconds, and a stdio server's `args` always a list. */
1384
+ function mcpSpecOf(mcp: NonNullable<JR2Config["mcp"]>): Record<string, unknown> {
1385
+ return Object.fromEntries(
1386
+ Object.entries(mcp).map(([name, server]) => {
1387
+ const timeoutMs = mcpTimeoutMs(server.timeout);
1388
+ return [
1389
+ name,
1390
+ isStdioServer(server)
1391
+ ? { command: server.command, args: [...(server.args ?? [])], timeoutMs }
1392
+ : { url: server.url, ...(server.secret !== undefined ? { secret: server.secret } : {}), timeoutMs },
1393
+ ];
1394
+ }),
1395
+ );
1396
+ }
1397
+
1398
+ /** The argv word of `provider-preflight.ts` before the MCP servers to list (`PREFLIGHT_MCP_ARG` in
1399
+ * `@jr2/harness`'s `preflight-line.ts`, which a test holds this to). */
1400
+ export const PREFLIGHT_MCP_ARG = "--mcp";
1401
+
1074
1402
  /** The provider preflight's objects (ADR-0019): a check pod and its own copies of what it reads,
1075
1403
  * each named `jr2-provider-preflight-<run>-<n>` or that name and a suffix, unique to one `jr2 up`. */
1076
1404
  export const PROVIDER_PREFLIGHT = "jr2-provider-preflight";
@@ -1084,6 +1412,7 @@ export const CHECK_CONTAINER = "check";
1084
1412
  export function checkPodObjectNames(object: string): Array<{ kind: string; name: string }> {
1085
1413
  return [
1086
1414
  { kind: "pod", name: object },
1415
+ { kind: "networkpolicy", name: object },
1087
1416
  { kind: "configmap", name: object },
1088
1417
  { kind: "configmap", name: `${object}-ca` },
1089
1418
  { kind: "secret", name: object },
@@ -1100,8 +1429,9 @@ export function checkPodObjectNames(object: string): Array<{ kind: string; name:
1100
1429
  * - `<object>` (ConfigMap): the Custodian's bootstrap and script, the program and the harness config
1101
1430
  * (`harnessConfigJson`).
1102
1431
  * - `<object>-ca` (ConfigMap): the trust bundles, as `jr2-ca` would carry them.
1103
- * - `<object>` (Secret): the Custodian's token, a value the Orchestrator never signed (the check pod
1104
- * reads no Menu), and the LITERAL values of the keys it holds, from the host. A `valueFrom` key
1432
+ * - `<object>` (Secret): the Custodian's control token, a value the Orchestrator never signed (the
1433
+ * check pod reads no Menu), the check pod's own secret Stand-ins (`podCredentials`, ADR-0059), and
1434
+ * the LITERAL values of the keys it holds, from the host. A `valueFrom` key
1105
1435
  * stays a reference to the user's Secret. The values are in this Secret for the check pod's life.
1106
1436
  * - `<object>-tls` (Secret): the leaves of the bound hosts. `<object>-env` (Secret): the literal
1107
1437
  * `harness.env` values, which `jr2-harness-env` would carry.
@@ -1112,6 +1442,9 @@ export function checkPodObjectNames(object: string): Array<{ kind: string; name:
1112
1442
  * container's env, so the call is the one a Turn makes, and the key never enters that container,
1113
1443
  * its argv or its log. The argv is the model specifiers alone.
1114
1444
  *
1445
+ * - `<object>` (NetworkPolicy): the egress of a Harness pod (`metadataEgress`, ADR-0076), so the
1446
+ * check reaches a provider only as a Turn does. It is in the List before the pod.
1447
+ *
1115
1448
  * The pod has the placement and the priority class of the Harness pods it stands for (ADR-0052,
1116
1449
  * ADR-0060). Its labels are its own and match no Harness pod's: the Instance Harness Service
1117
1450
  * selects by label, and would send Turns to it. The Custodian is a native sidecar with a startup
@@ -1136,11 +1469,18 @@ export function checkPodObjects(opts: {
1136
1469
  trust: Record<string, string>;
1137
1470
  /** The Custodian's two files, rendered from `held`. */
1138
1471
  custodian: CustodianFiles;
1139
- token: string;
1472
+ /** The check pod's credentials (`podCredentials`, ADR-0076): a control token the Orchestrator
1473
+ * never signed, and its own secret Stand-ins, minted for this pod alone. */
1474
+ credentials: Record<string, string>;
1140
1475
  /** The literal `harness.env` values. */
1141
1476
  harnessEnvData: Record<string, string>;
1142
1477
  /** The `<provider>/<model>` specifiers to check, in order. */
1143
1478
  models: readonly string[];
1479
+ /** The remote MCP servers whose tools to list, by the config's name, in order (ADR-0079). */
1480
+ servers?: readonly string[];
1481
+ /** The config's `mcp` block: the check pod's harness config carries it, as the Harness pods'
1482
+ * does, so the check reads each server's url and secret name where a Turn reads them. */
1483
+ mcp?: JR2Config["mcp"];
1144
1484
  /** The node predicate of the Harness pods this check pod stands for. */
1145
1485
  placement?: SandboxPlacement;
1146
1486
  priorityClassName: string;
@@ -1161,7 +1501,7 @@ export function checkPodObjects(opts: {
1161
1501
  pod: o,
1162
1502
  reads: { literals: o, tls: `${o}-tls`, trust: `${o}-ca`, program: o },
1163
1503
  config: o,
1164
- token: { secret: o, key: "token" },
1504
+ credentials: { secret: o },
1165
1505
  caBundle: opts.caBundle === true,
1166
1506
  resources: sized(KIT_POD_SIZES.instanceCustodian),
1167
1507
  });
@@ -1203,12 +1543,19 @@ export function checkPodObjects(opts: {
1203
1543
  "/opt/jr2/bin/node",
1204
1544
  "/opt/jr2/src/provider-preflight.ts",
1205
1545
  ...opts.models,
1546
+ ...(opts.servers?.length ? [PREFLIGHT_MCP_ARG, ...opts.servers] : []),
1206
1547
  ],
1207
- // The Harness container's env, in its order (`instanceHarnessObjects`): no credential.
1548
+ // The Harness container's env, in its order (`instanceHarnessObjects`): no credential. And
1549
+ // the check pod's own Stand-ins (ADR-0019): it runs no Workspace code, so it may hold them
1550
+ // in env, where pi reads a provider's key; each is a `secretKeyRef`, never a value here.
1208
1551
  env: [
1209
1552
  { name: "JR2_HARNESS_JSON", valueFrom: { configMapKeyRef: { name: o, key: HARNESS_CONFIG_KEY } } },
1210
1553
  ...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
1211
1554
  ...custodian.harnessEnv,
1555
+ ...opts.held.secrets.map((s) => ({
1556
+ name: s.name,
1557
+ valueFrom: { secretKeyRef: { name: o, key: heldStandInKey(s.name) } },
1558
+ })),
1212
1559
  ],
1213
1560
  envFrom: [{ secretRef: { name: `${o}-env` } }, ...(opts.harness?.envFrom ?? [])],
1214
1561
  volumeMounts: custodian.harnessMounts,
@@ -1250,13 +1597,19 @@ export function checkPodObjects(opts: {
1250
1597
  [CUSTODIAN_BOOTSTRAP_KEY]: opts.custodian.bootstrap,
1251
1598
  [CUSTODIAN_SCRIPT_KEY]: opts.custodian.script,
1252
1599
  [CUSTODIAN_PROGRAM_KEY]: CUSTODIAN_PROGRAM,
1253
- [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
1600
+ [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness, opts.mcp),
1254
1601
  },
1255
1602
  },
1256
1603
  { apiVersion: "v1", kind: "ConfigMap", metadata: meta(`${o}-ca`), data: opts.trust },
1257
- secret(o, { token: opts.token, ...literals }),
1604
+ secret(o, { ...opts.credentials, ...literals }),
1258
1605
  secret(`${o}-tls`, leaves),
1259
1606
  secret(`${o}-env`, opts.harnessEnvData),
1607
+ {
1608
+ apiVersion: "networking.k8s.io/v1",
1609
+ kind: "NetworkPolicy",
1610
+ metadata: meta(o),
1611
+ spec: metadataEgress({ matchLabels: { app: PROVIDER_PREFLIGHT, [LABEL_PROVIDER_PREFLIGHT]: opts.run } }),
1612
+ },
1260
1613
  pod,
1261
1614
  ];
1262
1615
  return JSON.stringify({ apiVersion: "v1", kind: "List", items });
@@ -1307,7 +1660,8 @@ export const effectorGraceSeconds = (plan: EffectorPlan): number =>
1307
1660
 
1308
1661
  /**
1309
1662
  * The Instance Effector (ADR-0068): the objects `jr2 up` converges when a registered Machine
1310
- * places an Effect there — an `http` Effect — and deletes otherwise.
1663
+ * places an Effect there — an `http` Effect — or a Hook needs it (a `mac` verifier, or a kind that
1664
+ * declares `posts`), and deletes otherwise.
1311
1665
  *
1312
1666
  * - A Deployment, not a StatefulSet: a rollout must run the new pod beside the old one (surge), so
1313
1667
  * the old one finishes an Effect in flight while the new one takes new Effects. The Kit's
@@ -1358,7 +1712,13 @@ export function instanceEffectorObjects(opts: {
1358
1712
  : [],
1359
1713
  ),
1360
1714
  ];
1361
- const config = { version: 1, placement: opts.plan.placement, targets: opts.plan.targets };
1715
+ // The servers its `mcp` Effects call (ADR-0079): each one's url, its secret's name, its timeout.
1716
+ const config = {
1717
+ version: 1,
1718
+ placement: opts.plan.placement,
1719
+ targets: opts.plan.targets,
1720
+ ...(Object.keys(opts.plan.mcp).length ? { mcp: opts.plan.mcp } : {}),
1721
+ };
1362
1722
 
1363
1723
  const container = {
1364
1724
  name: "effector",
@@ -1498,7 +1858,14 @@ export type ValueDigest = (secret: string, key: string) => string | undefined;
1498
1858
  * annotation reveals nothing about a value. */
1499
1859
  export function effectorDigest(signingKey: Buffer, plan: EffectorPlan, key: string, valueDigest: ValueDigest): string {
1500
1860
  const mac = createHmac("sha256", signingKey);
1501
- mac.update(JSON.stringify({ secrets: plan.secrets, targets: plan.targets, longest: plan.longestTimeoutMs }));
1861
+ mac.update(
1862
+ JSON.stringify({
1863
+ secrets: plan.secrets,
1864
+ targets: plan.targets,
1865
+ longest: plan.longestTimeoutMs,
1866
+ ...(Object.keys(plan.mcp).length ? { mcp: plan.mcp } : {}),
1867
+ }),
1868
+ );
1502
1869
  for (const name of Object.keys(plan.values).sort()) mac.update(`${name}=${plan.values[name]}`);
1503
1870
  for (const s of plan.secrets) {
1504
1871
  if (s.source.kind === "secret") mac.update(`${s.name}<-${valueDigest(s.source.secret, s.source.key) ?? ""}`);