@edgehero/pi-dispatch 1.10.2 → 2.0.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 (97) hide show
  1. package/.env.example +300 -148
  2. package/README.md +50 -0
  3. package/deploy/com.pi-dispatch.worker.plist +9 -3
  4. package/deploy/docker-compose.yml +49 -16
  5. package/deploy/egress-proxy.conf +32 -2
  6. package/deploy/nssm-install.cmd +12 -6
  7. package/deploy/pi-dispatch-egress-out.network +10 -0
  8. package/deploy/pi-dispatch-egress-proxy.container +50 -0
  9. package/deploy/pi-dispatch-netns-keeper.container +80 -0
  10. package/deploy/pi-dispatch-netns-keeper.network +18 -0
  11. package/deploy/pi-dispatch-valkey.container +51 -0
  12. package/deploy/pi-dispatch-valkey.network +16 -0
  13. package/deploy/receiver.service +6 -0
  14. package/deploy/worker-env-wrapper.cmd +11 -0
  15. package/deploy/worker-env-wrapper.sh +60 -34
  16. package/deploy/worker.service +18 -8
  17. package/package.json +14 -4
  18. package/src/azure-host.mjs +19 -0
  19. package/src/azure-identity.mjs +18 -2
  20. package/src/backend-conformance.mjs +71 -18
  21. package/src/backend-local.mjs +637 -21
  22. package/src/backend-podman.mjs +1168 -0
  23. package/src/backend-registry.mjs +86 -3
  24. package/src/backends.mjs +489 -37
  25. package/src/branch.mjs +7 -2
  26. package/src/cancel-cli.mjs +174 -0
  27. package/src/cancel-state.mjs +125 -0
  28. package/src/cli.mjs +188 -90
  29. package/src/config.mjs +503 -43
  30. package/src/connection.mjs +374 -8
  31. package/src/container-spec.mjs +102 -7
  32. package/src/daemon-facts.mjs +167 -0
  33. package/src/deployment-venue.mjs +158 -0
  34. package/src/docker-run.mjs +146 -15
  35. package/src/doctor.mjs +4701 -414
  36. package/src/egress-conf-copy.mjs +166 -0
  37. package/src/egress-proxy-state.mjs +151 -0
  38. package/src/egress.mjs +455 -25
  39. package/src/entry.mjs +27 -0
  40. package/src/env-allowlist.mjs +222 -40
  41. package/src/env-file.mjs +1869 -33
  42. package/src/exit-code.mjs +15 -0
  43. package/src/flow-gate.mjs +5 -3
  44. package/src/forgejo-host.mjs +19 -0
  45. package/src/forgejo-identity.mjs +21 -2
  46. package/src/get-token.mjs +67 -18
  47. package/src/git-dirty.mjs +9 -1
  48. package/src/git-hardening.mjs +33 -0
  49. package/src/github-app-setup.mjs +29 -12
  50. package/src/github-prompt.mjs +4 -1
  51. package/src/gitlab-host.mjs +19 -0
  52. package/src/gitlab-identity.mjs +19 -2
  53. package/src/host-registry.mjs +32 -5
  54. package/src/identity.mjs +29 -4
  55. package/src/image-preflight.mjs +46 -11
  56. package/src/image-ref.mjs +21 -0
  57. package/src/index.mjs +387 -17
  58. package/src/init.mjs +197 -38
  59. package/src/job-user.mjs +252 -0
  60. package/src/json-duplicates.mjs +204 -0
  61. package/src/live-probes.mjs +1020 -0
  62. package/src/materialize.mjs +4 -11
  63. package/src/netns-keeper.mjs +264 -0
  64. package/src/on-failure.mjs +119 -0
  65. package/src/outbox.mjs +7 -0
  66. package/src/podman-stack.mjs +1304 -0
  67. package/src/prepare-github.mjs +6 -6
  68. package/src/prepare-local.mjs +51 -17
  69. package/src/prepare.mjs +27 -6
  70. package/src/processor.mjs +505 -26
  71. package/src/provider-key.mjs +41 -0
  72. package/src/provider-steering.mjs +144 -0
  73. package/src/queue.mjs +35 -8
  74. package/src/redact.mjs +84 -0
  75. package/src/reserved-env.mjs +7 -3
  76. package/src/retention-sweep.mjs +178 -0
  77. package/src/run-container.mjs +181 -14
  78. package/src/run-history.mjs +105 -16
  79. package/src/runtime-observations.mjs +1152 -0
  80. package/src/runtime-settings.mjs +13 -8
  81. package/src/sandbox-cli.mjs +100 -95
  82. package/src/sandbox-store.mjs +612 -45
  83. package/src/sandbox.mjs +1459 -37
  84. package/src/schedules.mjs +16 -3
  85. package/src/secret-profiles.mjs +2 -1
  86. package/src/secrets.mjs +23 -6
  87. package/src/service-env.mjs +247 -0
  88. package/src/service.mjs +618 -28
  89. package/src/session-store.mjs +678 -53
  90. package/src/start.mjs +1395 -268
  91. package/src/transient.mjs +240 -0
  92. package/src/triggers-file.mjs +71 -15
  93. package/src/triggers.mjs +176 -19
  94. package/src/up.mjs +1399 -85
  95. package/src/valkey-auth.mjs +529 -0
  96. package/src/valkey-endpoint.mjs +367 -0
  97. package/src/watch-closer.mjs +158 -0
@@ -23,14 +23,47 @@
23
23
 
24
24
  import { execFile } from "node:child_process";
25
25
  import { promisify } from "node:util";
26
+ import { scrubCredentials } from "./redact.mjs";
26
27
  import { BACKENDS, DEFAULT_BACKEND, DOCKER_NEVER_STARTED_EXITS } from "./backends.mjs";
28
+ import { DEFAULT_EGRESS_PROXY, ENDPOINT_LISTED_STATES, networkEndpoints, removeNetworkOrSay } from "./egress.mjs";
29
+ import { makeDetachGate } from "./netns-keeper.mjs";
30
+ import { isDeterminateFsCode } from "./transient.mjs";
27
31
 
28
32
  const execDocker = promisify(execFile);
29
33
 
34
+ /**
35
+ * The boot reaper's step bound (issue #452, gate round 2). Its `exec` had none, so a wedged daemon held the worker's boot
36
+ * with nothing said. 30 s, `RUN_TIMEOUTS.cmd`'s bound for one CLI step, and not the sandbox sweep's 10 s: Podman's `rm
37
+ * -f` of a running container waits its stop timeout (10 s by default, measured 10.1 s on 5.8.1) before SIGKILL, and the
38
+ * container half removes RUNNING job containers. A step killed by the bound rejects like a failed one: in the container
39
+ * half that is the conservative `{ reaped: false }`, in the network half an unreadable or failed step.
40
+ */
41
+ export const REAPER_STEP_TIMEOUT_MS = 30_000;
42
+
43
+ /**
44
+ * The reaper's default `exec`: `promisify(execFile)`'s contract (resolves `{ stdout, stderr }`, rejects with the exit
45
+ * code on `.code` and both streams on the error), with a bound. A step the bound kills is SIGKILLed and rejects with
46
+ * `killed: true` and no numeric code, which every caller already reads as no answer.
47
+ */
48
+ export function reaperExec({ execFileFn = execFile, timeoutMs = REAPER_STEP_TIMEOUT_MS } = {}) {
49
+ // `opts` (issue #452, gate round 4): a step may ask for its own bound, as the detach gate's runtime read does (15 s,
50
+ // 1 MiB, the facts readers' own); every other step keeps the reaper's 30 s.
51
+ return (bin, args, opts = {}) =>
52
+ new Promise((resolve, reject) => {
53
+ execFileFn(bin, [...args], { timeout: opts.timeoutMs ?? timeoutMs, killSignal: "SIGKILL", maxBuffer: opts.maxBuffer ?? 1024 * 1024 }, (err, stdout, stderr) => {
54
+ if (err) reject(Object.assign(err, { stdout: String(stdout ?? ""), stderr: String(stderr ?? "") }));
55
+ else resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
56
+ });
57
+ });
58
+ }
59
+ const execReaperBounded = reaperExec();
60
+
30
61
  /**
31
62
  * `pi-job-` -- the container-name namespace, and a LOAD-BEARING string rather than a prefix chosen for
32
63
  * readability. TWO sweeps match it as a SUBSTRING, both at boot in `start.mjs`: the container reaper's
33
- * `docker ps` filter and the network reaper's `docker network ls` filter. The sandbox tooling is the
64
+ * `docker ps` filter and the network reaper's `docker network ls` filter. They share the NAME rule
65
+ * (`isJobNamespace`) and deliberately not the STATE question -- see `reapNetwork` for why one lists running
66
+ * containers and the other asks about members in any state. The sandbox tooling is the
34
67
  * counterpart rather than a third sweep -- it names itself `pi-sandbox-` precisely to stay OUTSIDE this
35
68
  * namespace, so a worker restart cannot tear down the shell an operator is sitting in, and it reaps its own
36
69
  * by job id rather than by name.
@@ -42,15 +75,33 @@ const execDocker = promisify(execFile);
42
75
  */
43
76
  export const JOB_NAME_PREFIX = "pi-job-";
44
77
 
78
+ /**
79
+ * The container states `docker network inspect` lists in `.Containers`, and therefore the ones the daemon
80
+ * itself guards: with one attached, `network rm` fails "has active endpoints" (measured, docker 27.4.0).
81
+ * `paused` is here because it is listed and the `rm` refuses while it is there.
82
+ *
83
+ * DEFINED in `egress.mjs` since issue #452 and re-exported here under its old name: `networkEndpoints` now
84
+ * sorts Podman's members by the same set, so it is one fact with two readers, and `egress.mjs` cannot import
85
+ * from this module without a cycle. Its reasons (an allowlist, and why `restarting` is not in it) are there.
86
+ */
87
+ export { ENDPOINT_LISTED_STATES };
88
+
45
89
  /**
46
90
  * `pi-job-<jobId>`. The name a running job answers to, for `docker stop` on the 30-minute timeout, for the
47
91
  * per-job egress network derived from it, and for the reaper's filter.
48
92
  *
49
- * Not sanitised here: BullMQ ids are already `[A-Za-z0-9._-]`, and the one place a job id comes from
50
- * anywhere else (a sandbox) goes through `sanitizeJobId` under its own prefix.
93
+ * Sanitised to the runtimes' own name rule, `[a-zA-Z0-9][a-zA-Z0-9_.-]*` (docker's `RestrictedNameChars`, and
94
+ * Podman's, measured), every other character becoming `_`, as `sanitizeJobId` does for file names. It said here that
95
+ * BullMQ ids are already that shape, and a cron job's is not: the job scheduler mints `repeat:<schedulerId>:<millis>`,
96
+ * so `pi-job-repeat:...` was refused at create (exit 125, measured on Podman 5.8.1) and so was its `-net` network, and
97
+ * every cron job ended `container-never-started` before it ever ran (issue #435). The prefix supplies the first
98
+ * character. Deterministic, because every path that must find the container again (the timeout's stop, the cancel,
99
+ * the network, the log sink) asks this function for the name rather than deriving it. Not injective: `repeat:a:1`
100
+ * and a literal `repeat_a_1` share a name. BullMQ ids are unique per queue and no producer here mints an id with `_`
101
+ * where a scheduler id has `:`, so it is a residual rather than a collision anything reaches.
51
102
  */
52
103
  export function jobContainerName(jobId) {
53
- return `${JOB_NAME_PREFIX}${jobId}`;
104
+ return `${JOB_NAME_PREFIX}${String(jobId).replace(/[^A-Za-z0-9._-]/g, "_")}`;
54
105
  }
55
106
 
56
107
  /**
@@ -158,12 +209,198 @@ export const LOCAL_NEVER_STARTED_EXITS = DOCKER_NEVER_STARTED_EXITS;
158
209
  * 137. An adapter implements "stop this job" however its runtime spells it and the classification is
159
210
  * unchanged.
160
211
  */
161
- export function makeStopContainer({ exec = execDocker } = {}) {
212
+ export function makeStopContainer({ exec = execDocker, bin = "docker" } = {}) {
213
+ // Issue #354: `bin` is the venue's CLI. A stop sent to a runtime other than the one that runs the job answers "no such
214
+ // container" while the job runs on, which is the unstoppable runaway this function exists to rule out.
162
215
  return async function stopContainer(name) {
163
- return exec("docker", ["stop", "-t", "5", name]);
216
+ return exec(bin, ["stop", "-t", "5", name]);
164
217
  };
165
218
  }
166
219
 
220
+ /**
221
+ * What the CLI resolved, as a phrase that is never EMPTY. Four call sites interpolate an endpoint into a
222
+ * sentence of the form "resolves <this>, which is not shown to be on this host", and a context can carry no
223
+ * host at all: `docker context create X --docker host=` is accepted by docker 27.4.0 (exit 0, "Successfully
224
+ * created context"), and `context inspect` then renders the Host as `""`. `classifyDockerEndpoint("")`
225
+ * answers `{ local: false, display: "" }`, which is the right answer to its own question, and every one of
226
+ * those sites then printed "resolves , which is not shown to be on this host" at an operator.
227
+ *
228
+ * A PHRASE rather than a fallback value, so no caller can mistake what it returns for something to hand to
229
+ * docker. WHITESPACE counts as empty: `docker context create` refuses a blank host, but `context inspect`,
230
+ * which is the command doctor actually runs, does NOT re-validate what the context store already holds, so
231
+ * the read path can return one.
232
+ *
233
+ * ANYTHING NOT PLAINLY PRINTABLE IS QUOTED AND ESCAPED, never removed, and the difference is the whole of
234
+ * this function's second job. `displayEndpoint` returns a host with no `@` VERBATIM -- it withholds
235
+ * credentials and was never a sanitiser -- so whatever a context store holds reaches a line an operator
236
+ * reads, and an erase-line plus a carriage return wipes the warning and rewrites it from column 0.
237
+ *
238
+ * A first attempt STRIPPED those bytes, and stripping is the wrong rule in both directions, measured on
239
+ * docker 27.4.0. It FORGES: `docker context create` accepts a C1 byte, so a stored
240
+ * `tcp://127.0.0.1<U+0085>:2375` is correctly classified NOT local (the parser percent-encodes it into the
241
+ * hostname) and then printed as a clean loopback address, giving a sentence that contradicts itself and a
242
+ * value that survives copy, paste and grep as something the operator never configured. And it CORRUPTS: a
243
+ * unix socket really can live at a path containing one (created, resolved and dialled on this host), and
244
+ * stripping renames it to a path that does not exist -- while `job-user.mjs`, `sandbox.mjs` and
245
+ * `runtime-observations.mjs` go on reading the UNSTRIPPED value as a real filesystem path, so doctor would
246
+ * name one file and the system use another.
247
+ *
248
+ * So: printable ASCII (U+0020 to U+007E) passes through untouched, and anything else is rendered as a
249
+ * quoted, fully escaped string, which one `JSON.parse` turns back into what was stored. Nothing is deleted,
250
+ * which is the property that matters, and it disarms the whole class at once rather than one codepoint
251
+ * range of it, so a right-to-left override, a zero-width joiner and a line separator are as visible as an
252
+ * ESC. U+007F is on the escaped side of the boundary with the C1 block, which is why the range ends at 7E.
253
+ *
254
+ * TWO LIMITS, stated because "lossless" on its own would overstate them. The mapping is not INJECTIVE: a
255
+ * stored value whose printable text happens to be a quoted escape sequence renders byte-identically to the
256
+ * escaped form of the value it describes, so an operator cannot tell whose quotes they are. Reachable only
257
+ * through the unvalidated read path, since `context create` refuses a quote-wrapped host. And a value that
258
+ * is only WHITESPACE, control whitespace such as a lone carriage return or tab included, is reported as
259
+ * empty rather than escaped: `trim()` decides that, and it is a deliberate simplification rather than an
260
+ * oversight, because an endpoint of one tab is empty in every way an operator cares about. The earlier
261
+ * comment here also claimed docker's URL parser kept control bytes out, having measured `context create`
262
+ * and `DOCKER_HOST`: both are WRITE paths, the read path does not re-validate, and C1 is accepted on the
263
+ * write path anyway, so the claim was wrong on its own terms as well as measured on the wrong command.
264
+ *
265
+ * RESIDUAL, named rather than closed: a stored endpoint whose value IS the literal text "an empty endpoint"
266
+ * is indistinguishable from the empty case. `docker context create` refuses it (no scheme), and it costs one
267
+ * sentence misread.
268
+ */
269
+ export function endpointShown(endpoint) {
270
+ const shown = String(endpoint?.endpoint ?? "");
271
+ if (shown.trim() === "") return "an empty endpoint";
272
+ if (shown.length <= ENDPOINT_SHOWN_MAX && /^[\x20-\x7e]+$/.test(shown)) return shown;
273
+ return boundedShown(shown);
274
+ }
275
+
276
+ /**
277
+ * The same escaping, always quoted, for a value that is not an endpoint: a context NAME.
278
+ *
279
+ * `JSON.stringify` was what printed those, and it escapes C0 and stops: a C1 CSI byte, a right-to-left
280
+ * override and a zero-width joiner all reached the terminal through it. This is the same class the endpoint
281
+ * itself is held to, so the two halves of `resolves context X to Y` cannot be protected differently.
282
+ *
283
+ * It must agree with `JSON.stringify` on the ordinary cases, because existing pins read that shape:
284
+ * `""` for the empty string, and `null`/`undefined` rendered as themselves.
285
+ */
286
+ export function quotedShown(value) {
287
+ if (value === null || value === undefined) return String(value);
288
+ const shown = String(value);
289
+ // The CAP IS ON THE RENDERED TEXT, quotes included, which the fast path got wrong: 300 printable
290
+ // characters render as 302, and a value of 300 quote characters as 602. A 300-character context name is
291
+ // creatable (`docker context create` accepts one, measured), so this was reachable.
292
+ const quoted = JSON.stringify(shown);
293
+ if (quoted.length <= ENDPOINT_SHOWN_MAX && /^[\x20-\x7e]*$/.test(shown)) return quoted;
294
+ return boundedShown(shown);
295
+ }
296
+
297
+ /**
298
+ * 300 characters of RENDERED text, and the number is a literal rather than a computation.
299
+ *
300
+ * A DNS name is at most 253 bytes plus a scheme and a port, and a unix socket path is capped at 104 bytes on
301
+ * macOS and 108 on Linux, so no PRINTABLE endpoint a daemon can actually have reaches this. A 104-byte
302
+ * socket path made entirely of C1 bytes renders to about 321 characters and IS cut, which is the honest
303
+ * statement of the bound: it cuts escaped text, not real endpoints.
304
+ */
305
+ export const ENDPOINT_SHOWN_MAX = 300;
306
+
307
+ /**
308
+ * Escape, then cut -- and the ORDER is the opposite of `SCRUBBED_MAX`'s, deliberately.
309
+ *
310
+ * Cutting first and escaping after can land the cut inside a `\u00e9` and produce text no `JSON.parse` will
311
+ * read. Escaping first and cutting after can do the same. So this walks CODE POINTS, escapes each one, and
312
+ * stops BEFORE the rendered text would pass the cap -- the quoted part is therefore always parseable and
313
+ * always a PREFIX of the input, which is the property an operator needs when they compare it to what they
314
+ * configured.
315
+ *
316
+ * Per code point, not per UTF-16 unit: iterating units cuts between surrogates and emits a lone one (1,537
317
+ * of them in a 50k-case fuzz of the version this replaced). And each escape is built from `JSON.stringify`
318
+ * of the whole code point rather than `codePointAt(0).toString(16)`, which emits `\u1f600`-shaped text for
319
+ * an astral character -- that parses as `\u1f60` followed by a literal `0`, so the result was not a prefix
320
+ * of anything (16,916 fuzz failures).
321
+ */
322
+ function boundedShown(value) {
323
+ let out = "";
324
+ let kept = 0;
325
+ let cut = false;
326
+ const points = [...value];
327
+ for (const point of points) {
328
+ const piece = /^[\x20-\x7e]$/.test(point) ? (point === '"' || point === "\\" ? `\\${point}` : point) : escapedPoint(point);
329
+ // +2 for the quotes this will be wrapped in. Stop BEFORE passing the cap, never after.
330
+ if (out.length + piece.length + 2 > ENDPOINT_SHOWN_MAX) {
331
+ cut = true;
332
+ break;
333
+ }
334
+ out += piece;
335
+ kept += 1;
336
+ }
337
+ // The count outside the quotes, so the quoted part stays exactly what `JSON.parse` will take.
338
+ return cut ? `"${out}" (first ${kept} of ${points.length} characters)` : `"${out}"`;
339
+ }
340
+
341
+ function escapedPoint(point) {
342
+ // PER UTF-16 UNIT, which is what `\uXXXX` means. Escaping the CODE POINT instead emits `\u1f600` for an
343
+ // astral character -- five hex digits, which `JSON.parse` reads as `\u1f60` followed by a literal `0`, so
344
+ // the result is not the value and not a prefix of it (16,916 failures in a 50,000-case fuzz). Both halves
345
+ // of a surrogate pair are always emitted together, because the caller walks whole code points, so this
346
+ // can never leave a lone surrogate behind.
347
+ // JSON's OWN SHORT ESCAPES for the five it has (`\b \t \n \f \r`), because the claim this renderer makes
348
+ // is that an under-cap value renders byte-identically to what `JSON.stringify` produced before the bound
349
+ // existed -- and `\u0009` for a tab is not that. Everything else is escaped per UTF-16 unit below.
350
+ const SHORT = { "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r" };
351
+ let out = "";
352
+ for (let i = 0; i < point.length; i++) {
353
+ const unit = point.charCodeAt(i);
354
+ out += SHORT[point[i]] ?? (unit >= 0x20 && unit <= 0x7e ? point[i] : `\\u${unit.toString(16).padStart(4, "0")}`);
355
+ }
356
+ return out;
357
+ }
358
+
359
+ /**
360
+ * Is this name one THIS project claims? ONE answer for both halves of the boot reaper, which is the whole
361
+ * point of it being a function rather than two tests (issue #360, item 7).
362
+ *
363
+ * It is the bare prefix, and it is NOT an accident that it does not require `-net` of a network. The two
364
+ * halves used to disagree: the container half was this `startsWith` and the network half was
365
+ * `^pi-job-.*-net$`, so an operator's `pi-job-runner_default` had its CONTAINER reaped and its NETWORK left
366
+ * standing. Two answers to "what is ours" is the defect; which answer to keep is the decision, and the
367
+ * container half cannot be the one that moves. After a crash nothing distinguishes our `pi-job-<id>` from
368
+ * any other name under the prefix, and a charset rule does not separate them either. `jobContainerName` maps a
369
+ * job id onto `[A-Za-z0-9._-]` (since issue #435, when a cron id's `:` was found to be refused by the runtime), so
370
+ * `runner_default` and `runner-db-1` are both shapes a real job's name can take. There is no stricter rule
371
+ * available that is also TRUE, so the halves agree by widening the network one.
372
+ *
373
+ * WHAT THAT COSTS, stated rather than buried in a test diff: a network called `pi-job-mine-net-backup`, or
374
+ * `pi-job-runner_default`, is now removed by the boot reaper. Their CONTAINERS always were. `SECURITY.md`
375
+ * states "the `pi-job-*` namespace the boot reaper clears" as a deliberate claim, so this makes the code
376
+ * agree with the claim rather than extending it -- but the objects are an operator's, so it is named in the
377
+ * commit, in `SECURITY.md` and in the spec, not only here.
378
+ *
379
+ * The prefix is still only half the rule at the call sites. `docker`'s `--filter name=` is a SUBSTRING match
380
+ * (measured on 27.4.0: `my-pi-job-notes` comes back from `--filter name=pi-job-`), so the filter is the cheap
381
+ * server-side narrowing and this anchored test is the namespace decision. A name that merely CONTAINS the
382
+ * prefix is not ours and never was.
383
+ *
384
+ * Not a constant called `_SHAPE`: a name that says "shape" while holding a prefix test is a name that lies,
385
+ * and the previous one did.
386
+ *
387
+ * The `typeof` guard is UNREACHABLE from both production call sites, and saying so is the point rather than
388
+ * claiming a coverage it does not have: the container listing is `stdout.split("\n").map(trim)`, and
389
+ * `networkEndpoints` already coerces every endpoint with `String(c?.Name ?? "")` before returning, so
390
+ * neither can hand this a non-string. It is here because this is EXPORTED, and a predicate that throws on a
391
+ * value it should simply answer `false` to is a trap for the next caller -- inside `makeReaper` that throw
392
+ * reaches the outer catch and answers `{ reaped: false }`, which is a money decision. Measured: dropping the
393
+ * guard is caught by the unit table below and by no call-site test.
394
+ *
395
+ * `SANDBOX_NETWORK_SHAPE` is the sibling that must NOT be loosened the same way, and the difference is what
396
+ * the name is FOR rather than taste: it carries a capture group and the sandbox sweep parses the session id
397
+ * back out of it to key against the directories it kept. A predicate cannot answer that question, so the
398
+ * sandbox's full shape is load-bearing where this one's was only a filter.
399
+ */
400
+ export function isJobNamespace(name) {
401
+ return typeof name === "string" && name.startsWith(JOB_NAME_PREFIX);
402
+ }
403
+
167
404
  /**
168
405
  * Boot-time reaper: clear stray `pi-job-*` containers a previous worker crash left behind.
169
406
  *
@@ -177,34 +414,162 @@ export function makeStopContainer({ exec = execDocker } = {}) {
177
414
  * for containers that may still be running and let another host start more alongside them. That is a spend
178
415
  * overrun rather than a tidy-up, which is why the catch below returns false rather than swallowing.
179
416
  */
180
- export function makeReaper({ log, exec = execDocker }) {
417
+ export function makeReaper({ log, exec = execReaperBounded, bin = "docker" }) {
418
+ // Issue #354: every spawn below names `bin`, the venue's CLI, so a podman venue's reaper enumerates, removes and
419
+ // sweeps in the store its jobs actually ran in. A mixed pass (listing under one binary, removing under another) would
420
+ // report `reaped: true` for a host whose containers it never saw, and the scope-claim sweep spends on that answer.
421
+ // The SAME injected `exec`, as a NON-THROWING `{ code, stdout, stderr }` step. Two things fall out and both
422
+ // are load-bearing. It is the shape `networkEndpoints` and `removeNetworkOrSay` need -- the "not found" rule
423
+ // reads both streams, and with `--format` the daemon puts that wording on stderr with stdout empty (measured
424
+ // on docker 27.4.0). And because it cannot throw, the PER-NETWORK calls cannot reach the outer catch, so the
425
+ // work this slice adds cannot flip the tri-state a scope claim is spent on.
426
+ //
427
+ // The `network ls` itself deliberately stays on the throwing `exec`, so a daemon that dies between the `ps`
428
+ // and the listing still answers `{ reaped: false }`. That is the pre-existing behaviour and it is the
429
+ // conservative direction: this host cannot claim it holds nothing while it could not finish looking.
430
+ // `opts` passed through (issue #452, gate round 4): the detach gate's runtime read asks for its own bound.
431
+ const step = async (args, opts) => {
432
+ try {
433
+ const { stdout, stderr } = await (opts ? exec(bin, args, opts) : exec(bin, args));
434
+ return { code: 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") };
435
+ } catch (err) {
436
+ // `promisify(execFile)` rejects with the exit code on `.code` and what the CLI printed on
437
+ // `.stdout`/`.stderr`. Both streams are MATCHED against and NEITHER is ever logged (issue #339).
438
+ return { code: typeof err?.code === "number" ? err.code : null, stdout: String(err?.stdout ?? ""), stderr: String(err?.stderr ?? "") };
439
+ }
440
+ };
441
+
442
+
443
+ /**
444
+ * One leftover network. The old body was a bare `network rm` in a `try {} catch {}` whose comment said an
445
+ * in-use network "belongs to something else" -- and on the path this sweep exists for, it does not: the
446
+ * worker died mid-job, its container is reaped three lines above, and the only thing still holding the
447
+ * network is this worker's OWN long-lived egress proxy, which nothing detached when the process died. So
448
+ * the network survived every later boot, silently, because the failure had nowhere to go (issue #357).
449
+ *
450
+ * DETACH WHAT IS ATTACHED, never a proxy named from configuration. That needs no `env` seam here, and it
451
+ * is right where a configured name would be wrong: a network left before a `PI_EGRESS_PROXY` change holds
452
+ * the OLD proxy, and on a deployment with the policy off it holds nothing at all. Inspecting is also the
453
+ * only way to see the one case that must be left alone.
454
+ *
455
+ * THE ONE THING THIS MUST NOT TOUCH is a network a `pi-job-` container is still on. Detaching there would
456
+ * sever a live job's only route out: it keeps running, spends its slot and dies at its first turn, which is
457
+ * strictly worse than the network it would have cleaned up. It should be unreachable -- the container loop
458
+ * `rm -f`'d every one of them, and a failure there throws to the outer catch -- so the ways in are a
459
+ * container started between the `ps` and this inspect, or the two-workers-per-daemon configuration
460
+ * `DES-CONCURRENCY-3` already calls catastrophic and unsupported. Where it is seen, it is left alone and
461
+ * said.
462
+ *
463
+ * WHAT IT CANNOT SEE IS NOW ASKED FOR SEPARATELY (issue #379, item 1). `.Containers` lists RUNNING
464
+ * endpoints, so a member that is `created` or `exited` was invisible to it AND to the container half's
465
+ * `docker ps` -- and the `rm` succeeded, leaving a container that can never start again (`docker start`
466
+ * answers `network <id> not found`, measured on 27.4.0 for both states). A second ask, `ps -a --filter
467
+ * network=`, closes that: any member outside `ENDPOINT_LISTED_STATES` keeps the network and is named.
468
+ *
469
+ * The two halves still ask DIFFERENT questions, deliberately. This one only declines to remove something,
470
+ * which costs a leftover network and a line. The container half `rm -f`s what it finds, so widening it
471
+ * the same way would destroy an operator's stopped container and a crashed job's forensic one.
472
+ */
473
+ async function reapNetwork(network, gate) {
474
+ const { ok, names, absent } = await networkEndpoints(step, network, { bin });
475
+ // Gone between the `ls` and now. Nothing was left behind, so there is nothing to say: the ONE silence
476
+ // this sweep allows, and only in the daemon's own words for a network.
477
+ if (absent) return;
478
+ if (!ok) return log("network_not_reaped", { network, reason: "unreadable" });
479
+ if (names.some(isJobNamespace)) return log("network_not_reaped", { network, reason: "job-container-attached" });
480
+ // THE OTHER HALF OF THE SAME QUESTION (issue #379, item 1). `.Containers` above lists RUNNING
481
+ // endpoints, which is what the daemon guards: with one of those attached the `rm` fails by itself.
482
+ // It lists nothing for a member that is `created` or `exited`, and the `rm` then SUCCEEDS -- after
483
+ // which that member can never start again, because it holds a network id the daemon no longer has.
484
+ // Measured on docker 27.4.0 for BOTH states: `docker start` answers `network <id> not found`.
485
+ //
486
+ // So this half asks about a member in ANY state, and the container half deliberately does not. The
487
+ // container half stays `docker ps`, running-only, because widening it to `ps -a` would `rm -f` an
488
+ // operator's stopped container and a crashed job's forensic one, which `design.md` refuses; this half
489
+ // only DECLINES to remove something, which costs a leftover network and a line saying so.
490
+ //
491
+ // On Podman the read above is itself this `ps -a` since issue #452 (4.9 renders no `.Containers`), and its
492
+ // `parked` already holds this answer. Asked again anyway, so both runtimes take ONE path from here on and
493
+ // the classification below stays the one this sweep has always pinned; the cost is one command per
494
+ // leftover network per boot.
495
+ const members = await step(["ps", "-a", "--filter", `network=${network}`, "--format", "{{.Names}}\t{{.State}}"]);
496
+ if (members.code !== 0) return log("network_not_reaped", { network, reason: "containers-unreadable" });
497
+ // AN ALLOWLIST, following `sandbox.mjs`: the states named here are the ones the daemon itself lists in
498
+ // `.Containers`, so the detach-or-leave logic below already handles them. Anything else -- `created`,
499
+ // `exited`, `dead`, a Podman state nothing here has measured -- keeps the network. `paused` is on this
500
+ // side because it IS listed and the `rm` refuses while it is there (measured); `restarting` is not,
501
+ // because whether it appears is a matter of which instant the sweep asks in.
502
+ // A NAME AND A STATE, both of them the daemon's own vocabulary. A line without a tab is not a member --
503
+ // docker writes warnings and deprecation notices to stdout, and reading one as a container name put
504
+ // free text into a log field this file's own closed-token rule forbids, and kept the network forever
505
+ // on the strength of it. A name that is not a docker name is not a member either (docker refuses
506
+ // anything outside `[a-zA-Z0-9][a-zA-Z0-9_.-]*`).
507
+ const parked = members.stdout
508
+ .split("\n")
509
+ .map((l) => l.split("\t"))
510
+ .filter(([name, state]) => /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(String(name ?? "").trim()) && state !== undefined && !ENDPOINT_LISTED_STATES.has(String(state).trim()))
511
+ .map(([name]) => name.trim());
512
+ // THE PROXY IS NOT A REASON TO KEEP THE NETWORK, and leaving it out is what keeps this sweep able to do
513
+ // its job. `reapNetwork` exists for exactly one shape: the worker died mid-job, its container is reaped
514
+ // three lines above, and the only thing still holding the network is this worker's OWN long-lived
515
+ // egress proxy. If that proxy is STOPPED at reap time -- an operator who turned the policy off, a host
516
+ // that rebooted -- then treating it as a member to protect leaves every leftover job network standing
517
+ // forever, logged once per boot, with nothing else in the project that would ever remove them.
518
+ //
519
+ // Detaching a stopped container is measured to work (`network disconnect -f`, exit 0 on docker 27.4.0),
520
+ // and it costs that container nothing it can use: the network it is being detached from is a dead job's
521
+ // network, which is not one the proxy needs to start. What the rule protects is a container that would
522
+ // be BROKEN by the removal, which is any other member.
523
+ const attached = parked.filter((name) => name !== DEFAULT_EGRESS_PROXY);
524
+ // BOUNDED, because this goes into a log line. Five hundred stopped members produced a single
525
+ // 18,000-character record, in the same change that bounded the endpoint line for the same reason.
526
+ if (attached.length > 0) return log("network_not_reaped", { network, reason: "container-attached-not-running", containers: attached.slice(0, 5), more: attached.length > 5 ? attached.length - 5 : 0 });
527
+ // The parked proxy is detached too: it is attached to this network and `.Containers` does not list it,
528
+ // so without naming it here the `rm` would fail "has active endpoints" on a member nothing detached.
529
+ const detach = [...names, ...parked.filter((name) => name === DEFAULT_EGRESS_PROXY)];
530
+ // Through the detach gate, as every detach is (issue #452 with #458, gate round 3): `names` are the RUNNING members, so
531
+ // a stopped proxy asks nothing, and a refusal leaves the network whole and said; the next boot with the keeper
532
+ // holding removes it. The gate is this pass's, so its runtime read is made once however many networks there are.
533
+ const outcome = await removeNetworkOrSay(step, { network, detach, running: names, bin, gate });
534
+ if (outcome.blocked) return log("network_not_reaped", { network, reason: outcome.blocked });
535
+ if (outcome.absent) return;
536
+ // `detached` is named rather than counted: one of them may be something this worker never attached.
537
+ if (outcome.removed) return log("reaped_network", { network, detached: outcome.detached });
538
+ log("network_not_reaped", { network, reason: "rm-failed", detached: outcome.detached });
539
+ }
540
+
181
541
  return async function reap() {
182
542
  try {
183
- const { stdout } = await exec("docker", ["ps", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Names}}"]);
543
+ const { stdout } = await exec(bin, ["ps", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Names}}"]);
544
+ // ANCHORED, because `--filter name=` is a SUBSTRING match: it also returns an operator's own
545
+ // `my-pi-job-notes`, which this sweep would then `rm -f`. Measured on docker 27.4.0 by creating
546
+ // exactly that name and watching it come back in the listing. The filter stays as the cheap
547
+ // server-side narrowing; the namespace decision is made here, on the name, where a test can pin it.
184
548
  const names = stdout
185
549
  .split("\n")
186
550
  .map((n) => n.trim())
187
- .filter(Boolean);
551
+ .filter(isJobNamespace);
188
552
  for (const name of names) {
189
- await exec("docker", ["rm", "-f", name]);
553
+ await exec(bin, ["rm", "-f", name]);
190
554
  log("reaped_container", { name });
191
555
  }
192
556
  // REQ-EGRESS-ALLOWLIST: the per-job networks those containers were on. Swept AFTER the containers,
193
557
  // because a network with a member still attached cannot be removed -- and swept by the SAME
194
558
  // `pi-job-` filter, so the namespace rule that keeps an operator's live sandbox safe from the
195
- // container reaper keeps their sandbox NETWORK safe too, with no second rule to remember.
559
+ // container reaper keeps their sandbox NETWORK safe too. The filter is the cheap narrowing only:
560
+ // the namespace decision is `isJobNamespace`, asked below and by the container loop above.
196
561
  //
197
562
  // A crashed worker is the case this exists for: `runContainer`'s own finally removes the network
198
563
  // on every ordinary path, so anything still here outlived a process that did not get to run it.
199
- // A network still in use by something else fails to remove and is skipped, which is correct: this
200
- // is a best-effort sweep and never a reason not to boot.
201
- const { stdout: nets } = await exec("docker", ["network", "ls", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Name}}"]);
202
- for (const net of nets.split("\n").map((n) => n.trim()).filter(Boolean)) {
203
- try {
204
- await exec("docker", ["network", "rm", net]);
205
- log("reaped_network", { network: net });
206
- } catch {} // still in use, or already gone -- either way not this boot's problem
207
- }
564
+ const { stdout: nets } = await exec(bin, ["network", "ls", "--filter", `name=${JOB_NAME_PREFIX}`, "--format", "{{.Name}}"]);
565
+ // Same substring hazard, and worse here: this sweep DETACHES before it removes, so a foreign
566
+ // network that merely contains `pi-job-` would have its endpoints stripped. THE SAME PREDICATE as
567
+ // the container loop above, which is the fix for #360 item 7: this used to be an anchored
568
+ // `^pi-job-.*-net$`, so the two loops gave different answers to "what is ours" and a network under
569
+ // the prefix without the suffix outlived the container it belonged to. See `isJobNamespace`.
570
+ // ONE gate for the pass (issue #452, gate round 3): a runtime that hangs costs one bound, not one per network.
571
+ const gate = makeDetachGate(step, { bin });
572
+ for (const net of nets.split("\n").map((n) => n.trim()).filter(isJobNamespace)) await reapNetwork(net, gate);
208
573
  // Whether the enumeration HAPPENED, which the scope-claim sweep depends on: it may only delete a
209
574
  // claim naming this host once this host has actually established that it holds no containers.
210
575
  return { reaped: true };
@@ -214,9 +579,260 @@ export function makeReaper({ log, exec = execDocker }) {
214
579
  // the daemon. Either way the claim "I hold nothing" is unproven, and sweeping on it would free
215
580
  // slots for containers that may STILL BE RUNNING, letting another host start more alongside
216
581
  // them: a money overrun rather than a tidy-up. Conservative in the only safe direction.
217
- log("reaper_skipped", { reason: err?.message });
582
+ // SCRUBBED, because this is the one line in this file that carries the CLI's own words: `step` above
583
+ // matches both streams and logs neither, but `promisify(execFile)` puts them on the Error's own
584
+ // message, and a docker error repeats an unparseable DOCKER_HOST with its credentials (issue #339).
585
+ log("reaper_skipped", { reason: scrubCredentials(err?.message) });
218
586
  return { reaped: false };
219
587
  }
220
588
  };
221
589
  }
222
590
 
591
+ /**
592
+ * WHERE THE DOCKER CLI WILL SEND A CONTAINER, AND WHETHER THAT IS THIS HOST (issue #278).
593
+ *
594
+ * `credentialTransit` is the property that the provider key and the per-job forge token reach the container
595
+ * without crossing a network this deployment does not own. For `local` they ride the worker's own `docker run`
596
+ * argv as `-e NAME=VALUE`, so the question is which daemon that CLI talks to -- and the CLI decides it from
597
+ * `DOCKER_HOST`, else `DOCKER_CONTEXT`, else the config file's `currentContext`, with its own normalisation
598
+ * (`DOCKER_HOST=bogus` becomes `tcp://bogus:2375`). Checking `DOCKER_HOST` alone misses both other sources, and
599
+ * this very machine resolves through a context. So the CLI is ASKED, never re-implemented: `DES-WORKER-ON-HOST`
600
+ * already rejected reimplementing docker's path translation, and a second copy of its context precedence would
601
+ * be the same failure one layer over. `docker context inspect` answers from local config in milliseconds and
602
+ * never contacts a daemon.
603
+ *
604
+ * The format is NARROW on purpose -- the context's name and the docker endpoint's host, each JSON-quoted -- and
605
+ * never `{{json .}}`, which carries TLS material paths and storage locations nobody asked for. (`job-user.mjs`'s
606
+ * `docker info` read is the one exception, and says why there: it parses the body in memory, keeps a handful of
607
+ * facts and drops the rest, and a narrow template turns a field one runtime lacks into a template error
608
+ * indistinguishable from no daemon.)
609
+ */
610
+ export const DOCKER_ENDPOINT_ARGS = Object.freeze(["context", "inspect", "--format={{json .Name}}|{{json .Endpoints.docker.Host}}"]);
611
+
612
+ /**
613
+ * `{ context, host }` from the CLI's output, or `null`. Both runners hand over stdout alone, and docker 27.4 puts
614
+ * nothing on stdout but the answer (its warnings and errors go to stderr, which neither runner reads). The scan still runs from the LAST line
615
+ * and credits only a line that parses whole, so a notice a plugin or a later CLI prints ahead of the answer is
616
+ * never read as it.
617
+ */
618
+ export function parseDockerEndpoint(output) {
619
+ const lines = String(output ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
620
+ for (let i = lines.length - 1; i >= 0; i--) {
621
+ const at = lines[i].indexOf("|");
622
+ if (at <= 0) continue;
623
+ try {
624
+ const context = JSON.parse(lines[i].slice(0, at));
625
+ const host = JSON.parse(lines[i].slice(at + 1));
626
+ if (typeof context === "string" && typeof host === "string") return { context, host };
627
+ } catch {
628
+ // not this line
629
+ }
630
+ }
631
+ return null;
632
+ }
633
+
634
+ /** 127.0.0.0/8 as a literal dotted quad, and nothing that merely starts with "127." (`127.0.0.1.nip.io` resolves anywhere). */
635
+ function isLoopbackV4(hostname) {
636
+ // No leading zeros. Go's parser (which the docker CLI dials with) refuses `127.0.0.09` as an address, so it is
637
+ // a NAME: looked up by the resolver, and sent through HTTP_PROXY when one is set -- measured, with the job's
638
+ // `-e` values in the proxied request. Only the canonical dotted quad is an address the CLI will not proxy.
639
+ const parts = hostname.split(".");
640
+ return parts.length === 4 && parts[0] === "127" && parts.every((p) => /^(0|[1-9]\d{0,2})$/.test(p) && Number(p) <= 255);
641
+ }
642
+
643
+ /**
644
+ * Is this endpoint on this host, judged by its FORM? `{ local, display }`, where `display` is the endpoint with
645
+ * the endpoint reduced or withheld so that no part of a credential in it can be logged or printed. Never an
646
+ * EDIT of the value: see `displayEndpoint`.
647
+ *
648
+ * LOCAL only when it can be shown local, the polarity this codebase uses wherever a thing that cannot be shown
649
+ * to be on gets no credit:
650
+ * - `unix://` -- a socket on this machine's filesystem (Docker Desktop's and colima's VMs included: they are
651
+ * this machine's own runtime, not a network the deployment does not own).
652
+ * - `npipe:` with the `.` host -- `npipe:////./pipe/docker_engine`. A named pipe on another host is SMB, and
653
+ * the CLI accepts one. Parsed by hand: `new URL` puts the `.` in the path with an empty host.
654
+ * - `tcp://` to exactly `localhost`, a canonical literal `127.0.0.0/8` address (no leading zeros) or `[::1]`.
655
+ * NOT local: `ssh://` (including `ssh://localhost` -- `~/.ssh/config` can send that anywhere), `tcp://` to any
656
+ * other name or address, any other scheme, and nothing at all.
657
+ *
658
+ * WHAT THIS CANNOT SEE, and says so: a unix socket or a loopback port can be a tunnel (`ssh -L`, socat) to
659
+ * another machine, and `localhost` is whatever this host's resolver says it is. The form is local and the daemon
660
+ * is not. That residual is named in the declaration's comment rather than claimed away.
661
+ */
662
+ export function classifyDockerEndpoint(host) {
663
+ if (typeof host !== "string" || host === "") return { local: false, display: "" };
664
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(host)?.[1]?.toLowerCase();
665
+ const display = displayEndpoint(host, scheme);
666
+ if (scheme === "unix") return { local: true, display };
667
+ if (scheme === "npipe") {
668
+ // `\\.\pipe\name` is this machine's pipe namespace; `\\.\UNC\host\...` is the same `.` prefix
669
+ // reaching another host over SMB, so the segment after `.` must be `pipe` too, and no later segment may
670
+ // be `.` or `..`, which Windows resolves in a `\\.\` path and could climb back out of `pipe` with. Split on
671
+ // both separators, because the CLI passes a backslash through and Windows reads it as one.
672
+ const [server, namespace, ...rest] = host.slice("npipe:".length).replace(/^[\\/]+/, "").split(/[\\/]/);
673
+ return { local: server === "." && String(namespace).toLowerCase() === "pipe" && rest.length > 0 && !rest.some((seg) => seg === "." || seg === ".." || seg === ""), display };
674
+ }
675
+ if (scheme === "tcp") {
676
+ let hostname;
677
+ try {
678
+ hostname = new URL(host).hostname;
679
+ } catch {
680
+ return { local: false, display };
681
+ }
682
+ return { local: hostname === "localhost" || hostname === "[::1]" || isLoopbackV4(hostname), display };
683
+ }
684
+ return { local: false, display };
685
+ }
686
+
687
+ /** Everything between `scheme://` and the first `/`, `?` or `#`; `null` when the value does not start `scheme://`. */
688
+ function rawAuthority(host, scheme) {
689
+ if (!scheme || host.slice(0, scheme.length + 3).toLowerCase() !== `${scheme}://`) return null;
690
+ const rest = host.slice(scheme.length + 3);
691
+ const end = rest.search(/[/?#]/);
692
+ return end === -1 ? rest : rest.slice(0, end);
693
+ }
694
+
695
+ /**
696
+ * The endpoint as it may be logged and printed. Never an EDIT of it: passed through whole, reduced to a host, or
697
+ * withheld. Editing is what kept failing, because a password may itself hold `@`, `/`, `?` or `#`, so any rule that
698
+ * splits the string and keeps a part can keep part of a password.
699
+ *
700
+ * THE RULE IS ONE FACT ABOUT URLs: userinfo ends at an `@`. So if the whole value holds exactly ONE `@` and that `@`
701
+ * lies inside the raw authority, then under every parse the userinfo is a prefix of what precedes it, and everything
702
+ * after it -- which is all that is shown -- is host and port. A password containing `@`, `/`, `?` or `#` either adds a
703
+ * second `@` or pushes the only one outside the authority, and both are withheld.
704
+ *
705
+ * `new URL` IS THE DEFECT THIS REPLACES (issue #340), not a helper it uses. It computes an authority of its own and
706
+ * takes the LAST `@` in it, so `ssh://bob:@secret/word@remote` parsed with host `secret` and `ssh://bob:4455?qzx@remote`
707
+ * with host `bob:4455`: part of a password, displayed. A 2,000,000-value fuzz over hostile password bodies leaks
708
+ * 639,930 times under that rule and zero under this one.
709
+ *
710
+ * `unix` and `npipe` are PATHS, where `@` is an ordinary filename character, and three call sites read this display
711
+ * form AS that path (`job-user.mjs`, `sandbox.mjs`, `runtime-observations.mjs`). They pass through whole when they have
712
+ * no authority at all, which every real socket path does. One WITH an authority is a userinfo position on a URL no
713
+ * socket needs, and is withheld -- the previous rule cut at the last `@` of a hand-split authority, so
714
+ * `unix://bob:p/w@/x.sock` displayed VERBATIM and `unix://bob@/var/run/docker.sock` displayed an INVENTED path that
715
+ * `job-user.mjs` then stat'ed. If the withheld token's shape ever loses its `scheme://` prefix, `podmanOnThisHost`
716
+ * changes answer with it.
717
+ *
718
+ * `\` is deliberately NOT an authority terminator: Go's `url.Parse`, which the docker CLI uses, ends an authority at
719
+ * the first `/` only, so `unix://\\srv\x@y` is userinfo to it and must be withheld rather than read as an empty
720
+ * authority and passed through. Adding it to the set makes `npipe://\\host\pipe:pw@x` display its
721
+ * password, measured, and there is a test for that rather than only this sentence.
722
+ *
723
+ * Taking the FIRST `@` of the authority rather than the last is equivalent while the one-`@` guard below
724
+ * stands, since there is then only one. It is written as `indexOf` because that is the rule being
725
+ * expressed; remove the guard and the difference between them is the entire defect this replaced.
726
+ *
727
+ * WHAT IS GIVEN UP, stated rather than glossed: a password in `DOCKER_HOST` no longer makes the display say so, since
728
+ * `tcp://bob:pw@127.0.0.1:2375` now shows its host like any other. Accepted because neither docker's tcp transport nor
729
+ * ssh takes a password from a URL, so it is inert junk in an operator's environment rather than a credential this
730
+ * worker puts on a wire. The old comment claimed the CLI refuses to dial every withheld form, and that was false twice:
731
+ * `tcp://bob:hunter2@127.0.0.1:P` dials, and `ssh://bob@[fe80::1%25en0]:22` dials (`URL` rejects IPv6 zone ids; the CLI
732
+ * runs `ssh -- fe80::1%en0`). Both now show their host, which is the fact an operator needs.
733
+ */
734
+ function displayEndpoint(host, scheme) {
735
+ if (!host.includes("@")) return host;
736
+ const authority = rawAuthority(host, scheme);
737
+ // The scheme is named only when the value really starts `scheme://`: in `bob:pw@host` the "scheme" is a username.
738
+ const withheld = `${authority === null ? "" : `${scheme}://`}(credentials not shown)`;
739
+ if (scheme === "unix" || scheme === "npipe") return authority === "" ? host : withheld;
740
+ if (authority === null || !authority.includes("@")) return withheld;
741
+ if (host.indexOf("@") !== host.lastIndexOf("@")) return withheld;
742
+ return `${scheme}://${authority.slice(authority.indexOf("@") + 1)}`;
743
+ }
744
+
745
+ /**
746
+ * Why a resolve failed, as `{ reason, transient }`. A fixed token, never the CLI's stderr, which is not read at
747
+ * all: a missing context's message carries the operator's home path, and a `DOCKER_HOST` it cannot parse is
748
+ * repeated in it, credentials included.
749
+ *
750
+ * Classified on what Node supplies as values, per `DES-TRANSIENT-VERSUS-DETERMINATE-IS-ONE-RULE`:
751
+ * - the SPAWN errno through `transient.mjs`'s allow-list (no docker binary is determinate; a spawn out of
752
+ * processes or descriptors, or refused with `EACCES`, is not);
753
+ * - the timer's kill is a `timeout`, and a death by any other signal is named by it; both are transient;
754
+ * - a NON-ZERO EXIT is determinate. It is the CLI's own answer, and on docker 27.4 most measured are
755
+ * configuration refusals that answer the same until the operator changes something: a context that does not
756
+ * exist, a `DOCKER_HOST` it cannot parse, a context file it cannot parse. Some are not -- `permission denied`
757
+ * on the context store, a CLI starved of file descriptors -- and telling them apart would need a table of
758
+ * another tool's stderr prose, the shape that entry rejects for `gh auth token` on exactly this argument. So
759
+ * the residual is named instead: under a floor such a passing failure exits 2 at boot, and per job it
760
+ * refuses with the fixed comment as `backend-floor-unobserved` rather than retrying;
761
+ * - output that does not parse on a clean exit is determinate.
762
+ */
763
+ export function classifyEndpointFailure({ error = null, code = null } = {}) {
764
+ if (error?.timedOut || error?.killed) return { reason: "timeout", transient: true };
765
+ if (typeof error?.signal === "string") return { reason: `signal-${error.signal.toLowerCase()}`, transient: true };
766
+ if (error?.code === "ENOENT") return { reason: "docker-not-found", transient: false };
767
+ if (typeof error?.code === "string") return { reason: `spawn-${error.code.toLowerCase()}`, transient: !isDeterminateFsCode(error.code) };
768
+ if (typeof error?.code === "number" && error.code !== 0) return { reason: `exit-${error.code}`, transient: false };
769
+ if (typeof code === "number" && code !== 0) return { reason: `exit-${code}`, transient: false };
770
+ if (error) return { reason: "spawn-failed", transient: true };
771
+ return { reason: "unparseable", transient: false };
772
+ }
773
+
774
+ /**
775
+ * Run the CLI bounded, as `{ code, stdout, error }`. `execFile`'s own `timeout` is NOT the bound: it sends a
776
+ * signal and then still waits for the child's `close`, so a CLI wedged on a dead socket never settles
777
+ * (`retention-sweep.mjs` records the same). A separate timer settles the promise regardless, kills with
778
+ * SIGKILL and destroys the pipes. REF'D, because at boot nothing else may be holding the event loop.
779
+ */
780
+ export function execDockerBounded(args, { timeoutMs = 5000, execFileFn = execFile, maxBuffer = 64 * 1024, bin = "docker", withStderr = false } = {}) {
781
+ return new Promise((resolve) => {
782
+ let settled = false;
783
+ let child = null;
784
+ let timer = null;
785
+ const finish = (value) => {
786
+ if (settled) return;
787
+ settled = true;
788
+ clearTimeout(timer);
789
+ resolve(value);
790
+ };
791
+ timer = setTimeout(() => {
792
+ try {
793
+ child?.kill?.("SIGKILL");
794
+ child?.stdout?.destroy?.();
795
+ child?.stderr?.destroy?.();
796
+ } catch {
797
+ // already gone
798
+ }
799
+ finish({ code: null, stdout: "", error: { timedOut: true } });
800
+ }, timeoutMs);
801
+ try {
802
+ // No `env`: the endpoint that matters is the one the job's own `docker run` will use, and that spawn
803
+ // inherits this process's environment. Passing an env here would ask about a different CLI.
804
+ // `stderr` only for a caller that ASKS (`withStderr`, issue #452, gate round 3): `execFile` passes it to the callback
805
+ // and never puts it on the error, so the sandbox sweep's runner, which reads `error.stderr`, saw an empty string
806
+ // and the podman-docker `.Containers` fallback never fired there (measured). Not by default: stderr can repeat an
807
+ // unparseable DOCKER_HOST with its credentials (issue #339), and no other caller reads it.
808
+ child = execFileFn(bin, [...args], { killSignal: "SIGKILL", maxBuffer }, (err, stdout, stderr) => {
809
+ finish({ code: err ? (typeof err.code === "number" ? err.code : null) : 0, stdout: String(stdout ?? ""), ...(withStderr ? { stderr: String(stderr ?? "") } : {}), error: err ?? null });
810
+ });
811
+ } catch (err) {
812
+ finish({ code: null, stdout: "", error: err });
813
+ }
814
+ });
815
+ }
816
+
817
+ /**
818
+ * The resolver: `async () => ({ local, context, endpoint, reason, transient })`. `local` is `true`, `false`, or
819
+ * `null` when the CLI did not answer; `endpoint` is the display form; `reason` and `transient` are set only when
820
+ * `local` is `null`. `run(args)` is the seam, returning `{ code, stdout, error }`.
821
+ */
822
+ export function makeDockerEndpointResolver({ run = (args) => execDockerBounded(args) } = {}) {
823
+ return async function resolveDockerEndpoint() {
824
+ let result;
825
+ try {
826
+ result = await run(DOCKER_ENDPOINT_ARGS);
827
+ } catch (err) {
828
+ result = { code: null, stdout: "", error: err };
829
+ }
830
+ const parsed = result?.error || result?.code !== 0 ? null : parseDockerEndpoint(result.stdout);
831
+ if (!parsed) {
832
+ const { reason, transient } = classifyEndpointFailure({ error: result?.error ?? null, code: result?.code ?? null });
833
+ return { local: null, context: null, endpoint: null, reason, transient };
834
+ }
835
+ const { local, display } = classifyDockerEndpoint(parsed.host);
836
+ return { local, context: parsed.context, endpoint: display, reason: null, transient: false };
837
+ };
838
+ }