@cat-factory/executor-harness 1.134.0 → 1.137.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,512 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { log, type Logger } from './logger.js'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // What this machine actually has, probed ONCE per job and stated to the agent.
7
+ //
8
+ // The platform used to tell every agent to discover its own environment ("probe for a tool before
9
+ // relying on it"), and every agent did, repeatedly, and often twice within one run: an architect
10
+ // ran `for c in docker kubectl helm kustomize; do command -v $c; done`, then `docker info`, and
11
+ // the coder it handed off to rediscovered both answers thirty calls later. Four calls out of a
12
+ // forty-call budget, spent on facts the harness holds before the agent's first turn.
13
+ //
14
+ // This is the layer that CAN hold them, and the only one. The backend composes its prompt before a
15
+ // transport is even chosen, and the same job body serves the harness image, a deployment's own
16
+ // image variant and (under `LOCAL_NATIVE_AGENTS`) the developer's own machine, where the
17
+ // toolchain is theirs. So the backend states the POLICY (no cluster credentials; an artifact this
18
+ // environment cannot execute is still a correct artifact) and names no tooling at all, while this
19
+ // file states the FACTS, from a probe of the machine the agent is about to run on.
20
+ //
21
+ // Three rules the block is built around:
22
+ //
23
+ // - ABSENT and UNKNOWN must not render the same. Only a spawn that came back `ENOENT` is an
24
+ // absence; a probe that failed says so and is reported as neither present nor absent.
25
+ // - A tool NOT ON THE LIST is unknown too, which the block's last line says. The list is curated
26
+ // (what agents were observed rediscovering), so silence about `terraform` has to read as
27
+ // "nobody looked", never as "it isn't there".
28
+ // - The Docker DAEMON is a separate fact from the docker CLI, and it is the one that decides
29
+ // anything: `command -v docker` succeeds in this image and `docker build` still fails, which
30
+ // is the half-truth the old instruction produced. It is answered by RUNNING `docker info`, and
31
+ // a daemon this machine is CONFIGURED for but which has not answered yet is a fourth state
32
+ // that resolves to `unknown`, never to the absence a refused connection looks like: the
33
+ // image's daemon is started in the background and the job begins before it is ready.
34
+ //
35
+ // Deliberately NOT here: the agent's own tools (web search, file tools, MCP servers). Those are
36
+ // the CLI's, they differ per harness, and each is already stated where it is true. Claiming one
37
+ // here would be the platform asserting a capability the dispatch may not have delivered, which is
38
+ // the defect this whole change exists to remove.
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const execFileAsync = promisify(execFile)
42
+
43
+ /**
44
+ * What running one probe did, kept as RAW as the spawn: whether the binary ran, and with what.
45
+ * Classification into presence lives in pure code below, because the two callers classify the
46
+ * same result differently: a non-zero exit proves an ordinary tool is installed and proves the
47
+ * Docker daemon is not reachable.
48
+ *
49
+ * `found` is the outcome with no exit code, and it exists so that the Windows second look cannot
50
+ * be mistaken for a run: the platform's own oracle located the binary and NOTHING WAS EXECUTED.
51
+ * Collapsing it into `ran` with a synthesised `exitCode: 0` is what let `docker info` report a
52
+ * reachable daemon on the strength of `where docker`, which is the CLI-for-daemon half-truth this
53
+ * whole file exists to remove.
54
+ */
55
+ export type ProbeResult =
56
+ | { outcome: 'ran'; exitCode: number; output: string }
57
+ | { outcome: 'found' }
58
+ | { outcome: 'missing' }
59
+ | { outcome: 'failed'; reason: string }
60
+
61
+ /** Run one probe. Injected so the suite drives every branch without needing the real binaries. */
62
+ export type ProbeRunner = (command: string, args: string[]) => Promise<ProbeResult>
63
+
64
+ /** What one probe learned. `unknown` is a THIRD answer, never folded into `absent`. */
65
+ export type ToolPresence =
66
+ | { status: 'present'; version?: string }
67
+ | { status: 'absent' }
68
+ | { status: 'unknown'; reason: string }
69
+
70
+ /** One probed entry: the name the agent would type, and what came back. */
71
+ export interface ProbedTool {
72
+ name: string
73
+ presence: ToolPresence
74
+ /** Whether the rendered line carries the version. Off where a yes/no is the whole answer. */
75
+ showVersion: boolean
76
+ }
77
+
78
+ /** Everything one job's probe pass learned about the machine it is about to run on. */
79
+ export interface EnvironmentInventory {
80
+ tools: ProbedTool[]
81
+ /**
82
+ * Whether a Docker daemon actually answered: the readiness fact, not the CLI's presence. The
83
+ * image's `entrypoint.sh` starts a rootless daemon BEST-EFFORT and execs the server without
84
+ * waiting for it, so at job start this probe is the only thing that knows how that went, and
85
+ * "has not answered yet" is one of its answers (see {@link probeDockerDaemon}).
86
+ */
87
+ dockerDaemon: ToolPresence
88
+ }
89
+
90
+ /**
91
+ * The curated probe list: the toolchain an agent assumes, plus the tools runs were observed
92
+ * burning calls to discover. Kept SHORT on purpose. Every entry is a spawn and a few more words
93
+ * in every system prompt, and the block's closing line keeps an unlisted tool honestly unknown
94
+ * rather than implicitly absent.
95
+ *
96
+ * `showVersion` is on where the number changes what the agent does (comparing against a target's
97
+ * declared engines, a language's syntax level) and off where presence is the whole question.
98
+ */
99
+ const PROBES: ReadonlyArray<{
100
+ name: string
101
+ command: string
102
+ args: string[]
103
+ showVersion: boolean
104
+ }> = [
105
+ { name: 'node', command: 'node', args: ['--version'], showVersion: true },
106
+ { name: 'npm', command: 'npm', args: ['--version'], showVersion: true },
107
+ { name: 'pnpm', command: 'pnpm', args: ['--version'], showVersion: true },
108
+ { name: 'git', command: 'git', args: ['--version'], showVersion: true },
109
+ { name: 'python3', command: 'python3', args: ['--version'], showVersion: true },
110
+ { name: 'docker', command: 'docker', args: ['--version'], showVersion: false },
111
+ { name: 'jq', command: 'jq', args: ['--version'], showVersion: false },
112
+ { name: 'rg', command: 'rg', args: ['--version'], showVersion: false },
113
+ { name: 'curl', command: 'curl', args: ['--version'], showVersion: false },
114
+ { name: 'make', command: 'make', args: ['--version'], showVersion: false },
115
+ { name: 'kubectl', command: 'kubectl', args: ['version', '--client'], showVersion: false },
116
+ { name: 'helm', command: 'helm', args: ['version'], showVersion: false },
117
+ { name: 'kustomize', command: 'kustomize', args: ['version'], showVersion: false },
118
+ ]
119
+
120
+ /** The daemon readiness probe: the one probe whose EXIT CODE, not its presence, is the answer. */
121
+ const DOCKER_INFO: { command: string; args: string[] } = {
122
+ command: 'docker',
123
+ args: ['info', '--format', '{{.ServerVersion}}'],
124
+ }
125
+
126
+ /**
127
+ * Every probe here is either instant or wedged: a `--version` banner, and a `docker info` that
128
+ * either connects or is REFUSED by a missing socket in milliseconds. So the ceiling is sized to
129
+ * cut a wedged binary out of the job's critical path, not to wait for anything.
130
+ *
131
+ * It used to be ten seconds, on the stated grounds that `docker info` "can genuinely take a
132
+ * moment" against a daemon still coming up. That was wrong in the way that matters: a daemon that
133
+ * is not up yet has no socket to connect to, so docker exits non-zero AT ONCE and the ceiling is
134
+ * never reached. Waiting for a starting daemon is a different problem and is solved where it
135
+ * actually lives, in {@link probeDockerDaemon}'s retry.
136
+ */
137
+ const PROBE_TIMEOUT_MS = 5_000
138
+
139
+ /**
140
+ * How long a daemon that was EXPECTED here gets to answer before the block says it could not be
141
+ * determined. One short retry, not a readiness wait: the honest verdict for a daemon still coming
142
+ * up is `unknown`, which the rendered line turns into "try it if you need it", so there is nothing
143
+ * to buy by blocking the agent's first turn any longer than this.
144
+ */
145
+ const DAEMON_RETRY_DELAY_MS = 1_500
146
+
147
+ /**
148
+ * The real runner: spawn the tool, bounded, and report what the spawn did.
149
+ *
150
+ * The Windows second look is for the NATIVE transport on a developer's own machine, not for the
151
+ * image. `execFile` without a shell resolves `.exe` and not `.cmd`, and Node refuses to spawn a
152
+ * `.cmd` at all without one, so a perfectly installed `npm` or `pnpm` comes back `ENOENT` there and
153
+ * would be stated to the agent as NOT INSTALLED. `where` is the platform's own presence oracle and
154
+ * answers that without running the shim, at the cost of the version, which is the honest trade: the
155
+ * rendered line then names the tool with no number rather than claiming one or denying the tool.
156
+ *
157
+ * It answers PRESENCE and nothing else, which is why it returns `found` and why it ignores `args`
158
+ * without pretending otherwise: `where <command>` locates a binary, so it can say nothing about
159
+ * what that binary would have printed or exited with. The version is not the only thing lost. A
160
+ * probe whose ANSWER is its exit code (`docker info`) gets no answer at all from this branch, and
161
+ * {@link daemonPresence} is where that is turned into an unknown rather than a reachable daemon.
162
+ */
163
+ export function spawnProbeRunner(signal?: AbortSignal): ProbeRunner {
164
+ const attempt = async (command: string, args: string[]): Promise<ProbeResult> => {
165
+ try {
166
+ const { stdout, stderr } = await execFileAsync(command, args, {
167
+ timeout: PROBE_TIMEOUT_MS,
168
+ maxBuffer: 256 * 1024,
169
+ windowsHide: true,
170
+ ...(signal ? { signal } : {}),
171
+ })
172
+ return { outcome: 'ran', exitCode: 0, output: `${stdout}\n${stderr}` }
173
+ } catch (err) {
174
+ return spawnFailure(err)
175
+ }
176
+ }
177
+ return async (command, args) => {
178
+ const first = await attempt(command, args)
179
+ if (first.outcome !== 'missing' || process.platform !== 'win32') return first
180
+ return readOracle(await attempt('where', [command]))
181
+ }
182
+ }
183
+
184
+ /**
185
+ * What the `where` oracle's own result says about the TOOL. Pure, and separate from the spawn, so
186
+ * every branch is asserted on any platform: reproducing the failing one for real needs a process
187
+ * whose PATH cannot reach `where`, and mutating the live `PATH` to get one leaks into whatever test
188
+ * runs next (on Windows `PATH` and `Path` are one variable, so restoring them is not symmetric).
189
+ *
190
+ * The distinction the branches exist for: `where` exiting non-zero IS the tool's absence, but
191
+ * `where` not answering at all says nothing about the tool, so that becomes a FAILED probe. Passing
192
+ * the oracle's result straight back reported the ORACLE's own `missing` as the tool's, which put a
193
+ * fully installed npm on the "Not installed" line on any host where `where` cannot be spawned.
194
+ *
195
+ * A located tool is `found`, never a synthesised `ran` with `exitCode: 0`: the oracle located a
196
+ * binary and executed nothing, so it cannot answer for a probe whose answer IS its exit code.
197
+ */
198
+ export function readOracle(located: ProbeResult): ProbeResult {
199
+ if (located.outcome === 'ran') {
200
+ return located.exitCode === 0 ? { outcome: 'found' } : { outcome: 'missing' }
201
+ }
202
+ if (located.outcome === 'found') return located
203
+ return {
204
+ outcome: 'failed',
205
+ reason:
206
+ located.outcome === 'failed'
207
+ ? located.reason
208
+ : 'the tool could not be spawned, and `where` could not be run to locate it',
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Read a rejected `execFile` back into a {@link ProbeResult}.
214
+ *
215
+ * `ENOENT` is the ONLY absence: the binary is not on PATH. A numeric `code` means the binary RAN
216
+ * and exited non-zero, which is a different fact and belongs to whoever asked. Everything else
217
+ * (a timeout, where the child is killed and there is no exit code; `EACCES`; an aborted job) is a
218
+ * failure OF THE PROBE, which is what keeps it out of the absent list.
219
+ *
220
+ * Every reason is WRITTEN HERE, in words, and the raw `code` is never one of them. The reason is
221
+ * rendered verbatim into the agent's system prompt, so passing `String(e.code)` through published
222
+ * `ABORT_ERR` (a cancelled job, on all thirteen entries at once), `EACCES` and
223
+ * `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` to a model as prose. A code nobody has mapped yet says only
224
+ * that the probe did not run, which is the whole of what the block needs from it.
225
+ */
226
+ function spawnFailure(err: unknown): ProbeResult {
227
+ const e = err as NodeJS.ErrnoException & { killed?: boolean; stdout?: string; stderr?: string }
228
+ if (e.code === 'ENOENT') return { outcome: 'missing' }
229
+ if (typeof e.code === 'number') {
230
+ return { outcome: 'ran', exitCode: e.code, output: `${e.stdout ?? ''}\n${e.stderr ?? ''}` }
231
+ }
232
+ // Abort before the timeout check: `execFile`'s `AbortError` carries no `killed`, so an aborted
233
+ // job would otherwise fall through to the catch-all and read as a machine that answered nothing.
234
+ if (e.code === 'ABORT_ERR' || (e as Error).name === 'AbortError') {
235
+ return { outcome: 'failed', reason: 'the job was cancelled before the probe finished' }
236
+ }
237
+ if (e.killed) return { outcome: 'failed', reason: 'the probe timed out' }
238
+ if (e.code === 'EACCES' || e.code === 'EPERM') {
239
+ return { outcome: 'failed', reason: 'the probe was not permitted to run here' }
240
+ }
241
+ return { outcome: 'failed', reason: 'the probe could not be run' }
242
+ }
243
+
244
+ /**
245
+ * The first version-shaped token in a `--version` banner, capped so a chatty tool can't run away.
246
+ *
247
+ * Deliberately no leading `\b`: the token is routinely glued to a letter (`v26.7.0`, `helm
248
+ * v3.16.2`), and a word boundary between `v` and `2` does not exist, so the anchored form skipped
249
+ * the major and reported `26.7.0` as `7.0`.
250
+ */
251
+ function firstVersion(output: string): string | undefined {
252
+ const match = /\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/.exec(output)
253
+ return match ? match[0].slice(0, 24) : undefined
254
+ }
255
+
256
+ /**
257
+ * An ordinary tool's presence: it ran (whatever it exited with) ⇒ installed, `ENOENT` ⇒ not
258
+ * installed, anything else ⇒ the probe failed and we do not know. `helm version` on a host with
259
+ * no cluster config exits non-zero and still proves helm is installed.
260
+ */
261
+ export function toolPresence(result: ProbeResult): ToolPresence {
262
+ if (result.outcome === 'missing') return { status: 'absent' }
263
+ if (result.outcome === 'failed') return { status: 'unknown', reason: result.reason }
264
+ if (result.outcome === 'found') return { status: 'present' }
265
+ const version = firstVersion(result.output)
266
+ return version ? { status: 'present', version } : { status: 'present' }
267
+ }
268
+
269
+ /**
270
+ * The daemon's presence, which reads the SAME result differently in exactly TWO places, and
271
+ * delegates the rest: duplicating the other three branches to add these two is how a change to
272
+ * how a version is read, or how a failure reason is carried, gets made in one classifier only.
273
+ *
274
+ * - A non-zero exit is "cannot connect to the Docker daemon", so it is an absence rather than the
275
+ * proof-of-install a non-zero exit is everywhere else.
276
+ * - `found` means the Windows oracle located the CLI and ran NOTHING, so the daemon was never
277
+ * asked. That is an unknown. Reading it as `toolPresence` would is what made `where docker`
278
+ * enough to tell an agent `docker build` works here.
279
+ */
280
+ export function daemonPresence(result: ProbeResult): ToolPresence {
281
+ if (result.outcome === 'found') {
282
+ return {
283
+ status: 'unknown',
284
+ reason: 'only the docker CLI was located; the daemon was not asked',
285
+ }
286
+ }
287
+ if (result.outcome === 'ran' && result.exitCode !== 0) return { status: 'absent' }
288
+ return toolPresence(result)
289
+ }
290
+
291
+ /** What one probe pass may be told, so the suite drives the daemon's retry without waiting on it. */
292
+ export interface ProbeEnvironmentOptions {
293
+ /** Injected so tests exercise the retry without paying {@link DAEMON_RETRY_DELAY_MS}. */
294
+ sleep?: (ms: number) => Promise<void>
295
+ /**
296
+ * Whether a daemon is CONFIGURED to serve this machine, defaulting to {@link daemonIsConfigured}.
297
+ * Stated as the fact rather than as the `DOCKER_HOST` string it is read from, so a test can drive
298
+ * both sides of the branch without an ambient environment variable deciding the outcome for it.
299
+ */
300
+ daemonExpected?: boolean
301
+ }
302
+
303
+ /**
304
+ * Whether anything is supposed to serve a Docker daemon here, which `entrypoint.sh` makes knowable:
305
+ * it EXPORTS `DOCKER_HOST` both when a pool hands us an external daemon and when it starts the
306
+ * rootless one, and leaves it unset in the one branch where no daemon is coming. Reading the
307
+ * machine's own configuration, not per-job state, so `process.env` is the right source here.
308
+ */
309
+ function daemonIsConfigured(): boolean {
310
+ return (process.env.DOCKER_HOST ?? '').trim() !== ''
311
+ }
312
+
313
+ /**
314
+ * Probe the machine. EVERYTHING runs concurrently, the daemon included.
315
+ *
316
+ * The daemon probe used to be sequenced after the tool pass, so that a missing CLI could
317
+ * short-circuit it. That bought a cleaner `absent` and charged the whole job for it: two ceilings
318
+ * back to back on the critical path, ahead of the clone, on every dispatch. It is unnecessary,
319
+ * because `docker info` answers the CLI question too: with no CLI on PATH the spawn comes back
320
+ * `missing`, which {@link daemonPresence} already reads as the same absence the short-circuit
321
+ * produced. Asking directly also removes a second defect the sequencing needed: reading the CLI's
322
+ * presence back out of `tools` defaulted a MISSING list entry to `absent`, so trimming the curated
323
+ * list would have had the block assert "NO Docker daemon is reachable" with nothing ever asked.
324
+ */
325
+ export async function probeEnvironment(
326
+ run: ProbeRunner,
327
+ opts: ProbeEnvironmentOptions = {},
328
+ ): Promise<EnvironmentInventory> {
329
+ const [tools, dockerDaemon] = await Promise.all([
330
+ Promise.all(
331
+ PROBES.map(async (probe) => ({
332
+ name: probe.name,
333
+ showVersion: probe.showVersion,
334
+ presence: toolPresence(await run(probe.command, probe.args)),
335
+ })),
336
+ ),
337
+ probeDockerDaemon(run, opts),
338
+ ])
339
+ return { tools, dockerDaemon }
340
+ }
341
+
342
+ /**
343
+ * Ask the daemon itself, and do not mistake a daemon that is STARTING for one that is not there.
344
+ *
345
+ * `entrypoint.sh` launches `dockerd-rootless.sh` detached and `exec`s the server without waiting,
346
+ * so `/health` answers (and the backend POSTs `/run`) seconds before the rootless daemon has
347
+ * finished its userns and fuse-overlayfs setup. Until then there is no socket, so `docker info`
348
+ * is refused AT ONCE. Read as a plain absence that produced the block's most consequential line,
349
+ * "NO Docker daemon is reachable ... will fail here whatever the CLI reports", on a machine whose
350
+ * daemon was up moments later, which authoritatively told a tester step not to try.
351
+ *
352
+ * Whether a daemon is CONFIGURED here is what separates the two (see {@link daemonIsConfigured}):
353
+ *
354
+ * - refused with no `DOCKER_HOST` ⇒ ABSENT. Nothing was coming. This is also the developer's
355
+ * laptop with Docker Desktop shut down, where absent is exactly right.
356
+ * - refused with `DOCKER_HOST` set ⇒ one short retry, then UNKNOWN. A daemon was expected and
357
+ * has not answered yet, which is neither of the other two answers, and the rendered line turns
358
+ * it into "try it if you need it" rather than into a prohibition.
359
+ */
360
+ async function probeDockerDaemon(
361
+ run: ProbeRunner,
362
+ opts: ProbeEnvironmentOptions,
363
+ ): Promise<ToolPresence> {
364
+ const expected = opts.daemonExpected ?? daemonIsConfigured()
365
+ const ask = async (): Promise<ToolPresence> =>
366
+ daemonPresence(await run(DOCKER_INFO.command, DOCKER_INFO.args))
367
+ const first = await ask()
368
+ if (first.status !== 'absent' || !expected) return first
369
+ await (opts.sleep ?? defaultSleep)(DAEMON_RETRY_DELAY_MS)
370
+ const second = await ask()
371
+ if (second.status !== 'absent') return second
372
+ return {
373
+ status: 'unknown',
374
+ reason:
375
+ 'a daemon is configured for this machine but had not answered when the job started; it may ' +
376
+ 'still be coming up',
377
+ }
378
+ }
379
+
380
+ function defaultSleep(ms: number): Promise<void> {
381
+ return new Promise((resolve) => {
382
+ setTimeout(resolve, ms)
383
+ })
384
+ }
385
+
386
+ /** Render one tool for the `Installed:` line: `node 26.7.0`, or just `jq`. */
387
+ function installedLabel(tool: ProbedTool): string {
388
+ const version = tool.presence.status === 'present' ? tool.presence.version : undefined
389
+ return tool.showVersion && version ? `${tool.name} ${version}` : tool.name
390
+ }
391
+
392
+ /**
393
+ * The block appended to the agent's system prompt. Pure, so what the agent reads is asserted
394
+ * against a probe result rather than against whichever machine the suite happens to run on.
395
+ *
396
+ * Each line is a different KIND of claim, and they are kept apart because collapsing them is the
397
+ * failure this replaces: what is here, what is not, what could not be determined, what the Docker
398
+ * daemon actually said, and, last because it governs everything the other lines omit, that the
399
+ * list is bounded.
400
+ */
401
+ export function renderEnvironmentInventory(inventory: EnvironmentInventory): string {
402
+ const installed = inventory.tools.filter((t) => t.presence.status === 'present')
403
+ const absent = inventory.tools.filter((t) => t.presence.status === 'absent')
404
+ // Flattened while the presence is still narrowed: the REASON is what makes an entry an unknown
405
+ // rather than an absence, so it has to reach the rendered line, and picking it back out of a
406
+ // `ProbedTool` later needs a re-narrowing branch nothing can ever take.
407
+ const unknown = inventory.tools.flatMap((t) =>
408
+ t.presence.status === 'unknown' ? [`${t.name} (${t.presence.reason})`] : [],
409
+ )
410
+ const lines = [
411
+ 'ENVIRONMENT INVENTORY: the platform probed this machine when the job started. These are ' +
412
+ 'facts about where you are running; do not spend turns re-checking them.',
413
+ ]
414
+ if (installed.length > 0) {
415
+ lines.push(`Installed: ${installed.map(installedLabel).join(', ')}.`)
416
+ }
417
+ if (absent.length > 0) {
418
+ lines.push(
419
+ `Not installed: ${absent.map((t) => t.name).join(', ')}. You are unprivileged here, so a ` +
420
+ 'SYSTEM install of one of these will fail, and a tool the platform did not provide is not ' +
421
+ "a defect in the work. Where one of them is the project's own package manager, reaching " +
422
+ 'it for that project alone (`npx <manager>`, a repo-local install) is fine and is the one ' +
423
+ 'thing worth trying.',
424
+ )
425
+ }
426
+ if (unknown.length > 0) {
427
+ lines.push(
428
+ 'Could not be determined, because the PROBE itself failed. Treat each as neither present ' +
429
+ `nor absent: ${unknown.join(', ')}.`,
430
+ )
431
+ }
432
+ lines.push(dockerDaemonLine(inventory.dockerDaemon))
433
+ // Stated as a FACT and not as an errand. This block is appended to the system prompt after the
434
+ // effort-report directive, whose closing sentences are the prompt's ordering rule (write the
435
+ // sentinel, then reply, and no tool call after the reply). "Check for that one yourself before
436
+ // relying on it" sat after that rule and invited exactly the trailing tool call it forbids, which
437
+ // is the displacement that once cost an architect run its design. The agent needs to know the
438
+ // list is bounded; when to go looking is the sandbox directive's business, and it is not last.
439
+ lines.push(
440
+ 'Nothing else was probed. A tool named on none of these lines is unknown to the platform ' +
441
+ 'rather than missing.',
442
+ )
443
+ return lines.join('\n')
444
+ }
445
+
446
+ /** The Docker line, which says something different in each of the three cases. */
447
+ function dockerDaemonLine(daemon: ToolPresence): string {
448
+ if (daemon.status === 'present') {
449
+ const server = daemon.version ? ` (server ${daemon.version})` : ''
450
+ return (
451
+ `A Docker daemon is reachable${server}: \`docker build\`, \`docker run\` and ` +
452
+ '`docker compose up` work here.'
453
+ )
454
+ }
455
+ if (daemon.status === 'unknown') {
456
+ return (
457
+ 'Whether a Docker daemon is reachable could not be determined ' +
458
+ `(${daemon.reason}): try it if you need it, and do not read a failure as a defect in the work.`
459
+ )
460
+ }
461
+ return (
462
+ 'NO Docker daemon is reachable: `docker build`, `docker run` and `docker compose up` will ' +
463
+ 'fail here whatever the CLI reports. Produce the Dockerfile or compose file you were asked ' +
464
+ 'for, say in one line that you could not build it here, and move on.'
465
+ )
466
+ }
467
+
468
+ /**
469
+ * Probe the machine and fold the inventory onto `systemPrompt`. THE composition point: the harness
470
+ * calls this once per job, in `handleAgent`, before any mode branches, so every mode and every CLI
471
+ * (claude-code, codex, Pi) inherits it from the job's own system prompt instead of each folding a
472
+ * copy, which is how one of them would silently end up without it, and another with it twice.
473
+ *
474
+ * It rides the SYSTEM prompt rather than the task prompt so it survives the claude runner's
475
+ * argv-size branch (`carryClaudeSystemPrompt` folds an oversized system prompt into stdin whole),
476
+ * Codex's unconditional fold and Pi's `AGENTS.md` write, none of which can drop part of it.
477
+ *
478
+ * Never throws: an inventory is context, and a job whose probe pass fell over is still a job worth
479
+ * running. A probe that fails is already reported as unknown by construction, so this catch is for
480
+ * the pass itself, and it says so in a log line rather than silently shortening the prompt.
481
+ */
482
+ export async function appendEnvironmentInventory(
483
+ systemPrompt: string,
484
+ opts: { signal?: AbortSignal; log?: Logger; run?: ProbeRunner } & ProbeEnvironmentOptions = {},
485
+ ): Promise<string> {
486
+ const logger = opts.log ?? log
487
+ try {
488
+ const inventory = await probeEnvironment(opts.run ?? spawnProbeRunner(opts.signal), {
489
+ ...(opts.sleep ? { sleep: opts.sleep } : {}),
490
+ ...(opts.daemonExpected === undefined ? {} : { daemonExpected: opts.daemonExpected }),
491
+ })
492
+ logger.info('agent: probed the environment', {
493
+ installed: inventory.tools
494
+ .filter((t) => t.presence.status === 'present')
495
+ .map((t) => t.name)
496
+ .join(','),
497
+ dockerDaemon: inventory.dockerDaemon.status,
498
+ // The unknowns, by NAME: the block tells the agent a probe failed, and this is the only place
499
+ // an operator can see WHICH, since the reason the agent reads is deliberately wordy prose.
500
+ unknown: inventory.tools
501
+ .filter((t) => t.presence.status === 'unknown')
502
+ .map((t) => t.name)
503
+ .join(','),
504
+ })
505
+ return `${systemPrompt}\n\n${renderEnvironmentInventory(inventory)}`
506
+ } catch (err) {
507
+ logger.warn('agent: the environment probe pass failed; dispatching without an inventory', {
508
+ error: err instanceof Error ? err.message : String(err),
509
+ })
510
+ return systemPrompt
511
+ }
512
+ }
@@ -23,6 +23,7 @@ import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js'
23
23
  import type { RunOptions } from './runner.js'
24
24
  import { log, type Logger } from './logger.js'
25
25
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
26
+ import { agentCapabilities } from './agent-shared.js'
26
27
  import {
27
28
  resolvePrTemplateNote,
28
29
  withPrTemplateNote,
@@ -201,16 +202,13 @@ export async function runMultiRepoCoding(
201
202
  proxyBaseUrl: job.proxyBaseUrl,
202
203
  proxyPhasePath: job.proxyPhasePath,
203
204
  sessionToken: job.sessionToken,
204
- webToolsGuidance: job.webToolsGuidance,
205
- webSearchProxy: job.webSearch,
206
205
  guardLimits: job.guardLimits,
207
206
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
208
- // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
209
- // are properties of the AGENT KIND, not of the checkout layout.
210
- ...(job.skills?.length ? { skills: job.skills } : {}),
211
- ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
212
- ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
213
- ...(job.designImages ? { designImages: job.designImages } : {}),
207
+ // Skills, tool servers and web research apply to a multi-repo run exactly as to a
208
+ // single-repo one: they are properties of the AGENT KIND, not of the checkout layout.
209
+ // Through the shared helper rather than re-spread here, which is what let this flow
210
+ // drift from the single-repo one in the first place.
211
+ ...agentCapabilities(job),
214
212
  multiRepo: true,
215
213
  // What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
216
214
  repoDirs: probeDirsForLegs(legs),