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';
@@ -37,6 +38,137 @@ async function exists(target) {
37
38
  return false;
38
39
  }
39
40
  }
41
+ function pathWithinBoundary(boundary, target) {
42
+ const relative = path.relative(boundary, target);
43
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
44
+ }
45
+ async function captureRegularParentPath(boundaryValue, targetValue, label) {
46
+ const boundary = path.resolve(boundaryValue);
47
+ const target = path.resolve(targetValue);
48
+ if (!pathWithinBoundary(boundary, target)) {
49
+ throw new Error(`${label} escapes its trusted path boundary: ${target}`);
50
+ }
51
+ const targetParent = target === boundary ? boundary : path.dirname(target);
52
+ const relativeParent = path.relative(boundary, targetParent);
53
+ const candidates = [boundary];
54
+ if (relativeParent) {
55
+ let current = boundary;
56
+ for (const segment of relativeParent.split(path.sep)) {
57
+ current = path.join(current, segment);
58
+ candidates.push(current);
59
+ }
60
+ }
61
+ const identities = [];
62
+ for (const candidate of candidates) {
63
+ const stat = await fsp.lstat(candidate);
64
+ if (stat.isSymbolicLink()) {
65
+ throw new Error(`${label} has a symlinked parent path segment: ${candidate}`);
66
+ }
67
+ if (!stat.isDirectory()) {
68
+ throw new Error(`${label} parent path segment must be a regular directory: ${candidate}`);
69
+ }
70
+ identities.push({ path: candidate, dev: stat.dev, ino: stat.ino });
71
+ }
72
+ return identities;
73
+ }
74
+ async function revalidateRegularParentPath(identities, label) {
75
+ for (const identity of identities) {
76
+ const stat = await fsp.lstat(identity.path);
77
+ if (stat.isSymbolicLink()
78
+ || !stat.isDirectory()
79
+ || stat.dev !== identity.dev
80
+ || stat.ino !== identity.ino) {
81
+ throw new Error(`${label} parent path changed identity: ${identity.path}`);
82
+ }
83
+ }
84
+ }
85
+ async function captureRegularDirectoryPath(boundaryValue, directoryValue, label) {
86
+ const boundary = path.resolve(boundaryValue);
87
+ const directory = path.resolve(directoryValue);
88
+ if (boundary === directory) {
89
+ const stat = await fsp.lstat(directory);
90
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
91
+ throw new Error(`${label} must be a regular directory: ${directory}`);
92
+ }
93
+ return [{ path: directory, dev: stat.dev, ino: stat.ino }];
94
+ }
95
+ const identities = await captureRegularParentPath(boundary, directory, label);
96
+ const stat = await fsp.lstat(directory);
97
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
98
+ throw new Error(`${label} must be a regular directory: ${directory}`);
99
+ }
100
+ identities.push({ path: directory, dev: stat.dev, ino: stat.ino });
101
+ return identities;
102
+ }
103
+ function readOnlyNoFollowFlags() {
104
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
105
+ ? fsConstants.O_NOFOLLOW
106
+ : 0;
107
+ return fsConstants.O_RDONLY | noFollow;
108
+ }
109
+ async function captureRegularFile(file, boundary = path.dirname(file)) {
110
+ const parents = await captureRegularParentPath(boundary, file, 'regular file read');
111
+ const before = await fsp.lstat(file);
112
+ if (!before.isFile() || before.isSymbolicLink())
113
+ throw new Error(`expected a regular file: ${file}`);
114
+ const handle = await fsp.open(file, readOnlyNoFollowFlags());
115
+ try {
116
+ const opened = await handle.stat();
117
+ if (!opened.isFile())
118
+ throw new Error(`expected a regular file: ${file}`);
119
+ const bytes = await handle.readFile();
120
+ const afterOpened = await handle.stat();
121
+ const after = await fsp.lstat(file);
122
+ await revalidateRegularParentPath(parents, 'regular file read');
123
+ if (!after.isFile()
124
+ || after.isSymbolicLink()
125
+ || before.dev !== opened.dev
126
+ || before.ino !== opened.ino
127
+ || afterOpened.dev !== opened.dev
128
+ || afterOpened.ino !== opened.ino
129
+ || after.dev !== opened.dev
130
+ || after.ino !== opened.ino) {
131
+ throw new Error(`regular file changed identity while being captured: ${file}`);
132
+ }
133
+ return { bytes, mode: opened.mode & 0o777 };
134
+ }
135
+ finally {
136
+ await handle.close();
137
+ }
138
+ }
139
+ async function readRegularFileBytes(file, boundary = path.dirname(file)) {
140
+ return (await captureRegularFile(file, boundary)).bytes;
141
+ }
142
+ async function copyRegularFile(source, target, sourceBoundary, targetBoundary) {
143
+ const sourceFile = await captureRegularFile(source, sourceBoundary);
144
+ const targetParents = await captureRegularParentPath(targetBoundary, target, 'install copy target');
145
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
146
+ ? fsConstants.O_NOFOLLOW
147
+ : 0;
148
+ const handle = await fsp.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, sourceFile.mode);
149
+ try {
150
+ await handle.writeFile(sourceFile.bytes);
151
+ const opened = await handle.stat();
152
+ if (!opened.isFile())
153
+ throw new Error(`install copy target must be a regular file: ${target}`);
154
+ const openedIdentity = { dev: opened.dev, ino: opened.ino };
155
+ const afterOpened = await handle.stat();
156
+ if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
157
+ throw new Error(`install copy target changed descriptor identity: ${target}`);
158
+ }
159
+ await revalidateRegularParentPath(targetParents, 'install copy target');
160
+ const installed = await fsp.lstat(target);
161
+ if (!installed.isFile()
162
+ || installed.isSymbolicLink()
163
+ || installed.dev !== openedIdentity.dev
164
+ || installed.ino !== openedIdentity.ino) {
165
+ throw new Error(`install copy target changed path identity: ${target}`);
166
+ }
167
+ }
168
+ finally {
169
+ await handle.close();
170
+ }
171
+ }
40
172
  export async function findInstallRecoveryArtifacts(root) {
41
173
  try {
42
174
  const entries = await fsp.readdir(root, { withFileTypes: true });
@@ -51,25 +183,23 @@ export async function findInstallRecoveryArtifacts(root) {
51
183
  throw error;
52
184
  }
53
185
  }
54
- async function digestFile(file, relativePath) {
186
+ async function digestFile(file, relativePath, boundary = path.dirname(file)) {
55
187
  const stat = await fsp.lstat(file);
56
188
  if (!stat.isFile() || stat.isSymbolicLink()) {
57
189
  throw new Error(`install source must be a regular file: ${file}`);
58
190
  }
59
- const contents = await fsp.readFile(file);
191
+ const contents = await readRegularFileBytes(file, boundary);
60
192
  return {
61
193
  path: relativePath.split(path.sep).join('/'),
62
194
  bytes: contents.byteLength,
63
195
  sha256: createHash('sha256').update(contents).digest('hex')
64
196
  };
65
197
  }
66
- async function collectTreeManifest(root, prefix = '') {
67
- const stat = await fsp.lstat(root);
68
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
69
- throw new Error(`install tree must be a regular directory: ${root}`);
70
- }
198
+ async function collectTreeManifest(root, prefix = '', boundary = root) {
199
+ const rootIdentity = await captureRegularDirectoryPath(boundary, root, 'install tree');
71
200
  const files = [];
72
201
  async function walk(current, relative) {
202
+ const directoryIdentity = await captureRegularDirectoryPath(boundary, current, 'install tree');
73
203
  const entries = await fsp.readdir(current, { withFileTypes: true });
74
204
  entries.sort((left, right) => compareStableText(left.name, right.name));
75
205
  for (const entry of entries) {
@@ -79,25 +209,29 @@ async function collectTreeManifest(root, prefix = '') {
79
209
  await walk(absolute, nextRelative);
80
210
  }
81
211
  else if (entry.isFile()) {
82
- files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
212
+ files.push(await digestFile(absolute, path.join(prefix, nextRelative), boundary));
83
213
  }
84
214
  else {
85
215
  throw new Error(`unsupported install tree entry: ${absolute}`);
86
216
  }
87
217
  }
218
+ await revalidateRegularParentPath(directoryIdentity, 'install tree');
88
219
  }
89
220
  await walk(root, '');
221
+ await revalidateRegularParentPath(rootIdentity, 'install tree');
90
222
  return files.sort((left, right) => compareStableText(left.path, right.path));
91
223
  }
92
224
  async function expectedInstallManifest(sourceRoot, skills) {
225
+ const sourceIdentity = await captureRegularDirectoryPath(sourceRoot, sourceRoot, 'install source root');
93
226
  const referencesRoot = path.join(sourceRoot, 'references');
94
- const referenceFiles = await collectTreeManifest(referencesRoot);
227
+ const referenceFiles = await collectTreeManifest(referencesRoot, '', sourceRoot);
95
228
  if (referenceFiles.length === 0)
96
229
  throw new Error(`no reference files found: ${referencesRoot}`);
97
230
  const files = [];
98
231
  for (const skill of skills) {
99
232
  const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
100
- const skillText = await fsp.readFile(skillFile, 'utf8');
233
+ const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
234
+ const skillText = skillBytes.toString('utf8');
101
235
  const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
102
236
  const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
103
237
  ?.trim()
@@ -105,7 +239,11 @@ async function expectedInstallManifest(sourceRoot, skills) {
105
239
  if (name !== skill) {
106
240
  throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
107
241
  }
108
- files.push(await digestFile(skillFile, path.join(skill, 'SKILL.md')));
242
+ files.push({
243
+ path: path.join(skill, 'SKILL.md').split(path.sep).join('/'),
244
+ bytes: skillBytes.byteLength,
245
+ sha256: createHash('sha256').update(skillBytes).digest('hex')
246
+ });
109
247
  for (const reference of referenceFiles) {
110
248
  files.push({
111
249
  ...reference,
@@ -113,29 +251,75 @@ async function expectedInstallManifest(sourceRoot, skills) {
113
251
  });
114
252
  }
115
253
  }
254
+ await revalidateRegularParentPath(sourceIdentity, 'install source root');
116
255
  return files.sort((left, right) => compareStableText(left.path, right.path));
117
256
  }
118
- async function copyTree(source, target) {
119
- const stat = await fsp.lstat(source);
120
- if (!stat.isDirectory() || stat.isSymbolicLink()) {
121
- throw new Error(`install source must be a regular directory: ${source}`);
122
- }
257
+ async function copyTree(source, target, sourceBoundary = source, targetBoundary = target) {
258
+ const sourceIdentity = await captureRegularDirectoryPath(sourceBoundary, source, 'install source tree');
259
+ const targetParents = await captureRegularParentPath(targetBoundary, target, 'install target tree');
123
260
  await fsp.mkdir(target, { recursive: true });
261
+ const targetIdentity = await captureRegularDirectoryPath(targetBoundary, target, 'install target tree');
262
+ await revalidateRegularParentPath(sourceIdentity, 'install source tree');
263
+ await revalidateRegularParentPath(targetParents, 'install target tree');
124
264
  const entries = await fsp.readdir(source, { withFileTypes: true });
125
265
  entries.sort((left, right) => left.name.localeCompare(right.name));
126
266
  for (const entry of entries) {
127
267
  const from = path.join(source, entry.name);
128
268
  const to = path.join(target, entry.name);
129
269
  if (entry.isDirectory()) {
130
- await copyTree(from, to);
270
+ await copyTree(from, to, sourceBoundary, targetBoundary);
131
271
  }
132
272
  else if (entry.isFile()) {
133
- await fsp.copyFile(from, to);
273
+ await copyRegularFile(from, to, sourceBoundary, targetBoundary);
134
274
  }
135
275
  else {
136
276
  throw new Error(`unsupported install source entry: ${from}`);
137
277
  }
138
278
  }
279
+ await revalidateRegularParentPath(sourceIdentity, 'install source tree');
280
+ await revalidateRegularParentPath(targetIdentity, 'install target tree');
281
+ }
282
+ async function moveRegularDirectory(source, target, sourceBoundary, targetBoundary, label, onMoved) {
283
+ const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
284
+ const sourceStat = await fsp.lstat(source);
285
+ if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
286
+ throw new Error(`${label} source must be a regular directory: ${source}`);
287
+ }
288
+ const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
289
+ if (await exists(target))
290
+ throw new Error(`${label} target unexpectedly exists: ${target}`);
291
+ await fsp.rename(source, target);
292
+ onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'directory' });
293
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
294
+ await revalidateRegularParentPath(targetParents, `${label} target`);
295
+ const targetStat = await fsp.lstat(target);
296
+ if (!targetStat.isDirectory()
297
+ || targetStat.isSymbolicLink()
298
+ || targetStat.dev !== sourceStat.dev
299
+ || targetStat.ino !== sourceStat.ino) {
300
+ throw new Error(`${label} directory changed identity while being moved: ${target}`);
301
+ }
302
+ }
303
+ async function moveRegularFile(source, target, sourceBoundary, targetBoundary, label, onMoved) {
304
+ const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
305
+ const sourceStat = await fsp.lstat(source);
306
+ if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
307
+ throw new Error(`${label} source must be a regular file: ${source}`);
308
+ }
309
+ const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
310
+ if (await exists(target))
311
+ throw new Error(`${label} target unexpectedly exists: ${target}`);
312
+ await fsp.rename(source, target);
313
+ onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'file' });
314
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
315
+ await revalidateRegularParentPath(targetParents, `${label} target`);
316
+ const targetStat = await fsp.lstat(target);
317
+ if (!targetStat.isFile()
318
+ || targetStat.isSymbolicLink()
319
+ || targetStat.dev !== sourceStat.dev
320
+ || targetStat.ino !== sourceStat.ino) {
321
+ throw new Error(`${label} file changed identity while being moved: ${target}`);
322
+ }
139
323
  }
140
324
  function manifestDigest(files) {
141
325
  return digestManifestFiles([...files].sort((left, right) => compareStableText(left.path, right.path)));
@@ -282,7 +466,7 @@ async function readOwnershipReceipt(destination, packageName) {
282
466
  if (!stat.isFile() || stat.isSymbolicLink()) {
283
467
  return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
284
468
  }
285
- const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
469
+ const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
286
470
  const issues = ownershipReceiptIssues(parsed, packageName, destination);
287
471
  return issues.length
288
472
  ? { exists: true, issues }
@@ -306,7 +490,7 @@ async function verifyReceiptOwnedSkill(root, skill, receipt) {
306
490
  if (!await exists(target))
307
491
  return [`receipt-owned skill is missing: ${skill}`];
308
492
  try {
309
- const actual = await collectTreeManifest(target, skill);
493
+ const actual = await collectTreeManifest(target, skill, root);
310
494
  return manifestIssues(expected, actual, `installed ${skill}`);
311
495
  }
312
496
  catch (error) {
@@ -382,36 +566,11 @@ async function preflightSafeUninstallOwnership(destination, packageName, skills)
382
566
  }
383
567
  return receipt;
384
568
  }
385
- async function readRegularFileBytes(file) {
386
- const before = await fsp.lstat(file);
387
- if (!before.isFile() || before.isSymbolicLink())
388
- throw new Error(`expected a regular file: ${file}`);
389
- const handle = await fsp.open(file, 'r');
390
- try {
391
- const opened = await handle.stat();
392
- if (!opened.isFile())
393
- throw new Error(`expected a regular file: ${file}`);
394
- const bytes = await handle.readFile();
395
- const after = await fsp.lstat(file);
396
- if (!after.isFile()
397
- || after.isSymbolicLink()
398
- || before.dev !== opened.dev
399
- || before.ino !== opened.ino
400
- || after.dev !== opened.dev
401
- || after.ino !== opened.ino) {
402
- throw new Error(`regular file changed identity while being captured: ${file}`);
403
- }
404
- return bytes;
405
- }
406
- finally {
407
- await handle.close();
408
- }
409
- }
410
569
  async function captureReceiptMutationSnapshot(destination, packageName) {
411
570
  const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
412
571
  let bytes;
413
572
  try {
414
- bytes = await readRegularFileBytes(receiptPath);
573
+ bytes = await readRegularFileBytes(receiptPath, destination);
415
574
  }
416
575
  catch (error) {
417
576
  if (error.code === 'ENOENT')
@@ -440,7 +599,7 @@ async function captureMutationSnapshot(destination, packageName, names, explicit
440
599
  const receipt = await captureReceiptMutationSnapshot(destination, packageName);
441
600
  const trees = [];
442
601
  for (const name of names) {
443
- const files = await collectTreeManifest(path.join(destination, name), name);
602
+ const files = await collectTreeManifest(path.join(destination, name), name, destination);
444
603
  if (!explicitReplacements.includes(name)) {
445
604
  if (!receipt.exists) {
446
605
  throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
@@ -468,7 +627,7 @@ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receipt
468
627
  continue;
469
628
  }
470
629
  try {
471
- const actual = await collectTreeManifest(movedEntry.backup, tree.name);
630
+ const actual = await collectTreeManifest(movedEntry.backup, tree.name, path.dirname(movedEntry.backup));
472
631
  issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
473
632
  }
474
633
  catch (error) {
@@ -482,7 +641,7 @@ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receipt
482
641
  }
483
642
  else if (snapshot.receipt.exists && receiptBackedUp) {
484
643
  try {
485
- const bytes = await readRegularFileBytes(receiptBackupPath);
644
+ const bytes = await readRegularFileBytes(receiptBackupPath, path.dirname(receiptBackupPath));
486
645
  const sha256 = createHash('sha256').update(bytes).digest('hex');
487
646
  if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
488
647
  issues.push('moved install receipt differs from the captured bytes');
@@ -556,7 +715,7 @@ export async function inspectSkillInstallation(options) {
556
715
  continue;
557
716
  }
558
717
  try {
559
- const installed = (await collectTreeManifest(target, skill));
718
+ const installed = (await collectTreeManifest(target, skill, destination));
560
719
  const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
561
720
  skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
562
721
  issues.push(...skillIssues);
@@ -574,7 +733,7 @@ export async function inspectSkillInstallation(options) {
574
733
  }
575
734
  else {
576
735
  try {
577
- const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
736
+ const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
578
737
  const problems = receiptIssues(parsed, options, destination, expected);
579
738
  if (problems.length) {
580
739
  receiptStatus = 'drift';
@@ -604,6 +763,7 @@ export async function inspectSkillInstallation(options) {
604
763
  }
605
764
  async function acquireInstallLock(destination) {
606
765
  const lockPath = path.join(destination, INSTALL_LOCK_NAME);
766
+ const destinationIdentity = await captureRegularDirectoryPath(destination, destination, 'skill destination');
607
767
  let handle;
608
768
  try {
609
769
  handle = await fsp.open(lockPath, 'wx', 0o600);
@@ -614,25 +774,60 @@ async function acquireInstallLock(destination) {
614
774
  }
615
775
  throw error;
616
776
  }
777
+ let lockIdentity;
617
778
  try {
779
+ const opened = await handle.stat();
780
+ if (!opened.isFile())
781
+ throw new Error(`install lock must be a regular file: ${lockPath}`);
782
+ lockIdentity = { path: lockPath, dev: opened.dev, ino: opened.ino, kind: 'file' };
618
783
  await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
784
+ await revalidateRegularParentPath(destinationIdentity, 'skill destination');
785
+ const current = await fsp.lstat(lockPath);
786
+ if (current.isSymbolicLink()
787
+ || !current.isFile()
788
+ || current.dev !== lockIdentity.dev
789
+ || current.ino !== lockIdentity.ino) {
790
+ throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
791
+ }
619
792
  }
620
793
  catch (error) {
621
- await handle.close().catch(() => undefined);
622
- await fsp.rm(lockPath, { force: true }).catch(() => undefined);
794
+ const cleanupErrors = [];
795
+ try {
796
+ await handle.close();
797
+ }
798
+ catch (closeError) {
799
+ cleanupErrors.push(message(closeError));
800
+ }
801
+ try {
802
+ await revalidateRegularParentPath(destinationIdentity, 'skill destination');
803
+ if (!lockIdentity)
804
+ throw new Error(`install lock identity was not captured: ${lockPath}`);
805
+ await removeRecordedEntry(lockIdentity, 'install lock');
806
+ }
807
+ catch (cleanupError) {
808
+ cleanupErrors.push(message(cleanupError));
809
+ }
810
+ if (cleanupErrors.length) {
811
+ throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
812
+ }
623
813
  throw error;
624
814
  }
625
- return { handle, lockPath };
815
+ return { handle, lockPath, lockIdentity, destinationIdentity };
626
816
  }
627
817
  async function moveExistingEntries(root, names, backupRoot, moved) {
628
818
  await fsp.mkdir(backupRoot, { recursive: true });
819
+ const backupBoundary = path.dirname(backupRoot);
629
820
  for (const name of names) {
630
821
  const original = path.join(root, name);
631
822
  if (!await exists(original))
632
823
  continue;
633
824
  const backup = path.join(backupRoot, name);
634
- await fsp.rename(original, backup);
635
- moved.push({ original, backup });
825
+ await moveRegularDirectory(original, backup, root, backupBoundary, 'managed install backup', () => moved.push({
826
+ original,
827
+ backup,
828
+ originalBoundary: root,
829
+ backupBoundary
830
+ }));
636
831
  }
637
832
  }
638
833
  async function restoreMovedEntries(entries) {
@@ -643,7 +838,7 @@ async function restoreMovedEntries(entries) {
643
838
  throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
644
839
  }
645
840
  await fsp.mkdir(path.dirname(entry.original), { recursive: true });
646
- await fsp.rename(entry.backup, entry.original);
841
+ await moveRegularDirectory(entry.backup, entry.original, entry.backupBoundary, entry.originalBoundary, 'managed install rollback');
647
842
  }
648
843
  catch (error) {
649
844
  errors.push(message(error));
@@ -655,14 +850,41 @@ async function removeInstalledEntries(entries) {
655
850
  const errors = [];
656
851
  for (const entry of [...entries].reverse()) {
657
852
  try {
658
- await fsp.rm(entry, { recursive: true, force: true });
853
+ await removeRecordedEntry(entry, 'rollback removal');
659
854
  }
660
855
  catch (error) {
661
- errors.push(`could not remove new install ${entry}: ${message(error)}`);
856
+ errors.push(`could not remove new install ${entry.path}: ${message(error)}`);
662
857
  }
663
858
  }
664
859
  return errors;
665
860
  }
861
+ async function captureRecordedEntry(target, kind, label) {
862
+ const stat = await fsp.lstat(target);
863
+ const typeMatches = kind === 'directory'
864
+ ? stat.isDirectory() && !stat.isSymbolicLink()
865
+ : stat.isFile() && !stat.isSymbolicLink();
866
+ if (!typeMatches)
867
+ throw new Error(`${label} must be a regular ${kind}: ${target}`);
868
+ return { path: target, dev: stat.dev, ino: stat.ino, kind };
869
+ }
870
+ async function removeRecordedEntry(entry, label) {
871
+ let current;
872
+ try {
873
+ current = await fsp.lstat(entry.path);
874
+ }
875
+ catch (error) {
876
+ if (error.code === 'ENOENT')
877
+ return;
878
+ throw error;
879
+ }
880
+ const typeMatches = entry.kind === 'directory'
881
+ ? current.isDirectory() && !current.isSymbolicLink()
882
+ : current.isFile() && !current.isSymbolicLink();
883
+ if (!typeMatches || current.dev !== entry.dev || current.ino !== entry.ino) {
884
+ throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
885
+ }
886
+ await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
887
+ }
666
888
  export async function installSkillSetTransactional(options) {
667
889
  const sourceRoot = path.resolve(options.sourceRoot);
668
890
  const destination = safeRoot(options.destination, 'skill destination');
@@ -678,6 +900,7 @@ export async function installSkillSetTransactional(options) {
678
900
  let result;
679
901
  let primaryError;
680
902
  try {
903
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
681
904
  const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
682
905
  if (recoveryArtifacts.length) {
683
906
  throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
@@ -687,6 +910,7 @@ export async function installSkillSetTransactional(options) {
687
910
  const expected = await expectedInstallManifest(sourceRoot, skills);
688
911
  const transactionId = options.transactionId || randomUUID();
689
912
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
913
+ const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'install transaction root');
690
914
  const stagedRoot = path.join(transactionRoot, 'staged');
691
915
  const backupRoot = path.join(transactionRoot, 'backup');
692
916
  const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
@@ -695,15 +919,15 @@ export async function installSkillSetTransactional(options) {
695
919
  const installedEntries = [];
696
920
  let destinationBackups = [];
697
921
  let receiptBackedUp = false;
698
- let receiptInstalled = false;
922
+ let receiptInstalled;
699
923
  let mutationStarted = false;
700
924
  try {
701
925
  await fsp.mkdir(stagedRoot, { recursive: true });
702
926
  for (const skill of skills) {
703
927
  const stagedSkill = path.join(stagedRoot, skill);
704
928
  await fsp.mkdir(stagedSkill, { recursive: true });
705
- await fsp.copyFile(path.join(sourceRoot, 'skills', skill, 'SKILL.md'), path.join(stagedSkill, 'SKILL.md'));
706
- await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'));
929
+ await copyRegularFile(path.join(sourceRoot, 'skills', skill, 'SKILL.md'), path.join(stagedSkill, 'SKILL.md'), sourceRoot, stagedRoot);
930
+ await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'), sourceRoot, stagedRoot);
707
931
  }
708
932
  const stagedManifest = await collectTreeManifest(stagedRoot);
709
933
  const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
@@ -729,24 +953,26 @@ export async function installSkillSetTransactional(options) {
729
953
  const mutationOwnership = await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
730
954
  const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, mutationOwnership.replaceNames, replaceSkills);
731
955
  await options.failpoint?.({ phase: 'before-mutation' });
956
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
732
957
  mutationStarted = true;
733
958
  await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
734
959
  if (await exists(destinationReceiptPath)) {
735
- await fsp.rename(destinationReceiptPath, receiptBackupPath);
736
- receiptBackedUp = true;
960
+ await moveRegularFile(destinationReceiptPath, receiptBackupPath, destination, transactionRoot, 'install receipt backup', () => { receiptBackedUp = true; });
737
961
  }
738
962
  await verifyMovedMutationSnapshot(destination, mutationSnapshot, destinationBackups, receiptBackedUp, receiptBackupPath);
739
963
  await options.failpoint?.({ phase: 'after-backup' });
964
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
740
965
  for (const skill of skills) {
966
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
741
967
  const target = path.join(destination, skill);
742
- await fsp.rename(path.join(stagedRoot, skill), target);
743
- installedEntries.push(target);
968
+ await moveRegularDirectory(path.join(stagedRoot, skill), target, stagedRoot, destination, 'staged skill install', (entry) => installedEntries.push(entry));
744
969
  await options.failpoint?.({ phase: 'skill-installed', skill });
745
970
  }
746
- await fsp.rename(stagedReceiptPath, destinationReceiptPath);
747
- receiptInstalled = true;
971
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
972
+ await moveRegularFile(stagedReceiptPath, destinationReceiptPath, transactionRoot, destination, 'staged install receipt', (entry) => { receiptInstalled = entry; });
748
973
  await options.failpoint?.({ phase: 'receipt-installed' });
749
974
  await options.failpoint?.({ phase: 'before-verify' });
975
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
750
976
  const inspection = await inspectSkillInstallation({
751
977
  sourceRoot,
752
978
  destination,
@@ -757,15 +983,11 @@ export async function installSkillSetTransactional(options) {
757
983
  });
758
984
  if (!inspection.ok)
759
985
  throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
760
- for (const cleanup of [transactionRoot]) {
761
- if (!cleanup)
762
- continue;
763
- try {
764
- await fsp.rm(cleanup, { recursive: true, force: true });
765
- }
766
- catch (error) {
767
- cleanupWarnings.push(`verified install retained cleanup artifact ${cleanup}: ${message(error)}`);
768
- }
986
+ try {
987
+ await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
988
+ }
989
+ catch (error) {
990
+ cleanupWarnings.push(`verified install retained cleanup artifact ${transactionRoot}: ${message(error)}`);
769
991
  }
770
992
  result = {
771
993
  receipt,
@@ -777,16 +999,24 @@ export async function installSkillSetTransactional(options) {
777
999
  }
778
1000
  catch (error) {
779
1001
  const rollbackErrors = [];
780
- if (mutationStarted) {
1002
+ let destinationStillOwned = true;
1003
+ try {
1004
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1005
+ }
1006
+ catch (identityError) {
1007
+ destinationStillOwned = false;
1008
+ rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
1009
+ }
1010
+ if (mutationStarted && destinationStillOwned) {
781
1011
  if (receiptInstalled)
782
- rollbackErrors.push(...await removeInstalledEntries([destinationReceiptPath]));
1012
+ rollbackErrors.push(...await removeInstalledEntries([receiptInstalled]));
783
1013
  rollbackErrors.push(...await removeInstalledEntries(installedEntries));
784
1014
  rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
785
1015
  if (receiptBackedUp) {
786
1016
  try {
787
1017
  if (await exists(destinationReceiptPath))
788
1018
  throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
789
- await fsp.rename(receiptBackupPath, destinationReceiptPath);
1019
+ await moveRegularFile(receiptBackupPath, destinationReceiptPath, transactionRoot, destination, 'install receipt rollback');
790
1020
  }
791
1021
  catch (receiptError) {
792
1022
  rollbackErrors.push(message(receiptError));
@@ -795,15 +1025,11 @@ export async function installSkillSetTransactional(options) {
795
1025
  }
796
1026
  if (rollbackErrors.length === 0) {
797
1027
  const cleanupErrors = [];
798
- for (const cleanup of [transactionRoot]) {
799
- if (!cleanup)
800
- continue;
801
- try {
802
- await fsp.rm(cleanup, { recursive: true, force: true });
803
- }
804
- catch (cleanupError) {
805
- cleanupErrors.push(`${cleanup}: ${message(cleanupError)}`);
806
- }
1028
+ try {
1029
+ await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
1030
+ }
1031
+ catch (cleanupError) {
1032
+ cleanupErrors.push(`${transactionRoot}: ${message(cleanupError)}`);
807
1033
  }
808
1034
  if (cleanupErrors.length) {
809
1035
  throw new Error(`yam install failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
@@ -827,7 +1053,8 @@ export async function installSkillSetTransactional(options) {
827
1053
  lockCleanupErrors.push(message(error));
828
1054
  }
829
1055
  try {
830
- await fsp.rm(lock.lockPath, { force: true });
1056
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1057
+ await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
831
1058
  }
832
1059
  catch (error) {
833
1060
  lockCleanupErrors.push(message(error));
@@ -860,6 +1087,7 @@ export async function uninstallSkillSetSafely(options) {
860
1087
  let result;
861
1088
  let primaryError;
862
1089
  try {
1090
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
863
1091
  const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
864
1092
  if (recoveryArtifacts.length) {
865
1093
  throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
@@ -867,6 +1095,7 @@ export async function uninstallSkillSetSafely(options) {
867
1095
  }
868
1096
  await preflightSafeUninstallOwnership(destination, options.packageName, skills);
869
1097
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
1098
+ const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'uninstall transaction root');
870
1099
  const backupRoot = path.join(transactionRoot, 'backup');
871
1100
  const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
872
1101
  const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
@@ -877,13 +1106,15 @@ export async function uninstallSkillSetSafely(options) {
877
1106
  await preflightSafeUninstallOwnership(destination, options.packageName, skills);
878
1107
  const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, skills, []);
879
1108
  await options.failpoint?.({ phase: 'before-mutation' });
1109
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
880
1110
  mutationStarted = true;
881
1111
  await moveExistingEntries(destination, skills, backupRoot, moved);
882
- await fsp.rename(receiptPath, receiptBackup);
883
- receiptMoved = true;
1112
+ await moveRegularFile(receiptPath, receiptBackup, destination, transactionRoot, 'uninstall receipt backup', () => { receiptMoved = true; });
884
1113
  await verifyMovedMutationSnapshot(destination, mutationSnapshot, moved, receiptMoved, receiptBackup);
885
1114
  await options.failpoint?.({ phase: 'after-backup' });
1115
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
886
1116
  await options.failpoint?.({ phase: 'before-verify' });
1117
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
887
1118
  const remaining = [];
888
1119
  for (const skill of skills) {
889
1120
  if (await exists(path.join(destination, skill)))
@@ -895,7 +1126,7 @@ export async function uninstallSkillSetSafely(options) {
895
1126
  throw new Error(`post-uninstall verification found active entries: ${remaining.join(', ')}`);
896
1127
  }
897
1128
  try {
898
- await fsp.rm(transactionRoot, { recursive: true, force: true });
1129
+ await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
899
1130
  }
900
1131
  catch (error) {
901
1132
  cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
@@ -908,12 +1139,20 @@ export async function uninstallSkillSetSafely(options) {
908
1139
  }
909
1140
  catch (error) {
910
1141
  const rollbackErrors = [];
911
- if (mutationStarted) {
1142
+ let destinationStillOwned = true;
1143
+ try {
1144
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1145
+ }
1146
+ catch (identityError) {
1147
+ destinationStillOwned = false;
1148
+ rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
1149
+ }
1150
+ if (mutationStarted && destinationStillOwned) {
912
1151
  if (receiptMoved) {
913
1152
  try {
914
1153
  if (await exists(receiptPath))
915
1154
  throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
916
- await fsp.rename(receiptBackup, receiptPath);
1155
+ await moveRegularFile(receiptBackup, receiptPath, transactionRoot, destination, 'uninstall receipt rollback');
917
1156
  }
918
1157
  catch (receiptError) {
919
1158
  rollbackErrors.push(message(receiptError));
@@ -923,7 +1162,7 @@ export async function uninstallSkillSetSafely(options) {
923
1162
  }
924
1163
  if (rollbackErrors.length === 0) {
925
1164
  try {
926
- await fsp.rm(transactionRoot, { recursive: true, force: true });
1165
+ await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
927
1166
  }
928
1167
  catch (cleanupError) {
929
1168
  throw new Error(`yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
@@ -946,7 +1185,8 @@ export async function uninstallSkillSetSafely(options) {
946
1185
  lockCleanupErrors.push(message(error));
947
1186
  }
948
1187
  try {
949
- await fsp.rm(lock.lockPath, { force: true });
1188
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1189
+ await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
950
1190
  }
951
1191
  catch (error) {
952
1192
  lockCleanupErrors.push(message(error));