@open-agent-toolkit/cli 0.2.56 → 0.2.58
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/assets/agents/oat-phase-implementer.md +166 -10
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/contributing/smoke-testing.md +44 -0
- package/assets/docs/workflows/projects/implementation-execution.md +6 -2
- package/assets/docs/workflows/skills/explainer-kit.md +8 -5
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/analyze/SKILL.md +18 -17
- package/assets/skills/oat-brainstorm/SKILL.md +2 -2
- package/assets/skills/oat-doctor/SKILL.md +49 -36
- package/assets/skills/oat-idea-summarize/SKILL.md +2 -2
- package/assets/skills/oat-project-autonomous/SKILL.md +17 -10
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +3 -3
- package/assets/skills/oat-project-complete/SKILL.md +18 -8
- package/assets/skills/oat-project-design/SKILL.md +15 -1
- package/assets/skills/oat-project-discover/SKILL.md +3 -3
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +3 -3
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +12 -3
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +3 -3
- package/assets/skills/oat-project-implement/references/phase-execution.md +225 -7
- package/assets/skills/oat-project-implement/references/plan-and-resume.md +5 -2
- package/assets/skills/oat-project-implement/scripts/capture-dirty-tree.mjs +1069 -0
- package/assets/skills/oat-project-import-plan/SKILL.md +12 -6
- package/assets/skills/oat-project-next/SKILL.md +7 -5
- package/assets/skills/oat-project-plan/SKILL.md +24 -12
- package/assets/skills/oat-project-pr-final/SKILL.md +2 -2
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +3 -3
- package/assets/skills/oat-project-quick-start/SKILL.md +11 -6
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +3 -3
- package/assets/skills/oat-project-retro/SKILL.md +4 -2
- package/assets/skills/oat-project-review-receive/SKILL.md +9 -5
- package/assets/skills/oat-project-revise/SKILL.md +6 -6
- package/package.json +2 -2
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Fail-closed capture of a lost child's uncommitted work.
|
|
4
|
+
*
|
|
5
|
+
* The root lifecycle runs this before it restores a phase worktree to its clean
|
|
6
|
+
* base, so that a fresh same-target continuation can apply, review, and commit
|
|
7
|
+
* the recovered work as its first action. Every failure mode is a hard stop
|
|
8
|
+
* with a named reason; nothing here performs a best-effort restore, and the
|
|
9
|
+
* script never writes to the worktree it captures.
|
|
10
|
+
*/
|
|
11
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { realpathSync } from 'node:fs';
|
|
14
|
+
import {
|
|
15
|
+
chmod,
|
|
16
|
+
copyFile,
|
|
17
|
+
lstat,
|
|
18
|
+
mkdir,
|
|
19
|
+
mkdtemp,
|
|
20
|
+
readdir,
|
|
21
|
+
readFile,
|
|
22
|
+
realpath,
|
|
23
|
+
rm,
|
|
24
|
+
stat,
|
|
25
|
+
writeFile,
|
|
26
|
+
} from 'node:fs/promises';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
29
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { promisify } from 'node:util';
|
|
32
|
+
|
|
33
|
+
const execFile = promisify(execFileCallback);
|
|
34
|
+
|
|
35
|
+
const NUL = '\u0000';
|
|
36
|
+
const MAX_GIT_BUFFER = 256 * 1024 * 1024;
|
|
37
|
+
const DEFAULT_QUIESCE_INTERVAL_MS = 750;
|
|
38
|
+
const MIN_QUIESCE_INTERVAL_MS = 25;
|
|
39
|
+
const PROOF_FILE = 'round-trip.proof';
|
|
40
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
41
|
+
|
|
42
|
+
/** Named stop reasons. Callers report these verbatim; none is recoverable. */
|
|
43
|
+
export const CAPTURE_REASONS = Object.freeze({
|
|
44
|
+
activeWriter: 'active-writer',
|
|
45
|
+
unsupportedDirt: 'unsupported-dirt',
|
|
46
|
+
roundTripFailed: 'round-trip-failed',
|
|
47
|
+
artifactVerificationFailed: 'artifact-verification-failed',
|
|
48
|
+
invalidUsage: 'invalid-usage',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const EXIT_CODES = Object.freeze({
|
|
52
|
+
'active-writer': 2,
|
|
53
|
+
'unsupported-dirt': 3,
|
|
54
|
+
'round-trip-failed': 4,
|
|
55
|
+
'artifact-verification-failed': 5,
|
|
56
|
+
'invalid-usage': 64,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const RENAME_DETECTION_CONFIG = Object.freeze([
|
|
60
|
+
'status.renames=true',
|
|
61
|
+
'diff.renames=true',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const IN_PROGRESS_MARKERS = Object.freeze([
|
|
65
|
+
'MERGE_HEAD',
|
|
66
|
+
'CHERRY_PICK_HEAD',
|
|
67
|
+
'REVERT_HEAD',
|
|
68
|
+
'rebase-merge',
|
|
69
|
+
'rebase-apply',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function captureError(reason, message, details = {}) {
|
|
73
|
+
const error = new Error(message);
|
|
74
|
+
error.code = 'E_CAPTURE_DIRTY_TREE';
|
|
75
|
+
error.reason = reason;
|
|
76
|
+
Object.assign(error, details);
|
|
77
|
+
return error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function git(
|
|
81
|
+
cwd,
|
|
82
|
+
args,
|
|
83
|
+
{ binary = false, literalPathspecs = false, config = [] } = {},
|
|
84
|
+
) {
|
|
85
|
+
// `--literal-pathspecs` exports GIT_LITERAL_PATHSPECS=1 to anything Git
|
|
86
|
+
// spawns, including repository hooks, so it is scoped to the one call that
|
|
87
|
+
// feeds status-derived filenames back to Git as a pathspec.
|
|
88
|
+
const { stdout } = await execFile(
|
|
89
|
+
'git',
|
|
90
|
+
[
|
|
91
|
+
'--no-optional-locks',
|
|
92
|
+
...(literalPathspecs ? ['--literal-pathspecs'] : []),
|
|
93
|
+
...config.flatMap((setting) => ['-c', setting]),
|
|
94
|
+
...args,
|
|
95
|
+
],
|
|
96
|
+
{
|
|
97
|
+
cwd,
|
|
98
|
+
encoding: binary ? 'buffer' : 'utf8',
|
|
99
|
+
maxBuffer: MAX_GIT_BUFFER,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
return stdout;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function pathExists(path) {
|
|
106
|
+
try {
|
|
107
|
+
await stat(path);
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sha256(bytes) {
|
|
115
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse `git status --porcelain=v2 -z`. A `2` (rename/copy) record is followed
|
|
120
|
+
* by a second NUL-terminated field holding the original path, so the reader
|
|
121
|
+
* consumes that extra token rather than treating it as its own record. A path
|
|
122
|
+
* Git emits in bytes that are not valid UTF-8 cannot be recorded or restored
|
|
123
|
+
* losslessly here, so it is refused instead of silently mangled.
|
|
124
|
+
*/
|
|
125
|
+
export function parsePorcelainStatus(buffer) {
|
|
126
|
+
const decoded = buffer.toString('utf8');
|
|
127
|
+
if (!Buffer.from(decoded, 'utf8').equals(buffer)) {
|
|
128
|
+
throw captureError(
|
|
129
|
+
CAPTURE_REASONS.unsupportedDirt,
|
|
130
|
+
'Refusing to capture: the worktree holds a path that is not valid UTF-8, which cannot be recorded or restored losslessly.',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tokens = decoded.split(NUL);
|
|
135
|
+
const entries = [];
|
|
136
|
+
|
|
137
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
138
|
+
const record = tokens[index];
|
|
139
|
+
if (!record) continue;
|
|
140
|
+
const fields = record.split(' ');
|
|
141
|
+
|
|
142
|
+
if (record.startsWith('1 ')) {
|
|
143
|
+
entries.push({
|
|
144
|
+
kind: 'ordinary',
|
|
145
|
+
xy: fields[1],
|
|
146
|
+
sub: fields[2],
|
|
147
|
+
path: fields.slice(8).join(' '),
|
|
148
|
+
});
|
|
149
|
+
} else if (record.startsWith('2 ')) {
|
|
150
|
+
entries.push({
|
|
151
|
+
kind: 'rename',
|
|
152
|
+
xy: fields[1],
|
|
153
|
+
sub: fields[2],
|
|
154
|
+
path: fields.slice(9).join(' '),
|
|
155
|
+
origPath: tokens[index + 1] ?? '',
|
|
156
|
+
});
|
|
157
|
+
index += 1;
|
|
158
|
+
} else if (record.startsWith('u ')) {
|
|
159
|
+
entries.push({
|
|
160
|
+
kind: 'unmerged',
|
|
161
|
+
xy: fields[1],
|
|
162
|
+
sub: fields[2],
|
|
163
|
+
path: fields.slice(10).join(' '),
|
|
164
|
+
});
|
|
165
|
+
} else if (record.startsWith('? ')) {
|
|
166
|
+
entries.push({ kind: 'untracked', path: record.slice(2) });
|
|
167
|
+
} else if (record.startsWith('! ')) {
|
|
168
|
+
entries.push({ kind: 'ignored', path: record.slice(2) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return entries;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Rename detection is forced on rather than inherited. With `status.renames`
|
|
177
|
+
* disabled a staged rename is reported as an ordinary delete plus an ordinary
|
|
178
|
+
* add, so whether the dirt is classified `unsupported-dirt` would otherwise
|
|
179
|
+
* depend on the repository's configuration rather than on the tree.
|
|
180
|
+
*/
|
|
181
|
+
async function statusBytes(worktree) {
|
|
182
|
+
return await git(
|
|
183
|
+
worktree,
|
|
184
|
+
['status', '--porcelain=v2', '-z', '--untracked-files=all'],
|
|
185
|
+
{ binary: true, config: RENAME_DETECTION_CONFIG },
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Content digest plus the one permission bit Git tracks. Comparing the full
|
|
191
|
+
* mode would produce false mismatches from umask differences between the
|
|
192
|
+
* captured worktree and a fresh checkout; the executable bit is the part Git
|
|
193
|
+
* actually carries, so it is the part the round trip must reproduce.
|
|
194
|
+
*/
|
|
195
|
+
async function fingerprintPath(worktree, relativePath) {
|
|
196
|
+
const absolute = join(worktree, relativePath);
|
|
197
|
+
try {
|
|
198
|
+
const entry = await lstat(absolute);
|
|
199
|
+
const executable = (entry.mode & 0o111) === 0 ? '-' : 'x';
|
|
200
|
+
const digest = sha256(await readFile(absolute));
|
|
201
|
+
return `${relativePath} | ${digest} | ${executable}`;
|
|
202
|
+
} catch {
|
|
203
|
+
return `${relativePath} | absent | -`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A worktree snapshot is `HEAD`, the porcelain status, and a fingerprint for
|
|
209
|
+
* every path the status names. Porcelain v2 carries HEAD and index object ids
|
|
210
|
+
* but no worktree object id, so status alone cannot see a writer that keeps
|
|
211
|
+
* appending to an already-modified file, and status plus digests alone cannot
|
|
212
|
+
* see a writer that modifies a clean path and then commits it. Together they
|
|
213
|
+
* can.
|
|
214
|
+
*/
|
|
215
|
+
async function snapshotWorktree(worktree) {
|
|
216
|
+
const head = (await git(worktree, ['rev-parse', 'HEAD'])).trim();
|
|
217
|
+
const status = await statusBytes(worktree);
|
|
218
|
+
const entries = parsePorcelainStatus(status);
|
|
219
|
+
const paths = new Set();
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
if (entry.kind === 'ignored') continue;
|
|
222
|
+
paths.add(entry.path);
|
|
223
|
+
if (entry.origPath) paths.add(entry.origPath);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const fingerprints = [];
|
|
227
|
+
for (const path of [...paths].sort()) {
|
|
228
|
+
fingerprints.push(await fingerprintPath(worktree, path));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
head,
|
|
233
|
+
status,
|
|
234
|
+
entries,
|
|
235
|
+
fingerprints: fingerprints.join('\n'),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function snapshotsMatch(first, second) {
|
|
240
|
+
return (
|
|
241
|
+
first.head === second.head &&
|
|
242
|
+
first.status.equals(second.status) &&
|
|
243
|
+
first.fingerprints === second.fingerprints
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function assertWriterQuiescent({ worktree, writerIdentity, intervalMs }) {
|
|
248
|
+
if (!writerIdentity) {
|
|
249
|
+
throw captureError(
|
|
250
|
+
CAPTURE_REASONS.activeWriter,
|
|
251
|
+
'Refusing to capture: the former child writer identity was not supplied, so quiescence cannot be established.',
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const pidMatch = /^pid:(\d+)$/.exec(writerIdentity);
|
|
256
|
+
if (pidMatch) {
|
|
257
|
+
let alive = false;
|
|
258
|
+
try {
|
|
259
|
+
process.kill(Number(pidMatch[1]), 0);
|
|
260
|
+
alive = true;
|
|
261
|
+
} catch {
|
|
262
|
+
alive = false;
|
|
263
|
+
}
|
|
264
|
+
if (alive) {
|
|
265
|
+
throw captureError(
|
|
266
|
+
CAPTURE_REASONS.activeWriter,
|
|
267
|
+
`Refusing to capture: former child process ${pidMatch[1]} is still running.`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!Number.isFinite(intervalMs) || intervalMs < MIN_QUIESCE_INTERVAL_MS) {
|
|
273
|
+
throw captureError(
|
|
274
|
+
CAPTURE_REASONS.invalidUsage,
|
|
275
|
+
`The quiescence interval must be a finite value of at least ${MIN_QUIESCE_INTERVAL_MS}ms.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const first = await snapshotWorktree(worktree);
|
|
280
|
+
await delay(intervalMs);
|
|
281
|
+
const second = await snapshotWorktree(worktree);
|
|
282
|
+
if (!snapshotsMatch(first, second)) {
|
|
283
|
+
throw captureError(
|
|
284
|
+
CAPTURE_REASONS.activeWriter,
|
|
285
|
+
'Refusing to capture: the worktree changed between two quiescence snapshots, so a writer is still active.',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return second;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function assertNoInProgressOperation(worktree) {
|
|
293
|
+
const gitDir = resolve(
|
|
294
|
+
worktree,
|
|
295
|
+
(await git(worktree, ['rev-parse', '--git-dir'])).trim(),
|
|
296
|
+
);
|
|
297
|
+
for (const marker of IN_PROGRESS_MARKERS) {
|
|
298
|
+
if (await pathExists(join(gitDir, marker))) {
|
|
299
|
+
throw captureError(
|
|
300
|
+
CAPTURE_REASONS.unsupportedDirt,
|
|
301
|
+
`Refusing to capture: an in-progress ${marker} operation is not a supported dirt class.`,
|
|
302
|
+
{ marker },
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function withinBounds(path, boundedFiles) {
|
|
309
|
+
return boundedFiles.some(
|
|
310
|
+
(bound) => path === bound || path.startsWith(`${bound}/`),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function classifyDirt(entries, boundedFiles) {
|
|
315
|
+
const tracked = [];
|
|
316
|
+
const untracked = [];
|
|
317
|
+
|
|
318
|
+
for (const entry of entries) {
|
|
319
|
+
if (entry.kind === 'ignored') continue;
|
|
320
|
+
|
|
321
|
+
if (entry.kind === 'unmerged') {
|
|
322
|
+
throw captureError(
|
|
323
|
+
CAPTURE_REASONS.unsupportedDirt,
|
|
324
|
+
`Refusing to capture: unmerged path ${entry.path} is not a supported dirt class.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// A staged rename has no per-path restore: unstaging leaves the destination
|
|
329
|
+
// untracked and the source missing from the index, so the caller-owned
|
|
330
|
+
// restore below could not return the tree to its clean base.
|
|
331
|
+
if (entry.kind === 'rename') {
|
|
332
|
+
throw captureError(
|
|
333
|
+
CAPTURE_REASONS.unsupportedDirt,
|
|
334
|
+
`Refusing to capture: staged rename or copy ${entry.origPath} -> ${entry.path} is not a supported dirt class.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (entry.kind === 'untracked') {
|
|
339
|
+
untracked.push(entry.path);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (typeof entry.sub === 'string' && !entry.sub.startsWith('N')) {
|
|
344
|
+
throw captureError(
|
|
345
|
+
CAPTURE_REASONS.unsupportedDirt,
|
|
346
|
+
`Refusing to capture: submodule change at ${entry.path} is not a supported dirt class.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
tracked.push(entry.path);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const overlap = tracked.filter((path) => untracked.includes(path));
|
|
354
|
+
if (overlap.length > 0) {
|
|
355
|
+
// One path cannot be both restored from HEAD and deleted as untracked; a
|
|
356
|
+
// staged deletion whose worktree file was recreated has no single action.
|
|
357
|
+
throw captureError(
|
|
358
|
+
CAPTURE_REASONS.unsupportedDirt,
|
|
359
|
+
`Refusing to capture: ${overlap.join(', ')} is both a tracked change and an untracked file, which has no single restore action.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const affectedPaths = [...new Set([...tracked, ...untracked])].sort();
|
|
364
|
+
const outOfBounds = affectedPaths.filter(
|
|
365
|
+
(path) => !withinBounds(path, boundedFiles),
|
|
366
|
+
);
|
|
367
|
+
if (outOfBounds.length > 0) {
|
|
368
|
+
throw captureError(
|
|
369
|
+
CAPTURE_REASONS.unsupportedDirt,
|
|
370
|
+
`Refusing to capture: ${outOfBounds.length} path(s) fall outside the phase bounded_files: ${outOfBounds.join(', ')}.`,
|
|
371
|
+
{ outOfBounds },
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
trackedPaths: [...new Set(tracked)].sort(),
|
|
377
|
+
untrackedPaths: [...new Set(untracked)].sort(),
|
|
378
|
+
affectedPaths,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The caller-owned restore, derived rather than guessed. A tracked path that
|
|
384
|
+
* exists in `HEAD` is reset from `HEAD`; a path staged as a new file has no
|
|
385
|
+
* `HEAD` content to restore, so it is unstaged and removed instead.
|
|
386
|
+
*/
|
|
387
|
+
async function buildRestorePlan(worktree, trackedPaths, untrackedPaths) {
|
|
388
|
+
const inHead = new Set();
|
|
389
|
+
if (trackedPaths.length > 0) {
|
|
390
|
+
const listed = await git(
|
|
391
|
+
worktree,
|
|
392
|
+
['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', ...trackedPaths],
|
|
393
|
+
{ literalPathspecs: true },
|
|
394
|
+
);
|
|
395
|
+
for (const path of listed.split(NUL)) {
|
|
396
|
+
if (path) inHead.add(path);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return [
|
|
401
|
+
...trackedPaths.map((path) => ({
|
|
402
|
+
path,
|
|
403
|
+
action: inHead.has(path) ? 'restore-from-head' : 'unstage-and-remove',
|
|
404
|
+
})),
|
|
405
|
+
...untrackedPaths.map((path) => ({ path, action: 'remove-untracked' })),
|
|
406
|
+
];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function writeArtifactFile(
|
|
410
|
+
artifactDir,
|
|
411
|
+
relativePath,
|
|
412
|
+
bytes,
|
|
413
|
+
files,
|
|
414
|
+
{ executable = false } = {},
|
|
415
|
+
) {
|
|
416
|
+
const target = join(artifactDir, relativePath);
|
|
417
|
+
await mkdir(dirname(target), { recursive: true });
|
|
418
|
+
await writeFile(target, bytes, { flag: 'wx' });
|
|
419
|
+
if (executable) await chmod(target, 0o755);
|
|
420
|
+
files.push({
|
|
421
|
+
path: relativePath,
|
|
422
|
+
sha256: sha256(bytes),
|
|
423
|
+
bytes: bytes.length,
|
|
424
|
+
executable,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function applyArtifact(artifactDir, manifest, targetWorktree) {
|
|
429
|
+
const indexPatch = manifest.files.find((file) => file.path === 'index.patch');
|
|
430
|
+
const worktreePatch = manifest.files.find(
|
|
431
|
+
(file) => file.path === 'worktree.patch',
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
if (indexPatch && indexPatch.bytes > 0) {
|
|
435
|
+
await git(targetWorktree, [
|
|
436
|
+
'apply',
|
|
437
|
+
'--index',
|
|
438
|
+
'--whitespace=nowarn',
|
|
439
|
+
join(artifactDir, 'index.patch'),
|
|
440
|
+
]);
|
|
441
|
+
}
|
|
442
|
+
if (worktreePatch && worktreePatch.bytes > 0) {
|
|
443
|
+
await git(targetWorktree, [
|
|
444
|
+
'apply',
|
|
445
|
+
'--whitespace=nowarn',
|
|
446
|
+
join(artifactDir, 'worktree.patch'),
|
|
447
|
+
]);
|
|
448
|
+
}
|
|
449
|
+
for (const path of manifest.untrackedPaths) {
|
|
450
|
+
const mirrored = join('untracked', path);
|
|
451
|
+
const target = join(targetWorktree, path);
|
|
452
|
+
await mkdir(dirname(target), { recursive: true });
|
|
453
|
+
await copyFile(join(artifactDir, mirrored), target);
|
|
454
|
+
// Restore the executable bit from the manifest, not from whatever mode the
|
|
455
|
+
// mirrored copy happens to carry on disk; only the manifest is digest
|
|
456
|
+
// anchored.
|
|
457
|
+
const recorded = manifest.files.find((file) => file.path === mirrored);
|
|
458
|
+
if (recorded) await chmod(target, recorded.executable ? 0o755 : 0o644);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Prove the artifact as written to disk replays into a pristine replica of the
|
|
464
|
+
* captured `HEAD` and reproduces the captured worktree exactly. Any mismatch is
|
|
465
|
+
* `round-trip-failed`; the captured worktree is never written to.
|
|
466
|
+
*
|
|
467
|
+
* The seal is written here and nowhere else, and the digest and size a capture
|
|
468
|
+
* hands to the continuation are the ones this function returns. A capture that
|
|
469
|
+
* skipped this proof would therefore have no reference to publish and no seal
|
|
470
|
+
* for `--verify` to accept.
|
|
471
|
+
*/
|
|
472
|
+
export async function proveArtifactRoundTrip({ worktree, artifactDir }) {
|
|
473
|
+
const worktreeRoot = resolve(worktree);
|
|
474
|
+
const artifactRoot = resolve(artifactDir);
|
|
475
|
+
let manifestBytes;
|
|
476
|
+
let manifest;
|
|
477
|
+
try {
|
|
478
|
+
manifestBytes = await readFile(join(artifactRoot, MANIFEST_FILE));
|
|
479
|
+
manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
480
|
+
} catch (error) {
|
|
481
|
+
throw captureError(
|
|
482
|
+
CAPTURE_REASONS.roundTripFailed,
|
|
483
|
+
`Refusing to hand over the artifact: its manifest could not be read (${error.message}).`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const snapshot = await snapshotWorktree(worktreeRoot);
|
|
488
|
+
await replayInto({
|
|
489
|
+
worktree: worktreeRoot,
|
|
490
|
+
artifactDir: artifactRoot,
|
|
491
|
+
manifest,
|
|
492
|
+
snapshot,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const manifestDigest = sha256(manifestBytes);
|
|
496
|
+
const size = (manifest.files ?? []).reduce(
|
|
497
|
+
(sum, file) => sum + file.bytes,
|
|
498
|
+
manifestBytes.length,
|
|
499
|
+
);
|
|
500
|
+
// Not exclusive: re-proving an artifact is legitimate and must be able to
|
|
501
|
+
// refresh the seal. The artifact directory itself is created exclusively, so
|
|
502
|
+
// there is never a foreign seal to clobber.
|
|
503
|
+
const proof = { manifestDigest, size, provenAt: new Date().toISOString() };
|
|
504
|
+
await writeFile(
|
|
505
|
+
join(artifactRoot, PROOF_FILE),
|
|
506
|
+
`${JSON.stringify(proof, null, 2)}\n`,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
ok: true,
|
|
511
|
+
head: manifest.head,
|
|
512
|
+
affectedPaths: manifest.affectedPaths ?? [],
|
|
513
|
+
manifestDigest,
|
|
514
|
+
size,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function replayInto({ worktree, artifactDir, manifest, snapshot }) {
|
|
519
|
+
const scratch = await mkdtemp(join(tmpdir(), 'oat-capture-roundtrip-'));
|
|
520
|
+
const replica = join(scratch, 'replica');
|
|
521
|
+
let added = false;
|
|
522
|
+
let primary = null;
|
|
523
|
+
let cleanupError = null;
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
await git(worktree, [
|
|
527
|
+
'worktree',
|
|
528
|
+
'add',
|
|
529
|
+
'--detach',
|
|
530
|
+
replica,
|
|
531
|
+
manifest.head,
|
|
532
|
+
]);
|
|
533
|
+
added = true;
|
|
534
|
+
await applyArtifact(artifactDir, manifest, replica);
|
|
535
|
+
|
|
536
|
+
const replicaSnapshot = await snapshotWorktree(replica);
|
|
537
|
+
if (!snapshotsMatch(snapshot, replicaSnapshot)) {
|
|
538
|
+
throw captureError(
|
|
539
|
+
CAPTURE_REASONS.roundTripFailed,
|
|
540
|
+
'Refusing to hand over the artifact: the replayed worktree does not match the captured worktree.',
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
} catch (error) {
|
|
544
|
+
primary = error.reason
|
|
545
|
+
? error
|
|
546
|
+
: captureError(
|
|
547
|
+
CAPTURE_REASONS.roundTripFailed,
|
|
548
|
+
`Refusing to hand over the artifact: the round trip failed (${error.message}).`,
|
|
549
|
+
);
|
|
550
|
+
} finally {
|
|
551
|
+
if (added) {
|
|
552
|
+
try {
|
|
553
|
+
await git(worktree, ['worktree', 'remove', '--force', replica]);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
cleanupError = error;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
await rm(scratch, { recursive: true, force: true });
|
|
560
|
+
} catch (error) {
|
|
561
|
+
cleanupError ??= error;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (primary) throw primary;
|
|
566
|
+
if (cleanupError) {
|
|
567
|
+
// A stale replay registration would leave the captured repository holding a
|
|
568
|
+
// worktree the lifecycle never authorized. Fail closed instead.
|
|
569
|
+
throw captureError(
|
|
570
|
+
CAPTURE_REASONS.roundTripFailed,
|
|
571
|
+
`Refusing to hand over the artifact: the replay worktree could not be removed (${cleanupError.message}).`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Lexical containment is not enough: a symlinked ancestor can point the
|
|
578
|
+
* artifact directory straight back into the worktree this script must not write
|
|
579
|
+
* to, and an existing directory could be silently overwritten. Require a fresh
|
|
580
|
+
* directory whose resolved location is outside the worktree.
|
|
581
|
+
*/
|
|
582
|
+
export async function resolveArtifactLocation(artifactRoot) {
|
|
583
|
+
let ancestor = artifactRoot;
|
|
584
|
+
while (!(await pathExists(ancestor))) {
|
|
585
|
+
const parent = dirname(ancestor);
|
|
586
|
+
if (parent === ancestor) break;
|
|
587
|
+
ancestor = parent;
|
|
588
|
+
}
|
|
589
|
+
const realAncestor = await realpath(ancestor);
|
|
590
|
+
// `resolve(realAncestor, relative(...))`, never `slice(ancestor.length + 1)`:
|
|
591
|
+
// when the nearest existing ancestor is the filesystem root the extra `+ 1`
|
|
592
|
+
// eats the first character of the next component, corrupting the path this
|
|
593
|
+
// containment check is about to judge.
|
|
594
|
+
return {
|
|
595
|
+
ancestor,
|
|
596
|
+
realAncestor,
|
|
597
|
+
realArtifact:
|
|
598
|
+
ancestor === artifactRoot
|
|
599
|
+
? realAncestor
|
|
600
|
+
: resolve(realAncestor, relative(ancestor, artifactRoot)),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function assertResolvesOutsideWorktree(worktreeRoot, artifactRoot) {
|
|
605
|
+
const { realArtifact } = await resolveArtifactLocation(artifactRoot);
|
|
606
|
+
const realWorktree = await realpath(worktreeRoot);
|
|
607
|
+
if (
|
|
608
|
+
realArtifact === realWorktree ||
|
|
609
|
+
realArtifact.startsWith(realWorktree + sep)
|
|
610
|
+
) {
|
|
611
|
+
throw captureError(
|
|
612
|
+
CAPTURE_REASONS.invalidUsage,
|
|
613
|
+
'The artifact directory must resolve outside the worktree it captures.',
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function resolveArtifactRoot(worktreeRoot, artifactDir) {
|
|
619
|
+
const artifactRoot = resolve(artifactDir);
|
|
620
|
+
if (await pathExists(artifactRoot)) {
|
|
621
|
+
throw captureError(
|
|
622
|
+
CAPTURE_REASONS.invalidUsage,
|
|
623
|
+
'The artifact directory must not already exist; capture creates it so that nothing can be overwritten.',
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
await assertResolvesOutsideWorktree(worktreeRoot, artifactRoot);
|
|
628
|
+
return artifactRoot;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Capture every index, worktree, and untracked component of a quiescent dirty
|
|
633
|
+
* worktree into an immutable artifact directory, prove the artifact replays
|
|
634
|
+
* byte-for-byte, seal it, and return the reference the caller records. The
|
|
635
|
+
* caller — not this script — performs the restore afterwards.
|
|
636
|
+
*/
|
|
637
|
+
export async function captureDirtyTree({
|
|
638
|
+
worktree,
|
|
639
|
+
artifactDir,
|
|
640
|
+
writerIdentity,
|
|
641
|
+
boundedFiles = [],
|
|
642
|
+
quiesceIntervalMs = DEFAULT_QUIESCE_INTERVAL_MS,
|
|
643
|
+
}) {
|
|
644
|
+
if (!worktree || !artifactDir) {
|
|
645
|
+
throw captureError(
|
|
646
|
+
CAPTURE_REASONS.invalidUsage,
|
|
647
|
+
'Both --worktree and --artifact-dir are required.',
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// The phase file boundary is not optional. An empty list would capture — and
|
|
652
|
+
// therefore hand a continuation permission to re-commit — dirt from outside
|
|
653
|
+
// the phase, which the surrounding contract lists as a terminal stop.
|
|
654
|
+
if (!Array.isArray(boundedFiles) || boundedFiles.length === 0) {
|
|
655
|
+
throw captureError(
|
|
656
|
+
CAPTURE_REASONS.invalidUsage,
|
|
657
|
+
'At least one --bounded-file value is required; repeat the flag once per in-phase path, because capture never runs without the phase file boundary.',
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const worktreeRoot = resolve(worktree);
|
|
662
|
+
const artifactRoot = await resolveArtifactRoot(worktreeRoot, artifactDir);
|
|
663
|
+
|
|
664
|
+
await assertNoInProgressOperation(worktreeRoot);
|
|
665
|
+
const snapshot = await assertWriterQuiescent({
|
|
666
|
+
worktree: worktreeRoot,
|
|
667
|
+
writerIdentity,
|
|
668
|
+
intervalMs: quiesceIntervalMs,
|
|
669
|
+
});
|
|
670
|
+
const { trackedPaths, untrackedPaths, affectedPaths } = classifyDirt(
|
|
671
|
+
snapshot.entries,
|
|
672
|
+
boundedFiles,
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// Untracked entries are captured as byte copies, which silently converts a
|
|
676
|
+
// symlink or a device node into a regular file. Refuse rather than restore
|
|
677
|
+
// something that is not what the lost child left behind.
|
|
678
|
+
const untrackedModes = new Map();
|
|
679
|
+
for (const path of untrackedPaths) {
|
|
680
|
+
const entry = await lstat(join(worktreeRoot, path));
|
|
681
|
+
if (!entry.isFile()) {
|
|
682
|
+
throw captureError(
|
|
683
|
+
CAPTURE_REASONS.unsupportedDirt,
|
|
684
|
+
`Refusing to capture: untracked path ${path} is not a regular file and cannot be copied faithfully.`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
untrackedModes.set(path, (entry.mode & 0o111) !== 0);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const indexPatch = await git(
|
|
691
|
+
worktreeRoot,
|
|
692
|
+
['diff', '--cached', '--binary', '--no-color', '--no-ext-diff'],
|
|
693
|
+
{ binary: true, config: RENAME_DETECTION_CONFIG },
|
|
694
|
+
);
|
|
695
|
+
const worktreePatch = await git(
|
|
696
|
+
worktreeRoot,
|
|
697
|
+
['diff', '--binary', '--no-color', '--no-ext-diff'],
|
|
698
|
+
{ binary: true, config: RENAME_DETECTION_CONFIG },
|
|
699
|
+
);
|
|
700
|
+
const indexStat = await git(
|
|
701
|
+
worktreeRoot,
|
|
702
|
+
['diff', '--cached', '--stat', '--no-color'],
|
|
703
|
+
{ config: RENAME_DETECTION_CONFIG },
|
|
704
|
+
);
|
|
705
|
+
const untrackedBytes = new Map();
|
|
706
|
+
for (const path of untrackedPaths) {
|
|
707
|
+
untrackedBytes.set(path, await readFile(join(worktreeRoot, path)));
|
|
708
|
+
}
|
|
709
|
+
const restorePlan = await buildRestorePlan(
|
|
710
|
+
worktreeRoot,
|
|
711
|
+
trackedPaths,
|
|
712
|
+
untrackedPaths,
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
// Nothing is written anywhere until the worktree is proven unchanged across
|
|
716
|
+
// the whole read, so an interrupted capture leaves no half-written artifact.
|
|
717
|
+
if (!snapshotsMatch(snapshot, await snapshotWorktree(worktreeRoot))) {
|
|
718
|
+
throw captureError(
|
|
719
|
+
CAPTURE_REASONS.activeWriter,
|
|
720
|
+
'Refusing to capture: the worktree changed while it was being read, so a writer is still active.',
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Exclusive, non-recursive: a directory raced into existence after the
|
|
725
|
+
// precheck fails here rather than being adopted, and the caller must have
|
|
726
|
+
// already created the parent it chose.
|
|
727
|
+
try {
|
|
728
|
+
await mkdir(artifactRoot);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
throw captureError(
|
|
731
|
+
CAPTURE_REASONS.invalidUsage,
|
|
732
|
+
`The artifact directory could not be created exclusively beneath an existing parent: ${error.message}`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
// Re-check after creation: the containment decision above was made against a
|
|
736
|
+
// path that did not exist yet, so a directory or symlink substituted in the
|
|
737
|
+
// meantime would otherwise go unnoticed.
|
|
738
|
+
await assertResolvesOutsideWorktree(worktreeRoot, artifactRoot);
|
|
739
|
+
const files = [];
|
|
740
|
+
await writeArtifactFile(artifactRoot, 'index.patch', indexPatch, files);
|
|
741
|
+
await writeArtifactFile(artifactRoot, 'worktree.patch', worktreePatch, files);
|
|
742
|
+
for (const path of untrackedPaths) {
|
|
743
|
+
await writeArtifactFile(
|
|
744
|
+
artifactRoot,
|
|
745
|
+
join('untracked', path),
|
|
746
|
+
untrackedBytes.get(path),
|
|
747
|
+
files,
|
|
748
|
+
{ executable: untrackedModes.get(path) },
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const components = [];
|
|
753
|
+
if (indexPatch.length > 0) components.push('index');
|
|
754
|
+
if (worktreePatch.length > 0) components.push('worktree');
|
|
755
|
+
if (untrackedPaths.length > 0) components.push('untracked');
|
|
756
|
+
|
|
757
|
+
const manifest = {
|
|
758
|
+
schemaVersion: 1,
|
|
759
|
+
capturedAt: new Date().toISOString(),
|
|
760
|
+
worktree: worktreeRoot,
|
|
761
|
+
head: snapshot.head,
|
|
762
|
+
writerIdentity,
|
|
763
|
+
components,
|
|
764
|
+
affectedPaths,
|
|
765
|
+
trackedPaths,
|
|
766
|
+
untrackedPaths,
|
|
767
|
+
restorePlan,
|
|
768
|
+
stat: indexStat,
|
|
769
|
+
files,
|
|
770
|
+
};
|
|
771
|
+
const manifestBytes = Buffer.from(
|
|
772
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
773
|
+
'utf8',
|
|
774
|
+
);
|
|
775
|
+
await writeFile(join(artifactRoot, MANIFEST_FILE), manifestBytes, {
|
|
776
|
+
flag: 'wx',
|
|
777
|
+
});
|
|
778
|
+
// The proof is what seals the artifact and what produces the digest and size
|
|
779
|
+
// this capture publishes, so an unproven artifact has neither a reference to
|
|
780
|
+
// hand over nor a seal `--verify` will accept.
|
|
781
|
+
const { manifestDigest, size } = await proveArtifactRoundTrip({
|
|
782
|
+
worktree: worktreeRoot,
|
|
783
|
+
artifactDir: artifactRoot,
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
ok: true,
|
|
788
|
+
artifact: artifactRoot,
|
|
789
|
+
manifestDigest,
|
|
790
|
+
size,
|
|
791
|
+
components,
|
|
792
|
+
affectedPaths,
|
|
793
|
+
restorePlan,
|
|
794
|
+
stat: indexStat,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Re-verify an artifact before it is applied: the round-trip seal, the
|
|
800
|
+
* manifest, and every named file must be readable and must still match the
|
|
801
|
+
* digest and size the continuation brief carries, the artifact directory must
|
|
802
|
+
* hold nothing the manifest does not list, and the captured `HEAD` must equal
|
|
803
|
+
* the `HEAD` the caller is about to patch. A tampered, unreadable, unproven, or
|
|
804
|
+
* base-mismatched artifact stops here.
|
|
805
|
+
*/
|
|
806
|
+
export async function verifyArtifact({
|
|
807
|
+
artifactDir,
|
|
808
|
+
manifestDigest,
|
|
809
|
+
size,
|
|
810
|
+
expectedHead,
|
|
811
|
+
}) {
|
|
812
|
+
const artifactRoot = resolve(artifactDir);
|
|
813
|
+
|
|
814
|
+
if (typeof manifestDigest !== 'string' || manifestDigest.length === 0) {
|
|
815
|
+
throw captureError(
|
|
816
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
817
|
+
'The expected --manifest-digest from the continuation brief is required to verify an artifact.',
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
if (typeof size !== 'number' || !Number.isInteger(size) || size < 0) {
|
|
821
|
+
throw captureError(
|
|
822
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
823
|
+
`The expected --size from the continuation brief must be a non-negative integer; received ${JSON.stringify(size)}.`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
if (typeof expectedHead !== 'string' || expectedHead.length === 0) {
|
|
827
|
+
throw captureError(
|
|
828
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
829
|
+
'The --expected-head the artifact will be applied onto is required; an artifact captured at a different base cannot be replayed safely.',
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
let proof;
|
|
834
|
+
try {
|
|
835
|
+
proof = JSON.parse(await readFile(join(artifactRoot, PROOF_FILE), 'utf8'));
|
|
836
|
+
} catch (error) {
|
|
837
|
+
throw captureError(
|
|
838
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
839
|
+
`Artifact round-trip seal is missing or unreadable, so the artifact was never proven to replay: ${error.message}`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if (
|
|
843
|
+
typeof proof !== 'object' ||
|
|
844
|
+
proof === null ||
|
|
845
|
+
typeof proof.manifestDigest !== 'string' ||
|
|
846
|
+
typeof proof.size !== 'number'
|
|
847
|
+
) {
|
|
848
|
+
throw captureError(
|
|
849
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
850
|
+
'Artifact round-trip seal is malformed, so the artifact cannot be treated as proven.',
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
let manifestBytes;
|
|
855
|
+
try {
|
|
856
|
+
manifestBytes = await readFile(join(artifactRoot, MANIFEST_FILE));
|
|
857
|
+
} catch (error) {
|
|
858
|
+
throw captureError(
|
|
859
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
860
|
+
`Artifact manifest is unreadable: ${error.message}`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const actualManifestDigest = sha256(manifestBytes);
|
|
865
|
+
for (const [label, expected, actual] of [
|
|
866
|
+
['brief manifest digest', manifestDigest, actualManifestDigest],
|
|
867
|
+
['sealed manifest digest', proof.manifestDigest, actualManifestDigest],
|
|
868
|
+
['sealed size', proof.size, size],
|
|
869
|
+
]) {
|
|
870
|
+
if (expected !== actual) {
|
|
871
|
+
throw captureError(
|
|
872
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
873
|
+
`Artifact ${label} mismatch: expected ${expected}, found ${actual}.`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
let manifest;
|
|
879
|
+
try {
|
|
880
|
+
manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
881
|
+
} catch (error) {
|
|
882
|
+
throw captureError(
|
|
883
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
884
|
+
`Artifact manifest is not valid JSON: ${error.message}`,
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
let total = manifestBytes.length;
|
|
889
|
+
for (const file of manifest.files ?? []) {
|
|
890
|
+
let bytes;
|
|
891
|
+
try {
|
|
892
|
+
bytes = await readFile(join(artifactRoot, file.path));
|
|
893
|
+
} catch (error) {
|
|
894
|
+
throw captureError(
|
|
895
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
896
|
+
`Artifact file ${file.path} is unreadable: ${error.message}`,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
if (bytes.length !== file.bytes) {
|
|
900
|
+
throw captureError(
|
|
901
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
902
|
+
`Artifact file ${file.path} size mismatch: expected ${file.bytes}, found ${bytes.length}.`,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
const digest = sha256(bytes);
|
|
906
|
+
if (digest !== file.sha256) {
|
|
907
|
+
throw captureError(
|
|
908
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
909
|
+
`Artifact file ${file.path} digest mismatch: expected ${file.sha256}, found ${digest}.`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
const executable =
|
|
913
|
+
((await lstat(join(artifactRoot, file.path))).mode & 0o111) !== 0;
|
|
914
|
+
if (executable !== Boolean(file.executable)) {
|
|
915
|
+
throw captureError(
|
|
916
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
917
|
+
`Artifact file ${file.path} executable-bit mismatch: expected ${Boolean(file.executable)}, found ${executable}.`,
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
total += bytes.length;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (total !== size) {
|
|
924
|
+
throw captureError(
|
|
925
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
926
|
+
`Artifact aggregate size mismatch: expected ${size}, found ${total}.`,
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// The aggregate size only covers what the manifest lists, so enumerate the
|
|
931
|
+
// directory as well: an artifact holding anything the manifest does not name
|
|
932
|
+
// is not the immutable artifact the capture sealed.
|
|
933
|
+
const listed = (manifest.files ?? []).map((file) => file.path);
|
|
934
|
+
const allowed = new Set([MANIFEST_FILE, PROOF_FILE, ...listed]);
|
|
935
|
+
// Directories only exist to hold listed files, so the permitted set is
|
|
936
|
+
// exactly the parent chain of those files. Anything else — including an empty
|
|
937
|
+
// directory — is not part of the artifact the capture sealed.
|
|
938
|
+
const allowedDirectories = new Set();
|
|
939
|
+
for (const path of listed) {
|
|
940
|
+
let parent = dirname(path);
|
|
941
|
+
while (parent !== '.' && parent !== sep && parent !== '') {
|
|
942
|
+
allowedDirectories.add(parent);
|
|
943
|
+
parent = dirname(parent);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const present = await readdir(artifactRoot, {
|
|
947
|
+
recursive: true,
|
|
948
|
+
withFileTypes: true,
|
|
949
|
+
});
|
|
950
|
+
for (const entry of present) {
|
|
951
|
+
const relativePath = relative(
|
|
952
|
+
artifactRoot,
|
|
953
|
+
join(entry.parentPath ?? entry.path, entry.name),
|
|
954
|
+
);
|
|
955
|
+
const permitted = entry.isDirectory()
|
|
956
|
+
? allowedDirectories.has(relativePath)
|
|
957
|
+
: allowed.has(relativePath);
|
|
958
|
+
if (!permitted) {
|
|
959
|
+
throw captureError(
|
|
960
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
961
|
+
`Artifact holds ${relativePath}, which the manifest does not list.`,
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// `git apply` matches on hunk context, not on base identity, so a patch can
|
|
967
|
+
// apply cleanly at the wrong commit and silently produce wrong content.
|
|
968
|
+
// Integrity is not base agreement; check both.
|
|
969
|
+
if (manifest.head !== expectedHead) {
|
|
970
|
+
throw captureError(
|
|
971
|
+
CAPTURE_REASONS.artifactVerificationFailed,
|
|
972
|
+
`Artifact base mismatch: captured at ${manifest.head}, but the target HEAD is ${expectedHead}.`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return {
|
|
977
|
+
ok: true,
|
|
978
|
+
artifact: artifactRoot,
|
|
979
|
+
manifestDigest: actualManifestDigest,
|
|
980
|
+
size: total,
|
|
981
|
+
components: manifest.components ?? [],
|
|
982
|
+
affectedPaths: manifest.affectedPaths ?? [],
|
|
983
|
+
restorePlan: manifest.restorePlan ?? [],
|
|
984
|
+
head: manifest.head ?? null,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function parseArguments(argv) {
|
|
989
|
+
const options = { boundedFiles: [], verify: false };
|
|
990
|
+
|
|
991
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
992
|
+
const flag = argv[index];
|
|
993
|
+
if (flag === '--verify') {
|
|
994
|
+
options.verify = true;
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
const value = argv[index + 1];
|
|
998
|
+
if (value === undefined) {
|
|
999
|
+
throw captureError(
|
|
1000
|
+
CAPTURE_REASONS.invalidUsage,
|
|
1001
|
+
`Missing value for ${flag}.`,
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
index += 1;
|
|
1005
|
+
if (flag === '--worktree') options.worktree = value;
|
|
1006
|
+
else if (flag === '--artifact-dir') options.artifactDir = value;
|
|
1007
|
+
else if (flag === '--writer-identity') options.writerIdentity = value;
|
|
1008
|
+
else if (flag === '--manifest-digest') options.manifestDigest = value;
|
|
1009
|
+
else if (flag === '--expected-head') options.expectedHead = value;
|
|
1010
|
+
else if (flag === '--size') options.size = Number(value);
|
|
1011
|
+
else if (flag === '--quiesce-interval-ms') {
|
|
1012
|
+
options.quiesceIntervalMs = Number(value);
|
|
1013
|
+
}
|
|
1014
|
+
// Repeated `--bounded-file` only: a delimited list would split a filename
|
|
1015
|
+
// that legitimately contains the delimiter and could widen the boundary to
|
|
1016
|
+
// a shorter prefix path.
|
|
1017
|
+
else if (flag === '--bounded-file') options.boundedFiles.push(value);
|
|
1018
|
+
else {
|
|
1019
|
+
throw captureError(
|
|
1020
|
+
CAPTURE_REASONS.invalidUsage,
|
|
1021
|
+
`Unsupported argument: ${flag}.`,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return options;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function main(argv) {
|
|
1030
|
+
try {
|
|
1031
|
+
const options = parseArguments(argv);
|
|
1032
|
+
const result = options.verify
|
|
1033
|
+
? await verifyArtifact(options)
|
|
1034
|
+
: await captureDirtyTree(options);
|
|
1035
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1036
|
+
return 0;
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
const reason = error.reason ?? CAPTURE_REASONS.invalidUsage;
|
|
1039
|
+
process.stderr.write(
|
|
1040
|
+
`${JSON.stringify({ ok: false, reason, message: error.message })}\n`,
|
|
1041
|
+
);
|
|
1042
|
+
return EXIT_CODES[reason] ?? 1;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Direct invocation, compared as canonical paths on both sides. Comparing a raw
|
|
1048
|
+
* `process.argv[1]` against `import.meta.url` makes the CLI a silent no-op that
|
|
1049
|
+
* exits 0 whenever the skill is reached through a symlinked install root, and
|
|
1050
|
+
* canonicalizing only one side has the same effect under
|
|
1051
|
+
* `--preserve-symlinks-main`, which keeps the link in `import.meta.url`. Either
|
|
1052
|
+
* way the caller reads "exited 0" as "verified" — the fail-open shape the
|
|
1053
|
+
* contracts exist to prevent.
|
|
1054
|
+
*/
|
|
1055
|
+
function isDirectInvocation(invokedPath) {
|
|
1056
|
+
if (!invokedPath) return false;
|
|
1057
|
+
try {
|
|
1058
|
+
return (
|
|
1059
|
+
realpathSync(fileURLToPath(import.meta.url)) ===
|
|
1060
|
+
realpathSync(resolve(invokedPath))
|
|
1061
|
+
);
|
|
1062
|
+
} catch {
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (isDirectInvocation(process.argv[1])) {
|
|
1068
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
1069
|
+
}
|