@cat-factory/executor-harness 1.66.0 → 1.70.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 +39 -1
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +72 -11
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +14 -139
- package/dist/bootstrap-mode.js +142 -0
- package/dist/coding-agent.js +87 -67
- package/dist/job.js +8 -75
- package/dist/pi-workspace.js +25 -16
- package/dist/pi.js +81 -16
- package/dist/runner.js +8 -0
- package/dist/structured-output.js +13 -2
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +97 -26
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +13 -165
- package/src/bootstrap-mode.ts +175 -0
- package/src/coding-agent.ts +106 -71
- package/src/job.ts +53 -93
- package/src/pi-workspace.ts +46 -21
- package/src/pi.ts +95 -16
- package/src/runner.ts +17 -0
- package/src/structured-output.ts +24 -2
|
@@ -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
|
+
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -14,9 +14,17 @@ import {
|
|
|
14
14
|
type PiRunStats,
|
|
15
15
|
type TodoProgress,
|
|
16
16
|
} from './pi.js'
|
|
17
|
+
import {
|
|
18
|
+
claudeAllowedToolPatterns,
|
|
19
|
+
codexMcpConfigToml,
|
|
20
|
+
mcpServerSecretValues,
|
|
21
|
+
writeClaudeMcpConfig,
|
|
22
|
+
type McpServerSpec,
|
|
23
|
+
type SkillSpec,
|
|
24
|
+
} from './agent-capabilities.js'
|
|
17
25
|
import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
18
26
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
19
|
-
import { redact, secretsToRedact } from './redact.js'
|
|
27
|
+
import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
|
|
20
28
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
21
29
|
import {
|
|
22
30
|
createTaskPlanTracker,
|
|
@@ -76,18 +84,19 @@ export interface SubscriptionRunOptions {
|
|
|
76
84
|
*/
|
|
77
85
|
ambientAuth?: boolean
|
|
78
86
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
87
|
+
* The skills to install natively before launch. The claude-code runner writes each to
|
|
88
|
+
* `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resource files) so the CLI loads them — but ONLY
|
|
89
|
+
* when it owns an isolated config home, i.e. NOT under `ambientAuth`. The codex runner ignores
|
|
90
|
+
* them outright. Every case that skips the native install reads the checkout's
|
|
91
|
+
* `.cat-context/skill/<name>/`, materialised by the caller.
|
|
84
92
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
skills?: SkillSpec[]
|
|
94
|
+
/**
|
|
95
|
+
* Tool servers (MCP) to wire into the CLI for this run. Written to a PER-RUN config the CLI is
|
|
96
|
+
* pointed at — never a HOME-global one, which a second concurrent job would clobber and which
|
|
97
|
+
* carries this job's credentials. Absent ⇒ the CLI's built-in tools only.
|
|
98
|
+
*/
|
|
99
|
+
mcpServers?: McpServerSpec[]
|
|
91
100
|
/**
|
|
92
101
|
* Extra environment for the CLI child, scoped to this job (the tester's secrets, a
|
|
93
102
|
* private-registry npmrc pointer). Merged over the inherited `process.env` at spawn, so the
|
|
@@ -320,10 +329,7 @@ export function carryClaudeSystemPrompt(
|
|
|
320
329
|
* would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
|
|
321
330
|
* valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
|
|
322
331
|
*/
|
|
323
|
-
async function writeNativeSkill(
|
|
324
|
-
skillsRoot: string,
|
|
325
|
-
skill: NonNullable<SubscriptionRunOptions['skill']>,
|
|
326
|
-
): Promise<void> {
|
|
332
|
+
async function writeNativeSkill(skillsRoot: string, skill: SkillSpec): Promise<void> {
|
|
327
333
|
const dir = join(skillsRoot, skill.name)
|
|
328
334
|
await mkdir(dir, { recursive: true })
|
|
329
335
|
const name = JSON.stringify(skill.name)
|
|
@@ -337,6 +343,49 @@ async function writeNativeSkill(
|
|
|
337
343
|
}
|
|
338
344
|
}
|
|
339
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Prepare the Claude Code CLI's MCP wiring for one run: write the servers to a PER-RUN config and
|
|
348
|
+
* return the argv that points the CLI at it, plus the cleanup for a directory we had to mint.
|
|
349
|
+
*
|
|
350
|
+
* Two decisions live here. `--strict-mcp-config` makes that file the ONLY source of servers, so an
|
|
351
|
+
* ambient run on a developer's own machine can never silently hand the agent their personal ones.
|
|
352
|
+
* And `--allowedTools` is passed ONLY when a server actually narrows its tools — an allow-list is
|
|
353
|
+
* whole-session, not MCP-scoped, so `claudeAllowedToolPatterns` re-grants the CLI's built-in
|
|
354
|
+
* file/bash tools in the same list; see it for why that holds whichever way the run's permission
|
|
355
|
+
* mode treats an allow-list.
|
|
356
|
+
*
|
|
357
|
+
* The config carries this job's resolved credentials, so it goes in the isolated config home when
|
|
358
|
+
* we own one and a throwaway per-JOB directory otherwise — never the checkout (it would land in a
|
|
359
|
+
* commit) and never a shared HOME path (a concurrent job would clobber it).
|
|
360
|
+
*/
|
|
361
|
+
async function setUpClaudeMcp(
|
|
362
|
+
servers: McpServerSpec[] | undefined,
|
|
363
|
+
configHome: string | undefined,
|
|
364
|
+
): Promise<{ args: string[]; cleanup: () => Promise<void> }> {
|
|
365
|
+
const noop = { args: [], cleanup: async () => {} }
|
|
366
|
+
if (!servers?.length) return noop
|
|
367
|
+
// Before anything can spawn: a failing MCP server echoes its own argv/headers into stderr, and
|
|
368
|
+
// that tail is carried onto the step's diagnostics.
|
|
369
|
+
registerKnownSecrets(mcpServerSecretValues(servers))
|
|
370
|
+
const home = configHome ?? (await mkdtemp(join(tmpdir(), 'cf-claude-mcp-')))
|
|
371
|
+
const owned = home === configHome ? undefined : home
|
|
372
|
+
const cleanup = async (): Promise<void> => {
|
|
373
|
+
if (owned) await rm(owned, { recursive: true, force: true }).catch(() => {})
|
|
374
|
+
}
|
|
375
|
+
const configPath = await writeClaudeMcpConfig(home, servers)
|
|
376
|
+
if (!configPath) return { args: [], cleanup }
|
|
377
|
+
const allowedTools = claudeAllowedToolPatterns(servers)
|
|
378
|
+
return {
|
|
379
|
+
args: [
|
|
380
|
+
'--mcp-config',
|
|
381
|
+
configPath,
|
|
382
|
+
'--strict-mcp-config',
|
|
383
|
+
...(allowedTools?.length ? ['--allowedTools', allowedTools.join(',')] : []),
|
|
384
|
+
],
|
|
385
|
+
cleanup,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
340
389
|
export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
|
|
341
390
|
const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
|
|
342
391
|
let summary = ''
|
|
@@ -518,17 +567,23 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
518
567
|
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log)
|
|
519
568
|
}
|
|
520
569
|
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
if (
|
|
529
|
-
|
|
570
|
+
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
571
|
+
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
572
|
+
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
573
|
+
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
574
|
+
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
575
|
+
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
576
|
+
// still names the skills.
|
|
577
|
+
if (configHome) {
|
|
578
|
+
for (const skill of opts.skills ?? []) {
|
|
579
|
+
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => {})
|
|
580
|
+
}
|
|
530
581
|
}
|
|
531
582
|
|
|
583
|
+
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
584
|
+
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
585
|
+
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome)
|
|
586
|
+
|
|
532
587
|
const env = buildClaudeEnv(opts, configHome)
|
|
533
588
|
|
|
534
589
|
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
@@ -572,6 +627,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
572
627
|
'bypassPermissions',
|
|
573
628
|
'--model',
|
|
574
629
|
opts.model,
|
|
630
|
+
...mcp.args,
|
|
575
631
|
...appendArgs,
|
|
576
632
|
],
|
|
577
633
|
},
|
|
@@ -613,6 +669,8 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
613
669
|
throw err
|
|
614
670
|
} finally {
|
|
615
671
|
await subagents?.stop()
|
|
672
|
+
// The ambient-mode MCP config dir (credential-bearing) never outlives the run.
|
|
673
|
+
await mcp.cleanup()
|
|
616
674
|
if (configHome) {
|
|
617
675
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
618
676
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
@@ -779,7 +837,20 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
779
837
|
const codexHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-codex-'))
|
|
780
838
|
if (codexHome) {
|
|
781
839
|
await writeFile(join(codexHome, 'auth.json'), opts.subscriptionToken!, { mode: 0o600 })
|
|
782
|
-
|
|
840
|
+
// Tool servers (MCP) ride the SAME per-run config.toml, so they are scoped to this job and
|
|
841
|
+
// torn down with the home. Under AMBIENT auth there is no per-run home — and writing servers
|
|
842
|
+
// into the developer's own `~/.codex/config.toml` would outlive the run and race a concurrent
|
|
843
|
+
// job — so an ambient codex run gets no MCP servers; the backend states them as unavailable
|
|
844
|
+
// the same way it does for a harness with no MCP client at all.
|
|
845
|
+
// Registered before the CLI starts, for the same reason the claude path does it: a server that
|
|
846
|
+
// fails to launch puts its own command line into the stderr tail we keep.
|
|
847
|
+
if (opts.mcpServers?.length) registerKnownSecrets(mcpServerSecretValues(opts.mcpServers))
|
|
848
|
+
const mcpToml = opts.mcpServers?.length ? codexMcpConfigToml(opts.mcpServers) : ''
|
|
849
|
+
await writeFile(
|
|
850
|
+
join(codexHome, 'config.toml'),
|
|
851
|
+
`cli_auth_credentials_store = "file"\n${mcpToml ? `\n${mcpToml}` : ''}`,
|
|
852
|
+
{ encoding: 'utf8', mode: 0o600 },
|
|
853
|
+
)
|
|
783
854
|
}
|
|
784
855
|
|
|
785
856
|
// Codex has no system-prompt flag, so fold the composed role + best-practice
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AgentJob, AgentResult, McpServerSpec, SkillSpec } from './job.js'
|
|
2
|
+
import type { EffortReport } from './effort.js'
|
|
3
|
+
|
|
4
|
+
// Small helpers shared by every agent MODE (explore / coding / bootstrap / preview). They live
|
|
5
|
+
// apart from `agent.ts` so the bootstrap mode — a whole flow of its own — could move to its own
|
|
6
|
+
// module without either file importing the other.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
10
|
+
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
11
|
+
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function mergeEffort(
|
|
14
|
+
result: AgentResult,
|
|
15
|
+
effortReport: EffortReport | undefined,
|
|
16
|
+
): AgentResult {
|
|
17
|
+
return effortReport ? { ...result, effortReport } : result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The agent-capability fields (skills + tool servers) every agent-running flow forwards to
|
|
22
|
+
* {@link runAgentInWorkspace}. One helper rather than a per-flow spread, so a flow cannot silently
|
|
23
|
+
* be the one that drops a kind's declared playbook or tool server — the failure mode is invisible
|
|
24
|
+
* (the agent simply works without it) and would only show up as degraded output.
|
|
25
|
+
*/
|
|
26
|
+
export function agentCapabilities(job: AgentJob): {
|
|
27
|
+
skills?: SkillSpec[]
|
|
28
|
+
mcpServers?: McpServerSpec[]
|
|
29
|
+
} {
|
|
30
|
+
return {
|
|
31
|
+
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
32
|
+
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
33
|
+
}
|
|
34
|
+
}
|