@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.
- package/README.md +49 -0
- package/dist/agent-capabilities.d.ts +21 -24
- package/dist/agent-capabilities.js +22 -50
- package/dist/agent-env.d.ts +17 -0
- package/dist/agent-env.js +47 -0
- package/dist/agent-runner.d.ts +18 -2
- package/dist/agent-runner.js +29 -231
- package/dist/agent-shared.d.ts +14 -5
- package/dist/agent-shared.js +14 -5
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js +7 -138
- package/dist/captured-command.d.ts +1 -1
- package/dist/captured-command.js +3 -2
- package/dist/claude-cli.d.ts +90 -0
- package/dist/claude-cli.js +181 -0
- package/dist/claude-home.d.ts +41 -0
- package/dist/claude-home.js +159 -0
- package/dist/coding-agent.d.ts +35 -0
- package/dist/coding-agent.js +213 -41
- package/dist/docker-status.d.ts +89 -0
- package/dist/docker-status.js +147 -0
- package/dist/frontend-infra.js +4 -3
- package/dist/git.d.ts +48 -5
- package/dist/git.js +93 -26
- package/dist/guard-driver.d.ts +71 -0
- package/dist/guard-driver.js +171 -0
- package/dist/harness-server.js +13 -0
- package/dist/infra-standup.d.ts +69 -0
- package/dist/infra-standup.js +182 -0
- package/dist/job.d.ts +10 -0
- package/dist/multi-repo-coding.d.ts +17 -0
- package/dist/multi-repo-coding.js +61 -16
- package/dist/pi-workspace.d.ts +11 -0
- package/dist/pi-workspace.js +126 -57
- package/dist/pi.d.ts +8 -0
- package/dist/pi.js +16 -9
- package/dist/progress-guard.d.ts +56 -10
- package/dist/progress-guard.js +84 -22
- package/dist/runner.d.ts +1 -1
- package/dist/salvage.d.ts +180 -0
- package/dist/salvage.js +289 -0
- package/dist/workspace-probe.d.ts +85 -0
- package/dist/workspace-probe.js +124 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +25 -51
- package/src/agent-env.ts +49 -0
- package/src/agent-runner.ts +40 -267
- package/src/agent-shared.ts +16 -5
- package/src/agent.ts +7 -164
- package/src/captured-command.ts +3 -2
- package/src/claude-cli.ts +217 -0
- package/src/claude-home.ts +233 -0
- package/src/coding-agent.ts +252 -44
- package/src/docker-status.ts +201 -0
- package/src/frontend-infra.ts +4 -3
- package/src/git.ts +104 -26
- package/src/guard-driver.ts +203 -0
- package/src/harness-server.ts +13 -0
- package/src/infra-standup.ts +218 -0
- package/src/job.ts +10 -0
- package/src/multi-repo-coding.ts +65 -16
- package/src/pi-workspace.ts +161 -57
- package/src/pi.ts +27 -12
- package/src/progress-guard.ts +110 -34
- package/src/runner.ts +1 -1
- package/src/salvage.ts +407 -0
- 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 {
|
|
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
|
-
* `
|
|
341
|
-
*
|
|
342
|
-
*
|
|
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 } : {}),
|
package/src/captured-command.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
+
}
|