diff --git a/docs/backends.md b/docs/backends.md index 71920533..1c15a583 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -116,12 +116,39 @@ ones adapters get wrong: and Docker Desktop the image's own `USER` runs. On a daemon that enforces bind-mount ownership (native Linux Docker, rootful Podman) the worker runs the job as its own uid with `--user` and `HOME=/home/pi`, because only the uid that owns the job's files can use them (a uid-1001 worker, the image's own uid, needs no flag). - The worker decides this from facts at boot and before each job, never by starting a probe container. It - refuses by name what no uid can serve: rootless Docker or Podman, userns-remap, a root worker, Docker - Desktop on Linux (WSL is not affected), an image without the `anyUid` capability for another uid, and a - `--user` whose primary group is 0 or the docker socket's. `pi-dispatch doctor` names the answer for the shell - it runs in, and `pi-dispatch doctor --live` runs its probe as that user and reads it back. An adapter for - another runtime answers the same question in its own terms. + The worker decides this from facts at boot and before each job, never by starting a probe container, and + refuses by name what no uid can serve. **When that refusal fires depends on which cause it is**, so both + sets are named here rather than one: + + + - **Stops the boot**: `rootless`, `userns-remap`, `worker-is-root`, `desktop-linux-userns`. Nothing a job + may run as works, so the worker exits rather than picking up work it could only refuse. Three of these + are read off the daemon, though one of them can instead be inferred from the socket this worker was + pointed at; the root-worker case is a fact about the account the worker itself runs as, and is fixed by + changing that account rather than the host. Docker Desktop is refused only on Linux outside WSL, and + WSL2 is not affected. That exit + is conditional, and the condition is real rather than decorative: it happens while `local` is the default + venue (`BOOT_REFUSING_JOB_USER_CAUSES` in `worker/src/job-user.mjs`, read by `jobUserBootRefusal` in + `start.mjs`), and a deployment whose default venue was elsewhere would boot and refuse each local job + instead. `local` is the only venue this build has, so every deployment today gets the exit. + - **Refuses each job**: `runtime-unreadable`, `root-group`, `docker-group`, `any-uid-unsupported`. The + worker boots, and each local job returns a policy refusal naming the cause. The first of those is a + daemon whose answer cannot be read at all, which is not the same as one that has not answered yet. + The last three are on the `--user` path only, so a worker that is already uid 1001, the image's own + uid, meets none of them: nothing is passed, and its primary group is not compared. + + A daemon that has not answered YET is in neither set. The decision is `unknown`, it is never cached and never a + boot exit, so a unit carrying `RestartPreventExitStatus=2` is not stranded by a daemon that is still + starting. A job picked up while the answer is still unknown is an infrastructure **retry**, not a refusal + (`CONST-RETRY-INFRA-ONLY`): the processor throws, so the queue tries again once the daemon answers, where a + policy refusal returns and is final. + + `pi-dispatch doctor` names the answer for the shell it runs in, and `pi-dispatch doctor --live` runs its + probe as that user and reads it back. An adapter for another runtime answers the same question in its own + terms. ## A declaration is not a claim that the property holds @@ -241,17 +268,36 @@ The harness cannot detect that. A green run is not a conformant backend. ## Registering it Pass your bundle to `startWorker` as an extra backend. It is registered after `local`, and its own `reap` -joins the boot sweep automatically. **There is no published import for `startWorker` today**: it lives in -`worker/src/start.mjs`, which the package's export map does not name, so this works from a checkout of this -repository and not from an installed package. Its first argument is the environment, and the backends ride -the second: +joins the boot sweep automatically. Its first argument is the environment, and the backends ride the second: ```js -import { startWorker } from "./worker/src/start.mjs"; // from a checkout; not an export of @edgehero/pi-dispatch +import { startWorker } from "./worker/src/start.mjs"; // from a checkout of this repository await startWorker(process.env, { extraBackends: [myBackend] }); ``` +**Registration is in-tree, and that is a decision rather than a gap** (issue #342). `startWorker` lives in +`worker/src/start.mjs`, which the package's export map does not name, so this works from a checkout and not +from an installed package. Exporting it was considered and refused: the second argument is the worker's +whole dependency-injection bag, test seams included, and publishing an import for it makes every one of +those seams a public API that a release has to keep. + +It costs an adapter author nothing they were not already paying, which is the part worth being clear about. +Step 1's `BACKENDS_TABLE` entry is in-tree by necessity, for the reason given at the top of this page: the +declaration is what an operator reads, so a venue that runs jobs without one would be a venue nobody can +reason about. An adapter that already has to land a declaration here loses nothing by registering here too. +The code itself can still live anywhere. + +Issue #342 asked for this to be decided alongside the Podman route rather than after it, and it is: issue +#354's `podman` backend is specified in-tree, declaring its own words in the backend table and implementing +the adapter contract on this page, so it needs no export and nothing here blocks it. + +Reopening this needs two things, not one, and the second is the harder. An export would let an +npm-installed operator CALL `startWorker`, and it would still leave them unable to register anything: +`BACKENDS` is frozen at module load, `parseBackendList` refuses a `PI_BACKENDS` name it does not know, and +`validateBackend` refuses a trigger naming one. So an out-of-tree venue also needs a way to DECLARE itself, +which is the thing the paragraph above refuses on purpose. Nobody has asked for either. + `startWorker` builds the registry itself: ```js diff --git a/docs/egress.md b/docs/egress.md index 07e52498..f4db419c 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -188,9 +188,17 @@ All of it was run. The method costs nothing and is worth repeating on your own h peer on another job network is unreachable by name and by address. See `docs/podman.md`. - **A denied host fails in about 20 ms, not on a DNS timeout**, because the client hands the name to the proxy in a `CONNECT` and never resolves it locally. An external name resolved *directly* from an internal - network fails without ever reaching the proxy, and how fast depends on the resolver in front of the container, - not on this design: about 10 seconds on the host this was first measured on, and under 5 ms in a Linux lab where - nothing answers at all. Either way it is the path a client that bypassed the proxy would take. + network fails without ever reaching the proxy, and how fast depends on the resolver **inside** the + container rather than on anything this design does. Measured on Docker Desktop 27.4.0 (macOS), an + `--internal` network, and docker's embedded resolver at `127.0.0.11`, whose upstream on that host is the + Desktop VM's own resolver and is not reachable from a network with no route off it. So the forward fails + rather than hanging, and what each client does with that failure is the whole of the difference: the job + image, which is Debian and glibc, gives up in about 10 ms (six runs, 6 to 23 ms, all `EAI_AGAIN`), taking + the embedded resolver's answer as final, while a musl image on the same network waits out musl's own 5 s + resolver timeout instead (five runs, 5013 to 5025 ms). The number that describes a job here is therefore + milliseconds, since the job image is glibc. The ten-second figure this replaces did not reproduce on this + host in any client, and what it was measured against is not known, so it is replaced rather than accounted + for. Either way it is the path a client that bypassed the proxy would take. ## Appendix: a host-firewall layer below docker's rules diff --git a/specs/design.md b/specs/design.md index e8ec5e5c..1ae2f54f 100644 --- a/specs/design.md +++ b/specs/design.md @@ -3290,6 +3290,28 @@ a tunnel. deployment has always run. Rootful Podman reached through its Docker API is that same `local` venue, not a second backend (`DES-PODMAN-THROUGH-ITS-DOCKER-API`). The table exists so a second can be added without reading the worker's source, and so that adding one cannot quietly weaken a control. +- **REGISTRATION IS IN-TREE, and that is a decision rather than the gap issue #342 filed it as.** `startWorker` + takes `extraBackends`, lives in `worker/src/start.mjs`, and the package's export map does not name it, so an + adapter registers from a checkout of this repository and not from an installed package. Publishing an entry + point was the alternative and is refused: `startWorker`'s second argument is the worker's entire + dependency-injection bag, every test seam included, so an export makes all of it a public API a release has + to keep. The narrower `"./start"` export is the same thing with a smaller name. + **It costs an adapter author nothing they were not already paying**, which is why this does not contradict the + two sentences above as sharply as it first reads. The `BACKENDS_TABLE` entry is in-tree BY NECESSITY, for this + entry's own stated reason: the declaration is what an operator reads, and a venue that runs jobs without one is + a venue nobody can reason about. An adapter that must already land a declaration here loses nothing by landing + a registration beside it, and its code can still live anywhere. So "a second can be added without reading the + worker's source" is unchanged in substance, which is what it was always about, and "written elsewhere by + someone who will never run `npm test` here" stays true of the ADAPTER and was never true of its declaration. + #342 asked to be decided alongside the Podman route rather than after it, and this decides it: #354's + `podman` backend is specified IN-TREE, declaring its own table words and implementing the adapter contract + here, so it needs no export and nothing in this refusal blocks it. Reopening the question needs two things + rather than one, and the second is the harder. An export would let an npm-installed operator CALL + `startWorker` and would still leave them unable to register anything: `BACKENDS` is frozen at module load, + `parseBackendList` refuses a `PI_BACKENDS` name it does not know, and `validateBackend` refuses a trigger + naming one. So an out-of-tree venue needs a way to DECLARE itself as well, which is exactly what the + paragraph above refuses. Nobody has asked for either, and the first version of this sentence claimed the + export alone would serve it, which this entry's own reasoning refutes eight lines earlier. - **Why the table declares and does not construct**: the obvious shape is one entry per backend carrying its own `make()`, and it cannot work. `backends.mjs` imports NOTHING on purpose, because `doctor`, the config loader and the receiver all need to read what a backend guarantees without pulling the Docker @@ -4336,3 +4358,4 @@ a tunnel. | 2026-09-21 | Issue #357, item 2. **`DES-CLI-SURFACE` AMENDED**: the gate ladder gains a tier `up` alone occupies, **shown but not prompted** -- filling a key that has NO VALUE in a `.env` that already exists. Every write is printed as it happens and named again in the summary, and it is off the prompt tier because it cannot destroy anything an operator chose: it never creates the file, never replaces a value, and writes only paths this deployment would have resolved anyway. A key whose value would be a capability, a credential or a policy is not eligible, and `POINTER_ENV_ALLOWLIST`'s own "paths and URLs, never capability grants" is the line that says why. **`DES-RUNTIME-SETTINGS-FILE-OVERLAY` UNCHANGED, checked**: `PI_SETTINGS_FILE` now usually carries an explicit value, and that value is what `settingsFilePath` resolved anyway, so no read, no write protocol and no default moved. **`DES-FIRST-RUN-SETUP-WIZARD` UNCHANGED, checked**: the wizard's pointer is untouched, and the two surfaces overlap in exactly two keys which a test now pins by basename. **`DES-RETENTION-SWEEPS-ON-A-TIMER` UNCHANGED, checked, and it is the entry that decided the split**: the log reaper's filter is why `PI_LOGS_DIR` may never be a deployment folder. **Code evidence**: worker/src/up.mjs -> runUp. | | 2026-09-21 | Issue #337, items 2, 3 and 4: the panel. **`DES-ADMIN-VIA-PI-EXTENSION` AMENDED TWICE.** (1) RUN_DETAIL is described as a dump of the run's PII-free record fields, and its sandbox BLOCK is now three lines that are not record fields: the retention verdict it already carried (which came from a manifest read, so the description was already loose), plus the egress posture a sandbox opened from this panel would get and the sentence that it was read from THIS SHELL rather than from the deployment. They are a property of the process the panel runs in, not of the run, and pressing `b` acts with that process's environment. (2) The control-byte belt, which scoped to `failedReason` in the deps layer, is applied again at RENDER time to every record string RUN_DETAIL prints and to the LIST rows beside it, and the entry now says plainly that this is BELT-AND-BRACES rather than a second boundary: the entry's own placement paragraph argues the injection boundary holds by PLACEMENT and not by filtering, and a filter that reads as the boundary invites the placement rule to be relaxed later. The scrub is applied inside the value before styling, because the styler emits its own escapes including OSC-8 hyperlinks and scrubbing a composed line would destroy the link and the pane's width math. The class is C0 + DEL + C1, matching `panel.mjs`'s `CONTROL_CHARS`, which is this project's class for untrusted text on its way to a terminal and already backs the plain and ascii render paths; `triggers.mjs`'s narrower C0 + DEL is a file VALIDATOR and a different job. `style.mjs`'s frame titles go through the same class, in `clipPlain` rather than at each call site, because two of the five titles are built from a record's job id and fixing one caller left the other carrying what the lines inside the frame no longer did. **`DES-SANDBOX-IS-A-FRESH-CONTAINER` UNCHANGED, checked**: no container shape, mount or credential rule moved; the panel says more about a session it already opened the same way. **Code evidence**: admin/src/dashboard.ts -> renderRunDetail, scrubControl; admin/src/index.ts -> readSandboxInfo, sandboxEgressPosture, openSandboxSession. | | 2026-09-21 | Issues #348, #352 and #357 item 4, the round-cap leftovers: test pins, one fix text, and prose. **`DES-JOB-USER-INFERRED-READ-BACK-ON-REQUEST` AMENDED**, one residual, and it is a CORRECTION of an overstatement the entry has carried since #341. It said the gap doctor leaves (a unit naming no explicit `User=`) is covered because the worker's own boot refusal names a root worker, which is true only while `local` is the DEFAULT VENUE: `BOOT_REFUSING_JOB_USER_CAUSES` is read by `jobUserBootRefusal(decision, defaultBackend)`, so a fleet whose default venue is elsewhere BOOTS with a root worker and refuses each local job one at a time, and an operator reading the old sentence waits for a boot failure that never comes. The residual states both halves now, and the same conditional replaces the two comments that mirrored it in `doctor.mjs` and `doctor.test.mjs`. It also records the one production line this round moves, and the two wrong versions of it the gate caught, because each looked right. Where a unit names uid 0, doctor states the refusal instead of printing "re-run doctor as that account (`sudo -u root ...`)", which was true and roundabout. Version one printed `worker-is-root` for ANY explicit uid 0: on a rootless daemon that tells an operator to run the worker unprivileged, which doctor's own line two above has just refused, and it is the SAME overstatement the residual this row amends exists to correct. Version two re-asked `decideJobUser` with `euid: 0`, which looks like the careful repair and is worse: that function's socket-owner rootless row is guarded `euid !== 0`, so a forced 0 walks past the only row that detects a rootless Podman older than 4.9.3 and comes back `worker-is-root` on exactly the host where that is most wrong. The rule that shipped is the simpler one the round cap asks for: name the refusal only where this shell's own decision is `worker`, because then the daemon maps uids fine and rootness is the only difference left. Every other decision keeps the instruction, and the final review corrected the reason given for it: a host-level refusal is already stated one line up, while one inferred from this shell's own socket is narrowed to `socket.uid === euid` and says nothing about another account, so "the line above already said it for every account" was exactly inverted for the third fixture this round added. Reusing `JOB_USER_FIX`'s own text rather than restating it is what stops the two drifting. Three fixtures pin it, the third being the socket-inferred one that kills version two. One more defect fell out of version one's withdrawal: uid 0 can now reach the instruction, and `sudo -u 0` asks sudo for a user NAMED zero, so a numeric account is rendered `'#4242'`, quoted because an unquoted `#` comments out the rest of an interactive line. **`DES-TRIGGERS-UNIFIED-FILE` UNCHANGED, checked, and the checking is the point**: four lines inside it, 160 to 174 characters from earlier edits that appended mid-paragraph, are re-wrapped BY HAND. A word-level diff of every entry in this file against `origin/main`, this history table excepted, reports the `DES-JOB-USER-INFERRED-READ-BACK-ON-REQUEST` sentences above and NOTHING else, which is the evidence that the wraps moved no word; a mechanical reflow was rejected rather than untried, because it merges bold-led paragraphs and drags the file's em dashes into lines that did not have them. **No other `DES-` entry changes, checked**: no decision, rejection or residual moves, and the rest of the round is test pins for behaviour that already shipped. Those pins, for the record, because each existed as a mutation that passed: an explicit `User=root` and `User=0` in doctor's system-unit warning (a change skipping uid 0 was green); the uid-0 fix text above; `podman ps -a --external` in the `ephemeral:name-held` fix, the only listing that shows a storage container another tool made (#352); and a RESUMED session in the CI job-user e2e, where the store had only ever staged a cold, empty transcript, so nothing proved the canonical file is readable by the job user out of the 0700 directory the worker copies it into. The e2e's own negative-control counter is incremented with it, because that script refuses to pass unless every declared control ran and a control that does not raise the count is vacuous by its own design. The three surviving `dropNetwork` mutants #352 also lists were killed by this round's earlier phase, not here: `networkAbsentInDaemonWords` replaced that rule, and dropping its `code !== 0` guard, reading stderr alone or matching case-sensitively each fail the test that phase added. The comment at the top of `worker/test/podman-doc.test.mjs` gains #357 item 4's lesson, and getting that lesson right took a second pass worth recording. The first version said each round's answer to the recurring refusal-timing prose had been a wider regex in that file, and that `9cb2bce` stopped the page describing timing. Neither is true: `REFUSED_ENTRY_POINT` was written once and has been byte-identical since, it governs the ENTRY-POINTS table rather than the refusal prose, and `9cb2bce` moved the timing PARAGRAPHS off the page while leaving a timing clause in each of the eight refusal headings -- one of which the very next commit, `0335146`, had to correct from `(at boot)` to the conditional form. So the recurrence ran to four rounds, not three, and no regex was ever widened to chase it. The comment records that and the prescription it earns: derive the claim from what a function returns, or delete the sentence; a regex over prose pins its shape and never its truth. **Code evidence**: worker/src/doctor.mjs -> jobUserChecks; worker/test/doctor.test.mjs; worker/test/podman-doc.test.mjs; .github/scripts/job-user-e2e.mjs. | +| 2026-09-21 | Issue #342 and issue #357 item 3, the backends page. **`DES-CONTAINER-BACKEND-REGISTRY` AMENDED**, one bullet, and it is new ground against this entry's own framing rather than a revision row beside it: the entry says the table exists so a second venue can be added "without reading the worker's source" and that an adapter is "written elsewhere by someone who will never run `npm test` here", so declaring registration IN-TREE ONLY had to be argued inside the entry or it would have read as its opposite. The argument is that it costs an adapter author nothing they were not already paying: `BACKENDS_TABLE`'s declaration is in-tree BY NECESSITY, on this entry's own ground that a venue nobody can read a declaration for is a venue nobody can reason about, so an adapter already lands one file here and its code can still live anywhere. Exporting `startWorker` was the alternative and is refused because its second argument is the worker's whole dependency-injection bag, test seams included, and an export makes every seam a public API a release has to keep. #342 asked to be decided alongside the Podman route rather than after it, and this decides it: #354's `podman` backend is specified IN-TREE, so it needs no export and nothing in this refusal blocks it. A review round caught this row still carrying the opposite claim after the entry above it had been corrected, which is the failure mode worth naming: a sentence fixed where it was pointed out and left standing at its second site. **`docs/backends.md` gains the job-user TIMING paragraph and its first test of any kind.** The page's refusal sentence named seven of the code's eight causes in six clauses, one of which covers two, and omitted `runtime-unreadable` entirely, which is what an untested page does; it now names both sets, boot-refusing and per-job, and states what is in NEITHER: a daemon that has not answered yet reads `unknown`, never cached and never a boot exit, and a job picked up meanwhile is an infra RETRY rather than a refusal (`CONST-RETRY-INFRA-ONLY`, `processor.mjs` throws where a policy refusal returns). **THE TEST GENERATES THE TWO LINES AND REQUIRES THEM VERBATIM, and how it got there is the part worth keeping.** It began as an EXTRACTOR: find a marked block, pull the backticked names out of it, compare the sets. An adversarial review walked most of a set of wrong pages past it, because the lists were derived and nothing pinned WHICH TEXT was being read. It was hardened, and a second pass walked ten more through, each a new hiding place rather than a new idea: a correct copy inside an HTML comment, a comment closed by a plain arrow in ordinary prose so the stripper ate the visible text around it, a link reference definition that renders as nothing, non-breaking hyphens that render identically, a decoy marker pair, the block's own end marker moved up. Two repairs bought one round each, and the hardening also began failing an HONEST page, because the per-image refusal id `job-image-any-uid-unsupported` contains a cause name as a bare substring. So the rule was replaced rather than widened a third time, which is the round cap's own instruction: the test now BUILDS each line from `JOB_USER_FIX` and `jobUserBootRefusal` and asserts the page carries it, trimmed, at the start of exactly one line. There is one correct string and the page either has it or does not. Removing the extractor removed its comment strip too, and the final review showed what that cost: a line inside a multi-line comment is still a line, so both lists could be moved into one, or deleted with a copy buried at EOF, or silently swallowed by deleting the single closing delimiter of the page's own instruction comment. The strip is back, with an unclosed-comment refusal beside it, and the direction is why that is safe rather than a return to the arms race: a stripper can only REMOVE candidate lines, so its worst outcome is a false red on an honest page and never a pass on a wrong one. The markers are gone with the extractor. A second rule counts each cause's BACKTICKED form page-wide and requires exactly one, which kills a contradicting copy elsewhere on the page and cannot collide with the longer id. The bolt is `jobUserBootRefusal(decision, defaultBackend)` rather than `BOOT_REFUSING_JOB_USER_CAUSES`, because the page's claim is not that four names sit in a Set, it is that they stop a boot AND only while `local` is the default venue, and only the function carries the second half. **THE RESIDUAL IS PROSE, stated rather than overstated after two reviews corrected earlier attempts at stating it**: only the two lines and the cause names are derived, so a third bullet, a sentence claiming the two headings were swapped in some release, a redefinition of "the boot" as the job container's, or an invented promise about `pi-dispatch doctor` all pass. Each was demonstrated green and none is reachable by a pattern; `podman-doc.test.mjs` records the same limit and what four rounds of trying to pattern it cost. **`DES-JOB-USER-INFERRED-READ-BACK-ON-REQUEST` UNCHANGED, checked, and cited**: its residual already carries the conditional this paragraph states, corrected under #348 earlier in this round, so the page and the entry landed agreeing rather than one correcting the other. **`DES-PODMAN-THROUGH-ITS-DOCKER-API` UNCHANGED, checked**: no route, refusal or measurement moves. **`DES-WORKER-ON-HOST` UNCHANGED, checked**: the worker still runs on the host and shells out. `worker/package.json`'s exports map is byte-unchanged and `worker/test/publish.test.mjs` still passes, which is the check that would have caught an export slipping in with the prose. One measurement lands with it, the last of #357's four items: `docs/egress.md` hedged an external name resolved directly from an internal network as "about 10 seconds on the host this was first measured on". Measured on Docker DESKTOP 27.4.0 (macOS) instead, which is the host shape the issue asked for, and the variable was the wrong one: the resolver INSIDE the container decides it, because the embedded resolver's upstream is not reachable from an `--internal` network and the forward fails rather than hanging. The job image, Debian and glibc, gives up in about 10 ms (six runs, 6 to 23, all `EAI_AGAIN`) because it takes the embedded resolver's failure as final; a musl image on the same network waits out musl's own 5 s timeout (five runs, 5013 to 5025). So the number that describes a job here is milliseconds, and a multi-second reading is a property of the client. **Code evidence**: docs/backends.md; worker/test/backends-doc.test.mjs; docs/egress.md. | diff --git a/worker/test/backends-doc.test.mjs b/worker/test/backends-doc.test.mjs new file mode 100644 index 00000000..9155e88f --- /dev/null +++ b/worker/test/backends-doc.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { DEFAULT_BACKEND } from "../src/backends.mjs"; +import { JOB_USER_FIX } from "../src/job-user.mjs"; +import { jobUserBootRefusal } from "../src/start.mjs"; + +// `docs/backends.md` names WHEN each job-user refusal fires, which is a restatement of a derivable source, so it is +// BOLTED to that source (CLAUDE.md: a hand-written table is derived or pinned, never trusted). Until this file there +// was NO test reading that page at all, which is why its own refusal sentence had drifted to naming seven of the +// code's eight causes in six clauses, omitting `runtime-unreadable` entirely. +// +// THIS FILE GENERATES THE TWO LINES AND REQUIRES THEM VERBATIM. It does not parse them back out of the page, and +// that is the whole design rather than a detail. The first version did parse: it found a marked block, pulled the +// backticked names out of it and compared the sets. An adversarial review got ELEVEN wrong pages past it; the lists +// were genuinely derived, but nothing pinned WHICH TEXT was being read. It was hardened, and the second pass got ten +// more through, each one a new hiding place rather than a new idea: a correct copy inside an HTML comment, a comment +// closed by a plain `-->` in ordinary prose so the stripper ate the visible text around it, a link reference +// definition that renders as nothing, non-breaking hyphens that render identically, a decoy marker pair, a narrowed +// marker pair. Every repair bought one round. Generating the line ends that class by construction: there is one +// correct string and the page either contains it or does not, so a hiding place buys nothing once the comments a +// reader never sees are removed first. +// +// The bolt is `jobUserBootRefusal(decision, defaultBackend)` rather than `BOOT_REFUSING_JOB_USER_CAUSES`, and the +// distinction is the point: the page's claim is not that four names sit in a Set, it is that they stop a boot AND +// only while `local` is the default venue. Only the function carries the second half, and it is exported +// (`start.mjs`) precisely so the branch a one-venue build cannot reach stays pinned. +// +// WHAT REMAINS UNPINNED, stated rather than overstated, because two reviews in a row corrected an earlier claim +// about this file's reach. Only the two list lines and the cause names are derived. Every other sentence on that +// page is prose, and prose can move a claim without moving a name: a third bullet, a sentence saying the two +// headings were swapped in some release, a redefinition of "the boot" as the job container's, an invented promise +// that `pi-dispatch doctor` clears the boot-stopping causes. All of those were demonstrated green and none is +// reachable by a pattern. `worker/test/podman-doc.test.mjs` records the same limit and what it cost to learn: the +// answer to prose that keeps being wrong is fewer such sentences, never a bigger regex. +// +// One more thing the generator does NOT prove, since a residual that lists only the easy half is the same sin: +// the second line is the COMPLEMENT of the first, not an observed behaviour. `stopsBoot` asks about a synthetic +// `{ mode: "unmappable", cause }` for every `JOB_USER_FIX` key, and `any-uid-unsupported` never reaches +// `decideJobUser` as a decision at all -- `resolveImageUser` returns it as a refusal. So a cause added to +// `JOB_USER_FIX` for doctor's use alone would be filed under "Refuses each job" here and this test would then +// REQUIRE the page to say something false. What bounds that is `worker/test/job-user.test.mjs`, which pins the +// exact eight keys, so such an addition cannot be accidental. + +const doc = readFileSync(new URL("../../docs/backends.md", import.meta.url), "utf8"); + +/** + * The page as a READER sees it. Removing the extractor removed its comment strip with it, and a final review + * showed what that cost: the generated line is matched against trimmed lines, and a line inside a multi-line + * HTML comment is still a line beginning `- **`. Both lists could be moved into a comment while the visible + * prose said the opposite, or deleted outright with a copy buried at the end of the file. The realistic one is + * worse than either: deleting the ` -->` that closes this page's own instruction comment, one token directly + * above the bullets, swallows them both and leaves this file green. + * + * Stripping here cannot reopen the arms race the extractor lost, and the direction is the whole reason it is + * safe: a stripper can only REMOVE candidate lines, so its worst outcome is a false red on an honest page, + * never a pass on a wrong one. The unclosed-comment assertion is what makes that true in both directions. + */ +const VISIBLE = doc.replace(//g, ""); + +const CAUSES = Object.keys(JOB_USER_FIX); +const stopsBoot = (cause) => jobUserBootRefusal({ mode: "unmappable", cause }, DEFAULT_BACKEND) !== null; +const listLine = (label, causes) => `- **${label}**: ${causes.map((cause) => `\`${cause}\``).join(", ")}.`; + +/** The two lines this build says the page must carry, in `JOB_USER_FIX`'s own order so there is one canonical form. */ +const EXPECTED = [listLine("Stops the boot", CAUSES.filter(stopsBoot)), listLine("Refuses each job", CAUSES.filter((cause) => !stopsBoot(cause)))]; + +test("the page carries the two cause lists this build generates, verbatim (#357)", () => { + // Matched against TRIMMED LINES of the visible page, which is what makes a hiding place useless: a link + // reference definition or a table cell is not a line beginning `- **`, a name spelled with a non-breaking + // hyphen is not this string, and a comment is not there at all by the time this runs. The bullet may carry + // prose after the full stop. + assert.ok(!VISIBLE.includes("