yam-flow 2.6.0 → 2.7.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.
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants as fsConstants } 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';
@@ -117,6 +118,8 @@ export type SafeUninstallResult = {
117
118
  type MovedEntry = {
118
119
  original: string;
119
120
  backup: string;
121
+ originalBoundary: string;
122
+ backupBoundary: string;
120
123
  };
121
124
 
122
125
  type OwnershipPreflight = {
@@ -143,6 +146,16 @@ type MutationSnapshot = {
143
146
  receipt: ReceiptMutationSnapshot;
144
147
  };
145
148
 
149
+ type PathIdentity = {
150
+ path: string;
151
+ dev: number;
152
+ ino: number;
153
+ };
154
+
155
+ type InstalledEntry = PathIdentity & {
156
+ kind: 'directory' | 'file';
157
+ };
158
+
146
159
  function message(error: unknown) {
147
160
  return error instanceof Error ? error.message : String(error);
148
161
  }
@@ -177,6 +190,157 @@ async function exists(target: string) {
177
190
  }
178
191
  }
179
192
 
193
+ function pathWithinBoundary(boundary: string, target: string) {
194
+ const relative = path.relative(boundary, target);
195
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
196
+ }
197
+
198
+ async function captureRegularParentPath(boundaryValue: string, targetValue: string, label: string) {
199
+ const boundary = path.resolve(boundaryValue);
200
+ const target = path.resolve(targetValue);
201
+ if (!pathWithinBoundary(boundary, target)) {
202
+ throw new Error(`${label} escapes its trusted path boundary: ${target}`);
203
+ }
204
+
205
+ const targetParent = target === boundary ? boundary : path.dirname(target);
206
+ const relativeParent = path.relative(boundary, targetParent);
207
+ const candidates = [boundary];
208
+ if (relativeParent) {
209
+ let current = boundary;
210
+ for (const segment of relativeParent.split(path.sep)) {
211
+ current = path.join(current, segment);
212
+ candidates.push(current);
213
+ }
214
+ }
215
+
216
+ const identities: PathIdentity[] = [];
217
+ for (const candidate of candidates) {
218
+ const stat = await fsp.lstat(candidate);
219
+ if (stat.isSymbolicLink()) {
220
+ throw new Error(`${label} has a symlinked parent path segment: ${candidate}`);
221
+ }
222
+ if (!stat.isDirectory()) {
223
+ throw new Error(`${label} parent path segment must be a regular directory: ${candidate}`);
224
+ }
225
+ identities.push({ path: candidate, dev: stat.dev, ino: stat.ino });
226
+ }
227
+ return identities;
228
+ }
229
+
230
+ async function revalidateRegularParentPath(identities: PathIdentity[], label: string) {
231
+ for (const identity of identities) {
232
+ const stat = await fsp.lstat(identity.path);
233
+ if (
234
+ stat.isSymbolicLink()
235
+ || !stat.isDirectory()
236
+ || stat.dev !== identity.dev
237
+ || stat.ino !== identity.ino
238
+ ) {
239
+ throw new Error(`${label} parent path changed identity: ${identity.path}`);
240
+ }
241
+ }
242
+ }
243
+
244
+ async function captureRegularDirectoryPath(boundaryValue: string, directoryValue: string, label: string) {
245
+ const boundary = path.resolve(boundaryValue);
246
+ const directory = path.resolve(directoryValue);
247
+ if (boundary === directory) {
248
+ const stat = await fsp.lstat(directory);
249
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
250
+ throw new Error(`${label} must be a regular directory: ${directory}`);
251
+ }
252
+ return [{ path: directory, dev: stat.dev, ino: stat.ino }];
253
+ }
254
+ const identities = await captureRegularParentPath(boundary, directory, label);
255
+ const stat = await fsp.lstat(directory);
256
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
257
+ throw new Error(`${label} must be a regular directory: ${directory}`);
258
+ }
259
+ identities.push({ path: directory, dev: stat.dev, ino: stat.ino });
260
+ return identities;
261
+ }
262
+
263
+ function readOnlyNoFollowFlags() {
264
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
265
+ ? fsConstants.O_NOFOLLOW
266
+ : 0;
267
+ return fsConstants.O_RDONLY | noFollow;
268
+ }
269
+
270
+ async function captureRegularFile(file: string, boundary = path.dirname(file)) {
271
+ const parents = await captureRegularParentPath(boundary, file, 'regular file read');
272
+ const before = await fsp.lstat(file);
273
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error(`expected a regular file: ${file}`);
274
+ const handle = await fsp.open(file, readOnlyNoFollowFlags());
275
+ try {
276
+ const opened = await handle.stat();
277
+ if (!opened.isFile()) throw new Error(`expected a regular file: ${file}`);
278
+ const bytes = await handle.readFile();
279
+ const afterOpened = await handle.stat();
280
+ const after = await fsp.lstat(file);
281
+ await revalidateRegularParentPath(parents, 'regular file read');
282
+ if (
283
+ !after.isFile()
284
+ || after.isSymbolicLink()
285
+ || before.dev !== opened.dev
286
+ || before.ino !== opened.ino
287
+ || afterOpened.dev !== opened.dev
288
+ || afterOpened.ino !== opened.ino
289
+ || after.dev !== opened.dev
290
+ || after.ino !== opened.ino
291
+ ) {
292
+ throw new Error(`regular file changed identity while being captured: ${file}`);
293
+ }
294
+ return { bytes, mode: opened.mode & 0o777 };
295
+ } finally {
296
+ await handle.close();
297
+ }
298
+ }
299
+
300
+ async function readRegularFileBytes(file: string, boundary = path.dirname(file)) {
301
+ return (await captureRegularFile(file, boundary)).bytes;
302
+ }
303
+
304
+ async function copyRegularFile(
305
+ source: string,
306
+ target: string,
307
+ sourceBoundary: string,
308
+ targetBoundary: string
309
+ ) {
310
+ const sourceFile = await captureRegularFile(source, sourceBoundary);
311
+ const targetParents = await captureRegularParentPath(targetBoundary, target, 'install copy target');
312
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
313
+ ? fsConstants.O_NOFOLLOW
314
+ : 0;
315
+ const handle = await fsp.open(
316
+ target,
317
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow,
318
+ sourceFile.mode
319
+ );
320
+ try {
321
+ await handle.writeFile(sourceFile.bytes);
322
+ const opened = await handle.stat();
323
+ if (!opened.isFile()) throw new Error(`install copy target must be a regular file: ${target}`);
324
+ const openedIdentity = { dev: opened.dev, ino: opened.ino };
325
+ const afterOpened = await handle.stat();
326
+ if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
327
+ throw new Error(`install copy target changed descriptor identity: ${target}`);
328
+ }
329
+ await revalidateRegularParentPath(targetParents, 'install copy target');
330
+ const installed = await fsp.lstat(target);
331
+ if (
332
+ !installed.isFile()
333
+ || installed.isSymbolicLink()
334
+ || installed.dev !== openedIdentity.dev
335
+ || installed.ino !== openedIdentity.ino
336
+ ) {
337
+ throw new Error(`install copy target changed path identity: ${target}`);
338
+ }
339
+ } finally {
340
+ await handle.close();
341
+ }
342
+ }
343
+
180
344
  export async function findInstallRecoveryArtifacts(root: string) {
181
345
  try {
182
346
  const entries = await fsp.readdir(root, { withFileTypes: true });
@@ -190,12 +354,16 @@ export async function findInstallRecoveryArtifacts(root: string) {
190
354
  }
191
355
  }
192
356
 
193
- async function digestFile(file: string, relativePath: string): Promise<InstallFileDigest> {
357
+ async function digestFile(
358
+ file: string,
359
+ relativePath: string,
360
+ boundary = path.dirname(file)
361
+ ): Promise<InstallFileDigest> {
194
362
  const stat = await fsp.lstat(file);
195
363
  if (!stat.isFile() || stat.isSymbolicLink()) {
196
364
  throw new Error(`install source must be a regular file: ${file}`);
197
365
  }
198
- const contents = await fsp.readFile(file);
366
+ const contents = await readRegularFileBytes(file, boundary);
199
367
  return {
200
368
  path: relativePath.split(path.sep).join('/'),
201
369
  bytes: contents.byteLength,
@@ -203,14 +371,16 @@ async function digestFile(file: string, relativePath: string): Promise<InstallFi
203
371
  };
204
372
  }
205
373
 
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
- }
374
+ async function collectTreeManifest(
375
+ root: string,
376
+ prefix = '',
377
+ boundary = root
378
+ ): Promise<InstallFileDigest[]> {
379
+ const rootIdentity = await captureRegularDirectoryPath(boundary, root, 'install tree');
211
380
 
212
381
  const files: InstallFileDigest[] = [];
213
382
  async function walk(current: string, relative: string): Promise<void> {
383
+ const directoryIdentity = await captureRegularDirectoryPath(boundary, current, 'install tree');
214
384
  const entries = await fsp.readdir(current, { withFileTypes: true });
215
385
  entries.sort((left, right) => compareStableText(left.name, right.name));
216
386
  for (const entry of entries) {
@@ -219,26 +389,30 @@ async function collectTreeManifest(root: string, prefix = ''): Promise<InstallFi
219
389
  if (entry.isDirectory()) {
220
390
  await walk(absolute, nextRelative);
221
391
  } else if (entry.isFile()) {
222
- files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
392
+ files.push(await digestFile(absolute, path.join(prefix, nextRelative), boundary));
223
393
  } else {
224
394
  throw new Error(`unsupported install tree entry: ${absolute}`);
225
395
  }
226
396
  }
397
+ await revalidateRegularParentPath(directoryIdentity, 'install tree');
227
398
  }
228
399
 
229
400
  await walk(root, '');
401
+ await revalidateRegularParentPath(rootIdentity, 'install tree');
230
402
  return files.sort((left, right) => compareStableText(left.path, right.path));
231
403
  }
232
404
 
233
405
  async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
406
+ const sourceIdentity = await captureRegularDirectoryPath(sourceRoot, sourceRoot, 'install source root');
234
407
  const referencesRoot = path.join(sourceRoot, 'references');
235
- const referenceFiles = await collectTreeManifest(referencesRoot);
408
+ const referenceFiles = await collectTreeManifest(referencesRoot, '', sourceRoot);
236
409
  if (referenceFiles.length === 0) throw new Error(`no reference files found: ${referencesRoot}`);
237
410
 
238
411
  const files: InstallFileDigest[] = [];
239
412
  for (const skill of skills) {
240
413
  const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
241
- const skillText = await fsp.readFile(skillFile, 'utf8');
414
+ const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
415
+ const skillText = skillBytes.toString('utf8');
242
416
  const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
243
417
  const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
244
418
  ?.trim()
@@ -246,7 +420,11 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
246
420
  if (name !== skill) {
247
421
  throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
248
422
  }
249
- files.push(await digestFile(skillFile, path.join(skill, 'SKILL.md')));
423
+ files.push({
424
+ path: path.join(skill, 'SKILL.md').split(path.sep).join('/'),
425
+ bytes: skillBytes.byteLength,
426
+ sha256: createHash('sha256').update(skillBytes).digest('hex')
427
+ });
250
428
  for (const reference of referenceFiles) {
251
429
  files.push({
252
430
  ...reference,
@@ -254,28 +432,97 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
254
432
  });
255
433
  }
256
434
  }
435
+ await revalidateRegularParentPath(sourceIdentity, 'install source root');
257
436
  return files.sort((left, right) => compareStableText(left.path, right.path));
258
437
  }
259
438
 
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
- }
439
+ async function copyTree(
440
+ source: string,
441
+ target: string,
442
+ sourceBoundary = source,
443
+ targetBoundary = target
444
+ ): Promise<void> {
445
+ const sourceIdentity = await captureRegularDirectoryPath(sourceBoundary, source, 'install source tree');
446
+ const targetParents = await captureRegularParentPath(targetBoundary, target, 'install target tree');
265
447
  await fsp.mkdir(target, { recursive: true });
448
+ const targetIdentity = await captureRegularDirectoryPath(targetBoundary, target, 'install target tree');
449
+ await revalidateRegularParentPath(sourceIdentity, 'install source tree');
450
+ await revalidateRegularParentPath(targetParents, 'install target tree');
266
451
  const entries = await fsp.readdir(source, { withFileTypes: true });
267
452
  entries.sort((left, right) => left.name.localeCompare(right.name));
268
453
  for (const entry of entries) {
269
454
  const from = path.join(source, entry.name);
270
455
  const to = path.join(target, entry.name);
271
456
  if (entry.isDirectory()) {
272
- await copyTree(from, to);
457
+ await copyTree(from, to, sourceBoundary, targetBoundary);
273
458
  } else if (entry.isFile()) {
274
- await fsp.copyFile(from, to);
459
+ await copyRegularFile(from, to, sourceBoundary, targetBoundary);
275
460
  } else {
276
461
  throw new Error(`unsupported install source entry: ${from}`);
277
462
  }
278
463
  }
464
+ await revalidateRegularParentPath(sourceIdentity, 'install source tree');
465
+ await revalidateRegularParentPath(targetIdentity, 'install target tree');
466
+ }
467
+
468
+ async function moveRegularDirectory(
469
+ source: string,
470
+ target: string,
471
+ sourceBoundary: string,
472
+ targetBoundary: string,
473
+ label: string,
474
+ onMoved?: (entry: InstalledEntry) => void
475
+ ) {
476
+ const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
477
+ const sourceStat = await fsp.lstat(source);
478
+ if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
479
+ throw new Error(`${label} source must be a regular directory: ${source}`);
480
+ }
481
+ const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
482
+ if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
483
+ await fsp.rename(source, target);
484
+ onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'directory' });
485
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
486
+ await revalidateRegularParentPath(targetParents, `${label} target`);
487
+ const targetStat = await fsp.lstat(target);
488
+ if (
489
+ !targetStat.isDirectory()
490
+ || targetStat.isSymbolicLink()
491
+ || targetStat.dev !== sourceStat.dev
492
+ || targetStat.ino !== sourceStat.ino
493
+ ) {
494
+ throw new Error(`${label} directory changed identity while being moved: ${target}`);
495
+ }
496
+ }
497
+
498
+ async function moveRegularFile(
499
+ source: string,
500
+ target: string,
501
+ sourceBoundary: string,
502
+ targetBoundary: string,
503
+ label: string,
504
+ onMoved?: (entry: InstalledEntry) => void
505
+ ) {
506
+ const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
507
+ const sourceStat = await fsp.lstat(source);
508
+ if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
509
+ throw new Error(`${label} source must be a regular file: ${source}`);
510
+ }
511
+ const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
512
+ if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
513
+ await fsp.rename(source, target);
514
+ onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'file' });
515
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
516
+ await revalidateRegularParentPath(targetParents, `${label} target`);
517
+ const targetStat = await fsp.lstat(target);
518
+ if (
519
+ !targetStat.isFile()
520
+ || targetStat.isSymbolicLink()
521
+ || targetStat.dev !== sourceStat.dev
522
+ || targetStat.ino !== sourceStat.ino
523
+ ) {
524
+ throw new Error(`${label} file changed identity while being moved: ${target}`);
525
+ }
279
526
  }
280
527
 
281
528
  function manifestDigest(files: InstallFileDigest[]) {
@@ -430,7 +677,7 @@ async function readOwnershipReceipt(
430
677
  if (!stat.isFile() || stat.isSymbolicLink()) {
431
678
  return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
432
679
  }
433
- const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
680
+ const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
434
681
  const issues = ownershipReceiptIssues(parsed, packageName, destination);
435
682
  return issues.length
436
683
  ? { exists: true, issues }
@@ -457,7 +704,7 @@ async function verifyReceiptOwnedSkill(
457
704
  const target = path.join(root, skill);
458
705
  if (!await exists(target)) return [`receipt-owned skill is missing: ${skill}`];
459
706
  try {
460
- const actual = await collectTreeManifest(target, skill);
707
+ const actual = await collectTreeManifest(target, skill, root);
461
708
  return manifestIssues(expected, actual, `installed ${skill}`);
462
709
  } catch (error) {
463
710
  return [`installed ${skill} cannot be verified: ${message(error)}`];
@@ -552,31 +799,6 @@ async function preflightSafeUninstallOwnership(
552
799
  return receipt;
553
800
  }
554
801
 
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
802
  async function captureReceiptMutationSnapshot(
581
803
  destination: string,
582
804
  packageName: string
@@ -584,7 +806,7 @@ async function captureReceiptMutationSnapshot(
584
806
  const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
585
807
  let bytes: Buffer;
586
808
  try {
587
- bytes = await readRegularFileBytes(receiptPath);
809
+ bytes = await readRegularFileBytes(receiptPath, destination);
588
810
  } catch (error) {
589
811
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { exists: false };
590
812
  throw new Error(`cannot capture install receipt ownership boundary: ${message(error)}`);
@@ -616,7 +838,7 @@ async function captureMutationSnapshot(
616
838
  const receipt = await captureReceiptMutationSnapshot(destination, packageName);
617
839
  const trees: TreeMutationSnapshot[] = [];
618
840
  for (const name of names) {
619
- const files = await collectTreeManifest(path.join(destination, name), name);
841
+ const files = await collectTreeManifest(path.join(destination, name), name, destination);
620
842
  if (!explicitReplacements.includes(name)) {
621
843
  if (!receipt.exists) {
622
844
  throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
@@ -651,7 +873,11 @@ async function verifyMovedMutationSnapshot(
651
873
  continue;
652
874
  }
653
875
  try {
654
- const actual = await collectTreeManifest(movedEntry.backup, tree.name);
876
+ const actual = await collectTreeManifest(
877
+ movedEntry.backup,
878
+ tree.name,
879
+ path.dirname(movedEntry.backup)
880
+ );
655
881
  issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
656
882
  } catch (error) {
657
883
  issues.push(`moved backup ${tree.name} cannot be verified: ${message(error)}`);
@@ -666,7 +892,7 @@ async function verifyMovedMutationSnapshot(
666
892
  );
667
893
  } else if (snapshot.receipt.exists && receiptBackedUp) {
668
894
  try {
669
- const bytes = await readRegularFileBytes(receiptBackupPath);
895
+ const bytes = await readRegularFileBytes(receiptBackupPath, path.dirname(receiptBackupPath));
670
896
  const sha256 = createHash('sha256').update(bytes).digest('hex');
671
897
  if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
672
898
  issues.push('moved install receipt differs from the captured bytes');
@@ -739,7 +965,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
739
965
  continue;
740
966
  }
741
967
  try {
742
- const installed = (await collectTreeManifest(target, skill));
968
+ const installed = (await collectTreeManifest(target, skill, destination));
743
969
  const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
744
970
  skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
745
971
  issues.push(...skillIssues);
@@ -756,7 +982,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
756
982
  issues.push(`install receipt missing: ${receiptPath}`);
757
983
  } else {
758
984
  try {
759
- const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
985
+ const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
760
986
  const problems = receiptIssues(parsed, options, destination, expected);
761
987
  if (problems.length) {
762
988
  receiptStatus = 'drift';
@@ -786,6 +1012,7 @@ export async function inspectSkillInstallation(options: InstallationInspectionOp
786
1012
 
787
1013
  async function acquireInstallLock(destination: string) {
788
1014
  const lockPath = path.join(destination, INSTALL_LOCK_NAME);
1015
+ const destinationIdentity = await captureRegularDirectoryPath(destination, destination, 'skill destination');
789
1016
  let handle: fsp.FileHandle;
790
1017
  try {
791
1018
  handle = await fsp.open(lockPath, 'wx', 0o600);
@@ -795,24 +1022,64 @@ async function acquireInstallLock(destination: string) {
795
1022
  }
796
1023
  throw error;
797
1024
  }
1025
+ let lockIdentity: InstalledEntry | undefined;
798
1026
  try {
1027
+ const opened = await handle.stat();
1028
+ if (!opened.isFile()) throw new Error(`install lock must be a regular file: ${lockPath}`);
1029
+ lockIdentity = { path: lockPath, dev: opened.dev, ino: opened.ino, kind: 'file' };
799
1030
  await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
1031
+ await revalidateRegularParentPath(destinationIdentity, 'skill destination');
1032
+ const current = await fsp.lstat(lockPath);
1033
+ if (
1034
+ current.isSymbolicLink()
1035
+ || !current.isFile()
1036
+ || current.dev !== lockIdentity.dev
1037
+ || current.ino !== lockIdentity.ino
1038
+ ) {
1039
+ throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
1040
+ }
800
1041
  } catch (error) {
801
- await handle.close().catch(() => undefined);
802
- await fsp.rm(lockPath, { force: true }).catch(() => undefined);
1042
+ const cleanupErrors: string[] = [];
1043
+ try {
1044
+ await handle.close();
1045
+ } catch (closeError) {
1046
+ cleanupErrors.push(message(closeError));
1047
+ }
1048
+ try {
1049
+ await revalidateRegularParentPath(destinationIdentity, 'skill destination');
1050
+ if (!lockIdentity) throw new Error(`install lock identity was not captured: ${lockPath}`);
1051
+ await removeRecordedEntry(lockIdentity, 'install lock');
1052
+ } catch (cleanupError) {
1053
+ cleanupErrors.push(message(cleanupError));
1054
+ }
1055
+ if (cleanupErrors.length) {
1056
+ throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
1057
+ }
803
1058
  throw error;
804
1059
  }
805
- return { handle, lockPath };
1060
+ return { handle, lockPath, lockIdentity, destinationIdentity };
806
1061
  }
807
1062
 
808
1063
  async function moveExistingEntries(root: string, names: string[], backupRoot: string, moved: MovedEntry[]) {
809
1064
  await fsp.mkdir(backupRoot, { recursive: true });
1065
+ const backupBoundary = path.dirname(backupRoot);
810
1066
  for (const name of names) {
811
1067
  const original = path.join(root, name);
812
1068
  if (!await exists(original)) continue;
813
1069
  const backup = path.join(backupRoot, name);
814
- await fsp.rename(original, backup);
815
- moved.push({ original, backup });
1070
+ await moveRegularDirectory(
1071
+ original,
1072
+ backup,
1073
+ root,
1074
+ backupBoundary,
1075
+ 'managed install backup',
1076
+ () => moved.push({
1077
+ original,
1078
+ backup,
1079
+ originalBoundary: root,
1080
+ backupBoundary
1081
+ })
1082
+ );
816
1083
  }
817
1084
  }
818
1085
 
@@ -824,7 +1091,13 @@ async function restoreMovedEntries(entries: MovedEntry[]) {
824
1091
  throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
825
1092
  }
826
1093
  await fsp.mkdir(path.dirname(entry.original), { recursive: true });
827
- await fsp.rename(entry.backup, entry.original);
1094
+ await moveRegularDirectory(
1095
+ entry.backup,
1096
+ entry.original,
1097
+ entry.backupBoundary,
1098
+ entry.originalBoundary,
1099
+ 'managed install rollback'
1100
+ );
828
1101
  } catch (error) {
829
1102
  errors.push(message(error));
830
1103
  }
@@ -832,18 +1105,44 @@ async function restoreMovedEntries(entries: MovedEntry[]) {
832
1105
  return errors;
833
1106
  }
834
1107
 
835
- async function removeInstalledEntries(entries: string[]) {
1108
+ async function removeInstalledEntries(entries: InstalledEntry[]) {
836
1109
  const errors: string[] = [];
837
1110
  for (const entry of [...entries].reverse()) {
838
1111
  try {
839
- await fsp.rm(entry, { recursive: true, force: true });
1112
+ await removeRecordedEntry(entry, 'rollback removal');
840
1113
  } catch (error) {
841
- errors.push(`could not remove new install ${entry}: ${message(error)}`);
1114
+ errors.push(`could not remove new install ${entry.path}: ${message(error)}`);
842
1115
  }
843
1116
  }
844
1117
  return errors;
845
1118
  }
846
1119
 
1120
+ async function captureRecordedEntry(target: string, kind: InstalledEntry['kind'], label: string): Promise<InstalledEntry> {
1121
+ const stat = await fsp.lstat(target);
1122
+ const typeMatches = kind === 'directory'
1123
+ ? stat.isDirectory() && !stat.isSymbolicLink()
1124
+ : stat.isFile() && !stat.isSymbolicLink();
1125
+ if (!typeMatches) throw new Error(`${label} must be a regular ${kind}: ${target}`);
1126
+ return { path: target, dev: stat.dev, ino: stat.ino, kind };
1127
+ }
1128
+
1129
+ async function removeRecordedEntry(entry: InstalledEntry, label: string) {
1130
+ let current;
1131
+ try {
1132
+ current = await fsp.lstat(entry.path);
1133
+ } catch (error) {
1134
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
1135
+ throw error;
1136
+ }
1137
+ const typeMatches = entry.kind === 'directory'
1138
+ ? current.isDirectory() && !current.isSymbolicLink()
1139
+ : current.isFile() && !current.isSymbolicLink();
1140
+ if (!typeMatches || current.dev !== entry.dev || current.ino !== entry.ino) {
1141
+ throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
1142
+ }
1143
+ await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
1144
+ }
1145
+
847
1146
  export async function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult> {
848
1147
  const sourceRoot = path.resolve(options.sourceRoot);
849
1148
  const destination = safeRoot(options.destination, 'skill destination');
@@ -859,6 +1158,7 @@ export async function installSkillSetTransactional(options: TransactionalInstall
859
1158
  let result: TransactionalInstallResult | undefined;
860
1159
  let primaryError: unknown;
861
1160
  try {
1161
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
862
1162
  const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
863
1163
  if (recoveryArtifacts.length) {
864
1164
  throw new Error(
@@ -876,15 +1176,16 @@ export async function installSkillSetTransactional(options: TransactionalInstall
876
1176
  const expected = await expectedInstallManifest(sourceRoot, skills);
877
1177
  const transactionId = options.transactionId || randomUUID();
878
1178
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
1179
+ const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'install transaction root');
879
1180
  const stagedRoot = path.join(transactionRoot, 'staged');
880
1181
  const backupRoot = path.join(transactionRoot, 'backup');
881
1182
  const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
882
1183
  const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
883
1184
  const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
884
- const installedEntries: string[] = [];
1185
+ const installedEntries: InstalledEntry[] = [];
885
1186
  let destinationBackups: MovedEntry[] = [];
886
1187
  let receiptBackedUp = false;
887
- let receiptInstalled = false;
1188
+ let receiptInstalled: InstalledEntry | undefined;
888
1189
  let mutationStarted = false;
889
1190
 
890
1191
  try {
@@ -892,8 +1193,18 @@ export async function installSkillSetTransactional(options: TransactionalInstall
892
1193
  for (const skill of skills) {
893
1194
  const stagedSkill = path.join(stagedRoot, skill);
894
1195
  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'));
1196
+ await copyRegularFile(
1197
+ path.join(sourceRoot, 'skills', skill, 'SKILL.md'),
1198
+ path.join(stagedSkill, 'SKILL.md'),
1199
+ sourceRoot,
1200
+ stagedRoot
1201
+ );
1202
+ await copyTree(
1203
+ path.join(sourceRoot, 'references'),
1204
+ path.join(stagedSkill, 'references'),
1205
+ sourceRoot,
1206
+ stagedRoot
1207
+ );
897
1208
  }
898
1209
  const stagedManifest = await collectTreeManifest(stagedRoot);
899
1210
  const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
@@ -931,11 +1242,18 @@ export async function installSkillSetTransactional(options: TransactionalInstall
931
1242
  replaceSkills
932
1243
  );
933
1244
  await options.failpoint?.({ phase: 'before-mutation' });
1245
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
934
1246
  mutationStarted = true;
935
1247
  await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
936
1248
  if (await exists(destinationReceiptPath)) {
937
- await fsp.rename(destinationReceiptPath, receiptBackupPath);
938
- receiptBackedUp = true;
1249
+ await moveRegularFile(
1250
+ destinationReceiptPath,
1251
+ receiptBackupPath,
1252
+ destination,
1253
+ transactionRoot,
1254
+ 'install receipt backup',
1255
+ () => { receiptBackedUp = true; }
1256
+ );
939
1257
  }
940
1258
  await verifyMovedMutationSnapshot(
941
1259
  destination,
@@ -945,18 +1263,34 @@ export async function installSkillSetTransactional(options: TransactionalInstall
945
1263
  receiptBackupPath
946
1264
  );
947
1265
  await options.failpoint?.({ phase: 'after-backup' });
1266
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
948
1267
 
949
1268
  for (const skill of skills) {
1269
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
950
1270
  const target = path.join(destination, skill);
951
- await fsp.rename(path.join(stagedRoot, skill), target);
952
- installedEntries.push(target);
1271
+ await moveRegularDirectory(
1272
+ path.join(stagedRoot, skill),
1273
+ target,
1274
+ stagedRoot,
1275
+ destination,
1276
+ 'staged skill install',
1277
+ (entry) => installedEntries.push(entry)
1278
+ );
953
1279
  await options.failpoint?.({ phase: 'skill-installed', skill });
954
1280
  }
955
- await fsp.rename(stagedReceiptPath, destinationReceiptPath);
956
- receiptInstalled = true;
1281
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1282
+ await moveRegularFile(
1283
+ stagedReceiptPath,
1284
+ destinationReceiptPath,
1285
+ transactionRoot,
1286
+ destination,
1287
+ 'staged install receipt',
1288
+ (entry) => { receiptInstalled = entry; }
1289
+ );
957
1290
  await options.failpoint?.({ phase: 'receipt-installed' });
958
1291
 
959
1292
  await options.failpoint?.({ phase: 'before-verify' });
1293
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
960
1294
  const inspection = await inspectSkillInstallation({
961
1295
  sourceRoot,
962
1296
  destination,
@@ -967,13 +1301,10 @@ export async function installSkillSetTransactional(options: TransactionalInstall
967
1301
  });
968
1302
  if (!inspection.ok) throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
969
1303
 
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
- }
1304
+ try {
1305
+ await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
1306
+ } catch (error) {
1307
+ cleanupWarnings.push(`verified install retained cleanup artifact ${transactionRoot}: ${message(error)}`);
977
1308
  }
978
1309
  result = {
979
1310
  receipt,
@@ -984,14 +1315,27 @@ export async function installSkillSetTransactional(options: TransactionalInstall
984
1315
  };
985
1316
  } catch (error) {
986
1317
  const rollbackErrors: string[] = [];
987
- if (mutationStarted) {
988
- if (receiptInstalled) rollbackErrors.push(...await removeInstalledEntries([destinationReceiptPath]));
1318
+ let destinationStillOwned = true;
1319
+ try {
1320
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1321
+ } catch (identityError) {
1322
+ destinationStillOwned = false;
1323
+ rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
1324
+ }
1325
+ if (mutationStarted && destinationStillOwned) {
1326
+ if (receiptInstalled) rollbackErrors.push(...await removeInstalledEntries([receiptInstalled]));
989
1327
  rollbackErrors.push(...await removeInstalledEntries(installedEntries));
990
1328
  rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
991
1329
  if (receiptBackedUp) {
992
1330
  try {
993
1331
  if (await exists(destinationReceiptPath)) throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
994
- await fsp.rename(receiptBackupPath, destinationReceiptPath);
1332
+ await moveRegularFile(
1333
+ receiptBackupPath,
1334
+ destinationReceiptPath,
1335
+ transactionRoot,
1336
+ destination,
1337
+ 'install receipt rollback'
1338
+ );
995
1339
  } catch (receiptError) {
996
1340
  rollbackErrors.push(message(receiptError));
997
1341
  }
@@ -1000,13 +1344,10 @@ export async function installSkillSetTransactional(options: TransactionalInstall
1000
1344
 
1001
1345
  if (rollbackErrors.length === 0) {
1002
1346
  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
- }
1347
+ try {
1348
+ await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
1349
+ } catch (cleanupError) {
1350
+ cleanupErrors.push(`${transactionRoot}: ${message(cleanupError)}`);
1010
1351
  }
1011
1352
  if (cleanupErrors.length) {
1012
1353
  throw new Error(
@@ -1033,7 +1374,8 @@ export async function installSkillSetTransactional(options: TransactionalInstall
1033
1374
  lockCleanupErrors.push(message(error));
1034
1375
  }
1035
1376
  try {
1036
- await fsp.rm(lock.lockPath, { force: true });
1377
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1378
+ await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
1037
1379
  } catch (error) {
1038
1380
  lockCleanupErrors.push(message(error));
1039
1381
  }
@@ -1063,6 +1405,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1063
1405
  let result: SafeUninstallResult | undefined;
1064
1406
  let primaryError: unknown;
1065
1407
  try {
1408
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1066
1409
  const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
1067
1410
  if (recoveryArtifacts.length) {
1068
1411
  throw new Error(
@@ -1073,6 +1416,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1073
1416
  await preflightSafeUninstallOwnership(destination, options.packageName, skills);
1074
1417
 
1075
1418
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
1419
+ const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'uninstall transaction root');
1076
1420
  const backupRoot = path.join(transactionRoot, 'backup');
1077
1421
  const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
1078
1422
  const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
@@ -1088,10 +1432,17 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1088
1432
  []
1089
1433
  );
1090
1434
  await options.failpoint?.({ phase: 'before-mutation' });
1435
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1091
1436
  mutationStarted = true;
1092
1437
  await moveExistingEntries(destination, skills, backupRoot, moved);
1093
- await fsp.rename(receiptPath, receiptBackup);
1094
- receiptMoved = true;
1438
+ await moveRegularFile(
1439
+ receiptPath,
1440
+ receiptBackup,
1441
+ destination,
1442
+ transactionRoot,
1443
+ 'uninstall receipt backup',
1444
+ () => { receiptMoved = true; }
1445
+ );
1095
1446
  await verifyMovedMutationSnapshot(
1096
1447
  destination,
1097
1448
  mutationSnapshot,
@@ -1100,7 +1451,9 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1100
1451
  receiptBackup
1101
1452
  );
1102
1453
  await options.failpoint?.({ phase: 'after-backup' });
1454
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1103
1455
  await options.failpoint?.({ phase: 'before-verify' });
1456
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1104
1457
 
1105
1458
  const remaining = [];
1106
1459
  for (const skill of skills) {
@@ -1112,7 +1465,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1112
1465
  }
1113
1466
 
1114
1467
  try {
1115
- await fsp.rm(transactionRoot, { recursive: true, force: true });
1468
+ await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
1116
1469
  } catch (error) {
1117
1470
  cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
1118
1471
  }
@@ -1123,11 +1476,24 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1123
1476
  };
1124
1477
  } catch (error) {
1125
1478
  const rollbackErrors: string[] = [];
1126
- if (mutationStarted) {
1479
+ let destinationStillOwned = true;
1480
+ try {
1481
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1482
+ } catch (identityError) {
1483
+ destinationStillOwned = false;
1484
+ rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
1485
+ }
1486
+ if (mutationStarted && destinationStillOwned) {
1127
1487
  if (receiptMoved) {
1128
1488
  try {
1129
1489
  if (await exists(receiptPath)) throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
1130
- await fsp.rename(receiptBackup, receiptPath);
1490
+ await moveRegularFile(
1491
+ receiptBackup,
1492
+ receiptPath,
1493
+ transactionRoot,
1494
+ destination,
1495
+ 'uninstall receipt rollback'
1496
+ );
1131
1497
  } catch (receiptError) {
1132
1498
  rollbackErrors.push(message(receiptError));
1133
1499
  }
@@ -1136,7 +1502,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1136
1502
  }
1137
1503
  if (rollbackErrors.length === 0) {
1138
1504
  try {
1139
- await fsp.rm(transactionRoot, { recursive: true, force: true });
1505
+ await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
1140
1506
  } catch (cleanupError) {
1141
1507
  throw new Error(
1142
1508
  `yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
@@ -1161,7 +1527,8 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1161
1527
  lockCleanupErrors.push(message(error));
1162
1528
  }
1163
1529
  try {
1164
- await fsp.rm(lock.lockPath, { force: true });
1530
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1531
+ await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
1165
1532
  } catch (error) {
1166
1533
  lockCleanupErrors.push(message(error));
1167
1534
  }