@cat-factory/executor-harness 1.134.0 → 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.
@@ -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
+ }
@@ -0,0 +1,233 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import {
5
+ claudeAllowedToolPatterns,
6
+ mcpServerSecretValues,
7
+ writeClaudeMcpConfig,
8
+ type McpServerSpec,
9
+ type SkillSpec,
10
+ } from './agent-capabilities.js'
11
+ import type { Logger } from './logger.js'
12
+ import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
13
+ import { registerKnownSecrets } from './redact.js'
14
+ import { retainSessionTranscripts } from './transcript-retention.js'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // The PER-RUN Claude Code config home: everything written for one claude-code job and torn down
18
+ // with it — the isolated `CLAUDE_CONFIG_DIR`, its onboarding pre-seed, the run's native skills,
19
+ // its MCP config, and the child env that points the CLI at all of it.
20
+ //
21
+ // The sibling of `codex-home.ts`, extracted from `runClaudeCode` for the same reason: the run
22
+ // loop's own job is streaming and reducing the CLI's events, while this is a directory with a
23
+ // lifecycle that holds a credential.
24
+ //
25
+ // CRITICAL, and why it is a temp dir rather than anything under the checkout: several handlers
26
+ // finish with `git add -A` + push, so a `.claude/` directory inside `opts.cwd` would publish any
27
+ // cached credential to the PR branch.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** What one claude-code job needs written into its own home. */
31
+ export interface ClaudeHomeOptions {
32
+ /** The decrypted subscription OAuth token. Required unless `ambientAuth`. */
33
+ subscriptionToken?: string
34
+ /**
35
+ * Anthropic-compatible base URL for a non-Anthropic Claude-Code vendor (GLM/Kimi): present ⇒
36
+ * ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN, absent ⇒ CLAUDE_CODE_OAUTH_TOKEN.
37
+ */
38
+ subscriptionBaseUrl?: string
39
+ /** Run the developer's own CLI login instead: no isolated home, nothing installed. */
40
+ ambientAuth?: boolean
41
+ /** Skills to install natively under `<configHome>/skills/<name>/`. */
42
+ skills?: SkillSpec[]
43
+ /** Tool servers to scope to this job's config. */
44
+ mcpServers?: McpServerSpec[]
45
+ /** Job-scoped child env (tester secrets, a private-registry npmrc pointer). */
46
+ extraEnv?: Record<string, string>
47
+ log?: Logger
48
+ }
49
+
50
+ /**
51
+ * Write a repo-sourced skill as a NATIVE Claude Code skill under `<skillsRoot>/<name>/`: a
52
+ * `SKILL.md` (YAML frontmatter `name`/`description` + the instructions body, the format the CLI
53
+ * expects) plus every resource file at its path within the skill directory. Resource sub-paths
54
+ * were sanitized at the job boundary (no traversal), so nested dirs are created as needed.
55
+ *
56
+ * The frontmatter `name`/`description` values are emitted as JSON-encoded (double-quoted) YAML
57
+ * scalars, not bare plain scalars: an author's description routinely contains `: ` (colon-space)
58
+ * or a leading YAML indicator (`#`, `-`, `[`, `{`, `"`, …), which is invalid as a plain scalar and
59
+ * would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
60
+ * valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
61
+ */
62
+ async function writeNativeSkill(skillsRoot: string, skill: SkillSpec): Promise<void> {
63
+ const dir = join(skillsRoot, skill.name)
64
+ await mkdir(dir, { recursive: true })
65
+ const name = JSON.stringify(skill.name)
66
+ const description = JSON.stringify(skill.description.replace(/\r?\n/g, ' '))
67
+ const frontmatter = `---\nname: ${name}\ndescription: ${description}\n---\n`
68
+ await writeFile(join(dir, 'SKILL.md'), `${frontmatter}\n${skill.instructions}\n`, 'utf8')
69
+ for (const resource of skill.resources) {
70
+ const dest = join(dir, resource.relPath)
71
+ await mkdir(dirname(dest), { recursive: true })
72
+ await writeFile(dest, resource.content, 'utf8')
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Prepare the Claude Code CLI's MCP wiring for one run: write the servers to a PER-RUN config and
78
+ * return the argv that points the CLI at it, plus the cleanup for a directory we had to mint.
79
+ *
80
+ * Two decisions live here. `--strict-mcp-config` makes that file the ONLY source of servers, so an
81
+ * ambient run on a developer's own machine can never silently hand the agent their personal ones.
82
+ * And `--allowedTools` is passed ONLY when a server actually narrows its tools — an allow-list is
83
+ * whole-session, not MCP-scoped, so it carries `builtIns`, the SAME list this run declared with
84
+ * `--tools`, in the same entry; see `claudeAllowedToolPatterns` for why that list is threaded in
85
+ * rather than re-derived, and how the run's permission mode treats an allow-list.
86
+ *
87
+ * The config carries this job's resolved credentials, so it goes in the isolated config home when
88
+ * we own one and a throwaway per-JOB directory otherwise — never the checkout (it would land in a
89
+ * commit) and never a shared HOME path (a concurrent job would clobber it).
90
+ */
91
+ async function setUpClaudeMcp(
92
+ servers: McpServerSpec[] | undefined,
93
+ configHome: string | undefined,
94
+ builtIns: readonly string[],
95
+ ): Promise<{ args: string[]; cleanup: () => Promise<void> }> {
96
+ const noop = { args: [], cleanup: async () => {} }
97
+ if (!servers?.length) return noop
98
+ // Before anything can spawn: a failing MCP server echoes its own argv/headers into stderr, and
99
+ // that tail is carried onto the step's diagnostics.
100
+ registerKnownSecrets(mcpServerSecretValues(servers))
101
+ const home = configHome ?? (await mkdtemp(join(tmpdir(), 'cf-claude-mcp-')))
102
+ const owned = home === configHome ? undefined : home
103
+ const cleanup = async (): Promise<void> => {
104
+ if (owned) await rm(owned, { recursive: true, force: true }).catch(() => {})
105
+ }
106
+ const configPath = await writeClaudeMcpConfig(home, servers)
107
+ if (!configPath) return { args: [], cleanup }
108
+ const allowedTools = claudeAllowedToolPatterns(servers, builtIns)
109
+ return {
110
+ args: [
111
+ '--mcp-config',
112
+ configPath,
113
+ '--strict-mcp-config',
114
+ ...(allowedTools?.length ? ['--allowedTools', allowedTools.join(',')] : []),
115
+ ],
116
+ cleanup,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The isolated, per-run home the `claude` CLI runs against: a temp config dir OUTSIDE the cloned
122
+ * checkout, pre-seeded past the first-launch prompts, carrying the run's native skills and MCP
123
+ * config, plus the child env pointing the CLI at it. {@link ClaudeRunHome.dispose} is the other
124
+ * half of the same concern — the leased credential must never outlive the run — so acquisition
125
+ * and teardown are defined together rather than split across a `finally` forty lines away.
126
+ *
127
+ * Ambient (native) mode has NO home: the developer's installed CLI uses its own `~/.claude`
128
+ * login, so nothing is created, nothing is pre-seeded, and `dispose` only clears the MCP config.
129
+ */
130
+ export interface ClaudeRunHome {
131
+ /** The per-run config dir; `undefined` in ambient mode (the developer's own login is used). */
132
+ configHome: string | undefined
133
+ /** The CLI argv selecting the run's tool servers; empty when it has none. */
134
+ mcpArgs: string[]
135
+ /** The child-process env (see {@link buildClaudeEnv}). */
136
+ env: Record<string, string>
137
+ dispose: () => Promise<void>
138
+ }
139
+
140
+ export async function openClaudeRunHome(
141
+ opts: ClaudeHomeOptions,
142
+ tools: readonly string[],
143
+ ): Promise<ClaudeRunHome> {
144
+ // Native (ambient) mode: run the developer's installed `claude` with its OWN login —
145
+ // no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
146
+ // Claude Code persists user config/credentials under its config dir; point that at an
147
+ // isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
148
+ // agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
149
+ // stray `.claude/` directory — and any cached credential in it — into the pushed branch.
150
+ // Mirrors the Codex CODEX_HOME isolation (`codex-home.ts`); removed by `dispose`.
151
+ if (!opts.ambientAuth && !opts.subscriptionToken) {
152
+ throw new Error('claude-code harness requires a subscription token (or ambientAuth)')
153
+ }
154
+ const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'))
155
+
156
+ // The config dir is brand-new every run, so Claude Code would otherwise treat this
157
+ // as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
158
+ // bypass-permissions acknowledgement prompts — which never get answered headlessly,
159
+ // hanging the job until the watchdog kills it. Pre-seed the config that marks those
160
+ // as already accepted so `-p` starts straight into the run. Best-effort: written
161
+ // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
162
+ // (Ambient mode skips this — the developer's own config is already onboarded.)
163
+ // ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
164
+ // version, so a future first-run gate this set doesn't cover (which looks identical to
165
+ // a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
166
+ if (configHome) {
167
+ await writeOnboardingPreseed(configHome)
168
+ await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
169
+ }
170
+
171
+ // Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
172
+ // discovers and can invoke it. ONLY into the isolated per-run config home — never the
173
+ // developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
174
+ // setup after the run and two concurrent jobs carrying same-named skills would clobber each
175
+ // other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
176
+ // materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
177
+ // still names the skills.
178
+ if (configHome) {
179
+ for (const skill of opts.skills ?? []) {
180
+ await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
181
+ }
182
+ }
183
+
184
+ // Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
185
+ // one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
186
+ const mcp = await setUpClaudeMcp(opts.mcpServers, configHome, tools)
187
+
188
+ return {
189
+ configHome,
190
+ mcpArgs: mcp.args,
191
+ env: buildClaudeEnv(opts, configHome),
192
+ dispose: async () => {
193
+ // The ambient-mode MCP config dir (credential-bearing) never outlives the run.
194
+ await mcp.cleanup()
195
+ if (!configHome) return
196
+ // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
197
+ // home is deleted — the credential lives at the home root, never in `projects/`, so this
198
+ // keeps the debugging artifact without leaking the token. Best-effort; never throws.
199
+ await retainSessionTranscripts(configHome, ['projects'], {
200
+ label: 'claude-code',
201
+ ...(opts.log ? { log: opts.log } : {}),
202
+ })
203
+ // Never leave the config dir (and any cached credential) on disk past the run.
204
+ await rm(configHome, { recursive: true, force: true }).catch(() => {})
205
+ },
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Build the child-process env for the `claude` CLI: an isolated config home plus subscription
211
+ * auth (Anthropic OAuth token, or an Anthropic-compatible base URL + auth token for a
212
+ * non-Anthropic Claude-Code vendor like GLM/Kimi/DeepSeek), or an empty env in ambient mode
213
+ * (the developer's own logged-in `~/.claude` is used). Extracted from {@link runClaudeCode} to
214
+ * keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
215
+ */
216
+ function buildClaudeEnv(
217
+ opts: ClaudeHomeOptions,
218
+ configHome: string | undefined,
219
+ ): Record<string, string> {
220
+ // The job-scoped env rides along in BOTH modes; the credential/config vars below are what
221
+ // ambient mode drops (the developer's own logged-in `~/.claude` is used instead).
222
+ if (opts.ambientAuth) return { ...opts.extraEnv }
223
+ return {
224
+ ...opts.extraEnv,
225
+ CLAUDE_CONFIG_DIR: configHome!,
226
+ ...(opts.subscriptionBaseUrl
227
+ ? {
228
+ ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
229
+ ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken!,
230
+ }
231
+ : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
232
+ }
233
+ }
@@ -23,6 +23,7 @@ import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js'
23
23
  import type { RunOptions } from './runner.js'
24
24
  import { log, type Logger } from './logger.js'
25
25
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
26
+ import { agentCapabilities } from './agent-shared.js'
26
27
  import {
27
28
  resolvePrTemplateNote,
28
29
  withPrTemplateNote,
@@ -201,16 +202,13 @@ export async function runMultiRepoCoding(
201
202
  proxyBaseUrl: job.proxyBaseUrl,
202
203
  proxyPhasePath: job.proxyPhasePath,
203
204
  sessionToken: job.sessionToken,
204
- webToolsGuidance: job.webToolsGuidance,
205
- webSearchProxy: job.webSearch,
206
205
  guardLimits: job.guardLimits,
207
206
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
208
- // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
209
- // are properties of the AGENT KIND, not of the checkout layout.
210
- ...(job.skills?.length ? { skills: job.skills } : {}),
211
- ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
212
- ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
213
- ...(job.designImages ? { designImages: job.designImages } : {}),
207
+ // Skills, tool servers and web research apply to a multi-repo run exactly as to a
208
+ // single-repo one: they are properties of the AGENT KIND, not of the checkout layout.
209
+ // Through the shared helper rather than re-spread here, which is what let this flow
210
+ // drift from the single-repo one in the first place.
211
+ ...agentCapabilities(job),
214
212
  multiRepo: true,
215
213
  // What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
216
214
  repoDirs: probeDirsForLegs(legs),
@@ -308,6 +308,90 @@ export async function checkoutHasBlueprints(dir: string, multiRepo: boolean): Pr
308
308
  return checks.some(Boolean)
309
309
  }
310
310
 
311
+ /**
312
+ * Run one pass on a SUBSCRIPTION harness (Claude Code / Codex): the leased-credential path, which
313
+ * shares only the checkout preparation with the Pi one.
314
+ *
315
+ * Split out of {@link runAgentInWorkspace} for its cyclomatic budget. It is also the honest seam:
316
+ * everything here is a decision about what the vendor's own CLI is handed, while everything left
317
+ * behind is about the proxy-backed Pi run.
318
+ */
319
+ async function runSubscriptionInWorkspace(
320
+ harness: 'claude-code' | 'codex',
321
+ spec: AgentRunSpec,
322
+ opts: RunOptions,
323
+ prepared: {
324
+ contextFiles: ContextFileInfo[]
325
+ imageGuidance: string
326
+ workspaceProbe: WorkspaceProbe
327
+ },
328
+ ): Promise<PiRunOutcome> {
329
+ const { contextFiles, imageGuidance, workspaceProbe } = prepared
330
+ // Ambient (native) mode authenticates with the developer's own CLI login, so no
331
+ // leased token is required; otherwise the leased subscription token is mandatory.
332
+ if (!spec.ambientAuth && !spec.subscriptionToken) {
333
+ throw new Error(`The ${harness} harness requires a subscription token`)
334
+ }
335
+ const subOutcome = await runSubscriptionHarness(harness, {
336
+ cwd: spec.dir,
337
+ model: spec.model,
338
+ systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
339
+ userPrompt: spec.userPrompt,
340
+ ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
341
+ subscriptionBaseUrl: spec.subscriptionBaseUrl,
342
+ ...(spec.ambientAuth ? { ambientAuth: true } : {}),
343
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
344
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
345
+ // Codex's own image tool. Passed for both subscription harnesses because the option lives on
346
+ // the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
347
+ // (unlike an MCP server) there is nothing to report as unservable — the backend never
348
+ // resolves a codex-served generator onto a claude-code step, because admission refuses it.
349
+ ...(spec.generateImages ? { generateImages: true } : {}),
350
+ // `spec.webSearchProxy` is deliberately NOT forwarded. It states whether OUR PROXY serves web
351
+ // research for this run's account, which is what Pi's tools ride and what they would fail
352
+ // without; neither subscription CLI touches that proxy. Claude Code's `WebSearch`/`WebFetch`
353
+ // are served by the vendor the leased subscription already pays and are declared
354
+ // unconditionally (see `CLAUDE_TOOL_SET`), and Codex's surface is per-tool config rather than
355
+ // a list. Passing the proxy's availability here would withhold working tools on the strength
356
+ // of an unrelated deployment's wiring.
357
+ ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
358
+ signal: opts.signal,
359
+ // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
360
+ // defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
361
+ // no-edit allowance, so a claude-code run that stops making progress is killed early
362
+ // instead of burning the full wall-clock budget. The claude runner consumes it; codex
363
+ // ignores it for now (its stream isn't wired to the guard).
364
+ guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
365
+ expectsEdits: spec.expectsEdits ?? true,
366
+ // What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
367
+ workspaceProbe,
368
+ onActivity: opts.onActivity,
369
+ onProgress: opts.onProgress,
370
+ // The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
371
+ // and a proxied one produce the same evidence rather than one of them producing none.
372
+ onSpan: opts.onSpan,
373
+ // The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
374
+ // Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
375
+ // each can beat the window it opens.
376
+ beginToolWindow: opts.beginToolWindow,
377
+ // Per-slice review capture, so a parallel review's finished slices are persisted as they
378
+ // land rather than only in the terminal output. Only the subscription runners fan work out
379
+ // across subagents, so this is the only path that can produce it.
380
+ onSliceReviews: opts.onSliceReviews,
381
+ // What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
382
+ // harnesses even though only claude-code's stream carries the report today: the hook is a
383
+ // pass-through, and a codex run that never calls it leaves the backend's record honestly
384
+ // absent rather than claiming every server it wired failed to start.
385
+ onToolServers: opts.onToolServers,
386
+ // Stream this run's per-call telemetry to the job's live drain. The subscription
387
+ // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
388
+ // proxy as they happen), so this is the only path that needs the hook.
389
+ onCallMetric: opts.onCallMetric,
390
+ ...(opts.log ? { log: opts.log } : {}),
391
+ })
392
+ return withEffortReport(spec.dir, subOutcome)
393
+ }
394
+
311
395
  /**
312
396
  * Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
313
397
  * then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
@@ -362,67 +446,15 @@ export async function runAgentInWorkspace(
362
446
  // the half that matters there anyway.
363
447
  const workspaceProbe = await buildWorkspaceProbe(spec, opts.signal)
364
448
 
365
- // Subscription harnesses (Claude Code / Codex) authenticate with the leased
366
- // token and talk direct to the vendor no proxy config, no AGENTS.md. The
367
- // system prompt is passed straight to the CLI; everything around this (clone,
368
- // push, watchdogs) is unchanged.
449
+ // Subscription harnesses (Claude Code / Codex) authenticate with the leased token and talk
450
+ // direct to the vendor: no proxy config, no AGENTS.md. The system prompt is passed straight to
451
+ // the CLI; everything around this (clone, push, watchdogs) is unchanged.
369
452
  if (spec.harness === 'claude-code' || spec.harness === 'codex') {
370
- // Ambient (native) mode authenticates with the developer's own CLI login, so no
371
- // leased token is required; otherwise the leased subscription token is mandatory.
372
- if (!spec.ambientAuth && !spec.subscriptionToken) {
373
- throw new Error(`The ${spec.harness} harness requires a subscription token`)
374
- }
375
- const subOutcome = await runSubscriptionHarness(spec.harness, {
376
- cwd: spec.dir,
377
- model: spec.model,
378
- systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
379
- userPrompt: spec.userPrompt,
380
- ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
381
- subscriptionBaseUrl: spec.subscriptionBaseUrl,
382
- ...(spec.ambientAuth ? { ambientAuth: true } : {}),
383
- ...(spec.skills?.length ? { skills: spec.skills } : {}),
384
- ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
385
- // Codex's own image tool. Passed for both subscription harnesses because the option lives on
386
- // the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
387
- // (unlike an MCP server) there is nothing to report as unservable — the backend never
388
- // resolves a codex-served generator onto a claude-code step, because admission refuses it.
389
- ...(spec.generateImages ? { generateImages: true } : {}),
390
- ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
391
- signal: opts.signal,
392
- // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
393
- // defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
394
- // no-edit allowance, so a claude-code run that stops making progress is killed early
395
- // instead of burning the full wall-clock budget. The claude runner consumes it; codex
396
- // ignores it for now (its stream isn't wired to the guard).
397
- guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
398
- expectsEdits: spec.expectsEdits ?? true,
399
- // What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
453
+ return await runSubscriptionInWorkspace(spec.harness, spec, opts, {
454
+ contextFiles,
455
+ imageGuidance,
400
456
  workspaceProbe,
401
- onActivity: opts.onActivity,
402
- onProgress: opts.onProgress,
403
- // The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
404
- // and a proxied one produce the same evidence rather than one of them producing none.
405
- onSpan: opts.onSpan,
406
- // The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
407
- // Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
408
- // each can beat the window it opens.
409
- beginToolWindow: opts.beginToolWindow,
410
- // Per-slice review capture, so a parallel review's finished slices are persisted as they
411
- // land rather than only in the terminal output. Only the subscription runners fan work out
412
- // across subagents, so this is the only path that can produce it.
413
- onSliceReviews: opts.onSliceReviews,
414
- // What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
415
- // harnesses even though only claude-code's stream carries the report today: the hook is a
416
- // pass-through, and a codex run that never calls it leaves the backend's record honestly
417
- // absent rather than claiming every server it wired failed to start.
418
- onToolServers: opts.onToolServers,
419
- // Stream this run's per-call telemetry to the job's live drain. The subscription
420
- // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
421
- // proxy as they happen), so this is the only path that needs the hook.
422
- onCallMetric: opts.onCallMetric,
423
- ...(opts.log ? { log: opts.log } : {}),
424
457
  })
425
- return withEffortReport(spec.dir, subOutcome)
426
458
  }
427
459
  if (!spec.proxyBaseUrl || !spec.sessionToken) {
428
460
  throw new Error('The Pi harness requires proxyBaseUrl and sessionToken')