@jr2/orchestrator 0.1.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
@@ -0,0 +1,1136 @@
1
+ // The canonical SandboxPort (ADR-0012 / GAP(3)): drives the operator's Sandbox CRD through
2
+ // `kubectl`, honoring the current kube context (ADR-0009: the kube target IS the kubectl
3
+ // context; `--context` overrides). Shelling to kubectl instead of a client library keeps the
4
+ // dependency surface at zero and the behavior identical to what a human debugging the cluster
5
+ // would type; the `exec` process seam is injectable so the mapping logic is unit-testable
6
+ // without a cluster. The kind e2e tier exercises the real thing.
7
+ //
8
+ // Reachability: the orchestrator always runs in-cluster (ADR-0019), so it dials
9
+ // `status.endpoint` (`http://<name>.<ns>.svc:…`) directly — stable across orchestrator
10
+ // restarts by nature, which is what ADR-0012's "same endpoint" re-attach promise rides on.
11
+ //
12
+ // All four operations are idempotent (SandboxPort contract): apply is create-or-update, attach
13
+ // guards every clone/worktree, delete ignores absent.
14
+ //
15
+ // WHICH REPOS a Sandbox attaches arrive resolved from the `workspace()`'s Repo Slots (ADR-0051):
16
+ // the CR names each by its cache key, the operator mounts the node's cache read-only at
17
+ // `/repos/<key>` and gates Ready on it, and the attach clones off that mount. The one judgement
18
+ // made here is the FENCE: a per-run url must match a `git.credentials` entry, or it is refused
19
+ // before anything is applied.
20
+ //
21
+ // WHICH IMAGE a Sandbox runs is not an option here (ADR-0037/0038/0049). The request carries what
22
+ // the `workspace()` wrapper statically declared — a `file:` docker context or a registry ref — and
23
+ // the resolved key→ref map arrives as a mounted ConfigMap read on EVERY provision, so a `jr2 up`
24
+ // that rebuilds an image reaches future Sandboxes without rolling this process.
25
+ //
26
+ // The pod's primary container is the Sandbox Image BYTE-FOR-BYTE (ADR-0037): no appended layers,
27
+ // no rewritten Dockerfile, no jr2 knowledge inside it. jr2's runtime arrives at POD time instead —
28
+ // an emptyDir at `/opt/jr2`, populated by an init container running the kit's Harness image — and
29
+ // the container's COMMAND is overridden to start the Harness from that volume. The image's own
30
+ // `USER` and `HOME` are respected (the human who execs in lands in the environment its author
31
+ // built); only its `ENTRYPOINT`/`CMD` do not run, because a container has one command and the
32
+ // Harness must own it — its death must be the container's death, which is what the operator's
33
+ // Ready probe and restart semantics at `:8080` mean. A process the image WANTS running is not
34
+ // lost: it has its own seat, the User Container (ADR-0005), composed here when the wrapper's static
35
+ // `user` option names an image (ADR-0049).
36
+ //
37
+ // So this module composes the whole pod — two init steps and up to three containers:
38
+ //
39
+ // initContainer runtime the kit's Harness image → copies /opt/jr2 into the volume
40
+ // initContainer preflight the USER'S image + that volume → ADR-0037's probe, the thing that
41
+ // proves a registry ref, whose first appearance is this provision
42
+ // container harness the Sandbox Image, command overridden, /work + /opt/jr2 mounted
43
+ // container adapter jr2-owned, the pod's only credential holder (below)
44
+ // container user optional, the image's own entrypoint, the checkouts (/work, plus
45
+ // /repos and /opt/jr2 read-only) and NOTHING else
46
+ //
47
+ // This is also where the ADAPTER is injected (ADR-0013). The operator needs no change to carry it:
48
+ // ADR-0001 made `Sidecars` generic container fragments it schedules WITHOUT understanding, so the
49
+ // Adapter is exactly that — a container with an image, an env, and a Secret. What this module
50
+ // builds is the pod's asymmetry:
51
+ //
52
+ // harness container JR2_ADAPTER_URL=http://127.0.0.1:8081 (an address, no credential)
53
+ // adapter container JR2_ORCHESTRATOR_URL + JR2_SANDBOX_TOKEN (the credential, via envFrom)
54
+ //
55
+ // The Agent has code execution in the first and none in the second. The token is minted here — a
56
+ // signed Sandbox name (see tokens.ts), so re-provisioning after a restart yields the SAME token and
57
+ // the Secret re-applies as a no-op.
58
+
59
+ import { execFile } from "node:child_process";
60
+ import { join } from "node:path";
61
+ import {
62
+ matchCredential,
63
+ type GitCredential,
64
+ type HarnessEnvFromSource,
65
+ type HarnessEnvVar,
66
+ type SandboxPlacement,
67
+ } from "./config.ts";
68
+ import { readImageRefs, resolveSandboxImage, resolveUserImage, type ImageRefs } from "./images.ts";
69
+ import { CA_CONFIGMAP, IMAGES_KEY, IMAGES_MOUNT, REPOS_MOUNT } from "./names.ts";
70
+ import { repoIdentity } from "./repo-identity.ts";
71
+ import type { RepoResources } from "./repos.ts";
72
+ import { sandboxToken } from "./tokens.ts";
73
+ import type { ProvisionedRepo, SandboxPort, WorkspaceSpec } from "./workspace.ts";
74
+
75
+ /** Run one kubectl invocation to completion. `input` is piped to stdin (`apply -f -`). */
76
+ export type KubectlExec = (args: string[], opts?: { input?: string }) => Promise<{ stdout: string; stderr: string }>;
77
+
78
+ /** Where the Harness container sees the instance's CA bundle (ADR-0020). */
79
+ const CA_MOUNT = "/etc/jr2/ca";
80
+
81
+ /** Where jr2's runtime lands in every container that gets it (ADR-0037). `/opt/jr2` and not `/app`
82
+ * because a stranger's base may already use `/app`, and one layout must serve both the stock
83
+ * Harness image and an arbitrary Sandbox Image. It is a PUBLISHED surface: `bin/` beside `lib/`
84
+ * (node's rpath is `$ORIGIN/../lib`), `src/main.ts`, `node_modules/`. */
85
+ export const RUNTIME_MOUNT = "/opt/jr2";
86
+
87
+ /** Where the populate init container writes the runtime. NOT `/opt/jr2`: mounting the volume there
88
+ * would shadow the very directory being copied out of the Harness image. */
89
+ const RUNTIME_STAGE = "/mnt/jr2";
90
+
91
+ /** The primary container's command (ADR-0037). Absolute, so it never depends on the image's
92
+ * `WORKDIR`, and identical to the stock Harness image's own `CMD` — one runtime, two placements. */
93
+ const HARNESS_COMMAND = [`${RUNTIME_MOUNT}/bin/node`, `${RUNTIME_MOUNT}/src/main.ts`];
94
+
95
+ /** The program `origin`'s fetch url runs (ADR-0053), on the runtime volume beside `work-acl`. It
96
+ * asks the node cache for a fetch and then serves the cache, so every seat that holds the
97
+ * checkouts must hold this volume — which is why the User Container mounts it (ADR-0005). */
98
+ const UPLOAD_PACK = `${RUNTIME_MOUNT}/bin/jr2-upload-pack`;
99
+
100
+ /** The Adapter's port on the pod's loopback. The program defaults to this address too, so the
101
+ * fetch url names it only when the composition moved it (attachScript). */
102
+ const DEFAULT_ADAPTER_PORT = 8081;
103
+
104
+ /** ADR-0005's default work group. Convention, not config: the pod's `fsGroup` is granted to every
105
+ * container as a supplemental group, so the Harness writes `/work` whatever the number and no
106
+ * image's `/etc/group` needs to know it. The one override is the spec's `workGroup`. */
107
+ const DEFAULT_WORK_GROUP = 2000;
108
+
109
+ /**
110
+ * The isolation baseline for a jr2-owned seat, spelled out HERE for the init containers because the
111
+ * operator's hardened default covers the primary container and the sidecars only (ADR-0001/0005) —
112
+ * init steps pass through verbatim, which is what keeps the operator agent-agnostic. Deliberately
113
+ * not applied to the `user` container: that seat's identity is "what jr2 does not own".
114
+ */
115
+ const HARDENED = {
116
+ runAsNonRoot: true,
117
+ allowPrivilegeEscalation: false,
118
+ capabilities: { drop: ["ALL"] },
119
+ seccompProfile: { type: "RuntimeDefault" },
120
+ };
121
+
122
+ /**
123
+ * ADR-0037's fallback seat, for a BUILT image that declares no `USER` (the converge's `docker
124
+ * inspect` is what saw that; images.ts holds the record). jr2 sets `runAsUser` nowhere else — an
125
+ * image's own `USER` decides its seat's uid (ADR-0005) — so this applies only where the image
126
+ * chose nothing and the alternative is root, which the hardened context refuses.
127
+ *
128
+ * The home is a POD volume, not a directory in the image: uid 1000 on a stranger's base has no
129
+ * home at all, and the floor needs a writable one (`git config --global` writes `$HOME/.gitconfig`
130
+ * on every attach; a real toolchain wants `~/.npm`, `~/.cargo`, `~/.cache`). An emptyDir lands
131
+ * group-writable under the pod's fsGroup, so uid 1000 owns it in practice without jr2 chown-ing
132
+ * anything. `/home/jr2` and not `/home/node`: the number is jr2's choice here, so the path is too.
133
+ */
134
+ const FALLBACK_UID = 1000;
135
+ const FALLBACK_HOME = "/home/jr2";
136
+
137
+ /**
138
+ * The kubelet's verdict on a container whose image resolves to root under `runAsNonRoot: true`.
139
+ * Matched, not merely reported, because it is the ONE provision failure with no evidence anywhere
140
+ * else: the container never starts, so it has no logs, and the pod sits in this waiting state until
141
+ * the provision times out — which then blames the preflight for a container the preflight never got
142
+ * to run. A BUILT image is caught earlier and cheaper (the converge's `docker inspect` recorded the
143
+ * string; `resolveSandboxImage` in images.ts judges it into `refusedUser` before anything is
144
+ * applied), so this is the brought ref's path: never inspected, never given the uid-1000 fallback,
145
+ * knowable only from the cluster.
146
+ *
147
+ * Reason and message are BOTH required. The reason alone covers a missing Secret or ConfigMap key
148
+ * too — a different fault with a different fix — and only the message distinguishes them.
149
+ */
150
+ const ROOT_IMAGE_REASON = "CreateContainerConfigError";
151
+ const ROOT_IMAGE_MESSAGE = /runAsNonRoot/i;
152
+
153
+ /** How many CR polls pass between two pod reads. See the provision loop for why it is not 1. */
154
+ const POD_CHECK_EVERY = 5;
155
+
156
+ /**
157
+ * The kubelet's own words when a container's image resolves to root under `runAsNonRoot`, or
158
+ * undefined for every other pod shape. A pure read of pod status: the caller supplies the parsed
159
+ * `kubectl get pod -o json`, so the claim is testable without a cluster and the fault detection
160
+ * cannot drift from the message the provision prints.
161
+ *
162
+ * Init containers are searched FIRST because they run first: the `preflight` step runs the user's
163
+ * image before the Harness container ever exists, so that is where a root image dies. The primary
164
+ * containers are searched too — the same image sits in the `harness` seat, and the `user` seat is
165
+ * deliberately un-hardened (ADR-0005), so a fault there would mean something else entirely.
166
+ *
167
+ * Both the reason AND the message must match. `CreateContainerConfigError` is also what an absent
168
+ * Secret key produces, and that fault has a different fix; a name with no evidence behind it is
169
+ * worse than the timeout it replaces.
170
+ */
171
+ export function rootImageFault(pod: unknown): string | undefined {
172
+ const status = (pod as { status?: Record<string, unknown> } | null)?.status;
173
+ if (!status) return undefined;
174
+ type Waiting = { name?: string; state?: { waiting?: { reason?: string; message?: string } } };
175
+ const groups = [status["initContainerStatuses"], status["containerStatuses"]];
176
+ for (const group of groups) {
177
+ if (!Array.isArray(group)) continue;
178
+ for (const cs of group as Waiting[]) {
179
+ const waiting = cs?.state?.waiting;
180
+ if (!waiting || waiting.reason !== ROOT_IMAGE_REASON) continue;
181
+ if (!waiting.message || !ROOT_IMAGE_MESSAGE.test(waiting.message)) continue;
182
+ return `container "${cs.name ?? "?"}": ${waiting.message}`;
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ /**
189
+ * The fix, not the symptom. The kubelet's message says what it refused; it cannot say that the
190
+ * image is a Sandbox Image, that jr2 declined to patch a uid onto it, or where the one-line edit
191
+ * goes — and without those three the reader has a Kubernetes error and no next step.
192
+ *
193
+ * It names the BROUGHT case specifically because that is the only one that reaches here: a built
194
+ * image's `USER` was inspected at converge and judged before anything was applied (images.ts), and
195
+ * an image declaring none gets the uid-1000 fallback. A ref is never inspected — that is the point
196
+ * of refs (ADR-0037) — so it must declare a numeric non-root `USER` itself.
197
+ */
198
+ function rootImageError(name: string, fault: string): string {
199
+ return (
200
+ `Sandbox "${name}" cannot start: its image runs as ROOT, and every jr2-owned seat is hardened ` +
201
+ `with runAsNonRoot (ADR-0005). The kubelet refused it — ${fault}\n` +
202
+ ` - the fix is one line in the image: a NUMERIC non-root \`USER <uid>\` (e.g. \`USER 1000\`)\n` +
203
+ ` - numeric because the kubelet does not read the image's /etc/passwd, so \`USER app\` is ` +
204
+ `refused too — it cannot prove that name is non-root\n` +
205
+ ` - jr2 does not supply a uid for a brought registry ref: it is never inspected and never ` +
206
+ `modified, which is what "bring your own image" means (ADR-0037). Only an image jr2 BUILDS, ` +
207
+ `and only one that declares no USER at all, gets the uid-${FALLBACK_UID} fallback.`
208
+ );
209
+ }
210
+
211
+ /**
212
+ * ADR-0037's preflight, VERBATIM: git present · `$HOME` writable · glibc new enough for jr2's node
213
+ * (with the relocated `libstdc++`) · the vendored ripgrep, reached through the mounted `/opt/jr2`.
214
+ *
215
+ * The three commands are the floor, one each: `git config --global` proves git is on the system
216
+ * PATH AND that `$HOME` is writable for the image's user; `node -e ""` proves the glibc is no
217
+ * older than the one jr2's node was built against (this is where musl dies); `rg` UNQUALIFIED
218
+ * proves the vendored static binary resolves THROUGH PATH, which is what the Harness's own append
219
+ * buys at runtime.
220
+ *
221
+ * ONE prover, and this is it (ADR-0037/0041). A converge cannot hold an image to this floor: the
222
+ * floor is a HARNESS-SEAT obligation, a built context may equally be destined for the User
223
+ * Container seat — which owes no floor at all (ADR-0005) — and which seat a directory serves is
224
+ * workflow-internal and statically unrecoverable (ADR-0031). Here the seat is known, and here is
225
+ * also the only moment a registry ref exists at all, since jr2 never builds or inspects one.
226
+ */
227
+ const SANDBOX_PREFLIGHT = `git config --global safe.directory "*" && ${RUNTIME_MOUNT}/bin/node -e "" && rg --version`;
228
+
229
+ /**
230
+ * The probe as a shell line. The PATH append is mechanism, not part of the claim, and it is not
231
+ * optional: nothing bakes `/opt/jr2/bin` into the user's image any more, so a probe that skipped it
232
+ * would report `rg: not found` for every image on earth. APPENDED, never prepended — a toolchain
233
+ * the image pinned wins, which is as much the property being proved as `rg`'s presence (ADR-0037).
234
+ * The seat gets no login shell at either end, so `$PATH` is whatever the image itself set.
235
+ */
236
+ function preflightShell(): string {
237
+ return `export PATH="$PATH:${RUNTIME_MOUNT}/bin"; ${SANDBOX_PREFLIGHT}`;
238
+ }
239
+
240
+ /**
241
+ * The preflight as an init step IN THE USER'S IMAGE with `/opt/jr2` mounted. It fails the pod
242
+ * before the Harness starts, instead of surfacing as a tool failure mid-turn on a pod nobody is
243
+ * watching.
244
+ *
245
+ * It PROVES, it does not set up: the `.gitconfig` it writes lives in this container's own
246
+ * ephemeral filesystem, so the attach script still runs the same line in the Harness container.
247
+ *
248
+ * `sh -c`, not `sh -ec`: the failure is handled below, because "node did not execute" is not
249
+ * actionable and the message has to name the fix.
250
+ */
251
+ const PREFLIGHT_SCRIPT = [
252
+ `${preflightShell()} && exit 0`,
253
+ `echo "jr2: this Sandbox Image does not meet the floor (ADR-0037): a glibc base no older than ` +
254
+ `jr2's node (musl is out entirely), git on the system PATH, a writable HOME for the image's ` +
255
+ `USER, and /opt/jr2 + /work + :8080 unclaimed. jr2 vendors the rest." >&2`,
256
+ `exit 1`,
257
+ ].join("\n");
258
+
259
+ export type KubectlSandboxOptions = {
260
+ /** The mounted image map (ADR-0037/0038) — every ref this port can name, written by `jr2 up`.
261
+ * Default: the `jr2-images` ConfigMap's mount. No image option here: which image a Sandbox runs
262
+ * is the `workspace()` wrapper's static `image` option (ADR-0049) — carried on the Machine, read
263
+ * off it at invoke time, handed to `provision()` as a string — and resolved against this map at
264
+ * provision. The per-run spec never names one (ADR-0051). */
265
+ imagesPath?: string;
266
+ /** Extra env for the HARNESS container (`harness.env`) — merged ahead of the
267
+ * mechanism-owned vars, which win on collision. */
268
+ env?: HarnessEnvVar[];
269
+ /** Whole-Secret/ConfigMap env for the Harness container (`harness.envFrom`) — how a real
270
+ * Harness gets its model API key without the value ever touching jr2 config. */
271
+ envFrom?: HarnessEnvFromSource[];
272
+ /** Where a Sandbox may land (ADR-0052): the Instance's `sandbox.nodeSelector` and
273
+ * `sandbox.tolerations`, written on the CR verbatim and copied onto the pod by the operator, which
274
+ * merges nothing with them. Absent → wherever an ordinary pod lands. */
275
+ placement?: SandboxPlacement;
276
+ /** The instance ships a private-CA bundle (ADR-0020): mount the `jr2-ca` ConfigMap into the
277
+ * HARNESS container and point NODE_EXTRA_CA_CERTS at it — never the Adapter, which speaks plain
278
+ * HTTP to the Orchestrator's Service (the same asymmetry as env/envFrom above). */
279
+ caBundle?: boolean;
280
+ /**
281
+ * Where the Adapter reaches the Orchestrator FROM INSIDE THE CLUSTER — the orchestrator's own
282
+ * Service DNS (derived from JR2_NAMESPACE by the entrypoint). The Agent is never told it.
283
+ * A thunk is still accepted for callers that resolve their address late.
284
+ */
285
+ orchestratorUrl?: string | (() => string | undefined);
286
+ /** The key Sandbox tokens are signed with — from the instance Secret (ADR-0013/0019). */
287
+ signingKey?: Buffer;
288
+ /** The Adapter's port on the pod's loopback. Default 8081. */
289
+ adapterPort?: number;
290
+ /** Kube namespace for Sandbox CRs. Default `default`. */
291
+ namespace?: string;
292
+ /** kubectl `--context` override. Default: the current context (ADR-0009). */
293
+ context?: string;
294
+ /** In-pod root for the pod-local clones + worktrees (ADR-0004 layout). Default `/work`. */
295
+ workRoot?: string;
296
+ /** CR `spec.idleTimeout` — the operator's abandoned-Sandbox GC backstop (ADR-0001). Default `30m`. */
297
+ idleTimeout?: string;
298
+ /** How often a workspace's lease actor renews (ADR-0001/0021): the cadence at which
299
+ * `jr2.dev/keepalive` is re-stamped AND continuity is read back. Must be ≪ idleTimeout, since
300
+ * a lapsed lease is what lets the operator reap. Default 5m. */
301
+ leaseIntervalMs?: number;
302
+ /** Await-Ready budget for the POD: from the CR apply until the operator reports the pod Ready.
303
+ * Default 120s, polled every second. A pod that never comes up (an image that misses ADR-0037's
304
+ * floor) is what this bounds; a pod that is up and waiting on its Repos is `repoTimeoutMs`'s. */
305
+ readyTimeoutMs?: number;
306
+ /**
307
+ * Await-Ready budget for the REPOS (ADR-0051): once the operator holds a Sandbox whose pod is
308
+ * Ready on a Repo reason — the node's cache agent is cloning a cold node, or fetching before the
309
+ * attach — the wait is measured against this, from the same CR apply. Sized for a clone, not a
310
+ * pod: the agent's clone budget is 20m, and this must exceed it so a clone that runs out of
311
+ * time fails BY NAME (`RepoCloneFailed`, git's words) instead of as this port's timeout; and it
312
+ * must stay inside `idleTimeout` (30m), because the lease starts after provision, so the operator
313
+ * reaps a Sandbox that waits longer than that. Default 25m.
314
+ */
315
+ repoTimeoutMs?: number;
316
+ pollMs?: number;
317
+ /**
318
+ * The Repo-resource port (ADR-0051, repos.ts): every Repo a provision names must exist as a
319
+ * `Repo` resource before the CR names it, or the operator reports it missing and the Sandbox
320
+ * never reaches Ready. So `provision` ensures each one here — a per-run url's resource is
321
+ * created at first attach, a bound one is found as the boot stated it — and REFUSES to run
322
+ * without the port: a Sandbox whose Repos nobody creates parks on `RepoMissing` for the whole
323
+ * Ready budget. The other operations (attach, renew, destroy) need no port, so it is optional
324
+ * at construction.
325
+ */
326
+ repos?: RepoResources;
327
+ /**
328
+ * The instance's `git.credentials` (ADR-0051) — THE FENCE. A per-run url is run input, a
329
+ * ticket field, and otherwise a way to spend the cluster's credential against any host: one
330
+ * whose identity matches no entry is refused here, before a Secret or a CR exists, naming the
331
+ * list. A bound url is code the instance typechecked and deployed, admitted without a match.
332
+ * Default: no entries, so every per-run url is refused.
333
+ */
334
+ credentials?: readonly GitCredential[];
335
+ /** Process seam, injectable for tests. Defaults shell to the `kubectl` on PATH. */
336
+ exec?: KubectlExec;
337
+ };
338
+
339
+ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
340
+ const ns = opts.namespace ?? "default";
341
+ const imagesPath = opts.imagesPath ?? join(IMAGES_MOUNT, IMAGES_KEY);
342
+ const workRoot = opts.workRoot ?? "/work";
343
+ const readyTimeoutMs = opts.readyTimeoutMs ?? 120_000;
344
+ const repoTimeoutMs = opts.repoTimeoutMs ?? 25 * 60_000;
345
+ const pollMs = opts.pollMs ?? 1_000;
346
+ const exec = opts.exec ?? defaultKubectlExec;
347
+ const credentials = opts.credentials ?? [];
348
+
349
+ const adapterPort = opts.adapterPort ?? DEFAULT_ADAPTER_PORT;
350
+ const leaseIntervalMs = opts.leaseIntervalMs ?? 5 * 60_000;
351
+ const base = ["--namespace", ns, ...(opts.context ? ["--context", opts.context] : [])];
352
+
353
+ /** The Sandbox's token Secret — read by the Adapter container, and by nothing else in the pod. */
354
+ const secretName = (name: string) => `${name}-token`;
355
+
356
+ /** Resolved at provision time (see the option's doc): the Orchestrator's in-cluster address. */
357
+ const orchestratorUrl = (): string | undefined =>
358
+ typeof opts.orchestratorUrl === "function" ? opts.orchestratorUrl() : opts.orchestratorUrl;
359
+
360
+ /** The Adapter, as the operator sees it: an opaque container fragment (ADR-0001). */
361
+ const adapterSidecar = (name: string, refs: ImageRefs) => ({
362
+ name: "adapter",
363
+ image: refs.adapter,
364
+ env: [
365
+ { name: "JR2_ORCHESTRATOR_URL", value: orchestratorUrl() },
366
+ { name: "JR2_SANDBOX", value: name },
367
+ { name: "JR2_ADAPTER_PORT", value: String(adapterPort) },
368
+ ],
369
+ // The credential, and the reason this is a separate container: `local()` tools give the Agent
370
+ // code execution in the HARNESS container, so anything mounted there is the Agent's. Here, it
371
+ // is out of reach — different container, no shared process namespace.
372
+ envFrom: [{ secretRef: { name: secretName(name) } }],
373
+ });
374
+
375
+ /**
376
+ * The User Container (ADR-0005): the opt-in third seat, composed only when the wrapper's static
377
+ * `user` option names an image (ADR-0049). The ZERO-CONTRACT seat — jr2 injects nothing, probes
378
+ * nothing, overrides nothing. So: no `command` (its own entrypoint runs, untouched), no `env`,
379
+ * no `envFrom`, no CA bundle, no ports, no resources. Every key jr2 forwarded would be a crack in
380
+ * "jr2 puts nothing in it", and widening the one authoring string to an object stays compatible
381
+ * if a concrete need ever argues its own way in. (Git's dubious-ownership guard is the line's
382
+ * cost, accepted with
383
+ * eyes open — ADR-0005: safe.directory is honored only from files this seat's image owns, so an
384
+ * image whose sessions run git carries its own line.)
385
+ *
386
+ * `/work` read-write plus the checkouts' two read-only halves are the single exception, and they
387
+ * are not an injection but the point: this seat and the Harness mount ONE worktree, so the human
388
+ * and the Agent see identical files — which is also why ADR-0005's cross-uid pair (the pod's
389
+ * `fsGroup`, the attach's default ACL) exists at all. The caches ride along because they are
390
+ * half of the same files: the worktrees are `--shared` clones whose alternates resolve objects
391
+ * from `/repos/<key>` (ADR-0004/0051), so a seat with `/work` alone holds checkouts whose every
392
+ * borrowed object is missing ("unable to normalize alternate object path"). `/opt/jr2` is the
393
+ * other half (ADR-0053): `origin`'s fetch url is a program on that volume, so a seat without it
394
+ * holds checkouts whose `git fetch` dies — and with it the human gets the same fetch as the
395
+ * Agent, with no credential of their own. Nothing else follows it in: `ext::` names the program
396
+ * by absolute path, so this seat still gets no env, no command, and no probe. The repo volumes
397
+ * are the operator's — it defines `repo-<key>` for every key the CR names — so this seat mounts
398
+ * them by name. The Adapter is deliberately not given any of the three: it reads no worktree, and it is the
399
+ * container holding the pod's only credential, so it gets the narrowest mount set that works.
400
+ * It also carries no `securityContext`, which the operator reads as the exemption — root is
401
+ * ALLOWED here, because hardening a seat whose identity is "what jr2 does not own" is an opinion,
402
+ * and the standard managed-access shape (a root sshd that setuids sessions down) must run
403
+ * unmodified.
404
+ */
405
+ const userSidecar = async (refs: ImageRefs, image: string, keys: string[]) => ({
406
+ name: "user",
407
+ image: await resolveUserImage(refs, image),
408
+ volumeMounts: [
409
+ { name: "work", mountPath: workRoot },
410
+ { name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true },
411
+ ...keys.map((key) => ({ name: repoVolumeName(key), mountPath: repoMountPath(key), readOnly: true })),
412
+ ],
413
+ });
414
+
415
+ /** The pod's sidecar list (ADR-0001: opaque fragments the operator schedules verbatim). The
416
+ * Adapter is ALWAYS here: with its ref in the image map there is no "no adapter configured"
417
+ * state left to branch on, and a Sandbox without one is a pod that comes up Ready and then parks
418
+ * its Machine forever on a tool call it cannot make (ADR-0013). A map with no `adapter` fails the
419
+ * read instead (images.ts). The User Container joins it only when the spec named one. */
420
+ const sidecarsFor = async (name: string, refs: ImageRefs, keys: string[], user?: string) => [
421
+ adapterSidecar(name, refs),
422
+ ...(user !== undefined ? [await userSidecar(refs, user, keys)] : []),
423
+ ];
424
+
425
+ // The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
426
+ // config) first, then the mechanism-owned vars (the Adapter address, the CA trust path), which
427
+ // win on collision. Note the asymmetry stands (ADR-0013): user env/envFrom land on the HARNESS
428
+ // container only — never on the Adapter, whose env is minted here and carries the pod's only
429
+ // credential.
430
+ const harnessEnv = (): HarnessEnvVar[] => [
431
+ ...(opts.env ?? []),
432
+ { name: "JR2_ADAPTER_URL", value: `http://127.0.0.1:${adapterPort}` },
433
+ ...(opts.caBundle ? [{ name: "NODE_EXTRA_CA_CERTS", value: `${CA_MOUNT}/ca.crt` }] : []),
434
+ ];
435
+
436
+ /**
437
+ * The two init steps, in order (ADR-0037). They are plain container fragments the operator
438
+ * schedules without understanding, exactly like sidecars — the operator stays agent-agnostic
439
+ * (ADR-0001), so "how a Sandbox gets its runtime" is composed here, not reconciled there.
440
+ *
441
+ * 1. `runtime` — the kit's Harness image, copying its `/opt/jr2` into the shared emptyDir. This
442
+ * is what makes the runtime's version ride the VOLUME rather than the image: a kit edit moves
443
+ * the harness image's own tag and re-images future pods without touching a single Sandbox
444
+ * Image tag, which is the only way a registry-ref image could ever follow a kit update.
445
+ * 2. `preflight` — the USER'S image with that volume mounted, running the probe. Ordered second
446
+ * because it needs what the first one wrote.
447
+ *
448
+ * Both carry jr2's hardened context explicitly, and `preflight` runs the probe in the SAME seat
449
+ * the Harness will get — the image's own user, or ADR-0037's fallback — because a probe that
450
+ * proved a different uid's `$HOME` proved nothing.
451
+ */
452
+ const initContainersFor = (refs: ImageRefs, seat: Seat) => [
453
+ {
454
+ name: "runtime",
455
+ image: refs.harness,
456
+ // The copy's rules live beside the tree they copy (`deploy/harness/init-copy`), not in a
457
+ // string here: `/opt/jr2` is a published surface whose SHAPE is load-bearing — node's rpath
458
+ // is `$ORIGIN/../lib`, so `bin/` and `lib/` must land as siblings — and the script proves
459
+ // its own result by running the copied node before the pod moves on.
460
+ command: [`${RUNTIME_MOUNT}/bin/init-copy`, RUNTIME_STAGE],
461
+ volumeMounts: [{ name: "runtime", mountPath: RUNTIME_STAGE }],
462
+ securityContext: HARDENED,
463
+ },
464
+ {
465
+ name: "preflight",
466
+ image: seat.image,
467
+ command: ["/bin/sh", "-c", PREFLIGHT_SCRIPT],
468
+ ...(seat.env.length ? { env: seat.env } : {}),
469
+ volumeMounts: [{ name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true }, ...seat.homeMount],
470
+ securityContext: seat.securityContext,
471
+ },
472
+ ];
473
+
474
+ /**
475
+ * The primary container's seat: which image runs, as whom, and with what home. One value, built
476
+ * once per provision and shared by the Harness container and the preflight, so the probe cannot
477
+ * drift from the thing it proves.
478
+ */
479
+ type Seat = {
480
+ image: string;
481
+ env: HarnessEnvVar[];
482
+ homeVolume: Array<{ name: string; emptyDir: Record<string, never> }>;
483
+ homeMount: Array<{ name: string; mountPath: string }>;
484
+ securityContext: typeof HARDENED & { runAsUser?: number };
485
+ };
486
+
487
+ const seatFor = async (refs: ImageRefs, name?: string): Promise<Seat> => {
488
+ // ONE resolution, so the ref and the two seat facts can never come off different legs of
489
+ // ADR-0037's chain (images.ts). It is async because a `file:` context is keyed by its content
490
+ // digest, which is a directory walk — the price of the host and the pod agreeing about an
491
+ // image without a path table (ADR-0049).
492
+ const { ref: image, fallbackSeat, refusedUser } = await resolveSandboxImage(refs, name);
493
+ // Fail HERE, before a Secret or a CR exists, on a `USER` the kubelet will refuse (images.ts).
494
+ // The alternative is the worst shape a failure has: the preflight container never starts, so
495
+ // it has no logs, and the whole 120s Ready budget burns before anything is said. The converge
496
+ // already inspected the image, so this is knowable at zero cost — and the message names the
497
+ // edit, because the fix is one line of the caller's own Dockerfile.
498
+ if (refusedUser !== undefined) {
499
+ throw new Error(
500
+ `the Sandbox Image "${name ?? "default"}" declares \`USER ${refusedUser}\`, which cannot run a jr2 seat: ` +
501
+ "every jr2-owned container is `runAsNonRoot` with no `runAsUser`, so the kubelet needs a NUMERIC " +
502
+ "non-zero uid it can check without reading the image (ADR-0005). Change the Dockerfile's last " +
503
+ "`USER` to that uid (e.g. `USER 1000`, or drop the line entirely and jr2 supplies uid 1000 with a " +
504
+ "writable HOME — ADR-0037), then re-run `jr2 up`.",
505
+ );
506
+ }
507
+ // The common case, and the one ADR-0037 is written around: the image chose its `USER` and its
508
+ // `HOME`, and jr2 touches neither — the human who execs in lands in the environment the
509
+ // image's author built, dotfiles included.
510
+ if (!fallbackSeat) {
511
+ return { image, env: [], homeVolume: [], homeMount: [], securityContext: HARDENED };
512
+ }
513
+ return {
514
+ image,
515
+ env: [{ name: "HOME", value: FALLBACK_HOME }],
516
+ homeVolume: [{ name: "home", emptyDir: {} }],
517
+ homeMount: [{ name: "home", mountPath: FALLBACK_HOME }],
518
+ securityContext: { ...HARDENED, runAsUser: FALLBACK_UID },
519
+ };
520
+ };
521
+
522
+ const crFor = async (
523
+ req: { name: string; runId: string; workflow: string; image?: string; user?: string; workGroup?: number },
524
+ refs: ImageRefs,
525
+ repos: FencedRepo[],
526
+ ) => {
527
+ const seat = await seatFor(refs, req.image);
528
+ const sidecars = await sidecarsFor(
529
+ req.name,
530
+ refs,
531
+ repos.map((r) => r.key),
532
+ req.user,
533
+ );
534
+ return {
535
+ apiVersion: "core.jr2.dev/v1alpha1",
536
+ kind: "Sandbox",
537
+ metadata: {
538
+ name: req.name,
539
+ namespace: ns,
540
+ // The run↔workspace link `jr2 ls` groups by (ADR-0009/0012) — readable without the host.
541
+ labels: { "jr2.dev/run": req.runId, "jr2.dev/workflow": req.workflow },
542
+ },
543
+ spec: {
544
+ // The Sandbox Image, unmodified (ADR-0037) — the user's tools, its own USER and HOME, and
545
+ // jr2's runtime arriving beside it on a volume. This container is both the Harness and the
546
+ // human's `exec` shell.
547
+ image: seat.image,
548
+ // The one thing jr2 takes from the image: its command. A container has exactly one, and it
549
+ // must be the Harness's — a pod whose main process is the user's entrypoint keeps
550
+ // "Running" through a Harness death, which makes the operator's Ready probe a lie.
551
+ command: HARNESS_COMMAND,
552
+ // Hardened, and the ONLY place jr2 ever names a uid: ADR-0037's fallback for an image that
553
+ // declared none. Stating the whole context here rather than leaving it to the operator's
554
+ // default is what makes that possible — the operator hardens only what says nothing.
555
+ securityContext: seat.securityContext,
556
+ idleTimeout: opts.idleTimeout ?? "30m",
557
+ // Placement (ADR-0052): the Instance's word on which nodes are Sandbox nodes, raw pod-spec
558
+ // shapes the operator copies verbatim. Absent keys are absent here too — the CR says
559
+ // nothing, and the pod lands wherever an ordinary pod lands. The operator's own soft
560
+ // affinity toward nodes holding this Sandbox's caches sits beside these untouched: a
561
+ // preference never conflicts with a requirement.
562
+ ...(opts.placement?.nodeSelector ? { nodeSelector: opts.placement.nodeSelector } : {}),
563
+ ...(opts.placement?.tolerations?.length ? { tolerations: opts.placement.tolerations } : {}),
564
+ // The work group (ADR-0005), the ownership half of cross-uid sharing on `/work`.
565
+ // Kubernetes grants it as a supplemental group to every container, and puts a setgid
566
+ // group on the volume root that propagates down; the WRITABILITY half is the default ACL
567
+ // the attach stamps on each repo root (attachScript below), without which fsGroup gives
568
+ // group-READ, which is the trap. Both are inert when the uids match.
569
+ fsGroup: req.workGroup ?? DEFAULT_WORK_GROUP,
570
+ // Ordered, and before any container starts: populate `/opt/jr2`, then prove the image on it.
571
+ initContainers: initContainersFor(refs, seat),
572
+ // Never empty any more: JR2_ADAPTER_URL is unconditional, so the "omit an empty env" branch
573
+ // this used to carry was unreachable. The seat's own vars (the fallback `HOME`) come
574
+ // FIRST, so the instance's `harness.env` can still override them the way it overrides
575
+ // anything the image set.
576
+ env: [...seat.env, ...harnessEnv()],
577
+ ...(opts.envFrom?.length ? { envFrom: opts.envFrom } : {}),
578
+ // What the AGENT gets: an address on its own loopback, and no credential anywhere. This is
579
+ // the only thing in the pod that tells it how to reach its Machine (ADR-0013).
580
+ sidecars,
581
+ // The Repos this Sandbox attaches, by cache key (ADR-0051). The operator does the rest: a
582
+ // `repo-<key>` volume per entry — the node's cache, hostPath, read-only in the primary
583
+ // container at `/repos/<key>` — a soft affinity toward nodes already holding them, and
584
+ // `Ready` only once every one is present on the pod's node and fetched since this CR
585
+ // asked. Read-only is load-bearing twice (ADR-0004): no write contention, and nothing in
586
+ // a Sandbox can `gc` the object store its `--shared` clones borrow from.
587
+ repos: repos.map(({ key, url }) => ({ key, url })),
588
+ volumes: [
589
+ // The worktree root is a POD volume, not a directory baked into the image. Two reasons,
590
+ // both load-bearing: every jr2-owned seat runs as an unprivileged uid, which cannot mkdir
591
+ // under `/` — so an image-owned `/work` would make every attach fail — and `/work` is
592
+ // the one thing all three containers share (ADR-0005), so human and Agent see identical
593
+ // files. An emptyDir lands group-writable under the pod's fsGroup, so it is writable
594
+ // whatever uid the Sandbox Image runs as: `/work` unclaimed is the only image contract.
595
+ { name: "work", emptyDir: {} },
596
+ // jr2's runtime (ADR-0037). An emptyDir, so it lives and dies with the pod and carries
597
+ // the version the pod STARTED with — a live Sandbox keeps its runtime across a kit
598
+ // update, the same create-if-absent stance ADR-0038 takes for images.
599
+ { name: "runtime", emptyDir: {} },
600
+ // Only for ADR-0037's fallback seat: uid 1000 on a stranger's base has no home at all.
601
+ ...seat.homeVolume,
602
+ ...(opts.caBundle ? [{ name: "ca", configMap: { name: CA_CONFIGMAP } }] : []),
603
+ ],
604
+ // CR-level volumeMounts land on the HARNESS container only (the operator's contract) —
605
+ // exactly the CA-trust asymmetry ADR-0020 wants: the Adapter never inherits it. The Repo
606
+ // caches are not listed: the operator mounts each `repo-<key>` into this container itself.
607
+ volumeMounts: [
608
+ { name: "work", mountPath: workRoot },
609
+ // Read-only: nothing writes under `/opt/jr2` at runtime, and the Agent has code execution
610
+ // in this container — leaving its own runtime writable would let a turn edit it.
611
+ { name: "runtime", mountPath: RUNTIME_MOUNT, readOnly: true },
612
+ ...seat.homeMount,
613
+ ...(opts.caBundle ? [{ name: "ca", mountPath: CA_MOUNT, readOnly: true }] : []),
614
+ ],
615
+ },
616
+ };
617
+ };
618
+
619
+ type SandboxStatus = {
620
+ phase?: string;
621
+ endpoint?: string;
622
+ podUID?: string;
623
+ uid?: string;
624
+ conditions?: Condition[];
625
+ };
626
+
627
+ /** Parse a Sandbox CR off any kubectl call that printed one (`get -o json`, and the lease's
628
+ * `annotate -o json` — which returns the object AFTER the patch, status included). */
629
+ const readSandbox = (stdout: string): SandboxStatus => {
630
+ const parsed = JSON.parse(stdout) as {
631
+ metadata?: { uid?: string };
632
+ status?: { phase?: string; endpoint?: string; podUID?: string; conditions?: Condition[] };
633
+ };
634
+ return { ...(parsed.status ?? {}), uid: parsed.metadata?.uid };
635
+ };
636
+
637
+ const conditionOf = (status: SandboxStatus | undefined, type: string): Condition | undefined =>
638
+ status?.conditions?.find((c) => c.type === type);
639
+
640
+ const getSandbox = async (name: string): Promise<SandboxStatus | undefined> => {
641
+ try {
642
+ const { stdout } = await exec(["get", "sandbox", name, ...base, "-o", "json"]);
643
+ return readSandbox(stdout);
644
+ } catch (err) {
645
+ if (isNotFound(err)) return undefined;
646
+ throw err;
647
+ }
648
+ };
649
+
650
+ /**
651
+ * Ask the POD whether it is stuck on a fault the Sandbox's phase cannot express (see
652
+ * {@link rootImageFault}). The CR is the port's normal window on a provision; this is the one
653
+ * question it cannot answer, because the operator reports "not Ready yet" for a pod that will
654
+ * never be Ready and one that simply has not started.
655
+ *
656
+ * Absent or unreadable answers undefined: the pod trails the CR by a moment at every provision,
657
+ * and a missing pod is a normal early poll, never evidence of a fault. This may only ever CONVERT
658
+ * a failure that was already going to happen into a named one.
659
+ */
660
+ const podFault = async (name: string): Promise<string | undefined> => {
661
+ try {
662
+ const { stdout } = await exec(["get", "pod", name, ...base, "-o", "json"]);
663
+ return rootImageFault(JSON.parse(stdout));
664
+ } catch {
665
+ return undefined;
666
+ }
667
+ };
668
+
669
+ /**
670
+ * Mint this Sandbox's token into a Secret, BEFORE the CR exists — the operator creates the pod
671
+ * the moment it sees the CR, and a pod whose `envFrom` names an absent Secret sits in
672
+ * CreateContainerConfigError. Idempotent by construction: the token is the Sandbox's name, signed
673
+ * (tokens.ts), so a re-provision after an orchestrator restart re-applies the SAME value, and the
674
+ * Adapter that has been holding it all along stays valid.
675
+ */
676
+ const applyTokenSecret = async (name: string): Promise<void> => {
677
+ if (!opts.signingKey) throw new Error("kubectlSandbox: an Adapter needs a signingKey to mint its Sandbox token");
678
+ // Fail the provision rather than ship an Adapter that cannot reach the Orchestrator. A mute
679
+ // Adapter is the worst possible outcome: the pod comes up Ready, the Agent is admitted, its
680
+ // tool call dies on `localhost`, and the Machine simply parks forever — a hang with no error.
681
+ if (!orchestratorUrl()) {
682
+ throw new Error(
683
+ "kubectlSandbox: the Adapter has no route to the Orchestrator (no orchestratorUrl — deployed " +
684
+ "instances derive Service DNS from JR2_NAMESPACE). An Agent with no Adapter cannot drive its " +
685
+ "Machine at all (ADR-0013).",
686
+ );
687
+ }
688
+ const secret = {
689
+ apiVersion: "v1",
690
+ kind: "Secret",
691
+ metadata: { name: secretName(name), namespace: ns, labels: { "jr2.dev/sandbox": name } },
692
+ type: "Opaque",
693
+ stringData: { JR2_SANDBOX_TOKEN: sandboxToken(opts.signingKey, name) },
694
+ };
695
+ await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(secret) });
696
+ };
697
+
698
+ /**
699
+ * Make the Secret a child of the Sandbox CR, so Kubernetes reaps it whenever the CR goes — including
700
+ * the paths no jr2 code observes (the operator's idle-timeout GC, a `kubectl delete sandbox` by hand).
701
+ * Needs the CR's uid, so it can only happen after the apply; a failure here leaks a Secret, never a
702
+ * pod, so it is not worth failing the provision over.
703
+ */
704
+ const ownSecret = async (name: string, uid: string | undefined): Promise<void> => {
705
+ if (!uid) return;
706
+ const ownerRef = [
707
+ { apiVersion: "core.jr2.dev/v1alpha1", kind: "Sandbox", name, uid, controller: true, blockOwnerDeletion: false },
708
+ ];
709
+ await exec([
710
+ "patch",
711
+ "secret",
712
+ secretName(name),
713
+ ...base,
714
+ "--type",
715
+ "merge",
716
+ "-p",
717
+ JSON.stringify({ metadata: { ownerReferences: ownerRef } }),
718
+ ]).catch(() => {});
719
+ };
720
+
721
+ /**
722
+ * Every Repo the provision names, resolved to its identity and key — and FENCED (ADR-0051). A
723
+ * per-run url is the run's input; one no `git.credentials` entry admits is refused here, before
724
+ * anything is read or applied, naming the list. A bound url is admitted without a match: it is
725
+ * code the instance typechecked and deployed. Two slots spelling one repository collapse to one
726
+ * CR entry (first spelling wins) — one cache, however many slots borrow from it — and the
727
+ * resource is BOUND when any of those slots is the Machine's: a per-run slot alone leaves it on
728
+ * `jr2 gc`'s clock.
729
+ */
730
+ const fencedRepos = (name: string, repos: ProvisionedRepo[]): FencedRepo[] => {
731
+ const byKey = new Map<string, FencedRepo>();
732
+ for (const repo of repos) {
733
+ const { identity, key } = repoIdentity(repo.url);
734
+ if (repo.perRun && !matchCredential(identity, credentials)) {
735
+ const entries = credentials.map((c) => c.match).join(", ") || "none";
736
+ throw new Error(
737
+ `Sandbox "${name}" refuses the per-run repo ${repo.url} for slot "${repo.slot}": no git.credentials ` +
738
+ `entry matches "${identity}" (entries: ${entries}). A per-run url can spend the cluster's credential ` +
739
+ "against any host, so jr2.config.ts must admit it by prefix (ADR-0051).",
740
+ );
741
+ }
742
+ const seen = byKey.get(key);
743
+ if (seen === undefined) byKey.set(key, { key, url: repo.url, identity, bound: !repo.perRun });
744
+ else seen.bound ||= !repo.perRun;
745
+ }
746
+ return [...byKey.values()];
747
+ };
748
+
749
+ /**
750
+ * What the operator will hold this Sandbox's Ready on, and what an attach reads afterwards
751
+ * (ADR-0051). `Ready` with reason `RepoCloneFailed` is terminal for the provision — the cache
752
+ * agent could not clone onto the node the pod landed on, and the reason names it — so the loop
753
+ * fails on it by name rather than burning the budget. `ReposFresh=False` is the other verdict:
754
+ * the caches are there but a fetch since this CR asked failed, so the attach proceeds STALE and
755
+ * says so. Remembered per name until the Sandbox is destroyed, because the attach is a separate
756
+ * call. The operator takes that verdict once per pod and keeps it, so a provision re-run on
757
+ * snapshot restore reads the same one — unless the pod was replaced, which is the lease's news.
758
+ */
759
+ const staleByName = new Map<string, string>();
760
+
761
+ return {
762
+ async provision(req) {
763
+ // The fence first: a refused url costs nothing — no map read, no Secret, no CR.
764
+ const repos = fencedRepos(req.name, req.repos);
765
+ if (opts.repos === undefined) {
766
+ throw new Error(
767
+ `Sandbox "${req.name}" cannot be provisioned: this port has no Repo-resource port (ADR-0051). The ` +
768
+ "operator holds a Sandbox's Ready until every Repo it names exists as a resource, and creating " +
769
+ "them is this provision's job — build the port with `repos: kubectlRepos(...)`.",
770
+ );
771
+ }
772
+ // Read PER PROVISION, and next (ADR-0038). Not hoisted into `kubectlSandbox()`: a boot-time
773
+ // read would freeze the map for the process lifetime, which is precisely the Deployment-env
774
+ // behavior the ConfigMap mount was chosen over — the point of the mount is that a `jr2 up`
775
+ // reaches future Sandboxes without rolling the Orchestrator. Reading before the Secret apply
776
+ // also means an unknown image name costs nothing: no Secret, no CR, nothing to clean up.
777
+ const refs = await readImageRefs(imagesPath);
778
+ const cr = await crFor(req, refs, repos);
779
+
780
+ // The Repo resources, BEFORE the CR names them (ADR-0051): a bound one already exists from
781
+ // the boot and only its eviction clock moves — this run's spelling never rewrites the spec
782
+ // the boot stated; a per-run one is created here, at its first attach, and every later
783
+ // attach anywhere finds it and restates its credential, so the `git.credentials` fix
784
+ // `repoCloneError` names reaches the cache at the next run. After the image resolution, so
785
+ // a refused image still costs nothing; before the token Secret, so no Secret is minted for
786
+ // a Sandbox whose Repo could not be recorded.
787
+ for (const repo of repos) await opts.repos.ensure(repo);
788
+
789
+ await applyTokenSecret(req.name); // before the CR: the pod's Adapter mounts it at start
790
+ await exec(["apply", ...base, "-f", "-"], { input: JSON.stringify(cr) });
791
+
792
+ const applied = Date.now();
793
+ // Two budgets from one instant (see the options): the pod's until the operator has seen the
794
+ // pod Ready, the Repos' from the first poll that finds the Sandbox held on a Repo reason —
795
+ // which the operator reports only once the pod IS Ready, so the preflight has already passed
796
+ // and what remains is a clone or a fetch on the node. Sticky: a pod that came up once is not
797
+ // a pod that will never come up, whatever it does afterwards.
798
+ let held = false;
799
+ let owned = false;
800
+ let polls = 0;
801
+ for (;;) {
802
+ const status = await getSandbox(req.name);
803
+ if (!owned && status?.uid) ((owned = true), await ownSecret(req.name, status.uid));
804
+ // The pod is read at a COARSER cadence than the CR, and only while not Ready. The fault it
805
+ // looks for is terminal — the kubelet never retries out of it — so learning about it a few
806
+ // seconds late costs nothing, while reading the pod on every poll would double this port's
807
+ // API traffic for every healthy provision in the cluster.
808
+ if (status?.phase !== "Ready" && polls++ % POD_CHECK_EVERY === 0) {
809
+ const fault = await podFault(req.name);
810
+ if (fault) throw new Error(rootImageError(req.name, fault));
811
+ }
812
+ // Terminal for THIS provision: the cache agent tried to clone onto the pod's node and git
813
+ // refused. The operator's message carries the key, the node, and git's own words; the
814
+ // agent keeps retrying on its own, so `jr2 status` will show the same error until it is fixed.
815
+ const ready = conditionOf(status, "Ready");
816
+ if (status?.phase !== "Ready" && ready?.reason === REPO_CLONE_FAILED) {
817
+ throw new Error(repoCloneError(req.name, ready.message ?? ready.reason));
818
+ }
819
+ if (ready?.reason !== undefined && REPO_HELD.has(ready.reason)) held = true;
820
+ if (status?.phase === "Ready") {
821
+ // Only `phase: Ready` means serving — status.endpoint appears earlier (ADR-0001).
822
+ if (!status.endpoint) throw new Error(`Sandbox "${req.name}" is Ready but reports no endpoint`);
823
+ // Freshness degrades, absence does not (ADR-0051): Ready with `ReposFresh=False` is a
824
+ // Sandbox whose caches exist but could not be fetched since it asked. Remembered for the
825
+ // attach, which is where a slot can be named; forgotten when the caches are fresh.
826
+ const fresh = conditionOf(status, "ReposFresh");
827
+ if (fresh?.status === "False" && fresh.message) staleByName.set(req.name, fresh.message);
828
+ else staleByName.delete(req.name);
829
+ // The identity the lease will hold this workspace to (ADR-0021). Ready means the pod
830
+ // is up, so the operator has published it; an operator too old to do so leaves it
831
+ // undefined and the lease falls back to presence.
832
+ return { endpoint: status.endpoint, identity: status.podUID };
833
+ }
834
+ if (Date.now() >= applied + (held ? repoTimeoutMs : readyTimeoutMs)) {
835
+ // A Sandbox the operator held on its Repos ran out the Repo budget: the pod is up and the
836
+ // preflight passed, so the hint about the image would be a lie. What is true is the
837
+ // operator's own verdict — which Repo, on which node — and that the agent is still at it.
838
+ if (held) throw new Error(repoWaitError(req.name, repoTimeoutMs, ready));
839
+ // One last look before falling back to the hint: the fault may have appeared inside the
840
+ // final interval, and a named error beats a timeout in every case where both are true.
841
+ const fault = await podFault(req.name);
842
+ if (fault) throw new Error(rootImageError(req.name, fault));
843
+ // Otherwise name where to look, because the next most likely cause is an image that
844
+ // misses ADR-0037's floor, and that failure is an INIT container's — invisible in the
845
+ // phase alone. A musl or git-less base dies INSIDE the preflight, on jr2's own message.
846
+ // The operator's own verdict rides along when it has one: a Repo still pending on the
847
+ // node (a slow clone) reads very differently from a preflight death.
848
+ throw new Error(
849
+ `Sandbox "${req.name}" never reached Ready (last phase: ${status?.phase ?? "absent"}) — if its ` +
850
+ "Sandbox Image is new, check the preflight: `kubectl logs " +
851
+ req.name +
852
+ " -c preflight` (ADR-0037's floor: glibc, git, a writable HOME, a numeric non-root USER)." +
853
+ (ready?.message
854
+ ? `\n the operator's Ready condition says: ${ready.reason ?? "?"}: ${ready.message}`
855
+ : ""),
856
+ );
857
+ }
858
+ await sleep(pollMs);
859
+ }
860
+ },
861
+
862
+ async attach(req) {
863
+ const { script, repos, review } = attachScript(req.spec, req.repos, {
864
+ reposMount: REPOS_MOUNT,
865
+ workRoot,
866
+ // The fetch url names the Adapter only when it is somewhere unexpected (ADR-0053).
867
+ ...(adapterPort !== DEFAULT_ADAPTER_PORT ? { adapterUrl: `http://127.0.0.1:${adapterPort}` } : {}),
868
+ });
869
+ // `-c harness` is unchanged and still correct after ADR-0037: the primary container runs the
870
+ // Sandbox Image, so `git` here is the git the user chose. Never `-c user` — that seat is
871
+ // zero-contract, may hold no git at all, and jr2 commands nothing in it (ADR-0005).
872
+ await exec(["exec", `pod/${req.name}`, ...base, "-c", "harness", "--", "sh", "-ec", script]);
873
+ const stale = staleSlots(req.repos, staleByName.get(req.name));
874
+ return { repos, ...(review ? { review } : {}), ...(stale ? { stale } : {}) };
875
+ },
876
+
877
+ leaseIntervalMs,
878
+
879
+ async renew(name) {
880
+ // ONE call, both directions: `annotate --overwrite -o json` writes the stamp and prints the
881
+ // object as it stands afterwards, status included. So asserting liveness and learning
882
+ // whether the workspace survived cost exactly one API round trip (ADR-0021).
883
+ try {
884
+ const { stdout } = await exec([
885
+ "annotate",
886
+ "sandbox",
887
+ name,
888
+ ...base,
889
+ `jr2.dev/keepalive=${new Date().toISOString()}`,
890
+ "--overwrite",
891
+ "-o",
892
+ "json",
893
+ ]);
894
+ return { present: true, identity: readSandbox(stdout).podUID };
895
+ } catch (err) {
896
+ if (isNotFound(err)) return { present: false };
897
+ throw err; // anything else is UNKNOWN — the caller must not read it as loss
898
+ }
899
+ },
900
+
901
+ async destroy(name) {
902
+ staleByName.delete(name);
903
+ // The Secret is an owned child of the CR, so deleting the CR reaps it — this is belt and
904
+ // braces for the case where the ownerRef patch didn't land.
905
+ await exec(["delete", "sandbox", name, ...base, "--ignore-not-found"]);
906
+ await exec(["delete", "secret", secretName(name), ...base, "--ignore-not-found"]).catch(() => {});
907
+ },
908
+ };
909
+ }
910
+
911
+ /** The pod volume the operator defines for one Repo's node cache, and where it lands in the
912
+ * primary container (ADR-0051). Two halves of one contract with the operator, spelled here so the
913
+ * User Container's mounts and the attach's clone source agree with it by construction. */
914
+ export const repoVolumeName = (key: string): string => `repo-${key}`;
915
+ export const repoMountPath = (key: string): string => `${REPOS_MOUNT}/${key}`;
916
+
917
+ /** One Repo as the provision names it: the CR entry, plus what its resource records — the
918
+ * identity, and whether a Machine's slot (not only the run's) binds it. */
919
+ type FencedRepo = { key: string; url: string; identity: string; bound: boolean };
920
+
921
+ /** One entry of a Sandbox CR's `status.conditions`, as the operator writes it. */
922
+ type Condition = { type: string; status: string; reason?: string; message?: string };
923
+
924
+ /** The operator's Ready reason when the cache agent could not clone onto the pod's node
925
+ * (sandbox_controller.go) — the one Ready verdict a provision cannot wait out. */
926
+ const REPO_CLONE_FAILED = "RepoCloneFailed";
927
+
928
+ /** The operator's Ready reasons that hold a Sandbox whose POD is Ready on its Repos
929
+ * (sandbox_controller.go, `reposReadiness`): the resource not yet seen, or the node's cache
930
+ * agent still cloning or fetching. The wait against them is the Repo budget, not the pod's. */
931
+ const REPO_HELD: ReadonlySet<string> = new Set(["RepoMissing", "RepoPending"]);
932
+
933
+ /**
934
+ * The Repo budget ran out with the operator still holding the Sandbox. The pod is up, so the
935
+ * preflight is not the question; the verdict names the Repo and the node, and the port adds
936
+ * what the operator cannot say: the agent is still working, `jr2 status` shows it per node, and
937
+ * the budget is the port's, not the clone's.
938
+ */
939
+ function repoWaitError(name: string, budgetMs: number, ready: Condition | undefined): string {
940
+ const verdict = ready?.message ? `${ready.reason ?? "?"}: ${ready.message}` : "no Ready condition reported";
941
+ return (
942
+ `Sandbox "${name}" waited ${Math.round(budgetMs / 60_000)}m for its Repos and the operator still holds it — ` +
943
+ `${verdict} (ADR-0051). The node's cache agent clones a cold node once and fetches before every attach; ` +
944
+ "`jr2 status` reports each Repo per node. Start the run again once the cache is present, or raise " +
945
+ "the port's `repoTimeoutMs` for a repository whose clone outlasts it."
946
+ );
947
+ }
948
+
949
+ /**
950
+ * The fix beside the symptom. The operator's message carries the Repo, the node, and git's own
951
+ * words; what it cannot say is that the cache agent keeps retrying, that `jr2 status` reports the
952
+ * same line per node, or where a credential is configured (ADR-0047/0051).
953
+ */
954
+ function repoCloneError(name: string, verdict: string): string {
955
+ return (
956
+ `Sandbox "${name}" cannot start: ${verdict} (ADR-0051). The node's cache agent keeps retrying on its own — ` +
957
+ "fix the url or its git.credentials entry (an ssh url needs its deploy key registered with the host, " +
958
+ "ADR-0047), then start the run again; `jr2 status` reports the same error per node until it clears."
959
+ );
960
+ }
961
+
962
+ /**
963
+ * The `ReposFresh=False` message, keyed back to SLOTS for the attach's `stale`. The operator
964
+ * writes one clause per stale Repo — `Repo "<key>" on node <n> is stale: <error>` — joined by
965
+ * `; `; each slot whose key a clause names gets that clause. A message that names no key at all
966
+ * lands on every slot: a verdict with no address is still a verdict.
967
+ */
968
+ function staleSlots(
969
+ repos: Array<{ slot: string; url: string }>,
970
+ message: string | undefined,
971
+ ): Record<string, string> | undefined {
972
+ if (!message) return undefined;
973
+ const byKey = new Map<string, string>();
974
+ for (const clause of message.split("; ")) {
975
+ const key = /^Repo "([^"]+)"/.exec(clause)?.[1];
976
+ if (key !== undefined) byKey.set(key, clause);
977
+ }
978
+ const stale: Record<string, string> = {};
979
+ for (const repo of repos) {
980
+ const clause = byKey.size === 0 ? message : byKey.get(repoIdentity(repo.url).key);
981
+ if (clause !== undefined) stale[repo.slot] = clause;
982
+ }
983
+ return Object.keys(stale).length ? stale : undefined;
984
+ }
985
+
986
+ /**
987
+ * The post-Ready attach step as one idempotent in-pod script (ADR-0004, ADR-0051): per Repo Slot
988
+ * in declaration order, a pod-local `git clone --shared` borrowing objects from the node's
989
+ * read-only cache at `/repos/<key>` — `<slot>/default/`, a checkout of the Repo's default branch
990
+ * — then the branch worktree as a sibling (gwtmux layout: `<slot>/default/` + `<slot>/<branch>/`). With a `reviewSha`, also the detached review worktree
991
+ * (ADR-0028) — another sibling. `repos` keeps the slots' declaration order. Exported for the port's
992
+ * tests; the workflow never sees it.
993
+ */
994
+ export function attachScript(
995
+ spec: WorkspaceSpec,
996
+ repos: Array<{ slot: string; url: string; ref?: string }>,
997
+ paths: { reposMount: string; workRoot: string; adapterUrl?: string },
998
+ ): { script: string; repos: Record<string, string>; review?: Record<string, string> } {
999
+ const worktrees: Record<string, string> = {};
1000
+ const review: Record<string, string> = {};
1001
+ // The cache is written by the node's cache agent and read here as the Harness's unprivileged
1002
+ // uid (ADR-0001/0004/0051), so git's dubious-ownership guard would refuse the clone source.
1003
+ // safe.directory is only honored from global/system config (never `-c`), and inside the pod
1004
+ // every path is jr2-owned — trusting them all is the honest scope.
1005
+ // The attach runs via exec, not as a child of the Harness process, so it does NOT inherit the
1006
+ // Harness's `umask 002` — without its own, the repo roots it mkdirs land 755 and the work group
1007
+ // could never create a file at a tree's top. INSIDE the trees the umask stops mattering: the
1008
+ // default ACL stamped below governs everything created beneath a repo root (ADR-0005).
1009
+ const lines: string[] = [`umask 002`, `git config --global safe.directory '*'`];
1010
+ const branchDir = spec.branch.replace(/\//g, "-");
1011
+ for (const repo of repos) {
1012
+ const slotDir = `${paths.workRoot}/${repo.slot}`;
1013
+ const dflt = `${slotDir}/default`;
1014
+ const worktree = `${slotDir}/${branchDir}`;
1015
+ const { identity, key } = repoIdentity(repo.url);
1016
+ const cache = `${paths.reposMount}/${key}`;
1017
+ worktrees[repo.slot] = worktree;
1018
+ lines.push(
1019
+ `mkdir -p ${sq(slotDir)}`,
1020
+ // BEFORE the clone fills it: a default ACL is inherited at creation, never retrofitted, so
1021
+ // the stamp must exist while the tree is still empty. From here down, both seats' files land
1022
+ // group-writable with zero umask lines in any image (ADR-0005); on a filesystem without
1023
+ // POSIX ACLs the helper warns and exits 0, degrading to the umask sharing above.
1024
+ `/opt/jr2/bin/work-acl ${sq(slotDir)}`,
1025
+ `[ -d ${sq(`${dflt}/.git`)} ] || git clone --shared ${sq(cache)} ${sq(dflt)}`,
1026
+ // No ref → the Repo's own default branch: this clone's `origin/HEAD` tracks the cache's
1027
+ // HEAD, which the cache agent's clone pointed at the remote's default (ADR-0004).
1028
+ `[ -d ${sq(worktree)} ] || git -C ${sq(dflt)} worktree add ${sq(worktree)} -b ${sq(spec.branch)} ${repo.ref === undefined ? sq("origin/HEAD") : baseOf(dflt, repo.ref)}`,
1029
+ // Fetch/push split (ADR-0005). The FETCH url is a command, not a path (ADR-0053): git's
1030
+ // built-in `ext::` transport runs the program on the runtime volume, which asks the node
1031
+ // cache to fetch the remote, waits for the landing, then serves the cache — so every fetch
1032
+ // inside the pod is a fetch of the remote's now, and a stale attach is stale only until the
1033
+ // next fetch anyone in the pod runs. Git substitutes `%S` with the service it wants
1034
+ // (`git-upload-pack`), and splits the rest on spaces with no quoting of its own, so the
1035
+ // url's arguments carry none: the Repo's IDENTITY, never the cache key — that is the name a
1036
+ // human reads in `git remote -v`, and a key is a derived directory name (ADR-0004). The
1037
+ // program discovers the cache from the checkout's alternates, so the url says nothing about
1038
+ // where the objects are. `git push` goes to the REAL remote — the Binding's own spelling, so
1039
+ // a Machine that bound over ssh pushes over ssh even when the cache was cloned over https
1040
+ // (ADR-0051). Push still succeeds only with a caller-supplied credential (a forwarded agent
1041
+ // in the User Container); the pod itself holds none. `--` keeps the url an operand, never an
1042
+ // option.
1043
+ `git -C ${sq(dflt)} remote set-url origin -- ${sq(fetchUrl(identity, paths.adapterUrl))}`,
1044
+ `git -C ${sq(dflt)} remote set-url --push origin -- ${sq(repo.url)}`,
1045
+ // `ext` is on git's own "known scary" list, so its built-in default is `never` and the url
1046
+ // above would die with `fatal: transport 'ext' not allowed` before the program ever ran.
1047
+ // `user` is the policy ADR-0053 argues for, said out loud: a fetch A PERSON OR THE AGENT
1048
+ // runs is allowed, and a recursive one git makes for itself (a submodule url, anything with
1049
+ // `GIT_PROTOCOL_FROM_USER=0`) is still refused — so a repository cannot smuggle a program
1050
+ // into this pod through a url jr2 did not write. Repo-level, on the pod-local clone: the
1051
+ // linked worktrees share this config, so the branch worktree and the review worktree inherit
1052
+ // it with no env and no `--global`.
1053
+ `git -C ${sq(dflt)} config protocol.ext.allow user`,
1054
+ );
1055
+ if (spec.reviewSha) {
1056
+ // The reviewer's seat (ADR-0028): a DETACHED HEAD at the sha under review, so a rogue write
1057
+ // cannot move the branch and a rogue commit evaporates with the checkout. Forced checkout
1058
+ // AND clean on every attach: a previous round's rogue edits (tracked) and leftovers
1059
+ // (untracked) must not survive into this round — the review worktree's contents are the
1060
+ // sha under review, period.
1061
+ const reviewDir = `${worktree}-review`;
1062
+ review[repo.slot] = reviewDir;
1063
+ lines.push(
1064
+ `[ -d ${sq(reviewDir)} ] || git -C ${sq(dflt)} worktree add --detach ${sq(reviewDir)} ${sq(spec.reviewSha)}`,
1065
+ `git -C ${sq(reviewDir)} checkout --detach -f ${sq(spec.reviewSha)}`,
1066
+ `git -C ${sq(reviewDir)} clean -fd`,
1067
+ );
1068
+ }
1069
+ }
1070
+ if (repos.length === 0)
1071
+ throw new Error("the attach names no Repo Slot — nothing to attach (a workspace() declares at least one)");
1072
+ return {
1073
+ script: lines.join("\n"),
1074
+ repos: worktrees,
1075
+ ...(spec.reviewSha ? { review } : {}),
1076
+ };
1077
+ }
1078
+
1079
+ /**
1080
+ * `origin`'s fetch url for one Repo (ADR-0053): the `ext::` transport, the program's absolute path
1081
+ * on the runtime volume, the service git asks for, and the Repo's identity. The Adapter's address
1082
+ * rides as a third argument only when the composition moved the Adapter off its default port —
1083
+ * the program reads `$JR2_ADAPTER_URL` and then falls back to that same address, so spelling it out
1084
+ * unconditionally would put a number in every `git remote -v` that says nothing.
1085
+ */
1086
+ function fetchUrl(identity: string, adapterUrl?: string): string {
1087
+ return `ext::${UPLOAD_PACK} %S ${extArg(identity)}${adapterUrl !== undefined ? ` ${extArg(adapterUrl)}` : ""}`;
1088
+ }
1089
+
1090
+ /**
1091
+ * One argument of an `ext::` url, in git's own escaping. Git splits the url on spaces and reads
1092
+ * `%` as a placeholder introducer — `%S` is the service it substitutes — so it DIES on a `%` it
1093
+ * does not recognize (`fatal: Bad remote-ext placeholder '%2'`) and silently splits an argument
1094
+ * that holds a space. An identity carries both: a forge path may be percent-encoded
1095
+ * (`dev.azure.com/org/My%20Project/_git/repo`) and an scp-style url may hold a literal space. Git
1096
+ * spells those two `%%` and `% `, and the program receives the identity back exactly as written —
1097
+ * which it must, because the Orchestrator derives the cache key from that same string. The
1098
+ * placeholder jr2 writes itself (`%S`) is not escaped: it is git's, not an argument's.
1099
+ */
1100
+ function extArg(value: string): string {
1101
+ return value.replace(/%/g, "%%").replace(/ /g, "% ");
1102
+ }
1103
+
1104
+ /**
1105
+ * The commit-ish a Binding's `ref` names inside the pod-local clone, as a shell expression: the
1106
+ * remote-tracking branch `refs/remotes/origin/<ref>` when the clone has one, else `<ref>` as
1107
+ * written (a tag, a sha). A fresh clone holds ONE local branch — the default — so a bare
1108
+ * branch name is never a local ref here, and git's "worktree add" DWIM would then create the BASE
1109
+ * branch tracking `origin/<ref>` and discard `-b`: the Agent would commit on, and push to, the base
1110
+ * it was meant to branch FROM. Naming the remote-tracking ref outright leaves nothing to guess.
1111
+ */
1112
+ function baseOf(dflt: string, ref: string): string {
1113
+ const remote = sq(`refs/remotes/origin/${ref}`);
1114
+ return `"$(git -C ${sq(dflt)} rev-parse --verify -q ${remote} >/dev/null && printf %s ${remote} || printf %s ${sq(ref)})"`;
1115
+ }
1116
+
1117
+ /** POSIX single-quote an argument for the in-pod `sh -ec` script. */
1118
+ function sq(s: string): string {
1119
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1120
+ }
1121
+
1122
+ function isNotFound(err: unknown): boolean {
1123
+ return err instanceof Error && /NotFound|not found/i.test(err.message);
1124
+ }
1125
+
1126
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
1127
+
1128
+ /** The `kubectl` on PATH, as every kubectl-driven port shells to it (this one and repos.ts). */
1129
+ export const defaultKubectlExec: KubectlExec = (args, opts) =>
1130
+ new Promise((resolve, reject) => {
1131
+ const child = execFile("kubectl", args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
1132
+ if (err) reject(new Error(`kubectl ${args[0]} failed: ${stderr || err.message}`));
1133
+ else resolve({ stdout, stderr });
1134
+ });
1135
+ if (opts?.input !== undefined) child.stdin?.end(opts.input);
1136
+ });