@cat-factory/executor-harness 1.66.0 → 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.
@@ -142,14 +142,14 @@ export async function runAgentInWorkspace(spec, opts = {}) {
142
142
  // harness paths; kept out of the agent's commits via a local git exclude entry.
143
143
  const contextFiles = spec.contextFiles ?? [];
144
144
  await materializeContextFiles(spec.dir, contextFiles);
145
- // Repo-sourced skill (slice 2): claude-code installs it natively into its ISOLATED config dir,
146
- // so it reads from there. Everything else reads the checkout, so materialise the skill's
147
- // resources under `.cat-context/skill/` (its instructions are folded into the prompt by the
148
- // backend) — Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install
149
- // into (the runner refuses to write a repo's skill into the developer's own `~/.claude`; see
150
- // `runClaudeCode`). A resource-free skill is a no-op here.
151
- if (spec.skill && !installsSkillNatively(spec)) {
152
- await materializeSkillResources(spec.dir, spec.skill);
145
+ // Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
146
+ // there. Everything else reads the checkout, so materialise each skill's resources under
147
+ // `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
148
+ // Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
149
+ // runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
150
+ // Resource-free skills are a no-op here.
151
+ if (spec.skills?.length && !installsSkillNatively(spec)) {
152
+ await materializeSkillResources(spec.dir, spec.skills);
153
153
  }
154
154
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
155
155
  // token and talk direct to the vendor — no proxy config, no AGENTS.md. The
@@ -169,7 +169,8 @@ export async function runAgentInWorkspace(spec, opts = {}) {
169
169
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
170
170
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
171
171
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
172
- ...(spec.skill ? { skill: spec.skill } : {}),
172
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
173
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
173
174
  ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
174
175
  signal: opts.signal,
175
176
  // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
@@ -240,12 +241,12 @@ export async function runAgentInWorkspace(spec, opts = {}) {
240
241
  return withEffortReport(spec.dir, piOutcome);
241
242
  }
242
243
  /**
243
- * Whether the claude-code runner will install this run's repo-sourced skill natively (into the
244
- * CLI's config dir) rather than the caller materialising it into the checkout. True ONLY for a
244
+ * Whether the claude-code runner will install this run's skills natively (into the CLI's config
245
+ * dir) rather than the caller materialising them into the checkout. True ONLY for a
245
246
  * leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
246
- * uses the developer's own `~/.claude`, which the runner will not write a repo's skill into —
247
- * it would outlive the run in their personal setup, and two concurrent jobs carrying same-named
248
- * skills from different repos would overwrite each other's.
247
+ * uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
248
+ * outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
249
+ * would overwrite each other's.
249
250
  */
250
251
  export function installsSkillNatively(spec) {
251
252
  return spec.harness === 'claude-code' && !spec.ambientAuth;
package/dist/pi.js CHANGED
@@ -203,26 +203,34 @@ export async function materializeContextFiles(cwd, files) {
203
203
  // No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
204
204
  }
205
205
  }
206
- /** Subdirectory of {@link CONTEXT_DIR} where a repo-sourced skill's resources are materialised. */
206
+ /** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
207
207
  export const SKILL_CONTEXT_SUBDIR = 'skill';
208
208
  /**
209
- * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
210
- * (repo-sourced Claude Skills, slice 2) — the path for every run that does NOT get a native
211
- * install: Pi, codex, and ambient claude-code (no isolated `CLAUDE_CONFIG_DIR` to install into).
212
- * Their agents read the checkout, and the skill's instructions are folded into their prompt by the
213
- * backend (`renderSkillForHarness`, which keys off ambient auth as well as the harness). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
214
- * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
215
- * exclude entry. A skill with no resource bodies is a no-op.
209
+ * Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
210
+ * — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
211
+ * (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
212
+ * skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
213
+ * which keys off ambient auth as well as the harness).
214
+ *
215
+ * Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
216
+ * the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
217
+ * overwrite each other — silently handing the agent the wrong template. The names were sanitized
218
+ * to a single safe path segment at the job boundary, as were the resource sub-paths (no
219
+ * traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
220
+ * `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
216
221
  */
217
- export async function materializeSkillResources(cwd, skill) {
218
- if (!skill.resources.length)
222
+ export async function materializeSkillResources(cwd, skills) {
223
+ const withResources = skills.filter((s) => s.resources.length);
224
+ if (!withResources.length)
219
225
  return;
220
- const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR);
221
- await mkdir(dir, { recursive: true });
222
- for (const r of skill.resources) {
223
- const dest = join(dir, r.relPath);
224
- await mkdir(dirname(dest), { recursive: true });
225
- await writeFile(dest, r.content, 'utf8');
226
+ for (const skill of withResources) {
227
+ const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name);
228
+ await mkdir(dir, { recursive: true });
229
+ for (const r of skill.resources) {
230
+ const dest = join(dir, r.relPath);
231
+ await mkdir(dirname(dest), { recursive: true });
232
+ await writeFile(dest, r.content, 'utf8');
233
+ }
226
234
  }
227
235
  const gitRoot = await findGitRoot(cwd);
228
236
  if (!gitRoot)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.66.0",
3
+ "version": "1.68.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,9 +26,9 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/kernel": "0.175.0",
30
- "@cat-factory/server": "0.165.0",
31
- "@cat-factory/spend": "0.12.104"
29
+ "@cat-factory/kernel": "0.176.0",
30
+ "@cat-factory/server": "0.166.0",
31
+ "@cat-factory/spend": "0.12.105"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,414 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // The AGENT CAPABILITIES a job body can carry: the skills the run applies and the tool servers
6
+ // (MCP) it may call. This module owns their wire shapes, the (defensive) parsing every job-body
7
+ // field gets at the boundary, and the writers that turn a tool-server spec into the config each
8
+ // agent CLI reads.
9
+ //
10
+ // Both are backend-authored data the harness only MATERIALISES — there is no `switch(agentKind)`
11
+ // here and no per-capability code path: a new skill or a new tool server is a backend
12
+ // registration, never a harness change or an image bump.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** One materialisable resource file of a skill. */
16
+ export interface SkillResourceSpec {
17
+ /** Path within the skill directory, e.g. `templates/report.md` (subdirs preserved, no traversal). */
18
+ relPath: string
19
+ content: string
20
+ }
21
+
22
+ /**
23
+ * A skill to make available for a run. Materialised HARNESS-AWARE:
24
+ * `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resources) for the claude-code CLI to load
25
+ * natively, or `.cat-context/skill/<name>/<relPath>` for the Pi/codex checkout (their prompt
26
+ * carries the instructions). A dedicated top-level body field, never a context file.
27
+ */
28
+ export interface SkillSpec {
29
+ name: string
30
+ description: string
31
+ instructions: string
32
+ resources: SkillResourceSpec[]
33
+ }
34
+
35
+ /**
36
+ * A tool server (MCP) to wire into the agent CLI for this run, with its transport and any
37
+ * credentials the backend resolved. Only the CLIs that speak MCP receive these; the backend has
38
+ * already dropped anything this harness cannot serve, so the harness never has to decide.
39
+ *
40
+ * The values here are SECRET-BEARING (`env` / `headers` carry resolved credentials), which is why
41
+ * the config files this module writes always live outside the checkout and are never logged.
42
+ */
43
+ export interface McpServerSpec {
44
+ /** The server name the CLI exposes tools under (`mcp__<id>__<tool>`). Id-safe by construction. */
45
+ id: string
46
+ transport: 'stdio' | 'http'
47
+ command?: string
48
+ args?: string[]
49
+ env?: Record<string, string>
50
+ url?: string
51
+ headers?: Record<string, string>
52
+ /** Bare tool names the agent may call. Absent ⇒ every tool the server exposes. */
53
+ allowedTools?: string[]
54
+ /**
55
+ * Which keys of `env` / `headers` hold a RESOLVED CREDENTIAL rather than declared configuration.
56
+ * {@link mcpServerSecretValues} reads exactly these for redaction — scrubbing the whole map
57
+ * instead would turn every later occurrence of an ordinary config string into `***`.
58
+ */
59
+ secretKeys?: string[]
60
+ }
61
+
62
+ /**
63
+ * The credential values carried by a run's tool servers, for {@link registerKnownSecrets}. An MCP
64
+ * server that fails to start routinely echoes its own argv or request headers into stderr, and
65
+ * that tail reaches the step's diagnostics — so these have to be scrubbed exactly like the leased
66
+ * subscription token. Only the keys the backend MARKED as secret are read (see `secretKeys`).
67
+ */
68
+ export function mcpServerSecretValues(servers: readonly McpServerSpec[]): string[] {
69
+ const values: string[] = []
70
+ for (const server of servers) {
71
+ for (const key of server.secretKeys ?? []) {
72
+ const value = server.env?.[key] ?? server.headers?.[key]
73
+ if (value) values.push(value)
74
+ }
75
+ }
76
+ return values
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Parsing (the job-body boundary)
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Sanitize a skill resource's relative path: keep the subdirectory structure (so
85
+ * `templates/report.md` materialises nested) but reject anything that could escape the skill
86
+ * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
87
+ * for an unsafe path (the resource is then dropped).
88
+ */
89
+ function sanitizeSkillRelPath(value: unknown): string | undefined {
90
+ if (typeof value !== 'string') return undefined
91
+ const segments = value.replace(/\\/g, '/').split('/')
92
+ const clean: string[] = []
93
+ for (const seg of segments) {
94
+ if (seg === '' || seg === '.') continue
95
+ if (seg === '..') return undefined
96
+ // Same character class as a context-file name, per segment.
97
+ const c = seg.replace(/[^A-Za-z0-9._-]/g, '')
98
+ if (!c || c === '.' || c === '..' || c.startsWith('.')) return undefined
99
+ clean.push(c)
100
+ }
101
+ return clean.length ? clean.join('/') : undefined
102
+ }
103
+
104
+ /**
105
+ * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
106
+ * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
107
+ * default keeps the skill installable rather than dropping it — which, on the claude-code path,
108
+ * would leave the prompt pointing at a skill that was never installed (a blind run).
109
+ */
110
+ const FALLBACK_SKILL_NAME = 'skill'
111
+
112
+ /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
113
+ function sanitizeSkillName(value: unknown): string | undefined {
114
+ if (typeof value !== 'string') return undefined
115
+ const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
116
+ const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
117
+ if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
118
+ return cleaned
119
+ }
120
+
121
+ /** Validate one entry of the `skills` field, or undefined when malformed. */
122
+ function parseSkillSpec(value: unknown): SkillSpec | undefined {
123
+ if (typeof value !== 'object' || value === null) return undefined
124
+ const o = value as Record<string, unknown>
125
+ const instructions = typeof o.instructions === 'string' ? o.instructions : undefined
126
+ // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
127
+ // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
128
+ // directory, so fall back to a safe default rather than dropping the whole skill.
129
+ if (!instructions) return undefined
130
+ const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME
131
+ const description = typeof o.description === 'string' ? o.description : ''
132
+ const resources: SkillResourceSpec[] = []
133
+ if (Array.isArray(o.resources)) {
134
+ const used = new Set<string>()
135
+ for (const entry of o.resources) {
136
+ if (typeof entry !== 'object' || entry === null) continue
137
+ const e = entry as Record<string, unknown>
138
+ const relPath = sanitizeSkillRelPath(e.relPath)
139
+ if (!relPath || used.has(relPath)) continue
140
+ if (typeof e.content !== 'string') continue
141
+ used.add(relPath)
142
+ resources.push({ relPath, content: e.content })
143
+ }
144
+ }
145
+ return { name, description, instructions, resources }
146
+ }
147
+
148
+ /**
149
+ * Validate the optional `skills` field. Names are de-duplicated: two skills sharing a directory
150
+ * name would overwrite each other's `SKILL.md`, leaving the agent pointed at whichever landed
151
+ * last — so the first wins and the collision is dropped rather than silently mixing two playbooks.
152
+ */
153
+ export function parseSkillSpecs(value: unknown): SkillSpec[] | undefined {
154
+ if (!Array.isArray(value)) return undefined
155
+ const skills: SkillSpec[] = []
156
+ const used = new Set<string>()
157
+ for (const entry of value) {
158
+ const skill = parseSkillSpec(entry)
159
+ if (!skill || used.has(skill.name)) continue
160
+ used.add(skill.name)
161
+ skills.push(skill)
162
+ }
163
+ return skills.length ? skills : undefined
164
+ }
165
+
166
+ /**
167
+ * A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
168
+ *
169
+ * Kept byte-identical to kernel's `MCP_SERVER_ID_PATTERN` (the harness image is built from `src/`
170
+ * plus typescript alone, so it can carry no runtime dependency on a workspace package) and pinned
171
+ * against it by `test/agent-capabilities.conformity.test.ts` — the same copy-plus-pin arrangement
172
+ * `src/host-markdown.ts` uses.
173
+ */
174
+ export const MCP_SERVER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/
175
+
176
+ function sanitizeServerId(value: unknown): string | undefined {
177
+ if (typeof value !== 'string') return undefined
178
+ return MCP_SERVER_ID_PATTERN.test(value) ? value : undefined
179
+ }
180
+
181
+ /**
182
+ * Whether an HTTP tool server's URL may be started. Mirrors kernel's `isAllowedMcpHttpUrl` (see
183
+ * {@link MCP_SERVER_ID_PATTERN} for why it is a copy, and the conformity suite that pins it):
184
+ * `https` anywhere, plain `http` only on loopback, since the headers carry a resolved credential.
185
+ * The backend refuses the same URLs at registration — this is the boundary check, so a body that
186
+ * reached the container by any other route is held to the rule too.
187
+ */
188
+ export function isAllowedMcpHttpUrl(raw: string): boolean {
189
+ const match = /^(https?):\/\/([^/?#]*)/i.exec(raw)
190
+ if (!match) return false
191
+ if (match[1]!.toLowerCase() === 'https') return true
192
+ // Plain http from here: the host must be loopback. Strip userinfo FIRST and from the LAST `@`,
193
+ // or `http://127.0.0.1@evil.example` reads as loopback while the request goes to evil.example.
194
+ const authority = match[2]!
195
+ const hostPort = authority.slice(authority.lastIndexOf('@') + 1)
196
+ const closingBracket = hostPort.indexOf(']')
197
+ const host = (
198
+ hostPort.startsWith('[') && closingBracket !== -1
199
+ ? hostPort.slice(1, closingBracket) // IPv6 literal, e.g. [::1]:8080
200
+ : (hostPort.split(':')[0] ?? '')
201
+ ).toLowerCase()
202
+ return host === 'localhost' || host === '::1' || /^127\.\d+\.\d+\.\d+$/.test(host)
203
+ }
204
+
205
+ /** A string→string record, dropping any non-string entry. Undefined when nothing survives. */
206
+ function parseStringRecord(value: unknown): Record<string, string> | undefined {
207
+ if (typeof value !== 'object' || value === null) return undefined
208
+ const out: Record<string, string> = {}
209
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
210
+ if (typeof raw === 'string') out[key] = raw
211
+ }
212
+ return Object.keys(out).length ? out : undefined
213
+ }
214
+
215
+ /** A string array, dropping non-string entries. Undefined when nothing survives. */
216
+ function parseStringArray(value: unknown): string[] | undefined {
217
+ if (!Array.isArray(value)) return undefined
218
+ const out = value.filter((v): v is string => typeof v === 'string')
219
+ return out.length ? out : undefined
220
+ }
221
+
222
+ /** Validate one `mcpServers` entry, or undefined when malformed for its transport. */
223
+ function parseMcpServerSpec(value: unknown): McpServerSpec | undefined {
224
+ if (typeof value !== 'object' || value === null) return undefined
225
+ const o = value as Record<string, unknown>
226
+ const id = sanitizeServerId(o.id)
227
+ if (!id) return undefined
228
+ const allowedTools = parseStringArray(o.allowedTools)
229
+ const secretKeys = parseStringArray(o.secretKeys)
230
+ if (o.transport === 'http') {
231
+ // https anywhere, plain http only on loopback: the CLI would happily be pointed at a
232
+ // `file:`/`ws:` URL, and the headers below carry this job's resolved credential.
233
+ const url = typeof o.url === 'string' && isAllowedMcpHttpUrl(o.url) ? o.url : undefined
234
+ if (!url) return undefined
235
+ const headers = parseStringRecord(o.headers)
236
+ return {
237
+ id,
238
+ transport: 'http',
239
+ url,
240
+ ...(headers ? { headers } : {}),
241
+ ...(allowedTools ? { allowedTools } : {}),
242
+ ...(secretKeys ? { secretKeys } : {}),
243
+ }
244
+ }
245
+ const command = typeof o.command === 'string' && o.command ? o.command : undefined
246
+ if (!command) return undefined
247
+ const args = parseStringArray(o.args)
248
+ const env = parseStringRecord(o.env)
249
+ return {
250
+ id,
251
+ transport: 'stdio',
252
+ command,
253
+ ...(args ? { args } : {}),
254
+ ...(env ? { env } : {}),
255
+ ...(allowedTools ? { allowedTools } : {}),
256
+ ...(secretKeys ? { secretKeys } : {}),
257
+ }
258
+ }
259
+
260
+ /** Validate the optional `mcpServers` field, dropping malformed entries and duplicate ids. */
261
+ export function parseMcpServerSpecs(value: unknown): McpServerSpec[] | undefined {
262
+ if (!Array.isArray(value)) return undefined
263
+ const servers: McpServerSpec[] = []
264
+ const used = new Set<string>()
265
+ for (const entry of value) {
266
+ const server = parseMcpServerSpec(entry)
267
+ if (!server || used.has(server.id)) continue
268
+ used.add(server.id)
269
+ servers.push(server)
270
+ }
271
+ return servers.length ? servers : undefined
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Materialisation (per CLI)
276
+ // ---------------------------------------------------------------------------
277
+
278
+ /**
279
+ * The `--mcp-config` document Claude Code reads: `{ "mcpServers": { "<id>": {...} } }`. An `http`
280
+ * server declares `type: "http"` with its headers; a `stdio` one declares its command/args/env.
281
+ */
282
+ export function claudeMcpConfig(servers: McpServerSpec[]): {
283
+ mcpServers: Record<string, Record<string, unknown>>
284
+ } {
285
+ const mcpServers: Record<string, Record<string, unknown>> = {}
286
+ for (const server of servers) {
287
+ mcpServers[server.id] =
288
+ server.transport === 'http'
289
+ ? { type: 'http', url: server.url, ...(server.headers ? { headers: server.headers } : {}) }
290
+ : {
291
+ type: 'stdio',
292
+ command: server.command,
293
+ ...(server.args ? { args: server.args } : {}),
294
+ ...(server.env ? { env: server.env } : {}),
295
+ }
296
+ }
297
+ return { mcpServers }
298
+ }
299
+
300
+ /**
301
+ * The claude-code CLI's own tools, named so an `--allowedTools` list can never take them away.
302
+ *
303
+ * An allow-list is whole-session: it does not scope itself to MCP just because every entry we
304
+ * generate happens to be an `mcp__*` pattern. So the moment one tool server narrows its tools, the
305
+ * list has to re-grant the agent's built-in file/bash/search tools or the run is handed a narrowed
306
+ * MCP surface AND no way to read, edit or build anything.
307
+ *
308
+ * Bias this list toward OVER-inclusion. A name the CLI does not have is inert; a name it has and
309
+ * this list lacks is a tool silently removed from a run — which surfaces as an agent that cannot
310
+ * do its work, far from the registration that caused it. Historical/renamed spellings are kept for
311
+ * the same reason: the harness image is pinned per workspace, so one image faces several CLI
312
+ * versions. When the CLI gains a tool, add it here.
313
+ */
314
+ export const CLAUDE_BUILT_IN_TOOLS: readonly string[] = [
315
+ 'Agent',
316
+ 'Bash',
317
+ 'BashOutput',
318
+ 'Edit',
319
+ 'ExitPlanMode',
320
+ 'Glob',
321
+ 'Grep',
322
+ 'KillBash',
323
+ 'KillShell',
324
+ 'ListMcpResources',
325
+ 'MultiEdit',
326
+ 'NotebookEdit',
327
+ 'NotebookRead',
328
+ 'Read',
329
+ 'ReadMcpResource',
330
+ 'SlashCommand',
331
+ 'Skill',
332
+ 'Task',
333
+ 'TaskCreate',
334
+ 'TaskUpdate',
335
+ 'TodoWrite',
336
+ 'WebFetch',
337
+ 'WebSearch',
338
+ 'Write',
339
+ ]
340
+
341
+ /**
342
+ * The tool-name list for `--allowedTools`: every declared server's tools in the CLI's
343
+ * `mcp__<server>__<tool>` convention, PLUS {@link CLAUDE_BUILT_IN_TOOLS}. A server with no
344
+ * restriction contributes the whole-server pattern, so an allow-list stays one entry per server.
345
+ *
346
+ * Returns undefined when NO server restricts its tools — there is then nothing to narrow, and the
347
+ * safest list is the one we never send.
348
+ *
349
+ * Whether the CLI ENFORCES this list is permission-mode dependent and not a contract we control:
350
+ * the run uses `--permission-mode bypassPermissions` (the container is the sandbox and no human is
351
+ * there to approve a call), under which an allow-list grants rather than gates. So this is written
352
+ * to be correct under BOTH readings — if the list gates, the narrowing is real and the built-ins
353
+ * survive it; if it is inert, sending it costs nothing. The always-present channel is the PROMPT,
354
+ * which states each server's permitted tool names on every harness. Treat `allowedTools` as
355
+ * scoping, not as a security boundary: a server the agent must not reach fully should not be
356
+ * wired for that kind at all.
357
+ */
358
+ export function claudeAllowedToolPatterns(servers: McpServerSpec[]): string[] | undefined {
359
+ if (!servers.some((s) => s.allowedTools?.length)) return undefined
360
+ const mcp = servers.flatMap((s) =>
361
+ s.allowedTools?.length ? s.allowedTools.map((t) => `mcp__${s.id}__${t}`) : [`mcp__${s.id}`],
362
+ )
363
+ return [...mcp, ...CLAUDE_BUILT_IN_TOOLS]
364
+ }
365
+
366
+ /** Escape a string as a TOML basic string (Codex config is TOML, not JSON). */
367
+ function tomlString(value: string): string {
368
+ return JSON.stringify(value)
369
+ }
370
+
371
+ /**
372
+ * The `[mcp_servers.<id>]` TOML block Codex reads from its `CODEX_HOME/config.toml`. Codex's MCP
373
+ * client is stdio-only, so an `http` server is skipped here — the backend states such a server as
374
+ * unavailable when it declares `harnesses: ['claude-code']`, and a deployment that wires an HTTP
375
+ * server for Codex gets a no-op rather than a malformed config.
376
+ */
377
+ export function codexMcpConfigToml(servers: McpServerSpec[]): string {
378
+ const blocks: string[] = []
379
+ for (const server of servers) {
380
+ if (server.transport !== 'stdio') continue
381
+ const lines = [`[mcp_servers.${server.id}]`, `command = ${tomlString(server.command!)}`]
382
+ if (server.args?.length) {
383
+ lines.push(`args = [${server.args.map(tomlString).join(', ')}]`)
384
+ }
385
+ if (server.env) {
386
+ const entries = Object.entries(server.env).map(
387
+ ([k, v]) => `${tomlString(k)} = ${tomlString(v)}`,
388
+ )
389
+ if (entries.length) lines.push(`env = { ${entries.join(', ')} }`)
390
+ }
391
+ blocks.push(lines.join('\n'))
392
+ }
393
+ return blocks.length ? `${blocks.join('\n\n')}\n` : ''
394
+ }
395
+
396
+ /**
397
+ * Write the Claude Code MCP config for this run and return its path, or undefined when there are
398
+ * no servers. The file is written into the caller's PER-RUN directory (an isolated config home, or
399
+ * an ambient job's own scratch dir) — never the checkout (it would land in a commit) and never a
400
+ * HOME-global path (a second concurrent job would clobber it, and it carries this job's credentials).
401
+ */
402
+ export async function writeClaudeMcpConfig(
403
+ dir: string,
404
+ servers: McpServerSpec[],
405
+ ): Promise<string | undefined> {
406
+ if (!servers.length) return undefined
407
+ const path = join(dir, 'mcp-servers.json')
408
+ await mkdir(dirname(path), { recursive: true })
409
+ await writeFile(path, `${JSON.stringify(claudeMcpConfig(servers), null, 2)}\n`, {
410
+ encoding: 'utf8',
411
+ mode: 0o600,
412
+ })
413
+ return path
414
+ }