yam-flow 2.6.0 → 2.8.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/AGENTS.md +1 -0
- package/CHANGELOG.md +51 -0
- package/COMMANDS.md +72 -5
- package/README.md +49 -9
- package/ROADMAP.md +20 -7
- package/dist/bin/yam.js +616 -68
- package/dist/lib/external-updates.d.ts +9 -0
- package/dist/lib/external-updates.js +232 -4
- package/dist/lib/media-provider-receipt.d.ts +79 -0
- package/dist/lib/media-provider-receipt.js +498 -0
- package/dist/lib/next-step.d.ts +61 -0
- package/dist/lib/next-step.js +340 -0
- package/dist/lib/release-auth-readiness.d.ts +81 -0
- package/dist/lib/release-auth-readiness.js +106 -0
- package/dist/lib/release-registry-status.d.ts +18 -0
- package/dist/lib/release-registry-status.js +37 -0
- package/dist/lib/scout-receipt.d.ts +89 -0
- package/dist/lib/scout-receipt.js +542 -0
- package/dist/lib/skill-installation.d.ts +30 -0
- package/dist/lib/skill-installation.js +738 -110
- package/dist/lib/trust-kernel.d.ts +2 -0
- package/dist/lib/trust-kernel.js +21 -2
- package/dist/lib/verification-closure.d.ts +97 -0
- package/dist/lib/verification-closure.js +728 -0
- package/package.json +23 -3
- package/references/final-report.md +15 -1
- package/references/next-step.md +67 -0
- package/references/scout-wide-scan.md +16 -0
- package/references/study-note.md +4 -2
- package/references/tool-trust-layer.md +2 -0
- package/skills/deep/SKILL.md +2 -0
- package/skills/mission/SKILL.md +2 -0
- package/skills/quick/SKILL.md +1 -0
- package/skills/scout/SKILL.md +19 -9
- package/skills/ueye/SKILL.md +1 -0
- package/src/bin/yam.ts +615 -64
- package/src/lib/external-updates.ts +262 -4
- package/src/lib/media-provider-receipt.ts +548 -0
- package/src/lib/next-step.ts +397 -0
- package/src/lib/release-auth-readiness.ts +148 -0
- package/src/lib/release-registry-status.ts +55 -0
- package/src/lib/scout-receipt.ts +593 -0
- package/src/lib/skill-installation.ts +930 -108
- package/src/lib/trust-kernel.ts +25 -2
- package/src/lib/verification-closure.ts +738 -0
- package/yam.manifest.json +4 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants, type BigIntStats } from 'node:fs';
|
|
2
3
|
import fsp from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { compareStableText } from './stable-order.js';
|
|
@@ -7,6 +8,7 @@ export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
|
|
|
7
8
|
export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
|
|
8
9
|
export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
|
|
9
10
|
export const INSTALL_TRANSACTION_PREFIX = '.yam-flow-install-';
|
|
11
|
+
const TRANSACTION_OWNER_MARKER = '.yam-flow-transaction-owner';
|
|
10
12
|
|
|
11
13
|
export type InstallFileDigest = {
|
|
12
14
|
path: string;
|
|
@@ -96,6 +98,37 @@ export type TransactionalInstallResult = {
|
|
|
96
98
|
cleanupWarnings: string[];
|
|
97
99
|
};
|
|
98
100
|
|
|
101
|
+
export type SkillInstallPlanOperation = {
|
|
102
|
+
action: 'create' | 'replace_managed' | 'replace_explicitly_authorized' | 'remove_retired' | 'write_receipt';
|
|
103
|
+
skill: string;
|
|
104
|
+
path: string;
|
|
105
|
+
reason: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type SkillInstallPlan = {
|
|
109
|
+
schema: 'yam.install-plan.v1';
|
|
110
|
+
generated_at: string;
|
|
111
|
+
mutation_authorized: false;
|
|
112
|
+
package: {
|
|
113
|
+
name: string;
|
|
114
|
+
version: string;
|
|
115
|
+
};
|
|
116
|
+
source: {
|
|
117
|
+
root: string;
|
|
118
|
+
digest: string;
|
|
119
|
+
file_count: number;
|
|
120
|
+
};
|
|
121
|
+
destination: string;
|
|
122
|
+
receipt_path: string;
|
|
123
|
+
operations: SkillInstallPlanOperation[];
|
|
124
|
+
preserved_paths: string[];
|
|
125
|
+
blockers: string[];
|
|
126
|
+
ready: boolean;
|
|
127
|
+
truth_status: 'verified' | 'blocked';
|
|
128
|
+
plan_digest: string;
|
|
129
|
+
next_action: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
99
132
|
export type SafeUninstallOptions = {
|
|
100
133
|
destination: string;
|
|
101
134
|
codexMirror?: string;
|
|
@@ -117,6 +150,8 @@ export type SafeUninstallResult = {
|
|
|
117
150
|
type MovedEntry = {
|
|
118
151
|
original: string;
|
|
119
152
|
backup: string;
|
|
153
|
+
originalBoundary: string;
|
|
154
|
+
backupBoundary: string;
|
|
120
155
|
};
|
|
121
156
|
|
|
122
157
|
type OwnershipPreflight = {
|
|
@@ -143,6 +178,27 @@ type MutationSnapshot = {
|
|
|
143
178
|
receipt: ReceiptMutationSnapshot;
|
|
144
179
|
};
|
|
145
180
|
|
|
181
|
+
type PathIdentity = {
|
|
182
|
+
path: string;
|
|
183
|
+
dev: number;
|
|
184
|
+
ino: number;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
type InstalledEntry = {
|
|
188
|
+
path: string;
|
|
189
|
+
dev: bigint;
|
|
190
|
+
ino: bigint;
|
|
191
|
+
kind: 'directory' | 'file';
|
|
192
|
+
birthtimeNs?: bigint;
|
|
193
|
+
handle?: fsp.FileHandle;
|
|
194
|
+
contentsSha256?: string;
|
|
195
|
+
treeDigest?: string;
|
|
196
|
+
ownershipMarker?: {
|
|
197
|
+
name: string;
|
|
198
|
+
sha256: string;
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
146
202
|
function message(error: unknown) {
|
|
147
203
|
return error instanceof Error ? error.message : String(error);
|
|
148
204
|
}
|
|
@@ -177,6 +233,199 @@ async function exists(target: string) {
|
|
|
177
233
|
}
|
|
178
234
|
}
|
|
179
235
|
|
|
236
|
+
function pathWithinBoundary(boundary: string, target: string) {
|
|
237
|
+
const relative = path.relative(boundary, target);
|
|
238
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function captureRegularParentPath(boundaryValue: string, targetValue: string, label: string) {
|
|
242
|
+
const boundary = path.resolve(boundaryValue);
|
|
243
|
+
const target = path.resolve(targetValue);
|
|
244
|
+
if (!pathWithinBoundary(boundary, target)) {
|
|
245
|
+
throw new Error(`${label} escapes its trusted path boundary: ${target}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const targetParent = target === boundary ? boundary : path.dirname(target);
|
|
249
|
+
const relativeParent = path.relative(boundary, targetParent);
|
|
250
|
+
const candidates = [boundary];
|
|
251
|
+
if (relativeParent) {
|
|
252
|
+
let current = boundary;
|
|
253
|
+
for (const segment of relativeParent.split(path.sep)) {
|
|
254
|
+
current = path.join(current, segment);
|
|
255
|
+
candidates.push(current);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const identities: PathIdentity[] = [];
|
|
260
|
+
for (const candidate of candidates) {
|
|
261
|
+
const stat = await fsp.lstat(candidate);
|
|
262
|
+
if (stat.isSymbolicLink()) {
|
|
263
|
+
throw new Error(`${label} has a symlinked parent path segment: ${candidate}`);
|
|
264
|
+
}
|
|
265
|
+
if (!stat.isDirectory()) {
|
|
266
|
+
throw new Error(`${label} parent path segment must be a regular directory: ${candidate}`);
|
|
267
|
+
}
|
|
268
|
+
identities.push({ path: candidate, dev: stat.dev, ino: stat.ino });
|
|
269
|
+
}
|
|
270
|
+
return identities;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function revalidateRegularParentPath(identities: PathIdentity[], label: string) {
|
|
274
|
+
for (const identity of identities) {
|
|
275
|
+
const stat = await fsp.lstat(identity.path);
|
|
276
|
+
if (
|
|
277
|
+
stat.isSymbolicLink()
|
|
278
|
+
|| !stat.isDirectory()
|
|
279
|
+
|| stat.dev !== identity.dev
|
|
280
|
+
|| stat.ino !== identity.ino
|
|
281
|
+
) {
|
|
282
|
+
throw new Error(`${label} parent path changed identity: ${identity.path}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function captureRegularDirectoryPath(boundaryValue: string, directoryValue: string, label: string) {
|
|
288
|
+
const boundary = path.resolve(boundaryValue);
|
|
289
|
+
const directory = path.resolve(directoryValue);
|
|
290
|
+
if (boundary === directory) {
|
|
291
|
+
const stat = await fsp.lstat(directory);
|
|
292
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
293
|
+
throw new Error(`${label} must be a regular directory: ${directory}`);
|
|
294
|
+
}
|
|
295
|
+
return [{ path: directory, dev: stat.dev, ino: stat.ino }];
|
|
296
|
+
}
|
|
297
|
+
const identities = await captureRegularParentPath(boundary, directory, label);
|
|
298
|
+
const stat = await fsp.lstat(directory);
|
|
299
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
300
|
+
throw new Error(`${label} must be a regular directory: ${directory}`);
|
|
301
|
+
}
|
|
302
|
+
identities.push({ path: directory, dev: stat.dev, ino: stat.ino });
|
|
303
|
+
return identities;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function readOnlyNoFollowFlags() {
|
|
307
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
308
|
+
? fsConstants.O_NOFOLLOW
|
|
309
|
+
: 0;
|
|
310
|
+
return fsConstants.O_RDONLY | noFollow;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function confirmWindowsPathForHandle(
|
|
314
|
+
target: string,
|
|
315
|
+
opened: BigIntStats,
|
|
316
|
+
before: BigIntStats,
|
|
317
|
+
label: string
|
|
318
|
+
) {
|
|
319
|
+
if (before.birthtimeNs <= 0n) {
|
|
320
|
+
throw new Error(`${label} requires a positive birthtime on Windows: ${target}`);
|
|
321
|
+
}
|
|
322
|
+
let pathHandle: fsp.FileHandle | undefined;
|
|
323
|
+
try {
|
|
324
|
+
pathHandle = await fsp.open(target, fsConstants.O_RDONLY);
|
|
325
|
+
const pathOpened = await pathHandle.stat({ bigint: true });
|
|
326
|
+
const confirmed = await fsp.lstat(target, { bigint: true });
|
|
327
|
+
if (
|
|
328
|
+
!opened.isFile()
|
|
329
|
+
|| !pathOpened.isFile()
|
|
330
|
+
|| pathOpened.dev !== opened.dev
|
|
331
|
+
|| pathOpened.ino !== opened.ino
|
|
332
|
+
|| confirmed.isSymbolicLink()
|
|
333
|
+
|| !confirmed.isFile()
|
|
334
|
+
|| confirmed.dev !== before.dev
|
|
335
|
+
|| confirmed.ino !== before.ino
|
|
336
|
+
|| confirmed.birthtimeNs !== before.birthtimeNs
|
|
337
|
+
) {
|
|
338
|
+
throw new Error(`${label} changed identity while confirming Windows path ownership: ${target}`);
|
|
339
|
+
}
|
|
340
|
+
return confirmed;
|
|
341
|
+
} finally {
|
|
342
|
+
if (pathHandle) await pathHandle.close();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function captureRegularFile(file: string, boundary = path.dirname(file)) {
|
|
347
|
+
const parents = await captureRegularParentPath(boundary, file, 'regular file read');
|
|
348
|
+
const before = await fsp.lstat(file, { bigint: true });
|
|
349
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error(`expected a regular file: ${file}`);
|
|
350
|
+
const handle = await fsp.open(file, readOnlyNoFollowFlags());
|
|
351
|
+
try {
|
|
352
|
+
const opened = await handle.stat({ bigint: true });
|
|
353
|
+
if (!opened.isFile()) throw new Error(`expected a regular file: ${file}`);
|
|
354
|
+
const bytes = await handle.readFile();
|
|
355
|
+
const afterOpened = await handle.stat({ bigint: true });
|
|
356
|
+
const after = process.platform === 'win32'
|
|
357
|
+
? await confirmWindowsPathForHandle(file, opened, before, 'regular file')
|
|
358
|
+
: await fsp.lstat(file, { bigint: true });
|
|
359
|
+
await revalidateRegularParentPath(parents, 'regular file read');
|
|
360
|
+
if (
|
|
361
|
+
!after.isFile()
|
|
362
|
+
|| after.isSymbolicLink()
|
|
363
|
+
|| afterOpened.dev !== opened.dev
|
|
364
|
+
|| afterOpened.ino !== opened.ino
|
|
365
|
+
|| (process.platform !== 'win32' && (
|
|
366
|
+
before.dev !== opened.dev
|
|
367
|
+
|| before.ino !== opened.ino
|
|
368
|
+
|| after.dev !== opened.dev
|
|
369
|
+
|| after.ino !== opened.ino
|
|
370
|
+
))
|
|
371
|
+
) {
|
|
372
|
+
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
373
|
+
}
|
|
374
|
+
return { bytes, mode: Number(opened.mode & 0o777n) };
|
|
375
|
+
} finally {
|
|
376
|
+
await handle.close();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function readRegularFileBytes(file: string, boundary = path.dirname(file)) {
|
|
381
|
+
return (await captureRegularFile(file, boundary)).bytes;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function copyRegularFile(
|
|
385
|
+
source: string,
|
|
386
|
+
target: string,
|
|
387
|
+
sourceBoundary: string,
|
|
388
|
+
targetBoundary: string
|
|
389
|
+
) {
|
|
390
|
+
const sourceFile = await captureRegularFile(source, sourceBoundary);
|
|
391
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, 'install copy target');
|
|
392
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
393
|
+
? fsConstants.O_NOFOLLOW
|
|
394
|
+
: 0;
|
|
395
|
+
const handle = await fsp.open(
|
|
396
|
+
target,
|
|
397
|
+
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow,
|
|
398
|
+
sourceFile.mode
|
|
399
|
+
);
|
|
400
|
+
try {
|
|
401
|
+
await handle.writeFile(sourceFile.bytes);
|
|
402
|
+
const opened = await handle.stat({ bigint: true });
|
|
403
|
+
if (!opened.isFile()) throw new Error(`install copy target must be a regular file: ${target}`);
|
|
404
|
+
const openedIdentity = { dev: opened.dev, ino: opened.ino };
|
|
405
|
+
const afterOpened = await handle.stat({ bigint: true });
|
|
406
|
+
if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
|
|
407
|
+
throw new Error(`install copy target changed descriptor identity: ${target}`);
|
|
408
|
+
}
|
|
409
|
+
await revalidateRegularParentPath(targetParents, 'install copy target');
|
|
410
|
+
const beforeInstalled = await fsp.lstat(target, { bigint: true });
|
|
411
|
+
const installed = process.platform === 'win32'
|
|
412
|
+
? await confirmWindowsPathForHandle(target, opened, beforeInstalled, 'install copy target')
|
|
413
|
+
: beforeInstalled;
|
|
414
|
+
if (
|
|
415
|
+
!installed.isFile()
|
|
416
|
+
|| installed.isSymbolicLink()
|
|
417
|
+
|| (process.platform !== 'win32' && (
|
|
418
|
+
installed.dev !== openedIdentity.dev
|
|
419
|
+
|| installed.ino !== openedIdentity.ino
|
|
420
|
+
))
|
|
421
|
+
) {
|
|
422
|
+
throw new Error(`install copy target changed path identity: ${target}`);
|
|
423
|
+
}
|
|
424
|
+
} finally {
|
|
425
|
+
await handle.close();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
180
429
|
export async function findInstallRecoveryArtifacts(root: string) {
|
|
181
430
|
try {
|
|
182
431
|
const entries = await fsp.readdir(root, { withFileTypes: true });
|
|
@@ -190,12 +439,16 @@ export async function findInstallRecoveryArtifacts(root: string) {
|
|
|
190
439
|
}
|
|
191
440
|
}
|
|
192
441
|
|
|
193
|
-
async function digestFile(
|
|
442
|
+
async function digestFile(
|
|
443
|
+
file: string,
|
|
444
|
+
relativePath: string,
|
|
445
|
+
boundary = path.dirname(file)
|
|
446
|
+
): Promise<InstallFileDigest> {
|
|
194
447
|
const stat = await fsp.lstat(file);
|
|
195
448
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
196
449
|
throw new Error(`install source must be a regular file: ${file}`);
|
|
197
450
|
}
|
|
198
|
-
const contents = await
|
|
451
|
+
const contents = await readRegularFileBytes(file, boundary);
|
|
199
452
|
return {
|
|
200
453
|
path: relativePath.split(path.sep).join('/'),
|
|
201
454
|
bytes: contents.byteLength,
|
|
@@ -203,14 +456,16 @@ async function digestFile(file: string, relativePath: string): Promise<InstallFi
|
|
|
203
456
|
};
|
|
204
457
|
}
|
|
205
458
|
|
|
206
|
-
async function collectTreeManifest(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
459
|
+
async function collectTreeManifest(
|
|
460
|
+
root: string,
|
|
461
|
+
prefix = '',
|
|
462
|
+
boundary = root
|
|
463
|
+
): Promise<InstallFileDigest[]> {
|
|
464
|
+
const rootIdentity = await captureRegularDirectoryPath(boundary, root, 'install tree');
|
|
211
465
|
|
|
212
466
|
const files: InstallFileDigest[] = [];
|
|
213
467
|
async function walk(current: string, relative: string): Promise<void> {
|
|
468
|
+
const directoryIdentity = await captureRegularDirectoryPath(boundary, current, 'install tree');
|
|
214
469
|
const entries = await fsp.readdir(current, { withFileTypes: true });
|
|
215
470
|
entries.sort((left, right) => compareStableText(left.name, right.name));
|
|
216
471
|
for (const entry of entries) {
|
|
@@ -219,34 +474,42 @@ async function collectTreeManifest(root: string, prefix = ''): Promise<InstallFi
|
|
|
219
474
|
if (entry.isDirectory()) {
|
|
220
475
|
await walk(absolute, nextRelative);
|
|
221
476
|
} else if (entry.isFile()) {
|
|
222
|
-
files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
|
|
477
|
+
files.push(await digestFile(absolute, path.join(prefix, nextRelative), boundary));
|
|
223
478
|
} else {
|
|
224
479
|
throw new Error(`unsupported install tree entry: ${absolute}`);
|
|
225
480
|
}
|
|
226
481
|
}
|
|
482
|
+
await revalidateRegularParentPath(directoryIdentity, 'install tree');
|
|
227
483
|
}
|
|
228
484
|
|
|
229
485
|
await walk(root, '');
|
|
486
|
+
await revalidateRegularParentPath(rootIdentity, 'install tree');
|
|
230
487
|
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
231
488
|
}
|
|
232
489
|
|
|
233
490
|
async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
|
|
491
|
+
const sourceIdentity = await captureRegularDirectoryPath(sourceRoot, sourceRoot, 'install source root');
|
|
234
492
|
const referencesRoot = path.join(sourceRoot, 'references');
|
|
235
|
-
const referenceFiles = await collectTreeManifest(referencesRoot);
|
|
493
|
+
const referenceFiles = await collectTreeManifest(referencesRoot, '', sourceRoot);
|
|
236
494
|
if (referenceFiles.length === 0) throw new Error(`no reference files found: ${referencesRoot}`);
|
|
237
495
|
|
|
238
496
|
const files: InstallFileDigest[] = [];
|
|
239
497
|
for (const skill of skills) {
|
|
240
498
|
const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
const
|
|
499
|
+
const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
|
|
500
|
+
const skillText = skillBytes.toString('utf8');
|
|
501
|
+
const frontmatter = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
502
|
+
const name = frontmatter?.[1].match(/^name:\s*([^\r\n]+)\s*$/m)?.[1]
|
|
244
503
|
?.trim()
|
|
245
504
|
.replace(/^["']|["']$/g, '');
|
|
246
505
|
if (name !== skill) {
|
|
247
506
|
throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
|
|
248
507
|
}
|
|
249
|
-
files.push(
|
|
508
|
+
files.push({
|
|
509
|
+
path: path.join(skill, 'SKILL.md').split(path.sep).join('/'),
|
|
510
|
+
bytes: skillBytes.byteLength,
|
|
511
|
+
sha256: createHash('sha256').update(skillBytes).digest('hex')
|
|
512
|
+
});
|
|
250
513
|
for (const reference of referenceFiles) {
|
|
251
514
|
files.push({
|
|
252
515
|
...reference,
|
|
@@ -254,28 +517,125 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
|
|
|
254
517
|
});
|
|
255
518
|
}
|
|
256
519
|
}
|
|
520
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source root');
|
|
257
521
|
return files.sort((left, right) => compareStableText(left.path, right.path));
|
|
258
522
|
}
|
|
259
523
|
|
|
260
|
-
async function copyTree(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
524
|
+
async function copyTree(
|
|
525
|
+
source: string,
|
|
526
|
+
target: string,
|
|
527
|
+
sourceBoundary = source,
|
|
528
|
+
targetBoundary = target
|
|
529
|
+
): Promise<void> {
|
|
530
|
+
const sourceIdentity = await captureRegularDirectoryPath(sourceBoundary, source, 'install source tree');
|
|
531
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, 'install target tree');
|
|
265
532
|
await fsp.mkdir(target, { recursive: true });
|
|
533
|
+
const targetIdentity = await captureRegularDirectoryPath(targetBoundary, target, 'install target tree');
|
|
534
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source tree');
|
|
535
|
+
await revalidateRegularParentPath(targetParents, 'install target tree');
|
|
266
536
|
const entries = await fsp.readdir(source, { withFileTypes: true });
|
|
267
537
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
268
538
|
for (const entry of entries) {
|
|
269
539
|
const from = path.join(source, entry.name);
|
|
270
540
|
const to = path.join(target, entry.name);
|
|
271
541
|
if (entry.isDirectory()) {
|
|
272
|
-
await copyTree(from, to);
|
|
542
|
+
await copyTree(from, to, sourceBoundary, targetBoundary);
|
|
273
543
|
} else if (entry.isFile()) {
|
|
274
|
-
await
|
|
544
|
+
await copyRegularFile(from, to, sourceBoundary, targetBoundary);
|
|
275
545
|
} else {
|
|
276
546
|
throw new Error(`unsupported install source entry: ${from}`);
|
|
277
547
|
}
|
|
278
548
|
}
|
|
549
|
+
await revalidateRegularParentPath(sourceIdentity, 'install source tree');
|
|
550
|
+
await revalidateRegularParentPath(targetIdentity, 'install target tree');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function moveRegularDirectory(
|
|
554
|
+
source: string,
|
|
555
|
+
target: string,
|
|
556
|
+
sourceBoundary: string,
|
|
557
|
+
targetBoundary: string,
|
|
558
|
+
label: string,
|
|
559
|
+
onMoved?: () => void,
|
|
560
|
+
identityOwner?: InstalledEntry[]
|
|
561
|
+
): Promise<InstalledEntry | undefined> {
|
|
562
|
+
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
563
|
+
const sourceStat = await fsp.lstat(source);
|
|
564
|
+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
565
|
+
throw new Error(`${label} source must be a regular directory: ${source}`);
|
|
566
|
+
}
|
|
567
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
568
|
+
if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
569
|
+
const recorded = identityOwner ? await captureRecordedEntry(source, 'directory', label) : undefined;
|
|
570
|
+
let identityTransferred = false;
|
|
571
|
+
try {
|
|
572
|
+
await fsp.rename(source, target);
|
|
573
|
+
if (!recorded) onMoved?.();
|
|
574
|
+
if (recorded) {
|
|
575
|
+
recorded.path = target;
|
|
576
|
+
identityOwner?.push(recorded);
|
|
577
|
+
identityTransferred = true;
|
|
578
|
+
}
|
|
579
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
580
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
581
|
+
const targetStat = await fsp.lstat(target);
|
|
582
|
+
if (
|
|
583
|
+
!targetStat.isDirectory()
|
|
584
|
+
|| targetStat.isSymbolicLink()
|
|
585
|
+
|| targetStat.dev !== sourceStat.dev
|
|
586
|
+
|| targetStat.ino !== sourceStat.ino
|
|
587
|
+
) {
|
|
588
|
+
throw new Error(`${label} directory changed identity while being moved: ${target}`);
|
|
589
|
+
}
|
|
590
|
+
return recorded;
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (recorded && !identityTransferred) await closeRecordedEntry(recorded);
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function moveRegularFile(
|
|
598
|
+
source: string,
|
|
599
|
+
target: string,
|
|
600
|
+
sourceBoundary: string,
|
|
601
|
+
targetBoundary: string,
|
|
602
|
+
label: string,
|
|
603
|
+
onMoved?: () => void,
|
|
604
|
+
identityOwner?: InstalledEntry[]
|
|
605
|
+
): Promise<InstalledEntry | undefined> {
|
|
606
|
+
const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
|
|
607
|
+
const sourceStat = await fsp.lstat(source);
|
|
608
|
+
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
609
|
+
throw new Error(`${label} source must be a regular file: ${source}`);
|
|
610
|
+
}
|
|
611
|
+
const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
|
|
612
|
+
if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
|
|
613
|
+
const recorded = identityOwner ? await captureRecordedEntry(source, 'file', label) : undefined;
|
|
614
|
+
let identityTransferred = false;
|
|
615
|
+
try {
|
|
616
|
+
await fsp.rename(source, target);
|
|
617
|
+
if (!recorded) onMoved?.();
|
|
618
|
+
if (recorded) {
|
|
619
|
+
recorded.path = target;
|
|
620
|
+
identityOwner?.push(recorded);
|
|
621
|
+
identityTransferred = true;
|
|
622
|
+
}
|
|
623
|
+
await revalidateRegularParentPath(sourceParents, `${label} source`);
|
|
624
|
+
await revalidateRegularParentPath(targetParents, `${label} target`);
|
|
625
|
+
const targetStat = await fsp.lstat(target);
|
|
626
|
+
if (
|
|
627
|
+
!targetStat.isFile()
|
|
628
|
+
|| targetStat.isSymbolicLink()
|
|
629
|
+
|| targetStat.dev !== sourceStat.dev
|
|
630
|
+
|| targetStat.ino !== sourceStat.ino
|
|
631
|
+
) {
|
|
632
|
+
throw new Error(`${label} file changed identity while being moved: ${target}`);
|
|
633
|
+
}
|
|
634
|
+
return recorded;
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (recorded && !identityTransferred) await closeRecordedEntry(recorded);
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
279
639
|
}
|
|
280
640
|
|
|
281
641
|
function manifestDigest(files: InstallFileDigest[]) {
|
|
@@ -430,7 +790,7 @@ async function readOwnershipReceipt(
|
|
|
430
790
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
431
791
|
return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
|
|
432
792
|
}
|
|
433
|
-
const parsed = JSON.parse(await
|
|
793
|
+
const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
|
|
434
794
|
const issues = ownershipReceiptIssues(parsed, packageName, destination);
|
|
435
795
|
return issues.length
|
|
436
796
|
? { exists: true, issues }
|
|
@@ -457,7 +817,7 @@ async function verifyReceiptOwnedSkill(
|
|
|
457
817
|
const target = path.join(root, skill);
|
|
458
818
|
if (!await exists(target)) return [`receipt-owned skill is missing: ${skill}`];
|
|
459
819
|
try {
|
|
460
|
-
const actual = await collectTreeManifest(target, skill);
|
|
820
|
+
const actual = await collectTreeManifest(target, skill, root);
|
|
461
821
|
return manifestIssues(expected, actual, `installed ${skill}`);
|
|
462
822
|
} catch (error) {
|
|
463
823
|
return [`installed ${skill} cannot be verified: ${message(error)}`];
|
|
@@ -552,31 +912,6 @@ async function preflightSafeUninstallOwnership(
|
|
|
552
912
|
return receipt;
|
|
553
913
|
}
|
|
554
914
|
|
|
555
|
-
async function readRegularFileBytes(file: string) {
|
|
556
|
-
const before = await fsp.lstat(file);
|
|
557
|
-
if (!before.isFile() || before.isSymbolicLink()) throw new Error(`expected a regular file: ${file}`);
|
|
558
|
-
const handle = await fsp.open(file, 'r');
|
|
559
|
-
try {
|
|
560
|
-
const opened = await handle.stat();
|
|
561
|
-
if (!opened.isFile()) throw new Error(`expected a regular file: ${file}`);
|
|
562
|
-
const bytes = await handle.readFile();
|
|
563
|
-
const after = await fsp.lstat(file);
|
|
564
|
-
if (
|
|
565
|
-
!after.isFile()
|
|
566
|
-
|| after.isSymbolicLink()
|
|
567
|
-
|| before.dev !== opened.dev
|
|
568
|
-
|| before.ino !== opened.ino
|
|
569
|
-
|| after.dev !== opened.dev
|
|
570
|
-
|| after.ino !== opened.ino
|
|
571
|
-
) {
|
|
572
|
-
throw new Error(`regular file changed identity while being captured: ${file}`);
|
|
573
|
-
}
|
|
574
|
-
return bytes;
|
|
575
|
-
} finally {
|
|
576
|
-
await handle.close();
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
915
|
async function captureReceiptMutationSnapshot(
|
|
581
916
|
destination: string,
|
|
582
917
|
packageName: string
|
|
@@ -584,7 +919,7 @@ async function captureReceiptMutationSnapshot(
|
|
|
584
919
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
585
920
|
let bytes: Buffer;
|
|
586
921
|
try {
|
|
587
|
-
bytes = await readRegularFileBytes(receiptPath);
|
|
922
|
+
bytes = await readRegularFileBytes(receiptPath, destination);
|
|
588
923
|
} catch (error) {
|
|
589
924
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exists: false };
|
|
590
925
|
throw new Error(`cannot capture install receipt ownership boundary: ${message(error)}`);
|
|
@@ -616,7 +951,7 @@ async function captureMutationSnapshot(
|
|
|
616
951
|
const receipt = await captureReceiptMutationSnapshot(destination, packageName);
|
|
617
952
|
const trees: TreeMutationSnapshot[] = [];
|
|
618
953
|
for (const name of names) {
|
|
619
|
-
const files = await collectTreeManifest(path.join(destination, name), name);
|
|
954
|
+
const files = await collectTreeManifest(path.join(destination, name), name, destination);
|
|
620
955
|
if (!explicitReplacements.includes(name)) {
|
|
621
956
|
if (!receipt.exists) {
|
|
622
957
|
throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
|
|
@@ -651,7 +986,11 @@ async function verifyMovedMutationSnapshot(
|
|
|
651
986
|
continue;
|
|
652
987
|
}
|
|
653
988
|
try {
|
|
654
|
-
const actual = await collectTreeManifest(
|
|
989
|
+
const actual = await collectTreeManifest(
|
|
990
|
+
movedEntry.backup,
|
|
991
|
+
tree.name,
|
|
992
|
+
path.dirname(movedEntry.backup)
|
|
993
|
+
);
|
|
655
994
|
issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
|
|
656
995
|
} catch (error) {
|
|
657
996
|
issues.push(`moved backup ${tree.name} cannot be verified: ${message(error)}`);
|
|
@@ -666,7 +1005,7 @@ async function verifyMovedMutationSnapshot(
|
|
|
666
1005
|
);
|
|
667
1006
|
} else if (snapshot.receipt.exists && receiptBackedUp) {
|
|
668
1007
|
try {
|
|
669
|
-
const bytes = await readRegularFileBytes(receiptBackupPath);
|
|
1008
|
+
const bytes = await readRegularFileBytes(receiptBackupPath, path.dirname(receiptBackupPath));
|
|
670
1009
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
671
1010
|
if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
|
|
672
1011
|
issues.push('moved install receipt differs from the captured bytes');
|
|
@@ -739,7 +1078,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
|
|
|
739
1078
|
continue;
|
|
740
1079
|
}
|
|
741
1080
|
try {
|
|
742
|
-
const installed = (await collectTreeManifest(target, skill));
|
|
1081
|
+
const installed = (await collectTreeManifest(target, skill, destination));
|
|
743
1082
|
const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
|
|
744
1083
|
skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
|
|
745
1084
|
issues.push(...skillIssues);
|
|
@@ -756,7 +1095,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
|
|
|
756
1095
|
issues.push(`install receipt missing: ${receiptPath}`);
|
|
757
1096
|
} else {
|
|
758
1097
|
try {
|
|
759
|
-
const parsed = JSON.parse(await
|
|
1098
|
+
const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
|
|
760
1099
|
const problems = receiptIssues(parsed, options, destination, expected);
|
|
761
1100
|
if (problems.length) {
|
|
762
1101
|
receiptStatus = 'drift';
|
|
@@ -786,6 +1125,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
|
|
|
786
1125
|
|
|
787
1126
|
async function acquireInstallLock(destination: string) {
|
|
788
1127
|
const lockPath = path.join(destination, INSTALL_LOCK_NAME);
|
|
1128
|
+
const destinationIdentity = await captureRegularDirectoryPath(destination, destination, 'skill destination');
|
|
789
1129
|
let handle: fsp.FileHandle;
|
|
790
1130
|
try {
|
|
791
1131
|
handle = await fsp.open(lockPath, 'wx', 0o600);
|
|
@@ -795,24 +1135,68 @@ async function acquireInstallLock(destination: string) {
|
|
|
795
1135
|
}
|
|
796
1136
|
throw error;
|
|
797
1137
|
}
|
|
1138
|
+
let lockIdentity: InstalledEntry | undefined;
|
|
798
1139
|
try {
|
|
799
|
-
|
|
1140
|
+
const lockContents = JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2);
|
|
1141
|
+
await handle.writeFile(lockContents);
|
|
1142
|
+
const opened = await handle.stat({ bigint: true });
|
|
1143
|
+
if (!opened.isFile()) throw new Error(`install lock must be a regular file: ${lockPath}`);
|
|
1144
|
+
lockIdentity = await captureLockIdentity(lockPath, opened);
|
|
1145
|
+
lockIdentity.contentsSha256 = createHash('sha256').update(lockContents).digest('hex');
|
|
1146
|
+
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
1147
|
+
const current = await fsp.lstat(lockPath, { bigint: true });
|
|
1148
|
+
if (
|
|
1149
|
+
current.isSymbolicLink()
|
|
1150
|
+
|| !current.isFile()
|
|
1151
|
+
|| current.dev !== lockIdentity.dev
|
|
1152
|
+
|| current.ino !== lockIdentity.ino
|
|
1153
|
+
|| (lockIdentity.birthtimeNs !== undefined && current.birthtimeNs !== lockIdentity.birthtimeNs)
|
|
1154
|
+
) {
|
|
1155
|
+
throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
|
|
1156
|
+
}
|
|
800
1157
|
} catch (error) {
|
|
801
|
-
|
|
802
|
-
await
|
|
1158
|
+
const cleanupErrors: string[] = [];
|
|
1159
|
+
cleanupErrors.push(...await closeFileHandle(handle, lockPath));
|
|
1160
|
+
try {
|
|
1161
|
+
await revalidateRegularParentPath(destinationIdentity, 'skill destination');
|
|
1162
|
+
if (!lockIdentity) throw new Error(`install lock identity was not captured: ${lockPath}`);
|
|
1163
|
+
await removeRecordedEntry(lockIdentity, 'install lock');
|
|
1164
|
+
} catch (cleanupError) {
|
|
1165
|
+
cleanupErrors.push(message(cleanupError));
|
|
1166
|
+
} finally {
|
|
1167
|
+
const closeErrors = lockIdentity
|
|
1168
|
+
? await closeRecordedEntries([lockIdentity])
|
|
1169
|
+
: [];
|
|
1170
|
+
cleanupErrors.push(...closeErrors);
|
|
1171
|
+
}
|
|
1172
|
+
if (cleanupErrors.length) {
|
|
1173
|
+
throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
|
|
1174
|
+
}
|
|
803
1175
|
throw error;
|
|
804
1176
|
}
|
|
805
|
-
return { handle, lockPath };
|
|
1177
|
+
return { handle, lockPath, lockIdentity, destinationIdentity };
|
|
806
1178
|
}
|
|
807
1179
|
|
|
808
1180
|
async function moveExistingEntries(root: string, names: string[], backupRoot: string, moved: MovedEntry[]) {
|
|
809
1181
|
await fsp.mkdir(backupRoot, { recursive: true });
|
|
1182
|
+
const backupBoundary = path.dirname(backupRoot);
|
|
810
1183
|
for (const name of names) {
|
|
811
1184
|
const original = path.join(root, name);
|
|
812
1185
|
if (!await exists(original)) continue;
|
|
813
1186
|
const backup = path.join(backupRoot, name);
|
|
814
|
-
await
|
|
815
|
-
|
|
1187
|
+
await moveRegularDirectory(
|
|
1188
|
+
original,
|
|
1189
|
+
backup,
|
|
1190
|
+
root,
|
|
1191
|
+
backupBoundary,
|
|
1192
|
+
'managed install backup',
|
|
1193
|
+
() => moved.push({
|
|
1194
|
+
original,
|
|
1195
|
+
backup,
|
|
1196
|
+
originalBoundary: root,
|
|
1197
|
+
backupBoundary
|
|
1198
|
+
})
|
|
1199
|
+
);
|
|
816
1200
|
}
|
|
817
1201
|
}
|
|
818
1202
|
|
|
@@ -824,7 +1208,13 @@ async function restoreMovedEntries(entries: MovedEntry[]) {
|
|
|
824
1208
|
throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
|
|
825
1209
|
}
|
|
826
1210
|
await fsp.mkdir(path.dirname(entry.original), { recursive: true });
|
|
827
|
-
await
|
|
1211
|
+
await moveRegularDirectory(
|
|
1212
|
+
entry.backup,
|
|
1213
|
+
entry.original,
|
|
1214
|
+
entry.backupBoundary,
|
|
1215
|
+
entry.originalBoundary,
|
|
1216
|
+
'managed install rollback'
|
|
1217
|
+
);
|
|
828
1218
|
} catch (error) {
|
|
829
1219
|
errors.push(message(error));
|
|
830
1220
|
}
|
|
@@ -832,18 +1222,369 @@ async function restoreMovedEntries(entries: MovedEntry[]) {
|
|
|
832
1222
|
return errors;
|
|
833
1223
|
}
|
|
834
1224
|
|
|
835
|
-
async function removeInstalledEntries(entries:
|
|
1225
|
+
async function removeInstalledEntries(entries: InstalledEntry[]) {
|
|
836
1226
|
const errors: string[] = [];
|
|
837
1227
|
for (const entry of [...entries].reverse()) {
|
|
838
1228
|
try {
|
|
839
|
-
await
|
|
1229
|
+
await removeRecordedEntry(entry, 'rollback removal');
|
|
840
1230
|
} catch (error) {
|
|
841
|
-
errors.push(`could not remove new install ${entry}: ${message(error)}`);
|
|
1231
|
+
errors.push(`could not remove new install ${entry.path}: ${message(error)}`);
|
|
842
1232
|
}
|
|
843
1233
|
}
|
|
844
1234
|
return errors;
|
|
845
1235
|
}
|
|
846
1236
|
|
|
1237
|
+
async function captureRecordedEntry(
|
|
1238
|
+
target: string,
|
|
1239
|
+
kind: InstalledEntry['kind'],
|
|
1240
|
+
label: string,
|
|
1241
|
+
ownershipMarker?: InstalledEntry['ownershipMarker']
|
|
1242
|
+
): Promise<InstalledEntry> {
|
|
1243
|
+
const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
|
|
1244
|
+
? fsConstants.O_NOFOLLOW
|
|
1245
|
+
: 0;
|
|
1246
|
+
const directoryOnly = kind === 'directory'
|
|
1247
|
+
&& process.platform !== 'win32'
|
|
1248
|
+
&& typeof fsConstants.O_DIRECTORY === 'number'
|
|
1249
|
+
? fsConstants.O_DIRECTORY
|
|
1250
|
+
: 0;
|
|
1251
|
+
let handle: fsp.FileHandle | undefined;
|
|
1252
|
+
try {
|
|
1253
|
+
if (process.platform !== 'win32') {
|
|
1254
|
+
handle = await fsp.open(target, fsConstants.O_RDONLY | noFollow | directoryOnly);
|
|
1255
|
+
}
|
|
1256
|
+
if (handle) return await captureRecordedEntryFromHandle(target, kind, label, handle);
|
|
1257
|
+
const stat = await fsp.lstat(target, { bigint: true });
|
|
1258
|
+
const typeMatches = kind === 'directory'
|
|
1259
|
+
? stat.isDirectory() && !stat.isSymbolicLink()
|
|
1260
|
+
: stat.isFile() && !stat.isSymbolicLink();
|
|
1261
|
+
if (!typeMatches || stat.birthtimeNs <= 0n) {
|
|
1262
|
+
throw new Error(`${label} must be one stable regular ${kind} with a positive birthtime: ${target}`);
|
|
1263
|
+
}
|
|
1264
|
+
const entry: InstalledEntry = {
|
|
1265
|
+
path: target,
|
|
1266
|
+
dev: stat.dev,
|
|
1267
|
+
ino: stat.ino,
|
|
1268
|
+
kind,
|
|
1269
|
+
birthtimeNs: stat.birthtimeNs,
|
|
1270
|
+
ownershipMarker
|
|
1271
|
+
};
|
|
1272
|
+
if (kind === 'file') {
|
|
1273
|
+
const bytes = await readRegularFileBytes(target, path.dirname(target));
|
|
1274
|
+
entry.contentsSha256 = createHash('sha256').update(bytes).digest('hex');
|
|
1275
|
+
} else if (!ownershipMarker) {
|
|
1276
|
+
entry.treeDigest = manifestDigest(await collectTreeManifest(target));
|
|
1277
|
+
}
|
|
1278
|
+
return entry;
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
if (handle) await handle.close().catch(() => {});
|
|
1281
|
+
throw error;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function captureRecordedEntryFromHandle(
|
|
1286
|
+
target: string,
|
|
1287
|
+
kind: InstalledEntry['kind'],
|
|
1288
|
+
label: string,
|
|
1289
|
+
handle: fsp.FileHandle
|
|
1290
|
+
): Promise<InstalledEntry> {
|
|
1291
|
+
const opened = await handle.stat({ bigint: true });
|
|
1292
|
+
const current = await fsp.lstat(target, { bigint: true });
|
|
1293
|
+
const typeMatches = kind === 'directory'
|
|
1294
|
+
? opened.isDirectory() && current.isDirectory() && !current.isSymbolicLink()
|
|
1295
|
+
: opened.isFile() && current.isFile() && !current.isSymbolicLink();
|
|
1296
|
+
const birthtimeAvailable = opened.birthtimeNs > 0n && current.birthtimeNs > 0n;
|
|
1297
|
+
if (
|
|
1298
|
+
!typeMatches
|
|
1299
|
+
|| opened.dev !== current.dev
|
|
1300
|
+
|| opened.ino !== current.ino
|
|
1301
|
+
|| (birthtimeAvailable && opened.birthtimeNs !== current.birthtimeNs)
|
|
1302
|
+
) {
|
|
1303
|
+
throw new Error(`${label} must be one stable regular ${kind}: ${target}`);
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
path: target,
|
|
1307
|
+
dev: opened.dev,
|
|
1308
|
+
ino: opened.ino,
|
|
1309
|
+
kind,
|
|
1310
|
+
birthtimeNs: birthtimeAvailable ? opened.birthtimeNs : undefined,
|
|
1311
|
+
handle
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
async function captureLockIdentity(target: string, created: BigIntStats): Promise<InstalledEntry> {
|
|
1316
|
+
const current = await fsp.lstat(target, { bigint: true });
|
|
1317
|
+
if (
|
|
1318
|
+
!created.isFile()
|
|
1319
|
+
|| current.isSymbolicLink()
|
|
1320
|
+
|| !current.isFile()
|
|
1321
|
+
|| (process.platform !== 'win32' && (
|
|
1322
|
+
created.dev !== current.dev
|
|
1323
|
+
|| created.ino !== current.ino
|
|
1324
|
+
))
|
|
1325
|
+
) {
|
|
1326
|
+
throw new Error(`install lock changed identity after exclusive creation: ${target}`);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
let pinHandle: fsp.FileHandle | undefined;
|
|
1330
|
+
try {
|
|
1331
|
+
if (process.platform !== 'win32') {
|
|
1332
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
1333
|
+
pinHandle = await fsp.open(target, fsConstants.O_RDONLY | noFollow);
|
|
1334
|
+
const pinned = await pinHandle.stat({ bigint: true });
|
|
1335
|
+
if (
|
|
1336
|
+
!pinned.isFile()
|
|
1337
|
+
|| pinned.dev !== created.dev
|
|
1338
|
+
|| pinned.ino !== created.ino
|
|
1339
|
+
) {
|
|
1340
|
+
throw new Error(`install lock pin descriptor does not match exclusive creation: ${target}`);
|
|
1341
|
+
}
|
|
1342
|
+
} else {
|
|
1343
|
+
const confirmed = await confirmWindowsPathForHandle(target, created, current, 'install lock');
|
|
1344
|
+
return {
|
|
1345
|
+
path: target,
|
|
1346
|
+
dev: confirmed.dev,
|
|
1347
|
+
ino: confirmed.ino,
|
|
1348
|
+
kind: 'file',
|
|
1349
|
+
birthtimeNs: confirmed.birthtimeNs
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
path: target,
|
|
1354
|
+
dev: created.dev,
|
|
1355
|
+
ino: created.ino,
|
|
1356
|
+
kind: 'file',
|
|
1357
|
+
handle: pinHandle
|
|
1358
|
+
};
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
if (pinHandle) await pinHandle.close().catch(() => {});
|
|
1361
|
+
throw error;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function removeRecordedEntry(entry: InstalledEntry, label: string) {
|
|
1366
|
+
try {
|
|
1367
|
+
let current;
|
|
1368
|
+
try {
|
|
1369
|
+
current = await fsp.lstat(entry.path, { bigint: true });
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
1372
|
+
throw error;
|
|
1373
|
+
}
|
|
1374
|
+
const opened = entry.handle ? await entry.handle.stat({ bigint: true }) : undefined;
|
|
1375
|
+
const typeMatches = entry.kind === 'directory'
|
|
1376
|
+
? current.isDirectory() && !current.isSymbolicLink()
|
|
1377
|
+
: current.isFile() && !current.isSymbolicLink();
|
|
1378
|
+
if (
|
|
1379
|
+
!typeMatches
|
|
1380
|
+
|| current.dev !== entry.dev
|
|
1381
|
+
|| current.ino !== entry.ino
|
|
1382
|
+
|| (entry.birthtimeNs !== undefined && current.birthtimeNs !== entry.birthtimeNs)
|
|
1383
|
+
|| (opened && (
|
|
1384
|
+
opened.dev !== current.dev
|
|
1385
|
+
|| opened.ino !== current.ino
|
|
1386
|
+
|| (entry.birthtimeNs !== undefined && opened.birthtimeNs !== current.birthtimeNs)
|
|
1387
|
+
))
|
|
1388
|
+
) {
|
|
1389
|
+
throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
|
|
1390
|
+
}
|
|
1391
|
+
if (entry.contentsSha256) {
|
|
1392
|
+
try {
|
|
1393
|
+
const bytes = await readRegularFileBytes(entry.path, path.dirname(entry.path));
|
|
1394
|
+
const currentSha256 = createHash('sha256').update(bytes).digest('hex');
|
|
1395
|
+
if (currentSha256 !== entry.contentsSha256) throw new Error('content digest changed');
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
throw new Error(`${label} identity mismatch (content: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
if (entry.treeDigest) {
|
|
1401
|
+
try {
|
|
1402
|
+
const currentTreeDigest = manifestDigest(await collectTreeManifest(entry.path));
|
|
1403
|
+
if (currentTreeDigest !== entry.treeDigest) throw new Error('tree digest changed');
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
throw new Error(`${label} identity mismatch (tree: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (entry.ownershipMarker) {
|
|
1409
|
+
try {
|
|
1410
|
+
const markerPath = path.join(entry.path, entry.ownershipMarker.name);
|
|
1411
|
+
const markerBytes = await readRegularFileBytes(markerPath, entry.path);
|
|
1412
|
+
const markerSha256 = createHash('sha256').update(markerBytes).digest('hex');
|
|
1413
|
+
if (markerSha256 !== entry.ownershipMarker.sha256) throw new Error('ownership marker digest changed');
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
throw new Error(`${label} identity mismatch (ownership marker: ${message(error)}); preserved current path: ${entry.path}`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
|
|
1419
|
+
} finally {
|
|
1420
|
+
await closeRecordedEntry(entry);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
async function captureTransactionRoot(target: string, label: string) {
|
|
1425
|
+
const markerContents = `${randomUUID()}\n`;
|
|
1426
|
+
const markerPath = path.join(target, TRANSACTION_OWNER_MARKER);
|
|
1427
|
+
await fsp.writeFile(markerPath, markerContents, { flag: 'wx', mode: 0o600 });
|
|
1428
|
+
return captureRecordedEntry(target, 'directory', label, {
|
|
1429
|
+
name: TRANSACTION_OWNER_MARKER,
|
|
1430
|
+
sha256: createHash('sha256').update(markerContents).digest('hex')
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function closeRecordedEntry(entry: InstalledEntry) {
|
|
1435
|
+
const handle = entry.handle;
|
|
1436
|
+
entry.handle = undefined;
|
|
1437
|
+
if (handle) await handle.close();
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
async function closeRecordedEntries(entries: Array<InstalledEntry | undefined>) {
|
|
1441
|
+
const errors: string[] = [];
|
|
1442
|
+
for (const entry of entries) {
|
|
1443
|
+
if (!entry) continue;
|
|
1444
|
+
try {
|
|
1445
|
+
await closeRecordedEntry(entry);
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
errors.push(`${entry.path}: ${message(error)}`);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return errors;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
async function closeFileHandle(handle: fsp.FileHandle, pathLabel: string) {
|
|
1454
|
+
try {
|
|
1455
|
+
await handle.close();
|
|
1456
|
+
return [];
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
return [`${pathLabel}: ${message(error)}`];
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
export async function planSkillSetInstallation(options: TransactionalInstallOptions): Promise<SkillInstallPlan> {
|
|
1463
|
+
const sourceRoot = path.resolve(options.sourceRoot);
|
|
1464
|
+
const destination = safeRoot(options.destination, 'skill destination');
|
|
1465
|
+
const codexMirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : '';
|
|
1466
|
+
const skills = unique(options.skills);
|
|
1467
|
+
const cleanupCandidates = unique([...(options.legacySkills || []), ...(options.retiredSkills || [])]);
|
|
1468
|
+
const replaceSkills = unique(options.replaceSkills || []);
|
|
1469
|
+
validateManagedNames([...skills, ...cleanupCandidates, ...replaceSkills]);
|
|
1470
|
+
|
|
1471
|
+
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
1472
|
+
const sourceDigest = manifestDigest(expected);
|
|
1473
|
+
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
1474
|
+
const operations: SkillInstallPlanOperation[] = [];
|
|
1475
|
+
const blockers = await nonMutatingInstallGateBlockers(destination);
|
|
1476
|
+
|
|
1477
|
+
let ownership: OwnershipPreflight | undefined;
|
|
1478
|
+
try {
|
|
1479
|
+
if (blockers.length) throw new Error(blockers[0]);
|
|
1480
|
+
ownership = await preflightInstallOwnership(
|
|
1481
|
+
destination,
|
|
1482
|
+
options.packageName,
|
|
1483
|
+
skills,
|
|
1484
|
+
cleanupCandidates,
|
|
1485
|
+
replaceSkills
|
|
1486
|
+
);
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
if (!blockers.length) blockers.push(message(error));
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (ownership) {
|
|
1492
|
+
const replaceNames = new Set(ownership.replaceNames);
|
|
1493
|
+
for (const skill of skills) {
|
|
1494
|
+
const target = path.join(destination, skill);
|
|
1495
|
+
const existing = await exists(target);
|
|
1496
|
+
operations.push({
|
|
1497
|
+
action: !existing
|
|
1498
|
+
? 'create'
|
|
1499
|
+
: replaceSkills.includes(skill)
|
|
1500
|
+
? 'replace_explicitly_authorized'
|
|
1501
|
+
: 'replace_managed',
|
|
1502
|
+
skill,
|
|
1503
|
+
path: target,
|
|
1504
|
+
reason: !existing
|
|
1505
|
+
? 'active skill is not installed'
|
|
1506
|
+
: replaceSkills.includes(skill)
|
|
1507
|
+
? 'operator supplied an explicit per-skill replacement override'
|
|
1508
|
+
: 'existing skill ownership and complete content integrity match the yam receipt'
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
for (const skill of cleanupCandidates) {
|
|
1512
|
+
if (!replaceNames.has(skill) || skills.includes(skill)) continue;
|
|
1513
|
+
operations.push({
|
|
1514
|
+
action: 'remove_retired',
|
|
1515
|
+
skill,
|
|
1516
|
+
path: path.join(destination, skill),
|
|
1517
|
+
reason: 'retired or legacy skill is still proven yam-owned by the current receipt'
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
operations.push({
|
|
1521
|
+
action: 'write_receipt',
|
|
1522
|
+
skill: '',
|
|
1523
|
+
path: receiptPath,
|
|
1524
|
+
reason: ownership.receipt ? 'replace the verified yam ownership receipt' : 'create the yam ownership receipt'
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
const preservedPaths = codexMirror && codexMirror !== destination ? [codexMirror] : [];
|
|
1529
|
+
const replaceNames = new Set(ownership?.replaceNames || []);
|
|
1530
|
+
for (const skill of cleanupCandidates) {
|
|
1531
|
+
if (replaceNames.has(skill) || !await exists(path.join(destination, skill))) continue;
|
|
1532
|
+
preservedPaths.push(path.join(destination, skill));
|
|
1533
|
+
}
|
|
1534
|
+
preservedPaths.sort(compareStableText);
|
|
1535
|
+
const planBasis = {
|
|
1536
|
+
package: { name: options.packageName, version: options.version },
|
|
1537
|
+
source: { root: sourceRoot, digest: sourceDigest, file_count: expected.length },
|
|
1538
|
+
destination,
|
|
1539
|
+
receipt_path: receiptPath,
|
|
1540
|
+
operations,
|
|
1541
|
+
preserved_paths: preservedPaths,
|
|
1542
|
+
blockers
|
|
1543
|
+
};
|
|
1544
|
+
const planDigest = createHash('sha256').update(JSON.stringify(planBasis)).digest('hex');
|
|
1545
|
+
const ready = blockers.length === 0;
|
|
1546
|
+
return {
|
|
1547
|
+
schema: 'yam.install-plan.v1',
|
|
1548
|
+
generated_at: (options.now || (() => new Date()))().toISOString(),
|
|
1549
|
+
mutation_authorized: false,
|
|
1550
|
+
...planBasis,
|
|
1551
|
+
ready,
|
|
1552
|
+
truth_status: ready ? 'verified' : 'blocked',
|
|
1553
|
+
plan_digest: planDigest,
|
|
1554
|
+
next_action: ready
|
|
1555
|
+
? 'review this non-mutating plan, then run `yam install` without --dry-run to authorize the transaction'
|
|
1556
|
+
: 'resolve the listed lock, recovery, destination, or ownership blocker without deleting unreviewed state, then regenerate the plan'
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
async function nonMutatingInstallGateBlockers(destination: string) {
|
|
1561
|
+
let stat;
|
|
1562
|
+
try {
|
|
1563
|
+
stat = await fsp.lstat(destination);
|
|
1564
|
+
} catch (error) {
|
|
1565
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
1566
|
+
return [`skill destination cannot be inspected: ${message(error)}`];
|
|
1567
|
+
}
|
|
1568
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
1569
|
+
return [`skill destination must be a regular physical directory: ${destination}`];
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
const blockers: string[] = [];
|
|
1573
|
+
const lockPath = path.join(destination, INSTALL_LOCK_NAME);
|
|
1574
|
+
if (await exists(lockPath)) {
|
|
1575
|
+
blockers.push(`another yam install may be active; lock exists at ${lockPath}`);
|
|
1576
|
+
}
|
|
1577
|
+
try {
|
|
1578
|
+
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
1579
|
+
if (recoveryArtifacts.length) {
|
|
1580
|
+
blockers.push(`unfinished yam install transaction requires inspection: ${recoveryArtifacts.join(', ')}`);
|
|
1581
|
+
}
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
blockers.push(`install recovery state cannot be inspected: ${message(error)}`);
|
|
1584
|
+
}
|
|
1585
|
+
return blockers;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
847
1588
|
export async function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult> {
|
|
848
1589
|
const sourceRoot = path.resolve(options.sourceRoot);
|
|
849
1590
|
const destination = safeRoot(options.destination, 'skill destination');
|
|
@@ -859,6 +1600,7 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
859
1600
|
let result: TransactionalInstallResult | undefined;
|
|
860
1601
|
let primaryError: unknown;
|
|
861
1602
|
try {
|
|
1603
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
862
1604
|
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
863
1605
|
if (recoveryArtifacts.length) {
|
|
864
1606
|
throw new Error(
|
|
@@ -876,15 +1618,17 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
876
1618
|
const expected = await expectedInstallManifest(sourceRoot, skills);
|
|
877
1619
|
const transactionId = options.transactionId || randomUUID();
|
|
878
1620
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
|
|
1621
|
+
const transactionEntry = await captureTransactionRoot(transactionRoot, 'install transaction root');
|
|
879
1622
|
const stagedRoot = path.join(transactionRoot, 'staged');
|
|
880
1623
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
881
1624
|
const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
|
|
882
1625
|
const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
883
1626
|
const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
|
|
884
|
-
const installedEntries:
|
|
1627
|
+
const installedEntries: InstalledEntry[] = [];
|
|
1628
|
+
const receiptInstalledEntries: InstalledEntry[] = [];
|
|
885
1629
|
let destinationBackups: MovedEntry[] = [];
|
|
886
1630
|
let receiptBackedUp = false;
|
|
887
|
-
let receiptInstalled
|
|
1631
|
+
let receiptInstalled: InstalledEntry | undefined;
|
|
888
1632
|
let mutationStarted = false;
|
|
889
1633
|
|
|
890
1634
|
try {
|
|
@@ -892,8 +1636,18 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
892
1636
|
for (const skill of skills) {
|
|
893
1637
|
const stagedSkill = path.join(stagedRoot, skill);
|
|
894
1638
|
await fsp.mkdir(stagedSkill, { recursive: true });
|
|
895
|
-
await
|
|
896
|
-
|
|
1639
|
+
await copyRegularFile(
|
|
1640
|
+
path.join(sourceRoot, 'skills', skill, 'SKILL.md'),
|
|
1641
|
+
path.join(stagedSkill, 'SKILL.md'),
|
|
1642
|
+
sourceRoot,
|
|
1643
|
+
stagedRoot
|
|
1644
|
+
);
|
|
1645
|
+
await copyTree(
|
|
1646
|
+
path.join(sourceRoot, 'references'),
|
|
1647
|
+
path.join(stagedSkill, 'references'),
|
|
1648
|
+
sourceRoot,
|
|
1649
|
+
stagedRoot
|
|
1650
|
+
);
|
|
897
1651
|
}
|
|
898
1652
|
const stagedManifest = await collectTreeManifest(stagedRoot);
|
|
899
1653
|
const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
|
|
@@ -931,11 +1685,18 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
931
1685
|
replaceSkills
|
|
932
1686
|
);
|
|
933
1687
|
await options.failpoint?.({ phase: 'before-mutation' });
|
|
1688
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
934
1689
|
mutationStarted = true;
|
|
935
1690
|
await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
|
|
936
1691
|
if (await exists(destinationReceiptPath)) {
|
|
937
|
-
await
|
|
938
|
-
|
|
1692
|
+
await moveRegularFile(
|
|
1693
|
+
destinationReceiptPath,
|
|
1694
|
+
receiptBackupPath,
|
|
1695
|
+
destination,
|
|
1696
|
+
transactionRoot,
|
|
1697
|
+
'install receipt backup',
|
|
1698
|
+
() => { receiptBackedUp = true; }
|
|
1699
|
+
);
|
|
939
1700
|
}
|
|
940
1701
|
await verifyMovedMutationSnapshot(
|
|
941
1702
|
destination,
|
|
@@ -945,18 +1706,40 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
945
1706
|
receiptBackupPath
|
|
946
1707
|
);
|
|
947
1708
|
await options.failpoint?.({ phase: 'after-backup' });
|
|
1709
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
948
1710
|
|
|
949
1711
|
for (const skill of skills) {
|
|
1712
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
950
1713
|
const target = path.join(destination, skill);
|
|
951
|
-
await
|
|
952
|
-
|
|
1714
|
+
const installedEntry = await moveRegularDirectory(
|
|
1715
|
+
path.join(stagedRoot, skill),
|
|
1716
|
+
target,
|
|
1717
|
+
stagedRoot,
|
|
1718
|
+
destination,
|
|
1719
|
+
'staged skill install',
|
|
1720
|
+
undefined,
|
|
1721
|
+
installedEntries
|
|
1722
|
+
);
|
|
1723
|
+
if (!installedEntry) throw new Error(`staged skill install identity was not recorded: ${target}`);
|
|
953
1724
|
await options.failpoint?.({ phase: 'skill-installed', skill });
|
|
954
1725
|
}
|
|
955
|
-
await
|
|
956
|
-
receiptInstalled =
|
|
1726
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1727
|
+
receiptInstalled = await moveRegularFile(
|
|
1728
|
+
stagedReceiptPath,
|
|
1729
|
+
destinationReceiptPath,
|
|
1730
|
+
transactionRoot,
|
|
1731
|
+
destination,
|
|
1732
|
+
'staged install receipt',
|
|
1733
|
+
undefined,
|
|
1734
|
+
receiptInstalledEntries
|
|
1735
|
+
);
|
|
1736
|
+
if (!receiptInstalled) {
|
|
1737
|
+
throw new Error(`staged install receipt identity was not recorded: ${destinationReceiptPath}`);
|
|
1738
|
+
}
|
|
957
1739
|
await options.failpoint?.({ phase: 'receipt-installed' });
|
|
958
1740
|
|
|
959
1741
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
1742
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
960
1743
|
const inspection = await inspectSkillInstallation({
|
|
961
1744
|
sourceRoot,
|
|
962
1745
|
destination,
|
|
@@ -967,13 +1750,15 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
967
1750
|
});
|
|
968
1751
|
if (!inspection.ok) throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
|
|
969
1752
|
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1753
|
+
const ownershipHandleErrors = await closeRecordedEntries([receiptInstalled, ...installedEntries]);
|
|
1754
|
+
if (ownershipHandleErrors.length) {
|
|
1755
|
+
cleanupWarnings.push(`verified install identity handle cleanup failed: ${ownershipHandleErrors.join('; ')}`);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
try {
|
|
1759
|
+
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
cleanupWarnings.push(`verified install retained cleanup artifact ${transactionRoot}: ${message(error)}`);
|
|
977
1762
|
}
|
|
978
1763
|
result = {
|
|
979
1764
|
receipt,
|
|
@@ -984,14 +1769,28 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
984
1769
|
};
|
|
985
1770
|
} catch (error) {
|
|
986
1771
|
const rollbackErrors: string[] = [];
|
|
987
|
-
|
|
988
|
-
|
|
1772
|
+
receiptInstalled ||= receiptInstalledEntries[0];
|
|
1773
|
+
let destinationStillOwned = true;
|
|
1774
|
+
try {
|
|
1775
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1776
|
+
} catch (identityError) {
|
|
1777
|
+
destinationStillOwned = false;
|
|
1778
|
+
rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
|
|
1779
|
+
}
|
|
1780
|
+
if (mutationStarted && destinationStillOwned) {
|
|
1781
|
+
if (receiptInstalled) rollbackErrors.push(...await removeInstalledEntries([receiptInstalled]));
|
|
989
1782
|
rollbackErrors.push(...await removeInstalledEntries(installedEntries));
|
|
990
1783
|
rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
|
|
991
1784
|
if (receiptBackedUp) {
|
|
992
1785
|
try {
|
|
993
1786
|
if (await exists(destinationReceiptPath)) throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
|
|
994
|
-
await
|
|
1787
|
+
await moveRegularFile(
|
|
1788
|
+
receiptBackupPath,
|
|
1789
|
+
destinationReceiptPath,
|
|
1790
|
+
transactionRoot,
|
|
1791
|
+
destination,
|
|
1792
|
+
'install receipt rollback'
|
|
1793
|
+
);
|
|
995
1794
|
} catch (receiptError) {
|
|
996
1795
|
rollbackErrors.push(message(receiptError));
|
|
997
1796
|
}
|
|
@@ -1000,13 +1799,10 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1000
1799
|
|
|
1001
1800
|
if (rollbackErrors.length === 0) {
|
|
1002
1801
|
const cleanupErrors: string[] = [];
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
} catch (cleanupError) {
|
|
1008
|
-
cleanupErrors.push(`${cleanup}: ${message(cleanupError)}`);
|
|
1009
|
-
}
|
|
1802
|
+
try {
|
|
1803
|
+
await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
|
|
1804
|
+
} catch (cleanupError) {
|
|
1805
|
+
cleanupErrors.push(`${transactionRoot}: ${message(cleanupError)}`);
|
|
1010
1806
|
}
|
|
1011
1807
|
if (cleanupErrors.length) {
|
|
1012
1808
|
throw new Error(
|
|
@@ -1016,6 +1812,8 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1016
1812
|
}
|
|
1017
1813
|
throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
|
|
1018
1814
|
}
|
|
1815
|
+
const retainedHandleErrors = await closeRecordedEntries([transactionEntry, receiptInstalled, ...installedEntries]);
|
|
1816
|
+
rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
|
|
1019
1817
|
throw new Error(
|
|
1020
1818
|
`yam install failed and rollback is incomplete: ${message(error)}. `
|
|
1021
1819
|
+ `Recovery artifacts were kept at ${transactionRoot}. `
|
|
@@ -1027,15 +1825,14 @@ export async function installSkillSetTransactional(options: TransactionalInstall
|
|
|
1027
1825
|
}
|
|
1028
1826
|
|
|
1029
1827
|
const lockCleanupErrors: string[] = [];
|
|
1828
|
+
lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
|
|
1030
1829
|
try {
|
|
1031
|
-
await lock.
|
|
1032
|
-
|
|
1033
|
-
lockCleanupErrors.push(message(error));
|
|
1034
|
-
}
|
|
1035
|
-
try {
|
|
1036
|
-
await fsp.rm(lock.lockPath, { force: true });
|
|
1830
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1831
|
+
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
1037
1832
|
} catch (error) {
|
|
1038
1833
|
lockCleanupErrors.push(message(error));
|
|
1834
|
+
} finally {
|
|
1835
|
+
lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
|
|
1039
1836
|
}
|
|
1040
1837
|
if (lockCleanupErrors.length) {
|
|
1041
1838
|
const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
|
|
@@ -1063,6 +1860,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1063
1860
|
let result: SafeUninstallResult | undefined;
|
|
1064
1861
|
let primaryError: unknown;
|
|
1065
1862
|
try {
|
|
1863
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1066
1864
|
const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
|
|
1067
1865
|
if (recoveryArtifacts.length) {
|
|
1068
1866
|
throw new Error(
|
|
@@ -1073,6 +1871,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1073
1871
|
await preflightSafeUninstallOwnership(destination, options.packageName, skills);
|
|
1074
1872
|
|
|
1075
1873
|
const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
|
|
1874
|
+
const transactionEntry = await captureTransactionRoot(transactionRoot, 'uninstall transaction root');
|
|
1076
1875
|
const backupRoot = path.join(transactionRoot, 'backup');
|
|
1077
1876
|
const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
|
|
1078
1877
|
const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
|
|
@@ -1088,10 +1887,17 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1088
1887
|
[]
|
|
1089
1888
|
);
|
|
1090
1889
|
await options.failpoint?.({ phase: 'before-mutation' });
|
|
1890
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1091
1891
|
mutationStarted = true;
|
|
1092
1892
|
await moveExistingEntries(destination, skills, backupRoot, moved);
|
|
1093
|
-
await
|
|
1094
|
-
|
|
1893
|
+
await moveRegularFile(
|
|
1894
|
+
receiptPath,
|
|
1895
|
+
receiptBackup,
|
|
1896
|
+
destination,
|
|
1897
|
+
transactionRoot,
|
|
1898
|
+
'uninstall receipt backup',
|
|
1899
|
+
() => { receiptMoved = true; }
|
|
1900
|
+
);
|
|
1095
1901
|
await verifyMovedMutationSnapshot(
|
|
1096
1902
|
destination,
|
|
1097
1903
|
mutationSnapshot,
|
|
@@ -1100,7 +1906,9 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1100
1906
|
receiptBackup
|
|
1101
1907
|
);
|
|
1102
1908
|
await options.failpoint?.({ phase: 'after-backup' });
|
|
1909
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1103
1910
|
await options.failpoint?.({ phase: 'before-verify' });
|
|
1911
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1104
1912
|
|
|
1105
1913
|
const remaining = [];
|
|
1106
1914
|
for (const skill of skills) {
|
|
@@ -1112,7 +1920,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1112
1920
|
}
|
|
1113
1921
|
|
|
1114
1922
|
try {
|
|
1115
|
-
await
|
|
1923
|
+
await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
|
|
1116
1924
|
} catch (error) {
|
|
1117
1925
|
cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
|
|
1118
1926
|
}
|
|
@@ -1123,11 +1931,24 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1123
1931
|
};
|
|
1124
1932
|
} catch (error) {
|
|
1125
1933
|
const rollbackErrors: string[] = [];
|
|
1126
|
-
|
|
1934
|
+
let destinationStillOwned = true;
|
|
1935
|
+
try {
|
|
1936
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1937
|
+
} catch (identityError) {
|
|
1938
|
+
destinationStillOwned = false;
|
|
1939
|
+
rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
|
|
1940
|
+
}
|
|
1941
|
+
if (mutationStarted && destinationStillOwned) {
|
|
1127
1942
|
if (receiptMoved) {
|
|
1128
1943
|
try {
|
|
1129
1944
|
if (await exists(receiptPath)) throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
|
|
1130
|
-
await
|
|
1945
|
+
await moveRegularFile(
|
|
1946
|
+
receiptBackup,
|
|
1947
|
+
receiptPath,
|
|
1948
|
+
transactionRoot,
|
|
1949
|
+
destination,
|
|
1950
|
+
'uninstall receipt rollback'
|
|
1951
|
+
);
|
|
1131
1952
|
} catch (receiptError) {
|
|
1132
1953
|
rollbackErrors.push(message(receiptError));
|
|
1133
1954
|
}
|
|
@@ -1136,7 +1957,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1136
1957
|
}
|
|
1137
1958
|
if (rollbackErrors.length === 0) {
|
|
1138
1959
|
try {
|
|
1139
|
-
await
|
|
1960
|
+
await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
|
|
1140
1961
|
} catch (cleanupError) {
|
|
1141
1962
|
throw new Error(
|
|
1142
1963
|
`yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
|
|
@@ -1145,6 +1966,8 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1145
1966
|
}
|
|
1146
1967
|
throw new Error(`yam uninstall failed; previous installation state restored: ${message(error)}`);
|
|
1147
1968
|
}
|
|
1969
|
+
const retainedHandleErrors = await closeRecordedEntries([transactionEntry]);
|
|
1970
|
+
rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
|
|
1148
1971
|
throw new Error(
|
|
1149
1972
|
`yam uninstall failed and rollback is incomplete: ${message(error)}. `
|
|
1150
1973
|
+ `Recovery artifacts were kept at ${transactionRoot}. Rollback errors: ${rollbackErrors.join('; ')}`
|
|
@@ -1155,15 +1978,14 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
|
|
|
1155
1978
|
}
|
|
1156
1979
|
|
|
1157
1980
|
const lockCleanupErrors: string[] = [];
|
|
1981
|
+
lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
|
|
1158
1982
|
try {
|
|
1159
|
-
await lock.
|
|
1160
|
-
|
|
1161
|
-
lockCleanupErrors.push(message(error));
|
|
1162
|
-
}
|
|
1163
|
-
try {
|
|
1164
|
-
await fsp.rm(lock.lockPath, { force: true });
|
|
1983
|
+
await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
|
|
1984
|
+
await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
|
|
1165
1985
|
} catch (error) {
|
|
1166
1986
|
lockCleanupErrors.push(message(error));
|
|
1987
|
+
} finally {
|
|
1988
|
+
lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
|
|
1167
1989
|
}
|
|
1168
1990
|
if (lockCleanupErrors.length) {
|
|
1169
1991
|
const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
|