@cat-factory/executor-harness 1.132.3 → 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 +49 -0
- package/dist/agent-capabilities.d.ts +21 -24
- package/dist/agent-capabilities.js +22 -50
- package/dist/agent-env.d.ts +17 -0
- package/dist/agent-env.js +47 -0
- package/dist/agent-runner.d.ts +18 -2
- package/dist/agent-runner.js +29 -231
- package/dist/agent-shared.d.ts +14 -5
- package/dist/agent-shared.js +14 -5
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js +7 -138
- package/dist/captured-command.d.ts +1 -1
- package/dist/captured-command.js +3 -2
- 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/coding-agent.d.ts +35 -0
- package/dist/coding-agent.js +213 -41
- package/dist/docker-status.d.ts +89 -0
- package/dist/docker-status.js +147 -0
- package/dist/frontend-infra.js +4 -3
- package/dist/git.d.ts +48 -5
- package/dist/git.js +93 -26
- package/dist/guard-driver.d.ts +71 -0
- package/dist/guard-driver.js +171 -0
- package/dist/harness-server.js +13 -0
- package/dist/infra-standup.d.ts +69 -0
- package/dist/infra-standup.js +182 -0
- package/dist/job.d.ts +10 -0
- package/dist/multi-repo-coding.d.ts +17 -0
- package/dist/multi-repo-coding.js +61 -16
- package/dist/pi-workspace.d.ts +11 -0
- package/dist/pi-workspace.js +126 -57
- package/dist/pi.d.ts +8 -0
- package/dist/pi.js +16 -9
- package/dist/progress-guard.d.ts +56 -10
- package/dist/progress-guard.js +84 -22
- package/dist/runner.d.ts +1 -1
- package/dist/salvage.d.ts +180 -0
- package/dist/salvage.js +289 -0
- package/dist/workspace-probe.d.ts +85 -0
- package/dist/workspace-probe.js +124 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +25 -51
- package/src/agent-env.ts +49 -0
- package/src/agent-runner.ts +40 -267
- package/src/agent-shared.ts +16 -5
- package/src/agent.ts +7 -164
- package/src/captured-command.ts +3 -2
- package/src/claude-cli.ts +217 -0
- package/src/claude-home.ts +233 -0
- package/src/coding-agent.ts +252 -44
- package/src/docker-status.ts +201 -0
- package/src/frontend-infra.ts +4 -3
- package/src/git.ts +104 -26
- package/src/guard-driver.ts +203 -0
- package/src/harness-server.ts +13 -0
- package/src/infra-standup.ts +218 -0
- package/src/job.ts +10 -0
- package/src/multi-repo-coding.ts +65 -16
- package/src/pi-workspace.ts +161 -57
- package/src/pi.ts +27 -12
- package/src/progress-guard.ts +110 -34
- package/src/runner.ts +1 -1
- package/src/salvage.ts +407 -0
- package/src/workspace-probe.ts +155 -0
package/dist/salvage.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { commitPaths, listUntrackedFiles } from './git.js';
|
|
4
|
+
import { HARNESS_SENTINEL_FILES } from './workspace-probe.js';
|
|
5
|
+
// Recovering the work an aborted run left in the tree. `commitTrackedEdits` is a safety net for
|
|
6
|
+
// forgotten edits to files git ALREADY tracks, so a NEW file the agent created and never committed
|
|
7
|
+
// was found, warned about, and dropped. On a greenfield task every file is new, which made that
|
|
8
|
+
// warning the whole deliverable going in the bin: a run that built, tested and verified a service
|
|
9
|
+
// through `bash` heredocs was killed by the progress guard and lost all of it.
|
|
10
|
+
//
|
|
11
|
+
// Observable is not recovered. This makes the salvage real, under guardrails, and MARKED — a
|
|
12
|
+
// salvage commit is evidence from an interrupted run, never work anyone should read as reviewed.
|
|
13
|
+
/**
|
|
14
|
+
* Directory and file names never salvaged. A greenfield checkout may not have a `.gitignore` yet
|
|
15
|
+
* (the agent had not written one when it was killed), and git only excludes what a `.gitignore`
|
|
16
|
+
* tells it to, so without this a blanket salvage would commit `node_modules` into the PR.
|
|
17
|
+
*
|
|
18
|
+
* Matched against every SEGMENT of a path, so `packages/api/node_modules/x` is caught as surely as
|
|
19
|
+
* a root-level one. Deliberately a short list of the unambiguous ones: a cleverer heuristic starts
|
|
20
|
+
* discarding the deliverable, and a `dist/` that genuinely belonged in a commit is a far cheaper
|
|
21
|
+
* miss than a `node_modules/` that did not.
|
|
22
|
+
*/
|
|
23
|
+
export const SALVAGE_DENIED_SEGMENTS = [
|
|
24
|
+
'node_modules',
|
|
25
|
+
'dist',
|
|
26
|
+
'build',
|
|
27
|
+
'coverage',
|
|
28
|
+
'.venv',
|
|
29
|
+
'__pycache__',
|
|
30
|
+
'target',
|
|
31
|
+
'vendor',
|
|
32
|
+
'.git',
|
|
33
|
+
];
|
|
34
|
+
/** Suffixes never salvaged: run output, not source. */
|
|
35
|
+
export const SALVAGE_DENIED_SUFFIXES = ['.log'];
|
|
36
|
+
/**
|
|
37
|
+
* Basenames and suffixes that carry CREDENTIALS, withheld from every salvage.
|
|
38
|
+
*
|
|
39
|
+
* The deny-list above trades a cheap miss (a `dist/` that belonged in a commit) against an
|
|
40
|
+
* expensive one (`node_modules/` in a PR). For a secret that trade INVERTS: a private key or a
|
|
41
|
+
* populated `.env` pushed to a branch is a disclosure that outlives the run, cannot be taken back
|
|
42
|
+
* by deleting the commit, and forces a rotation. Missing a file is recoverable; leaking one is not.
|
|
43
|
+
*
|
|
44
|
+
* This exists for the same reason the deny-list does: on the greenfield case the salvage was
|
|
45
|
+
* written for, the agent was killed before it wrote a `.gitignore`, so git excludes nothing and
|
|
46
|
+
* the harness is the only thing standing between an agent-authored key and the pull request.
|
|
47
|
+
*
|
|
48
|
+
* Unlike a junk path, a withheld secret is REPORTED (see {@link SalvageReport.withheld}): the file
|
|
49
|
+
* is real work that did not land, and whoever reads the run has to decide whether to re-create it
|
|
50
|
+
* or, if it holds a live credential, to rotate it.
|
|
51
|
+
*/
|
|
52
|
+
export const SALVAGE_SECRET_BASENAMES = [
|
|
53
|
+
'.netrc',
|
|
54
|
+
'.npmrc',
|
|
55
|
+
'.pypirc',
|
|
56
|
+
'credentials',
|
|
57
|
+
'id_dsa',
|
|
58
|
+
'id_ecdsa',
|
|
59
|
+
'id_ed25519',
|
|
60
|
+
'id_rsa',
|
|
61
|
+
'secrets.json',
|
|
62
|
+
'secrets.yaml',
|
|
63
|
+
'secrets.yml',
|
|
64
|
+
];
|
|
65
|
+
/**
|
|
66
|
+
* Suffixes that mark a key store or an environment file, withheld for the reason above.
|
|
67
|
+
*
|
|
68
|
+
* `.env` is here as well as in {@link isSecretBearingName}'s own `.env` / `.env.*` test, so that
|
|
69
|
+
* `prod.env` and `local.env` are caught alongside `.env` and `.env.production`. The sample
|
|
70
|
+
* allow-list is unaffected: `.env.example` ends in `.example`, not in `.env`.
|
|
71
|
+
*/
|
|
72
|
+
export const SALVAGE_SECRET_SUFFIXES = [
|
|
73
|
+
'.env',
|
|
74
|
+
'.jks',
|
|
75
|
+
'.key',
|
|
76
|
+
'.keystore',
|
|
77
|
+
'.p12',
|
|
78
|
+
'.pem',
|
|
79
|
+
'.pfx',
|
|
80
|
+
];
|
|
81
|
+
/** Path segments that are credential or state stores rather than source. */
|
|
82
|
+
export const SALVAGE_SECRET_SEGMENTS = ['.aws', '.gnupg', '.ssh', '.terraform'];
|
|
83
|
+
/**
|
|
84
|
+
* The `.env` files that carry no secret and ARE the deliverable: the checked-in sample every
|
|
85
|
+
* scaffold ships so a reader knows which variables the service wants.
|
|
86
|
+
*
|
|
87
|
+
* An allow-list rather than a cleverer rule, because the two are the same shape and only the
|
|
88
|
+
* convention tells them apart. `.env` and every other `.env.<something>` is withheld: a scaffolded
|
|
89
|
+
* `.env.local` or `.env.production` is exactly where a real key ends up.
|
|
90
|
+
*/
|
|
91
|
+
export const SALVAGE_ENV_SAMPLE_BASENAMES = [
|
|
92
|
+
'.env.defaults',
|
|
93
|
+
'.env.dist',
|
|
94
|
+
'.env.example',
|
|
95
|
+
'.env.sample',
|
|
96
|
+
'.env.template',
|
|
97
|
+
];
|
|
98
|
+
/** Whether `basename` is a credential-bearing file the salvage must never commit. */
|
|
99
|
+
function isSecretBearingName(basename) {
|
|
100
|
+
const lower = basename.toLowerCase();
|
|
101
|
+
if (SALVAGE_ENV_SAMPLE_BASENAMES.includes(lower))
|
|
102
|
+
return false;
|
|
103
|
+
if (lower === '.env' || lower.startsWith('.env.'))
|
|
104
|
+
return true;
|
|
105
|
+
if (SALVAGE_SECRET_BASENAMES.includes(lower))
|
|
106
|
+
return true;
|
|
107
|
+
return SALVAGE_SECRET_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The default bounds. Generous enough for a scaffolded service (the run this was written for left
|
|
111
|
+
* about twenty source files) and far below anything that looks like a build output or a dependency
|
|
112
|
+
* tree that slipped past the deny-list.
|
|
113
|
+
*/
|
|
114
|
+
export const DEFAULT_SALVAGE_BOUNDS = { maxFiles: 200, maxBytes: 5_000_000 };
|
|
115
|
+
/** How many paths a report quotes. The count carries the rest; a report is a summary, not a manifest. */
|
|
116
|
+
const REPORTED_PATHS = 20;
|
|
117
|
+
/** What the salvage would do with `path`: keep it, drop it quietly, or withhold it as a secret. */
|
|
118
|
+
export function classifySalvagePath(path) {
|
|
119
|
+
const segments = path.split('/');
|
|
120
|
+
const basename = segments[segments.length - 1] ?? '';
|
|
121
|
+
if (isSecretBearingName(basename))
|
|
122
|
+
return 'secret';
|
|
123
|
+
if (segments.some((segment) => SALVAGE_SECRET_SEGMENTS.includes(segment)))
|
|
124
|
+
return 'secret';
|
|
125
|
+
if (HARNESS_SENTINEL_FILES.includes(basename))
|
|
126
|
+
return 'skip';
|
|
127
|
+
if (SALVAGE_DENIED_SUFFIXES.some((suffix) => basename.endsWith(suffix)))
|
|
128
|
+
return 'skip';
|
|
129
|
+
if (segments.some((segment) => SALVAGE_DENIED_SEGMENTS.includes(segment)))
|
|
130
|
+
return 'skip';
|
|
131
|
+
return 'salvage';
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Split the untracked paths into what the salvage commits and what it withholds as secret-bearing.
|
|
135
|
+
*
|
|
136
|
+
* The secret check runs BEFORE the junk one, so a key under a denied directory is still counted as
|
|
137
|
+
* withheld rather than swallowed as junk: the point of the count is telling someone a credential
|
|
138
|
+
* may have been created, and where it happened to sit does not change that.
|
|
139
|
+
*/
|
|
140
|
+
export function partitionSalvageCandidates(paths) {
|
|
141
|
+
const candidates = [];
|
|
142
|
+
const withheld = [];
|
|
143
|
+
for (const path of paths) {
|
|
144
|
+
const disposition = classifySalvagePath(path);
|
|
145
|
+
if (disposition === 'salvage')
|
|
146
|
+
candidates.push(path);
|
|
147
|
+
else if (disposition === 'secret')
|
|
148
|
+
withheld.push(path);
|
|
149
|
+
}
|
|
150
|
+
return { candidates, withheld };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Commit the new, untracked, non-ignored files the agent left behind in `dir`.
|
|
154
|
+
*
|
|
155
|
+
* Bounded by FILE COUNT and TOTAL BYTES, and over either bound it salvages NOTHING and says so:
|
|
156
|
+
* committing a prefix would produce a tree that looks complete and is not, which is the one
|
|
157
|
+
* outcome worse than the loss this exists to prevent.
|
|
158
|
+
*
|
|
159
|
+
* The message names the salvage as a salvage. A commit that arrives on a branch with no
|
|
160
|
+
* explanation is indistinguishable from work the agent chose to make, and this work was chosen by
|
|
161
|
+
* nobody — the run was killed with it still on the floor.
|
|
162
|
+
*
|
|
163
|
+
* CODING MODE ONLY: the caller decides that. A read-only kind has no branch to carry a commit and
|
|
164
|
+
* must never be given one.
|
|
165
|
+
*/
|
|
166
|
+
export async function salvageUntrackedWork(args) {
|
|
167
|
+
const bounds = args.bounds ?? DEFAULT_SALVAGE_BOUNDS;
|
|
168
|
+
const { candidates, withheld } = partitionSalvageCandidates(await listUntrackedFiles(args.dir, args.signal));
|
|
169
|
+
if (withheld.length > 0) {
|
|
170
|
+
args.logger.warn('salvage: withheld secret-bearing files from the commit', { withheld });
|
|
171
|
+
}
|
|
172
|
+
const secrets = withheld.length > 0 ? { withheld } : {};
|
|
173
|
+
if (candidates.length === 0) {
|
|
174
|
+
return { status: 'none', files: [], fileCount: 0, totalBytes: 0, ...secrets };
|
|
175
|
+
}
|
|
176
|
+
const totalBytes = await measure(args.dir, candidates);
|
|
177
|
+
const report = {
|
|
178
|
+
files: candidates.slice(0, REPORTED_PATHS),
|
|
179
|
+
fileCount: candidates.length,
|
|
180
|
+
totalBytes,
|
|
181
|
+
...secrets,
|
|
182
|
+
};
|
|
183
|
+
if (candidates.length > bounds.maxFiles || totalBytes > bounds.maxBytes) {
|
|
184
|
+
const reason = `${candidates.length} uncommitted new files totalling ${totalBytes} bytes exceed the salvage ` +
|
|
185
|
+
`bounds (${bounds.maxFiles} files / ${bounds.maxBytes} bytes), so none were committed — a ` +
|
|
186
|
+
`partial salvage would read as a complete change.`;
|
|
187
|
+
args.logger.warn('salvage: refused, over bounds', { ...report, reason });
|
|
188
|
+
return { status: 'refused', ...report, reason };
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const commitSha = await commitPaths(args.dir, candidates, salvageCommitMessage(candidates.length, args.occasion), args.signal);
|
|
192
|
+
if (!commitSha)
|
|
193
|
+
return { status: 'none', files: [], fileCount: 0, totalBytes: 0, ...secrets };
|
|
194
|
+
args.logger.warn('salvage: committed the new files the agent left untracked', {
|
|
195
|
+
...report,
|
|
196
|
+
commitSha,
|
|
197
|
+
});
|
|
198
|
+
return { status: 'committed', ...report, commitSha };
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
202
|
+
args.logger.error('salvage: could not commit the files the agent left behind', {
|
|
203
|
+
...report,
|
|
204
|
+
reason,
|
|
205
|
+
});
|
|
206
|
+
return { status: 'failed', ...report, reason };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** The salvage commit's message: what it is, why it exists, and how much to trust it. */
|
|
210
|
+
export function salvageCommitMessage(fileCount, occasion) {
|
|
211
|
+
const noun = fileCount === 1 ? 'file' : 'files';
|
|
212
|
+
if (occasion.kind === 'settled') {
|
|
213
|
+
return (`chore: commit ${fileCount} new ${noun} the agent left untracked\n\n` +
|
|
214
|
+
`The agent created these files and finished without committing them. The harness committed ` +
|
|
215
|
+
`them so they reach the pull request rather than being discarded with the container.`);
|
|
216
|
+
}
|
|
217
|
+
return (`chore: salvage ${fileCount} uncommitted ${noun} from an aborted agent run\n\n` +
|
|
218
|
+
`This run was ABORTED (${occasion.cause}) with these files created and never committed. The ` +
|
|
219
|
+
`harness committed them so the work is not lost. They are NOT a reviewed change: nothing ` +
|
|
220
|
+
`checked that they are complete or consistent, and the run had not said it was finished.`);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The banner for a pull request whose ENTIRE content is a salvage.
|
|
224
|
+
*
|
|
225
|
+
* A branch the agent never committed to, which exists only because the harness swept up the
|
|
226
|
+
* untracked files left in that checkout, is not a change anyone proposed. It is still worth
|
|
227
|
+
* opening (dropping it is the loss this whole module exists to prevent, and a peer repository in a
|
|
228
|
+
* multi-repo run is where a cross-service change most easily goes missing), but its reviewer has
|
|
229
|
+
* to be told that before reading it as a considered contribution: the agent may have been building
|
|
230
|
+
* there, or it may have left scratch work behind while working on a sibling repository, and
|
|
231
|
+
* nothing in the diff distinguishes the two.
|
|
232
|
+
*
|
|
233
|
+
* Lives here with {@link salvageCommitMessage} and {@link describeSalvage} because all three are
|
|
234
|
+
* the same job — saying what a salvage is to whoever finds it — and the three had better not drift
|
|
235
|
+
* into describing it differently. The caller decides WHERE it goes.
|
|
236
|
+
*/
|
|
237
|
+
export function salvageOnlyNotice() {
|
|
238
|
+
return (`> **This branch is a salvage.** The agent committed nothing to this repository; everything ` +
|
|
239
|
+
`here is new files it left uncommitted in the checkout, swept up by the harness so they would ` +
|
|
240
|
+
`not be discarded with the container. Nothing has reviewed them for completeness or ` +
|
|
241
|
+
`relevance, and some may be scratch work from the agent's task in a sibling repository.`);
|
|
242
|
+
}
|
|
243
|
+
/** Total size of `paths` under `dir`; a file that cannot be stat'd counts as zero rather than failing. */
|
|
244
|
+
async function measure(dir, paths) {
|
|
245
|
+
const sizes = await Promise.all(paths.map((path) => stat(join(dir, path)).then((info) => info.size, () => 0)));
|
|
246
|
+
return sizes.reduce((total, size) => total + size, 0);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* What a human can act on, in one or two sentences. Joined onto the failure an aborted run
|
|
250
|
+
* reports, so the person reading "the run was killed" is told in the same breath what became of
|
|
251
|
+
* its work: on the branch and reviewed by nobody, still in the container, or never committed.
|
|
252
|
+
*
|
|
253
|
+
* `delivery` is supplied by whoever pushed. Absent means the caller is on a path where the
|
|
254
|
+
* ordinary push follows (the settle path), so there is nothing extra to say.
|
|
255
|
+
*/
|
|
256
|
+
export function describeSalvage(report, delivery) {
|
|
257
|
+
const parts = [describeOutcome(report, delivery), describeWithheld(report)].filter((part) => part !== undefined);
|
|
258
|
+
return parts.length > 0 ? parts.join(' ') : undefined;
|
|
259
|
+
}
|
|
260
|
+
/** The fate of the files the salvage DID try to keep. */
|
|
261
|
+
function describeOutcome(report, delivery) {
|
|
262
|
+
switch (report.status) {
|
|
263
|
+
case 'none':
|
|
264
|
+
return undefined;
|
|
265
|
+
case 'committed': {
|
|
266
|
+
const landed = delivery && !delivery.pushed
|
|
267
|
+
? `commit ${report.commitSha ?? 'unknown'}, which could NOT be pushed ` +
|
|
268
|
+
`(${delivery.reason ?? 'the push failed'}) and so is lost with the container`
|
|
269
|
+
: `commit ${report.commitSha ?? 'unknown'}`;
|
|
270
|
+
return (`${report.fileCount} uncommitted new file(s) the agent left behind were salvaged into ` +
|
|
271
|
+
`${landed}; this run was aborted, so review them before trusting them.`);
|
|
272
|
+
}
|
|
273
|
+
case 'refused':
|
|
274
|
+
return `Uncommitted new files were NOT salvaged: ${report.reason ?? 'over the salvage bounds'}`;
|
|
275
|
+
case 'failed':
|
|
276
|
+
return (`${report.fileCount} uncommitted new file(s) were left behind and could NOT be salvaged: ` +
|
|
277
|
+
`${report.reason ?? 'the commit failed'}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** The secret-bearing files the salvage refused, named so a live credential can be rotated. */
|
|
281
|
+
function describeWithheld(report) {
|
|
282
|
+
const withheld = report.withheld ?? [];
|
|
283
|
+
if (withheld.length === 0)
|
|
284
|
+
return undefined;
|
|
285
|
+
const shown = withheld.slice(0, REPORTED_PATHS).join(', ');
|
|
286
|
+
const rest = withheld.length > REPORTED_PATHS ? ` (and ${withheld.length - REPORTED_PATHS} more)` : '';
|
|
287
|
+
return (`${withheld.length} file(s) that look credential-bearing were withheld from the salvage and ` +
|
|
288
|
+
`are NOT on the branch: ${shown}${rest}. Re-create them, and rotate anything real they held.`);
|
|
289
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness's own side-channel sentinels, written INTO the checkout by the platform rather than
|
|
3
|
+
* by the agent. Excluded from the dirty check, or a run that wrote nothing but its effort report
|
|
4
|
+
* reads as productive and the guard it is meant to satisfy never fires again.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately just this list. A cleverer rule (anything dotted, anything the harness has ever
|
|
7
|
+
* touched) would start excluding the agent's own work — a `.github/workflows/ci.yml` or an
|
|
8
|
+
* `eslint.config.js` is exactly the greenfield deliverable this whole change exists to keep.
|
|
9
|
+
*/
|
|
10
|
+
export declare const HARNESS_SENTINEL_FILES: readonly string[];
|
|
11
|
+
/** What a workspace probe found. Never `undefined`: a probe that cannot answer THROWS. */
|
|
12
|
+
export interface WorkspaceEvidence {
|
|
13
|
+
/** Whether the repository changed: a dirty working tree, or HEAD moved off the pass's base. */
|
|
14
|
+
mutated: boolean;
|
|
15
|
+
/** HEAD at probe time, quoted in the guard's diagnostic so the evidence is on the record. */
|
|
16
|
+
headSha: string;
|
|
17
|
+
/** Whether HEAD moved off the sha the pass started from (the agent committed). */
|
|
18
|
+
headMoved: boolean;
|
|
19
|
+
/** How many non-sentinel paths the working tree reports as changed. */
|
|
20
|
+
dirtyPathCount: number;
|
|
21
|
+
}
|
|
22
|
+
/** Probes the working tree for evidence the agent changed the repository. Throws if it cannot. */
|
|
23
|
+
export type WorkspaceProbe = () => Promise<WorkspaceEvidence>;
|
|
24
|
+
/**
|
|
25
|
+
* The non-sentinel paths in a porcelain status — the working-tree half of the evidence.
|
|
26
|
+
*
|
|
27
|
+
* Pure, so the sentinel rule is unit-testable without a repository. A sentinel matches by BASENAME
|
|
28
|
+
* as well as by exact path: the agent's cwd is a service subdirectory in a monorepo, so its effort
|
|
29
|
+
* report lands at `services/api/.cat-effort.json`, and a root-anchored comparison would miss it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function agentChangedPaths(status: string): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Build the probe for one pass: the working tree at `dir`, judged against the sha the pass
|
|
34
|
+
* started from.
|
|
35
|
+
*
|
|
36
|
+
* The repository changed if the tree is dirty or HEAD has moved off `baseSha`. Both are the agent
|
|
37
|
+
* changing the repo, and between them they cover the two shapes the tool-name proxy missed: files
|
|
38
|
+
* written through `bash` and left in the tree, and files written and then committed. Gitignored
|
|
39
|
+
* paths are excluded by git itself, so an `npm install` still reads as nothing.
|
|
40
|
+
*
|
|
41
|
+
* ORDER MATTERS, and carries the "is this a repository at all" question. The status runs FIRST and
|
|
42
|
+
* is never caught: a `dir` that is no git repository fails there, and the driver treats a throw as
|
|
43
|
+
* inconclusive (re-arm and warn, never abort). HEAD is read second and a failure to read it is
|
|
44
|
+
* NOT a failed probe: a scaffold-from-scratch checkout has no commit yet, so `rev-parse HEAD`
|
|
45
|
+
* errors in exactly the case the dirty-tree half was written for. It reads as the empty sha, which
|
|
46
|
+
* is what the pass baselined against too, so the two agree that HEAD has not moved and the tree
|
|
47
|
+
* decides. Catching it around the status instead would turn "not a repository" into "no evidence
|
|
48
|
+
* of change" and hand the guard a clean verdict it has no business acting on.
|
|
49
|
+
*
|
|
50
|
+
* INJECTED, never imported by the guard: the guard stays pure and synchronous so it can be driven
|
|
51
|
+
* over a fixed event sequence in a unit test, and the git access lives out here where a test
|
|
52
|
+
* substitutes a stub.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createWorkspaceProbe(deps: {
|
|
55
|
+
dir: string;
|
|
56
|
+
/** HEAD when this pass began — a repair round's base is its own start, not the clone's. */
|
|
57
|
+
baseSha: string;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
}): WorkspaceProbe;
|
|
60
|
+
/**
|
|
61
|
+
* HEAD at `dir`, or the empty sha where there is no commit to read.
|
|
62
|
+
*
|
|
63
|
+
* The one shared reader for both the pass BASELINE and the probe, so the two can never disagree
|
|
64
|
+
* about what a commit-less checkout is worth: if the baseline tolerates a missing HEAD and the
|
|
65
|
+
* probe throws on it, a from-scratch build has no working bound at all.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readHeadOrEmpty(dir: string, signal?: AbortSignal): Promise<string>;
|
|
68
|
+
/**
|
|
69
|
+
* One probe over SEVERAL checkouts, for a run whose cwd is not itself a repository.
|
|
70
|
+
*
|
|
71
|
+
* A multi-repo run works at a WORKSPACE ROOT holding sibling checkouts, so probing the cwd asks
|
|
72
|
+
* git about a directory that is no repository: every probe throws, the driver re-arms forever and
|
|
73
|
+
* the no-edit bound is permanently unenforceable. The honest question there is "did the run change
|
|
74
|
+
* ANY of the repositories it was given", which is this.
|
|
75
|
+
*
|
|
76
|
+
* Mutation is a disjunction and inconclusiveness WINS OVER cleanliness. A leg that answers
|
|
77
|
+
* `mutated` settles it, since one changed repository is the run making progress. But a leg that
|
|
78
|
+
* THREW might have been the changed one, so `mutated: false` is only reported when every leg
|
|
79
|
+
* actually answered; otherwise this throws and the driver re-arms, which is the same fail-open
|
|
80
|
+
* disposition a single failing probe already gets. Killing a productive run is the expensive error.
|
|
81
|
+
*
|
|
82
|
+
* `headSha` joins the answering legs' shas, because a workspace has no single HEAD and quoting one
|
|
83
|
+
* leg's would put a sha in the abort diagnostic that says nothing about where the run actually was.
|
|
84
|
+
*/
|
|
85
|
+
export declare function composeWorkspaceProbes(probes: readonly WorkspaceProbe[]): WorkspaceProbe;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { changedPathsFromPorcelain, headCommit, workingTreeStatus } from './git.js';
|
|
2
|
+
import { EFFORT_REPORT_FILE } from './effort.js';
|
|
3
|
+
import { FOLLOW_UPS_FILENAME } from './follow-ups.js';
|
|
4
|
+
import { PR_DESCRIPTION_FILE } from './pr-description.js';
|
|
5
|
+
// The working-tree answer to "has this run actually changed the repository". The no-progress
|
|
6
|
+
// guard's no-edit bound used to answer it from TOOL NAMES, which is a fact about which tool the
|
|
7
|
+
// model happened to pick, not about the repo: an agent writing every file through `bash`
|
|
8
|
+
// (heredocs, `sed -i`, `node -e`) read as forty calls and not one edit however much it had built,
|
|
9
|
+
// and the guard killed it. This module is the evidence the guard now decides on instead.
|
|
10
|
+
//
|
|
11
|
+
// Kept OFF the hot path. The bound only matters at the instant it is about to abort, so the probe
|
|
12
|
+
// runs there and at most once per run (see `guard-driver.ts`), never per tool call.
|
|
13
|
+
/**
|
|
14
|
+
* The harness's own side-channel sentinels, written INTO the checkout by the platform rather than
|
|
15
|
+
* by the agent. Excluded from the dirty check, or a run that wrote nothing but its effort report
|
|
16
|
+
* reads as productive and the guard it is meant to satisfy never fires again.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately just this list. A cleverer rule (anything dotted, anything the harness has ever
|
|
19
|
+
* touched) would start excluding the agent's own work — a `.github/workflows/ci.yml` or an
|
|
20
|
+
* `eslint.config.js` is exactly the greenfield deliverable this whole change exists to keep.
|
|
21
|
+
*/
|
|
22
|
+
export const HARNESS_SENTINEL_FILES = [
|
|
23
|
+
EFFORT_REPORT_FILE,
|
|
24
|
+
FOLLOW_UPS_FILENAME,
|
|
25
|
+
PR_DESCRIPTION_FILE,
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* The non-sentinel paths in a porcelain status — the working-tree half of the evidence.
|
|
29
|
+
*
|
|
30
|
+
* Pure, so the sentinel rule is unit-testable without a repository. A sentinel matches by BASENAME
|
|
31
|
+
* as well as by exact path: the agent's cwd is a service subdirectory in a monorepo, so its effort
|
|
32
|
+
* report lands at `services/api/.cat-effort.json`, and a root-anchored comparison would miss it.
|
|
33
|
+
*/
|
|
34
|
+
export function agentChangedPaths(status) {
|
|
35
|
+
const sentinels = new Set(HARNESS_SENTINEL_FILES);
|
|
36
|
+
return changedPathsFromPorcelain(status).filter((path) => {
|
|
37
|
+
const basename = path.slice(path.lastIndexOf('/') + 1);
|
|
38
|
+
return !sentinels.has(path) && !sentinels.has(basename);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the probe for one pass: the working tree at `dir`, judged against the sha the pass
|
|
43
|
+
* started from.
|
|
44
|
+
*
|
|
45
|
+
* The repository changed if the tree is dirty or HEAD has moved off `baseSha`. Both are the agent
|
|
46
|
+
* changing the repo, and between them they cover the two shapes the tool-name proxy missed: files
|
|
47
|
+
* written through `bash` and left in the tree, and files written and then committed. Gitignored
|
|
48
|
+
* paths are excluded by git itself, so an `npm install` still reads as nothing.
|
|
49
|
+
*
|
|
50
|
+
* ORDER MATTERS, and carries the "is this a repository at all" question. The status runs FIRST and
|
|
51
|
+
* is never caught: a `dir` that is no git repository fails there, and the driver treats a throw as
|
|
52
|
+
* inconclusive (re-arm and warn, never abort). HEAD is read second and a failure to read it is
|
|
53
|
+
* NOT a failed probe: a scaffold-from-scratch checkout has no commit yet, so `rev-parse HEAD`
|
|
54
|
+
* errors in exactly the case the dirty-tree half was written for. It reads as the empty sha, which
|
|
55
|
+
* is what the pass baselined against too, so the two agree that HEAD has not moved and the tree
|
|
56
|
+
* decides. Catching it around the status instead would turn "not a repository" into "no evidence
|
|
57
|
+
* of change" and hand the guard a clean verdict it has no business acting on.
|
|
58
|
+
*
|
|
59
|
+
* INJECTED, never imported by the guard: the guard stays pure and synchronous so it can be driven
|
|
60
|
+
* over a fixed event sequence in a unit test, and the git access lives out here where a test
|
|
61
|
+
* substitutes a stub.
|
|
62
|
+
*/
|
|
63
|
+
export function createWorkspaceProbe(deps) {
|
|
64
|
+
return async () => {
|
|
65
|
+
const status = await workingTreeStatus(deps.dir, deps.signal);
|
|
66
|
+
const dirty = agentChangedPaths(status);
|
|
67
|
+
const headSha = await readHeadOrEmpty(deps.dir, deps.signal);
|
|
68
|
+
const headMoved = headSha !== deps.baseSha;
|
|
69
|
+
return {
|
|
70
|
+
mutated: dirty.length > 0 || headMoved,
|
|
71
|
+
headSha,
|
|
72
|
+
headMoved,
|
|
73
|
+
dirtyPathCount: dirty.length,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* HEAD at `dir`, or the empty sha where there is no commit to read.
|
|
79
|
+
*
|
|
80
|
+
* The one shared reader for both the pass BASELINE and the probe, so the two can never disagree
|
|
81
|
+
* about what a commit-less checkout is worth: if the baseline tolerates a missing HEAD and the
|
|
82
|
+
* probe throws on it, a from-scratch build has no working bound at all.
|
|
83
|
+
*/
|
|
84
|
+
export async function readHeadOrEmpty(dir, signal) {
|
|
85
|
+
return headCommit(dir, signal).catch(() => '');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One probe over SEVERAL checkouts, for a run whose cwd is not itself a repository.
|
|
89
|
+
*
|
|
90
|
+
* A multi-repo run works at a WORKSPACE ROOT holding sibling checkouts, so probing the cwd asks
|
|
91
|
+
* git about a directory that is no repository: every probe throws, the driver re-arms forever and
|
|
92
|
+
* the no-edit bound is permanently unenforceable. The honest question there is "did the run change
|
|
93
|
+
* ANY of the repositories it was given", which is this.
|
|
94
|
+
*
|
|
95
|
+
* Mutation is a disjunction and inconclusiveness WINS OVER cleanliness. A leg that answers
|
|
96
|
+
* `mutated` settles it, since one changed repository is the run making progress. But a leg that
|
|
97
|
+
* THREW might have been the changed one, so `mutated: false` is only reported when every leg
|
|
98
|
+
* actually answered; otherwise this throws and the driver re-arms, which is the same fail-open
|
|
99
|
+
* disposition a single failing probe already gets. Killing a productive run is the expensive error.
|
|
100
|
+
*
|
|
101
|
+
* `headSha` joins the answering legs' shas, because a workspace has no single HEAD and quoting one
|
|
102
|
+
* leg's would put a sha in the abort diagnostic that says nothing about where the run actually was.
|
|
103
|
+
*/
|
|
104
|
+
export function composeWorkspaceProbes(probes) {
|
|
105
|
+
if (probes.length === 1)
|
|
106
|
+
return probes[0];
|
|
107
|
+
return async () => {
|
|
108
|
+
const settled = await Promise.allSettled(probes.map((probe) => probe()));
|
|
109
|
+
const answered = settled.filter((result) => result.status === 'fulfilled');
|
|
110
|
+
const evidence = answered.map((result) => result.value);
|
|
111
|
+
const mutated = evidence.some((one) => one.mutated);
|
|
112
|
+
if (!mutated && answered.length < probes.length) {
|
|
113
|
+
const first = settled.find((result) => result.status === 'rejected');
|
|
114
|
+
throw new Error(`${probes.length - answered.length} of ${probes.length} checkouts could not be probed and ` +
|
|
115
|
+
`none of the rest had changed, so whether this run changed anything is unknown`, { cause: first?.status === 'rejected' ? first.reason : undefined });
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
mutated,
|
|
119
|
+
headSha: evidence.map((one) => one.headSha).join(', '),
|
|
120
|
+
headMoved: evidence.some((one) => one.headMoved),
|
|
121
|
+
dirtyPathCount: evidence.reduce((total, one) => total + one.dirtyPathCount, 0),
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.135.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",
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@cat-factory/kernel": "0.
|
|
29
|
-
"@cat-factory/server": "0.306.
|
|
30
|
-
"@cat-factory/spend": "0.16.
|
|
28
|
+
"@cat-factory/kernel": "0.321.0",
|
|
29
|
+
"@cat-factory/server": "0.306.5",
|
|
30
|
+
"@cat-factory/spend": "0.16.17",
|
|
31
31
|
"@hono/node-server": "^2.1.1",
|
|
32
32
|
"@types/node": "^26.2.0",
|
|
33
33
|
"hono": "^4.13.4",
|
|
@@ -519,70 +519,44 @@ export function claudeMcpConfig(servers: McpServerSpec[]): {
|
|
|
519
519
|
return { mcpServers }
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
-
/**
|
|
523
|
-
* The claude-code CLI's own tools, named so an `--allowedTools` list can never take them away.
|
|
524
|
-
*
|
|
525
|
-
* An allow-list is whole-session: it does not scope itself to MCP just because every entry we
|
|
526
|
-
* generate happens to be an `mcp__*` pattern. So the moment one tool server narrows its tools, the
|
|
527
|
-
* list has to re-grant the agent's built-in file/bash/search tools or the run is handed a narrowed
|
|
528
|
-
* MCP surface AND no way to read, edit or build anything.
|
|
529
|
-
*
|
|
530
|
-
* Bias this list toward OVER-inclusion. A name the CLI does not have is inert; a name it has and
|
|
531
|
-
* this list lacks is a tool silently removed from a run — which surfaces as an agent that cannot
|
|
532
|
-
* do its work, far from the registration that caused it. Historical/renamed spellings are kept for
|
|
533
|
-
* the same reason: the harness image is pinned per workspace, so one image faces several CLI
|
|
534
|
-
* versions. When the CLI gains a tool, add it here.
|
|
535
|
-
*/
|
|
536
|
-
export const CLAUDE_BUILT_IN_TOOLS: readonly string[] = [
|
|
537
|
-
'Agent',
|
|
538
|
-
'Bash',
|
|
539
|
-
'BashOutput',
|
|
540
|
-
'Edit',
|
|
541
|
-
'ExitPlanMode',
|
|
542
|
-
'Glob',
|
|
543
|
-
'Grep',
|
|
544
|
-
'KillBash',
|
|
545
|
-
'KillShell',
|
|
546
|
-
'ListMcpResources',
|
|
547
|
-
'MultiEdit',
|
|
548
|
-
'NotebookEdit',
|
|
549
|
-
'NotebookRead',
|
|
550
|
-
'Read',
|
|
551
|
-
'ReadMcpResource',
|
|
552
|
-
'SlashCommand',
|
|
553
|
-
'Skill',
|
|
554
|
-
'Task',
|
|
555
|
-
'TaskCreate',
|
|
556
|
-
'TaskUpdate',
|
|
557
|
-
'TodoWrite',
|
|
558
|
-
'WebFetch',
|
|
559
|
-
'WebSearch',
|
|
560
|
-
'Write',
|
|
561
|
-
]
|
|
562
|
-
|
|
563
522
|
/**
|
|
564
523
|
* The tool-name list for `--allowedTools`: every declared server's tools in the CLI's
|
|
565
|
-
* `mcp__<server>__<tool>` convention, PLUS
|
|
566
|
-
* restriction contributes the whole-server pattern, so
|
|
524
|
+
* `mcp__<server>__<tool>` convention, PLUS the built-in tools this run declared with `--tools`
|
|
525
|
+
* (`CLAUDE_TOOL_SET`). A server with no restriction contributes the whole-server pattern, so
|
|
526
|
+
* an allow-list stays one entry per server.
|
|
567
527
|
*
|
|
568
528
|
* Returns undefined when NO server restricts its tools — there is then nothing to narrow, and the
|
|
569
529
|
* safest list is the one we never send.
|
|
570
530
|
*
|
|
531
|
+
* An allow-list is whole-session, not MCP-scoped: it does not confine itself to MCP just because
|
|
532
|
+
* every entry we generate happens to be an `mcp__*` pattern. So the moment one tool server narrows
|
|
533
|
+
* its tools, the list has to carry the built-in file/bash/search tools too or the run is handed a
|
|
534
|
+
* narrowed MCP surface AND no way to read, edit or build anything.
|
|
535
|
+
*
|
|
536
|
+
* And carrying them is not merely a re-grant. MEASURED against CLI 2.1.245, the list is ADDITIVE:
|
|
537
|
+
* `--allowedTools "Bash,Grep"` yields the CLI's default set PLUS `Glob` and `Grep`, and an EMPTY
|
|
538
|
+
* list yields the default set plus `Glob`, `Grep` and the four `Task*` tools. A name here UNLOCKS
|
|
539
|
+
* a tool. That is why `builtIns` is the run's OWN declared set passed by reference rather than a
|
|
540
|
+
* constant re-read here: a separately-derived list would silently re-grant exactly what the
|
|
541
|
+
* `--tools` declaration withheld, and only on the runs that happen to wire a narrowing tool
|
|
542
|
+
* server.
|
|
543
|
+
*
|
|
571
544
|
* Whether the CLI ENFORCES this list is permission-mode dependent and not a contract we control:
|
|
572
545
|
* the run uses `--permission-mode bypassPermissions` (the container is the sandbox and no human is
|
|
573
|
-
* there to approve a call), under which an allow-list grants rather than gates.
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
* scoping, not as a security boundary: a server the agent must not reach fully should not be
|
|
578
|
-
* wired for that kind at all.
|
|
546
|
+
* there to approve a call), under which an allow-list grants rather than gates. The always-present
|
|
547
|
+
* channel is the PROMPT, which states each server's permitted tool names on every harness. Treat
|
|
548
|
+
* `allowedTools` as scoping, not as a security boundary: a server the agent must not reach fully
|
|
549
|
+
* should not be wired for that kind at all.
|
|
579
550
|
*/
|
|
580
|
-
export function claudeAllowedToolPatterns(
|
|
551
|
+
export function claudeAllowedToolPatterns(
|
|
552
|
+
servers: McpServerSpec[],
|
|
553
|
+
builtIns: readonly string[],
|
|
554
|
+
): string[] | undefined {
|
|
581
555
|
if (!servers.some((s) => s.allowedTools?.length)) return undefined
|
|
582
556
|
const mcp = servers.flatMap((s) =>
|
|
583
557
|
s.allowedTools?.length ? s.allowedTools.map((t) => `mcp__${s.id}__${t}`) : [`mcp__${s.id}`],
|
|
584
558
|
)
|
|
585
|
-
return [...mcp, ...
|
|
559
|
+
return [...mcp, ...builtIns]
|
|
586
560
|
}
|
|
587
561
|
|
|
588
562
|
/** Escape a string as a TOML basic string (Codex config is TOML, not JSON). */
|