@jr2/cli 0.5.1 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.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,14 +39,14 @@
39
39
  "ts-blank-space": "^0.9.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@jr2/orchestrator": "0.5.1"
42
+ "@jr2/orchestrator": "0.6.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@anthropic-ai/sdk": "0.91.1",
46
46
  "@types/node": "^26.0.1",
47
47
  "xstate": "^5.18.0",
48
48
  "zod": "^4.4.3",
49
- "@jr2/orchestrator": "0.5.1"
49
+ "@jr2/orchestrator": "0.6.0"
50
50
  },
51
51
  "files": [
52
52
  "bin",
package/src/client.ts CHANGED
@@ -53,6 +53,9 @@ export type RunStatus = {
53
53
  /** The runs this run's `dispatch` Effects started, oldest first: the slot, the run, and when its
54
54
  * ledger row was written (ADR-0071). Absent when it started none. */
55
55
  dispatched?: Array<{ slot: string; run: string; at: string }>;
56
+ /** The Worktrees this run's cuts made, oldest first: the Repo Slot, the path, and the branch,
57
+ * `null` when detached (ADR-0075). Absent when it cut none. */
58
+ worktrees?: Array<{ slot: string; path: string; branch: string | null }>;
56
59
  };
57
60
 
58
61
  /** Where a Workflow came from (ADR-0070): a Machine in the Assembly, a Chart file in the Assembly, or
@@ -20,6 +20,9 @@
20
20
  // started: `dispatched: <run> (<slot>)`. The result itself carries `dispatchedBy`, `dispatched`
21
21
  // and, once the run is no longer live, its `output`, as the Orchestrator sends them.
22
22
  //
23
+ // A run that cut a Worktree (ADR-0075) says each one beside the result, one line per cut:
24
+ // `worktree: <path> (<slot>), branch <branch>`, or `, detached`. The result carries `worktrees`.
25
+ //
23
26
  // A failed run (ADR-0073) says its reason beside the result: `failed: <reason>`. The result's
24
27
  // `output` holds the whole Failure, `{ reason, detail? }`.
25
28
 
@@ -51,7 +54,13 @@ export async function status(args: string[], io: Io): Promise<number> {
51
54
  return 1;
52
55
  }
53
56
  result(io, s);
54
- const notes = [failureActivity(s), dispatchedActivity(s), ...dispatchedLines(s), boundActivity(s)];
57
+ const notes = [
58
+ failureActivity(s),
59
+ dispatchedActivity(s),
60
+ ...dispatchedLines(s),
61
+ boundActivity(s),
62
+ ...worktreeLines(s),
63
+ ];
55
64
  for (const note of notes) if (note) activity(io, note);
56
65
  return 0;
57
66
  } finally {
@@ -83,6 +92,13 @@ export function dispatchedLines(s: RunStatus): string[] {
83
92
  ];
84
93
  }
85
94
 
95
+ /** The `worktree:` lines for the Worktrees this run's cuts made (ADR-0075), oldest first. */
96
+ export function worktreeLines(s: RunStatus): string[] {
97
+ return (s.worktrees ?? []).map(
98
+ (w) => `worktree: ${w.path} (${w.slot}), ${w.branch === null ? "detached" : `branch ${w.branch}`}`,
99
+ );
100
+ }
101
+
86
102
  /** The `dispatched by:` line for a run a `dispatch` Effect started (ADR-0071), or undefined for a
87
103
  * run nobody dispatched. A foreign caller is named by its Orchestrator, as that one sent it: opaque
88
104
  * text, which this Orchestrator could not verify. */
@@ -100,7 +100,6 @@ import {
100
100
  harnessTokenDigest,
101
101
  formatCpu,
102
102
  formatMemory,
103
- sandboxToken,
104
103
  type CarriedEffect,
105
104
  type CarriedImage,
106
105
  type CarriedRepo,
@@ -142,6 +141,8 @@ import {
142
141
  INSTANCE_HARNESS_PVC,
143
142
  INSTANCE_HARNESS_SERVICE,
144
143
  instanceHarnessObjects,
144
+ INSTANCE_HARNESS_CREDENTIALS,
145
+ podCredentials,
145
146
  instanceEffectorObjects,
146
147
  effectorDigest,
147
148
  storedValueDigest,
@@ -189,6 +190,7 @@ import {
189
190
  type PodEnd,
190
191
  type RolloutTarget,
191
192
  } from "../kube.ts";
193
+ import { awaitCacheAgent } from "../landlock.ts";
192
194
  import { anyNodeHolds, buildNodes, sandboxNodes, type NodeObject } from "../nodes.ts";
193
195
  import { activity, chooseOrBail, confirmOrBail, promptLine, readSecretInput, type Io } from "../output.ts";
194
196
  import { kindCluster, sweepImages } from "../sweep.ts";
@@ -714,12 +716,10 @@ export async function up(args: string[], io: Io): Promise<number> {
714
716
  JR2_INSTANCE_TOKEN: instanceToken,
715
717
  JR2_PUBLISH_TOKEN: publishToken,
716
718
  JR2_SIGNING_KEY: signingKey,
717
- // The Instance Harness Custodian's credential (ADR-0013/0031): a sandbox-style token signed
718
- // for the placement's name — it may deliver only to Turns hosted THERE (tokens.ts), so the
719
- // Instance token never enters that pod. Derived from the kept key, so re-runs converge to
720
- // the same value; live Custodians keep verifying.
721
- JR2_INSTANCE_HARNESS_TOKEN: sandboxToken(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
722
719
  };
720
+ // The Instance Harness Custodian's credentials are not here (ADR-0076): its control token and
721
+ // its secret Stand-ins ride its own Secret (`INSTANCE_HARNESS_CREDENTIALS`), whose Stand-ins its
722
+ // Harness container mounts — so it must never be this one.
723
723
  // Git tokens (ADR-0051, ADR-0068): each entry's token, from `.env`, goes into that entry's own clone
724
724
  // Secret (`jr2-git-<hash>`, Flux's key names), which the cache agent reads — never into this
725
725
  // Secret, the Orchestrator's env. The Orchestrator names the Secret on a Repo and holds no git token.
@@ -843,6 +843,7 @@ export async function up(args: string[], io: Io): Promise<number> {
843
843
  trust: trustData(caPem, held.bundles),
844
844
  harnessEnvData,
845
845
  priorityClassName: priorityClassNames(config.priorityClasses).sandbox,
846
+ signingKey: Buffer.from(signingKey, "base64"),
846
847
  });
847
848
 
848
849
  // --- operator (per-cluster, shared): after the provider preflight ------------------------------
@@ -1015,14 +1016,17 @@ export async function up(args: string[], io: Io): Promise<number> {
1015
1016
  // over from a Machine that dropped its `workspace()` is deleted with its RBAC: the layer
1016
1017
  // converges toward the Machines like the Instance Harness does.
1017
1018
  if (carried.composesSandbox) {
1019
+ // The agent is also the Landlock detector (ADR-0077): on a Sandbox node without Landlock it
1020
+ // exits at start with the reason, and the converge refuses, naming each such node.
1018
1021
  activity(io, "repo cache: waiting for the cache agent on every Sandbox node");
1019
- await awaitRollout(kube, {
1022
+ const cacheTarget: RolloutTarget = {
1020
1023
  kind: "daemonset",
1021
1024
  name: REPO_CACHE,
1022
1025
  namespace,
1023
1026
  selector: `app=${REPO_CACHE}`,
1024
1027
  ...ctx,
1025
- });
1028
+ };
1029
+ await awaitCacheAgent(kube, cacheTarget, () => awaitRollout(kube, cacheTarget));
1026
1030
  await verifyRunningImage(io, kube, {
1027
1031
  layer: "repo cache",
1028
1032
  namespace,
@@ -1078,6 +1082,13 @@ export async function up(args: string[], io: Io): Promise<number> {
1078
1082
  held: instance.manifest,
1079
1083
  custodian: instance.custodian,
1080
1084
  heldDigest: heldDigest(Buffer.from(signingKey, "base64"), held.objects, instance, valueDigest),
1085
+ // Its control token and secret Stand-ins (ADR-0076, ADR-0059): minted here, as the pod's
1086
+ // maker, from the kept key — so a re-run converges to the same values.
1087
+ credentials: podCredentials(Buffer.from(signingKey, "base64"), {
1088
+ placement: INSTANCE_HARNESS_SERVICE,
1089
+ heldPod: INSTANCE_HARNESS_HELD_POD,
1090
+ manifest: instance.manifest,
1091
+ }),
1081
1092
  harness: config.harness,
1082
1093
  caBundle: caPem !== undefined,
1083
1094
  // The wire's gate (ADR-0058): the digest of the bearer the Orchestrator derives for this
@@ -1112,8 +1123,10 @@ export async function up(args: string[], io: Io): Promise<number> {
1112
1123
  await kube.deleteObject({ kind: "persistentvolumeclaim", name: INSTANCE_HARNESS_PVC, namespace, ...ctx });
1113
1124
  await kube.deleteObject({ kind: "service", name: INSTANCE_HARNESS_SERVICE, namespace, ...ctx });
1114
1125
  await kube.deleteObject({ kind: "service", name: INSTANCE_HARNESS_PODS_SERVICE, namespace, ...ctx });
1115
- // Its Custodian's files go with the pod they were rendered for (ADR-0059).
1126
+ // Its Custodian's files and its credentials go with the pod they were made for (ADR-0059,
1127
+ // ADR-0076).
1116
1128
  await kube.deleteObject({ kind: "configmap", name: INSTANCE_CUSTODIAN_CONFIGMAP, namespace, ...ctx });
1129
+ await kube.deleteObject({ kind: "secret", name: INSTANCE_HARNESS_CREDENTIALS, namespace, ...ctx });
1117
1130
  }
1118
1131
 
1119
1132
  // --- Instance Effector (ADR-0068): converged when an Effect is placed there, absent otherwise ---
@@ -1480,6 +1493,8 @@ async function resolveHeld(
1480
1493
  models: opts.models,
1481
1494
  });
1482
1495
  for (const warning of warnings) activity(io, `warning: ${warning}`);
1496
+ const r14 = envFromHeldRefusal(config, manifest, opts.envFromKeys);
1497
+ if (r14) throw new Error(r14);
1483
1498
  // Each pod's own: the bindings of the names it holds and their literal values. Its Custodian's
1484
1499
  // files are rendered by whoever makes the pod — here for the Instance Harness alone; the
1485
1500
  // Orchestrator at provision for a Sandbox, from the same manifest (ADR-0059).
@@ -1496,7 +1511,7 @@ async function resolveHeld(
1496
1511
  const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
1497
1512
  const instance: CustodianFiles | undefined = instancePod && {
1498
1513
  bootstrap: JSON.stringify(custodianBootstrap(instancePod.manifest, orchestrator)),
1499
- script: custodianScript(instancePod.manifest),
1514
+ script: custodianScript(instancePod.manifest, { fetch: false }),
1500
1515
  };
1501
1516
  if (manifest.secrets.length === 0) return { objects: { pods, ...(instance && { instance }), leaves: {} } };
1502
1517
  for (const s of manifest.secrets) {
@@ -1531,6 +1546,36 @@ async function resolveHeld(
1531
1546
  };
1532
1547
  }
1533
1548
 
1549
+ /**
1550
+ * R14 (ADR-0059): a held secret's name that is also a key of a Secret `harness.envFrom` loads. The
1551
+ * Stand-ins are files, not env (ADR-0076), so no Stand-in hides that key: the Secret's real value
1552
+ * would reach the Harness container's env under the held secret's own name, and the Harness uses a
1553
+ * held secret but never holds it. The key is compared as the kubelet writes it, `prefix` first.
1554
+ * The message names the Secret, the key and the held secret, and never a value. Exported for its
1555
+ * test.
1556
+ */
1557
+ export function envFromHeldRefusal(
1558
+ config: JR2Config,
1559
+ manifest: HeldManifest,
1560
+ envFromKeys: ReadonlyMap<string, readonly string[]>,
1561
+ ): string | undefined {
1562
+ const held = new Set(manifest.secrets.map((s) => s.name));
1563
+ for (const [i, ref] of (config.harness?.envFrom ?? []).entries()) {
1564
+ const secret = ref.secretRef?.name;
1565
+ if (secret === undefined) continue;
1566
+ for (const key of envFromKeys.get(`secret/${secret}`) ?? []) {
1567
+ const name = (ref.prefix ?? "") + key;
1568
+ if (!held.has(name)) continue;
1569
+ return (
1570
+ `harness.envFrom[${i}] loads Secret "${secret}", whose key "${key}" is the env var ${name} — and ${name} is ` +
1571
+ `a held secret. Its Stand-in is a file, so nothing hides that key, and its value would reach the ` +
1572
+ `Harness's env. Drop the key from Secret "${secret}", or rename the held secret (ADR-0059, R14)`
1573
+ );
1574
+ }
1575
+ }
1576
+ return undefined;
1577
+ }
1578
+
1534
1579
  /** The Instance Harness's held objects and its Custodian's files — the walk always places it when
1535
1580
  * it converges. */
1536
1581
  function instanceHeld(held: HeldObjects): HeldPodObjects & { custodian: CustodianFiles } {
@@ -2460,6 +2505,8 @@ async function preflightProviders(
2460
2505
  trust: Record<string, string>;
2461
2506
  harnessEnvData: Record<string, string>;
2462
2507
  priorityClassName: string;
2508
+ /** The signing key, for each check pod's own Stand-ins (ADR-0019, ADR-0059). */
2509
+ signingKey: Buffer;
2463
2510
  },
2464
2511
  ): Promise<void> {
2465
2512
  const { namespace, ctx } = opts;
@@ -2521,9 +2568,16 @@ async function preflightProviders(
2521
2568
  trust: opts.trust,
2522
2569
  custodian: {
2523
2570
  bootstrap: JSON.stringify(custodianBootstrap(checkPod.manifest, orchestrator)),
2524
- script: custodianScript(checkPod.manifest),
2571
+ script: custodianScript(checkPod.manifest, { fetch: false }),
2525
2572
  },
2526
- token: randomBytes(24).toString("hex"),
2573
+ // Its own credentials (ADR-0019, ADR-0076): a control token the Orchestrator never signed,
2574
+ // and secret Stand-ins minted for this pod alone.
2575
+ credentials: podCredentials(opts.signingKey, {
2576
+ placement: object,
2577
+ heldPod: object,
2578
+ manifest: checkPod.manifest,
2579
+ controlToken: "unsigned",
2580
+ }),
2527
2581
  harnessEnvData: opts.harnessEnvData,
2528
2582
  models,
2529
2583
  ...(checkPod.placement ? { placement: checkPod.placement } : {}),
@@ -2666,7 +2720,7 @@ async function removeCheckLeftovers(
2666
2720
  if (!(since(finished ?? p.metadata.creationTimestamp) > CHECK_ENDED_MARGIN_SECONDS)) continue;
2667
2721
  await removeCheckPod(io, kube, p.metadata.name, namespace, ctx);
2668
2722
  }
2669
- for (const kind of ["configmap", "secret"]) {
2723
+ for (const kind of ["configmap", "secret", "networkpolicy"]) {
2670
2724
  for (const o of await kube.listJson<CheckObject>({ kind, selector, namespace, ...ctx })) {
2671
2725
  const owner = o.metadata.name.replace(/-(ca|tls|env)$/, "");
2672
2726
  if (named.has(owner) || !(since(o.metadata.creationTimestamp) > CHECK_LEFTOVER_SECONDS)) continue;
package/src/deploy.ts CHANGED
@@ -3,7 +3,7 @@
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 { createHmac, randomBytes } from "node:crypto";
7
7
  import { readFile } from "node:fs/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import {
@@ -19,6 +19,11 @@ import {
19
19
  CUSTODIAN_PROGRAM,
20
20
  CUSTODIAN_PROGRAM_KEY,
21
21
  CUSTODIAN_SCRIPT_KEY,
22
+ CREDENTIAL_KEYS,
23
+ controlStandIn,
24
+ controlToken,
25
+ heldStandIn,
26
+ heldStandInKey,
22
27
  INSTANCE_CUSTODIAN_CONFIGMAP,
23
28
  GIT_SSH_SECRET,
24
29
  HELD_CA_SECRET,
@@ -60,6 +65,7 @@ import {
60
65
  type GitPushManifest,
61
66
  type HarnessConfig,
62
67
  type HeldManifest,
68
+ markHarnessEnv,
63
69
  type PriorityClasses,
64
70
  type SandboxPlacement,
65
71
  } from "@jr2/orchestrator/internal";
@@ -93,6 +99,39 @@ export const ANNOTATION_HELD_DIGEST = "jr2.dev/held-digest";
93
99
  * same reason: a group every container is granted, which the Custodian's values are readable by. */
94
100
  const INSTANCE_HARNESS_FS_GROUP = 2000;
95
101
 
102
+ /**
103
+ * The Instance Harness's credentials Secret (ADR-0076, ADR-0059): what a Sandbox's `<sandbox>-token`
104
+ * is to a Sandbox. It holds the pod's control token (`controlToken`, for the placement's name), its
105
+ * secret control Stand-in and one secret Stand-in per held secret of the pod, under
106
+ * `CREDENTIAL_KEYS` and `heldStandInKey`. `jr2 up` mints it, as the pod's maker. The Custodian
107
+ * mounts the token and the Stand-ins; the Harness container mounts the Stand-ins alone, as files
108
+ * under `/run/jr2/` (`custodianComposition`). A Secret of its own, never `jr2-instance`: the
109
+ * Instance token and the signing key share no Secret with anything the Harness container mounts.
110
+ */
111
+ export const INSTANCE_HARNESS_CREDENTIALS = `${INSTANCE_HARNESS_SERVICE}-token`;
112
+
113
+ /**
114
+ * One pod's credentials Secret data (ADR-0076, ADR-0059), minted by `jr2 up` as that pod's maker:
115
+ * the control token, the secret control Stand-in, and one secret Stand-in per held secret of
116
+ * `manifest` (`heldStandIn`, under the pod key `heldPod`). The Instance Harness's control token is
117
+ * signed for the placement's name, so it speaks only for the Turns hosted there; a check pod's
118
+ * (`controlToken: "unsigned"`) is a value the Orchestrator never signed — it reads no Menu
119
+ * (ADR-0019). No pod here serves a fetch, so neither holds a fetch token. Never logged: the values
120
+ * reach a Secret and nothing else.
121
+ */
122
+ export function podCredentials(
123
+ key: Buffer,
124
+ opts: { placement: string; heldPod: string; manifest: HeldManifest; controlToken?: "signed" | "unsigned" },
125
+ ): Record<string, string> {
126
+ const data: Record<string, string> = {
127
+ [CREDENTIAL_KEYS.controlToken]:
128
+ opts.controlToken === "unsigned" ? randomBytes(24).toString("hex") : controlToken(key, opts.placement),
129
+ [CREDENTIAL_KEYS.controlStandIn]: controlStandIn(key, opts.placement),
130
+ };
131
+ for (const s of opts.manifest.secrets) data[heldStandInKey(s.name)] = heldStandIn(key, opts.heldPod, s.name);
132
+ return data;
133
+ }
134
+
96
135
  /** How long the Instance Harness pod is given to stop (ADR-0031's drain): on SIGTERM it stops
97
136
  * admitting and finishes the Turns it holds, so the grace is a Turn's worst case. `jr2 up` waits
98
137
  * out a rollout that long, and no longer. */
@@ -110,6 +149,48 @@ export const INSTANCE_HARNESS_PODS_SERVICE = `${INSTANCE_HARNESS_SERVICE}-pods`;
110
149
  /** The two ingress NetworkPolicies (ADR-0058) — see `harnessIngressPolicies`. */
111
150
  export const SANDBOX_INGRESS_POLICY = "jr2-sandbox-ingress";
112
151
  export const INSTANCE_HARNESS_INGRESS_POLICY = "jr2-instance-harness-ingress";
152
+ /** The two egress NetworkPolicies (ADR-0076) — see `metadataEgress`. */
153
+ export const SANDBOX_EGRESS_POLICY = "jr2-sandbox-egress";
154
+ export const INSTANCE_HARNESS_EGRESS_POLICY = "jr2-instance-harness-egress";
155
+
156
+ /**
157
+ * The cloud metadata addresses that a Harness pod does not reach (ADR-0076): where a node's
158
+ * metadata service answers pods, it gives the node's own cloud credential, which no `workspace()`
159
+ * grants. AWS's IMDS (IPv4 and IPv6), ECS's and EKS Pod Identity's credential agents, the
160
+ * metadata service of GCP and Azure (the same 169.254.169.254), and Alibaba's. Each is a single
161
+ * address: all of 169.254.0.0/16 would cut NodeLocal DNSCache (169.254.20.10). The list is not
162
+ * complete for every provider, so the shared-cluster guide tells the admin to check theirs.
163
+ */
164
+ export const METADATA_ADDRESSES_V4 = [
165
+ "169.254.169.254/32",
166
+ "169.254.170.2/32",
167
+ "169.254.170.23/32",
168
+ "100.100.100.200/32",
169
+ ] as const;
170
+ export const METADATA_ADDRESSES_V6 = ["fd00:ec2::254/128"] as const;
171
+
172
+ /**
173
+ * The egress of a Harness pod (ADR-0076): everything, except the cloud metadata addresses.
174
+ * NetworkPolicy has no deny rule, so the rule allows every address but those, and every pod of the
175
+ * cluster as a peer of its own. A CNI that matches an `ipBlock` against a pod's address too (Cilium)
176
+ * would otherwise cut DNS, the Orchestrator and each Service. The policy needs a CNI that enforces
177
+ * NetworkPolicy; on one that does not, nothing is blocked.
178
+ */
179
+ export function metadataEgress(podSelector: Record<string, unknown>): Record<string, unknown> {
180
+ return {
181
+ podSelector,
182
+ policyTypes: ["Egress"],
183
+ egress: [
184
+ {
185
+ to: [
186
+ { ipBlock: { cidr: "0.0.0.0/0", except: [...METADATA_ADDRESSES_V4] } },
187
+ { ipBlock: { cidr: "::/0", except: [...METADATA_ADDRESSES_V6] } },
188
+ { namespaceSelector: {} },
189
+ ],
190
+ },
191
+ ],
192
+ };
193
+ }
113
194
 
114
195
  /** The operator's install location — per-cluster, shared by every instance (ADR-0019). */
115
196
  export const OPERATOR_NAMESPACE = "jr2-system";
@@ -579,6 +660,7 @@ export function instanceObjects(opts: {
579
660
  },
580
661
  },
581
662
  ...harnessIngressPolicies(opts.name, meta),
663
+ ...harnessEgressPolicies(meta),
582
664
  ...(opts.repoCache
583
665
  ? repoCacheObjects({
584
666
  ...opts.repoCache,
@@ -610,7 +692,7 @@ const SANDBOX_POD_LABEL = "sandbox.jr2.dev/name";
610
692
  *
611
693
  * - The Orchestrator is not selected. Every route there is token-gated, and a Custodian in any
612
694
  * 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).
695
+ * - No egress. That is `harnessEgressPolicies`: open, except the cloud metadata addresses (ADR-0076).
614
696
  * - No port. The Orchestrator speaks only the Harness wire to these pods, and a port here would
615
697
  * have to track the CR's `port`; `kubectl port-forward` (the CLI, a human's shell into the User
616
698
  * Container — ADR-0005) and kubelet probes never cross a policy at all.
@@ -631,6 +713,26 @@ function harnessIngressPolicies(instance: string, meta: (name: string) => KubeMa
631
713
  ];
632
714
  }
633
715
 
716
+ /**
717
+ * No Harness pod reaches a cloud metadata address (ADR-0076, `metadataEgress`). A Sandbox runs
718
+ * Workspace code, and the node's credential is one that no `workspace()` grants. The Instance
719
+ * Harness runs no Workspace code, but it gets the same rule: a check pod of the provider preflight
720
+ * stands for every Harness pod (ADR-0019), so a model that only the node's credential reaches must
721
+ * fail the check, not a Turn. Converged unconditionally, for the reason the ingress policies are.
722
+ */
723
+ function harnessEgressPolicies(meta: (name: string) => KubeManifest): KubeManifest[] {
724
+ const policy = (name: string, podSelector: Record<string, unknown>): KubeManifest => ({
725
+ apiVersion: "networking.k8s.io/v1",
726
+ kind: "NetworkPolicy",
727
+ metadata: meta(name),
728
+ spec: metadataEgress(podSelector),
729
+ });
730
+ return [
731
+ policy(SANDBOX_EGRESS_POLICY, { matchExpressions: [{ key: SANDBOX_POD_LABEL, operator: "Exists" }] }),
732
+ policy(INSTANCE_HARNESS_EGRESS_POLICY, { matchLabels: { app: INSTANCE_HARNESS_SERVICE } }),
733
+ ];
734
+ }
735
+
634
736
  /** Where the cache agent's pod sees the node's directory: `--cache-dir`'s default. */
635
737
  const REPO_CACHE_MOUNT = "/cache";
636
738
  /** The agent's `$HOME` — an emptyDir, where it writes a deploy key for the life of the pod. */
@@ -651,6 +753,10 @@ const REPO_CACHE_HOME = "/home/jr2";
651
753
  * (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
754
  * of the pods on its node (never of Sandboxes — demand is a pod's mount), unlike a Sandbox, whose
653
755
  * north star is never reaching the API.
756
+ *
757
+ * Because it lands on exactly the Sandbox nodes, it is also the Landlock detector (ADR-0077): it
758
+ * probes at start and, on a node without Landlock, exits with the reason, and `jr2 up` refuses to
759
+ * converge (`landlock.ts`).
654
760
  */
655
761
  function repoCacheObjects(opts: {
656
762
  image: string;
@@ -713,6 +819,11 @@ function repoCacheObjects(opts: {
713
819
  image,
714
820
  imagePullPolicy: "IfNotPresent",
715
821
  command: ["/manager", "repo-cache"],
822
+ // On a node without Landlock the agent exits at start with a message that names
823
+ // Landlock, the node and the reason (ADR-0077). The kubelet keeps it in the
824
+ // container's status, where `jr2 up` reads it; the log line serves if the file
825
+ // write fails.
826
+ terminationMessagePolicy: "FallbackToLogsOnError",
716
827
  env: [
717
828
  // The downward API names the node this pod is the writer for, and the namespace
718
829
  // whose Repos and pods it watches (the agent's `--node` / `--namespace`).
@@ -853,6 +964,9 @@ export function instanceHarnessObjects(opts: {
853
964
  * Custodian's config), keyed with the signing key so it reveals nothing — on the pod template, so
854
965
  * a change to any of them rolls this pod (ADR-0059). */
855
966
  heldDigest: string;
967
+ /** The pod's credentials Secret data (`podCredentials`): its control token and its secret
968
+ * Stand-ins, applied here as {@link INSTANCE_HARNESS_CREDENTIALS} with the pod they are for. */
969
+ credentials: Record<string, string>;
856
970
  harness?: HarnessConfig;
857
971
  /** The instance ships a private-CA bundle (ADR-0020). */
858
972
  caBundle?: boolean;
@@ -880,15 +994,16 @@ export function instanceHarnessObjects(opts: {
880
994
  seccompProfile: { type: "RuntimeDefault" },
881
995
  });
882
996
 
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.
997
+ // The Custodian (ADR-0013, ADR-0059, ADR-0076): the placement's control token — signed for this
998
+ // placement's name, so it speaks only for the Turns hosted HERE (tokens.ts) — and the held secrets
999
+ // placed here, from the pod's own credentials Secret (`podCredentials`). The tokens are mounted
1000
+ // into the Custodian alone, the Stand-ins into the Custodian and, as files, the Harness; the
1001
+ // Instance token itself never enters this pod. The pod serves no fetch: no Repo is mounted here.
887
1002
  const custodian = custodianComposition(opts.held, {
888
1003
  image: opts.custodianImage,
889
1004
  pod: INSTANCE_HARNESS_HELD_POD,
890
1005
  config: INSTANCE_CUSTODIAN_CONFIGMAP,
891
- token: { secret: INSTANCE_SECRET, key: "JR2_INSTANCE_HARNESS_TOKEN" },
1006
+ credentials: { secret: INSTANCE_HARNESS_CREDENTIALS },
892
1007
  caBundle: opts.caBundle === true,
893
1008
  resources: sized(KIT_POD_SIZES.instanceCustodian),
894
1009
  });
@@ -901,24 +1016,28 @@ export function instanceHarnessObjects(opts: {
901
1016
  // The same asymmetry the Sandbox pod builds (ADR-0013/0020/0059): the mounted harness config
902
1017
  // and the instance's valueFrom entries ride `env` (literal values live in the jr2-harness-env
903
1018
  // 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 ?? [])],
1019
+ // Marked (ADR-0076, `harness-env.ts`): the stock image's command starts the Harness with
1020
+ // `effector nocore --harness-env`, which gives the Harness process jr2's names alone.
1021
+ ...markHarnessEnv(
1022
+ [
1023
+ {
1024
+ name: "JR2_HARNESS_JSON",
1025
+ valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
1026
+ },
1027
+ // The placement gate (ADR-0031): every admission carries its own definition (ADR-0049), so
1028
+ // the Harness itself refuses any admission whose definition declares Workspace access —
1029
+ // Menu-only Agents alone run here, which is what makes "no code execution in this pod" true
1030
+ // rather than asserted. The bearer (below) narrows who may try; this decides what.
1031
+ { name: "JR2_MENU_ONLY", value: "1" },
1032
+ ...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
1033
+ ...custodian.harnessEnv,
1034
+ // Where the conversations persist (ADR-0031): the claim below. Mechanism, as on a Sandbox.
1035
+ { name: CONVERSATIONS_DIR_ENV, value: CONVERSATIONS_DIR },
1036
+ // The wire's gate (ADR-0058), last — as on a Sandbox — so no `harness.env` entry chooses it.
1037
+ { name: "JR2_HARNESS_TOKEN_SHA256", value: opts.bearerSha256 },
1038
+ ],
1039
+ [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(opts.harness?.envFrom ?? [])],
1040
+ ),
922
1041
  // The Harness container's alone: the Custodian has no business with an Agent's history.
923
1042
  volumeMounts: [{ name: CONVERSATIONS_VOLUME, mountPath: CONVERSATIONS_DIR }, ...custodian.harnessMounts],
924
1043
  // The operator probes a Sandbox's Harness the same way: serving = the socket accepts.
@@ -938,6 +1057,14 @@ export function instanceHarnessObjects(opts: {
938
1057
  };
939
1058
 
940
1059
  const items: KubeManifest[] = [
1060
+ // The pod's credentials (ADR-0076): a Secret, never a ConfigMap, env or the pod template.
1061
+ {
1062
+ apiVersion: "v1",
1063
+ kind: "Secret",
1064
+ metadata: { ...meta(), name: INSTANCE_HARNESS_CREDENTIALS },
1065
+ type: "Opaque",
1066
+ stringData: opts.credentials,
1067
+ },
941
1068
  // The one pod `jr2 up` makes, so the one whose Custodian files it renders: its bootstrap and
942
1069
  // its HELD table, from its own manifest, beside the program its volume projects from `jr2-held`.
943
1070
  {
@@ -1084,6 +1211,7 @@ export const CHECK_CONTAINER = "check";
1084
1211
  export function checkPodObjectNames(object: string): Array<{ kind: string; name: string }> {
1085
1212
  return [
1086
1213
  { kind: "pod", name: object },
1214
+ { kind: "networkpolicy", name: object },
1087
1215
  { kind: "configmap", name: object },
1088
1216
  { kind: "configmap", name: `${object}-ca` },
1089
1217
  { kind: "secret", name: object },
@@ -1100,8 +1228,9 @@ export function checkPodObjectNames(object: string): Array<{ kind: string; name:
1100
1228
  * - `<object>` (ConfigMap): the Custodian's bootstrap and script, the program and the harness config
1101
1229
  * (`harnessConfigJson`).
1102
1230
  * - `<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
1231
+ * - `<object>` (Secret): the Custodian's control token, a value the Orchestrator never signed (the
1232
+ * check pod reads no Menu), the check pod's own secret Stand-ins (`podCredentials`, ADR-0059), and
1233
+ * the LITERAL values of the keys it holds, from the host. A `valueFrom` key
1105
1234
  * stays a reference to the user's Secret. The values are in this Secret for the check pod's life.
1106
1235
  * - `<object>-tls` (Secret): the leaves of the bound hosts. `<object>-env` (Secret): the literal
1107
1236
  * `harness.env` values, which `jr2-harness-env` would carry.
@@ -1112,6 +1241,9 @@ export function checkPodObjectNames(object: string): Array<{ kind: string; name:
1112
1241
  * container's env, so the call is the one a Turn makes, and the key never enters that container,
1113
1242
  * its argv or its log. The argv is the model specifiers alone.
1114
1243
  *
1244
+ * - `<object>` (NetworkPolicy): the egress of a Harness pod (`metadataEgress`, ADR-0076), so the
1245
+ * check reaches a provider only as a Turn does. It is in the List before the pod.
1246
+ *
1115
1247
  * The pod has the placement and the priority class of the Harness pods it stands for (ADR-0052,
1116
1248
  * ADR-0060). Its labels are its own and match no Harness pod's: the Instance Harness Service
1117
1249
  * selects by label, and would send Turns to it. The Custodian is a native sidecar with a startup
@@ -1136,7 +1268,9 @@ export function checkPodObjects(opts: {
1136
1268
  trust: Record<string, string>;
1137
1269
  /** The Custodian's two files, rendered from `held`. */
1138
1270
  custodian: CustodianFiles;
1139
- token: string;
1271
+ /** The check pod's credentials (`podCredentials`, ADR-0076): a control token the Orchestrator
1272
+ * never signed, and its own secret Stand-ins, minted for this pod alone. */
1273
+ credentials: Record<string, string>;
1140
1274
  /** The literal `harness.env` values. */
1141
1275
  harnessEnvData: Record<string, string>;
1142
1276
  /** The `<provider>/<model>` specifiers to check, in order. */
@@ -1161,7 +1295,7 @@ export function checkPodObjects(opts: {
1161
1295
  pod: o,
1162
1296
  reads: { literals: o, tls: `${o}-tls`, trust: `${o}-ca`, program: o },
1163
1297
  config: o,
1164
- token: { secret: o, key: "token" },
1298
+ credentials: { secret: o },
1165
1299
  caBundle: opts.caBundle === true,
1166
1300
  resources: sized(KIT_POD_SIZES.instanceCustodian),
1167
1301
  });
@@ -1204,11 +1338,17 @@ export function checkPodObjects(opts: {
1204
1338
  "/opt/jr2/src/provider-preflight.ts",
1205
1339
  ...opts.models,
1206
1340
  ],
1207
- // The Harness container's env, in its order (`instanceHarnessObjects`): no credential.
1341
+ // The Harness container's env, in its order (`instanceHarnessObjects`): no credential. And
1342
+ // the check pod's own Stand-ins (ADR-0019): it runs no Workspace code, so it may hold them
1343
+ // in env, where pi reads a provider's key; each is a `secretKeyRef`, never a value here.
1208
1344
  env: [
1209
1345
  { name: "JR2_HARNESS_JSON", valueFrom: { configMapKeyRef: { name: o, key: HARNESS_CONFIG_KEY } } },
1210
1346
  ...(opts.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
1211
1347
  ...custodian.harnessEnv,
1348
+ ...opts.held.secrets.map((s) => ({
1349
+ name: s.name,
1350
+ valueFrom: { secretKeyRef: { name: o, key: heldStandInKey(s.name) } },
1351
+ })),
1212
1352
  ],
1213
1353
  envFrom: [{ secretRef: { name: `${o}-env` } }, ...(opts.harness?.envFrom ?? [])],
1214
1354
  volumeMounts: custodian.harnessMounts,
@@ -1254,9 +1394,15 @@ export function checkPodObjects(opts: {
1254
1394
  },
1255
1395
  },
1256
1396
  { apiVersion: "v1", kind: "ConfigMap", metadata: meta(`${o}-ca`), data: opts.trust },
1257
- secret(o, { token: opts.token, ...literals }),
1397
+ secret(o, { ...opts.credentials, ...literals }),
1258
1398
  secret(`${o}-tls`, leaves),
1259
1399
  secret(`${o}-env`, opts.harnessEnvData),
1400
+ {
1401
+ apiVersion: "networking.k8s.io/v1",
1402
+ kind: "NetworkPolicy",
1403
+ metadata: meta(o),
1404
+ spec: metadataEgress({ matchLabels: { app: PROVIDER_PREFLIGHT, [LABEL_PROVIDER_PREFLIGHT]: opts.run } }),
1405
+ },
1260
1406
  pod,
1261
1407
  ];
1262
1408
  return JSON.stringify({ apiVersion: "v1", kind: "List", items });
@@ -131,7 +131,7 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
131
131
  return undefined;
132
132
  }
133
133
  // What each registered Machine runs outside itself (ADR-0067, ADR-0068): every Effect slot, with
134
- // the plain data it carries — a command's argv, its timeout, the Repo Slot it runs in; an http
134
+ // the plain data it carries — a command's argv and its timeout; an http
135
135
  // Effect's method, its fixed target and the secrets it names — read, never run.
136
136
  for (const w of workflows) {
137
137
  const effects = partsOf([w.machine]).effects;
@@ -139,13 +139,10 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
139
139
  activity(io, `effects: workflow "${w.name}" — what it runs outside itself (ADR-0067)`);
140
140
  for (const e of effects) {
141
141
  const where = e.path?.length ? ` (on ${e.path.map((k) => `"${k}"`).join(" → ")})` : "";
142
- const slot = "slot" in e && e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
143
142
  if (e.kind === "http") {
144
143
  const names = e.secrets.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
145
144
  // Its body decides where it runs (ADR-0067): a Worktree file needs its Workspace's Sandbox Effector.
146
- const where2 = e.file
147
- ? `in the Sandbox Effector, a Worktree file as its body${slot}`
148
- : "in the Instance Effector";
145
+ const where2 = e.file ? "in the Sandbox Effector, a Worktree file as its body" : "in the Instance Effector";
149
146
  activity(
150
147
  io,
151
148
  ` ${e.name}${where}: http ${e.method} ${e.target ? targetText({ target: e.target }) : "<open>"} — ${e.timeoutMs / 1000}s, ` +
@@ -167,6 +164,7 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
167
164
  }
168
165
  if (e.kind === "push") {
169
166
  const opts = (["pin", "sign", "squash", "force"] as const).filter((k) => e[k]);
167
+ const slot = e.slot !== undefined ? `, in Repo Slot "${e.slot}"` : "";
170
168
  activity(
171
169
  io,
172
170
  ` ${e.name}${where}: push${opts.length ? ` (${opts.join(", ")})` : ""} — ${e.timeoutMs / 1000}s, ` +
@@ -175,11 +173,17 @@ export async function checkInstance(io: Io, root: string, config: JR2Config): Pr
175
173
  continue;
176
174
  }
177
175
  if (!("argv" in e)) continue;
176
+ if (e.cut) {
177
+ // A cut is a kit program: its argv is the kit's business, and its one choice is `expect`.
178
+ const expect = e.cut.expect ? ` (expect ${e.cut.expect})` : "";
179
+ activity(io, ` ${e.name}${where}: worktree${expect} — ${e.timeoutMs / 1000}s, in the Harness`);
180
+ continue;
181
+ }
178
182
  const names = e.secrets?.length ? `, secrets ${e.secrets.map((n) => `"${n}"`).join(", ")}` : "";
179
183
  const seat = e.secrets?.length ? `the Sandbox Effector${e.pin ? ", pinned" : ""}` : "the Harness";
180
184
  activity(
181
185
  io,
182
- ` ${e.name}${where}: ${e.kind} ${JSON.stringify(e.argv)} — ${e.timeoutMs / 1000}s, in ${seat}${slot}${names}`,
186
+ ` ${e.name}${where}: ${e.kind} ${JSON.stringify(e.argv)} — ${e.timeoutMs / 1000}s, in ${seat}${names}`,
183
187
  );
184
188
  }
185
189
  }
@@ -0,0 +1,129 @@
1
+ // jr2 requires Landlock on every Sandbox node (ADR-0077). The Repo cache agent is the detector: its
2
+ // DaemonSet has one pod on each Sandbox node, by the Sandboxes' own placement (ADR-0052), and on a
3
+ // node without Landlock, or with an ABI below 3, the agent exits at start with a termination
4
+ // message that names Landlock, the node and the probe's reason, which states the ABI it found
5
+ // (`operator/internal/repocache/landlock.go`). jr2's minimum is ABI 3: under ABI 1 no confined
6
+ // process can move a file into another folder, and under ABI 2 a confined process can truncate a
7
+ // file of jr2's that it may not write. That pod never gets Ready, so the cache agent's
8
+ // rollout never completes.
9
+ //
10
+ // `jr2 up` reads the DaemonSet's pods within the rollout wait it already makes, and refuses to
11
+ // converge while any of them reports no Landlock. It names each node, the reason, and the fix: keep
12
+ // the node out of the Sandbox nodes with `sandbox.nodeSelector` or a taint, or enable Landlock. There
13
+ // is no check pod, no node label and no flag to skip it. A run after the fix converges.
14
+
15
+ import { setTimeout as sleep } from "node:timers/promises";
16
+ import type { KubeAdmin, RolloutTarget } from "./kube.ts";
17
+
18
+ /** The start of the cache agent's termination message on a node without Landlock — the Go side's
19
+ * `LandlockMessagePrefix`. The two change together. */
20
+ export const LANDLOCK_MESSAGE_PREFIX = "no Landlock on node ";
21
+
22
+ /** How often `awaitCacheAgent` reads the DaemonSet's pods while the rollout wait runs. */
23
+ const POLL_MS = 3000;
24
+
25
+ type Terminated = { message?: string };
26
+ type ContainerStatus = {
27
+ state?: { terminated?: Terminated };
28
+ lastState?: { terminated?: Terminated };
29
+ };
30
+ /** A cache agent pod, read for the node it runs on and its containers' termination messages. */
31
+ export type CacheAgentPod = {
32
+ metadata: { name: string; deletionTimestamp?: string };
33
+ spec?: { nodeName?: string };
34
+ status?: { containerStatuses?: ContainerStatus[] };
35
+ };
36
+
37
+ /** One Sandbox node whose cache agent reports no Landlock, and the probe's reason. */
38
+ export type NoLandlock = { node: string; reason: string };
39
+
40
+ // The node, then the reason up to the sentence the agent ends its message with. Not anchored: with
41
+ // `FallbackToLogsOnError` the message can be the agent's log line, which quotes it.
42
+ const MESSAGE = /no Landlock on node ([^\s:]+): (.+?) — jr2 needs Landlock/;
43
+
44
+ /** The Sandbox nodes whose cache agent reports no Landlock, each once, in node order. */
45
+ export function nodesWithoutLandlock(pods: readonly CacheAgentPod[]): NoLandlock[] {
46
+ const found = new Map<string, string>();
47
+ for (const pod of pods) {
48
+ if (pod.metadata.deletionTimestamp) continue;
49
+ for (const s of pod.status?.containerStatuses ?? []) {
50
+ const message = s.state?.terminated?.message ?? s.lastState?.terminated?.message ?? "";
51
+ const match = MESSAGE.exec(message);
52
+ if (!match) continue;
53
+ const node = pod.spec?.nodeName ?? match[1]!;
54
+ if (!found.has(node)) found.set(node, match[2]!);
55
+ }
56
+ }
57
+ return [...found].sort(([a], [b]) => a.localeCompare(b)).map(([node, reason]) => ({ node, reason }));
58
+ }
59
+
60
+ /** The refusal (ADR-0077, ADR-0019): each node, its reason, and the fix. */
61
+ export function landlockRefusal(nodes: readonly NoLandlock[]): Error {
62
+ return new Error(
63
+ [
64
+ `repo cache: ${nodes.length === 1 ? "a Sandbox node has" : `${nodes.length} Sandbox nodes have`} no Landlock, ` +
65
+ "and jr2 needs it on every Sandbox node (ADR-0077):",
66
+ ...nodes.map((n) => ` ${n.node}: ${n.reason}`),
67
+ "Keep each such node out of the Sandbox nodes with sandbox.nodeSelector or a taint (ADR-0052), or enable " +
68
+ "Landlock ABI 3 or later in its kernel (Linux 6.2 or later, or a kernel with a backport, with landlock in " +
69
+ "its lsm= list). Then run `jr2 up` again.",
70
+ ].join("\n"),
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Wait for the cache agent's rollout, and refuse as soon as one of its pods reports no Landlock:
76
+ * a pod that exits at start never gets Ready, so the wait alone would only time out. `rollout` is
77
+ * the converge's own rollout wait (with its own diagnosis on failure); while it runs, the
78
+ * DaemonSet's pods are read every few seconds, one list call each. A failed rollout is read once
79
+ * more, so a Landlock refusal wins over the generic diagnosis.
80
+ */
81
+ export async function awaitCacheAgent(
82
+ kube: KubeAdmin,
83
+ target: RolloutTarget,
84
+ rollout: () => Promise<void>,
85
+ opts: { pollMs?: number } = {},
86
+ ): Promise<void> {
87
+ const read = async (): Promise<NoLandlock[]> => {
88
+ try {
89
+ return nodesWithoutLandlock(
90
+ await kube.listJson<CacheAgentPod>({
91
+ kind: "pod",
92
+ selector: target.selector,
93
+ namespace: target.namespace,
94
+ ...(target.context ? { context: target.context } : {}),
95
+ }),
96
+ );
97
+ } catch {
98
+ // A read that fails is no evidence: the rollout wait still decides.
99
+ return [];
100
+ }
101
+ };
102
+ const stop = new AbortController();
103
+ const waited = rollout().then(
104
+ () => ({ ok: true as const }),
105
+ (err: unknown) => ({ ok: false as const, err }),
106
+ );
107
+ const polled = (async (): Promise<NoLandlock[] | undefined> => {
108
+ for (;;) {
109
+ try {
110
+ await sleep(opts.pollMs ?? POLL_MS, undefined, { signal: stop.signal });
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ const nodes = await read();
115
+ if (nodes.length > 0) return nodes;
116
+ }
117
+ })();
118
+ const first = await Promise.race([waited, polled.then((nodes) => ({ nodes }))]);
119
+ stop.abort();
120
+ if ("nodes" in first) {
121
+ if (first.nodes) throw landlockRefusal(first.nodes);
122
+ // The poll stops only when aborted, so the rollout settled first.
123
+ }
124
+ const result = await waited;
125
+ if (result.ok) return;
126
+ const nodes = await read();
127
+ if (nodes.length > 0) throw landlockRefusal(nodes);
128
+ throw result.err;
129
+ }