@cat-factory/executor-harness 1.64.4 → 1.68.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 CHANGED
@@ -88,6 +88,36 @@ Bootstrap differs at the ends — it may start from an empty dir, and **resets
88
88
  history to one commit and force-pushes** the default branch instead of opening a
89
89
  PR. Blueprint **commits onto a branch** (no history reset) and returns the tree.
90
90
 
91
+ ### Skills and tool servers
92
+
93
+ A job body may carry `skills[]` (procedural playbooks) and `mcpServers[]` (MCP tool servers) — the
94
+ harness MATERIALISES both and decides nothing about them; the backend has already resolved which
95
+ apply and dropped what this harness cannot serve (see
96
+ [`backend/docs/adr/0029-agent-kind-capabilities.md`](../../docs/adr/0029-agent-kind-capabilities.md)).
97
+
98
+ - **Skills** install natively under `CLAUDE_CONFIG_DIR/skills/<name>/` for a leased-credential
99
+ claude-code run (the CLI discovers and invokes them), and under
100
+ `.cat-context/skill/<name>/` in the checkout for Pi, Codex, and an AMBIENT claude-code run —
101
+ whose prompt carries the instructions instead, because there is no isolated config home to
102
+ install into and the runner refuses to write into the developer's own `~/.claude`.
103
+ - **Tool servers** become a per-run `--mcp-config` file plus `--strict-mcp-config` for claude-code
104
+ (so an ambient run never picks up the developer's personal servers), and `[mcp_servers.*]` blocks
105
+ in the per-run `CODEX_HOME/config.toml` for Codex — stdio only, and skipped entirely under
106
+ ambient auth, which has no per-run home to write into. `--allowedTools` is passed ONLY when a
107
+ server actually narrows its tools, and then carries the CLI's built-in tool names alongside the
108
+ `mcp__*` patterns — an allow-list is whole-session, not MCP-scoped, so a bare list of MCP
109
+ patterns would leave the agent unable to read, edit or build anything. Whether the CLI gates on
110
+ that list at all is permission-mode dependent, so treat the narrowing as scoping rather than
111
+ enforcement; the prompt states it either way.
112
+ - **An `http` tool server must be `https`, or loopback.** Its headers carry a resolved credential,
113
+ so the job boundary refuses a cleartext off-box URL (the backend refuses the same at
114
+ registration). `secretKeys` names which `env`/`headers` entries are credentials, so exactly those
115
+ values are registered for redaction — scrubbing the whole map would turn ordinary config strings
116
+ into `***` in every later log line.
117
+
118
+ Both config files carry this job's resolved credentials, so they are written to a per-job directory
119
+ (mode `0600`) and never into the checkout or a HOME-global path — see the next section.
120
+
91
121
  ## Per-job state: never a process- or HOME-global
92
122
 
93
123
  A job's staging state (the tester's secrets, private-registry auth, a repo-sourced Claude
@@ -153,6 +183,9 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
153
183
  | `src/captured-command.ts` | The one way the harness runs a declared shell command on its own behalf: `sh -c` with a per-command watchdog, abort handling, conventional exit codes (124/127/130) and a scrub-then-bound output capture. Shared by both pre-PR verification phases so a fix to one cannot miss the other. |
154
184
  | `src/validation-checks.ts` | Pre-PR validation: runs the job's check commands in the checkout (bounded, secret-scrubbed capture, per-command watchdog) and drives the retry-until-green loop that gates the PR. Generic — keyed off the job body, never the agent kind. |
155
185
  | `src/reproduction-proof.ts` | Bugfix reproduction proof: runs the job's declared reproduction command against two symmetric fresh worktrees (the pre-fix tree and the final tree) and computes red-then-green from the exit codes, with a repair loop that never fails the run. Generic — keyed off the job body, never the agent kind. |
186
+ | `src/agent-capabilities.ts` | The agent CAPABILITIES a job body carries — the run's `skills` (a `SKILL.md` payload + resources) and its `mcpServers` (tool servers) — with their defensive parsing and the per-CLI config writers (`--mcp-config` JSON for claude-code, `[mcp_servers.*]` TOML for Codex). Backend-authored data the harness only MATERIALISES: adding a skill or a tool server is a backend registration, never a harness change. |
187
+ | `src/bootstrap-mode.ts` | The repo-bootstrap MODE: clone-a-reference-or-scaffold → run the agent → refuse to push an empty tree → reinit + force-push to the pre-created target repo. |
188
+ | `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
156
189
  | `src/logger.ts` | Structured logging. |
157
190
 
158
191
  ## Runner lifecycle knobs
@@ -0,0 +1,354 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ /**
4
+ * The credential values carried by a run's tool servers, for {@link registerKnownSecrets}. An MCP
5
+ * server that fails to start routinely echoes its own argv or request headers into stderr, and
6
+ * that tail reaches the step's diagnostics — so these have to be scrubbed exactly like the leased
7
+ * subscription token. Only the keys the backend MARKED as secret are read (see `secretKeys`).
8
+ */
9
+ export function mcpServerSecretValues(servers) {
10
+ const values = [];
11
+ for (const server of servers) {
12
+ for (const key of server.secretKeys ?? []) {
13
+ const value = server.env?.[key] ?? server.headers?.[key];
14
+ if (value)
15
+ values.push(value);
16
+ }
17
+ }
18
+ return values;
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // Parsing (the job-body boundary)
22
+ // ---------------------------------------------------------------------------
23
+ /**
24
+ * Sanitize a skill resource's relative path: keep the subdirectory structure (so
25
+ * `templates/report.md` materialises nested) but reject anything that could escape the skill
26
+ * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
27
+ * for an unsafe path (the resource is then dropped).
28
+ */
29
+ function sanitizeSkillRelPath(value) {
30
+ if (typeof value !== 'string')
31
+ return undefined;
32
+ const segments = value.replace(/\\/g, '/').split('/');
33
+ const clean = [];
34
+ for (const seg of segments) {
35
+ if (seg === '' || seg === '.')
36
+ continue;
37
+ if (seg === '..')
38
+ return undefined;
39
+ // Same character class as a context-file name, per segment.
40
+ const c = seg.replace(/[^A-Za-z0-9._-]/g, '');
41
+ if (!c || c === '.' || c === '..' || c.startsWith('.'))
42
+ return undefined;
43
+ clean.push(c);
44
+ }
45
+ return clean.length ? clean.join('/') : undefined;
46
+ }
47
+ /**
48
+ * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
49
+ * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
50
+ * default keeps the skill installable rather than dropping it — which, on the claude-code path,
51
+ * would leave the prompt pointing at a skill that was never installed (a blind run).
52
+ */
53
+ const FALLBACK_SKILL_NAME = 'skill';
54
+ /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
55
+ function sanitizeSkillName(value) {
56
+ if (typeof value !== 'string')
57
+ return undefined;
58
+ const base = value.replace(/\\/g, '/').split('/').pop() ?? '';
59
+ const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '');
60
+ if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.'))
61
+ return undefined;
62
+ return cleaned;
63
+ }
64
+ /** Validate one entry of the `skills` field, or undefined when malformed. */
65
+ function parseSkillSpec(value) {
66
+ if (typeof value !== 'object' || value === null)
67
+ return undefined;
68
+ const o = value;
69
+ const instructions = typeof o.instructions === 'string' ? o.instructions : undefined;
70
+ // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
71
+ // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
72
+ // directory, so fall back to a safe default rather than dropping the whole skill.
73
+ if (!instructions)
74
+ return undefined;
75
+ const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME;
76
+ const description = typeof o.description === 'string' ? o.description : '';
77
+ const resources = [];
78
+ if (Array.isArray(o.resources)) {
79
+ const used = new Set();
80
+ for (const entry of o.resources) {
81
+ if (typeof entry !== 'object' || entry === null)
82
+ continue;
83
+ const e = entry;
84
+ const relPath = sanitizeSkillRelPath(e.relPath);
85
+ if (!relPath || used.has(relPath))
86
+ continue;
87
+ if (typeof e.content !== 'string')
88
+ continue;
89
+ used.add(relPath);
90
+ resources.push({ relPath, content: e.content });
91
+ }
92
+ }
93
+ return { name, description, instructions, resources };
94
+ }
95
+ /**
96
+ * Validate the optional `skills` field. Names are de-duplicated: two skills sharing a directory
97
+ * name would overwrite each other's `SKILL.md`, leaving the agent pointed at whichever landed
98
+ * last — so the first wins and the collision is dropped rather than silently mixing two playbooks.
99
+ */
100
+ export function parseSkillSpecs(value) {
101
+ if (!Array.isArray(value))
102
+ return undefined;
103
+ const skills = [];
104
+ const used = new Set();
105
+ for (const entry of value) {
106
+ const skill = parseSkillSpec(entry);
107
+ if (!skill || used.has(skill.name))
108
+ continue;
109
+ used.add(skill.name);
110
+ skills.push(skill);
111
+ }
112
+ return skills.length ? skills : undefined;
113
+ }
114
+ /**
115
+ * A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
116
+ *
117
+ * Kept byte-identical to kernel's `MCP_SERVER_ID_PATTERN` (the harness image is built from `src/`
118
+ * plus typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
119
+ * against it by `test/agent-capabilities.conformity.test.ts` — the same copy-plus-pin arrangement
120
+ * `src/host-markdown.ts` uses.
121
+ */
122
+ export const MCP_SERVER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
123
+ function sanitizeServerId(value) {
124
+ if (typeof value !== 'string')
125
+ return undefined;
126
+ return MCP_SERVER_ID_PATTERN.test(value) ? value : undefined;
127
+ }
128
+ /**
129
+ * Whether an HTTP tool server's URL may be started. Mirrors kernel's `isAllowedMcpHttpUrl` (see
130
+ * {@link MCP_SERVER_ID_PATTERN} for why it is a copy, and the conformity suite that pins it):
131
+ * `https` anywhere, plain `http` only on loopback, since the headers carry a resolved credential.
132
+ * The backend refuses the same URLs at registration — this is the boundary check, so a body that
133
+ * reached the container by any other route is held to the rule too.
134
+ */
135
+ export function isAllowedMcpHttpUrl(raw) {
136
+ const match = /^(https?):\/\/([^/?#]*)/i.exec(raw);
137
+ if (!match)
138
+ return false;
139
+ if (match[1].toLowerCase() === 'https')
140
+ return true;
141
+ // Plain http from here: the host must be loopback. Strip userinfo FIRST and from the LAST `@`,
142
+ // or `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example.
143
+ const authority = match[2];
144
+ const hostPort = authority.slice(authority.lastIndexOf('@') + 1);
145
+ const closingBracket = hostPort.indexOf(']');
146
+ const host = (hostPort.startsWith('[') && closingBracket !== -1
147
+ ? hostPort.slice(1, closingBracket) // IPv6 literal, e.g. [::1]:8080
148
+ : (hostPort.split(':')[0] ?? '')).toLowerCase();
149
+ return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host);
150
+ }
151
+ /** A string→string record, dropping any non-string entry. Undefined when nothing survives. */
152
+ function parseStringRecord(value) {
153
+ if (typeof value !== 'object' || value === null)
154
+ return undefined;
155
+ const out = {};
156
+ for (const [key, raw] of Object.entries(value)) {
157
+ if (typeof raw === 'string')
158
+ out[key] = raw;
159
+ }
160
+ return Object.keys(out).length ? out : undefined;
161
+ }
162
+ /** A string array, dropping non-string entries. Undefined when nothing survives. */
163
+ function parseStringArray(value) {
164
+ if (!Array.isArray(value))
165
+ return undefined;
166
+ const out = value.filter((v) => typeof v === 'string');
167
+ return out.length ? out : undefined;
168
+ }
169
+ /** Validate one `mcpServers` entry, or undefined when malformed for its transport. */
170
+ function parseMcpServerSpec(value) {
171
+ if (typeof value !== 'object' || value === null)
172
+ return undefined;
173
+ const o = value;
174
+ const id = sanitizeServerId(o.id);
175
+ if (!id)
176
+ return undefined;
177
+ const allowedTools = parseStringArray(o.allowedTools);
178
+ const secretKeys = parseStringArray(o.secretKeys);
179
+ if (o.transport === 'http') {
180
+ // https anywhere, plain http only on loopback: the CLI would happily be pointed at a
181
+ // `file:`/`ws:` URL, and the headers below carry this job's resolved credential.
182
+ const url = typeof o.url === 'string' && isAllowedMcpHttpUrl(o.url) ? o.url : undefined;
183
+ if (!url)
184
+ return undefined;
185
+ const headers = parseStringRecord(o.headers);
186
+ return {
187
+ id,
188
+ transport: 'http',
189
+ url,
190
+ ...(headers ? { headers } : {}),
191
+ ...(allowedTools ? { allowedTools } : {}),
192
+ ...(secretKeys ? { secretKeys } : {}),
193
+ };
194
+ }
195
+ const command = typeof o.command === 'string' && o.command ? o.command : undefined;
196
+ if (!command)
197
+ return undefined;
198
+ const args = parseStringArray(o.args);
199
+ const env = parseStringRecord(o.env);
200
+ return {
201
+ id,
202
+ transport: 'stdio',
203
+ command,
204
+ ...(args ? { args } : {}),
205
+ ...(env ? { env } : {}),
206
+ ...(allowedTools ? { allowedTools } : {}),
207
+ ...(secretKeys ? { secretKeys } : {}),
208
+ };
209
+ }
210
+ /** Validate the optional `mcpServers` field, dropping malformed entries and duplicate ids. */
211
+ export function parseMcpServerSpecs(value) {
212
+ if (!Array.isArray(value))
213
+ return undefined;
214
+ const servers = [];
215
+ const used = new Set();
216
+ for (const entry of value) {
217
+ const server = parseMcpServerSpec(entry);
218
+ if (!server || used.has(server.id))
219
+ continue;
220
+ used.add(server.id);
221
+ servers.push(server);
222
+ }
223
+ return servers.length ? servers : undefined;
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Materialisation (per CLI)
227
+ // ---------------------------------------------------------------------------
228
+ /**
229
+ * The `--mcp-config` document Claude Code reads: `{ "mcpServers": { "<id>": {...} } }`. An `http`
230
+ * server declares `type: "http"` with its headers; a `stdio` one declares its command/args/env.
231
+ */
232
+ export function claudeMcpConfig(servers) {
233
+ const mcpServers = {};
234
+ for (const server of servers) {
235
+ mcpServers[server.id] =
236
+ server.transport === 'http'
237
+ ? { type: 'http', url: server.url, ...(server.headers ? { headers: server.headers } : {}) }
238
+ : {
239
+ type: 'stdio',
240
+ command: server.command,
241
+ ...(server.args ? { args: server.args } : {}),
242
+ ...(server.env ? { env: server.env } : {}),
243
+ };
244
+ }
245
+ return { mcpServers };
246
+ }
247
+ /**
248
+ * The claude-code CLI's own tools, named so an `--allowedTools` list can never take them away.
249
+ *
250
+ * An allow-list is whole-session: it does not scope itself to MCP just because every entry we
251
+ * generate happens to be an `mcp__*` pattern. So the moment one tool server narrows its tools, the
252
+ * list has to re-grant the agent's built-in file/bash/search tools or the run is handed a narrowed
253
+ * MCP surface AND no way to read, edit or build anything.
254
+ *
255
+ * Bias this list toward OVER-inclusion. A name the CLI does not have is inert; a name it has and
256
+ * this list lacks is a tool silently removed from a run — which surfaces as an agent that cannot
257
+ * do its work, far from the registration that caused it. Historical/renamed spellings are kept for
258
+ * the same reason: the harness image is pinned per workspace, so one image faces several CLI
259
+ * versions. When the CLI gains a tool, add it here.
260
+ */
261
+ export const CLAUDE_BUILT_IN_TOOLS = [
262
+ 'Agent',
263
+ 'Bash',
264
+ 'BashOutput',
265
+ 'Edit',
266
+ 'ExitPlanMode',
267
+ 'Glob',
268
+ 'Grep',
269
+ 'KillBash',
270
+ 'KillShell',
271
+ 'ListMcpResources',
272
+ 'MultiEdit',
273
+ 'NotebookEdit',
274
+ 'NotebookRead',
275
+ 'Read',
276
+ 'ReadMcpResource',
277
+ 'SlashCommand',
278
+ 'Skill',
279
+ 'Task',
280
+ 'TaskCreate',
281
+ 'TaskUpdate',
282
+ 'TodoWrite',
283
+ 'WebFetch',
284
+ 'WebSearch',
285
+ 'Write',
286
+ ];
287
+ /**
288
+ * The tool-name list for `--allowedTools`: every declared server's tools in the CLI's
289
+ * `mcp__<server>__<tool>` convention, PLUS {@link CLAUDE_BUILT_IN_TOOLS}. A server with no
290
+ * restriction contributes the whole-server pattern, so an allow-list stays one entry per server.
291
+ *
292
+ * Returns undefined when NO server restricts its tools — there is then nothing to narrow, and the
293
+ * safest list is the one we never send.
294
+ *
295
+ * Whether the CLI ENFORCES this list is permission-mode dependent and not a contract we control:
296
+ * the run uses `--permission-mode bypassPermissions` (the container is the sandbox and no human is
297
+ * there to approve a call), under which an allow-list grants rather than gates. So this is written
298
+ * to be correct under BOTH readings — if the list gates, the narrowing is real and the built-ins
299
+ * survive it; if it is inert, sending it costs nothing. The always-present channel is the PROMPT,
300
+ * which states each server's permitted tool names on every harness. Treat `allowedTools` as
301
+ * scoping, not as a security boundary: a server the agent must not reach fully should not be
302
+ * wired for that kind at all.
303
+ */
304
+ export function claudeAllowedToolPatterns(servers) {
305
+ if (!servers.some((s) => s.allowedTools?.length))
306
+ return undefined;
307
+ const mcp = servers.flatMap((s) => s.allowedTools?.length ? s.allowedTools.map((t) => `mcp__${s.id}__${t}`) : [`mcp__${s.id}`]);
308
+ return [...mcp, ...CLAUDE_BUILT_IN_TOOLS];
309
+ }
310
+ /** Escape a string as a TOML basic string (Codex config is TOML, not JSON). */
311
+ function tomlString(value) {
312
+ return JSON.stringify(value);
313
+ }
314
+ /**
315
+ * The `[mcp_servers.<id>]` TOML block Codex reads from its `CODEX_HOME/config.toml`. Codex's MCP
316
+ * client is stdio-only, so an `http` server is skipped here — the backend states such a server as
317
+ * unavailable when it declares `harnesses: ['claude-code']`, and a deployment that wires an HTTP
318
+ * server for Codex gets a no-op rather than a malformed config.
319
+ */
320
+ export function codexMcpConfigToml(servers) {
321
+ const blocks = [];
322
+ for (const server of servers) {
323
+ if (server.transport !== 'stdio')
324
+ continue;
325
+ const lines = [`[mcp_servers.${server.id}]`, `command = ${tomlString(server.command)}`];
326
+ if (server.args?.length) {
327
+ lines.push(`args = [${server.args.map(tomlString).join(', ')}]`);
328
+ }
329
+ if (server.env) {
330
+ const entries = Object.entries(server.env).map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`);
331
+ if (entries.length)
332
+ lines.push(`env = { ${entries.join(', ')} }`);
333
+ }
334
+ blocks.push(lines.join('\n'));
335
+ }
336
+ return blocks.length ? `${blocks.join('\n\n')}\n` : '';
337
+ }
338
+ /**
339
+ * Write the Claude Code MCP config for this run and return its path, or undefined when there are
340
+ * no servers. The file is written into the caller's PER-RUN directory (an isolated config home, or
341
+ * an ambient job's own scratch dir) — never the checkout (it would land in a commit) and never a
342
+ * HOME-global path (a second concurrent job would clobber it, and it carries this job's credentials).
343
+ */
344
+ export async function writeClaudeMcpConfig(dir, servers) {
345
+ if (!servers.length)
346
+ return undefined;
347
+ const path = join(dir, 'mcp-servers.json');
348
+ await mkdir(dirname(path), { recursive: true });
349
+ await writeFile(path, `${JSON.stringify(claudeMcpConfig(servers), null, 2)}\n`, {
350
+ encoding: 'utf8',
351
+ mode: 0o600,
352
+ });
353
+ return path;
354
+ }
@@ -5,9 +5,10 @@ import { dirname, join } from 'node:path';
5
5
  import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude-stream.js';
6
6
  import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js';
7
7
  import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
8
+ import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, writeClaudeMcpConfig, } from './agent-capabilities.js';
8
9
  import { ProgressGuard } from './progress-guard.js';
9
10
  import { killChildProcess, spawnDetached } from './process.js';
10
- import { redact, secretsToRedact } from './redact.js';
11
+ import { redact, registerKnownSecrets, secretsToRedact } from './redact.js';
11
12
  import { createSliceTracker, startSubagentWatcher } from './subagents.js';
12
13
  import { createTaskPlanTracker, mergeProgress, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
13
14
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
@@ -200,6 +201,48 @@ async function writeNativeSkill(skillsRoot, skill) {
200
201
  await writeFile(dest, resource.content, 'utf8');
201
202
  }
202
203
  }
204
+ /**
205
+ * Prepare the Claude Code CLI's MCP wiring for one run: write the servers to a PER-RUN config and
206
+ * return the argv that points the CLI at it, plus the cleanup for a directory we had to mint.
207
+ *
208
+ * Two decisions live here. `--strict-mcp-config` makes that file the ONLY source of servers, so an
209
+ * ambient run on a developer's own machine can never silently hand the agent their personal ones.
210
+ * And `--allowedTools` is passed ONLY when a server actually narrows its tools — an allow-list is
211
+ * whole-session, not MCP-scoped, so `claudeAllowedToolPatterns` re-grants the CLI's built-in
212
+ * file/bash tools in the same list; see it for why that holds whichever way the run's permission
213
+ * mode treats an allow-list.
214
+ *
215
+ * The config carries this job's resolved credentials, so it goes in the isolated config home when
216
+ * we own one and a throwaway per-JOB directory otherwise — never the checkout (it would land in a
217
+ * commit) and never a shared HOME path (a concurrent job would clobber it).
218
+ */
219
+ async function setUpClaudeMcp(servers, configHome) {
220
+ const noop = { args: [], cleanup: async () => { } };
221
+ if (!servers?.length)
222
+ return noop;
223
+ // Before anything can spawn: a failing MCP server echoes its own argv/headers into stderr, and
224
+ // that tail is carried onto the step's diagnostics.
225
+ registerKnownSecrets(mcpServerSecretValues(servers));
226
+ const home = configHome ?? (await mkdtemp(join(tmpdir(), 'cf-claude-mcp-')));
227
+ const owned = home === configHome ? undefined : home;
228
+ const cleanup = async () => {
229
+ if (owned)
230
+ await rm(owned, { recursive: true, force: true }).catch(() => { });
231
+ };
232
+ const configPath = await writeClaudeMcpConfig(home, servers);
233
+ if (!configPath)
234
+ return { args: [], cleanup };
235
+ const allowedTools = claudeAllowedToolPatterns(servers);
236
+ return {
237
+ args: [
238
+ '--mcp-config',
239
+ configPath,
240
+ '--strict-mcp-config',
241
+ ...(allowedTools?.length ? ['--allowedTools', allowedTools.join(',')] : []),
242
+ ],
243
+ cleanup,
244
+ };
245
+ }
203
246
  export async function runClaudeCode(opts) {
204
247
  const stats = { toolCalls: 0, assistantChars: 0 };
205
248
  let summary = '';
@@ -381,16 +424,21 @@ export async function runClaudeCode(opts) {
381
424
  await writeOnboardingPreseed(configHome);
382
425
  await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
383
426
  }
384
- // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
385
- // `skills/<name>/` so the CLI discovers and can invoke it. ONLY into the isolated per-run config
386
- // home — never the developer's own `~/.claude` (ambient/native mode), where it would persist in
387
- // their personal setup after the run and two concurrent jobs carrying same-named skills from
388
- // different repos would clobber each other. An ambient run reads the skill from the checkout
389
- // instead (`.cat-context/skill/`, materialised by the caller). Best-effort: a write failure must
390
- // not wedge the run — the prompt still names the skill.
391
- if (opts.skill && configHome) {
392
- await writeNativeSkill(join(configHome, 'skills'), opts.skill).catch(() => { });
427
+ // Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
428
+ // discovers and can invoke it. ONLY into the isolated per-run config home — never the
429
+ // developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
430
+ // setup after the run and two concurrent jobs carrying same-named skills would clobber each
431
+ // other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
432
+ // materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
433
+ // still names the skills.
434
+ if (configHome) {
435
+ for (const skill of opts.skills ?? []) {
436
+ await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => { });
437
+ }
393
438
  }
439
+ // Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
440
+ // one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
441
+ const mcp = await setUpClaudeMcp(opts.mcpServers, configHome);
394
442
  const env = buildClaudeEnv(opts, configHome);
395
443
  // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
396
444
  // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
@@ -430,6 +478,7 @@ export async function runClaudeCode(opts) {
430
478
  'bypassPermissions',
431
479
  '--model',
432
480
  opts.model,
481
+ ...mcp.args,
433
482
  ...appendArgs,
434
483
  ],
435
484
  }, prompt, { ...opts, signal: runSignal }, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
@@ -466,6 +515,8 @@ export async function runClaudeCode(opts) {
466
515
  }
467
516
  finally {
468
517
  await subagents?.stop();
518
+ // The ambient-mode MCP config dir (credential-bearing) never outlives the run.
519
+ await mcp.cleanup();
469
520
  if (configHome) {
470
521
  // Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
471
522
  // home is deleted — the credential lives at the home root, never in `projects/`, so this
@@ -574,7 +625,11 @@ function claudeUsage(raw) {
574
625
  export async function runCodex(opts) {
575
626
  const stats = { toolCalls: 0, assistantChars: 0 };
576
627
  let summary = '';
577
- let usage;
628
+ // The running CUMULATIVE total, kept in its reported (inclusive) form plus the cached share
629
+ // it contains. `PiRunOutcome.usage` needs the inclusive figure — it is the key-rotation
630
+ // weight — while the fallback call metric below needs the split, so both are derived from
631
+ // this one value rather than one being reconstructed from the other.
632
+ let cumulative;
578
633
  // Codex reads its credentials from $CODEX_HOME/auth.json with file-backed
579
634
  // storage. CRITICAL: this home must live OUTSIDE the cloned checkout (`opts.cwd`)
580
635
  // — the blueprint/requirements/conflict-resolver handlers finish with
@@ -601,7 +656,17 @@ export async function runCodex(opts) {
601
656
  const codexHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-codex-'));
602
657
  if (codexHome) {
603
658
  await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken, { mode: 0o600 });
604
- await writeFile(join(codexHome, 'config.toml'), 'cli_auth_credentials_store = "file"\n', 'utf8');
659
+ // Tool servers (MCP) ride the SAME per-run config.toml, so they are scoped to this job and
660
+ // torn down with the home. Under AMBIENT auth there is no per-run home — and writing servers
661
+ // into the developer's own `~/.codex/config.toml` would outlive the run and race a concurrent
662
+ // job — so an ambient codex run gets no MCP servers; the backend states them as unavailable
663
+ // the same way it does for a harness with no MCP client at all.
664
+ // Registered before the CLI starts, for the same reason the claude path does it: a server that
665
+ // fails to launch puts its own command line into the stderr tail we keep.
666
+ if (opts.mcpServers?.length)
667
+ registerKnownSecrets(mcpServerSecretValues(opts.mcpServers));
668
+ const mcpToml = opts.mcpServers?.length ? codexMcpConfigToml(opts.mcpServers) : '';
669
+ await writeFile(join(codexHome, 'config.toml'), `cli_auth_credentials_store = "file"\n${mcpToml ? `\n${mcpToml}` : ''}`, { encoding: 'utf8', mode: 0o600 });
605
670
  }
606
671
  // Codex has no system-prompt flag, so fold the composed role + best-practice
607
672
  // context into the prompt itself (Claude Code instead rides --append-system-prompt,
@@ -635,7 +700,7 @@ export async function runCodex(opts) {
635
700
  opts.onProgress(progress);
636
701
  const turnUsage = codexUsage(event);
637
702
  if (turnUsage)
638
- usage = turnUsage;
703
+ cumulative = turnUsage;
639
704
  // A `token_count` event closes a model turn: pair its per-turn usage with the
640
705
  // assistant text seen since the previous turn as one telemetry call.
641
706
  const perTurn = codexLastTurnUsage(event);
@@ -647,7 +712,8 @@ export async function runCodex(opts) {
647
712
  responseText: redactBody(pendingText, secrets),
648
713
  reasoningText: '',
649
714
  inputTokens: perTurn.inputTokens,
650
- cachedInputTokens: perTurn.cachedInputTokens,
715
+ cacheReadTokens: perTurn.cacheReadTokens,
716
+ cacheWriteTokens: perTurn.cacheWriteTokens,
651
717
  outputTokens: perTurn.outputTokens,
652
718
  finishReason: null,
653
719
  }, opts.onCallMetric);
@@ -673,19 +739,29 @@ export async function runCodex(opts) {
673
739
  }, prompt, opts, { ...opts.extraEnv, ...(codexHome ? { CODEX_HOME: codexHome } : {}) }, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
674
740
  // Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
675
741
  // single call from the cumulative total + final text so the run is still observable.
676
- if (calls.length === 0 && (usage || summary)) {
742
+ // The cumulative total is inclusive of its cached share exactly as a per-turn one is, so
743
+ // it is split the same way rather than being filed wholesale as fresh — which would report
744
+ // a cache-heavy run as if nothing had been cached, the one reading this telemetry exists
745
+ // to rule out.
746
+ if (calls.length === 0 && (cumulative || summary)) {
677
747
  publishCallMetric(calls, {
678
748
  model: opts.model,
679
749
  promptText: redactBody(JSON.stringify(messages), secrets),
680
750
  messageCount: messages.length,
681
751
  responseText: redactBody(summary, secrets),
682
752
  reasoningText: '',
683
- inputTokens: usage?.inputTokens ?? 0,
684
- cachedInputTokens: 0,
685
- outputTokens: usage?.outputTokens ?? 0,
753
+ inputTokens: Math.max(0, (cumulative?.inputTokens ?? 0) - (cumulative?.cachedInputTokens ?? 0)),
754
+ cacheReadTokens: cumulative?.cachedInputTokens ?? 0,
755
+ // Codex reports no separate cache-WRITE class; 0 rather than guessed.
756
+ cacheWriteTokens: 0,
757
+ outputTokens: cumulative?.outputTokens ?? 0,
686
758
  finishReason: null,
687
759
  }, opts.onCallMetric);
688
760
  }
761
+ // The outcome's usage is the key-rotation WEIGHT, so it keeps the inclusive input count.
762
+ const usage = cumulative
763
+ ? { inputTokens: cumulative.inputTokens, outputTokens: cumulative.outputTokens }
764
+ : undefined;
689
765
  return {
690
766
  summary,
691
767
  stats,
@@ -765,8 +841,6 @@ function codexPlanProgress(event) {
765
841
  * other shapes put it on `usage` / `info.usage` directly. We read the cumulative
766
842
  * total when present so the caller can simply overwrite (not sum) — summing
767
843
  * cumulative totals across events would multiply-count. Checked most-likely first.
768
- * `input_tokens` is the TOTAL prompt count (OpenAI semantics: `cached_input_tokens`
769
- * is a subset already inside it), so it is NOT summed with the cached share.
770
844
  */
771
845
  function codexUsage(event) {
772
846
  const info = isObject(event.info) ? event.info : undefined;
@@ -780,14 +854,21 @@ function codexUsage(event) {
780
854
  const output = numberOf(raw.output_tokens);
781
855
  if (input === 0 && output === 0)
782
856
  return undefined;
783
- return { inputTokens: input, outputTokens: output };
857
+ return {
858
+ inputTokens: input,
859
+ cachedInputTokens: numberOf(raw.cached_input_tokens),
860
+ outputTokens: output,
861
+ };
784
862
  }
785
863
  /**
786
864
  * Per-TURN Codex token usage off a `token_count` event's `info.last_token_usage` (the
787
865
  * delta for the turn just completed, as opposed to `codexUsage`'s cumulative total).
788
- * `input_tokens` is the total prompt count for the turn and already INCLUDES the cached
789
- * share (OpenAI semantics), so `cachedInputTokens` is surfaced as the subset it is
790
- * NOT added on top (adding it would double-count every cached token).
866
+ *
867
+ * OpenAI semantics: `input_tokens` is the turn's WHOLE prompt count and already INCLUDES
868
+ * the cached share, so the fresh figure is the difference. Clamped at 0 because the two
869
+ * counts come off the same event and a vendor inconsistency must not mint a negative token
870
+ * count. Codex reports no separate cache-WRITE class, so that class is 0 here rather than
871
+ * guessed.
791
872
  */
792
873
  function codexLastTurnUsage(event) {
793
874
  const info = isObject(event.info) ? event.info : undefined;
@@ -799,7 +880,12 @@ function codexLastTurnUsage(event) {
799
880
  const output = numberOf(raw.output_tokens);
800
881
  if (input === 0 && output === 0)
801
882
  return undefined;
802
- return { inputTokens: input, cachedInputTokens: cached, outputTokens: output };
883
+ return {
884
+ inputTokens: Math.max(0, input - cached),
885
+ cacheReadTokens: cached,
886
+ cacheWriteTokens: 0,
887
+ outputTokens: output,
888
+ };
803
889
  }
804
890
  /** Dispatch to the configured subscription harness runner. */
805
891
  export function runSubscriptionHarness(harness, opts) {
@@ -0,0 +1,23 @@
1
+ // Small helpers shared by every agent MODE (explore / coding / bootstrap / preview). They live
2
+ // apart from `agent.ts` so the bootstrap mode — a whole flow of its own — could move to its own
3
+ // module without either file importing the other.
4
+ /**
5
+ * Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
6
+ * onto its final result. Every container mode routes its result through this so the report reaches
7
+ * the backend uniformly. A run that wrote no report passes through unchanged.
8
+ */
9
+ export function mergeEffort(result, effortReport) {
10
+ return effortReport ? { ...result, effortReport } : result;
11
+ }
12
+ /**
13
+ * The agent-capability fields (skills + tool servers) every agent-running flow forwards to
14
+ * {@link runAgentInWorkspace}. One helper rather than a per-flow spread, so a flow cannot silently
15
+ * be the one that drops a kind's declared playbook or tool server — the failure mode is invisible
16
+ * (the agent simply works without it) and would only show up as degraded output.
17
+ */
18
+ export function agentCapabilities(job) {
19
+ return {
20
+ ...(job.skills?.length ? { skills: job.skills } : {}),
21
+ ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
22
+ };
23
+ }