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