@cat-factory/executor-harness 1.145.1 → 1.149.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,767 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import {
3
+ type CommandOutcome,
4
+ type DockerCommandRunner,
5
+ spawnDockerCommand,
6
+ } from './docker-command.js'
7
+ import {
8
+ buildEgressCommand,
9
+ buildProbeArchive,
10
+ EGRESS_DNS_MARKER,
11
+ EGRESS_TCP_MARKER,
12
+ type EgressTarget,
13
+ parseEgressTarget,
14
+ payloadArchitecture,
15
+ PROBE_COMMAND,
16
+ PROBE_IMAGE_TAG,
17
+ PROBE_SENTINEL,
18
+ } from './docker-probe-image.js'
19
+ import { log, type Logger } from './logger.js'
20
+ import { scrubbedExcerpt } from './redact.js'
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Whether this machine's Docker daemon can RUN A CONTAINER, as opposed to merely answering.
24
+ //
25
+ // `docker info` and `docker version` talk to the daemon, and for a long time everything here
26
+ // treated an answer from one as proof that `docker build`, `docker run` and `docker compose up`
27
+ // work. They are different facts. A rootless daemon nested inside a sandboxed container serves
28
+ // perfectly while its snapshotter cannot mount a single image layer, so every one of those
29
+ // commands fails with the same EINVAL from `mount(2)` (issue #2120). The harness stated the
30
+ // wrong one of the two in every agent's system prompt, in a block that also says not to spend
31
+ // turns re-checking it, so three agents in one run each paid to disprove it.
32
+ //
33
+ // The answer is a real workload: load a one-layer image built in this process
34
+ // (docker-probe-image.ts) and run a container from it that has to print a marker. That is the
35
+ // smallest thing that exercises the whole path an agent's `docker run` takes, and it needs no
36
+ // registry, no network and no second image in the container.
37
+ //
38
+ // THREE answers, and which failure maps to which is the whole design:
39
+ //
40
+ // - `usable`: a container ran and printed the marker. Nothing else proves this.
41
+ // - `unusable`: the container did NOT run, and the daemon is what refused it. A DECIDED
42
+ // negative, and the only one anything states to an agent as a prohibition.
43
+ // - `unknown`: the check could not be carried out: no payload on this machine (the native
44
+ // host transport, where the harness runs on a developer's laptop), a daemon
45
+ // whose architecture the payload is not built for, `docker load` refusing the
46
+ // archive, the probe binary failing to exec, a timeout, a cancelled job.
47
+ // Every one of those is a fact about THE CHECK, and reading it as a fact about
48
+ // the daemon would trade this module's original lie for its mirror image.
49
+ //
50
+ // The `unknown` arm carries {@link DockerWorkload.daemonAnswered} because that distinction is
51
+ // load-bearing one level up: `resolveDockerVerdict` needs the cheap fact this check establishes
52
+ // on its way past (a daemon is answering RIGHT NOW) to keep a warm container out of a stale boot
53
+ // record's refusal, and only the check knows whether it ever got that far.
54
+ //
55
+ // `usable` then carries a SECOND fact, because running a container and reaching the network from
56
+ // inside one are different things and only the first of them was ever measured. A local image
57
+ // loads and runs with no network at all, so a daemon whose nested containers are cut off passes
58
+ // this check exactly as a working one does: the published image ran its daemon with
59
+ // `--iptables=false` and every `docker build` that fetched a dependency was guaranteed to fail,
60
+ // while the harness reported `usable` and told each agent that `docker build` works here (issue
61
+ // #2174). It is a separate field rather than a fourth status because it answers a separate
62
+ // question, with its own three outcomes and its own way of being undeterminable, and because the
63
+ // two have different consequences: no egress is a constraint to plan around, where an unusable
64
+ // daemon is a prohibition.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * What a NESTED container could reach, measured from inside one.
69
+ *
70
+ * Measured there and nowhere else, which is the whole point. The harness container's own network
71
+ * is fine in both cases: it resolves and fetches normally, and the daemon pulls images
72
+ * successfully, so every check one layer out reports a working network over a daemon whose
73
+ * containers have none.
74
+ *
75
+ * `reachable` needs BOTH halves. A route with no DNS is not a network an agent can use: nothing
76
+ * it installs is fetched by address, so `blocked` is the honest verdict and the detail names DNS
77
+ * as the half to fix. The reverse (a name resolved, the configured address refused) is
78
+ * `undetermined` instead of `blocked`, because a resolved name proves a path out exists and the
79
+ * likeliest cause is that this deployment filters the address the check was pointed at.
80
+ */
81
+ export type ContainerEgress =
82
+ /** A container opened a TCP connection out AND resolved a public name. */
83
+ | { status: 'reachable' }
84
+ /** It could not get out. `detail` names how far it got, since the two have different fixes. */
85
+ | { status: 'blocked'; detail: string }
86
+ /** The check could not be carried out, or could not be read as evidence about the network. */
87
+ | {
88
+ status: 'undetermined'
89
+ reason: string
90
+ /**
91
+ * Whether asking again could give a different answer.
92
+ *
93
+ * Load-bearing, because the probe re-measures an undetermined egress and re-measuring
94
+ * means two container starts plus an image load, per job, on the critical path ahead of
95
+ * the clone. Most of the ways to land here cannot change while this container lives: a
96
+ * rejected `HARNESS_DOCKER_EGRESS_TARGET`, a payload with no `nc` applet, an address this
97
+ * deployment filters. Latching those is not a stale verdict, it is the same measurement
98
+ * with the same inputs; re-running it forever costs every job about forty seconds for a
99
+ * value that is settled. Only a genuinely transient failure (a timeout, a cancelled job,
100
+ * a daemon that could not attach the bridge yet) is worth asking about again.
101
+ */
102
+ recheck: boolean
103
+ }
104
+
105
+ /** What one measurement concluded. See the three answers above; nothing collapses them. */
106
+ export type DockerWorkload =
107
+ | { status: 'usable'; egress: ContainerEgress }
108
+ | { status: 'unusable'; detail: string }
109
+ | {
110
+ status: 'unknown'
111
+ reason: string
112
+ /**
113
+ * Whether a daemon ANSWERED before the check ran out of things it could do. A weaker fact
114
+ * than the check exists to establish, and the one a stale boot record must be read against.
115
+ */
116
+ daemonAnswered: boolean
117
+ }
118
+
119
+ /**
120
+ * The statically linked binary the probe image is built from, overridable for an image variant
121
+ * that ships it elsewhere. Absent is a supported answer, not a failure: under
122
+ * `LOCAL_NATIVE_AGENTS` the harness runs on a developer's machine that never saw this image.
123
+ */
124
+ const PAYLOAD_PATH = process.env.HARNESS_DOCKER_PROBE_BINARY?.trim() || '/bin/busybox'
125
+
126
+ /**
127
+ * Where the egress check aims, overridable for a deployment whose network permits something else.
128
+ *
129
+ * A raw IPv4 address rather than a name, so the connect answers a question about ROUTING alone:
130
+ * pointing it at a hostname would make every verdict depend on DNS, which is the other half and
131
+ * is measured separately. `1.1.1.1:443` is an anycast address that answers TLS from everywhere
132
+ * and belongs to no API this repo calls; the name is npm's because npm is what the outage
133
+ * actually broke. Neither is validated here (see `parseEgressTarget`), so a rejected setting is
134
+ * reported rather than replaced.
135
+ *
136
+ * Both defaults aim at the PUBLIC internet, and a deployment that deliberately has none should
137
+ * point these at what it does run (an internal registry mirror and its own DNS zone). Left at
138
+ * the defaults there, the measurement is honest but narrow: it establishes that a container
139
+ * cannot reach these two, which is why the prompt built from a `blocked` verdict says which
140
+ * targets were tried rather than that nothing at all is reachable.
141
+ */
142
+ const EGRESS_TARGET = process.env.HARNESS_DOCKER_EGRESS_TARGET?.trim() || '1.1.1.1:443'
143
+ const EGRESS_DNS_NAME = process.env.HARNESS_DOCKER_EGRESS_DNS_NAME?.trim() || 'registry.npmjs.org'
144
+
145
+ /**
146
+ * The ceiling on ONE WHOLE measurement, shared out across the docker commands it makes: each
147
+ * gets what is left of it, down to {@link MIN_COMMAND_MS}.
148
+ *
149
+ * One budget rather than a per-command ceiling, because a per-command one multiplies: three
150
+ * commands at 30s each is a minute and a half of dead time on a wedged daemon, on the critical
151
+ * path ahead of the clone. Sized for a WEDGED daemon and not for a slow one: the payload is a
152
+ * couple of megabytes already on local disk, so a daemon that works answers in about the time it
153
+ * takes to start one container, and a daemon that cannot mount fails immediately.
154
+ *
155
+ * What it does NOT do is bound the cost per JOB, and the comment that once claimed so was wrong.
156
+ * A POSITIVE verdict is memoised for the container's life; a negative is deliberately
157
+ * re-measured (see {@link createDockerWorkloadProbe}), and two independent sites ask per job (the
158
+ * environment inventory and the compose stand-up), so a serving-but-wedged daemon costs this
159
+ * twice per job. That is the price of not latching a warm container into a stale refusal, which
160
+ * is why the budget is the size it is and why an abandoned job stops paying it at once.
161
+ */
162
+ const WORKLOAD_BUDGET_MS = 20_000
163
+
164
+ /** The floor on one command's share of the budget, so an exhausted budget still gets an answer. */
165
+ const MIN_COMMAND_MS = 1_000
166
+
167
+ /**
168
+ * The ceiling on the egress container, which gets its OWN budget rather than a share of the one
169
+ * above.
170
+ *
171
+ * The argument for a single shared budget is that a per-command ceiling multiplies on a WEDGED
172
+ * daemon, and that argument does not reach here: this container is started only after another one
173
+ * has already run to completion, so the daemon is known to work by the time it is spawned. What
174
+ * it does have to allow for is a check that is SUPPOSED to be slow in the failing case, since a
175
+ * blocked route is silent rather than refused and both in-container timeouts have to expire.
176
+ * Taking that out of the workload budget would have starved the step this whole module exists
177
+ * for; leaving it unbounded would hand a wedged network the whole job.
178
+ */
179
+ const EGRESS_BUDGET_MS = 20_000
180
+
181
+ /**
182
+ * The ceiling on removing the probe image again.
183
+ *
184
+ * Its own, and deliberately NOT given the caller's abort signal: the image is the platform's, and
185
+ * a cancelled job is the one case where nobody is left to clean up after it. Bounded separately
186
+ * so a wedged daemon cannot turn the cleanup into a second full budget.
187
+ */
188
+ const CLEANUP_TIMEOUT_MS = 5_000
189
+
190
+ /** How much of a failing command's output is kept. It is quoted into an agent's system prompt. */
191
+ const DETAIL_CHARS = 300
192
+
193
+ /**
194
+ * A one-slot memo for the assembled archive.
195
+ *
196
+ * The archive is byte-stable for one `(payload path, architecture)` pair by construction
197
+ * (docker-probe-image.ts pins every timestamp for exactly this reason), and neither half changes
198
+ * within a process. Since a NEGATIVE verdict is re-measured on purpose and two sites ask per job,
199
+ * without this the same two megabytes are re-read and re-sha256'd four times per job for a value
200
+ * that cannot differ. One slot rather than a map: there is only ever one key.
201
+ */
202
+ export interface ProbeArchiveMemo {
203
+ read(key: string): Buffer | undefined
204
+ write(key: string, archive: Buffer): void
205
+ }
206
+
207
+ /** Build a {@link ProbeArchiveMemo}. Supplied only by {@link realDeps}, so a test memoises nothing. */
208
+ export function oneSlotArchiveMemo(): ProbeArchiveMemo {
209
+ let held: { key: string; archive: Buffer } | undefined
210
+ return {
211
+ read: (key) => (held?.key === key ? held.archive : undefined),
212
+ write: (key, archive) => {
213
+ held = { key, archive }
214
+ },
215
+ }
216
+ }
217
+
218
+ /** What a measurement needs from the machine, so a test can supply all of it. */
219
+ export interface DockerWorkloadDeps {
220
+ /** Read the probe payload. Rejecting (ENOENT) is the supported "this machine has none". */
221
+ readPayload: (path: string) => Promise<Buffer>
222
+ payloadPath: string
223
+ runDocker: DockerCommandRunner
224
+ /** This process's architecture, as `process.arch` spells it: the PAYLOAD's, never the daemon's. */
225
+ arch: string
226
+ /** Where the egress container aims, as configured. Validated at use, never here. */
227
+ egress: { target: string; dnsName: string }
228
+ logger?: Logger
229
+ archives?: ProbeArchiveMemo
230
+ }
231
+
232
+ const realDeps: DockerWorkloadDeps = {
233
+ readPayload: (path) => readFile(path),
234
+ payloadPath: PAYLOAD_PATH,
235
+ runDocker: spawnDockerCommand,
236
+ arch: process.arch,
237
+ egress: { target: EGRESS_TARGET, dnsName: EGRESS_DNS_NAME },
238
+ archives: oneSlotArchiveMemo(),
239
+ }
240
+
241
+ /**
242
+ * Carry out one measurement. Pure of caching, so the suite states every branch directly.
243
+ *
244
+ * TOTAL: it never rejects, whatever happens inside it. The thing it replaced was total by
245
+ * construction (a `try/catch` around one `execFile`), and it is consulted from a stand-up path
246
+ * documented as best-effort, so a throw here would fail a job over a probe whose whole purpose is
247
+ * to make a failure legible. A throw is also, by definition, the platform's own machinery
248
+ * breaking, which is the `unknown` disposition and never the `unusable` one.
249
+ *
250
+ * That asymmetry is the design. Only the RUN produces `unusable`; everything before it produces
251
+ * `unknown`, because everything before it is the platform's own machinery and a bug in it must be
252
+ * able to say "I could not tell" and never "your daemon is broken". The load step in particular is
253
+ * the one this repo wrote itself.
254
+ */
255
+ export async function measureDockerWorkload(
256
+ deps: DockerWorkloadDeps = realDeps,
257
+ signal?: AbortSignal,
258
+ ): Promise<DockerWorkload> {
259
+ const seen = { daemonAnswered: false }
260
+ try {
261
+ return await measure(deps, seen, signal)
262
+ } catch (err) {
263
+ const cause = describeThrown(err)
264
+ ;(deps.logger ?? log).warn('docker capability: the container check itself fell over', {
265
+ error: cause,
266
+ })
267
+ return undeterminable(
268
+ `the platform's own container check could not be completed (${cause})`,
269
+ seen.daemonAnswered,
270
+ )
271
+ }
272
+ }
273
+
274
+ async function measure(
275
+ deps: DockerWorkloadDeps,
276
+ seen: { daemonAnswered: boolean },
277
+ signal?: AbortSignal,
278
+ ): Promise<DockerWorkload> {
279
+ const deadline = Date.now() + WORKLOAD_BUDGET_MS
280
+ const share = (): number => Math.max(MIN_COMMAND_MS, deadline - Date.now())
281
+ const command = (args: string[], stdin?: Buffer): Promise<CommandOutcome> =>
282
+ deps.runDocker(args, {
283
+ ...(stdin ? { stdin } : {}),
284
+ ...(signal ? { signal } : {}),
285
+ timeoutMs: share(),
286
+ ...(deps.logger ? { logger: deps.logger } : {}),
287
+ })
288
+
289
+ // Ask the DAEMON which architecture it runs, rather than assuming it shares this process's.
290
+ // An external `DOCKER_HOST` is a supported path, and an arm64 harness against an amd64 sidecar
291
+ // (or a remote x86_64 daemon reached from an arm64 laptop) shares nothing with it but a socket:
292
+ // declaring the wrong one in the image config gets the run refused, which would report a
293
+ // perfectly good daemon as one that cannot run containers. It is also the cheapest proof that a
294
+ // daemon is answering at all, which is the fact `resolveDockerVerdict` reads back.
295
+ const asked = await command(['version', '--format', '{{.Server.Arch}}'])
296
+ if (asked.outcome !== 'ran' || asked.code !== 0) {
297
+ return undeterminable(
298
+ `no Docker daemon answered the platform's container check (${describeOutcome(asked)})`,
299
+ false,
300
+ )
301
+ }
302
+ seen.daemonAnswered = true
303
+ const daemonArch = asked.stdout.trim()
304
+ if (!/^[a-z0-9_]+$/.test(daemonArch)) {
305
+ return undeterminable(
306
+ `the Docker daemon did not name an architecture the platform can build an image for (${scrubbedExcerpt(daemonArch, 40) || 'it answered nothing'})`,
307
+ true,
308
+ )
309
+ }
310
+ const payloadArch = payloadArchitecture(deps.arch)
311
+ if (!payloadArch) {
312
+ return undeterminable(
313
+ `the platform has no container check for the ${deps.arch} architecture`,
314
+ true,
315
+ )
316
+ }
317
+ if (payloadArch !== daemonArch) {
318
+ return undeterminable(
319
+ `the platform's container check is built for ${payloadArch} and this daemon runs ${daemonArch}`,
320
+ true,
321
+ )
322
+ }
323
+
324
+ const assembled = await assembleArchive(deps, daemonArch)
325
+ if ('reason' in assembled) return undeterminable(assembled.reason, true)
326
+
327
+ const load = await command(['load'], assembled.archive)
328
+ if (load.outcome !== 'ran' || load.code !== 0) {
329
+ return undeterminable(
330
+ `the platform could not load its own probe image (${describeOutcome(load)})`,
331
+ true,
332
+ )
333
+ }
334
+
335
+ const run = await command([
336
+ 'run',
337
+ '--rm',
338
+ '--pull',
339
+ 'never',
340
+ '--network',
341
+ 'none',
342
+ PROBE_IMAGE_TAG,
343
+ ...PROBE_COMMAND,
344
+ ])
345
+ // A daemon that ran that container has answered the first question, and only then is there a
346
+ // second one worth asking. An `unusable` daemon cannot run the egress container either, and a
347
+ // check that could not be carried out has nothing to measure egress against.
348
+ const verdict = classifyRun(run)
349
+ const measured: DockerWorkload =
350
+ verdict.status === 'usable'
351
+ ? { status: 'usable', egress: await measureEgress(deps, signal) }
352
+ : verdict
353
+ // After both runs, whatever the verdict is: the probe image is the platform's, and an agent
354
+ // that runs `docker images` should not have to wonder whose it is.
355
+ await removeProbeImage(deps)
356
+ return measured
357
+ }
358
+
359
+ /**
360
+ * Run the second container and read what it reached.
361
+ *
362
+ * On the DEFAULT network, deliberately, which is the one thing that separates it from the marker
363
+ * run above (`--network none`). What an agent's own `docker build` and `docker run` get is the
364
+ * bridge, and the bridge is exactly what a daemon started with `--iptables=false` fails to NAT.
365
+ *
366
+ * Never concludes anything about the DAEMON. Every failure here is either evidence about the
367
+ * network or evidence about this check, and the caller has already established that the daemon
368
+ * runs containers.
369
+ */
370
+ async function measureEgress(
371
+ deps: DockerWorkloadDeps,
372
+ signal?: AbortSignal,
373
+ ): Promise<ContainerEgress> {
374
+ const setting = parseEgressTarget(deps.egress.target, deps.egress.dnsName)
375
+ // A rejected setting is read from this container's own environment, so it answers the same way
376
+ // on every job: latched rather than re-measured, which would otherwise spend two container
377
+ // starts per job re-reading one unchanged string.
378
+ if ('invalid' in setting) {
379
+ return { status: 'undetermined', reason: setting.invalid, recheck: false }
380
+ }
381
+ const run = await deps.runDocker(
382
+ ['run', '--rm', '--pull', 'never', PROBE_IMAGE_TAG, ...buildEgressCommand(setting.target)],
383
+ {
384
+ ...(signal ? { signal } : {}),
385
+ timeoutMs: EGRESS_BUDGET_MS,
386
+ ...(deps.logger ? { logger: deps.logger } : {}),
387
+ },
388
+ )
389
+ return classifyEgress(run, setting.target)
390
+ }
391
+
392
+ /**
393
+ * What the egress container's output proves, over the four combinations its two markers can
394
+ * carry.
395
+ *
396
+ * Read off the STATUS each command printed rather than off the run's own exit code, because the
397
+ * two failures that look alike from outside need opposite answers: a refused connection is
398
+ * evidence about the network, and a 126/127 is busybox saying the image has no such applet, which
399
+ * is evidence about the platform's own payload and may never be reported as a network that is not
400
+ * there.
401
+ */
402
+ function classifyEgress(run: CommandOutcome, target: EgressTarget): ContainerEgress {
403
+ const where = `${target.host}:${target.port}`
404
+ if (run.outcome === 'failed') {
405
+ return {
406
+ status: 'undetermined',
407
+ reason: `the platform's egress check did not run (${run.reason})`,
408
+ // A spawn failure or a timeout is about this attempt and not about the container.
409
+ recheck: true,
410
+ }
411
+ }
412
+ const tcp = readMarker(run.stdout, EGRESS_TCP_MARKER)
413
+ const dns = readMarker(run.stdout, EGRESS_DNS_MARKER)
414
+ if (tcp === undefined || dns === undefined) {
415
+ // Nothing was measured, and the two ways to get here need different words. `docker run`
416
+ // failing at the DAEMON level (125, or its own "no such image") means the container never
417
+ // started, so the network was not the thing that did not answer; a container that started
418
+ // and printed something unrecognisable is the platform's own payload misbehaving. Reporting
419
+ // the first as "printed no verdict" tells an operator to go looking at the check's output
420
+ // for a container that produced none.
421
+ const refused =
422
+ platformSideRunFailure(run) ?? (run.code === 125 ? daemonRefusedEgressRun : undefined)
423
+ return refused
424
+ ? {
425
+ status: 'undetermined',
426
+ reason: `the platform's egress container did not start (${refused}: ${describeOutcome(run)})`,
427
+ // The daemon just ran the marker container, so a bridge it could not attach now is
428
+ // the sort of thing that can differ on the next job.
429
+ recheck: true,
430
+ }
431
+ : {
432
+ status: 'undetermined',
433
+ reason: `the platform's egress check printed no verdict (${describeOutcome(run)})`,
434
+ // Same image, same argv, same output: asking again re-reads the same non-answer.
435
+ recheck: false,
436
+ }
437
+ }
438
+ if ([tcp, dns].some((code) => code === 126 || code === 127)) {
439
+ return {
440
+ status: 'undetermined',
441
+ reason:
442
+ "the platform's egress check could not run inside its own probe container (the payload " +
443
+ 'has no `nc` or `nslookup` applet)',
444
+ // A fact about the image this repo builds, which does not change under a running container.
445
+ recheck: false,
446
+ }
447
+ }
448
+ if (tcp === 0 && dns === 0) return { status: 'reachable' }
449
+ if (tcp === 0) {
450
+ return {
451
+ status: 'blocked',
452
+ detail: `a container reached ${where} but could not resolve ${target.dnsName}: the route out works and DNS does not`,
453
+ }
454
+ }
455
+ if (dns === 0) {
456
+ // A resolved name proves a path out of the container exists, so the connect failing is far
457
+ // more likely to be about the ADDRESS than about the network. Saying "blocked" here would
458
+ // condemn a working sandbox over a target it happens to filter.
459
+ return {
460
+ status: 'undetermined',
461
+ reason:
462
+ `a container resolved ${target.dnsName} but could not connect to ${where}, so this ` +
463
+ 'deployment probably filters that address; point HARNESS_DOCKER_EGRESS_TARGET at one it permits',
464
+ // A filtered address is a standing property of the network this container sits in.
465
+ recheck: false,
466
+ }
467
+ }
468
+ return {
469
+ status: 'blocked',
470
+ detail: `a container could reach neither ${where} nor ${target.dnsName}, the two the platform is configured to try`,
471
+ }
472
+ }
473
+
474
+ /** What a 125 from the egress run means, kept beside the other platform-side run messages. */
475
+ const daemonRefusedEgressRun = 'the daemon refused to create or start it'
476
+
477
+ /**
478
+ * The exit status printed after `marker`, or undefined when the container never printed one.
479
+ *
480
+ * The LAST occurrence wins, so a marker that somehow reached the stream twice is read at its
481
+ * final value rather than at whichever came first.
482
+ */
483
+ function readMarker(stdout: string, marker: string): number | undefined {
484
+ const status = [...stdout.matchAll(new RegExp(`${marker}(\\d{1,3})`, 'g'))].pop()?.[1]
485
+ return status === undefined ? undefined : Number(status)
486
+ }
487
+
488
+ /**
489
+ * What the container RUN proves. The one step allowed to conclude `unusable`, and even here not
490
+ * every non-zero exit is evidence about the daemon.
491
+ *
492
+ * Docker splits its own failures from the container's by exit code: 125 is `docker run` itself
493
+ * failing, 126 is a command that could not be invoked and 127 one that was not found. The last
494
+ * two are facts about THE PAYLOAD, a binary this platform put in an image it built, and the
495
+ * container had to be created and started to produce them. 125 covers both the daemon refusing to
496
+ * create the container (the verdict this whole module exists for) and the tag not being there to
497
+ * run, which is our own load, so that one is split by what docker SAID.
498
+ *
499
+ * Its own return type, and not {@link DockerWorkload}: what the marker run establishes is a
500
+ * daemon that runs containers, which is the first half of a `usable` verdict and not the whole
501
+ * of one. Naming the intermediate is what stops it being returned as a finished answer with the
502
+ * egress half silently absent.
503
+ */
504
+ type RunVerdict = { status: 'usable' } | Exclude<DockerWorkload, { status: 'usable' }>
505
+
506
+ function classifyRun(run: CommandOutcome): RunVerdict {
507
+ if (run.outcome === 'failed') {
508
+ return undeterminable(`the platform's container check did not run (${run.reason})`, true)
509
+ }
510
+ if (run.code === 0) {
511
+ if (run.stdout.includes(PROBE_SENTINEL)) return { status: 'usable' }
512
+ // Nothing explains this: the container was reported as having run cleanly and produced none
513
+ // of the output it exists to produce. That is a fact about the check, not about the daemon.
514
+ return undeterminable(
515
+ "the platform's probe container exited cleanly without printing its marker",
516
+ true,
517
+ )
518
+ }
519
+ const ours = platformSideRunFailure(run)
520
+ return ours
521
+ ? undeterminable(`${ours} (${describeOutcome(run)})`, true)
522
+ : { status: 'unusable', detail: describeOutcome(run) }
523
+ }
524
+
525
+ /** Messages that name the PLATFORM's half of a failed run rather than the daemon's. */
526
+ const PLATFORM_SIDE_RUN_MESSAGES: readonly { pattern: RegExp; cause: string }[] = [
527
+ {
528
+ pattern: /no such image|unable to find image/i,
529
+ cause: "the platform's probe image was not there to be run",
530
+ },
531
+ {
532
+ pattern: /exec format error/i,
533
+ cause: "the platform's probe binary cannot be executed on this daemon's machine",
534
+ },
535
+ ]
536
+
537
+ function platformSideRunFailure(run: {
538
+ code: number
539
+ stdout: string
540
+ stderr: string
541
+ }): string | undefined {
542
+ if (run.code === 126 || run.code === 127) {
543
+ return "the platform's probe binary could not be invoked inside the container"
544
+ }
545
+ const said = `${run.stderr}\n${run.stdout}`
546
+ return PLATFORM_SIDE_RUN_MESSAGES.find((m) => m.pattern.test(said))?.cause
547
+ }
548
+
549
+ /**
550
+ * Assemble the archive for the daemon's architecture, reusing the last one built.
551
+ *
552
+ * A read that fails is classified rather than asserted away: `HARNESS_DOCKER_PROBE_BINARY`
553
+ * pointing at a directory, at a path this user may not read, or at a failing mount is a
554
+ * misconfiguration an operator can fix, and "this machine does not have it" states the opposite
555
+ * fact. The sentence goes into an agent's system prompt and into `GET /health`, so a discarded
556
+ * cause is a cause nobody ever sees.
557
+ */
558
+ async function assembleArchive(
559
+ deps: DockerWorkloadDeps,
560
+ architecture: string,
561
+ ): Promise<{ archive: Buffer } | { reason: string }> {
562
+ const key = `${deps.payloadPath}::${architecture}`
563
+ const held = deps.archives?.read(key)
564
+ if (held) return { archive: held }
565
+ let payload: Buffer
566
+ try {
567
+ payload = await deps.readPayload(deps.payloadPath)
568
+ } catch (err) {
569
+ return { reason: describePayloadFailure(err, deps.payloadPath) }
570
+ }
571
+ const archive = buildProbeArchive(payload, architecture)
572
+ deps.archives?.write(key, archive)
573
+ return { archive }
574
+ }
575
+
576
+ function describePayloadFailure(err: unknown, path: string): string {
577
+ const needs = `the platform's own container check needs ${path}`
578
+ switch ((err as NodeJS.ErrnoException).code) {
579
+ case 'ENOENT':
580
+ case 'ENOTDIR':
581
+ return `${needs}, which this machine does not have`
582
+ case 'EACCES':
583
+ case 'EPERM':
584
+ return `${needs}, which it is not permitted to read`
585
+ case 'EISDIR':
586
+ return `${needs} to be a file, and it is a directory`
587
+ default:
588
+ return `${needs}, which could not be read (${describeThrown(err)})`
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Remove the probe image, and SAY SO when that did not work.
594
+ *
595
+ * The one line above it promises an agent will never find a `cat-factory-docker-probe` and wonder
596
+ * whose it is, and the daemon has two ordinary ways to refuse: a `--rm` teardown still in flight
597
+ * holds the image ("image is being used by stopped container"), and a wedged daemon does not
598
+ * answer at all. Discarding the outcome left both silent, so the promise was unverifiable in
599
+ * exactly the states that break it.
600
+ */
601
+ async function removeProbeImage(deps: DockerWorkloadDeps): Promise<void> {
602
+ const removed = await deps.runDocker(['image', 'rm', '-f', PROBE_IMAGE_TAG], {
603
+ timeoutMs: CLEANUP_TIMEOUT_MS,
604
+ ...(deps.logger ? { logger: deps.logger } : {}),
605
+ })
606
+ if (removed.outcome === 'ran' && removed.code === 0) return
607
+ ;(deps.logger ?? log).warn('docker capability: the probe image could not be removed', {
608
+ image: PROBE_IMAGE_TAG,
609
+ error: describeOutcome(removed),
610
+ })
611
+ }
612
+
613
+ function undeterminable(reason: string, daemonAnswered: boolean): DockerWorkload {
614
+ return { status: 'unknown', reason, daemonAnswered }
615
+ }
616
+
617
+ /** A bounded, scrubbed one-line summary of what a command said, for a prompt or a log field. */
618
+ function describeOutcome(outcome: CommandOutcome): string {
619
+ if (outcome.outcome === 'failed') return outcome.reason
620
+ const said = `${outcome.stderr}\n${outcome.stdout}`
621
+ .split('\n')
622
+ .map((line) => line.trim())
623
+ .filter(Boolean)
624
+ .join('; ')
625
+ return bounded(said) || `docker exited ${outcome.code} without saying why`
626
+ }
627
+
628
+ /** The one describer for a thrown value here: scrubbed and bounded, like any other detail. */
629
+ function describeThrown(err: unknown): string {
630
+ return bounded(err instanceof Error ? err.message : String(err)) || 'it said nothing'
631
+ }
632
+
633
+ function bounded(text: string): string {
634
+ return scrubbedExcerpt(text, DETAIL_CHARS)
635
+ }
636
+
637
+ /**
638
+ * A measurement, plus what the last one concluded without taking another.
639
+ *
640
+ * Callable because every caller wants the verdict; `last()` exists for `GET /health`, which is
641
+ * polled and must not spawn a container per poll to answer a question it does not act on.
642
+ */
643
+ export interface DockerWorkloadProbe {
644
+ (signal?: AbortSignal): Promise<DockerWorkload>
645
+ last(): DockerWorkload | undefined
646
+ }
647
+
648
+ /** One in-flight measurement and the callers still waiting for it. */
649
+ interface Measurement {
650
+ readonly result: Promise<DockerWorkload>
651
+ readonly cancel: AbortController
652
+ waiters: number
653
+ }
654
+
655
+ /**
656
+ * Build a probe that measures at most once per container for a POSITIVE answer.
657
+ *
658
+ * A daemon that has run a container has proved something that does not stop being true, so that
659
+ * verdict is kept and every later job reads it for free. A negative is NOT kept, for the reason
660
+ * `resolveDockerVerdict` gives about the boot record: a container outlives its boot, a warm pool
661
+ * serves many jobs from one, and a daemon that was not ready for the first job must not latch
662
+ * the whole container into saying so. Re-measuring a negative is cheap; a daemon that cannot
663
+ * mount fails at once.
664
+ *
665
+ * A `usable` verdict whose EGRESS could not be determined is re-measured on the same rule and for
666
+ * the same reason. Whether the bridge is NATed is settled once and for the daemon's life, so a
667
+ * measured `reachable` or `blocked` is kept; a check that timed out measured nothing, and latching
668
+ * that would leave the container permanently unable to say which of the two it is.
669
+ *
670
+ * But only where asking again could ANSWER differently, which is what `ContainerEgress.recheck`
671
+ * carries. Most ways to reach `undetermined` are standing facts about this container: a rejected
672
+ * target setting, a payload with no `nc`, an address the deployment filters. Re-running the whole
673
+ * measurement on those never converges, and it is not cheap: it is `docker version`, the archive,
674
+ * `docker load`, two container starts and an `image rm`, per job, ahead of the clone.
675
+ *
676
+ * Concurrent callers share one in-flight measurement rather than each starting a container, and
677
+ * the measurement is cancelled when the LAST of them has abandoned it. Neither half is optional:
678
+ * one job's abort may not kill a measurement a sibling job is still waiting on (the local native
679
+ * transport serves every concurrent job from one process), and a measurement nobody is waiting
680
+ * for is a container start no job will read, which is what an abandoned run should stop paying
681
+ * for the moment it is abandoned.
682
+ */
683
+ export function createDockerWorkloadProbe(
684
+ deps: DockerWorkloadDeps = realDeps,
685
+ ): DockerWorkloadProbe {
686
+ let latest: DockerWorkload | undefined
687
+ let inFlight: Measurement | undefined
688
+ const begin = (): Measurement => {
689
+ const cancel = new AbortController()
690
+ const measurement: Measurement = {
691
+ cancel,
692
+ waiters: 0,
693
+ result: measureDockerWorkload(deps, cancel.signal).then((verdict) => {
694
+ latest = verdict
695
+ if (inFlight === measurement) inFlight = undefined
696
+ return verdict
697
+ }),
698
+ }
699
+ return measurement
700
+ }
701
+ const probe = (async (signal?: AbortSignal): Promise<DockerWorkload> => {
702
+ if (latest?.status === 'usable' && !isWorthReMeasuring(latest.egress)) return latest
703
+ const measurement = (inFlight ??= begin())
704
+ measurement.waiters += 1
705
+ const watch = signal ? watchAbandonment(signal) : undefined
706
+ try {
707
+ return watch
708
+ ? await Promise.race([measurement.result, watch.abandoned])
709
+ : await measurement.result
710
+ } finally {
711
+ watch?.dispose()
712
+ measurement.waiters -= 1
713
+ if (measurement.waiters === 0 && inFlight === measurement) measurement.cancel.abort()
714
+ }
715
+ }) as DockerWorkloadProbe
716
+ probe.last = () => latest
717
+ return probe
718
+ }
719
+
720
+ /** Whether a kept verdict's egress half is one that asking again could still settle. */
721
+ function isWorthReMeasuring(egress: ContainerEgress): boolean {
722
+ return egress.status === 'undetermined' && egress.recheck
723
+ }
724
+
725
+ /**
726
+ * A verdict for the caller whose job was cancelled while it waited, and the listener teardown
727
+ * that keeps a long-lived native-transport process from accumulating one per job.
728
+ */
729
+ function watchAbandonment(signal: AbortSignal): {
730
+ abandoned: Promise<DockerWorkload>
731
+ dispose: () => void
732
+ } {
733
+ let give: () => void = () => {}
734
+ const abandoned = new Promise<DockerWorkload>((resolve) => {
735
+ give = () =>
736
+ resolve(
737
+ undeterminable(
738
+ "the job was cancelled before the platform's container check answered",
739
+ false,
740
+ ),
741
+ )
742
+ if (signal.aborted) give()
743
+ else signal.addEventListener('abort', give, { once: true })
744
+ })
745
+ return { abandoned, dispose: () => signal.removeEventListener('abort', give) }
746
+ }
747
+
748
+ /** The process-wide probe. One per container, which is what makes the positive memo worth having. */
749
+ export const probeDockerWorkload: DockerWorkloadProbe = createDockerWorkloadProbe()
750
+
751
+ /**
752
+ * What `GET /health` reports about the workload check.
753
+ *
754
+ * `unmeasured` is its own word rather than an omitted key or a `null`: this endpoint is polled
755
+ * from boot, so "nothing has needed the daemon yet" is the normal early answer and it must not
756
+ * read as either a broken daemon or a build that cannot report one.
757
+ */
758
+ export function reportedDockerWorkload(
759
+ probe: DockerWorkloadProbe = probeDockerWorkload,
760
+ ): DockerWorkload | { status: 'unmeasured'; reason: string } {
761
+ return (
762
+ probe.last() ?? {
763
+ status: 'unmeasured',
764
+ reason: 'nothing in this container has needed the docker daemon yet',
765
+ }
766
+ )
767
+ }