@cat-factory/executor-harness 1.145.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.
@@ -0,0 +1,91 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { log } from './logger.js';
3
+ import { killChildProcess, spawnDetached } from './process.js';
4
+ // ---------------------------------------------------------------------------
5
+ // How the harness runs one `docker …` command ON ITS OWN BEHALF, bounded and abortable.
6
+ //
7
+ // This is NOT a second `runCapturedCommand` (captured-command.ts), which stays the one way the
8
+ // harness runs a DECLARED shell command: that one takes a shell string, merges both streams into
9
+ // one rolling tail and answers with a conventional exit code, because its two callers report a
10
+ // pass/fail plus a tail to a model. The docker checks need the three things it deliberately does
11
+ // not offer: an argv (no shell, so nothing quotes an image tag), a STDIN body (the probe archive
12
+ // is piped to `docker load`), and stdout kept APART from stderr, since the whole evidence that a
13
+ // container ran is a marker on stdout while the evidence of why it did not is on stderr.
14
+ //
15
+ // What it does NOT re-decide is how a child dies: `killChildProcess` owns the SIGTERM→SIGKILL
16
+ // escalation for every process this harness spawns, and a bespoke `SIGKILL` here would be one
17
+ // path whose kill semantics drift from the rest with no test able to see it.
18
+ // ---------------------------------------------------------------------------
19
+ /** How much of each stream is buffered. The TAIL is kept: that is where a failure prints. */
20
+ const OUTPUT_CAP_CHARS = 64 * 1024;
21
+ /**
22
+ * The real runner: spawn docker, feed it `stdin` when there is any, and report what happened.
23
+ *
24
+ * Never rejects. Every way a spawn can go wrong is one of the two outcomes, because the caller
25
+ * classifies them differently and an exception would collapse that distinction into whichever
26
+ * `catch` caught it first.
27
+ */
28
+ export const spawnDockerCommand = (args, opts) => new Promise((resolve) => {
29
+ const logger = opts.logger ?? log;
30
+ if (opts.signal?.aborted) {
31
+ resolve({ outcome: 'failed', reason: abandonedReason(args) });
32
+ return;
33
+ }
34
+ const child = spawn('docker', args, {
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ detached: spawnDetached,
37
+ windowsHide: true,
38
+ });
39
+ let stdout = '';
40
+ let stderr = '';
41
+ let settled = false;
42
+ const finish = (result) => {
43
+ if (settled)
44
+ return;
45
+ settled = true;
46
+ clearTimeout(timer);
47
+ opts.signal?.removeEventListener('abort', onAbort);
48
+ resolve(result);
49
+ };
50
+ const timer = setTimeout(() => {
51
+ logger.warn('docker: command did not answer in time, killing it', {
52
+ command: args[0] ?? '',
53
+ timeoutMs: opts.timeoutMs,
54
+ });
55
+ killChildProcess(child, undefined, logger);
56
+ finish({
57
+ outcome: 'failed',
58
+ reason: `\`docker ${args[0] ?? ''}\` did not answer within ${Math.round(opts.timeoutMs / 1000)}s`,
59
+ });
60
+ }, opts.timeoutMs);
61
+ timer.unref?.();
62
+ const onAbort = () => {
63
+ killChildProcess(child, undefined, logger);
64
+ finish({ outcome: 'failed', reason: abandonedReason(args) });
65
+ };
66
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
67
+ // The tail, not the head: a `docker run` that failed says why in its last lines, and the
68
+ // marker a passing one prints is the whole of its output anyway.
69
+ child.stdout.on('data', (chunk) => {
70
+ stdout = (stdout + chunk.toString('utf8')).slice(-OUTPUT_CAP_CHARS);
71
+ });
72
+ child.stderr.on('data', (chunk) => {
73
+ stderr = (stderr + chunk.toString('utf8')).slice(-OUTPUT_CAP_CHARS);
74
+ });
75
+ child.on('error', (err) => {
76
+ finish({
77
+ outcome: 'failed',
78
+ reason: err.code === 'ENOENT'
79
+ ? 'the docker CLI is not on PATH'
80
+ : `the docker CLI could not be spawned (${err.code ?? err.message})`,
81
+ });
82
+ });
83
+ child.on('close', (code) => finish({ outcome: 'ran', code: code ?? -1, stdout, stderr }));
84
+ // A daemon that dies mid-load closes the pipe under us; `close` above already reports that,
85
+ // so the EPIPE here has nothing to add and must not become an unhandled error event.
86
+ child.stdin.on('error', () => { });
87
+ child.stdin.end(opts.stdin);
88
+ });
89
+ function abandonedReason(args) {
90
+ return `the job was cancelled before \`docker ${args[0] ?? ''}\` answered`;
91
+ }
@@ -0,0 +1,36 @@
1
+ /** The tag the probe image is loaded under. Removed again once the check has answered. */
2
+ export declare const PROBE_IMAGE_TAG = "cat-factory-docker-probe:1";
3
+ /**
4
+ * What the container must print for the check to pass.
5
+ *
6
+ * A marker on stdout rather than a zero exit status: the point of the check is that a process
7
+ * inside the container actually ran, and only output it produced proves that. An exit status is
8
+ * the daemon's word for it.
9
+ */
10
+ export declare const PROBE_SENTINEL = "cat-factory-docker-probe-ok";
11
+ /** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
12
+ export declare const PROBE_COMMAND: readonly string[];
13
+ /** The docker name for the architecture THIS process's payload is built for, when there is one. */
14
+ export declare function payloadArchitecture(arch?: string): string | undefined;
15
+ interface TarEntry {
16
+ name: string;
17
+ content: Buffer;
18
+ mode: number;
19
+ }
20
+ /** A whole tar stream: the members, then the two zero blocks that terminate one. */
21
+ export declare function tarArchive(entries: readonly TarEntry[]): Buffer;
22
+ /**
23
+ * Assemble the docker-archive `docker load` reads, from one statically linked binary.
24
+ *
25
+ * Classic (v1) docker-archive rather than OCI layout: `docker load` accepts both on every engine
26
+ * this image can run against, and the v1 shape is three files with no blob directory to get
27
+ * wrong. The layer digest is the sha256 of the UNCOMPRESSED layer tar, which is what
28
+ * `rootfs.diff_ids` means; an engine that disagrees with it refuses the load, which the caller
29
+ * reads as could-not-determine rather than as a broken daemon.
30
+ *
31
+ * `architecture` is the DAEMON's own word for its architecture, in docker's vocabulary, so
32
+ * nothing here decides it (see {@link PAYLOAD_ARCHITECTURES}). The result is byte-stable for one
33
+ * `(payload, architecture)` pair, which is what lets the caller build it once per container.
34
+ */
35
+ export declare function buildProbeArchive(payload: Buffer, architecture: string): Buffer;
36
+ export {};
@@ -0,0 +1,148 @@
1
+ import { createHash } from 'node:crypto';
2
+ // ---------------------------------------------------------------------------
3
+ // The one-container image the platform runs to find out whether this machine's Docker daemon
4
+ // can actually run a container, built here in memory rather than pulled.
5
+ //
6
+ // It exists because `docker info` answers a different question from the one every caller
7
+ // actually asks. A daemon that ANSWERS is not a daemon that WORKS: this container's rootless
8
+ // daemon runs inside whatever sandbox the deployment gave it, and a nested user namespace
9
+ // routinely refuses the overlay mount every image materialisation needs. The daemon serves
10
+ // happily, `docker version` reports a server, and `docker pull` of a multi-layer image,
11
+ // `docker run` of a single-layer one and `docker build` all fail with the same EINVAL. Issue
12
+ // #2120 is three agents in one run each discovering that for themselves, against a system
13
+ // prompt that told them, as stated fact, that Docker worked here.
14
+ //
15
+ // Why the payload is BUILT and not pulled: a probe that needs the network answers a question
16
+ // about the registry as much as about the daemon, cannot run in a sandbox with no egress, and
17
+ // costs the job its first turn. This one is a single layer holding one statically linked
18
+ // binary already in the image, assembled into a docker-archive tar and handed to `docker load`
19
+ // on stdin, so the whole check is local and takes about as long as starting one container.
20
+ //
21
+ // ONE layer, deliberately. The reported failure kills `docker run` of a single-layer image too
22
+ // (the container's own writable layer is already a second overlay lower dir), so one layer is
23
+ // enough to detect it, and it keeps `docker load` (the step this file could plausibly get WRONG)
24
+ // as small as it can be. That matters because the caller reads a load failure as "could
25
+ // not determine" and a RUN failure as "this daemon cannot run containers": a bug in the archive
26
+ // below must never be able to tell an agent that a working daemon is broken.
27
+ // ---------------------------------------------------------------------------
28
+ /** The tag the probe image is loaded under. Removed again once the check has answered. */
29
+ export const PROBE_IMAGE_TAG = 'cat-factory-docker-probe:1';
30
+ /** Where the payload binary lands inside the probe image, and what the container is asked to run. */
31
+ const PROBE_BINARY_PATH = 'busybox';
32
+ /**
33
+ * What the container must print for the check to pass.
34
+ *
35
+ * A marker on stdout rather than a zero exit status: the point of the check is that a process
36
+ * inside the container actually ran, and only output it produced proves that. An exit status is
37
+ * the daemon's word for it.
38
+ */
39
+ export const PROBE_SENTINEL = 'cat-factory-docker-probe-ok';
40
+ /** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
41
+ export const PROBE_COMMAND = [`/${PROBE_BINARY_PATH}`, 'echo', PROBE_SENTINEL];
42
+ /**
43
+ * Node's architecture names mapped onto the docker name for the same machine.
44
+ *
45
+ * This names THE PAYLOAD, never the daemon. `process.arch` is the architecture of the harness
46
+ * process, and the binary it hands over is built for that; the daemon it is measured against
47
+ * answers for itself (`docker version --format {{.Server.Arch}}`), and the caller compares the
48
+ * two rather than assuming they agree. An external `DOCKER_HOST` is a first-class path here, and
49
+ * an arm64 harness talking to an amd64 sidecar shares nothing with it but the socket.
50
+ *
51
+ * That comparison is also what makes two of these entries safe. `process.arch` reports `ppc64` on
52
+ * both endiannesses and `arm` with no variant, so those rows are a HYPOTHESIS about the payload,
53
+ * not a claim: a machine the guess is wrong about answers with a different name and the check
54
+ * reports that it could not be carried out. Nothing here may produce a verdict about the daemon.
55
+ * An architecture nothing maps does the same, which is why `386` was worth adding rather than
56
+ * leaving to a fallback: the mapping is unambiguous and its absence cost a real check.
57
+ */
58
+ const PAYLOAD_ARCHITECTURES = {
59
+ x64: 'amd64',
60
+ ia32: '386',
61
+ arm64: 'arm64',
62
+ arm: 'arm',
63
+ s390x: 's390x',
64
+ ppc64: 'ppc64le',
65
+ riscv64: 'riscv64',
66
+ };
67
+ /** The docker name for the architecture THIS process's payload is built for, when there is one. */
68
+ export function payloadArchitecture(arch = process.arch) {
69
+ return PAYLOAD_ARCHITECTURES[arch];
70
+ }
71
+ const TAR_BLOCK = 512;
72
+ /** A fixed timestamp everywhere a tar or an image config wants one, so the archive is byte-stable. */
73
+ const EPOCH = '1970-01-01T00:00:00Z';
74
+ /**
75
+ * One ustar header block.
76
+ *
77
+ * The checksum is summed with its own field read as eight SPACES and only then written back
78
+ * over it, which is the format's own rule and the one detail a hand-rolled writer gets wrong. A
79
+ * header whose checksum covers its own checksum bytes is rejected by every reader, and
80
+ * `docker load` reports that as an unreadable archive, which this module's caller would then
81
+ * have to decide was not the daemon's fault.
82
+ */
83
+ function tarHeader(name, size, mode) {
84
+ const header = Buffer.alloc(TAR_BLOCK);
85
+ header.write(name, 0, 100, 'utf8');
86
+ header.write(octalField(mode, 8), 100, 8, 'latin1');
87
+ header.write(octalField(0, 8), 108, 8, 'latin1'); // uid
88
+ header.write(octalField(0, 8), 116, 8, 'latin1'); // gid
89
+ header.write(octalField(size, 12), 124, 12, 'latin1');
90
+ header.write(octalField(0, 12), 136, 12, 'latin1'); // mtime
91
+ header.write(' ', 148, 8, 'latin1');
92
+ header.write('0', 156, 1, 'latin1'); // typeflag: a regular file
93
+ header.write('ustar\0', 257, 6, 'latin1');
94
+ header.write('00', 263, 2, 'latin1');
95
+ let sum = 0;
96
+ for (const byte of header)
97
+ sum += byte;
98
+ header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'latin1');
99
+ return header;
100
+ }
101
+ /** A numeric tar field: zero-padded octal in `width - 1` characters, then a NUL. */
102
+ function octalField(value, width) {
103
+ return `${value.toString(8).padStart(width - 1, '0')}\0`;
104
+ }
105
+ /** One tar member: its header, its content, and the padding up to the next 512-byte block. */
106
+ function tarMember(entry) {
107
+ const padding = (TAR_BLOCK - (entry.content.length % TAR_BLOCK)) % TAR_BLOCK;
108
+ return Buffer.concat([
109
+ tarHeader(entry.name, entry.content.length, entry.mode),
110
+ entry.content,
111
+ Buffer.alloc(padding),
112
+ ]);
113
+ }
114
+ /** A whole tar stream: the members, then the two zero blocks that terminate one. */
115
+ export function tarArchive(entries) {
116
+ return Buffer.concat([...entries.map(tarMember), Buffer.alloc(TAR_BLOCK * 2)]);
117
+ }
118
+ /**
119
+ * Assemble the docker-archive `docker load` reads, from one statically linked binary.
120
+ *
121
+ * Classic (v1) docker-archive rather than OCI layout: `docker load` accepts both on every engine
122
+ * this image can run against, and the v1 shape is three files with no blob directory to get
123
+ * wrong. The layer digest is the sha256 of the UNCOMPRESSED layer tar, which is what
124
+ * `rootfs.diff_ids` means; an engine that disagrees with it refuses the load, which the caller
125
+ * reads as could-not-determine rather than as a broken daemon.
126
+ *
127
+ * `architecture` is the DAEMON's own word for its architecture, in docker's vocabulary, so
128
+ * nothing here decides it (see {@link PAYLOAD_ARCHITECTURES}). The result is byte-stable for one
129
+ * `(payload, architecture)` pair, which is what lets the caller build it once per container.
130
+ */
131
+ export function buildProbeArchive(payload, architecture) {
132
+ const layer = tarArchive([{ name: PROBE_BINARY_PATH, content: payload, mode: 0o755 }]);
133
+ const diffId = `sha256:${createHash('sha256').update(layer).digest('hex')}`;
134
+ const config = Buffer.from(JSON.stringify({
135
+ architecture,
136
+ os: 'linux',
137
+ created: EPOCH,
138
+ config: {},
139
+ rootfs: { type: 'layers', diff_ids: [diffId] },
140
+ history: [{ created: EPOCH, created_by: 'cat-factory docker capability probe' }],
141
+ }));
142
+ const manifest = Buffer.from(JSON.stringify([{ Config: 'config.json', RepoTags: [PROBE_IMAGE_TAG], Layers: ['layer.tar'] }]));
143
+ return tarArchive([
144
+ { name: 'config.json', content: config, mode: 0o644 },
145
+ { name: 'layer.tar', content: layer, mode: 0o644 },
146
+ { name: 'manifest.json', content: manifest, mode: 0o644 },
147
+ ]);
148
+ }
@@ -1,3 +1,5 @@
1
+ import { type DockerWorkload } from './docker-capability.js';
2
+ import { type Logger } from './logger.js';
1
3
  /**
2
4
  * Where `entrypoint.sh` records its verdict. The two halves of one contract: change this and the
3
5
  * `DOCKER_STATUS_FILE` default in `entrypoint.sh` together. `HARNESS_DOCKER_STATUS_FILE`
@@ -58,32 +60,97 @@ export declare function readDockerStatus(path?: string): Promise<DockerStatus>;
58
60
  * adding a source without a sentence stops building.
59
61
  */
60
62
  export declare function describeDockerAbsence(status: DockerStatus): string;
61
- /** Whether a daemon is answering RIGHT NOW. Injected so the unit suite can state either answer. */
62
- export type DockerProbe = () => Promise<boolean>;
63
63
  /**
64
- * The default {@link DockerProbe}: `docker version` talks to the SERVER, unlike the client-only
65
- * `docker --version`, which answers happily with no daemon at all.
64
+ * What a daemon can do RIGHT NOW. Injected so the unit suite can state every answer.
65
+ *
66
+ * It answers with a WORKLOAD rather than with a boolean, and that is the correction this type
67
+ * carries. It used to be `docker version`, which proves the daemon is serving; a stand-up needs
68
+ * a daemon that can materialise an image, and a sandboxed rootless daemon routinely serves while
69
+ * being unable to (issue #2120). Running compose against that one produced a mount error the
70
+ * agent had to interpret, from the one mechanism whose entire job is to say why infra did not
71
+ * come up.
72
+ *
73
+ * The weaker fact did not go away, though: it rides `daemonAnswered` on the `unknown` arm, since
74
+ * the workload check establishes it on its way past and {@link resolveDockerVerdict} still needs
75
+ * it. Takes the job's signal, because this is a live check on the critical path of a run that can
76
+ * be cancelled under it.
77
+ */
78
+ export type DockerProbe = (signal?: AbortSignal) => Promise<DockerWorkload>;
79
+ /**
80
+ * The default {@link DockerProbe}: the process-wide workload probe, which loads a one-layer
81
+ * image and runs a container from it, memoised per container.
82
+ *
83
+ * Named for what it answers. It was `probeDockerServing`, which is the fact this module exists to
84
+ * say is not enough.
66
85
  */
67
- export declare const probeDockerServing: DockerProbe;
86
+ export declare const probeLiveDockerCapability: DockerProbe;
87
+ /**
88
+ * The sentence for a daemon that is serving and cannot run anything. It names what was tried,
89
+ * because "docker is unavailable" against a daemon the agent can see answering reads as a bug in
90
+ * the platform rather than as the sandbox limit it is.
91
+ */
92
+ export declare function describeDockerUnusable(workload: {
93
+ detail: string;
94
+ }): string;
68
95
  /** What a stand-up is entitled to conclude about the daemon at the moment it is about to run. */
69
96
  export interface DockerVerdict {
70
- /** Three-valued exactly as {@link DockerStatus.available}, and read the same way. */
97
+ /**
98
+ * Whether a stand-up may PROCEED, three-valued exactly as {@link DockerStatus.available} and
99
+ * read the same way. It is the decision, not a description of the daemon: a daemon that is
100
+ * answering and cannot run a container is `false` here and `daemon: true` below, and a record
101
+ * that reported the first as the second would send an operator to restart a daemon that is
102
+ * already up.
103
+ */
71
104
  available: boolean | undefined;
72
- /** Set only for a CONFIRMED absence: the sentence to refuse with. Absent means proceed. */
105
+ /**
106
+ * Set only for a CONFIRMED negative: the sentence to refuse with. Absent means proceed.
107
+ *
108
+ * Two causes reach it and they read differently on purpose. Nothing is answering here, and
109
+ * something is answering here but cannot run a container: an operator sent to restart a daemon
110
+ * that is already up would find nothing wrong with it.
111
+ */
73
112
  refusal?: string;
113
+ /**
114
+ * Whether a daemon ANSWERED the live check. Absent when nothing was checked (an undecided
115
+ * record probes nothing) or when nothing answered, so it is never read as a decided `false`.
116
+ */
117
+ daemon?: boolean;
118
+ /** What a real container did on it, when one was tried. Absent when nothing was measured. */
119
+ workload?: DockerWorkload;
74
120
  }
75
121
  /**
76
- * Resolve what to do now, from what boot recorded plus what a daemon says today.
122
+ * Resolve what to do now, from what boot recorded plus what the daemon can do today.
123
+ *
124
+ * `entrypoint.sh` probes once, at boot, within a bounded wait, and it probes for a SOCKET. Two
125
+ * things follow, and the branches below are one each.
126
+ *
127
+ * A recorded absence is a HYPOTHESIS. A container outlives its boot: a warm pool serves many jobs
128
+ * from one, and a sidecar daemon that took longer than the wait allows is serving perfectly well
129
+ * by the second job. Refusing off the record alone latches that container into refusing local
130
+ * infra that works, for its whole life, with a stale sentence explaining why. The record is still
131
+ * what supplies the cause and the daemon's own log tail, which no probe can reconstruct.
132
+ *
133
+ * A recorded PRESENCE is a hypothesis too, and that half was missing. `serving` is not `usable`:
134
+ * a rootless daemon in a sandbox answers while unable to mount any image layer, so compose ran
135
+ * and died on a mount error the agent then had to interpret. So the probe is consulted in both
136
+ * directions, and it runs a real container rather than asking the daemon about itself.
77
137
  *
78
- * `entrypoint.sh` probes once, at boot, within a bounded wait. A container outlives that: a warm
79
- * pool serves many jobs from one, and a sidecar daemon that took longer than the wait allows is
80
- * serving perfectly well by the second job. Refusing off the recorded verdict alone latches that
81
- * container into refusing local infra that in fact works, for its whole life, with a stale
82
- * sentence explaining why. So a recorded absence is a HYPOTHESIS here, and the live probe settles
83
- * it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
84
- * no probe can reconstruct.
138
+ * A check that could not be CARRIED OUT settles nothing, and the cheap fact is what decides
139
+ * there. Falling straight back to the boot record would re-latch the very refusal the paragraph
140
+ * above rules out: the four ways the workload check can come back undeterminable (no payload in
141
+ * this image variant, an architecture it is not built for, a `docker load` the engine refuses, a
142
+ * timeout) have nothing to do with whether a daemon is up, so a warm container whose sidecar
143
+ * arrived late would be denied local infra for the rest of its life over a stale sentence. So a
144
+ * daemon that ANSWERED contradicts a recorded absence exactly as the old `docker version` probe
145
+ * did, and only a check that never reached a daemon at all leaves the record to decide.
85
146
  *
86
- * Only a recorded `false` is re-confirmed. "Not decided" keeps attempting exactly as before: the
87
- * point of the third value is that nothing turns it into a refusal, and a probe here would.
147
+ * "Not decided" still keeps attempting, untouched. The point of the third value is that NOTHING
148
+ * turns it into a refusal: the entrypoint's bounded wait may still be running, and a workload
149
+ * probe against a daemon that has not finished starting fails for a reason that says nothing
150
+ * about what it will do a second later.
88
151
  */
89
- export declare function resolveDockerVerdict(status: DockerStatus, probe?: DockerProbe): Promise<DockerVerdict>;
152
+ export declare function resolveDockerVerdict(status: DockerStatus, opts?: {
153
+ probe?: DockerProbe;
154
+ signal?: AbortSignal;
155
+ logger?: Logger;
156
+ }): Promise<DockerVerdict>;
@@ -1,6 +1,6 @@
1
- import { execFile } from 'node:child_process';
2
1
  import { readFile } from 'node:fs/promises';
3
- import { promisify } from 'node:util';
2
+ import { probeDockerWorkload } from './docker-capability.js';
3
+ import { log } from './logger.js';
4
4
  // What this container knows about its own Docker daemon, as recorded by `entrypoint.sh`.
5
5
  //
6
6
  // The Tester's local-mode infra stand-up (`docker compose up --wait`) is the only thing in the
@@ -13,8 +13,12 @@ import { promisify } from 'node:util';
13
13
  // daemon reads it instead.
14
14
  //
15
15
  // The recorded verdict describes BOOT, and a container outlives its boot, so nothing refuses on
16
- // it unconfirmed: `resolveDockerVerdict` re-checks a recorded absence against a live daemon and
17
- // keeps the record for what only the record holds, the cause and the daemon's own log tail.
16
+ // it unconfirmed: `resolveDockerVerdict` re-checks it against a live daemon and keeps the record
17
+ // for what only the record holds, the cause and the daemon's own log tail. What it records is
18
+ // also only that a SOCKET answered, which is a weaker fact than any caller wants, so the live
19
+ // check RUNS A CONTAINER (docker-capability.ts) rather than settling for the daemon's word about
20
+ // itself. It still reports that weaker fact alongside, since a check that could not be carried
21
+ // out is what the boot record has to be read against, and nothing else establishes it.
18
22
  //
19
23
  // The three-valued shape is deliberate and is the point (CLAUDE.md, "Degrade loudly"): a daemon
20
24
  // that FAILED and a daemon nobody asked about are different facts with different correct
@@ -106,42 +110,95 @@ function absenceCause(source) {
106
110
  function unnamedSource(source) {
107
111
  return `no Docker daemon answered in this container (unrecognised source ${JSON.stringify(source)})`;
108
112
  }
109
- /** A live probe may not outlast the thing it is guarding; a hung socket is an absent daemon here. */
110
- const PROBE_TIMEOUT_MS = 10_000;
111
- const execFileAsync = promisify(execFile);
112
113
  /**
113
- * The default {@link DockerProbe}: `docker version` talks to the SERVER, unlike the client-only
114
- * `docker --version`, which answers happily with no daemon at all.
114
+ * The default {@link DockerProbe}: the process-wide workload probe, which loads a one-layer
115
+ * image and runs a container from it, memoised per container.
116
+ *
117
+ * Named for what it answers. It was `probeDockerServing`, which is the fact this module exists to
118
+ * say is not enough.
115
119
  */
116
- export const probeDockerServing = async () => {
117
- try {
118
- await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], {
119
- timeout: PROBE_TIMEOUT_MS,
120
- });
121
- return true;
122
- }
123
- catch {
124
- return false;
125
- }
126
- };
120
+ export const probeLiveDockerCapability = (signal) => probeDockerWorkload(signal);
127
121
  /**
128
- * Resolve what to do now, from what boot recorded plus what a daemon says today.
122
+ * The sentence for a daemon that is serving and cannot run anything. It names what was tried,
123
+ * because "docker is unavailable" against a daemon the agent can see answering reads as a bug in
124
+ * the platform rather than as the sandbox limit it is.
125
+ */
126
+ export function describeDockerUnusable(workload) {
127
+ return `this container's Docker daemon is reachable but cannot run a container (${workload.detail})`;
128
+ }
129
+ /**
130
+ * Resolve what to do now, from what boot recorded plus what the daemon can do today.
131
+ *
132
+ * `entrypoint.sh` probes once, at boot, within a bounded wait, and it probes for a SOCKET. Two
133
+ * things follow, and the branches below are one each.
129
134
  *
130
- * `entrypoint.sh` probes once, at boot, within a bounded wait. A container outlives that: a warm
131
- * pool serves many jobs from one, and a sidecar daemon that took longer than the wait allows is
132
- * serving perfectly well by the second job. Refusing off the recorded verdict alone latches that
133
- * container into refusing local infra that in fact works, for its whole life, with a stale
134
- * sentence explaining why. So a recorded absence is a HYPOTHESIS here, and the live probe settles
135
- * it; the recorded verdict is still what supplies the cause and the daemon's own log tail, which
136
- * no probe can reconstruct.
135
+ * A recorded absence is a HYPOTHESIS. A container outlives its boot: a warm pool serves many jobs
136
+ * from one, and a sidecar daemon that took longer than the wait allows is serving perfectly well
137
+ * by the second job. Refusing off the record alone latches that container into refusing local
138
+ * infra that works, for its whole life, with a stale sentence explaining why. The record is still
139
+ * what supplies the cause and the daemon's own log tail, which no probe can reconstruct.
137
140
  *
138
- * Only a recorded `false` is re-confirmed. "Not decided" keeps attempting exactly as before: the
139
- * point of the third value is that nothing turns it into a refusal, and a probe here would.
141
+ * A recorded PRESENCE is a hypothesis too, and that half was missing. `serving` is not `usable`:
142
+ * a rootless daemon in a sandbox answers while unable to mount any image layer, so compose ran
143
+ * and died on a mount error the agent then had to interpret. So the probe is consulted in both
144
+ * directions, and it runs a real container rather than asking the daemon about itself.
145
+ *
146
+ * A check that could not be CARRIED OUT settles nothing, and the cheap fact is what decides
147
+ * there. Falling straight back to the boot record would re-latch the very refusal the paragraph
148
+ * above rules out: the four ways the workload check can come back undeterminable (no payload in
149
+ * this image variant, an architecture it is not built for, a `docker load` the engine refuses, a
150
+ * timeout) have nothing to do with whether a daemon is up, so a warm container whose sidecar
151
+ * arrived late would be denied local infra for the rest of its life over a stale sentence. So a
152
+ * daemon that ANSWERED contradicts a recorded absence exactly as the old `docker version` probe
153
+ * did, and only a check that never reached a daemon at all leaves the record to decide.
154
+ *
155
+ * "Not decided" still keeps attempting, untouched. The point of the third value is that NOTHING
156
+ * turns it into a refusal: the entrypoint's bounded wait may still be running, and a workload
157
+ * probe against a daemon that has not finished starting fails for a reason that says nothing
158
+ * about what it will do a second later.
140
159
  */
141
- export async function resolveDockerVerdict(status, probe = probeDockerServing) {
142
- if (status.available !== false)
143
- return { available: status.available };
144
- if (await probe())
145
- return { available: true };
146
- return { available: false, refusal: describeDockerAbsence(status) };
160
+ export async function resolveDockerVerdict(status, opts = {}) {
161
+ if (status.available === undefined)
162
+ return { available: undefined };
163
+ const workload = await askTotally(opts.probe ?? probeLiveDockerCapability, opts.signal, opts.logger);
164
+ if (workload.status === 'usable')
165
+ return { available: true, daemon: true, workload };
166
+ if (workload.status === 'unusable') {
167
+ return {
168
+ available: false,
169
+ refusal: describeDockerUnusable(workload),
170
+ daemon: true,
171
+ workload,
172
+ };
173
+ }
174
+ if (workload.daemonAnswered)
175
+ return { available: true, daemon: true, workload };
176
+ return status.available
177
+ ? { available: true, workload }
178
+ : { available: false, refusal: describeDockerAbsence(status), workload };
179
+ }
180
+ /**
181
+ * Call the probe and answer even if it throws.
182
+ *
183
+ * The default probe is total by construction and says so, but this is the seam an injected one
184
+ * arrives through, and the caller is a stand-up documented as best-effort: a throw here would
185
+ * fail a job over the mechanism whose whole purpose is to make a failure legible. A throw settles
186
+ * nothing about the daemon, so it becomes the same value as any other check that could not be
187
+ * carried out and the boot record decides, exactly as it did before the probe existed.
188
+ */
189
+ async function askTotally(probe, signal, logger) {
190
+ try {
191
+ return await probe(signal);
192
+ }
193
+ catch (err) {
194
+ const message = err instanceof Error ? err.message : String(err);
195
+ (logger ?? log).warn('docker: the live daemon check threw; falling back to the boot record', {
196
+ error: message,
197
+ });
198
+ return {
199
+ status: 'unknown',
200
+ reason: `the live Docker check could not be carried out (${message})`,
201
+ daemonAnswered: false,
202
+ };
203
+ }
147
204
  }
@@ -1,4 +1,5 @@
1
1
  import { type Logger } from './logger.js';
2
+ import { type DockerWorkload } from './docker-capability.js';
2
3
  /**
3
4
  * What running one probe did, kept as RAW as the spawn: whether the binary ran, and with what.
4
5
  * Classification into presence lives in pure code below, because the two callers classify the
@@ -35,6 +36,38 @@ export type ToolPresence = {
35
36
  status: 'unknown';
36
37
  reason: string;
37
38
  };
39
+ /**
40
+ * What this machine's Docker daemon is good for, which is FIVE answers and not three.
41
+ *
42
+ * `absent` and `unknown` mean for the daemon what they mean for any other tool. The three that
43
+ * are particular to Docker split the case where a daemon ANSWERED, because answering is not the
44
+ * question anyone is asking:
45
+ *
46
+ * - `usable`: a container was built and run on it here. `docker build` / `run` / `compose`
47
+ * work, and this is the only state that may say so.
48
+ * - `unusable`: a container could NOT be run, with the daemon serving throughout. The state
49
+ * issue #2120 is about; stated as a prohibition, with the cause.
50
+ * - `serving`: it answered, and the workload check could not be carried out (no payload on
51
+ * this machine, an unmapped architecture, a timeout). Neither of the other two,
52
+ * and rendered as "try it if you need it".
53
+ */
54
+ export type DockerCapability = {
55
+ status: 'usable';
56
+ server?: string;
57
+ } | {
58
+ status: 'unusable';
59
+ server?: string;
60
+ detail: string;
61
+ } | {
62
+ status: 'serving';
63
+ server?: string;
64
+ reason: string;
65
+ } | {
66
+ status: 'absent';
67
+ } | {
68
+ status: 'unknown';
69
+ reason: string;
70
+ };
38
71
  /** One probed entry: the name the agent would type, and what came back. */
39
72
  export interface ProbedTool {
40
73
  name: string;
@@ -46,12 +79,13 @@ export interface ProbedTool {
46
79
  export interface EnvironmentInventory {
47
80
  tools: ProbedTool[];
48
81
  /**
49
- * Whether a Docker daemon actually answered: the readiness fact, not the CLI's presence. The
50
- * image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs the server without
51
- * waiting for it, so at job start this probe is the only thing that knows how that went, and
52
- * "has not answered yet" is one of its answers (see {@link probeDockerDaemon}).
82
+ * What the Docker daemon is good for: not the CLI's presence, and not merely whether the
83
+ * daemon answered. The image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs
84
+ * the server without waiting for it, so at job start this probe is the only thing that knows
85
+ * how that went; "has not answered yet" is one of its answers (see {@link probeDockerDaemon})
86
+ * and "answered, but cannot run a container" is another (see {@link DockerCapability}).
53
87
  */
54
- dockerDaemon: ToolPresence;
88
+ dockerDaemon: DockerCapability;
55
89
  /**
56
90
  * The port the harness's own job server holds in this network namespace. Not probed: the
57
91
  * process reads its own {@link harnessListenPort}, which is the only honest answer when a
@@ -124,6 +158,18 @@ export interface ProbeEnvironmentOptions {
124
158
  * only so the suite can assert the rendered line without an ambient `PORT` deciding its text.
125
159
  */
126
160
  harnessPort?: number;
161
+ /**
162
+ * Whether the daemon can actually RUN a container, defaulting to the process-wide probe
163
+ * (docker-capability.ts). Asked only once a daemon has answered, since there is nothing to run
164
+ * a workload on otherwise, and memoised per container so a warm pool pays for it once.
165
+ */
166
+ workload?: (signal?: AbortSignal) => Promise<DockerWorkload>;
167
+ /**
168
+ * The job's signal, forwarded to the probes that spawn something. The workload check starts a
169
+ * CONTAINER, so a cancelled job must stop paying for it rather than hold the daemon for the
170
+ * rest of its budget.
171
+ */
172
+ signal?: AbortSignal;
127
173
  }
128
174
  /**
129
175
  * Probe the machine. EVERYTHING runs concurrently, the daemon included.
@@ -163,7 +209,6 @@ export declare function renderEnvironmentInventory(inventory: EnvironmentInvento
163
209
  * the pass itself, and it says so in a log line rather than silently shortening the prompt.
164
210
  */
165
211
  export declare function appendEnvironmentInventory(systemPrompt: string, opts?: {
166
- signal?: AbortSignal;
167
212
  log?: Logger;
168
213
  run?: ProbeRunner;
169
214
  } & ProbeEnvironmentOptions): Promise<string>;