@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,117 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { log, type Logger } from './logger.js'
3
+ import { killChildProcess, spawnDetached } from './process.js'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // How the harness runs one `docker …` command ON ITS OWN BEHALF, bounded and abortable.
7
+ //
8
+ // This is NOT a second `runCapturedCommand` (captured-command.ts), which stays the one way the
9
+ // harness runs a DECLARED shell command: that one takes a shell string, merges both streams into
10
+ // one rolling tail and answers with a conventional exit code, because its two callers report a
11
+ // pass/fail plus a tail to a model. The docker checks need the three things it deliberately does
12
+ // not offer: an argv (no shell, so nothing quotes an image tag), a STDIN body (the probe archive
13
+ // is piped to `docker load`), and stdout kept APART from stderr, since the whole evidence that a
14
+ // container ran is a marker on stdout while the evidence of why it did not is on stderr.
15
+ //
16
+ // What it does NOT re-decide is how a child dies: `killChildProcess` owns the SIGTERM→SIGKILL
17
+ // escalation for every process this harness spawns, and a bespoke `SIGKILL` here would be one
18
+ // path whose kill semantics drift from the rest with no test able to see it.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /** How much of each stream is buffered. The TAIL is kept: that is where a failure prints. */
22
+ const OUTPUT_CAP_CHARS = 64 * 1024
23
+
24
+ /** What running one docker command did, kept as raw as the spawn. */
25
+ export type CommandOutcome =
26
+ | { outcome: 'ran'; code: number; stdout: string; stderr: string }
27
+ | { outcome: 'failed'; reason: string }
28
+
29
+ /** What one docker invocation is given. `timeoutMs` is required: an unbounded one has no caller. */
30
+ export interface DockerCommandOptions {
31
+ /** Piped to the command's stdin and closed. */
32
+ stdin?: Buffer
33
+ /** The job's signal. An abandoned job's command is killed rather than left running. */
34
+ signal?: AbortSignal
35
+ timeoutMs: number
36
+ logger?: Logger
37
+ }
38
+
39
+ /** Run one `docker …` command. Injected so the suite drives every branch with no daemon. */
40
+ export type DockerCommandRunner = (
41
+ args: string[],
42
+ opts: DockerCommandOptions,
43
+ ) => Promise<CommandOutcome>
44
+
45
+ /**
46
+ * The real runner: spawn docker, feed it `stdin` when there is any, and report what happened.
47
+ *
48
+ * Never rejects. Every way a spawn can go wrong is one of the two outcomes, because the caller
49
+ * classifies them differently and an exception would collapse that distinction into whichever
50
+ * `catch` caught it first.
51
+ */
52
+ export const spawnDockerCommand: DockerCommandRunner = (args, opts) =>
53
+ new Promise<CommandOutcome>((resolve) => {
54
+ const logger = opts.logger ?? log
55
+ if (opts.signal?.aborted) {
56
+ resolve({ outcome: 'failed', reason: abandonedReason(args) })
57
+ return
58
+ }
59
+ const child = spawn('docker', args, {
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ detached: spawnDetached,
62
+ windowsHide: true,
63
+ })
64
+ let stdout = ''
65
+ let stderr = ''
66
+ let settled = false
67
+ const finish = (result: CommandOutcome): void => {
68
+ if (settled) return
69
+ settled = true
70
+ clearTimeout(timer)
71
+ opts.signal?.removeEventListener('abort', onAbort)
72
+ resolve(result)
73
+ }
74
+ const timer = setTimeout(() => {
75
+ logger.warn('docker: command did not answer in time, killing it', {
76
+ command: args[0] ?? '',
77
+ timeoutMs: opts.timeoutMs,
78
+ })
79
+ killChildProcess(child, undefined, logger)
80
+ finish({
81
+ outcome: 'failed',
82
+ reason: `\`docker ${args[0] ?? ''}\` did not answer within ${Math.round(opts.timeoutMs / 1000)}s`,
83
+ })
84
+ }, opts.timeoutMs)
85
+ timer.unref?.()
86
+ const onAbort = (): void => {
87
+ killChildProcess(child, undefined, logger)
88
+ finish({ outcome: 'failed', reason: abandonedReason(args) })
89
+ }
90
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
91
+ // The tail, not the head: a `docker run` that failed says why in its last lines, and the
92
+ // marker a passing one prints is the whole of its output anyway.
93
+ child.stdout.on('data', (chunk: Buffer) => {
94
+ stdout = (stdout + chunk.toString('utf8')).slice(-OUTPUT_CAP_CHARS)
95
+ })
96
+ child.stderr.on('data', (chunk: Buffer) => {
97
+ stderr = (stderr + chunk.toString('utf8')).slice(-OUTPUT_CAP_CHARS)
98
+ })
99
+ child.on('error', (err: NodeJS.ErrnoException) => {
100
+ finish({
101
+ outcome: 'failed',
102
+ reason:
103
+ err.code === 'ENOENT'
104
+ ? 'the docker CLI is not on PATH'
105
+ : `the docker CLI could not be spawned (${err.code ?? err.message})`,
106
+ })
107
+ })
108
+ child.on('close', (code) => finish({ outcome: 'ran', code: code ?? -1, stdout, stderr }))
109
+ // A daemon that dies mid-load closes the pipe under us; `close` above already reports that,
110
+ // so the EPIPE here has nothing to add and must not become an unhandled error event.
111
+ child.stdin.on('error', () => {})
112
+ child.stdin.end(opts.stdin)
113
+ })
114
+
115
+ function abandonedReason(args: string[]): string {
116
+ return `the job was cancelled before \`docker ${args[0] ?? ''}\` answered`
117
+ }
@@ -0,0 +1,328 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { scrubbedExcerpt } from './redact.js'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // The one-container image the platform runs to find out whether this machine's Docker daemon
6
+ // can actually run a container, built here in memory rather than pulled.
7
+ //
8
+ // It exists because `docker info` answers a different question from the one every caller
9
+ // actually asks. A daemon that ANSWERS is not a daemon that WORKS: this container's rootless
10
+ // daemon runs inside whatever sandbox the deployment gave it, and a nested user namespace
11
+ // routinely refuses the overlay mount every image materialisation needs. The daemon serves
12
+ // happily, `docker version` reports a server, and `docker pull` of a multi-layer image,
13
+ // `docker run` of a single-layer one and `docker build` all fail with the same EINVAL. Issue
14
+ // #2120 is three agents in one run each discovering that for themselves, against a system
15
+ // prompt that told them, as stated fact, that Docker worked here.
16
+ //
17
+ // Why the payload is BUILT and not pulled: a probe that needs the network answers a question
18
+ // about the registry as much as about the daemon, cannot run in a sandbox with no egress, and
19
+ // costs the job its first turn. This one is a single layer holding one statically linked
20
+ // binary already in the image, assembled into a docker-archive tar and handed to `docker load`
21
+ // on stdin, so the whole check is local and takes about as long as starting one container.
22
+ //
23
+ // ONE layer, deliberately. The reported failure kills `docker run` of a single-layer image too
24
+ // (the container's own writable layer is already a second overlay lower dir), so one layer is
25
+ // enough to detect it, and it keeps `docker load` (the step this file could plausibly get WRONG)
26
+ // as small as it can be. That matters because the caller reads a load failure as "could
27
+ // not determine" and a RUN failure as "this daemon cannot run containers": a bug in the archive
28
+ // below must never be able to tell an agent that a working daemon is broken.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** The tag the probe image is loaded under. Removed again once the check has answered. */
32
+ export const PROBE_IMAGE_TAG = 'cat-factory-docker-probe:1'
33
+
34
+ /** Where the payload binary lands inside the probe image, and what the container is asked to run. */
35
+ const PROBE_BINARY_PATH = 'busybox'
36
+
37
+ /**
38
+ * What the container must print for the check to pass.
39
+ *
40
+ * A marker on stdout rather than a zero exit status: the point of the check is that a process
41
+ * inside the container actually ran, and only output it produced proves that. An exit status is
42
+ * the daemon's word for it.
43
+ */
44
+ export const PROBE_SENTINEL = 'cat-factory-docker-probe-ok'
45
+
46
+ /** The argv the probe container runs. `busybox` dispatches on its own name, so this is an echo. */
47
+ export const PROBE_COMMAND: readonly string[] = [`/${PROBE_BINARY_PATH}`, 'echo', PROBE_SENTINEL]
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // The second thing the same image is asked, and the one the marker run above structurally cannot
51
+ // answer: whether a container started on this daemon can reach the NETWORK.
52
+ //
53
+ // Loading and running a local image needs no network at all, so a daemon whose nested containers
54
+ // are cut off passes the marker run exactly as a working one does. That is not hypothetical: the
55
+ // published executor image ran its rootless daemon with `--iptables=false`, which drops the
56
+ // MASQUERADE rule for the bridge, and every nested container on it had no egress whatsoever
57
+ // (issue #2173). The harness reported `dockerDaemon: "usable"` throughout, and each agent
58
+ // discovered otherwise about seven minutes into an `npm ci` inside a `docker build` (issue
59
+ // #2174). An agent TOLD it has no egress can plan around it; an agent told docker works cannot.
60
+ //
61
+ // It runs as its own container rather than as one more command in the marker run, because the
62
+ // marker run is deliberately `--network none`. The two need opposite networking, so they cannot
63
+ // be the same `docker run`, and keeping them apart has a second payoff: a failure of anything
64
+ // below can only ever produce an EGRESS verdict, never a verdict about the daemon.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * What the egress container prints for each observation: the marker, then the exit STATUS of the
69
+ * command that made it.
70
+ *
71
+ * The status rather than a pass/fail marker, because the two failures need different answers.
72
+ * A refused connection is evidence about the network; a 127 is busybox saying it has no such
73
+ * applet, which is evidence about the platform's own probe image and may never be reported as a
74
+ * network that is not there.
75
+ */
76
+ export const EGRESS_TCP_MARKER = 'cat-factory-egress-tcp='
77
+ export const EGRESS_DNS_MARKER = 'cat-factory-egress-dns='
78
+
79
+ /** How long the in-container connect may take. Short: a blocked route is silent, not slow. */
80
+ const EGRESS_CONNECT_TIMEOUT_SECONDS = 3
81
+
82
+ /**
83
+ * How long busybox is given to print its own `nc` usage, for the capability check below. Bounded
84
+ * like everything else in that container: an applet that somehow blocks may not take the budget
85
+ * of the measurement it is only a preamble to.
86
+ */
87
+ const EGRESS_USAGE_TIMEOUT_SECONDS = 2
88
+
89
+ /**
90
+ * How long the in-container lookup may take. Its own ceiling because busybox's `nslookup` retries
91
+ * on its own schedule, and an unbounded one would spend the whole check's budget on the half that
92
+ * is the diagnostic rather than the verdict.
93
+ */
94
+ const EGRESS_LOOKUP_TIMEOUT_SECONDS = 6
95
+
96
+ /** Where the egress check aims: a raw address, plus a name to resolve. */
97
+ export interface EgressTarget {
98
+ host: string
99
+ port: number
100
+ dnsName: string
101
+ }
102
+
103
+ /**
104
+ * How much of a rejected setting is quoted back. Enough to recognise which value was refused,
105
+ * short of letting a pasted blob be most of an agent's system prompt.
106
+ */
107
+ const SETTING_CHARS = 60
108
+
109
+ /** An IPv4 literal. Names are refused on purpose: a target that needs DNS cannot TEST DNS. */
110
+ const IPV4 =
111
+ /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/
112
+
113
+ /** A hostname, in the narrow shape a DNS lookup can be aimed at. */
114
+ const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i
115
+
116
+ /**
117
+ * Read the configured target, or say why it cannot be used.
118
+ *
119
+ * Validated rather than trusted, and strictly, for two reasons that both matter. The host and the
120
+ * name are interpolated into a `sh -c` script INSIDE the probe container, so anything else there
121
+ * would be running whatever a deployment's environment happened to hold; and a target that is
122
+ * quietly wrong produces a confident `blocked` about a daemon that is fine, which is the exact
123
+ * class of lie this whole module exists to remove. A rejected setting is REPORTED as a check that
124
+ * could not be carried out, never silently swapped for the default: an operator who pointed this
125
+ * at an address their network permits is entitled to find out that it was ignored.
126
+ */
127
+ export function parseEgressTarget(
128
+ target: string,
129
+ dnsName: string,
130
+ ): { target: EgressTarget } | { invalid: string } {
131
+ const [host = '', port = '', ...rest] = target.split(':')
132
+ if (rest.length > 0 || !IPV4.test(host)) {
133
+ return {
134
+ invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, which is not an \`IPv4:port\` address`,
135
+ }
136
+ }
137
+ const parsed = Number(port)
138
+ if (!/^[0-9]{1,5}$/.test(port) || parsed < 1 || parsed > 65535) {
139
+ return {
140
+ invalid: `the platform's egress check is configured with \`${scrubbedExcerpt(target, SETTING_CHARS)}\`, whose port is not a number between 1 and 65535`,
141
+ }
142
+ }
143
+ if (!HOSTNAME.test(dnsName)) {
144
+ return {
145
+ invalid: `the platform's egress check is configured to resolve \`${scrubbedExcerpt(dnsName, SETTING_CHARS)}\`, which is not a hostname`,
146
+ }
147
+ }
148
+ return { target: { host, port: parsed, dnsName } }
149
+ }
150
+
151
+ /**
152
+ * The argv the egress container runs: connect, say what that returned, resolve, say the same.
153
+ *
154
+ * Both observations are made and BOTH are reported, because they fail for different reasons and
155
+ * have different fixes. A connect to a raw address needs only a route; a lookup needs the
156
+ * daemon's embedded resolver to be reachable and to forward. Reporting only the first would call
157
+ * a container with a working route and broken DNS "reachable", and nothing an agent fetches by
158
+ * name would work there.
159
+ *
160
+ * Every applet is called by its full path (`/busybox nc`) rather than by name. The image holds
161
+ * one file and no PATH, and busybox's standalone-shell dispatch is a build-time option nothing
162
+ * here may assume.
163
+ *
164
+ * The connect is the half that is easy to get silently wrong, and `nc -w SEC` alone gets it
165
+ * wrong. busybox documents that flag as the timeout for connects AND FINAL NET READS: once stdin
166
+ * hits EOF `nc` half-closes and then waits to be spoken to, so a connect that SUCCEEDED to a peer
167
+ * which expects the client to speak first (every TLS port, the default `1.1.1.1:443` included)
168
+ * hits the alarm and exits non-zero. Read off the exit status alone that is indistinguishable
169
+ * from a refusal, so a working network reports a route that is not there. `-z` means "connect,
170
+ * then stop", which is the question being asked, so it is used wherever the payload's busybox was
171
+ * built with it; where it was not, the connect runs with no `-w`, which is the build whose `nc`
172
+ * exits on its own when stdin closes.
173
+ *
174
+ * Both halves are wrapped in `${busybox} timeout` either way, since a blackholed route is silent
175
+ * rather than refused and the applet's own ceiling is the thing this comment exists because we
176
+ * cannot assume.
177
+ */
178
+ export function buildEgressCommand(target: EgressTarget): readonly string[] {
179
+ const busybox = `/${PROBE_BINARY_PATH}`
180
+ const bounded = (seconds: number, command: string): string =>
181
+ `${busybox} timeout ${seconds} ${command}`
182
+ const where = `${target.host} ${target.port}`
183
+ const connectSeconds = EGRESS_CONNECT_TIMEOUT_SECONDS + 1
184
+ const connect = [
185
+ 'nc_z=no',
186
+ `case "$(${bounded(EGRESS_USAGE_TIMEOUT_SECONDS, `${busybox} nc`)} 2>&1)" in *-z*) nc_z=yes ;; esac`,
187
+ 'if [ "$nc_z" = yes ]; then',
188
+ ` ${bounded(connectSeconds, `${busybox} nc -w ${EGRESS_CONNECT_TIMEOUT_SECONDS} -z ${where}`)} >/dev/null 2>&1`,
189
+ 'else',
190
+ ` ${bounded(connectSeconds, `${busybox} nc ${where}`)} </dev/null >/dev/null 2>&1`,
191
+ 'fi',
192
+ ].join('\n')
193
+ const resolve = bounded(EGRESS_LOOKUP_TIMEOUT_SECONDS, `${busybox} nslookup ${target.dnsName}`)
194
+ return [
195
+ busybox,
196
+ 'sh',
197
+ '-c',
198
+ [
199
+ `${connect}\necho "${EGRESS_TCP_MARKER}$?"`,
200
+ `${resolve} >/dev/null 2>&1; echo "${EGRESS_DNS_MARKER}$?"`,
201
+ ].join('\n'),
202
+ ]
203
+ }
204
+
205
+ /**
206
+ * Node's architecture names mapped onto the docker name for the same machine.
207
+ *
208
+ * This names THE PAYLOAD, never the daemon. `process.arch` is the architecture of the harness
209
+ * process, and the binary it hands over is built for that; the daemon it is measured against
210
+ * answers for itself (`docker version --format {{.Server.Arch}}`), and the caller compares the
211
+ * two rather than assuming they agree. An external `DOCKER_HOST` is a first-class path here, and
212
+ * an arm64 harness talking to an amd64 sidecar shares nothing with it but the socket.
213
+ *
214
+ * That comparison is also what makes two of these entries safe. `process.arch` reports `ppc64` on
215
+ * both endiannesses and `arm` with no variant, so those rows are a HYPOTHESIS about the payload,
216
+ * not a claim: a machine the guess is wrong about answers with a different name and the check
217
+ * reports that it could not be carried out. Nothing here may produce a verdict about the daemon.
218
+ * An architecture nothing maps does the same, which is why `386` was worth adding rather than
219
+ * leaving to a fallback: the mapping is unambiguous and its absence cost a real check.
220
+ */
221
+ const PAYLOAD_ARCHITECTURES: Readonly<Record<string, string>> = {
222
+ x64: 'amd64',
223
+ ia32: '386',
224
+ arm64: 'arm64',
225
+ arm: 'arm',
226
+ s390x: 's390x',
227
+ ppc64: 'ppc64le',
228
+ riscv64: 'riscv64',
229
+ }
230
+
231
+ /** The docker name for the architecture THIS process's payload is built for, when there is one. */
232
+ export function payloadArchitecture(arch: string = process.arch): string | undefined {
233
+ return PAYLOAD_ARCHITECTURES[arch]
234
+ }
235
+
236
+ const TAR_BLOCK = 512
237
+
238
+ /** A fixed timestamp everywhere a tar or an image config wants one, so the archive is byte-stable. */
239
+ const EPOCH = '1970-01-01T00:00:00Z'
240
+
241
+ interface TarEntry {
242
+ name: string
243
+ content: Buffer
244
+ mode: number
245
+ }
246
+
247
+ /**
248
+ * One ustar header block.
249
+ *
250
+ * The checksum is summed with its own field read as eight SPACES and only then written back
251
+ * over it, which is the format's own rule and the one detail a hand-rolled writer gets wrong. A
252
+ * header whose checksum covers its own checksum bytes is rejected by every reader, and
253
+ * `docker load` reports that as an unreadable archive, which this module's caller would then
254
+ * have to decide was not the daemon's fault.
255
+ */
256
+ function tarHeader(name: string, size: number, mode: number): Buffer {
257
+ const header = Buffer.alloc(TAR_BLOCK)
258
+ header.write(name, 0, 100, 'utf8')
259
+ header.write(octalField(mode, 8), 100, 8, 'latin1')
260
+ header.write(octalField(0, 8), 108, 8, 'latin1') // uid
261
+ header.write(octalField(0, 8), 116, 8, 'latin1') // gid
262
+ header.write(octalField(size, 12), 124, 12, 'latin1')
263
+ header.write(octalField(0, 12), 136, 12, 'latin1') // mtime
264
+ header.write(' ', 148, 8, 'latin1')
265
+ header.write('0', 156, 1, 'latin1') // typeflag: a regular file
266
+ header.write('ustar\0', 257, 6, 'latin1')
267
+ header.write('00', 263, 2, 'latin1')
268
+ let sum = 0
269
+ for (const byte of header) sum += byte
270
+ header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'latin1')
271
+ return header
272
+ }
273
+
274
+ /** A numeric tar field: zero-padded octal in `width - 1` characters, then a NUL. */
275
+ function octalField(value: number, width: number): string {
276
+ return `${value.toString(8).padStart(width - 1, '0')}\0`
277
+ }
278
+
279
+ /** One tar member: its header, its content, and the padding up to the next 512-byte block. */
280
+ function tarMember(entry: TarEntry): Buffer {
281
+ const padding = (TAR_BLOCK - (entry.content.length % TAR_BLOCK)) % TAR_BLOCK
282
+ return Buffer.concat([
283
+ tarHeader(entry.name, entry.content.length, entry.mode),
284
+ entry.content,
285
+ Buffer.alloc(padding),
286
+ ])
287
+ }
288
+
289
+ /** A whole tar stream: the members, then the two zero blocks that terminate one. */
290
+ export function tarArchive(entries: readonly TarEntry[]): Buffer {
291
+ return Buffer.concat([...entries.map(tarMember), Buffer.alloc(TAR_BLOCK * 2)])
292
+ }
293
+
294
+ /**
295
+ * Assemble the docker-archive `docker load` reads, from one statically linked binary.
296
+ *
297
+ * Classic (v1) docker-archive rather than OCI layout: `docker load` accepts both on every engine
298
+ * this image can run against, and the v1 shape is three files with no blob directory to get
299
+ * wrong. The layer digest is the sha256 of the UNCOMPRESSED layer tar, which is what
300
+ * `rootfs.diff_ids` means; an engine that disagrees with it refuses the load, which the caller
301
+ * reads as could-not-determine rather than as a broken daemon.
302
+ *
303
+ * `architecture` is the DAEMON's own word for its architecture, in docker's vocabulary, so
304
+ * nothing here decides it (see {@link PAYLOAD_ARCHITECTURES}). The result is byte-stable for one
305
+ * `(payload, architecture)` pair, which is what lets the caller build it once per container.
306
+ */
307
+ export function buildProbeArchive(payload: Buffer, architecture: string): Buffer {
308
+ const layer = tarArchive([{ name: PROBE_BINARY_PATH, content: payload, mode: 0o755 }])
309
+ const diffId = `sha256:${createHash('sha256').update(layer).digest('hex')}`
310
+ const config = Buffer.from(
311
+ JSON.stringify({
312
+ architecture,
313
+ os: 'linux',
314
+ created: EPOCH,
315
+ config: {},
316
+ rootfs: { type: 'layers', diff_ids: [diffId] },
317
+ history: [{ created: EPOCH, created_by: 'cat-factory docker capability probe' }],
318
+ }),
319
+ )
320
+ const manifest = Buffer.from(
321
+ JSON.stringify([{ Config: 'config.json', RepoTags: [PROBE_IMAGE_TAG], Layers: ['layer.tar'] }]),
322
+ )
323
+ return tarArchive([
324
+ { name: 'config.json', content: config, mode: 0o644 },
325
+ { name: 'layer.tar', content: layer, mode: 0o644 },
326
+ { name: 'manifest.json', content: manifest, mode: 0o644 },
327
+ ])
328
+ }