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.
Files changed (46) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +51 -0
  3. package/COMMANDS.md +72 -5
  4. package/README.md +49 -9
  5. package/ROADMAP.md +20 -7
  6. package/dist/bin/yam.js +616 -68
  7. package/dist/lib/external-updates.d.ts +9 -0
  8. package/dist/lib/external-updates.js +232 -4
  9. package/dist/lib/media-provider-receipt.d.ts +79 -0
  10. package/dist/lib/media-provider-receipt.js +498 -0
  11. package/dist/lib/next-step.d.ts +61 -0
  12. package/dist/lib/next-step.js +340 -0
  13. package/dist/lib/release-auth-readiness.d.ts +81 -0
  14. package/dist/lib/release-auth-readiness.js +106 -0
  15. package/dist/lib/release-registry-status.d.ts +18 -0
  16. package/dist/lib/release-registry-status.js +37 -0
  17. package/dist/lib/scout-receipt.d.ts +89 -0
  18. package/dist/lib/scout-receipt.js +542 -0
  19. package/dist/lib/skill-installation.d.ts +30 -0
  20. package/dist/lib/skill-installation.js +738 -110
  21. package/dist/lib/trust-kernel.d.ts +2 -0
  22. package/dist/lib/trust-kernel.js +21 -2
  23. package/dist/lib/verification-closure.d.ts +97 -0
  24. package/dist/lib/verification-closure.js +728 -0
  25. package/package.json +23 -3
  26. package/references/final-report.md +15 -1
  27. package/references/next-step.md +67 -0
  28. package/references/scout-wide-scan.md +16 -0
  29. package/references/study-note.md +4 -2
  30. package/references/tool-trust-layer.md +2 -0
  31. package/skills/deep/SKILL.md +2 -0
  32. package/skills/mission/SKILL.md +2 -0
  33. package/skills/quick/SKILL.md +1 -0
  34. package/skills/scout/SKILL.md +19 -9
  35. package/skills/ueye/SKILL.md +1 -0
  36. package/src/bin/yam.ts +615 -64
  37. package/src/lib/external-updates.ts +262 -4
  38. package/src/lib/media-provider-receipt.ts +548 -0
  39. package/src/lib/next-step.ts +397 -0
  40. package/src/lib/release-auth-readiness.ts +148 -0
  41. package/src/lib/release-registry-status.ts +55 -0
  42. package/src/lib/scout-receipt.ts +593 -0
  43. package/src/lib/skill-installation.ts +930 -108
  44. package/src/lib/trust-kernel.ts +25 -2
  45. package/src/lib/verification-closure.ts +738 -0
  46. 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(file: string, relativePath: string): Promise<InstallFileDigest> {
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 fsp.readFile(file);
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(root: string, prefix = ''): Promise<InstallFileDigest[]> {
207
- const stat = await fsp.lstat(root);
208
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
209
- throw new Error(`install tree must be a regular directory: ${root}`);
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 skillText = await fsp.readFile(skillFile, 'utf8');
242
- const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
243
- const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
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(await digestFile(skillFile, path.join(skill, 'SKILL.md')));
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(source: string, target: string): Promise<void> {
261
- const stat = await fsp.lstat(source);
262
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
263
- throw new Error(`install source must be a regular directory: ${source}`);
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 fsp.copyFile(from, to);
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 fsp.readFile(receiptPath, 'utf8'));
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(movedEntry.backup, tree.name);
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 fsp.readFile(receiptPath, 'utf8'));
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
- await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
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
- await handle.close().catch(() => undefined);
802
- await fsp.rm(lockPath, { force: true }).catch(() => undefined);
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 fsp.rename(original, backup);
815
- moved.push({ original, backup });
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 fsp.rename(entry.backup, entry.original);
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: string[]) {
1225
+ async function removeInstalledEntries(entries: InstalledEntry[]) {
836
1226
  const errors: string[] = [];
837
1227
  for (const entry of [...entries].reverse()) {
838
1228
  try {
839
- await fsp.rm(entry, { recursive: true, force: true });
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: string[] = [];
1627
+ const installedEntries: InstalledEntry[] = [];
1628
+ const receiptInstalledEntries: InstalledEntry[] = [];
885
1629
  let destinationBackups: MovedEntry[] = [];
886
1630
  let receiptBackedUp = false;
887
- let receiptInstalled = false;
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 fsp.copyFile(path.join(sourceRoot, 'skills', skill, 'SKILL.md'), path.join(stagedSkill, 'SKILL.md'));
896
- await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'));
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 fsp.rename(destinationReceiptPath, receiptBackupPath);
938
- receiptBackedUp = true;
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 fsp.rename(path.join(stagedRoot, skill), target);
952
- installedEntries.push(target);
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 fsp.rename(stagedReceiptPath, destinationReceiptPath);
956
- receiptInstalled = true;
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
- for (const cleanup of [transactionRoot]) {
971
- if (!cleanup) continue;
972
- try {
973
- await fsp.rm(cleanup, { recursive: true, force: true });
974
- } catch (error) {
975
- cleanupWarnings.push(`verified install retained cleanup artifact ${cleanup}: ${message(error)}`);
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
- if (mutationStarted) {
988
- if (receiptInstalled) rollbackErrors.push(...await removeInstalledEntries([destinationReceiptPath]));
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 fsp.rename(receiptBackupPath, destinationReceiptPath);
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
- for (const cleanup of [transactionRoot]) {
1004
- if (!cleanup) continue;
1005
- try {
1006
- await fsp.rm(cleanup, { recursive: true, force: true });
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.handle.close();
1032
- } catch (error) {
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 fsp.rename(receiptPath, receiptBackup);
1094
- receiptMoved = true;
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 fsp.rm(transactionRoot, { recursive: true, force: true });
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
- if (mutationStarted) {
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 fsp.rename(receiptBackup, receiptPath);
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 fsp.rm(transactionRoot, { recursive: true, force: true });
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.handle.close();
1160
- } catch (error) {
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('; ')}`;