@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,90 @@
1
+ import type { Logger } from './logger.js';
2
+ /**
3
+ * Every built-in tool a run asks the CLI for, and the ONE value that rides both `--tools` and the
4
+ * `--allowedTools` re-grant.
5
+ *
6
+ * One value rather than two derived ones, because the allow-list turned out to be ADDITIVE rather
7
+ * than inert: a name in it is UNLOCKED, not merely re-permitted (measured: `--allowedTools
8
+ * "Bash,Grep"` yields the default set PLUS `Glob` and `Grep`). Two independently-computed lists
9
+ * would therefore not merely disagree, they would silently re-grant what the other withheld.
10
+ *
11
+ * Deliberately OVER-inclusive, and safe to be, because a name the build does not have is dropped
12
+ * silently rather than refused. The cost is one-directional: a name the CLI HAS and this list
13
+ * LACKS is a capability silently removed from every run. So the list is measured against the
14
+ * headless default it replaces, not only against what was wanted, and when the CLI gains a tool a
15
+ * container agent can use, it is added here.
16
+ *
17
+ * A name 2.1.246 does not serve is kept for one of two measured reasons, and they are different
18
+ * facts worth keeping apart (each probed alone, reading the `init` event's own `tools` array):
19
+ *
20
+ * - ALIASED onto a successor, so the old spelling still buys the capability: `BashOutput` grants
21
+ * `TaskOutput`, `KillBash` and `KillShell` both grant `TaskStop`, `Agent` grants `Task`.
22
+ * - DROPPED outright (`ListMcpResources`, `ReadMcpResource`, `MultiEdit`, `NotebookRead`,
23
+ * `TodoWrite`), and kept only because the harness image is pinned per workspace, so one build
24
+ * of this source faces several CLI versions and an older one still serves them.
25
+ *
26
+ * The second category is why the CURRENT spelling has to be listed beside the old one rather than
27
+ * instead of it: `ListMcpResources`/`ReadMcpResource` were carried alone, and since neither is an
28
+ * alias, every tool-server run reached its resources through nothing at all.
29
+ *
30
+ * `WebSearch`/`WebFetch` are unconditional, which is a deliberate reversal of the first cut of this
31
+ * module. They were gated on the job's `webSearch` flag, which states whether OUR PROXY can serve
32
+ * web research for the run's account (see `resolveWebSearchAvailability`, whose whole rationale is
33
+ * that Pi's proxy-backed tools "would just fail/return nothing" without a key). The CLI's web tools
34
+ * are not proxy-backed: the vendor the leased subscription already pays serves them, and they work
35
+ * on a deployment with no search provider wired at all. Gating them on that flag therefore withheld
36
+ * a WORKING capability on the strength of an unrelated fact, which is the opposite of the
37
+ * pass-through an unwired capability owes. The flag that would legitimately withhold them is a
38
+ * per-run "may this run reach the web" POLICY, which this platform does not have today; when it
39
+ * gains one, it gates here and on the Pi path together.
40
+ */
41
+ export declare const CLAUDE_TOOL_SET: readonly string[];
42
+ /**
43
+ * One capability the run genuinely cannot do without, and every CLI spelling that satisfies it.
44
+ *
45
+ * The floor is expressed as CAPABILITIES rather than as names because {@link CLAUDE_TOOL_SET}
46
+ * is over-inclusive on purpose: a literal "warn on anything requested but absent" would fire on
47
+ * every single run for the alternate spellings this image carries for other CLI versions, and a
48
+ * warning that is always on is one nobody reads. A capability with no granted spelling is the
49
+ * fact worth a line: it means an upstream rename or removal took a tool out of every run of this
50
+ * image, which otherwise surfaces days later as an agent behaving oddly.
51
+ */
52
+ interface ClaudeToolCapability {
53
+ capability: string;
54
+ spellings: readonly string[];
55
+ }
56
+ /** The floor, exported so a test can assert every spelling is one this harness actually asks for. */
57
+ export declare const CLAUDE_TOOL_CAPABILITIES: readonly ClaudeToolCapability[];
58
+ /**
59
+ * The `claude` argv for one run, in the order the CLI's variadic flags require.
60
+ *
61
+ * `--tools` and `--allowedTools` are both declared `<tools...>`, so each swallows any trailing
62
+ * POSITIONAL argument as another tool name; only a following `--flag` terminates them. The prompt
63
+ * therefore stays on stdin (see `streamCli`) and every flag here is placed before the variadic
64
+ * pair or introduced by its own `--`, so a new flag cannot be eaten by the one in front of it.
65
+ */
66
+ export declare function claudeCliArgs(opts: {
67
+ model: string;
68
+ /** The built-in tools this run asks for; see {@link CLAUDE_TOOL_SET}. */
69
+ tools: readonly string[];
70
+ /** `--mcp-config` + `--strict-mcp-config` + any `--allowedTools`; empty when no server is wired. */
71
+ mcpArgs: readonly string[];
72
+ /** `--append-system-prompt <prompt>`, or empty when the prompt was folded into stdin. */
73
+ appendArgs: readonly string[];
74
+ }): string[];
75
+ /**
76
+ * Read the CLI's startup report (`{"type":"system","subtype":"init"}`) back against what this run
77
+ * asked for, and say when a required capability is missing.
78
+ *
79
+ * The same pairing, and for the same reason, as `assertOnboardingKeysCurrent`: the CLI is the only
80
+ * one who knows what it granted, it says so exactly once before the first model call, and pairing
81
+ * that answer with the CLI version is what makes an upstream rename diffable instead of a mystery.
82
+ * The version comes off the event itself (`claude_code_version`) rather than an env var the image
83
+ * would have to remember to bake.
84
+ *
85
+ * Best-effort and never throws: a run whose tool surface is short is still a run, and the honest
86
+ * disposition for a floor this image cannot verify is to SAY it could not be read, not to fail the
87
+ * job and not to stay silent (which reads exactly like a satisfied request).
88
+ */
89
+ export declare function assertClaudeToolsCurrent(event: Record<string, unknown>, requested: readonly string[], log: Logger | undefined): void;
90
+ export {};
@@ -0,0 +1,181 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The Claude Code CLI's INVOCATION surface: which of its built-in tools a run asks for, the argv
3
+ // that asks, and the read-back that says what the CLI actually granted.
4
+ //
5
+ // The three belong together because they are one decision seen from three sides. Before this
6
+ // module the harness declared nothing and took whatever the CLI's headless default happened to
7
+ // be, which drifted across CLI versions: 2.1.226 offered the plan tools, 2.1.245 did not, and
8
+ // nothing in the run said so. Both halves of that default were wrong for a disposable container:
9
+ // no `Grep`/`Glob` (so every search went through `Bash` and counted against the progress guard's
10
+ // no-edit budget), no plan tools (so `step.progress` had no signal to lift), and a dozen tools
11
+ // (`CronCreate`, `DesignSync`, `EnterWorktree`, `ScheduleWakeup`, `SendMessage`, `Workflow`,
12
+ // `ReportFindings`, …) an agent in a per-run container can act on none of.
13
+ //
14
+ // Declaring a set therefore has to be measured against the default it replaces, not just against
15
+ // the issue that asked for it: everything the default carried and a container CAN use has to be
16
+ // asked for by name, or the declaration is itself a capability loss. That is what `Monitor` and
17
+ // the web tools are doing in the list below.
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * Every built-in tool a run asks the CLI for, and the ONE value that rides both `--tools` and the
21
+ * `--allowedTools` re-grant.
22
+ *
23
+ * One value rather than two derived ones, because the allow-list turned out to be ADDITIVE rather
24
+ * than inert: a name in it is UNLOCKED, not merely re-permitted (measured: `--allowedTools
25
+ * "Bash,Grep"` yields the default set PLUS `Glob` and `Grep`). Two independently-computed lists
26
+ * would therefore not merely disagree, they would silently re-grant what the other withheld.
27
+ *
28
+ * Deliberately OVER-inclusive, and safe to be, because a name the build does not have is dropped
29
+ * silently rather than refused. The cost is one-directional: a name the CLI HAS and this list
30
+ * LACKS is a capability silently removed from every run. So the list is measured against the
31
+ * headless default it replaces, not only against what was wanted, and when the CLI gains a tool a
32
+ * container agent can use, it is added here.
33
+ *
34
+ * A name 2.1.246 does not serve is kept for one of two measured reasons, and they are different
35
+ * facts worth keeping apart (each probed alone, reading the `init` event's own `tools` array):
36
+ *
37
+ * - ALIASED onto a successor, so the old spelling still buys the capability: `BashOutput` grants
38
+ * `TaskOutput`, `KillBash` and `KillShell` both grant `TaskStop`, `Agent` grants `Task`.
39
+ * - DROPPED outright (`ListMcpResources`, `ReadMcpResource`, `MultiEdit`, `NotebookRead`,
40
+ * `TodoWrite`), and kept only because the harness image is pinned per workspace, so one build
41
+ * of this source faces several CLI versions and an older one still serves them.
42
+ *
43
+ * The second category is why the CURRENT spelling has to be listed beside the old one rather than
44
+ * instead of it: `ListMcpResources`/`ReadMcpResource` were carried alone, and since neither is an
45
+ * alias, every tool-server run reached its resources through nothing at all.
46
+ *
47
+ * `WebSearch`/`WebFetch` are unconditional, which is a deliberate reversal of the first cut of this
48
+ * module. They were gated on the job's `webSearch` flag, which states whether OUR PROXY can serve
49
+ * web research for the run's account (see `resolveWebSearchAvailability`, whose whole rationale is
50
+ * that Pi's proxy-backed tools "would just fail/return nothing" without a key). The CLI's web tools
51
+ * are not proxy-backed: the vendor the leased subscription already pays serves them, and they work
52
+ * on a deployment with no search provider wired at all. Gating them on that flag therefore withheld
53
+ * a WORKING capability on the strength of an unrelated fact, which is the opposite of the
54
+ * pass-through an unwired capability owes. The flag that would legitimately withhold them is a
55
+ * per-run "may this run reach the web" POLICY, which this platform does not have today; when it
56
+ * gains one, it gates here and on the Pi path together.
57
+ */
58
+ export const CLAUDE_TOOL_SET = [
59
+ 'Agent',
60
+ 'Bash',
61
+ 'BashOutput',
62
+ 'Edit',
63
+ 'Glob',
64
+ 'Grep',
65
+ 'KillBash',
66
+ 'KillShell',
67
+ 'ListMcpResources',
68
+ 'ListMcpResourcesTool',
69
+ // Waits on the background shells `Bash(run_in_background)` starts. A tool in its own right
70
+ // (measured: `Monitor` grants `Monitor`), not an alias of the retired kill/output pair, and it
71
+ // is in the headless default, so omitting it was this declaration's own capability loss.
72
+ 'Monitor',
73
+ 'MultiEdit',
74
+ 'NotebookEdit',
75
+ 'NotebookRead',
76
+ 'Read',
77
+ 'ReadMcpResource',
78
+ 'ReadMcpResourceTool',
79
+ 'Skill',
80
+ 'Task',
81
+ 'TaskCreate',
82
+ 'TaskGet',
83
+ 'TaskList',
84
+ 'TaskOutput',
85
+ 'TaskStop',
86
+ 'TaskUpdate',
87
+ 'TodoWrite',
88
+ // Loads the schemas of tools a build defers rather than declaring up front. Dropped by 2.1.246
89
+ // (measured, with and without a tool server wired), and asked for anyway under the
90
+ // over-inclusive rule: a run wiring several tool servers is exactly the shape a build that
91
+ // defers tool schemas would hand one to.
92
+ 'ToolSearch',
93
+ 'WebFetch',
94
+ 'WebSearch',
95
+ 'Write',
96
+ ];
97
+ const CLAUDE_TOOL_FLOOR = [
98
+ { capability: 'shell', spellings: ['Bash'] },
99
+ { capability: 'read', spellings: ['Read'] },
100
+ { capability: 'write', spellings: ['Write'] },
101
+ { capability: 'edit', spellings: ['Edit', 'MultiEdit'] },
102
+ { capability: 'search', spellings: ['Grep'] },
103
+ { capability: 'glob', spellings: ['Glob'] },
104
+ { capability: 'subagents', spellings: ['Task', 'Agent'] },
105
+ // The plan signal the harness lifts into `step.subtasks` / `step.progress`, in the two
106
+ // vocabularies the CLI has used for it (see `progress.ts`, which reads both).
107
+ { capability: 'plan', spellings: ['TaskCreate', 'TodoWrite'] },
108
+ ];
109
+ /** The floor, exported so a test can assert every spelling is one this harness actually asks for. */
110
+ export const CLAUDE_TOOL_CAPABILITIES = CLAUDE_TOOL_FLOOR;
111
+ /**
112
+ * The `claude` argv for one run, in the order the CLI's variadic flags require.
113
+ *
114
+ * `--tools` and `--allowedTools` are both declared `<tools...>`, so each swallows any trailing
115
+ * POSITIONAL argument as another tool name; only a following `--flag` terminates them. The prompt
116
+ * therefore stays on stdin (see `streamCli`) and every flag here is placed before the variadic
117
+ * pair or introduced by its own `--`, so a new flag cannot be eaten by the one in front of it.
118
+ */
119
+ export function claudeCliArgs(opts) {
120
+ return [
121
+ '-p',
122
+ '--output-format',
123
+ 'stream-json',
124
+ '--verbose',
125
+ // The per-run container IS the sandbox, and the run is fully headless (no one to approve a
126
+ // tool call) — so bypass permissions entirely. `acceptEdits` would auto-accept file edits but
127
+ // still gate Bash, which in `-p` mode is then denied, leaving the agent unable to run
128
+ // builds/tests/git to verify its work.
129
+ '--permission-mode',
130
+ 'bypassPermissions',
131
+ '--model',
132
+ opts.model,
133
+ // Declared rather than defaulted: see this module's header for what the default set costs.
134
+ '--tools',
135
+ opts.tools.join(','),
136
+ ...opts.mcpArgs,
137
+ ...opts.appendArgs,
138
+ ];
139
+ }
140
+ /**
141
+ * Read the CLI's startup report (`{"type":"system","subtype":"init"}`) back against what this run
142
+ * asked for, and say when a required capability is missing.
143
+ *
144
+ * The same pairing, and for the same reason, as `assertOnboardingKeysCurrent`: the CLI is the only
145
+ * one who knows what it granted, it says so exactly once before the first model call, and pairing
146
+ * that answer with the CLI version is what makes an upstream rename diffable instead of a mystery.
147
+ * The version comes off the event itself (`claude_code_version`) rather than an env var the image
148
+ * would have to remember to bake.
149
+ *
150
+ * Best-effort and never throws: a run whose tool surface is short is still a run, and the honest
151
+ * disposition for a floor this image cannot verify is to SAY it could not be read, not to fail the
152
+ * job and not to stay silent (which reads exactly like a satisfied request).
153
+ */
154
+ export function assertClaudeToolsCurrent(event, requested, log) {
155
+ if (!log || event.type !== 'system' || event.subtype !== 'init')
156
+ return;
157
+ const version = typeof event.claude_code_version === 'string' ? event.claude_code_version : undefined;
158
+ const cliVersion = version ? { cliVersion: version } : {};
159
+ if (!Array.isArray(event.tools)) {
160
+ log.warn('claude-code announced no tool list, so this run has an unverified tool surface', {
161
+ requestedTools: [...requested],
162
+ ...cliVersion,
163
+ });
164
+ return;
165
+ }
166
+ const granted = new Set(event.tools.filter((t) => typeof t === 'string'));
167
+ const missing = CLAUDE_TOOL_FLOOR.filter((c) => !c.spellings.some((s) => granted.has(s))).map((c) => c.capability);
168
+ const fields = {
169
+ requestedTools: [...requested],
170
+ grantedTools: [...granted].sort(),
171
+ ...cliVersion,
172
+ };
173
+ if (missing.length > 0) {
174
+ log.warn('claude-code granted no tool for a capability this run requires', {
175
+ ...fields,
176
+ missingCapabilities: missing,
177
+ });
178
+ return;
179
+ }
180
+ log.info('claude-code tool set granted', fields);
181
+ }
@@ -0,0 +1,41 @@
1
+ import { type McpServerSpec, type SkillSpec } from './agent-capabilities.js';
2
+ import type { Logger } from './logger.js';
3
+ /** What one claude-code job needs written into its own home. */
4
+ export interface ClaudeHomeOptions {
5
+ /** The decrypted subscription OAuth token. Required unless `ambientAuth`. */
6
+ subscriptionToken?: string;
7
+ /**
8
+ * Anthropic-compatible base URL for a non-Anthropic Claude-Code vendor (GLM/Kimi): present ⇒
9
+ * ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN, absent ⇒ CLAUDE_CODE_OAUTH_TOKEN.
10
+ */
11
+ subscriptionBaseUrl?: string;
12
+ /** Run the developer's own CLI login instead: no isolated home, nothing installed. */
13
+ ambientAuth?: boolean;
14
+ /** Skills to install natively under `<configHome>/skills/<name>/`. */
15
+ skills?: SkillSpec[];
16
+ /** Tool servers to scope to this job's config. */
17
+ mcpServers?: McpServerSpec[];
18
+ /** Job-scoped child env (tester secrets, a private-registry npmrc pointer). */
19
+ extraEnv?: Record<string, string>;
20
+ log?: Logger;
21
+ }
22
+ /**
23
+ * The isolated, per-run home the `claude` CLI runs against: a temp config dir OUTSIDE the cloned
24
+ * checkout, pre-seeded past the first-launch prompts, carrying the run's native skills and MCP
25
+ * config, plus the child env pointing the CLI at it. {@link ClaudeRunHome.dispose} is the other
26
+ * half of the same concern — the leased credential must never outlive the run — so acquisition
27
+ * and teardown are defined together rather than split across a `finally` forty lines away.
28
+ *
29
+ * Ambient (native) mode has NO home: the developer's installed CLI uses its own `~/.claude`
30
+ * login, so nothing is created, nothing is pre-seeded, and `dispose` only clears the MCP config.
31
+ */
32
+ export interface ClaudeRunHome {
33
+ /** The per-run config dir; `undefined` in ambient mode (the developer's own login is used). */
34
+ configHome: string | undefined;
35
+ /** The CLI argv selecting the run's tool servers; empty when it has none. */
36
+ mcpArgs: string[];
37
+ /** The child-process env (see {@link buildClaudeEnv}). */
38
+ env: Record<string, string>;
39
+ dispose: () => Promise<void>;
40
+ }
41
+ export declare function openClaudeRunHome(opts: ClaudeHomeOptions, tools: readonly string[]): Promise<ClaudeRunHome>;
@@ -0,0 +1,159 @@
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 { claudeAllowedToolPatterns, mcpServerSecretValues, writeClaudeMcpConfig, } from './agent-capabilities.js';
5
+ import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
6
+ import { registerKnownSecrets } from './redact.js';
7
+ import { retainSessionTranscripts } from './transcript-retention.js';
8
+ /**
9
+ * Write a repo-sourced skill as a NATIVE Claude Code skill under `<skillsRoot>/<name>/`: a
10
+ * `SKILL.md` (YAML frontmatter `name`/`description` + the instructions body, the format the CLI
11
+ * expects) plus every resource file at its path within the skill directory. Resource sub-paths
12
+ * were sanitized at the job boundary (no traversal), so nested dirs are created as needed.
13
+ *
14
+ * The frontmatter `name`/`description` values are emitted as JSON-encoded (double-quoted) YAML
15
+ * scalars, not bare plain scalars: an author's description routinely contains `: ` (colon-space)
16
+ * or a leading YAML indicator (`#`, `-`, `[`, `{`, `"`, …), which is invalid as a plain scalar and
17
+ * would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
18
+ * valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
19
+ */
20
+ async function writeNativeSkill(skillsRoot, skill) {
21
+ const dir = join(skillsRoot, skill.name);
22
+ await mkdir(dir, { recursive: true });
23
+ const name = JSON.stringify(skill.name);
24
+ const description = JSON.stringify(skill.description.replace(/\r?\n/g, ' '));
25
+ const frontmatter = `---\nname: ${name}\ndescription: ${description}\n---\n`;
26
+ await writeFile(join(dir, 'SKILL.md'), `${frontmatter}\n${skill.instructions}\n`, 'utf8');
27
+ for (const resource of skill.resources) {
28
+ const dest = join(dir, resource.relPath);
29
+ await mkdir(dirname(dest), { recursive: true });
30
+ await writeFile(dest, resource.content, 'utf8');
31
+ }
32
+ }
33
+ /**
34
+ * Prepare the Claude Code CLI's MCP wiring for one run: write the servers to a PER-RUN config and
35
+ * return the argv that points the CLI at it, plus the cleanup for a directory we had to mint.
36
+ *
37
+ * Two decisions live here. `--strict-mcp-config` makes that file the ONLY source of servers, so an
38
+ * ambient run on a developer's own machine can never silently hand the agent their personal ones.
39
+ * And `--allowedTools` is passed ONLY when a server actually narrows its tools — an allow-list is
40
+ * whole-session, not MCP-scoped, so it carries `builtIns`, the SAME list this run declared with
41
+ * `--tools`, in the same entry; see `claudeAllowedToolPatterns` for why that list is threaded in
42
+ * rather than re-derived, and how the run's permission mode treats an allow-list.
43
+ *
44
+ * The config carries this job's resolved credentials, so it goes in the isolated config home when
45
+ * we own one and a throwaway per-JOB directory otherwise — never the checkout (it would land in a
46
+ * commit) and never a shared HOME path (a concurrent job would clobber it).
47
+ */
48
+ async function setUpClaudeMcp(servers, configHome, builtIns) {
49
+ const noop = { args: [], cleanup: async () => { } };
50
+ if (!servers?.length)
51
+ return noop;
52
+ // Before anything can spawn: a failing MCP server echoes its own argv/headers into stderr, and
53
+ // that tail is carried onto the step's diagnostics.
54
+ registerKnownSecrets(mcpServerSecretValues(servers));
55
+ const home = configHome ?? (await mkdtemp(join(tmpdir(), 'cf-claude-mcp-')));
56
+ const owned = home === configHome ? undefined : home;
57
+ const cleanup = async () => {
58
+ if (owned)
59
+ await rm(owned, { recursive: true, force: true }).catch(() => { });
60
+ };
61
+ const configPath = await writeClaudeMcpConfig(home, servers);
62
+ if (!configPath)
63
+ return { args: [], cleanup };
64
+ const allowedTools = claudeAllowedToolPatterns(servers, builtIns);
65
+ return {
66
+ args: [
67
+ '--mcp-config',
68
+ configPath,
69
+ '--strict-mcp-config',
70
+ ...(allowedTools?.length ? ['--allowedTools', allowedTools.join(',')] : []),
71
+ ],
72
+ cleanup,
73
+ };
74
+ }
75
+ export async function openClaudeRunHome(opts, tools) {
76
+ // Native (ambient) mode: run the developer's installed `claude` with its OWN login —
77
+ // no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
78
+ // Claude Code persists user config/credentials under its config dir; point that at an
79
+ // isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
80
+ // agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
81
+ // stray `.claude/` directory — and any cached credential in it — into the pushed branch.
82
+ // Mirrors the Codex CODEX_HOME isolation (`codex-home.ts`); removed by `dispose`.
83
+ if (!opts.ambientAuth && !opts.subscriptionToken) {
84
+ throw new Error('claude-code harness requires a subscription token (or ambientAuth)');
85
+ }
86
+ const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'));
87
+ // The config dir is brand-new every run, so Claude Code would otherwise treat this
88
+ // as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
89
+ // bypass-permissions acknowledgement prompts — which never get answered headlessly,
90
+ // hanging the job until the watchdog kills it. Pre-seed the config that marks those
91
+ // as already accepted so `-p` starts straight into the run. Best-effort: written
92
+ // before the CLI starts; unknown keys are harmless if a CLI version ignores them.
93
+ // (Ambient mode skips this — the developer's own config is already onboarded.)
94
+ // ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
95
+ // version, so a future first-run gate this set doesn't cover (which looks identical to
96
+ // a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
97
+ if (configHome) {
98
+ await writeOnboardingPreseed(configHome);
99
+ await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
100
+ }
101
+ // Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
102
+ // discovers and can invoke it. ONLY into the isolated per-run config home — never the
103
+ // developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
104
+ // setup after the run and two concurrent jobs carrying same-named skills would clobber each
105
+ // other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
106
+ // materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
107
+ // still names the skills.
108
+ if (configHome) {
109
+ for (const skill of opts.skills ?? []) {
110
+ await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => { });
111
+ }
112
+ }
113
+ // Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
114
+ // one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
115
+ const mcp = await setUpClaudeMcp(opts.mcpServers, configHome, tools);
116
+ return {
117
+ configHome,
118
+ mcpArgs: mcp.args,
119
+ env: buildClaudeEnv(opts, configHome),
120
+ dispose: async () => {
121
+ // The ambient-mode MCP config dir (credential-bearing) never outlives the run.
122
+ await mcp.cleanup();
123
+ if (!configHome)
124
+ return;
125
+ // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
126
+ // home is deleted — the credential lives at the home root, never in `projects/`, so this
127
+ // keeps the debugging artifact without leaking the token. Best-effort; never throws.
128
+ await retainSessionTranscripts(configHome, ['projects'], {
129
+ label: 'claude-code',
130
+ ...(opts.log ? { log: opts.log } : {}),
131
+ });
132
+ // Never leave the config dir (and any cached credential) on disk past the run.
133
+ await rm(configHome, { recursive: true, force: true }).catch(() => { });
134
+ },
135
+ };
136
+ }
137
+ /**
138
+ * Build the child-process env for the `claude` CLI: an isolated config home plus subscription
139
+ * auth (Anthropic OAuth token, or an Anthropic-compatible base URL + auth token for a
140
+ * non-Anthropic Claude-Code vendor like GLM/Kimi/DeepSeek), or an empty env in ambient mode
141
+ * (the developer's own logged-in `~/.claude` is used). Extracted from {@link runClaudeCode} to
142
+ * keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
143
+ */
144
+ function buildClaudeEnv(opts, configHome) {
145
+ // The job-scoped env rides along in BOTH modes; the credential/config vars below are what
146
+ // ambient mode drops (the developer's own logged-in `~/.claude` is used instead).
147
+ if (opts.ambientAuth)
148
+ return { ...opts.extraEnv };
149
+ return {
150
+ ...opts.extraEnv,
151
+ CLAUDE_CONFIG_DIR: configHome,
152
+ ...(opts.subscriptionBaseUrl
153
+ ? {
154
+ ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
155
+ ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken,
156
+ }
157
+ : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
158
+ };
159
+ }
@@ -8,6 +8,7 @@ import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription } from './pr
8
8
  import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js';
9
9
  import { log } from './logger.js';
10
10
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
11
+ import { agentCapabilities } from './agent-shared.js';
11
12
  import { resolvePrTemplateNote, withPrTemplateNote, } from './pr-template.js';
12
13
  import { noChangesReason } from './coding-agent.js';
13
14
  /**
@@ -133,16 +134,13 @@ export async function runMultiRepoCoding(job, opts = {}) {
133
134
  proxyBaseUrl: job.proxyBaseUrl,
134
135
  proxyPhasePath: job.proxyPhasePath,
135
136
  sessionToken: job.sessionToken,
136
- webToolsGuidance: job.webToolsGuidance,
137
- webSearchProxy: job.webSearch,
138
137
  guardLimits: job.guardLimits,
139
138
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
140
- // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
141
- // are properties of the AGENT KIND, not of the checkout layout.
142
- ...(job.skills?.length ? { skills: job.skills } : {}),
143
- ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
144
- ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
145
- ...(job.designImages ? { designImages: job.designImages } : {}),
139
+ // Skills, tool servers and web research apply to a multi-repo run exactly as to a
140
+ // single-repo one: they are properties of the AGENT KIND, not of the checkout layout.
141
+ // Through the shared helper rather than re-spread here, which is what let this flow
142
+ // drift from the single-repo one in the first place.
143
+ ...agentCapabilities(job),
146
144
  multiRepo: true,
147
145
  // What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
148
146
  repoDirs: probeDirsForLegs(legs),