@cat-factory/executor-harness 1.144.1 → 1.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,6 +142,19 @@ Three rules bind anything added to it:
142
142
  installed in this image unconditionally, `entrypoint.sh` starts the rootless daemon best-effort
143
143
  and execs the server without waiting for it, so at job start this probe is the only thing that
144
144
  knows how that went.
145
+ - **A daemon that ANSWERS is not a daemon that WORKS**, which is the same mistake one level in and
146
+ the one this block used to make. A rootless daemon nested inside a sandbox serves throughout
147
+ while its snapshotter cannot mount an image layer, so `docker info` succeeds and `docker build`,
148
+ `docker run` and `docker pull` of anything multi-layer all fail on one EINVAL. Issue #2120 is
149
+ three agents in a single run each paying to disprove the claim, against a block that also tells
150
+ them not to re-check it. Only a container that RAN settles it, so the reachable case is split by
151
+ a real workload (`src/docker-capability.ts`) into `usable`, `unusable` and a daemon that answered
152
+ while the check could not be carried out. **Only `usable` may say the commands work**, and the
153
+ asymmetry runs the other way too: a failure of the platform's own machinery reports that it
154
+ could not tell, never that the daemon is broken. That covers every step before the run (no probe
155
+ payload on this machine, a daemon whose architecture the payload is not built for, `docker load`
156
+ refusing the archive) AND the halves of a failed run that are ours rather than the daemon's,
157
+ which is what docker's exit 126/127, a tag that did not resolve and an unexecutable payload are.
145
158
  - **A daemon that is STARTING is not a daemon that is absent.** Because the entrypoint does not
146
159
  wait, the backend dispatches seconds before there is a socket, and `docker info` is then refused
147
160
  at once rather than slowly. So a refusal is read against `DOCKER_HOST`, which the entrypoint sets
@@ -374,19 +387,47 @@ Docker socket would hand the container root on the host.
374
387
 
375
388
  - `GET /health` reports it, so an operator (and a boot-time probe) can see what the container
376
389
  concluded about itself.
377
- - The compose stand-up REFUSES on a decided absence and says why, instead of running compose
390
+ - The compose stand-up REFUSES on a decided negative and says why, instead of running compose
378
391
  against nothing and handing the agent a connection error to interpret. The refusal rides back on
379
- the Tester step as `infraSetup.dockerAvailable: false` with the cause.
392
+ the Tester step as the cause plus the two facts that decide where a human should look:
393
+ `infraSetup.dockerAvailable` (was anything answering) and `infraSetup.dockerWorkload` (what a
394
+ container did on it). Two fields rather than one, because the daemon has two ways to stop a
395
+ stand-up: nothing to talk to, and a daemon that answers and cannot run a container. Flattening
396
+ the second onto `dockerAvailable: false` renders as "no Docker daemon in the executor" and sends
397
+ an operator to restart a daemon that is already up.
398
+
399
+ **What the entrypoint probes for is a SOCKET, and serving is not usable.** That is the whole of
400
+ what a boot record can know, and it is weaker than what either consumer wants: a rootless daemon in
401
+ a sandbox answers `docker version` while being unable to mount an image, so compose ran and died on
402
+ a mount error inside the one mechanism whose job is to explain why the dependencies did not come up
403
+ (issue #2120). The live half of the verdict is therefore a real workload (load a one-layer image
404
+ and run a container from it, `src/docker-capability.ts`), and `resolveDockerVerdict` consults it in
405
+ BOTH directions: a recorded absence a working daemon contradicts, and a recorded presence that
406
+ cannot run anything. `GET /health` reports the last measurement beside the boot record under
407
+ `docker.workload` and never takes one itself, since it is polled; `unmeasured` is one of its answers.
408
+
409
+ **The weaker fact did not stop mattering, though, and it is what a stale record is read against.**
410
+ A workload check can come back undeterminable for reasons that have nothing to do with whether a
411
+ daemon is up (no probe payload in a deployment's own image variant, an architecture it is not built
412
+ for, a `docker load` the engine refuses, a timeout), so falling straight back to the boot record
413
+ there would re-latch the very refusal the paragraph below rules out. The check therefore reports
414
+ `daemonAnswered` alongside its `unknown`, established on its way past at no extra cost, and a
415
+ daemon that merely ANSWERED overrules a recorded absence exactly as the old `docker version` probe
416
+ did. Only a check that never reached a daemon at all leaves the record to decide.
417
+
418
+ The workload check is memoised per container for a POSITIVE answer only. A daemon that has run a
419
+ container proved something that does not stop being true; a negative is re-measured for the same
420
+ reason a recorded absence is, and it fails fast anyway.
380
421
 
381
422
  The verdict is three-valued, and that is the point. `false` is a decided absence. `undefined` is
382
423
  "nothing decided" — the probe is still in flight, or nothing recorded anything at all, which is the
383
424
  normal state under the native host transport (`LOCAL_NATIVE_AGENTS`) where the harness runs on a
384
- developer's machine with no entrypoint. Undecided attempts the stand-up; only a decided absence
385
- refuses it.
425
+ developer's machine with no entrypoint. Undecided attempts the stand-up, and nothing probes it into
426
+ a refusal; only a decided negative refuses.
386
427
 
387
428
  What is recorded describes BOOT, and a container outlives its boot: a warm pool serves many jobs
388
429
  from one, and a sidecar daemon that took longer to come up than the entrypoint's bounded wait
389
- allows is serving perfectly well by the second job. So a recorded absence is a hypothesis, not the
430
+ allows is serving perfectly well by the second job. So a recorded verdict is a hypothesis, not the
390
431
  refusal: `resolveDockerVerdict` re-checks it against a live daemon at the moment a stand-up is
391
432
  about to run, and the record supplies what only the record holds, the cause and the daemon's own
392
433
  log tail. `GET /health` deliberately keeps reporting the boot record rather than probing per poll,
@@ -438,10 +479,13 @@ verdict is stated.
438
479
  | `src/bootstrap-mode.ts` | The repo-bootstrap MODE: clone-a-reference-or-scaffold → run the agent → refuse to push an empty tree → reinit + force-push to the pre-created target repo. |
439
480
  | `src/artifact-upload.ts` | The OUTBOUND half of the artifact seam: parses the body's `artifactUpload` and projects it onto the agent's env as `ARTIFACT_UPLOAD_URL` / `ARTIFACT_UPLOAD_TOKEN`, registering the token for redaction first. Passes through what the body carries and decides nothing: which kinds get the seam is the backend's call. |
440
481
  | `src/codex-images.ts` | Codex's own `image_gen` output, staged where the agent can reach it: creates `$CODEX_HOME/generated_images` as a symlink into `.cat-context/binary-output/generated/` before the CLI starts, sweeps anything a failed redirect left behind, and unlinks (never follows) the redirect at teardown — a failed unlink is REPORTED, because that unlink is what stops the recursive delete reaching the checkout. Exists because codex exposes no path for what it generated AND `$CODEX_HOME` holds the run's decrypted credential, so neither asking the agent nor sending it there is available. |
441
- | `src/environment-inventory.ts` | What the MACHINE holds, probed once per job and appended to the agent's system prompt as an ENVIRONMENT INVENTORY block. The only layer that can state it: the backend composes its prompt before a transport is chosen, and the same body serves this image, a deployment's own variant and the developer's laptop under `LOCAL_NATIVE_AGENTS`. Three-valued on purpose, so a probe that failed renders as unknown rather than as an absence, and the Docker DAEMON is answered by running `docker info` rather than by finding the CLI, which is installed here either way. See [The environment is probed once, not by the agent](#the-environment-is-probed-once-not-by-the-agent). |
482
+ | `src/environment-inventory.ts` | What the MACHINE holds, probed once per job and appended to the agent's system prompt as an ENVIRONMENT INVENTORY block. The only layer that can state it: the backend composes its prompt before a transport is chosen, and the same body serves this image, a deployment's own variant and the developer's laptop under `LOCAL_NATIVE_AGENTS`. Three-valued on purpose, so a probe that failed renders as unknown rather than as an absence; Docker gets FIVE, because a daemon that answers `docker info` is not a daemon that can run a container. See [The environment is probed once, not by the agent](#the-environment-is-probed-once-not-by-the-agent). |
442
483
  | `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
443
484
  | `src/logger.ts` | Structured logging. |
444
- | `src/docker-status.ts` | This container's own verdict about its Docker daemon, as recorded by `entrypoint.sh`. Three-valued on purpose: a daemon that FAILED and a daemon nobody asked about are different facts, and only a DECIDED absence refuses a stand-up. See [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon). |
485
+ | `src/docker-status.ts` | This container's own verdict about its Docker daemon, as recorded by `entrypoint.sh`. Three-valued on purpose: a daemon that FAILED and a daemon nobody asked about are different facts, and only a DECIDED negative refuses a stand-up. See [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon). |
486
+ | `src/docker-capability.ts` | Whether the daemon can RUN A CONTAINER, which is the fact every caller wanted and `docker info` does not answer. Loads a one-layer image built in-process and runs it; `usable` / `unusable` / `unknown`, and only the container RUN may produce the middle one, AND only where the daemon is what refused it (docker's 126/127, a tag that did not resolve and an unexecutable payload are the platform's own machinery, so they say "could not tell"). Total: it answers even if it throws. One budget for the whole pass, cancelled when the last caller abandons it, memoised per container for a positive and re-measured for a negative. |
487
+ | `src/docker-probe-image.ts` | The one-layer docker-archive that check loads, assembled here from a statically linked binary already in the image, so the whole thing is local: no registry, no network, no second image. Pure, byte-stable, and it names the architecture the DAEMON reported rather than this process's. |
488
+ | `src/docker-command.ts` | How the harness runs one `docker …` command on its own behalf: an argv (no shell), a stdin body, stdout kept apart from stderr, one required timeout, the job's signal, and `killChildProcess` for the kill. Not a second `captured-command.ts`, which stays the one way a DECLARED shell command runs; the header says which of its choices this needs to differ on and why. |
445
489
  | `src/agent-env.ts` | The env for anything the harness spawns into the agent's CHECKOUT: its own environment minus the variables that are facts about the HARNESS. Today that is `NODE_ENV` (the harness runs in production mode, and an inherited `NODE_ENV=production` makes npm omit devDependencies in a checkout that never asked for it) and `PORT` (the port this harness is listening on, so a service that reads it would bind the one address in the container's network namespace that is already taken). |
446
490
 
447
491
  ## Runner lifecycle knobs
@@ -468,6 +512,7 @@ runner):
468
512
  | `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content, though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
469
513
  | `HARNESS_DOCKER_READY_TIMEOUT_SECONDS` | `60` | How long `entrypoint.sh` waits for the container's Docker daemon before recording it unavailable. Only a HUNG daemon pays this in full: the wait ends early both when the socket answers and when the daemon process is gone. It runs in the BACKGROUND, so it never delays the container's boot. |
470
514
  | `HARNESS_DOCKER_STATUS_FILE` | `/tmp/harness-docker-status.json` | Where that verdict is recorded. `entrypoint.sh` writes it and the harness reads it, so an override must be set for BOTH (they share one process env). |
515
+ | `HARNESS_DOCKER_PROBE_BINARY` | `/bin/busybox` | The statically linked binary the platform builds its one-layer probe image from, for the check that answers whether this daemon can RUN a container. Only an image variant that ships it elsewhere needs this. Absent is a supported answer, not a failure: the check then reports that it could not be carried out, which is what the native host transport gets on a developer laptop. |
471
516
 
472
517
  ## Build / test
473
518
 
package/dist/agent.js CHANGED
@@ -137,8 +137,11 @@ export async function handleAgent(job, opts = {}) {
137
137
  // This sits on the critical path AHEAD of the clone, which is the cost of having one
138
138
  // composition point instead of one per mode (each mode owns its own clone, so there is no
139
139
  // single post-clone place to put this). The pass is sized for that: everything in it runs
140
- // concurrently, every probe is a call that answers in milliseconds or is wedged, and the one
141
- // deliberate wait is a single short retry for a daemon that is still starting.
140
+ // concurrently, and every probe either answers in milliseconds or is bounded. Two of them are
141
+ // deliberate waits rather than instant answers: one short retry for a daemon that is still
142
+ // starting, and, only once a daemon has answered, the container the platform runs to find out
143
+ // whether it can run one at all (`docker-capability.ts`, budgeted and memoised per container
144
+ // for a positive). Both take the job's signal, so an abandoned run stops paying at once.
142
145
  const staged = {
143
146
  ...job,
144
147
  systemPrompt: await appendEnvironmentInventory(job.systemPrompt, {
@@ -0,0 +1,99 @@
1
+ import { type DockerCommandRunner } from './docker-command.js';
2
+ import { type Logger } from './logger.js';
3
+ /** What one measurement concluded. See the three answers above; nothing collapses them. */
4
+ export type DockerWorkload = {
5
+ status: 'usable';
6
+ } | {
7
+ status: 'unusable';
8
+ detail: string;
9
+ } | {
10
+ status: 'unknown';
11
+ reason: string;
12
+ /**
13
+ * Whether a daemon ANSWERED before the check ran out of things it could do. A weaker fact
14
+ * than the check exists to establish, and the one a stale boot record must be read against.
15
+ */
16
+ daemonAnswered: boolean;
17
+ };
18
+ /**
19
+ * A one-slot memo for the assembled archive.
20
+ *
21
+ * The archive is byte-stable for one `(payload path, architecture)` pair by construction
22
+ * (docker-probe-image.ts pins every timestamp for exactly this reason), and neither half changes
23
+ * within a process. Since a NEGATIVE verdict is re-measured on purpose and two sites ask per job,
24
+ * without this the same two megabytes are re-read and re-sha256'd four times per job for a value
25
+ * that cannot differ. One slot rather than a map: there is only ever one key.
26
+ */
27
+ export interface ProbeArchiveMemo {
28
+ read(key: string): Buffer | undefined;
29
+ write(key: string, archive: Buffer): void;
30
+ }
31
+ /** Build a {@link ProbeArchiveMemo}. Supplied only by {@link realDeps}, so a test memoises nothing. */
32
+ export declare function oneSlotArchiveMemo(): ProbeArchiveMemo;
33
+ /** What a measurement needs from the machine, so a test can supply all of it. */
34
+ export interface DockerWorkloadDeps {
35
+ /** Read the probe payload. Rejecting (ENOENT) is the supported "this machine has none". */
36
+ readPayload: (path: string) => Promise<Buffer>;
37
+ payloadPath: string;
38
+ runDocker: DockerCommandRunner;
39
+ /** This process's architecture, as `process.arch` spells it: the PAYLOAD's, never the daemon's. */
40
+ arch: string;
41
+ logger?: Logger;
42
+ archives?: ProbeArchiveMemo;
43
+ }
44
+ /**
45
+ * Carry out one measurement. Pure of caching, so the suite states every branch directly.
46
+ *
47
+ * TOTAL: it never rejects, whatever happens inside it. The thing it replaced was total by
48
+ * construction (a `try/catch` around one `execFile`), and it is consulted from a stand-up path
49
+ * documented as best-effort, so a throw here would fail a job over a probe whose whole purpose is
50
+ * to make a failure legible. A throw is also, by definition, the platform's own machinery
51
+ * breaking, which is the `unknown` disposition and never the `unusable` one.
52
+ *
53
+ * That asymmetry is the design. Only the RUN produces `unusable`; everything before it produces
54
+ * `unknown`, because everything before it is the platform's own machinery and a bug in it must be
55
+ * able to say "I could not tell" and never "your daemon is broken". The load step in particular is
56
+ * the one this repo wrote itself.
57
+ */
58
+ export declare function measureDockerWorkload(deps?: DockerWorkloadDeps, signal?: AbortSignal): Promise<DockerWorkload>;
59
+ /**
60
+ * A measurement, plus what the last one concluded without taking another.
61
+ *
62
+ * Callable because every caller wants the verdict; `last()` exists for `GET /health`, which is
63
+ * polled and must not spawn a container per poll to answer a question it does not act on.
64
+ */
65
+ export interface DockerWorkloadProbe {
66
+ (signal?: AbortSignal): Promise<DockerWorkload>;
67
+ last(): DockerWorkload | undefined;
68
+ }
69
+ /**
70
+ * Build a probe that measures at most once per container for a POSITIVE answer.
71
+ *
72
+ * A daemon that has run a container has proved something that does not stop being true, so that
73
+ * verdict is kept and every later job reads it for free. A negative is NOT kept, for the reason
74
+ * `resolveDockerVerdict` gives about the boot record: a container outlives its boot, a warm pool
75
+ * serves many jobs from one, and a daemon that was not ready for the first job must not latch
76
+ * the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
77
+ * mount fails at once.
78
+ *
79
+ * Concurrent callers share one in-flight measurement rather than each starting a container, and
80
+ * the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
81
+ * one job's abort may not kill a measurement a sibling job is still waiting on (the local native
82
+ * transport serves every concurrent job from one process), and a measurement nobody is waiting
83
+ * for is a container start no job will read, which is what an abandoned run should stop paying
84
+ * for the moment it is abandoned.
85
+ */
86
+ export declare function createDockerWorkloadProbe(deps?: DockerWorkloadDeps): DockerWorkloadProbe;
87
+ /** The process-wide probe. One per container, which is what makes the positive memo worth having. */
88
+ export declare const probeDockerWorkload: DockerWorkloadProbe;
89
+ /**
90
+ * What `GET /health` reports about the workload check.
91
+ *
92
+ * `unmeasured` is its own word rather than an omitted key or a `null`: this endpoint is polled
93
+ * from boot, so "nothing has needed the daemon yet" is the normal early answer and it must not
94
+ * read as either a broken daemon or a build that cannot report one.
95
+ */
96
+ export declare function reportedDockerWorkload(probe?: DockerWorkloadProbe): DockerWorkload | {
97
+ status: 'unmeasured';
98
+ reason: string;
99
+ };
@@ -0,0 +1,351 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { spawnDockerCommand, } from './docker-command.js';
3
+ import { buildProbeArchive, payloadArchitecture, PROBE_COMMAND, PROBE_IMAGE_TAG, PROBE_SENTINEL, } from './docker-probe-image.js';
4
+ import { log } from './logger.js';
5
+ import { redactSecrets } from './redact.js';
6
+ /**
7
+ * The statically linked binary the probe image is built from, overridable for an image variant
8
+ * that ships it elsewhere. Absent is a supported answer, not a failure: under
9
+ * `LOCAL_NATIVE_AGENTS` the harness runs on a developer's machine that never saw this image.
10
+ */
11
+ const PAYLOAD_PATH = process.env.HARNESS_DOCKER_PROBE_BINARY?.trim() || '/bin/busybox';
12
+ /**
13
+ * The ceiling on ONE WHOLE measurement, shared out across the docker commands it makes: each
14
+ * gets what is left of it, down to {@link MIN_COMMAND_MS}.
15
+ *
16
+ * One budget rather than a per-command ceiling, because a per-command one multiplies: three
17
+ * commands at 30s each is a minute and a half of dead time on a wedged daemon, on the critical
18
+ * path ahead of the clone. Sized for a WEDGED daemon and not for a slow one: the payload is a
19
+ * couple of megabytes already on local disk, so a daemon that works answers in about the time it
20
+ * takes to start one container, and a daemon that cannot mount fails immediately.
21
+ *
22
+ * What it does NOT do is bound the cost per JOB, and the comment that once claimed so was wrong.
23
+ * A POSITIVE verdict is memoised for the container's life; a negative is deliberately
24
+ * re-measured (see {@link createDockerWorkloadProbe}), and two independent sites ask per job (the
25
+ * environment inventory and the compose stand-up), so a serving-but-wedged daemon costs this
26
+ * twice per job. That is the price of not latching a warm container into a stale refusal, which
27
+ * is why the budget is the size it is and why an abandoned job stops paying it at once.
28
+ */
29
+ const WORKLOAD_BUDGET_MS = 20_000;
30
+ /** The floor on one command's share of the budget, so an exhausted budget still gets an answer. */
31
+ const MIN_COMMAND_MS = 1_000;
32
+ /**
33
+ * The ceiling on removing the probe image again.
34
+ *
35
+ * Its own, and deliberately NOT given the caller's abort signal: the image is the platform's, and
36
+ * a cancelled job is the one case where nobody is left to clean up after it. Bounded separately
37
+ * so a wedged daemon cannot turn the cleanup into a second full budget.
38
+ */
39
+ const CLEANUP_TIMEOUT_MS = 5_000;
40
+ /** How much of a failing command's output is kept. It is quoted into an agent's system prompt. */
41
+ const DETAIL_CHARS = 300;
42
+ /** Build a {@link ProbeArchiveMemo}. Supplied only by {@link realDeps}, so a test memoises nothing. */
43
+ export function oneSlotArchiveMemo() {
44
+ let held;
45
+ return {
46
+ read: (key) => (held?.key === key ? held.archive : undefined),
47
+ write: (key, archive) => {
48
+ held = { key, archive };
49
+ },
50
+ };
51
+ }
52
+ const realDeps = {
53
+ readPayload: (path) => readFile(path),
54
+ payloadPath: PAYLOAD_PATH,
55
+ runDocker: spawnDockerCommand,
56
+ arch: process.arch,
57
+ archives: oneSlotArchiveMemo(),
58
+ };
59
+ /**
60
+ * Carry out one measurement. Pure of caching, so the suite states every branch directly.
61
+ *
62
+ * TOTAL: it never rejects, whatever happens inside it. The thing it replaced was total by
63
+ * construction (a `try/catch` around one `execFile`), and it is consulted from a stand-up path
64
+ * documented as best-effort, so a throw here would fail a job over a probe whose whole purpose is
65
+ * to make a failure legible. A throw is also, by definition, the platform's own machinery
66
+ * breaking, which is the `unknown` disposition and never the `unusable` one.
67
+ *
68
+ * That asymmetry is the design. Only the RUN produces `unusable`; everything before it produces
69
+ * `unknown`, because everything before it is the platform's own machinery and a bug in it must be
70
+ * able to say "I could not tell" and never "your daemon is broken". The load step in particular is
71
+ * the one this repo wrote itself.
72
+ */
73
+ export async function measureDockerWorkload(deps = realDeps, signal) {
74
+ const seen = { daemonAnswered: false };
75
+ try {
76
+ return await measure(deps, seen, signal);
77
+ }
78
+ catch (err) {
79
+ const cause = describeThrown(err);
80
+ (deps.logger ?? log).warn('docker capability: the container check itself fell over', {
81
+ error: cause,
82
+ });
83
+ return undeterminable(`the platform's own container check could not be completed (${cause})`, seen.daemonAnswered);
84
+ }
85
+ }
86
+ async function measure(deps, seen, signal) {
87
+ const deadline = Date.now() + WORKLOAD_BUDGET_MS;
88
+ const share = () => Math.max(MIN_COMMAND_MS, deadline - Date.now());
89
+ const command = (args, stdin) => deps.runDocker(args, {
90
+ ...(stdin ? { stdin } : {}),
91
+ ...(signal ? { signal } : {}),
92
+ timeoutMs: share(),
93
+ ...(deps.logger ? { logger: deps.logger } : {}),
94
+ });
95
+ // Ask the DAEMON which architecture it runs, rather than assuming it shares this process's.
96
+ // An external `DOCKER_HOST` is a supported path, and an arm64 harness against an amd64 sidecar
97
+ // (or a remote x86_64 daemon reached from an arm64 laptop) shares nothing with it but a socket:
98
+ // declaring the wrong one in the image config gets the run refused, which would report a
99
+ // perfectly good daemon as one that cannot run containers. It is also the cheapest proof that a
100
+ // daemon is answering at all, which is the fact `resolveDockerVerdict` reads back.
101
+ const asked = await command(['version', '--format', '{{.Server.Arch}}']);
102
+ if (asked.outcome !== 'ran' || asked.code !== 0) {
103
+ return undeterminable(`no Docker daemon answered the platform's container check (${describeOutcome(asked)})`, false);
104
+ }
105
+ seen.daemonAnswered = true;
106
+ const daemonArch = asked.stdout.trim();
107
+ if (!/^[a-z0-9_]+$/.test(daemonArch)) {
108
+ return undeterminable(`the Docker daemon did not name an architecture the platform can build an image for (${redactSecrets(daemonArch).slice(0, 40) || 'it answered nothing'})`, true);
109
+ }
110
+ const payloadArch = payloadArchitecture(deps.arch);
111
+ if (!payloadArch) {
112
+ return undeterminable(`the platform has no container check for the ${deps.arch} architecture`, true);
113
+ }
114
+ if (payloadArch !== daemonArch) {
115
+ return undeterminable(`the platform's container check is built for ${payloadArch} and this daemon runs ${daemonArch}`, true);
116
+ }
117
+ const assembled = await assembleArchive(deps, daemonArch);
118
+ if ('reason' in assembled)
119
+ return undeterminable(assembled.reason, true);
120
+ const load = await command(['load'], assembled.archive);
121
+ if (load.outcome !== 'ran' || load.code !== 0) {
122
+ return undeterminable(`the platform could not load its own probe image (${describeOutcome(load)})`, true);
123
+ }
124
+ const run = await command([
125
+ 'run',
126
+ '--rm',
127
+ '--pull',
128
+ 'never',
129
+ '--network',
130
+ 'none',
131
+ PROBE_IMAGE_TAG,
132
+ ...PROBE_COMMAND,
133
+ ]);
134
+ // Before the verdict, and whatever the verdict is: the probe image is the platform's, and an
135
+ // agent that runs `docker images` should not have to wonder whose it is.
136
+ await removeProbeImage(deps);
137
+ return classifyRun(run);
138
+ }
139
+ /**
140
+ * What the container RUN proves. The one step allowed to conclude `unusable`, and even here not
141
+ * every non-zero exit is evidence about the daemon.
142
+ *
143
+ * Docker splits its own failures from the container's by exit code: 125 is `docker run` itself
144
+ * failing, 126 is a command that could not be invoked and 127 one that was not found. The last
145
+ * two are facts about THE PAYLOAD, a binary this platform put in an image it built, and the
146
+ * container had to be created and started to produce them. 125 covers both the daemon refusing to
147
+ * create the container (the verdict this whole module exists for) and the tag not being there to
148
+ * run, which is our own load, so that one is split by what docker SAID.
149
+ */
150
+ function classifyRun(run) {
151
+ if (run.outcome === 'failed') {
152
+ return undeterminable(`the platform's container check did not run (${run.reason})`, true);
153
+ }
154
+ if (run.code === 0) {
155
+ if (run.stdout.includes(PROBE_SENTINEL))
156
+ return { status: 'usable' };
157
+ // Nothing explains this: the container was reported as having run cleanly and produced none
158
+ // of the output it exists to produce. That is a fact about the check, not about the daemon.
159
+ return undeterminable("the platform's probe container exited cleanly without printing its marker", true);
160
+ }
161
+ const ours = platformSideRunFailure(run);
162
+ return ours
163
+ ? undeterminable(`${ours} (${describeOutcome(run)})`, true)
164
+ : { status: 'unusable', detail: describeOutcome(run) };
165
+ }
166
+ /** Messages that name the PLATFORM's half of a failed run rather than the daemon's. */
167
+ const PLATFORM_SIDE_RUN_MESSAGES = [
168
+ {
169
+ pattern: /no such image|unable to find image/i,
170
+ cause: "the platform's probe image was not there to be run",
171
+ },
172
+ {
173
+ pattern: /exec format error/i,
174
+ cause: "the platform's probe binary cannot be executed on this daemon's machine",
175
+ },
176
+ ];
177
+ function platformSideRunFailure(run) {
178
+ if (run.code === 126 || run.code === 127) {
179
+ return "the platform's probe binary could not be invoked inside the container";
180
+ }
181
+ const said = `${run.stderr}\n${run.stdout}`;
182
+ return PLATFORM_SIDE_RUN_MESSAGES.find((m) => m.pattern.test(said))?.cause;
183
+ }
184
+ /**
185
+ * Assemble the archive for the daemon's architecture, reusing the last one built.
186
+ *
187
+ * A read that fails is classified rather than asserted away: `HARNESS_DOCKER_PROBE_BINARY`
188
+ * pointing at a directory, at a path this user may not read, or at a failing mount is a
189
+ * misconfiguration an operator can fix, and "this machine does not have it" states the opposite
190
+ * fact. The sentence goes into an agent's system prompt and into `GET /health`, so a discarded
191
+ * cause is a cause nobody ever sees.
192
+ */
193
+ async function assembleArchive(deps, architecture) {
194
+ const key = `${deps.payloadPath}::${architecture}`;
195
+ const held = deps.archives?.read(key);
196
+ if (held)
197
+ return { archive: held };
198
+ let payload;
199
+ try {
200
+ payload = await deps.readPayload(deps.payloadPath);
201
+ }
202
+ catch (err) {
203
+ return { reason: describePayloadFailure(err, deps.payloadPath) };
204
+ }
205
+ const archive = buildProbeArchive(payload, architecture);
206
+ deps.archives?.write(key, archive);
207
+ return { archive };
208
+ }
209
+ function describePayloadFailure(err, path) {
210
+ const needs = `the platform's own container check needs ${path}`;
211
+ switch (err.code) {
212
+ case 'ENOENT':
213
+ case 'ENOTDIR':
214
+ return `${needs}, which this machine does not have`;
215
+ case 'EACCES':
216
+ case 'EPERM':
217
+ return `${needs}, which it is not permitted to read`;
218
+ case 'EISDIR':
219
+ return `${needs} to be a file, and it is a directory`;
220
+ default:
221
+ return `${needs}, which could not be read (${describeThrown(err)})`;
222
+ }
223
+ }
224
+ /**
225
+ * Remove the probe image, and SAY SO when that did not work.
226
+ *
227
+ * The one line above it promises an agent will never find a `cat-factory-docker-probe` and wonder
228
+ * whose it is, and the daemon has two ordinary ways to refuse: a `--rm` teardown still in flight
229
+ * holds the image ("image is being used by stopped container"), and a wedged daemon does not
230
+ * answer at all. Discarding the outcome left both silent, so the promise was unverifiable in
231
+ * exactly the states that break it.
232
+ */
233
+ async function removeProbeImage(deps) {
234
+ const removed = await deps.runDocker(['image', 'rm', '-f', PROBE_IMAGE_TAG], {
235
+ timeoutMs: CLEANUP_TIMEOUT_MS,
236
+ ...(deps.logger ? { logger: deps.logger } : {}),
237
+ });
238
+ if (removed.outcome === 'ran' && removed.code === 0)
239
+ return;
240
+ (deps.logger ?? log).warn('docker capability: the probe image could not be removed', {
241
+ image: PROBE_IMAGE_TAG,
242
+ error: describeOutcome(removed),
243
+ });
244
+ }
245
+ function undeterminable(reason, daemonAnswered) {
246
+ return { status: 'unknown', reason, daemonAnswered };
247
+ }
248
+ /** A bounded, scrubbed one-line summary of what a command said, for a prompt or a log field. */
249
+ function describeOutcome(outcome) {
250
+ if (outcome.outcome === 'failed')
251
+ return outcome.reason;
252
+ const said = `${outcome.stderr}\n${outcome.stdout}`
253
+ .split('\n')
254
+ .map((line) => line.trim())
255
+ .filter(Boolean)
256
+ .join('; ');
257
+ return bounded(said) || `docker exited ${outcome.code} without saying why`;
258
+ }
259
+ /** The one describer for a thrown value here: scrubbed and bounded, like any other detail. */
260
+ function describeThrown(err) {
261
+ return bounded(err instanceof Error ? err.message : String(err)) || 'it said nothing';
262
+ }
263
+ function bounded(text) {
264
+ const scrubbed = redactSecrets(text);
265
+ return scrubbed.length > DETAIL_CHARS ? `${scrubbed.slice(0, DETAIL_CHARS)}…` : scrubbed;
266
+ }
267
+ /**
268
+ * Build a probe that measures at most once per container for a POSITIVE answer.
269
+ *
270
+ * A daemon that has run a container has proved something that does not stop being true, so that
271
+ * verdict is kept and every later job reads it for free. A negative is NOT kept, for the reason
272
+ * `resolveDockerVerdict` gives about the boot record: a container outlives its boot, a warm pool
273
+ * serves many jobs from one, and a daemon that was not ready for the first job must not latch
274
+ * the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
275
+ * mount fails at once.
276
+ *
277
+ * Concurrent callers share one in-flight measurement rather than each starting a container, and
278
+ * the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
279
+ * one job's abort may not kill a measurement a sibling job is still waiting on (the local native
280
+ * transport serves every concurrent job from one process), and a measurement nobody is waiting
281
+ * for is a container start no job will read, which is what an abandoned run should stop paying
282
+ * for the moment it is abandoned.
283
+ */
284
+ export function createDockerWorkloadProbe(deps = realDeps) {
285
+ let latest;
286
+ let inFlight;
287
+ const begin = () => {
288
+ const cancel = new AbortController();
289
+ const measurement = {
290
+ cancel,
291
+ waiters: 0,
292
+ result: measureDockerWorkload(deps, cancel.signal).then((verdict) => {
293
+ latest = verdict;
294
+ if (inFlight === measurement)
295
+ inFlight = undefined;
296
+ return verdict;
297
+ }),
298
+ };
299
+ return measurement;
300
+ };
301
+ const probe = (async (signal) => {
302
+ if (latest?.status === 'usable')
303
+ return latest;
304
+ const measurement = (inFlight ??= begin());
305
+ measurement.waiters += 1;
306
+ const watch = signal ? watchAbandonment(signal) : undefined;
307
+ try {
308
+ return watch
309
+ ? await Promise.race([measurement.result, watch.abandoned])
310
+ : await measurement.result;
311
+ }
312
+ finally {
313
+ watch?.dispose();
314
+ measurement.waiters -= 1;
315
+ if (measurement.waiters === 0 && inFlight === measurement)
316
+ measurement.cancel.abort();
317
+ }
318
+ });
319
+ probe.last = () => latest;
320
+ return probe;
321
+ }
322
+ /**
323
+ * A verdict for the caller whose job was cancelled while it waited, and the listener teardown
324
+ * that keeps a long-lived native-transport process from accumulating one per job.
325
+ */
326
+ function watchAbandonment(signal) {
327
+ let give = () => { };
328
+ const abandoned = new Promise((resolve) => {
329
+ give = () => resolve(undeterminable("the job was cancelled before the platform's container check answered", false));
330
+ if (signal.aborted)
331
+ give();
332
+ else
333
+ signal.addEventListener('abort', give, { once: true });
334
+ });
335
+ return { abandoned, dispose: () => signal.removeEventListener('abort', give) };
336
+ }
337
+ /** The process-wide probe. One per container, which is what makes the positive memo worth having. */
338
+ export const probeDockerWorkload = createDockerWorkloadProbe();
339
+ /**
340
+ * What `GET /health` reports about the workload check.
341
+ *
342
+ * `unmeasured` is its own word rather than an omitted key or a `null`: this endpoint is polled
343
+ * from boot, so "nothing has needed the daemon yet" is the normal early answer and it must not
344
+ * read as either a broken daemon or a build that cannot report one.
345
+ */
346
+ export function reportedDockerWorkload(probe = probeDockerWorkload) {
347
+ return (probe.last() ?? {
348
+ status: 'unmeasured',
349
+ reason: 'nothing in this container has needed the docker daemon yet',
350
+ });
351
+ }
@@ -0,0 +1,30 @@
1
+ import { type Logger } from './logger.js';
2
+ /** What running one docker command did, kept as raw as the spawn. */
3
+ export type CommandOutcome = {
4
+ outcome: 'ran';
5
+ code: number;
6
+ stdout: string;
7
+ stderr: string;
8
+ } | {
9
+ outcome: 'failed';
10
+ reason: string;
11
+ };
12
+ /** What one docker invocation is given. `timeoutMs` is required: an unbounded one has no caller. */
13
+ export interface DockerCommandOptions {
14
+ /** Piped to the command's stdin and closed. */
15
+ stdin?: Buffer;
16
+ /** The job's signal. An abandoned job's command is killed rather than left running. */
17
+ signal?: AbortSignal;
18
+ timeoutMs: number;
19
+ logger?: Logger;
20
+ }
21
+ /** Run one `docker …` command. Injected so the suite drives every branch with no daemon. */
22
+ export type DockerCommandRunner = (args: string[], opts: DockerCommandOptions) => Promise<CommandOutcome>;
23
+ /**
24
+ * The real runner: spawn docker, feed it `stdin` when there is any, and report what happened.
25
+ *
26
+ * Never rejects. Every way a spawn can go wrong is one of the two outcomes, because the caller
27
+ * classifies them differently and an exception would collapse that distinction into whichever
28
+ * `catch` caught it first.
29
+ */
30
+ export declare const spawnDockerCommand: DockerCommandRunner;