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