@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/src/agent.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import { join } from 'node:path'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { mkdir, mkdtemp, rm } from 'node:fs/promises'
4
- import { execFile } from 'node:child_process'
5
- import { promisify } from 'node:util'
6
4
  import type {
7
5
  AgentInfraSpec,
8
6
  AgentJob,
@@ -11,10 +9,13 @@ import type {
11
9
  ServiceInfraSpec,
12
10
  TestSecretSpec,
13
11
  } from './job.js'
12
+ // The preview mode drives the frontend stand-up directly rather than through `manageInfra`:
13
+ // its serve/WireMock children outlive the job on purpose, so it wants no cleanup handle.
14
14
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js'
15
+ import { buildInfraNotes, manageInfra } from './infra-standup.js'
15
16
  import { artifactUploadEnv } from './artifact-upload.js'
16
17
  import { configurePackageRegistries } from './package-registries.js'
17
- import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js'
18
+ import { registerKnownSecrets } from './redact.js'
18
19
  import {
19
20
  cloneRepo,
20
21
  commitAll,
@@ -71,158 +72,6 @@ import { log, type Logger } from './logger.js'
71
72
  // general `if (job.someFlag)` dispatch; anything that doesn't need a checkout belongs in
72
73
  // backend pre/post-ops. See backend/docs/custom-agents.md.
73
74
 
74
- const exec = promisify(execFile)
75
-
76
- /**
77
- * Bring the service's docker-compose dependencies up (local infra only). Best-effort:
78
- * runs `docker compose -f <path> up -d --wait` in the checkout. A missing Docker daemon
79
- * or a compose failure is logged and surfaced to the agent (as a prompt note) rather
80
- * than failing the job — the agent can still run unit-level tests and report what it
81
- * could. A no-op for ephemeral / no-infra / no-compose-path runs.
82
- *
83
- * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
84
- * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
85
- * the in-container dependency stand-up logs on the Tester step — the failure-class artifact
86
- * the orchestrator-side provisioning logs can't see.
87
- */
88
- async function standUpInfra(
89
- dir: string,
90
- infra: ServiceInfraSpec,
91
- signal: AbortSignal | undefined,
92
- logger: Logger,
93
- ): Promise<{ started: boolean; note?: string; record?: InfraSetupRecord }> {
94
- if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
95
- return { started: false }
96
- }
97
- const startedAt = Date.now()
98
- try {
99
- logger.info('agent(explore): standing up infra', { composePath: infra.composePath })
100
- // Raise maxBuffer well above the 1MB default so a chatty compose stand-up can't fail the
101
- // (best-effort) infra step with ENOBUFS; the captured output is tail-bounded on storage.
102
- const { stdout, stderr } = await exec(
103
- 'docker',
104
- ['compose', '-f', infra.composePath, 'up', '-d', '--wait'],
105
- { cwd: dir, signal, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024 },
106
- )
107
- const logs = captureRedactedOutput(stdout, stderr)
108
- return {
109
- started: true,
110
- record: {
111
- started: true,
112
- composePath: infra.composePath,
113
- at: Date.now(),
114
- durationMs: Date.now() - startedAt,
115
- ...(logs ? { logs } : {}),
116
- },
117
- }
118
- } catch (err) {
119
- const note = err instanceof Error ? err.message : String(err)
120
- logger.warn('agent(explore): infra stand-up failed', { error: note })
121
- // `execFile` rejections carry the partial stdout/stderr on the error object — capture them
122
- // so the stored logs explain the failure (a port clash, a pull-auth error, an exited
123
- // dependency), not just the one-line exit message.
124
- const e = err as { stdout?: unknown; stderr?: unknown }
125
- const logs = captureRedactedOutput(e.stdout, e.stderr)
126
- return {
127
- started: false,
128
- note,
129
- record: {
130
- started: false,
131
- composePath: infra.composePath,
132
- at: Date.now(),
133
- durationMs: Date.now() - startedAt,
134
- error: redactSecrets(note),
135
- ...(logs ? { logs } : {}),
136
- },
137
- }
138
- }
139
- }
140
-
141
- /**
142
- * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
143
- * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
144
- * processes (torn down by killing them); the default backend-service flow stands the
145
- * docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
146
- * `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
147
- *
148
- * `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
149
- * monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
150
- * repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
151
- * `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
152
- * building, serving and seeding WireMock from the root would target the wrong directory.
153
- */
154
- async function manageInfra(
155
- dir: string,
156
- workDir: string,
157
- infra: AgentInfraSpec,
158
- opts: RunOptions,
159
- logger: Logger,
160
- ): Promise<{
161
- note?: string
162
- serveUrl?: string
163
- record?: InfraSetupRecord
164
- cleanup: () => Promise<void>
165
- }> {
166
- if (infra.kind === 'frontend') {
167
- // `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
168
- // which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
169
- // Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
170
- const fe = await standUpFrontend(workDir, infra, opts, logger)
171
- return {
172
- ...(fe.note ? { note: fe.note } : {}),
173
- ...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
174
- record: fe.record,
175
- cleanup: () => tearDownFrontend(fe.processes, logger),
176
- }
177
- }
178
- const standUp = await standUpInfra(dir, infra, opts.signal, logger)
179
- return {
180
- ...(standUp.note ? { note: standUp.note } : {}),
181
- ...(standUp.record ? { record: standUp.record } : {}),
182
- cleanup: () => tearDownInfra(dir, infra),
183
- }
184
- }
185
-
186
- /**
187
- * Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
188
- * A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
189
- * frontend serve URL points the UI tester at the app that was just built + served and pre-empts
190
- * a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
191
- * wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
192
- */
193
- export function buildInfraNotes(managed: { note?: string; serveUrl?: string }): string[] {
194
- const notes: string[] = []
195
- if (managed.note) {
196
- notes.push(
197
- `standing the infra up reported a problem (${managed.note}). Test what you can and ` +
198
- `flag any dependency-related gaps as concerns.`,
199
- )
200
- }
201
- if (managed.serveUrl) {
202
- notes.push(
203
- `The frontend under test is built and served at ${managed.serveUrl}, with its other ` +
204
- `backend upstreams handled by WireMock. Drive your UI tests against ${managed.serveUrl}. ` +
205
- `If a call to a live backend fails with a CORS / cross-origin error, that is an infra ` +
206
- `gap (the backend must allow the ${managed.serveUrl} origin), not an app defect — flag ` +
207
- `it as a concern rather than a failing test.`,
208
- )
209
- }
210
- return notes
211
- }
212
-
213
- /** Tear the docker-compose dependencies down (best-effort; a no-op when none were started). */
214
- async function tearDownInfra(dir: string, infra: ServiceInfraSpec): Promise<void> {
215
- if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) return
216
- try {
217
- await exec('docker', ['compose', '-f', infra.composePath, 'down', '-v'], {
218
- cwd: dir,
219
- timeout: 2 * 60_000,
220
- })
221
- } catch {
222
- // The container is ephemeral and torn down with the run anyway — ignore.
223
- }
224
- }
225
-
226
75
  /**
227
76
  * Parse an agent's final reply into the structured JSON `custom`, shared by the explore and
228
77
  * coding structured-output paths. With repair enabled (default) a malformed reply gets ONE
@@ -337,9 +186,9 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
337
186
 
338
187
  /**
339
188
  * Layer extra child-process env onto a job's {@link RunOptions}. The agent CLI is spawned with
340
- * `{...process.env, ...agentEnv}`, so this is how per-job values reach the agent (and the shell
341
- * tools it spawns) WITHOUT mutating the harness's own `process.env` — which is shared by every
342
- * concurrent job when the harness runs as a native host process. Empty `env` ⇒ `opts` unchanged.
189
+ * `agentChildEnv(agentEnv)`, so this is how per-job values reach the agent (and the shell tools it
190
+ * spawns) WITHOUT mutating the harness's own `process.env` — which is shared by every concurrent
191
+ * job when the harness runs as a native host process. Empty `env` ⇒ `opts` unchanged.
343
192
  */
344
193
  function withAgentEnv(opts: RunOptions, env: Record<string, string>): RunOptions {
345
194
  if (Object.keys(env).length === 0) return opts
@@ -646,8 +495,6 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
646
495
  // Read-only: it inspects and reports, making no edits — so the no-progress
647
496
  // guard's no-edit bound must not fire on its legitimately edit-free run.
648
497
  expectsEdits: false,
649
- webToolsGuidance: job.webToolsGuidance,
650
- webSearchProxy: job.webSearch,
651
498
  contextFiles: job.contextFiles,
652
499
  guardLimits: job.guardLimits,
653
500
  ...agentCapabilities(job),
@@ -897,8 +744,6 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
897
744
  sessionToken: job.sessionToken,
898
745
  // Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
899
746
  expectsEdits: false,
900
- webToolsGuidance: job.webToolsGuidance,
901
- webSearchProxy: job.webSearch,
902
747
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
903
748
  guardLimits: job.guardLimits,
904
749
  ...agentCapabilities(job),
@@ -1019,8 +864,6 @@ export function buildSingleRepoCodingSpec(
1019
864
  proxyPhasePath: job.proxyPhasePath,
1020
865
  sessionToken: job.sessionToken,
1021
866
  commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
1022
- webToolsGuidance: job.webToolsGuidance,
1023
- webSearchProxy: job.webSearch,
1024
867
  guardLimits: job.guardLimits,
1025
868
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
1026
869
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { killChildProcess, spawnDetached } from './process.js'
3
+ import { agentChildEnv } from './agent-env.js'
3
4
  import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
4
5
  import type { RunOptions } from './runner.js'
5
6
  import type { Logger } from './logger.js'
@@ -54,7 +55,7 @@ export interface CapturedCommandResult {
54
55
  * tree on timeout and an aborted run resolves non-zero, so a phase is never what blocks a job
55
56
  * from settling.
56
57
  *
57
- * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
58
+ * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over `agentChildEnv`),
58
59
  * not a mutated global: the harness spawns this itself rather than through the agent, so without
59
60
  * the explicit merge a native-mode job would run without the private-registry npmrc pointer (and,
60
61
  * had this been staged in `process.env`, against a sibling job's state).
@@ -84,7 +85,7 @@ export async function runCapturedCommand(args: {
84
85
  cwd,
85
86
  detached: spawnDetached,
86
87
  stdio: ['ignore', 'pipe', 'pipe'],
87
- env: { ...process.env, ...opts.agentEnv },
88
+ env: agentChildEnv(opts.agentEnv),
88
89
  })
89
90
  // Keep only the tail (plus the scrub margin); guard against unbounded buffering on a chatty
90
91
  // command.
@@ -0,0 +1,217 @@
1
+ import type { Logger } from './logger.js'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // The Claude Code CLI's INVOCATION surface: which of its built-in tools a run asks for, the argv
5
+ // that asks, and the read-back that says what the CLI actually granted.
6
+ //
7
+ // The three belong together because they are one decision seen from three sides. Before this
8
+ // module the harness declared nothing and took whatever the CLI's headless default happened to
9
+ // be, which drifted across CLI versions: 2.1.226 offered the plan tools, 2.1.245 did not, and
10
+ // nothing in the run said so. Both halves of that default were wrong for a disposable container:
11
+ // no `Grep`/`Glob` (so every search went through `Bash` and counted against the progress guard's
12
+ // no-edit budget), no plan tools (so `step.progress` had no signal to lift), and a dozen tools
13
+ // (`CronCreate`, `DesignSync`, `EnterWorktree`, `ScheduleWakeup`, `SendMessage`, `Workflow`,
14
+ // `ReportFindings`, …) an agent in a per-run container can act on none of.
15
+ //
16
+ // Declaring a set therefore has to be measured against the default it replaces, not just against
17
+ // the issue that asked for it: everything the default carried and a container CAN use has to be
18
+ // asked for by name, or the declaration is itself a capability loss. That is what `Monitor` and
19
+ // the web tools are doing in the list below.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Every built-in tool a run asks the CLI for, and the ONE value that rides both `--tools` and the
24
+ * `--allowedTools` re-grant.
25
+ *
26
+ * One value rather than two derived ones, because the allow-list turned out to be ADDITIVE rather
27
+ * than inert: a name in it is UNLOCKED, not merely re-permitted (measured: `--allowedTools
28
+ * "Bash,Grep"` yields the default set PLUS `Glob` and `Grep`). Two independently-computed lists
29
+ * would therefore not merely disagree, they would silently re-grant what the other withheld.
30
+ *
31
+ * Deliberately OVER-inclusive, and safe to be, because a name the build does not have is dropped
32
+ * silently rather than refused. The cost is one-directional: a name the CLI HAS and this list
33
+ * LACKS is a capability silently removed from every run. So the list is measured against the
34
+ * headless default it replaces, not only against what was wanted, and when the CLI gains a tool a
35
+ * container agent can use, it is added here.
36
+ *
37
+ * A name 2.1.246 does not serve is kept for one of two measured reasons, and they are different
38
+ * facts worth keeping apart (each probed alone, reading the `init` event's own `tools` array):
39
+ *
40
+ * - ALIASED onto a successor, so the old spelling still buys the capability: `BashOutput` grants
41
+ * `TaskOutput`, `KillBash` and `KillShell` both grant `TaskStop`, `Agent` grants `Task`.
42
+ * - DROPPED outright (`ListMcpResources`, `ReadMcpResource`, `MultiEdit`, `NotebookRead`,
43
+ * `TodoWrite`), and kept only because the harness image is pinned per workspace, so one build
44
+ * of this source faces several CLI versions and an older one still serves them.
45
+ *
46
+ * The second category is why the CURRENT spelling has to be listed beside the old one rather than
47
+ * instead of it: `ListMcpResources`/`ReadMcpResource` were carried alone, and since neither is an
48
+ * alias, every tool-server run reached its resources through nothing at all.
49
+ *
50
+ * `WebSearch`/`WebFetch` are unconditional, which is a deliberate reversal of the first cut of this
51
+ * module. They were gated on the job's `webSearch` flag, which states whether OUR PROXY can serve
52
+ * web research for the run's account (see `resolveWebSearchAvailability`, whose whole rationale is
53
+ * that Pi's proxy-backed tools "would just fail/return nothing" without a key). The CLI's web tools
54
+ * are not proxy-backed: the vendor the leased subscription already pays serves them, and they work
55
+ * on a deployment with no search provider wired at all. Gating them on that flag therefore withheld
56
+ * a WORKING capability on the strength of an unrelated fact, which is the opposite of the
57
+ * pass-through an unwired capability owes. The flag that would legitimately withhold them is a
58
+ * per-run "may this run reach the web" POLICY, which this platform does not have today; when it
59
+ * gains one, it gates here and on the Pi path together.
60
+ */
61
+ export const CLAUDE_TOOL_SET: readonly string[] = [
62
+ 'Agent',
63
+ 'Bash',
64
+ 'BashOutput',
65
+ 'Edit',
66
+ 'Glob',
67
+ 'Grep',
68
+ 'KillBash',
69
+ 'KillShell',
70
+ 'ListMcpResources',
71
+ 'ListMcpResourcesTool',
72
+ // Waits on the background shells `Bash(run_in_background)` starts. A tool in its own right
73
+ // (measured: `Monitor` grants `Monitor`), not an alias of the retired kill/output pair, and it
74
+ // is in the headless default, so omitting it was this declaration's own capability loss.
75
+ 'Monitor',
76
+ 'MultiEdit',
77
+ 'NotebookEdit',
78
+ 'NotebookRead',
79
+ 'Read',
80
+ 'ReadMcpResource',
81
+ 'ReadMcpResourceTool',
82
+ 'Skill',
83
+ 'Task',
84
+ 'TaskCreate',
85
+ 'TaskGet',
86
+ 'TaskList',
87
+ 'TaskOutput',
88
+ 'TaskStop',
89
+ 'TaskUpdate',
90
+ 'TodoWrite',
91
+ // Loads the schemas of tools a build defers rather than declaring up front. Dropped by 2.1.246
92
+ // (measured, with and without a tool server wired), and asked for anyway under the
93
+ // over-inclusive rule: a run wiring several tool servers is exactly the shape a build that
94
+ // defers tool schemas would hand one to.
95
+ 'ToolSearch',
96
+ 'WebFetch',
97
+ 'WebSearch',
98
+ 'Write',
99
+ ]
100
+
101
+ /**
102
+ * One capability the run genuinely cannot do without, and every CLI spelling that satisfies it.
103
+ *
104
+ * The floor is expressed as CAPABILITIES rather than as names because {@link CLAUDE_TOOL_SET}
105
+ * is over-inclusive on purpose: a literal "warn on anything requested but absent" would fire on
106
+ * every single run for the alternate spellings this image carries for other CLI versions, and a
107
+ * warning that is always on is one nobody reads. A capability with no granted spelling is the
108
+ * fact worth a line: it means an upstream rename or removal took a tool out of every run of this
109
+ * image, which otherwise surfaces days later as an agent behaving oddly.
110
+ */
111
+ interface ClaudeToolCapability {
112
+ capability: string
113
+ spellings: readonly string[]
114
+ }
115
+
116
+ const CLAUDE_TOOL_FLOOR: readonly ClaudeToolCapability[] = [
117
+ { capability: 'shell', spellings: ['Bash'] },
118
+ { capability: 'read', spellings: ['Read'] },
119
+ { capability: 'write', spellings: ['Write'] },
120
+ { capability: 'edit', spellings: ['Edit', 'MultiEdit'] },
121
+ { capability: 'search', spellings: ['Grep'] },
122
+ { capability: 'glob', spellings: ['Glob'] },
123
+ { capability: 'subagents', spellings: ['Task', 'Agent'] },
124
+ // The plan signal the harness lifts into `step.subtasks` / `step.progress`, in the two
125
+ // vocabularies the CLI has used for it (see `progress.ts`, which reads both).
126
+ { capability: 'plan', spellings: ['TaskCreate', 'TodoWrite'] },
127
+ ]
128
+
129
+ /** The floor, exported so a test can assert every spelling is one this harness actually asks for. */
130
+ export const CLAUDE_TOOL_CAPABILITIES: readonly ClaudeToolCapability[] = CLAUDE_TOOL_FLOOR
131
+
132
+ /**
133
+ * The `claude` argv for one run, in the order the CLI's variadic flags require.
134
+ *
135
+ * `--tools` and `--allowedTools` are both declared `<tools...>`, so each swallows any trailing
136
+ * POSITIONAL argument as another tool name; only a following `--flag` terminates them. The prompt
137
+ * therefore stays on stdin (see `streamCli`) and every flag here is placed before the variadic
138
+ * pair or introduced by its own `--`, so a new flag cannot be eaten by the one in front of it.
139
+ */
140
+ export function claudeCliArgs(opts: {
141
+ model: string
142
+ /** The built-in tools this run asks for; see {@link CLAUDE_TOOL_SET}. */
143
+ tools: readonly string[]
144
+ /** `--mcp-config` + `--strict-mcp-config` + any `--allowedTools`; empty when no server is wired. */
145
+ mcpArgs: readonly string[]
146
+ /** `--append-system-prompt <prompt>`, or empty when the prompt was folded into stdin. */
147
+ appendArgs: readonly string[]
148
+ }): string[] {
149
+ return [
150
+ '-p',
151
+ '--output-format',
152
+ 'stream-json',
153
+ '--verbose',
154
+ // The per-run container IS the sandbox, and the run is fully headless (no one to approve a
155
+ // tool call) — so bypass permissions entirely. `acceptEdits` would auto-accept file edits but
156
+ // still gate Bash, which in `-p` mode is then denied, leaving the agent unable to run
157
+ // builds/tests/git to verify its work.
158
+ '--permission-mode',
159
+ 'bypassPermissions',
160
+ '--model',
161
+ opts.model,
162
+ // Declared rather than defaulted: see this module's header for what the default set costs.
163
+ '--tools',
164
+ opts.tools.join(','),
165
+ ...opts.mcpArgs,
166
+ ...opts.appendArgs,
167
+ ]
168
+ }
169
+
170
+ /**
171
+ * Read the CLI's startup report (`{"type":"system","subtype":"init"}`) back against what this run
172
+ * asked for, and say when a required capability is missing.
173
+ *
174
+ * The same pairing, and for the same reason, as `assertOnboardingKeysCurrent`: the CLI is the only
175
+ * one who knows what it granted, it says so exactly once before the first model call, and pairing
176
+ * that answer with the CLI version is what makes an upstream rename diffable instead of a mystery.
177
+ * The version comes off the event itself (`claude_code_version`) rather than an env var the image
178
+ * would have to remember to bake.
179
+ *
180
+ * Best-effort and never throws: a run whose tool surface is short is still a run, and the honest
181
+ * disposition for a floor this image cannot verify is to SAY it could not be read, not to fail the
182
+ * job and not to stay silent (which reads exactly like a satisfied request).
183
+ */
184
+ export function assertClaudeToolsCurrent(
185
+ event: Record<string, unknown>,
186
+ requested: readonly string[],
187
+ log: Logger | undefined,
188
+ ): void {
189
+ if (!log || event.type !== 'system' || event.subtype !== 'init') return
190
+ const version =
191
+ typeof event.claude_code_version === 'string' ? event.claude_code_version : undefined
192
+ const cliVersion = version ? { cliVersion: version } : {}
193
+ if (!Array.isArray(event.tools)) {
194
+ log.warn('claude-code announced no tool list, so this run has an unverified tool surface', {
195
+ requestedTools: [...requested],
196
+ ...cliVersion,
197
+ })
198
+ return
199
+ }
200
+ const granted = new Set(event.tools.filter((t): t is string => typeof t === 'string'))
201
+ const missing = CLAUDE_TOOL_FLOOR.filter((c) => !c.spellings.some((s) => granted.has(s))).map(
202
+ (c) => c.capability,
203
+ )
204
+ const fields = {
205
+ requestedTools: [...requested],
206
+ grantedTools: [...granted].sort(),
207
+ ...cliVersion,
208
+ }
209
+ if (missing.length > 0) {
210
+ log.warn('claude-code granted no tool for a capability this run requires', {
211
+ ...fields,
212
+ missingCapabilities: missing,
213
+ })
214
+ return
215
+ }
216
+ log.info('claude-code tool set granted', fields)
217
+ }