axstack 0.20.8 → 0.20.9
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/package.json
CHANGED
|
@@ -59,12 +59,44 @@ state and require exit proof for that exact terminal incarnation. A task
|
|
|
59
59
|
terminal, manual chat, unexpected terminal, failed close, or uncertain exit
|
|
60
60
|
remains protected.
|
|
61
61
|
|
|
62
|
-
After receipt and continuity readback,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
After receipt and continuity readback, invoke the same installed helper with
|
|
63
|
+
the same identity and complete `--file` set, plus the recorded manifest hash:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
bun scripts/archive-evidence.js \
|
|
67
|
+
--source-root <absolute-worktree-or-evidence-root> \
|
|
68
|
+
--archive-root <absolute-private-archive-root> \
|
|
69
|
+
--repo <owner/repository> --pr <number> --head <40-character-sha> \
|
|
70
|
+
--dispatch <exact-dispatch-id> \
|
|
71
|
+
--file <classified-relative-file> [--file <classified-relative-file> ...] \
|
|
72
|
+
--operation retire --manifest-hash <recorded-64-character-sha256>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use the corresponding `--run` and `--task` identity for a non-PR archive. The
|
|
76
|
+
retirement operation independently verifies the immutable private archive, its
|
|
77
|
+
exact identity and complete file set, the caller-recorded manifest hash, the
|
|
78
|
+
exact Git top-level and HEAD, and every remaining source file. It refuses
|
|
79
|
+
tracked, staged, or unclassified dirt; changed bytes; unsafe or overlapping
|
|
80
|
+
roots; symlinks; hard links; and non-regular files. It applies exact unlinks only
|
|
81
|
+
to matching listed files and never removes directories.
|
|
82
|
+
|
|
83
|
+
Record the retirement receipt's `removed`, `alreadyAbsent`, and `pending` file
|
|
84
|
+
sets. A repeat invocation reconciles already absent files without changing the
|
|
85
|
+
manifest. A partial or failed invocation preserves the archive; resolve its
|
|
86
|
+
exact hold and retry the same operation until `pending` is empty. Never replace
|
|
87
|
+
this operation with a shell loop, broad deletion, force, or a waiver.
|
|
88
|
+
|
|
89
|
+
Re-read Git and native state after successful retirement. Any remaining or
|
|
90
|
+
uncertain dirt holds worktree removal. Settlement, liveness/no-writer proof,
|
|
91
|
+
useful-work and publication checks, evidence classification, and removal
|
|
92
|
+
authority remain driver decisions; archive or retirement success proves none of
|
|
93
|
+
them.
|
|
94
|
+
|
|
95
|
+
Before native worktree removal, verify the effective **Archive Script**
|
|
96
|
+
provenance. An unknown hook or a required hook whose provenance is not trusted
|
|
97
|
+
holds removal. Record its native outcome as exactly `unconfigured`, `passed`,
|
|
98
|
+
`failed`, or `unknown`; only `unconfigured` or a trusted `passed` outcome may
|
|
99
|
+
advance, while `failed` and `unknown` preserve the resource.
|
|
68
100
|
|
|
69
101
|
Only then use the version-matched Orca guide's native worktree cleanup operation
|
|
70
102
|
with the exact workspace identity. Never use shell recursive deletion and never
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
readFile,
|
|
8
8
|
rename,
|
|
9
9
|
rm,
|
|
10
|
+
unlink,
|
|
10
11
|
writeFile,
|
|
11
12
|
} from 'node:fs/promises';
|
|
12
13
|
|
|
@@ -68,7 +69,7 @@ function parseArgs(argv) {
|
|
|
68
69
|
if (!flag?.startsWith('--') || value === undefined) fail(`invalid argument near ${flag ?? '(end)'}`);
|
|
69
70
|
const key = flag.slice(2);
|
|
70
71
|
if (key === 'file') values.files.push(value);
|
|
71
|
-
else if (['source-root', 'archive-root', 'repo', 'pr', 'run', 'task', 'head', 'dispatch'].includes(key)) {
|
|
72
|
+
else if (['source-root', 'archive-root', 'repo', 'pr', 'run', 'task', 'head', 'dispatch', 'operation', 'manifest-hash'].includes(key)) {
|
|
72
73
|
if (values[key] !== undefined) fail(`duplicate --${key}`);
|
|
73
74
|
values[key] = value;
|
|
74
75
|
} else fail(`unknown argument: ${flag}`);
|
|
@@ -76,6 +77,7 @@ function parseArgs(argv) {
|
|
|
76
77
|
for (const key of ['source-root', 'archive-root', 'repo', 'head', 'dispatch']) {
|
|
77
78
|
if (!values[key]) fail(`missing --${key}`);
|
|
78
79
|
}
|
|
80
|
+
values.operation ??= 'archive';
|
|
79
81
|
if (values.files.length === 0) fail('at least one --file is required');
|
|
80
82
|
if (!isAbsolute(values['source-root']) || !isAbsolute(values['archive-root'])) {
|
|
81
83
|
fail('source and archive roots must be absolute');
|
|
@@ -95,6 +97,13 @@ function parseArgs(argv) {
|
|
|
95
97
|
}
|
|
96
98
|
if (!/^[0-9a-f]{40}$/.test(values.head)) fail('head must be an exact 40-character lowercase SHA');
|
|
97
99
|
if (!/^[A-Za-z0-9_-]+$/.test(values.dispatch)) fail('dispatch contains unsafe characters');
|
|
100
|
+
if (!['archive', 'retire'].includes(values.operation)) fail('operation must be archive or retire');
|
|
101
|
+
if (values.operation === 'retire' && !/^[0-9a-f]{64}$/.test(values['manifest-hash'] ?? '')) {
|
|
102
|
+
fail('retirement requires an exact lowercase --manifest-hash');
|
|
103
|
+
}
|
|
104
|
+
if (values.operation === 'archive' && values['manifest-hash'] !== undefined) {
|
|
105
|
+
fail('--manifest-hash applies only to retirement');
|
|
106
|
+
}
|
|
98
107
|
values.files = [...new Set(values.files)].sort();
|
|
99
108
|
for (const file of values.files) {
|
|
100
109
|
const parts = file.split('/');
|
|
@@ -212,6 +221,203 @@ async function verifyArchive(archiveDir, identity, collected) {
|
|
|
212
221
|
};
|
|
213
222
|
}
|
|
214
223
|
|
|
224
|
+
function sameJson(left, right) {
|
|
225
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function verifyRetirementArchive(archiveRoot, archiveDir, identity, files, manifestHash) {
|
|
229
|
+
const archiveRel = relative(archiveRoot, archiveDir);
|
|
230
|
+
let current = archiveRoot;
|
|
231
|
+
for (const part of ['', ...archiveRel.split('/')]) {
|
|
232
|
+
if (part) current = join(current, part);
|
|
233
|
+
const st = await assertRealPath(current, 'directory');
|
|
234
|
+
if ((st.mode & 0o077) !== 0) fail(`archive permissions are not private: ${current}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const manifestPath = join(archiveDir, 'manifest.json');
|
|
238
|
+
const manifestStat = await assertRealPath(manifestPath, 'file');
|
|
239
|
+
if (manifestStat.nlink !== 1) fail(`refusing hard-linked archive file: ${manifestPath}`);
|
|
240
|
+
if ((manifestStat.mode & 0o077) !== 0) fail(`archive manifest permissions are not private: ${manifestPath}`);
|
|
241
|
+
const manifestBytes = await readFile(manifestPath);
|
|
242
|
+
if (sha256(manifestBytes) !== manifestHash) fail('recorded manifest hash mismatch');
|
|
243
|
+
|
|
244
|
+
let manifest;
|
|
245
|
+
try {
|
|
246
|
+
manifest = JSON.parse(manifestBytes.toString());
|
|
247
|
+
} catch {
|
|
248
|
+
fail(`invalid archive manifest: ${manifestPath}`);
|
|
249
|
+
}
|
|
250
|
+
if (manifest.version !== 1 || !sameJson(manifest.identity, identity)) {
|
|
251
|
+
fail(`archive manifest identity mismatch: ${manifestPath}`);
|
|
252
|
+
}
|
|
253
|
+
const manifestFiles = Object.keys(manifest.files ?? {}).sort();
|
|
254
|
+
if (!sameJson(manifestFiles, files)) fail(`archive manifest file set mismatch: ${manifestPath}`);
|
|
255
|
+
if (!manifestBytes.equals(Buffer.from(JSON.stringify(manifest, null, 2) + '\n'))) {
|
|
256
|
+
fail(`archive manifest bytes are not canonical: ${manifestPath}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const filesRoot = join(archiveDir, 'files');
|
|
260
|
+
const filesRootStat = await assertRealPath(filesRoot, 'directory');
|
|
261
|
+
if ((filesRootStat.mode & 0o077) !== 0) fail(`archive permissions are not private: ${filesRoot}`);
|
|
262
|
+
for (const rel of files) {
|
|
263
|
+
const expected = manifest.files[rel];
|
|
264
|
+
if (!expected || !/^[0-9a-f]{64}$/.test(expected.sha256) || !Number.isSafeInteger(expected.size) || expected.size < 0) {
|
|
265
|
+
fail(`invalid archive manifest entry: ${rel}`);
|
|
266
|
+
}
|
|
267
|
+
await assertNoSymlinkComponents(filesRoot, rel);
|
|
268
|
+
const archived = join(filesRoot, rel);
|
|
269
|
+
const st = await assertRealPath(archived, 'file');
|
|
270
|
+
if (st.nlink !== 1) fail(`refusing hard-linked archive file: ${archived}`);
|
|
271
|
+
if ((st.mode & 0o077) !== 0) fail(`archived evidence permissions are not private: ${archived}`);
|
|
272
|
+
const bytes = await readFile(archived);
|
|
273
|
+
if (bytes.length !== expected.size || sha256(bytes) !== expected.sha256) {
|
|
274
|
+
fail(`archived evidence hash mismatch: ${rel}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const actualFiles = await listArchiveFiles(archiveDir);
|
|
278
|
+
const expectedFiles = ['manifest.json', ...files.map((rel) => `files/${rel}`)].sort();
|
|
279
|
+
if (!sameJson(actualFiles, expectedFiles)) fail(`archive contains unexpected or missing files: ${archiveDir}`);
|
|
280
|
+
return { archiveDir, manifestPath, manifestHash, files: files.length, manifest };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function gitOutput(sourceRoot, args) {
|
|
284
|
+
const result = Bun.spawnSync(['git', '-C', sourceRoot, ...args], { stdout: 'pipe', stderr: 'pipe' });
|
|
285
|
+
if (result.exitCode !== 0) {
|
|
286
|
+
fail(`git ${args.join(' ')} failed: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`);
|
|
287
|
+
}
|
|
288
|
+
return result.stdout.toString();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function inspectSource(sourceRoot, rel) {
|
|
292
|
+
let current = sourceRoot;
|
|
293
|
+
const parts = rel.split('/');
|
|
294
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
295
|
+
current = join(current, parts[index]);
|
|
296
|
+
const st = await lstat(current, { bigint: true }).catch((err) => {
|
|
297
|
+
if (err?.code === 'ENOENT') return null;
|
|
298
|
+
throw err;
|
|
299
|
+
});
|
|
300
|
+
if (!st) return null;
|
|
301
|
+
if (st.isSymbolicLink()) fail(`refusing symlink: ${current}`);
|
|
302
|
+
if (index < parts.length - 1 && !st.isDirectory()) fail(`not a directory: ${current}`);
|
|
303
|
+
if (index === parts.length - 1) {
|
|
304
|
+
if (!st.isFile()) fail(`not a regular file: ${current}`);
|
|
305
|
+
if (st.nlink !== 1n) fail(`refusing hard-linked source file: ${current}`);
|
|
306
|
+
const bytes = await readFile(current);
|
|
307
|
+
const after = await lstat(current, { bigint: true }).catch((err) => {
|
|
308
|
+
if (err?.code === 'ENOENT') fail(`source changed during verification: ${rel}`);
|
|
309
|
+
throw err;
|
|
310
|
+
});
|
|
311
|
+
for (const key of ['dev', 'ino', 'size', 'mtimeNs', 'nlink']) {
|
|
312
|
+
if (st[key] !== after[key]) fail(`source changed during verification: ${rel}`);
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
bytes,
|
|
316
|
+
sha256: sha256(bytes),
|
|
317
|
+
size: bytes.length,
|
|
318
|
+
fingerprint: Object.fromEntries(
|
|
319
|
+
['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs', 'nlink'].map((key) => [key, after[key]]),
|
|
320
|
+
),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function sameSource(left, right) {
|
|
327
|
+
return left.size === right.size &&
|
|
328
|
+
left.sha256 === right.sha256 &&
|
|
329
|
+
Object.keys(left.fingerprint).every((key) => left.fingerprint[key] === right.fingerprint[key]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function verifyRetirementSource(sourceRoot, head, files, manifest) {
|
|
333
|
+
await assertSafeAncestors(sourceRoot);
|
|
334
|
+
await assertRealPath(sourceRoot, 'directory');
|
|
335
|
+
const topLevel = resolve(gitOutput(sourceRoot, ['rev-parse', '--show-toplevel']).trim());
|
|
336
|
+
if (topLevel !== sourceRoot) fail(`source root is not the exact Git top-level: ${sourceRoot}`);
|
|
337
|
+
const actualHead = gitOutput(sourceRoot, ['rev-parse', 'HEAD']).trim();
|
|
338
|
+
if (actualHead !== head) fail(`Git HEAD mismatch: expected ${head}, found ${actualHead}`);
|
|
339
|
+
|
|
340
|
+
const states = {};
|
|
341
|
+
for (const rel of files) {
|
|
342
|
+
const state = await inspectSource(sourceRoot, rel);
|
|
343
|
+
if (state && (state.size !== manifest.files[rel].size || state.sha256 !== manifest.files[rel].sha256)) {
|
|
344
|
+
fail(`source evidence hash mismatch: ${rel}`);
|
|
345
|
+
}
|
|
346
|
+
states[rel] = state;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const expectedUntracked = new Set(files.filter((rel) => states[rel]));
|
|
350
|
+
const statusEntries = gitOutput(sourceRoot, [
|
|
351
|
+
'status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=no',
|
|
352
|
+
]).split('\0').filter(Boolean);
|
|
353
|
+
for (const entry of statusEntries) {
|
|
354
|
+
const code = entry.slice(0, 2);
|
|
355
|
+
const rel = entry.slice(3);
|
|
356
|
+
if (code !== '??' || !expectedUntracked.delete(rel)) {
|
|
357
|
+
fail(`tracked, staged, or unclassified Git dirt: ${rel || '(unknown)'}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (expectedUntracked.size > 0) {
|
|
361
|
+
fail(`source evidence is not classified as untracked: ${[...expectedUntracked].sort()[0]}`);
|
|
362
|
+
}
|
|
363
|
+
return states;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function retireEvidence(args, sourceRoot, archiveRoot, archiveDir, identity) {
|
|
367
|
+
const archive = await verifyRetirementArchive(
|
|
368
|
+
archiveRoot, archiveDir, identity, args.files, args['manifest-hash'],
|
|
369
|
+
);
|
|
370
|
+
const states = await verifyRetirementSource(sourceRoot, args.head, args.files, archive.manifest);
|
|
371
|
+
const removed = [];
|
|
372
|
+
const alreadyAbsent = args.files.filter((rel) => !states[rel]);
|
|
373
|
+
|
|
374
|
+
for (const rel of args.files) {
|
|
375
|
+
if (!states[rel]) continue;
|
|
376
|
+
const current = await inspectSource(sourceRoot, rel);
|
|
377
|
+
if (!current || !sameSource(current, states[rel])) fail(`source changed before retirement: ${rel}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (let index = 0; index < args.files.length; index += 1) {
|
|
381
|
+
const rel = args.files[index];
|
|
382
|
+
if (!states[rel]) continue;
|
|
383
|
+
const current = await inspectSource(sourceRoot, rel);
|
|
384
|
+
if (!current || !sameSource(current, states[rel])) {
|
|
385
|
+
fail(`source changed before retirement: ${rel}`);
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await unlink(join(sourceRoot, rel));
|
|
389
|
+
} catch (err) {
|
|
390
|
+
const pending = args.files.slice(index).filter((file) => states[file]);
|
|
391
|
+
console.log(JSON.stringify({
|
|
392
|
+
status: 'partial',
|
|
393
|
+
archiveDir: archive.archiveDir,
|
|
394
|
+
manifestPath: archive.manifestPath,
|
|
395
|
+
manifestHash: archive.manifestHash,
|
|
396
|
+
files: archive.files,
|
|
397
|
+
removed,
|
|
398
|
+
alreadyAbsent,
|
|
399
|
+
pending,
|
|
400
|
+
}));
|
|
401
|
+
fail(`retirement stopped at ${rel}: ${err.message}`);
|
|
402
|
+
}
|
|
403
|
+
if (await lstat(join(sourceRoot, rel)).catch((err) => err?.code === 'ENOENT' ? null : Promise.reject(err))) {
|
|
404
|
+
fail(`source still exists after unlink: ${rel}`);
|
|
405
|
+
}
|
|
406
|
+
removed.push(rel);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
console.log(JSON.stringify({
|
|
410
|
+
status: 'retired',
|
|
411
|
+
archiveDir: archive.archiveDir,
|
|
412
|
+
manifestPath: archive.manifestPath,
|
|
413
|
+
manifestHash: archive.manifestHash,
|
|
414
|
+
files: archive.files,
|
|
415
|
+
removed,
|
|
416
|
+
alreadyAbsent,
|
|
417
|
+
pending: [],
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
215
421
|
async function listArchiveFiles(root, prefix = '') {
|
|
216
422
|
const files = [];
|
|
217
423
|
const entries = await readdir(join(root, prefix), { withFileTypes: true });
|
|
@@ -238,7 +444,6 @@ async function main() {
|
|
|
238
444
|
archiveToSource === '' || (!archiveToSource.startsWith('..') && !isAbsolute(archiveToSource))
|
|
239
445
|
) fail('source and archive roots must not contain each other');
|
|
240
446
|
|
|
241
|
-
const collected = await collectSource(sourceRoot, args.files);
|
|
242
447
|
const identity = args.pr
|
|
243
448
|
? { repo: args.repo, pr: Number(args.pr), head: args.head, dispatch: args.dispatch }
|
|
244
449
|
: { repo: args.repo, run: args.run, task: args.task, head: args.head, dispatch: args.dispatch };
|
|
@@ -249,6 +454,12 @@ async function main() {
|
|
|
249
454
|
const archiveDir = join(archiveRoot, repoSlug, ...identityParts, args.head, args.dispatch);
|
|
250
455
|
|
|
251
456
|
await assertSafeAncestors(archiveRoot);
|
|
457
|
+
if (args.operation === 'retire') {
|
|
458
|
+
await retireEvidence(args, sourceRoot, archiveRoot, archiveDir, identity);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const collected = await collectSource(sourceRoot, args.files);
|
|
252
463
|
let current = archiveRoot;
|
|
253
464
|
const generated = [repoSlug, ...identityParts, args.head];
|
|
254
465
|
await ensurePrivateDir(current);
|
|
@@ -81,9 +81,15 @@ Treat these operations as separate decisions and receipts:
|
|
|
81
81
|
3. **Worktree removal.** Re-read Git status, branch/upstream divergence,
|
|
82
82
|
unpushed commits, forge merge/publication state, children, terminals, and
|
|
83
83
|
archived evidence immediately before the native exact-workspace removal.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
When archived evidence is the last dirt, use the evidence archive helper's
|
|
85
|
+
manifest-bound retirement operation and require an empty pending set; never
|
|
86
|
+
unlink through prose or a shell loop. Verify the effective Archive Script
|
|
87
|
+
provenance before native removal: an unknown or required-but-untrusted hook
|
|
88
|
+
holds. Record its native outcome as `unconfigured`, `passed`, `failed`, or
|
|
89
|
+
`unknown`; only `unconfigured` or trusted `passed` may advance. Account for
|
|
90
|
+
branch-deletion side effects explicitly, then re-list both native workspaces
|
|
91
|
+
and Git refs. A failed or unknown hook outcome or uncertain response preserves
|
|
92
|
+
the resource; never force or substitute shell deletion.
|
|
87
93
|
4. **Chat archival.** Attempt it only if the version-matched runtime guide
|
|
88
94
|
advertises a distinct supported operation and the scoped chat is eligible.
|
|
89
95
|
Otherwise record chat archival as unsupported. Process exit, worker release,
|