@cat-factory/executor-harness 1.132.3 → 1.135.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.
Files changed (67) hide show
  1. package/README.md +49 -0
  2. package/dist/agent-capabilities.d.ts +21 -24
  3. package/dist/agent-capabilities.js +22 -50
  4. package/dist/agent-env.d.ts +17 -0
  5. package/dist/agent-env.js +47 -0
  6. package/dist/agent-runner.d.ts +18 -2
  7. package/dist/agent-runner.js +29 -231
  8. package/dist/agent-shared.d.ts +14 -5
  9. package/dist/agent-shared.js +14 -5
  10. package/dist/agent.d.ts +0 -11
  11. package/dist/agent.js +7 -138
  12. package/dist/captured-command.d.ts +1 -1
  13. package/dist/captured-command.js +3 -2
  14. package/dist/claude-cli.d.ts +90 -0
  15. package/dist/claude-cli.js +181 -0
  16. package/dist/claude-home.d.ts +41 -0
  17. package/dist/claude-home.js +159 -0
  18. package/dist/coding-agent.d.ts +35 -0
  19. package/dist/coding-agent.js +213 -41
  20. package/dist/docker-status.d.ts +89 -0
  21. package/dist/docker-status.js +147 -0
  22. package/dist/frontend-infra.js +4 -3
  23. package/dist/git.d.ts +48 -5
  24. package/dist/git.js +93 -26
  25. package/dist/guard-driver.d.ts +71 -0
  26. package/dist/guard-driver.js +171 -0
  27. package/dist/harness-server.js +13 -0
  28. package/dist/infra-standup.d.ts +69 -0
  29. package/dist/infra-standup.js +182 -0
  30. package/dist/job.d.ts +10 -0
  31. package/dist/multi-repo-coding.d.ts +17 -0
  32. package/dist/multi-repo-coding.js +61 -16
  33. package/dist/pi-workspace.d.ts +11 -0
  34. package/dist/pi-workspace.js +126 -57
  35. package/dist/pi.d.ts +8 -0
  36. package/dist/pi.js +16 -9
  37. package/dist/progress-guard.d.ts +56 -10
  38. package/dist/progress-guard.js +84 -22
  39. package/dist/runner.d.ts +1 -1
  40. package/dist/salvage.d.ts +180 -0
  41. package/dist/salvage.js +289 -0
  42. package/dist/workspace-probe.d.ts +85 -0
  43. package/dist/workspace-probe.js +124 -0
  44. package/package.json +4 -4
  45. package/src/agent-capabilities.ts +25 -51
  46. package/src/agent-env.ts +49 -0
  47. package/src/agent-runner.ts +40 -267
  48. package/src/agent-shared.ts +16 -5
  49. package/src/agent.ts +7 -164
  50. package/src/captured-command.ts +3 -2
  51. package/src/claude-cli.ts +217 -0
  52. package/src/claude-home.ts +233 -0
  53. package/src/coding-agent.ts +252 -44
  54. package/src/docker-status.ts +201 -0
  55. package/src/frontend-infra.ts +4 -3
  56. package/src/git.ts +104 -26
  57. package/src/guard-driver.ts +203 -0
  58. package/src/harness-server.ts +13 -0
  59. package/src/infra-standup.ts +218 -0
  60. package/src/job.ts +10 -0
  61. package/src/multi-repo-coding.ts +65 -16
  62. package/src/pi-workspace.ts +161 -57
  63. package/src/pi.ts +27 -12
  64. package/src/progress-guard.ts +110 -34
  65. package/src/runner.ts +1 -1
  66. package/src/salvage.ts +407 -0
  67. package/src/workspace-probe.ts +155 -0
package/README.md CHANGED
@@ -14,6 +14,7 @@ accepts the job and returns immediately with a `jobId`; the driver then polls
14
14
  - [Job protocol](#job-protocol)
15
15
  - [What a job does](#what-a-job-does)
16
16
  - [No secrets in the image](#no-secrets-in-the-image)
17
+ - [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon)
17
18
  - [Layout](#layout)
18
19
  - [Runner lifecycle knobs](#runner-lifecycle-knobs)
19
20
  - [Build / test](#build--test)
@@ -316,6 +317,44 @@ signed, model-locked LLM-proxy **session token** in the request body. Pi reaches
316
317
  models only through the Worker proxy, which injects the real provider key (qwen /
317
318
  Kimi / DeepSeek) and meters spend. The provider key never enters the container.
318
319
 
320
+ ## Local infra: the container's Docker daemon
321
+
322
+ The Tester's local-mode infra stand-up runs `docker compose up --wait` INSIDE this container, so
323
+ the container needs a daemon of its own. It runs rootless, as the unprivileged `harness` user:
324
+ Cloudflare Containers (and most managed runners) give no root and no privileged mode, and a host
325
+ Docker socket would hand the container root on the host.
326
+
327
+ `entrypoint.sh` starts it, waits for it in the background, and RECORDS the verdict
328
+ (`src/docker-status.ts`). Two things consume that record and nothing else does:
329
+
330
+ - `GET /health` reports it, so an operator (and a boot-time probe) can see what the container
331
+ concluded about itself.
332
+ - The compose stand-up REFUSES on a decided absence and says why, instead of running compose
333
+ against nothing and handing the agent a connection error to interpret. The refusal rides back on
334
+ the Tester step as `infraSetup.dockerAvailable: false` with the cause.
335
+
336
+ The verdict is three-valued, and that is the point. `false` is a decided absence. `undefined` is
337
+ "nothing decided" — the probe is still in flight, or nothing recorded anything at all, which is the
338
+ normal state under the native host transport (`LOCAL_NATIVE_AGENTS`) where the harness runs on a
339
+ developer's machine with no entrypoint. Undecided attempts the stand-up; only a decided absence
340
+ refuses it.
341
+
342
+ What is recorded describes BOOT, and a container outlives its boot: a warm pool serves many jobs
343
+ from one, and a sidecar daemon that took longer to come up than the entrypoint's bounded wait
344
+ allows is serving perfectly well by the second job. So a recorded absence is a hypothesis, not the
345
+ refusal: `resolveDockerVerdict` re-checks it against a live daemon at the moment a stand-up is
346
+ about to run, and the record supplies what only the record holds, the cause and the daemon's own
347
+ log tail. `GET /health` deliberately keeps reporting the boot record rather than probing per poll,
348
+ since it is not the surface that acts on the answer.
349
+
350
+ Why it is written down at all: the image shipped for months with `docker-ce-rootless-extras` (the
351
+ wrappers that START a daemon) and no `docker-ce` (the daemon itself), and no `iproute2` for the
352
+ network rootlesskit builds. The entrypoint backgrounded the start in a subshell where its exit
353
+ status could not be observed, so every local-infra Tester run degraded silently to a no-infra run
354
+ whose only trace was a compose error in a prompt note. A capability that reports itself present and
355
+ then degrades in silence is worse than one that is absent, so the daemon is installed AND the
356
+ verdict is stated.
357
+
319
358
  ## Layout
320
359
 
321
360
  | File | Responsibility |
@@ -329,11 +368,17 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
329
368
  | `src/pi-reduction.ts` | Reducing a Pi event stream to what the run PRODUCED (summary, stats, diagnostics, terminal failure), FOLDED as records stream rather than over a retained array — memory is O(largest record), not O(records). The array-taking entry points offline tooling uses are defined in terms of the same reducer. |
330
369
  | `src/tool-silence.ts` | The tool-silence watchdog (F13) and the `ToolProgressWindow` an agent stream opens, beats and closes. Separate from the phase marker on purpose: a window is only meaningful while something able to reset it is running. |
331
370
  | `src/git.ts` | clone / branch / commit / push (lease-guarded: [The work-branch push is CHECKPOINTED, so it is lease-guarded](#the-work-branch-push-is-checkpointed-so-it-is-lease-guarded)) + GitHub PR creation; bootstrap history reset + force-push. |
371
+ | `src/progress-guard.ts` | The live anti-rabbithole bounds every agent run is held to, plus the tool-name vocabulary they classify calls with. PURE and SYNCHRONOUS: it spawns nothing and reads nothing off disk, so it can be driven over a fixed event sequence in a unit test. Shared by both runners, because two copies of a bound are two bounds. |
372
+ | `src/workspace-probe.ts` | The working-tree answer to "has this run actually changed the repository": a dirty tree, or HEAD moved off the sha the pass began at. What the no-edit bound decides on, since the tool names it can see are a fact about which tool the model picked and not about the repo (an agent writing everything through `bash` heredocs read as making no edits at all). Gitignored paths are excluded by git, which is what keeps a dependency install from reading as progress. Order carries the "is this a repository at all" question: the status runs first and is never caught, while a missing HEAD is the from-scratch case rather than a failure. A run whose cwd is a workspace of sibling checkouts composes one probe over them, where a checkout that could not be probed makes the answer inconclusive rather than clean. |
373
+ | `src/guard-driver.ts` | The bridge between the synchronous guard and the async evidence one of its bounds needs. Both runners feed the guard from a sync stream handler, so the driver owns the probe's lifetime: at most one probe per run, a positive answer satisfying the bound permanently, a negative one aborting with the evidence quoted, and a THROWN one inconclusive (re-arm and warn, never kill). Also hosts the claude-code stream's tool_use/tool_result pairing. |
374
+ | `src/salvage.ts` | Committing the new, untracked files an agent left behind, under a dependency/build deny-list and file-count + byte bounds that refuse ALL-or-nothing rather than truncating. Coding modes only. A credential-bearing name (`.env`, a private key, `.npmrc`) is a THIRD disposition, not a fourth junk entry: it is withheld like the rest but NAMED on the outcome, because for a secret the deny-list's usual trade inverts (a missed file is recoverable, a leaked key is not) and someone has to decide whether to rotate it. Every message here states the salvage's own provenance: a commit arriving with no explanation is indistinguishable from work someone chose to keep, and nobody chose this. |
332
375
  | `src/bootstrap.ts` | The `/bootstrap` handler (clone-or-empty → adapt → reinit + force-push). |
333
376
  | `src/blueprint.ts` | The `/blueprint` handler (decompose → render `blueprints/` → commit on branch). |
334
377
  | `src/embed.ts` | Bundled assets/templates written into the workspace. |
335
378
  | `src/package-registries.ts` | Private-registry (npm) auth: renders the job's allowlisted entries into an npmrc; the user `~/.npmrc` in a container, a per-job file pointed at by `npm_config_userconfig` for a native job. |
336
379
  | `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`): talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
380
+ | `src/claude-cli.ts` | The claude-code CLI's INVOCATION surface: the built-in tools a run DECLARES with `--tools` (the CLI's headless default has no `Grep`/`Glob` and no plan tools, and does carry a dozen an ephemeral container can act on none of), the argv that declares them, and the read-back of the CLI's `init` event that warns when a required capability was granted no tool. The list is over-inclusive on purpose, which `--tools` makes safe: an unknown name is dropped silently and a RETIRED one is an alias onto its successor, so one pinned image can face several CLI versions. The web tools are unconditional, being served by the vendor the leased subscription pays rather than by this deployment's search proxy. The same list also rides the `--allowedTools` re-grant, which is ADDITIVE rather than inert, so the two are one value threaded rather than two lists. |
381
+ | `src/claude-home.ts` | The PER-RUN claude-code config home (`codex-home.ts`'s sibling): the isolated `CLAUDE_CONFIG_DIR` outside the checkout, its onboarding pre-seed, the run's natively-installed skills, its `--mcp-config`, the child env carrying the leased credential, and the teardown that lifts the session transcripts out before deleting it. |
337
382
  | `src/claude-call-aggregator.ts` | Folds Claude Code's per-CONTENT-BLOCK `stream-json` envelopes back into the model calls they belong to (by `message.id`), reconstructs each call's request transcript, and routes subagent turns off the parent's chain. **Exported as the `./claude-call-aggregator` subpath and driven by the BACKEND too** (`runtimes/local`, for an inline step running on the developer's host `claude`), so it stays the ONE implementation: the per-envelope over-count it fixes inflated a measured 1.47M tokens to 5.53M, and both drivers have to learn that only once. That second driver is why the transcript is retained only to `MAX_TRANSCRIPT_CHARS` (stating what it stopped retaining) and why assembling bodies at all is a `bodies` switch: in a container the reconstruction is one job's memory in a box sized for it, in the backend it is per concurrent inline step in the orchestrator process. Unlike the compile-only `./embed`, this subpath is a `dist` import, which is why the package emits declarations, and why a consumer's typecheck depends on Turbo's `^build` edge having built this package first (see `tsconfig.json`'s `comment:buildOrder`). |
338
383
  | `src/usage-attribution.ts` | Reconciles a subscription CLI's TWO token channels: the per-turn usage its stream narrates and the cumulative total its terminal event reports. They disagree routinely and in one direction (Claude Code's per-turn `output_tokens` is the message-START snapshot, single digits), so whatever the turns did not account for becomes ONE extra metric standing for the job (`standsForJob`, filed with a null turn index) rather than tokens grafted onto a real turn, which would make a derived number read as a measured one. Reconciled against the PARENT loop's calls alone, since the terminal cumulative covers only that conversation. |
339
384
  | `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
@@ -350,6 +395,8 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
350
395
  | `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. |
351
396
  | `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
352
397
  | `src/logger.ts` | Structured logging. |
398
+ | `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). |
399
+ | `src/agent-env.ts` | The env for anything the harness spawns into the agent's CHECKOUT: its own environment minus the variables that are facts about the HARNESS. Today that is `NODE_ENV` — the harness runs in production mode, and an inherited `NODE_ENV=production` makes npm omit devDependencies in a checkout that never asked for it. |
353
400
 
354
401
  ## Runner lifecycle knobs
355
402
 
@@ -373,6 +420,8 @@ runner):
373
420
  | `REPRODUCTION_TOTAL_BUDGET_MS` | `2700000` (45m) | Wall-clock ceiling on the WHOLE proof phase (every attempt, both trees, setup included). Attempts multiply two full tree runs each and the heartbeat above deliberately stops the inactivity watchdog from firing, so this is what bounds the phase. Checked at phase boundaries; exceeding it settles `inconclusive`, never a run failure. |
374
421
  | `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
375
422
  | `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content, though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
423
+ | `HARNESS_DOCKER_READY_TIMEOUT_SECONDS` | `60` | How long `entrypoint.sh` waits for the container's Docker daemon before recording it unavailable. Only a HUNG daemon pays this in full: the wait ends early both when the socket answers and when the daemon process is gone. It runs in the BACKGROUND, so it never delays the container's boot. |
424
+ | `HARNESS_DOCKER_STATUS_FILE` | `/tmp/harness-docker-status.json` | Where that verdict is recorded. `entrypoint.sh` writes it and the harness reads it, so an override must be set for BOTH (they share one process env). |
376
425
 
377
426
  ## Build / test
378
427
 
@@ -173,39 +173,36 @@ export declare function parseMcpServerSpecs(value: unknown): McpServerSpec[] | u
173
173
  export declare function claudeMcpConfig(servers: McpServerSpec[]): {
174
174
  mcpServers: Record<string, Record<string, unknown>>;
175
175
  };
176
- /**
177
- * The claude-code CLI's own tools, named so an `--allowedTools` list can never take them away.
178
- *
179
- * An allow-list is whole-session: it does not scope itself to MCP just because every entry we
180
- * generate happens to be an `mcp__*` pattern. So the moment one tool server narrows its tools, the
181
- * list has to re-grant the agent's built-in file/bash/search tools or the run is handed a narrowed
182
- * MCP surface AND no way to read, edit or build anything.
183
- *
184
- * Bias this list toward OVER-inclusion. A name the CLI does not have is inert; a name it has and
185
- * this list lacks is a tool silently removed from a run — which surfaces as an agent that cannot
186
- * do its work, far from the registration that caused it. Historical/renamed spellings are kept for
187
- * the same reason: the harness image is pinned per workspace, so one image faces several CLI
188
- * versions. When the CLI gains a tool, add it here.
189
- */
190
- export declare const CLAUDE_BUILT_IN_TOOLS: readonly string[];
191
176
  /**
192
177
  * The tool-name list for `--allowedTools`: every declared server's tools in the CLI's
193
- * `mcp__<server>__<tool>` convention, PLUS {@link CLAUDE_BUILT_IN_TOOLS}. A server with no
194
- * restriction contributes the whole-server pattern, so an allow-list stays one entry per server.
178
+ * `mcp__<server>__<tool>` convention, PLUS the built-in tools this run declared with `--tools`
179
+ * (`CLAUDE_TOOL_SET`). A server with no restriction contributes the whole-server pattern, so
180
+ * an allow-list stays one entry per server.
195
181
  *
196
182
  * Returns undefined when NO server restricts its tools — there is then nothing to narrow, and the
197
183
  * safest list is the one we never send.
198
184
  *
185
+ * An allow-list is whole-session, not MCP-scoped: it does not confine itself to MCP just because
186
+ * every entry we generate happens to be an `mcp__*` pattern. So the moment one tool server narrows
187
+ * its tools, the list has to carry the built-in file/bash/search tools too or the run is handed a
188
+ * narrowed MCP surface AND no way to read, edit or build anything.
189
+ *
190
+ * And carrying them is not merely a re-grant. MEASURED against CLI 2.1.245, the list is ADDITIVE:
191
+ * `--allowedTools "Bash,Grep"` yields the CLI's default set PLUS `Glob` and `Grep`, and an EMPTY
192
+ * list yields the default set plus `Glob`, `Grep` and the four `Task*` tools. A name here UNLOCKS
193
+ * a tool. That is why `builtIns` is the run's OWN declared set passed by reference rather than a
194
+ * constant re-read here: a separately-derived list would silently re-grant exactly what the
195
+ * `--tools` declaration withheld, and only on the runs that happen to wire a narrowing tool
196
+ * server.
197
+ *
199
198
  * Whether the CLI ENFORCES this list is permission-mode dependent and not a contract we control:
200
199
  * the run uses `--permission-mode bypassPermissions` (the container is the sandbox and no human is
201
- * there to approve a call), under which an allow-list grants rather than gates. So this is written
202
- * to be correct under BOTH readings if the list gates, the narrowing is real and the built-ins
203
- * survive it; if it is inert, sending it costs nothing. The always-present channel is the PROMPT,
204
- * which states each server's permitted tool names on every harness. Treat `allowedTools` as
205
- * scoping, not as a security boundary: a server the agent must not reach fully should not be
206
- * wired for that kind at all.
200
+ * there to approve a call), under which an allow-list grants rather than gates. The always-present
201
+ * channel is the PROMPT, which states each server's permitted tool names on every harness. Treat
202
+ * `allowedTools` as scoping, not as a security boundary: a server the agent must not reach fully
203
+ * should not be wired for that kind at all.
207
204
  */
208
- export declare function claudeAllowedToolPatterns(servers: McpServerSpec[]): string[] | undefined;
205
+ export declare function claudeAllowedToolPatterns(servers: McpServerSpec[], builtIns: readonly string[]): string[] | undefined;
209
206
  /**
210
207
  * The `[mcp_servers.<id>]` TOML block Codex reads from its `CODEX_HOME/config.toml`. Codex's MCP
211
208
  * client is stdio-only, so an `http` server is skipped here.
@@ -418,68 +418,40 @@ export function claudeMcpConfig(servers) {
418
418
  }
419
419
  return { mcpServers };
420
420
  }
421
- /**
422
- * The claude-code CLI's own tools, named so an `--allowedTools` list can never take them away.
423
- *
424
- * An allow-list is whole-session: it does not scope itself to MCP just because every entry we
425
- * generate happens to be an `mcp__*` pattern. So the moment one tool server narrows its tools, the
426
- * list has to re-grant the agent's built-in file/bash/search tools or the run is handed a narrowed
427
- * MCP surface AND no way to read, edit or build anything.
428
- *
429
- * Bias this list toward OVER-inclusion. A name the CLI does not have is inert; a name it has and
430
- * this list lacks is a tool silently removed from a run — which surfaces as an agent that cannot
431
- * do its work, far from the registration that caused it. Historical/renamed spellings are kept for
432
- * the same reason: the harness image is pinned per workspace, so one image faces several CLI
433
- * versions. When the CLI gains a tool, add it here.
434
- */
435
- export const CLAUDE_BUILT_IN_TOOLS = [
436
- 'Agent',
437
- 'Bash',
438
- 'BashOutput',
439
- 'Edit',
440
- 'ExitPlanMode',
441
- 'Glob',
442
- 'Grep',
443
- 'KillBash',
444
- 'KillShell',
445
- 'ListMcpResources',
446
- 'MultiEdit',
447
- 'NotebookEdit',
448
- 'NotebookRead',
449
- 'Read',
450
- 'ReadMcpResource',
451
- 'SlashCommand',
452
- 'Skill',
453
- 'Task',
454
- 'TaskCreate',
455
- 'TaskUpdate',
456
- 'TodoWrite',
457
- 'WebFetch',
458
- 'WebSearch',
459
- 'Write',
460
- ];
461
421
  /**
462
422
  * The tool-name list for `--allowedTools`: every declared server's tools in the CLI's
463
- * `mcp__<server>__<tool>` convention, PLUS {@link CLAUDE_BUILT_IN_TOOLS}. A server with no
464
- * restriction contributes the whole-server pattern, so an allow-list stays one entry per server.
423
+ * `mcp__<server>__<tool>` convention, PLUS the built-in tools this run declared with `--tools`
424
+ * (`CLAUDE_TOOL_SET`). A server with no restriction contributes the whole-server pattern, so
425
+ * an allow-list stays one entry per server.
465
426
  *
466
427
  * Returns undefined when NO server restricts its tools — there is then nothing to narrow, and the
467
428
  * safest list is the one we never send.
468
429
  *
430
+ * An allow-list is whole-session, not MCP-scoped: it does not confine itself to MCP just because
431
+ * every entry we generate happens to be an `mcp__*` pattern. So the moment one tool server narrows
432
+ * its tools, the list has to carry the built-in file/bash/search tools too or the run is handed a
433
+ * narrowed MCP surface AND no way to read, edit or build anything.
434
+ *
435
+ * And carrying them is not merely a re-grant. MEASURED against CLI 2.1.245, the list is ADDITIVE:
436
+ * `--allowedTools "Bash,Grep"` yields the CLI's default set PLUS `Glob` and `Grep`, and an EMPTY
437
+ * list yields the default set plus `Glob`, `Grep` and the four `Task*` tools. A name here UNLOCKS
438
+ * a tool. That is why `builtIns` is the run's OWN declared set passed by reference rather than a
439
+ * constant re-read here: a separately-derived list would silently re-grant exactly what the
440
+ * `--tools` declaration withheld, and only on the runs that happen to wire a narrowing tool
441
+ * server.
442
+ *
469
443
  * Whether the CLI ENFORCES this list is permission-mode dependent and not a contract we control:
470
444
  * the run uses `--permission-mode bypassPermissions` (the container is the sandbox and no human is
471
- * there to approve a call), under which an allow-list grants rather than gates. So this is written
472
- * to be correct under BOTH readings if the list gates, the narrowing is real and the built-ins
473
- * survive it; if it is inert, sending it costs nothing. The always-present channel is the PROMPT,
474
- * which states each server's permitted tool names on every harness. Treat `allowedTools` as
475
- * scoping, not as a security boundary: a server the agent must not reach fully should not be
476
- * wired for that kind at all.
445
+ * there to approve a call), under which an allow-list grants rather than gates. The always-present
446
+ * channel is the PROMPT, which states each server's permitted tool names on every harness. Treat
447
+ * `allowedTools` as scoping, not as a security boundary: a server the agent must not reach fully
448
+ * should not be wired for that kind at all.
477
449
  */
478
- export function claudeAllowedToolPatterns(servers) {
450
+ export function claudeAllowedToolPatterns(servers, builtIns) {
479
451
  if (!servers.some((s) => s.allowedTools?.length))
480
452
  return undefined;
481
453
  const mcp = servers.flatMap((s) => s.allowedTools?.length ? s.allowedTools.map((t) => `mcp__${s.id}__${t}`) : [`mcp__${s.id}`]);
482
- return [...mcp, ...CLAUDE_BUILT_IN_TOOLS];
454
+ return [...mcp, ...builtIns];
483
455
  }
484
456
  /** Escape a string as a TOML basic string (Codex config is TOML, not JSON). */
485
457
  function tomlString(value) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Variables of the HARNESS PROCESS that must not reach the agent's checkout.
3
+ *
4
+ * Deliberately short, and it stays short: the bar is a variable whose value is a fact about the
5
+ * harness that a tool in the checkout will silently act on. It is not a sandbox (an agent can set
6
+ * whatever it likes in its own shell) and not a secret filter (the harness holds per-job secrets
7
+ * in `agentEnv`, never in `process.env`).
8
+ */
9
+ export declare const HARNESS_ONLY_ENV_NAMES: readonly string[];
10
+ /**
11
+ * The child env for a command run in the agent's checkout: the harness's own environment minus
12
+ * {@link HARNESS_ONLY_ENV_NAMES}, with each layer merged over it in order.
13
+ *
14
+ * A layer may still SET a stripped name — a job that explicitly asks for `NODE_ENV` gets it. The
15
+ * strip removes what was merely INHERITED, which is the thing nobody chose.
16
+ */
17
+ export declare function agentChildEnv(...layers: (Record<string, string | undefined> | undefined)[]): NodeJS.ProcessEnv;
@@ -0,0 +1,47 @@
1
+ // The environment the harness hands to everything it spawns INTO the agent's checkout: the agent
2
+ // CLI itself, the captured commands (dependency prepopulation, validation checks, the reproduction
3
+ // proof) and the frontend build/serve.
4
+ //
5
+ // The rule this exists for: the harness process and the agent's checkout are two different
6
+ // programs, and a few of the harness's own environment variables are actively wrong for the
7
+ // second. `NODE_ENV=production` is the one that bit: npm reads it as `omit=dev`, so `npm install`
8
+ // in a checkout silently skips devDependencies, leaving the agent with no test runner, no linter
9
+ // and no build tool. One measured coder run spent six of its forty budgeted tool calls
10
+ // discovering and undoing that (install, `npm ls`, `npm config get omit`, reinstall with
11
+ // `--include=dev`, re-check the bin directory, approve an install script) — all of it caused by a
12
+ // variable the platform set, on a project the platform knows nothing about.
13
+ //
14
+ // Stripping it at THIS seam rather than in the image is what makes it true everywhere: the
15
+ // container gets `NODE_ENV=production` from `entrypoint.sh` (so the harness itself still runs in
16
+ // production mode) and the native host transport sets the same variable on the harness process it
17
+ // spawns, so an image-only fix would have left the developer's own machine leaking it.
18
+ //
19
+ // Per-job env NEVER goes through `process.env` (CLAUDE.md, "Harness rules"): the native transport
20
+ // serves every concurrent `ambientAuth` job from one long-lived process, so a mutation here would
21
+ // be a cross-job leak. This function only READS the process env and returns a fresh object.
22
+ /**
23
+ * Variables of the HARNESS PROCESS that must not reach the agent's checkout.
24
+ *
25
+ * Deliberately short, and it stays short: the bar is a variable whose value is a fact about the
26
+ * harness that a tool in the checkout will silently act on. It is not a sandbox (an agent can set
27
+ * whatever it likes in its own shell) and not a secret filter (the harness holds per-job secrets
28
+ * in `agentEnv`, never in `process.env`).
29
+ */
30
+ export const HARNESS_ONLY_ENV_NAMES = ['NODE_ENV'];
31
+ /**
32
+ * The child env for a command run in the agent's checkout: the harness's own environment minus
33
+ * {@link HARNESS_ONLY_ENV_NAMES}, with each layer merged over it in order.
34
+ *
35
+ * A layer may still SET a stripped name — a job that explicitly asks for `NODE_ENV` gets it. The
36
+ * strip removes what was merely INHERITED, which is the thing nobody chose.
37
+ */
38
+ export function agentChildEnv(...layers) {
39
+ const env = { ...process.env };
40
+ for (const name of HARNESS_ONLY_ENV_NAMES)
41
+ delete env[name];
42
+ for (const layer of layers) {
43
+ if (layer)
44
+ Object.assign(env, layer);
45
+ }
46
+ return env;
47
+ }
@@ -2,7 +2,8 @@ import { type Logger } from './logger.js';
2
2
  import { type ToolProgressWindow } from './tool-silence.js';
3
3
  import { type HarnessCallMetric, type PiRunOutcome, type TodoProgress, type ToolSpan } from './pi.js';
4
4
  import { type McpServerSpec, type ObservedMcpServer, type SkillSpec } from './agent-capabilities.js';
5
- import { type ProgressGuardLimits } from './progress-guard.js';
5
+ import type { ProgressGuardLimits } from './progress-guard.js';
6
+ import type { WorkspaceProbe } from './workspace-probe.js';
6
7
  import { type SliceReview } from './subagents.js';
7
8
  /** Which subscription harness to run (the Pi harness uses `runPi` directly). */
8
9
  export type SubscriptionHarness = 'claude-code' | 'codex';
@@ -65,7 +66,7 @@ export interface SubscriptionRunOptions {
65
66
  generateImages?: boolean;
66
67
  /**
67
68
  * Extra environment for the CLI child, scoped to this job (the tester's secrets, a
68
- * private-registry npmrc pointer). Merged over the inherited `process.env` at spawn, so the
69
+ * private-registry npmrc pointer). Merged over the inherited env at spawn (`agentChildEnv`), so the
69
70
  * agent and its shell tools see them without the harness mutating its OWN environment — which
70
71
  * is shared by every concurrent job under the native host-process transport. See
71
72
  * `RunOptions.agentEnv`.
@@ -84,6 +85,14 @@ export interface SubscriptionRunOptions {
84
85
  guardLimits?: ProgressGuardLimits;
85
86
  /** Whether this run is expected to edit files (false for assess-only runs); gates the no-edit bound. */
86
87
  expectsEdits?: boolean;
88
+ /**
89
+ * Probes the working tree for evidence the agent changed the repository. The guard's no-edit
90
+ * bound asks that question and can only see TOOL NAMES, so an agent writing files through
91
+ * `bash` reads as making no edits at all; this is what settles it before anything is killed.
92
+ * Injected so the guard stays pure, and consulted at most once per run (only when the bound is
93
+ * about to abort). Omitted ⇒ the bound falls back to its tool-name-only judgement.
94
+ */
95
+ workspaceProbe?: WorkspaceProbe;
87
96
  /** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
88
97
  onActivity?: () => void;
89
98
  /** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
@@ -152,6 +161,13 @@ export declare function carryClaudeSystemPrompt(systemPrompt: string, userPrompt
152
161
  prompt: string;
153
162
  folded: boolean;
154
163
  };
164
+ /**
165
+ * Run the Claude Code CLI headlessly against `opts.cwd`, authenticated with the
166
+ * leased subscription OAuth token (CLAUDE_CODE_OAUTH_TOKEN), talking direct to
167
+ * api.anthropic.com. Streams `--output-format stream-json`, mapping the
168
+ * `TodoWrite` tool calls onto subtask progress and the terminal `result` event
169
+ * onto the summary + usage.
170
+ */
155
171
  export declare function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome>;
156
172
  /**
157
173
  * Run the Codex CLI headlessly against `opts.cwd`, authenticated with the leased