@jr2/cli 0.6.0 → 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, randomBytes } 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,
@@ -24,6 +29,9 @@ import {
24
29
  controlToken,
25
30
  heldStandIn,
26
31
  heldStandInKey,
32
+ HOOK_DIGESTS_KEY,
33
+ HOOK_DIGESTS_MOUNT,
34
+ HOOK_DIGESTS_SECRET,
27
35
  INSTANCE_CUSTODIAN_CONFIGMAP,
28
36
  GIT_SSH_SECRET,
29
37
  HELD_CA_SECRET,
@@ -52,6 +60,8 @@ import {
52
60
  REPO_CACHE_HOSTPATH,
53
61
  SERVICE_ACCOUNT_CA,
54
62
  STATE_PVC,
63
+ STATE_VOLUME_DEFAULT,
64
+ memoryBytes,
55
65
  type EffectorPlan,
56
66
  type SandboxEffectorPlan,
57
67
  effectorManifestKey,
@@ -64,6 +74,9 @@ import {
64
74
  type GitCloneSecret,
65
75
  type GitPushManifest,
66
76
  type HarnessConfig,
77
+ type JR2Config,
78
+ isStdioServer,
79
+ mcpTimeoutMs,
67
80
  type HeldManifest,
68
81
  markHarnessEnv,
69
82
  type PriorityClasses,
@@ -92,9 +105,85 @@ export const ANNOTATION_IMAGES = "jr2.dev/images";
92
105
 
93
106
  export const ORCHESTRATOR_SA = "jr2-orchestrator";
94
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
+
95
178
  /** The HMAC over the held-secret inputs, on the Instance Harness's pod template (ADR-0059). */
96
179
  export const ANNOTATION_HELD_DIGEST = "jr2.dev/held-digest";
97
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
+
98
187
  /** The Instance Harness pod's `fsGroup` — a Sandbox pod's default work group (ADR-0005), for the
99
188
  * same reason: a group every container is granted, which the Custodian's values are readable by. */
100
189
  const INSTANCE_HARNESS_FS_GROUP = 2000;
@@ -346,21 +435,77 @@ export type HeldObjects = {
346
435
  /** Does any pod hold a secret? */
347
436
  export const holdsAny = (held: HeldObjects): boolean => held.pods.some((p) => p.manifest.secrets.length > 0);
348
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
+
349
484
  /** Everything `jr2 up` converges inside the instance's namespace, as one apply-able List. */
350
485
  export function instanceObjects(opts: {
351
486
  name: string;
352
487
  namespace: string;
488
+ /** The size of the state volume claim (`stateVolumePlan`, ADR-0065). Default: `5Gi`. */
489
+ stateVolume?: string;
353
490
  /** The Orchestrator Kit image's ref (ADR-0069). The Deployment carries no content hash: the
354
491
  * Assembly's hash is an Orchestrator fact, read from `GET /assembly`. */
355
492
  image: string;
356
493
  /** Secret data (the Instance token, the publish token, the signing key) — written stringData,
357
494
  * kube encodes. The Orchestrator reads it through `envFrom`. */
358
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>>;
359
502
  /** The HARNESS containers' literal `harness.env` values — a SEPARATE Secret from `secretData` by
360
503
  * doctrine (ADR-0013): Agent code executes where this lands, so the Instance token and signing
361
504
  * key must never share a Secret with it, and no model key rides it either (ADR-0059). */
362
505
  harnessEnvData: Record<string, string>;
363
506
  harness?: HarnessConfig;
507
+ /** The config's `mcp` block (ADR-0079), for the `jr2-harness` ConfigMap. */
508
+ mcp?: JR2Config["mcp"];
364
509
  /** Held secrets (ADR-0059): the Custodian's objects. The `jr2-held` ConfigMap is applied always —
365
510
  * every Harness pod runs a Custodian — and the rest only while a secret is held. */
366
511
  held: HeldObjects;
@@ -404,7 +549,7 @@ export function instanceObjects(opts: {
404
549
  metadata: meta(STATE_PVC),
405
550
  spec: {
406
551
  accessModes: ["ReadWriteOnce"],
407
- resources: { requests: { storage: "1Gi" } },
552
+ resources: { requests: { storage: opts.stateVolume ?? STATE_VOLUME_DEFAULT } },
408
553
  },
409
554
  },
410
555
  { apiVersion: "v1", kind: "ServiceAccount", metadata: meta(ORCHESTRATOR_SA) },
@@ -449,7 +594,7 @@ export function instanceObjects(opts: {
449
594
  kind: "ConfigMap",
450
595
  metadata: meta(HARNESS_CONFIGMAP),
451
596
  data: {
452
- [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
597
+ [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness, opts.mcp),
453
598
  },
454
599
  },
455
600
  {
@@ -541,6 +686,13 @@ export function instanceObjects(opts: {
541
686
  type: "Opaque",
542
687
  stringData: opts.secretData,
543
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),
544
696
  {
545
697
  // What the HARNESS containers envFrom (see `harnessEnvData` above): Agent creds, never the
546
698
  // Instance token. Always applied (possibly empty) so the Sandbox spec can reference it
@@ -594,6 +746,9 @@ export function instanceObjects(opts: {
594
746
  // built-in `fetch`, which trusts the ServiceAccount's CA through this and nothing
595
747
  // else — no TLS code, no dependency.
596
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 },
597
752
  ],
598
753
  // The Orchestrator creates Repo resources and never clones (ADR-0051) — the cache
599
754
  // agent on each node does, reading the credential Secret a Repo's `secretRef`
@@ -608,6 +763,13 @@ export function instanceObjects(opts: {
608
763
  // (ADR-0059): names and hosts, which this process could not derive — its `.env`
609
764
  // values are absent.
610
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 },
611
773
  ],
612
774
  resources: sized(KIT_POD_SIZES.orchestrator),
613
775
  // The period, not the boot, is what `up`'s rollout wait measures. Measured: the
@@ -645,6 +807,8 @@ export function instanceObjects(opts: {
645
807
  { name: "state", persistentVolumeClaim: { claimName: STATE_PVC } },
646
808
  { name: "images", configMap: { name: IMAGES_CONFIGMAP } },
647
809
  { name: "held", configMap: { name: HELD_CONFIGMAP } },
810
+ { name: "callers", secret: { secretName: CALLER_DIGESTS_SECRET } },
811
+ { name: "hooks", secret: { secretName: HOOK_DIGESTS_SECRET } },
648
812
  ],
649
813
  },
650
814
  },
@@ -955,7 +1119,8 @@ export function instanceHarnessObjects(opts: {
955
1119
  * Agent cannot execute code — forking the path for one pod buys a divergence ADR-0031 declines. */
956
1120
  custodianImage: string;
957
1121
  /** What this converge placed in the Instance Harness (ADR-0059): its own manifest — the model
958
- * 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. */
959
1124
  held: HeldManifest;
960
1125
  /** Its Custodian's two files, rendered from `held` — applied here, with the pod they are for
961
1126
  * (`INSTANCE_CUSTODIAN_CONFIGMAP`, ADR-0059). */
@@ -968,6 +1133,9 @@ export function instanceHarnessObjects(opts: {
968
1133
  * Stand-ins, applied here as {@link INSTANCE_HARNESS_CREDENTIALS} with the pod they are for. */
969
1134
  credentials: Record<string, string>;
970
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"];
971
1139
  /** The instance ships a private-CA bundle (ADR-0020). */
972
1140
  caBundle?: boolean;
973
1141
  /** The digest of this placement's Harness bearer (ADR-0058; `harnessTokenDigest(key,
@@ -1109,7 +1277,16 @@ export function instanceHarnessObjects(opts: {
1109
1277
  // start (ADR-0059, the ADR-0037 stance). A move costs a drain, not the conversations
1110
1278
  // (the volume keeps them), yet no autoscaler may choose one (ADR-0060) — a drain still
1111
1279
  // can.
1112
- 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
+ },
1113
1290
  },
1114
1291
  spec: {
1115
1292
  // The drain (ADR-0031): on SIGTERM the Harness stops admitting and finishes the Turns
@@ -1171,9 +1348,10 @@ export function instanceHarnessObjects(opts: {
1171
1348
  }
1172
1349
 
1173
1350
  /** What the Instance can REACH (ADR-0018), as the `jr2-harness` ConfigMap carries it to every
1174
- * Harness container: the custom provider without its key and the catalog gateways. The check pod
1175
- * carries the same text in its own ConfigMap (ADR-0019). */
1176
- 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 {
1177
1355
  return JSON.stringify(
1178
1356
  {
1179
1357
  // apiKey is deliberately dropped: it is a held secret (ADR-0059) — the Custodian holds
@@ -1192,12 +1370,35 @@ export function harnessConfigJson(harness: HarnessConfig | undefined): string {
1192
1370
  : undefined,
1193
1371
  // Where pi's catalog providers send their calls (ADR-0059): endpoints, not keys.
1194
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),
1195
1376
  },
1196
1377
  null,
1197
1378
  2,
1198
1379
  );
1199
1380
  }
1200
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
+
1201
1402
  /** The provider preflight's objects (ADR-0019): a check pod and its own copies of what it reads,
1202
1403
  * each named `jr2-provider-preflight-<run>-<n>` or that name and a suffix, unique to one `jr2 up`. */
1203
1404
  export const PROVIDER_PREFLIGHT = "jr2-provider-preflight";
@@ -1275,6 +1476,11 @@ export function checkPodObjects(opts: {
1275
1476
  harnessEnvData: Record<string, string>;
1276
1477
  /** The `<provider>/<model>` specifiers to check, in order. */
1277
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"];
1278
1484
  /** The node predicate of the Harness pods this check pod stands for. */
1279
1485
  placement?: SandboxPlacement;
1280
1486
  priorityClassName: string;
@@ -1337,6 +1543,7 @@ export function checkPodObjects(opts: {
1337
1543
  "/opt/jr2/bin/node",
1338
1544
  "/opt/jr2/src/provider-preflight.ts",
1339
1545
  ...opts.models,
1546
+ ...(opts.servers?.length ? [PREFLIGHT_MCP_ARG, ...opts.servers] : []),
1340
1547
  ],
1341
1548
  // The Harness container's env, in its order (`instanceHarnessObjects`): no credential. And
1342
1549
  // the check pod's own Stand-ins (ADR-0019): it runs no Workspace code, so it may hold them
@@ -1390,7 +1597,7 @@ export function checkPodObjects(opts: {
1390
1597
  [CUSTODIAN_BOOTSTRAP_KEY]: opts.custodian.bootstrap,
1391
1598
  [CUSTODIAN_SCRIPT_KEY]: opts.custodian.script,
1392
1599
  [CUSTODIAN_PROGRAM_KEY]: CUSTODIAN_PROGRAM,
1393
- [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness),
1600
+ [HARNESS_CONFIG_KEY]: harnessConfigJson(opts.harness, opts.mcp),
1394
1601
  },
1395
1602
  },
1396
1603
  { apiVersion: "v1", kind: "ConfigMap", metadata: meta(`${o}-ca`), data: opts.trust },
@@ -1453,7 +1660,8 @@ export const effectorGraceSeconds = (plan: EffectorPlan): number =>
1453
1660
 
1454
1661
  /**
1455
1662
  * The Instance Effector (ADR-0068): the objects `jr2 up` converges when a registered Machine
1456
- * 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.
1457
1665
  *
1458
1666
  * - A Deployment, not a StatefulSet: a rollout must run the new pod beside the old one (surge), so
1459
1667
  * the old one finishes an Effect in flight while the new one takes new Effects. The Kit's
@@ -1504,7 +1712,13 @@ export function instanceEffectorObjects(opts: {
1504
1712
  : [],
1505
1713
  ),
1506
1714
  ];
1507
- 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
+ };
1508
1722
 
1509
1723
  const container = {
1510
1724
  name: "effector",
@@ -1644,7 +1858,14 @@ export type ValueDigest = (secret: string, key: string) => string | undefined;
1644
1858
  * annotation reveals nothing about a value. */
1645
1859
  export function effectorDigest(signingKey: Buffer, plan: EffectorPlan, key: string, valueDigest: ValueDigest): string {
1646
1860
  const mac = createHmac("sha256", signingKey);
1647
- 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
+ );
1648
1869
  for (const name of Object.keys(plan.values).sort()) mac.update(`${name}=${plan.values[name]}`);
1649
1870
  for (const s of plan.secrets) {
1650
1871
  if (s.source.kind === "secret") mac.update(`${s.name}<-${valueDigest(s.source.secret, s.source.key) ?? ""}`);
@@ -18,9 +18,15 @@ import {
18
18
  CUSTOM_PROVIDER_APIS,
19
19
  dispatchRefusals,
20
20
  doorRefusals,
21
+ gateLookWarnings,
22
+ gateScopeChecks,
23
+ standingPoolLines,
24
+ standingPoolRefusals,
21
25
  loadCharts,
22
26
  loadWorkflows,
23
27
  machineBodies,
28
+ mcpRefusals,
29
+ mcpUrlRefusals,
24
30
  openPartRefusals,
25
31
  partsOf,
26
32
  placementWalkOf,
@@ -106,7 +112,17 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
106
112
  // and the door's walk cannot drift.
107
113
  // A zod `input` that no door reads (ADR-0033) is in the same listing, first: the door refuses it
108
114
  // with the same line.
109
- const open = [...doorRefusals(workflows), ...openPartRefusals(workflows)];
115
+ // A standing Pool that is not the top Machine of its Workflow (ADR-0017) is in the listing too.
116
+ // An Agent's `mcp` list against the config's `mcp` block (ADR-0079) is in the same listing: a
117
+ // server name the block does not declare is refused as an Open part is, with its customize() line,
118
+ // and the door refuses the same lines. The urls are the host's alone (`.env`).
119
+ const open = [
120
+ ...doorRefusals(workflows),
121
+ ...standingPoolRefusals(workflows),
122
+ ...openPartRefusals(workflows),
123
+ ...mcpRefusals(workflows, config),
124
+ ...mcpUrlRefusals(config),
125
+ ];
110
126
  if (open.length) {
111
127
  for (const line of open) activity(io, `refusing: ${line}`);
112
128
  return undefined;
@@ -120,6 +136,15 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
120
136
  for (const line of chartRefusals) activity(io, `refusing: ${line}`);
121
137
  return undefined;
122
138
  }
139
+ // Each `answer` item that names single Gates (ADR-0078) names Gates that the Workflow's Machines or
140
+ // its Chart files have: the door's own check, so a typo fails here and not at the first answer. A
141
+ // name that only a runtime Chart can have is a warning, because the host cannot see those Charts.
142
+ const gateScopes = gateScopeChecks(workflows, config, charts);
143
+ for (const line of gateScopes.warnings) activity(io, `warning: ${line}`);
144
+ if (gateScopes.errors.length) {
145
+ for (const line of gateScopes.errors) activity(io, `refusing: ${line}`);
146
+ return undefined;
147
+ }
123
148
  // What each same-Instance dispatch names resolves in this Assembly, and its slot schema matches
124
149
  // the callee's door (ADR-0071): the door's own check, so the publish would refuse the same lines.
125
150
  // The host cannot see the Orchestrator's volume, so a named Chart that is no Chart file here is a
@@ -130,6 +155,15 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
130
155
  for (const line of dispatched.errors) activity(io, `refusing: ${line}`);
131
156
  return undefined;
132
157
  }
158
+ // A hooked Gate that a lost delivery can stop (ADR-0011, ADR-0054): no look and no `after` on its
159
+ // state, or a look that a package left Open and nobody bound. A warning, never a refusal: a person
160
+ // can still answer the Gate with `jr2 send`.
161
+ for (const line of gateLookWarnings(workflows)) activity(io, `warning: ${line}`);
162
+ // Each standing Pool (ADR-0017): the chain from each Hook that reaches it to its callee, and a
163
+ // warning when two of them dispatch to one Workflow, whose caps then add.
164
+ const standing = standingPoolLines(workflows, config.hooks);
165
+ for (const chain of standing.chains) activity(io, `standing: ${chain}`);
166
+ for (const line of standing.warnings) activity(io, `warning: ${line}`);
133
167
  // What each registered Machine runs outside itself (ADR-0067, ADR-0068): every Effect slot, with
134
168
  // the plain data it carries — a command's argv and its timeout; an http
135
169
  // Effect's method, its fixed target and the secrets it names — read, never run.
@@ -162,6 +196,18 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
162
196
  );
163
197
  continue;
164
198
  }
199
+ if (e.kind === "mcp") {
200
+ // The server's url and secret are the config's (ADR-0079): the line names the server, and
201
+ // its timeout is the server's unless the Effect sets one.
202
+ const asks =
203
+ e.op === "call" ? `call ${e.server ?? "<open>"}/${e.tool ?? "<open>"}` : `${e.op} ${e.server ?? "<open>"}`;
204
+ const timeout = e.timeoutMs > 0 ? `${e.timeoutMs / 1000}s` : "the server's timeout";
205
+ activity(
206
+ io,
207
+ ` ${e.name}${where}: mcp ${asks} — ${timeout}, in the Instance Effector${e.idempotent ? ", idempotent" : ""}`,
208
+ );
209
+ continue;
210
+ }
165
211
  if (e.kind === "push") {
166
212
  const opts = (["pin", "sign", "squash", "force"] as const).filter((k) => e[k]);
167
213
  const slot = e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
package/src/instance.ts CHANGED
@@ -11,10 +11,13 @@
11
11
  // `config.name` > folder name): the Instance token is read from the in-cluster `jr2-instance`
12
12
  // Secret and the `jr2-orchestrator` Service is port-forwarded for the life of the command —
13
13
  // kube RBAC is the real gate, and no local state file can go stale (ADR-0019).
14
+ // - On the kube path the CLI says whom it acts for (ADR-0078): the user name a kube
15
+ // `SelfSubjectReview` gives for the context, sent as `jr2-on-behalf-of`. The Instance token is
16
+ // the Caller `token/instance`; the name says which person held it. With `--url` it says none.
14
17
  // - Every resolution prints its target on stderr, so ambient-context drift stays visible.
15
18
 
16
19
  import { basename } from "node:path";
17
- import { loadConfig } from "@jr2/orchestrator/internal";
20
+ import { loadConfig, ON_BEHALF_OF_MAX } from "@jr2/orchestrator/internal";
18
21
  import { INSTANCE_SECRET, kubectlKube, ORCHESTRATOR_PORT, ORCHESTRATOR_SERVICE } from "./kube.ts";
19
22
  import { assertKitVersion } from "./kit-version.ts";
20
23
  import { activity, type Io } from "./output.ts";
@@ -31,8 +34,9 @@ export function resolveRoot(cwd: string): string {
31
34
  return root;
32
35
  }
33
36
 
34
- /** Where to talk, as whom, and how to hang up (the port-forward lives only as long as the verb). */
35
- export type Target = { url: string; token?: string; close?: () => void };
37
+ /** Where to talk, as whom, and how to hang up (the port-forward lives only as long as the verb).
38
+ * `onBehalfOf` is the party the CLI says it acts for (ADR-0078): on the kube path, the kube user. */
39
+ export type Target = { url: string; token?: string; onBehalfOf?: string; close?: () => void };
36
40
 
37
41
  export type TargetOptions = {
38
42
  /** Explicit orchestrator address (`--url`); with `JR2_URL` the escape hatch past the kube path. */
@@ -90,6 +94,13 @@ export async function resolveTarget(io: Io, opts: TargetOptions): Promise<Target
90
94
  `not deployed here — right context? (context ${context}, namespace ${namespace}; \`jr2 up\` deploys)`,
91
95
  );
92
96
  }
93
- const fwd = await kube.portForward({ namespace, service: ORCHESTRATOR_SERVICE, port: ORCHESTRATOR_PORT, ...ctx });
94
- return { url: fwd.url, token, close: fwd.close };
97
+ // The kube user, read beside the forward so it costs no time of its own. A name past the
98
+ // header's bound is cut, never sent whole to a refusal. A name that is not printable ASCII is not
99
+ // sent: a header value cannot carry it, and the claim is not worth a failed verb.
100
+ const [fwd, user] = await Promise.all([
101
+ kube.portForward({ namespace, service: ORCHESTRATOR_SERVICE, port: ORCHESTRATOR_PORT, ...ctx }),
102
+ kube.selfSubject(ctx),
103
+ ]);
104
+ const onBehalfOf = user && /^[\x20-\x7e]+$/.test(user) ? user.slice(0, ON_BEHALF_OF_MAX).trim() : undefined;
105
+ return { url: fwd.url, token, ...(onBehalfOf ? { onBehalfOf } : {}), close: fwd.close };
95
106
  }
package/src/kube.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // `KubePort` is what target resolution consumes (tests inject a fake), `kubectlKube` is the real
5
5
  // one, shelling out to the same `kubectl` a human debugging the cluster would use.
6
6
 
7
- import { execFile, spawn } from "node:child_process";
7
+ import { execFile, spawn, type ChildProcess } from "node:child_process";
8
8
  import { setTimeout as sleep } from "node:timers/promises";
9
9
  import { promisify } from "node:util";
10
10
 
@@ -41,6 +41,11 @@ export type KubePort = {
41
41
  * cluster could not answer at all — the two are different faults (ADR-0019), and a caller that
42
42
  * merged them would send a user with a dead VPN off to check a context that is correct. */
43
43
  readSecret(opts: { namespace: string; name: string; key: string; context?: string }): Promise<string | undefined>;
44
+ /** The user name that the API server gives the current credentials, from a `SelfSubjectReview`
45
+ * (`kubectl auth whoami`): the party the CLI says it acts for (ADR-0078). Undefined when the
46
+ * server does not answer it. Never rejects: the name is a claim the CLI adds, and a verb does not
47
+ * fail for want of it. */
48
+ selfSubject(opts: { context?: string }): Promise<string | undefined>;
44
49
  /** Forward a Service port to an ephemeral local port for the life of the command. */
45
50
  portForward(opts: {
46
51
  namespace: string;
@@ -50,6 +55,21 @@ export type KubePort = {
50
55
  }): Promise<{ url: string; close: () => void }>;
51
56
  };
52
57
 
58
+ /**
59
+ * A long-lived kubectl child (a port-forward) ends with this process. Node does not end a child
60
+ * when it exits, so a verb that `process.exit`s, or that a signal handler ends, leaves the child
61
+ * alive, and it holds an API-server stream. Here the child is killed on this process's `exit`.
62
+ * Returns the kill, which is also what `close` calls; the hook goes when the child closes.
63
+ */
64
+ export function endsWithProcess(child: ChildProcess): () => void {
65
+ const kill = () => {
66
+ if (child.exitCode === null && child.signalCode === null) child.kill();
67
+ };
68
+ process.once("exit", kill);
69
+ child.once("close", () => process.removeListener("exit", kill));
70
+ return kill;
71
+ }
72
+
53
73
  const ctxArgs = (context?: string): string[] => (context ? ["--context", context] : []);
54
74
 
55
75
  /** The loosely-typed kube object shape the converge logic inspects: labels carry the instance's
@@ -1139,6 +1159,19 @@ export const kubectlKube: KubePort = {
1139
1159
  }
1140
1160
  },
1141
1161
 
1162
+ async selfSubject({ context }) {
1163
+ try {
1164
+ const { stdout } = await exec(
1165
+ "kubectl",
1166
+ [...ctxArgs(context), "auth", "whoami", "-o", "jsonpath={.status.userInfo.username}", ...reachArgs],
1167
+ { timeout: REACH_BUDGET_MS },
1168
+ );
1169
+ return stdout.trim() || undefined;
1170
+ } catch {
1171
+ return undefined; // a server without the API, or RBAC that refuses it: no claim, and no failure
1172
+ }
1173
+ },
1174
+
1142
1175
  // `:port` asks kubectl for an ephemeral local port, announced on its stdout as
1143
1176
  // "Forwarding from 127.0.0.1:<local> -> <remote>" — parsed rather than raced.
1144
1177
  portForward({ namespace, service, port, context }) {
@@ -1151,13 +1184,14 @@ export const kubectlKube: KubePort = {
1151
1184
  `service/${service}`,
1152
1185
  `:${port}`,
1153
1186
  ]);
1187
+ const kill = endsWithProcess(child);
1154
1188
  let out = "";
1155
1189
  let err = "";
1156
1190
  // The forward is long-lived by design, so the bound is on its FIRST BYTE, not on the child:
1157
1191
  // silence here is the same unreachable cluster the Secret read just survived, and a verb that
1158
1192
  // waits forever for the announcement has only moved the hang one line down.
1159
1193
  const gaveUp = setTimeout(() => {
1160
- child.kill();
1194
+ kill();
1161
1195
  reject(new Error(`kubectl port-forward: no answer after ${REACH_BUDGET_MS / 1000}s`));
1162
1196
  }, REACH_BUDGET_MS);
1163
1197
  gaveUp.unref?.();
@@ -1172,7 +1206,7 @@ export const kubectlKube: KubePort = {
1172
1206
  child.stdout.on("data", (d: Buffer) => {
1173
1207
  out += d.toString();
1174
1208
  const m = /Forwarding from 127\.0\.0\.1:(\d+)/.exec(out);
1175
- if (m) done({ url: `http://127.0.0.1:${m[1]}`, close: () => child.kill() });
1209
+ if (m) done({ url: `http://127.0.0.1:${m[1]}`, close: kill });
1176
1210
  });
1177
1211
  child.stderr.on("data", (d: Buffer) => (err += d.toString()));
1178
1212
  child.on("error", (e) => failed(new Error(`kubectl port-forward failed: ${e.message}`)));