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 } 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';
@@ -6,6 +7,7 @@ export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
6
7
  export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
7
8
  export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
8
9
  export const INSTALL_TRANSACTION_PREFIX = '.yam-flow-install-';
10
+ const TRANSACTION_OWNER_MARKER = '.yam-flow-transaction-owner';
9
11
  function message(error) {
10
12
  return error instanceof Error ? error.message : String(error);
11
13
  }
@@ -37,6 +39,169 @@ async function exists(target) {
37
39
  return false;
38
40
  }
39
41
  }
42
+ function pathWithinBoundary(boundary, target) {
43
+ const relative = path.relative(boundary, target);
44
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
45
+ }
46
+ async function captureRegularParentPath(boundaryValue, targetValue, label) {
47
+ const boundary = path.resolve(boundaryValue);
48
+ const target = path.resolve(targetValue);
49
+ if (!pathWithinBoundary(boundary, target)) {
50
+ throw new Error(`${label} escapes its trusted path boundary: ${target}`);
51
+ }
52
+ const targetParent = target === boundary ? boundary : path.dirname(target);
53
+ const relativeParent = path.relative(boundary, targetParent);
54
+ const candidates = [boundary];
55
+ if (relativeParent) {
56
+ let current = boundary;
57
+ for (const segment of relativeParent.split(path.sep)) {
58
+ current = path.join(current, segment);
59
+ candidates.push(current);
60
+ }
61
+ }
62
+ const identities = [];
63
+ for (const candidate of candidates) {
64
+ const stat = await fsp.lstat(candidate);
65
+ if (stat.isSymbolicLink()) {
66
+ throw new Error(`${label} has a symlinked parent path segment: ${candidate}`);
67
+ }
68
+ if (!stat.isDirectory()) {
69
+ throw new Error(`${label} parent path segment must be a regular directory: ${candidate}`);
70
+ }
71
+ identities.push({ path: candidate, dev: stat.dev, ino: stat.ino });
72
+ }
73
+ return identities;
74
+ }
75
+ async function revalidateRegularParentPath(identities, label) {
76
+ for (const identity of identities) {
77
+ const stat = await fsp.lstat(identity.path);
78
+ if (stat.isSymbolicLink()
79
+ || !stat.isDirectory()
80
+ || stat.dev !== identity.dev
81
+ || stat.ino !== identity.ino) {
82
+ throw new Error(`${label} parent path changed identity: ${identity.path}`);
83
+ }
84
+ }
85
+ }
86
+ async function captureRegularDirectoryPath(boundaryValue, directoryValue, label) {
87
+ const boundary = path.resolve(boundaryValue);
88
+ const directory = path.resolve(directoryValue);
89
+ if (boundary === directory) {
90
+ const stat = await fsp.lstat(directory);
91
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
92
+ throw new Error(`${label} must be a regular directory: ${directory}`);
93
+ }
94
+ return [{ path: directory, dev: stat.dev, ino: stat.ino }];
95
+ }
96
+ const identities = await captureRegularParentPath(boundary, directory, label);
97
+ const stat = await fsp.lstat(directory);
98
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
99
+ throw new Error(`${label} must be a regular directory: ${directory}`);
100
+ }
101
+ identities.push({ path: directory, dev: stat.dev, ino: stat.ino });
102
+ return identities;
103
+ }
104
+ function readOnlyNoFollowFlags() {
105
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
106
+ ? fsConstants.O_NOFOLLOW
107
+ : 0;
108
+ return fsConstants.O_RDONLY | noFollow;
109
+ }
110
+ async function confirmWindowsPathForHandle(target, opened, before, label) {
111
+ if (before.birthtimeNs <= 0n) {
112
+ throw new Error(`${label} requires a positive birthtime on Windows: ${target}`);
113
+ }
114
+ let pathHandle;
115
+ try {
116
+ pathHandle = await fsp.open(target, fsConstants.O_RDONLY);
117
+ const pathOpened = await pathHandle.stat({ bigint: true });
118
+ const confirmed = await fsp.lstat(target, { bigint: true });
119
+ if (!opened.isFile()
120
+ || !pathOpened.isFile()
121
+ || pathOpened.dev !== opened.dev
122
+ || pathOpened.ino !== opened.ino
123
+ || confirmed.isSymbolicLink()
124
+ || !confirmed.isFile()
125
+ || confirmed.dev !== before.dev
126
+ || confirmed.ino !== before.ino
127
+ || confirmed.birthtimeNs !== before.birthtimeNs) {
128
+ throw new Error(`${label} changed identity while confirming Windows path ownership: ${target}`);
129
+ }
130
+ return confirmed;
131
+ }
132
+ finally {
133
+ if (pathHandle)
134
+ await pathHandle.close();
135
+ }
136
+ }
137
+ async function captureRegularFile(file, boundary = path.dirname(file)) {
138
+ const parents = await captureRegularParentPath(boundary, file, 'regular file read');
139
+ const before = await fsp.lstat(file, { bigint: true });
140
+ if (!before.isFile() || before.isSymbolicLink())
141
+ throw new Error(`expected a regular file: ${file}`);
142
+ const handle = await fsp.open(file, readOnlyNoFollowFlags());
143
+ try {
144
+ const opened = await handle.stat({ bigint: true });
145
+ if (!opened.isFile())
146
+ throw new Error(`expected a regular file: ${file}`);
147
+ const bytes = await handle.readFile();
148
+ const afterOpened = await handle.stat({ bigint: true });
149
+ const after = process.platform === 'win32'
150
+ ? await confirmWindowsPathForHandle(file, opened, before, 'regular file')
151
+ : await fsp.lstat(file, { bigint: true });
152
+ await revalidateRegularParentPath(parents, 'regular file read');
153
+ if (!after.isFile()
154
+ || after.isSymbolicLink()
155
+ || afterOpened.dev !== opened.dev
156
+ || afterOpened.ino !== opened.ino
157
+ || (process.platform !== 'win32' && (before.dev !== opened.dev
158
+ || before.ino !== opened.ino
159
+ || after.dev !== opened.dev
160
+ || after.ino !== opened.ino))) {
161
+ throw new Error(`regular file changed identity while being captured: ${file}`);
162
+ }
163
+ return { bytes, mode: Number(opened.mode & 511n) };
164
+ }
165
+ finally {
166
+ await handle.close();
167
+ }
168
+ }
169
+ async function readRegularFileBytes(file, boundary = path.dirname(file)) {
170
+ return (await captureRegularFile(file, boundary)).bytes;
171
+ }
172
+ async function copyRegularFile(source, target, sourceBoundary, targetBoundary) {
173
+ const sourceFile = await captureRegularFile(source, sourceBoundary);
174
+ const targetParents = await captureRegularParentPath(targetBoundary, target, 'install copy target');
175
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
176
+ ? fsConstants.O_NOFOLLOW
177
+ : 0;
178
+ const handle = await fsp.open(target, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, sourceFile.mode);
179
+ try {
180
+ await handle.writeFile(sourceFile.bytes);
181
+ const opened = await handle.stat({ bigint: true });
182
+ if (!opened.isFile())
183
+ throw new Error(`install copy target must be a regular file: ${target}`);
184
+ const openedIdentity = { dev: opened.dev, ino: opened.ino };
185
+ const afterOpened = await handle.stat({ bigint: true });
186
+ if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
187
+ throw new Error(`install copy target changed descriptor identity: ${target}`);
188
+ }
189
+ await revalidateRegularParentPath(targetParents, 'install copy target');
190
+ const beforeInstalled = await fsp.lstat(target, { bigint: true });
191
+ const installed = process.platform === 'win32'
192
+ ? await confirmWindowsPathForHandle(target, opened, beforeInstalled, 'install copy target')
193
+ : beforeInstalled;
194
+ if (!installed.isFile()
195
+ || installed.isSymbolicLink()
196
+ || (process.platform !== 'win32' && (installed.dev !== openedIdentity.dev
197
+ || installed.ino !== openedIdentity.ino))) {
198
+ throw new Error(`install copy target changed path identity: ${target}`);
199
+ }
200
+ }
201
+ finally {
202
+ await handle.close();
203
+ }
204
+ }
40
205
  export async function findInstallRecoveryArtifacts(root) {
41
206
  try {
42
207
  const entries = await fsp.readdir(root, { withFileTypes: true });
@@ -51,25 +216,23 @@ export async function findInstallRecoveryArtifacts(root) {
51
216
  throw error;
52
217
  }
53
218
  }
54
- async function digestFile(file, relativePath) {
219
+ async function digestFile(file, relativePath, boundary = path.dirname(file)) {
55
220
  const stat = await fsp.lstat(file);
56
221
  if (!stat.isFile() || stat.isSymbolicLink()) {
57
222
  throw new Error(`install source must be a regular file: ${file}`);
58
223
  }
59
- const contents = await fsp.readFile(file);
224
+ const contents = await readRegularFileBytes(file, boundary);
60
225
  return {
61
226
  path: relativePath.split(path.sep).join('/'),
62
227
  bytes: contents.byteLength,
63
228
  sha256: createHash('sha256').update(contents).digest('hex')
64
229
  };
65
230
  }
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
- }
231
+ async function collectTreeManifest(root, prefix = '', boundary = root) {
232
+ const rootIdentity = await captureRegularDirectoryPath(boundary, root, 'install tree');
71
233
  const files = [];
72
234
  async function walk(current, relative) {
235
+ const directoryIdentity = await captureRegularDirectoryPath(boundary, current, 'install tree');
73
236
  const entries = await fsp.readdir(current, { withFileTypes: true });
74
237
  entries.sort((left, right) => compareStableText(left.name, right.name));
75
238
  for (const entry of entries) {
@@ -79,33 +242,41 @@ async function collectTreeManifest(root, prefix = '') {
79
242
  await walk(absolute, nextRelative);
80
243
  }
81
244
  else if (entry.isFile()) {
82
- files.push(await digestFile(absolute, path.join(prefix, nextRelative)));
245
+ files.push(await digestFile(absolute, path.join(prefix, nextRelative), boundary));
83
246
  }
84
247
  else {
85
248
  throw new Error(`unsupported install tree entry: ${absolute}`);
86
249
  }
87
250
  }
251
+ await revalidateRegularParentPath(directoryIdentity, 'install tree');
88
252
  }
89
253
  await walk(root, '');
254
+ await revalidateRegularParentPath(rootIdentity, 'install tree');
90
255
  return files.sort((left, right) => compareStableText(left.path, right.path));
91
256
  }
92
257
  async function expectedInstallManifest(sourceRoot, skills) {
258
+ const sourceIdentity = await captureRegularDirectoryPath(sourceRoot, sourceRoot, 'install source root');
93
259
  const referencesRoot = path.join(sourceRoot, 'references');
94
- const referenceFiles = await collectTreeManifest(referencesRoot);
260
+ const referenceFiles = await collectTreeManifest(referencesRoot, '', sourceRoot);
95
261
  if (referenceFiles.length === 0)
96
262
  throw new Error(`no reference files found: ${referencesRoot}`);
97
263
  const files = [];
98
264
  for (const skill of skills) {
99
265
  const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
100
- const skillText = await fsp.readFile(skillFile, 'utf8');
101
- const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
102
- const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
266
+ const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
267
+ const skillText = skillBytes.toString('utf8');
268
+ const frontmatter = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
269
+ const name = frontmatter?.[1].match(/^name:\s*([^\r\n]+)\s*$/m)?.[1]
103
270
  ?.trim()
104
271
  .replace(/^["']|["']$/g, '');
105
272
  if (name !== skill) {
106
273
  throw new Error(`skill source frontmatter mismatch for ${skill}: ${name || 'missing'}`);
107
274
  }
108
- files.push(await digestFile(skillFile, path.join(skill, 'SKILL.md')));
275
+ files.push({
276
+ path: path.join(skill, 'SKILL.md').split(path.sep).join('/'),
277
+ bytes: skillBytes.byteLength,
278
+ sha256: createHash('sha256').update(skillBytes).digest('hex')
279
+ });
109
280
  for (const reference of referenceFiles) {
110
281
  files.push({
111
282
  ...reference,
@@ -113,29 +284,107 @@ async function expectedInstallManifest(sourceRoot, skills) {
113
284
  });
114
285
  }
115
286
  }
287
+ await revalidateRegularParentPath(sourceIdentity, 'install source root');
116
288
  return files.sort((left, right) => compareStableText(left.path, right.path));
117
289
  }
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
- }
290
+ async function copyTree(source, target, sourceBoundary = source, targetBoundary = target) {
291
+ const sourceIdentity = await captureRegularDirectoryPath(sourceBoundary, source, 'install source tree');
292
+ const targetParents = await captureRegularParentPath(targetBoundary, target, 'install target tree');
123
293
  await fsp.mkdir(target, { recursive: true });
294
+ const targetIdentity = await captureRegularDirectoryPath(targetBoundary, target, 'install target tree');
295
+ await revalidateRegularParentPath(sourceIdentity, 'install source tree');
296
+ await revalidateRegularParentPath(targetParents, 'install target tree');
124
297
  const entries = await fsp.readdir(source, { withFileTypes: true });
125
298
  entries.sort((left, right) => left.name.localeCompare(right.name));
126
299
  for (const entry of entries) {
127
300
  const from = path.join(source, entry.name);
128
301
  const to = path.join(target, entry.name);
129
302
  if (entry.isDirectory()) {
130
- await copyTree(from, to);
303
+ await copyTree(from, to, sourceBoundary, targetBoundary);
131
304
  }
132
305
  else if (entry.isFile()) {
133
- await fsp.copyFile(from, to);
306
+ await copyRegularFile(from, to, sourceBoundary, targetBoundary);
134
307
  }
135
308
  else {
136
309
  throw new Error(`unsupported install source entry: ${from}`);
137
310
  }
138
311
  }
312
+ await revalidateRegularParentPath(sourceIdentity, 'install source tree');
313
+ await revalidateRegularParentPath(targetIdentity, 'install target tree');
314
+ }
315
+ async function moveRegularDirectory(source, target, sourceBoundary, targetBoundary, label, onMoved, identityOwner) {
316
+ const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
317
+ const sourceStat = await fsp.lstat(source);
318
+ if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
319
+ throw new Error(`${label} source must be a regular directory: ${source}`);
320
+ }
321
+ const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
322
+ if (await exists(target))
323
+ throw new Error(`${label} target unexpectedly exists: ${target}`);
324
+ const recorded = identityOwner ? await captureRecordedEntry(source, 'directory', label) : undefined;
325
+ let identityTransferred = false;
326
+ try {
327
+ await fsp.rename(source, target);
328
+ if (!recorded)
329
+ onMoved?.();
330
+ if (recorded) {
331
+ recorded.path = target;
332
+ identityOwner?.push(recorded);
333
+ identityTransferred = true;
334
+ }
335
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
336
+ await revalidateRegularParentPath(targetParents, `${label} target`);
337
+ const targetStat = await fsp.lstat(target);
338
+ if (!targetStat.isDirectory()
339
+ || targetStat.isSymbolicLink()
340
+ || targetStat.dev !== sourceStat.dev
341
+ || targetStat.ino !== sourceStat.ino) {
342
+ throw new Error(`${label} directory changed identity while being moved: ${target}`);
343
+ }
344
+ return recorded;
345
+ }
346
+ catch (error) {
347
+ if (recorded && !identityTransferred)
348
+ await closeRecordedEntry(recorded);
349
+ throw error;
350
+ }
351
+ }
352
+ async function moveRegularFile(source, target, sourceBoundary, targetBoundary, label, onMoved, identityOwner) {
353
+ const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
354
+ const sourceStat = await fsp.lstat(source);
355
+ if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
356
+ throw new Error(`${label} source must be a regular file: ${source}`);
357
+ }
358
+ const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
359
+ if (await exists(target))
360
+ throw new Error(`${label} target unexpectedly exists: ${target}`);
361
+ const recorded = identityOwner ? await captureRecordedEntry(source, 'file', label) : undefined;
362
+ let identityTransferred = false;
363
+ try {
364
+ await fsp.rename(source, target);
365
+ if (!recorded)
366
+ onMoved?.();
367
+ if (recorded) {
368
+ recorded.path = target;
369
+ identityOwner?.push(recorded);
370
+ identityTransferred = true;
371
+ }
372
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
373
+ await revalidateRegularParentPath(targetParents, `${label} target`);
374
+ const targetStat = await fsp.lstat(target);
375
+ if (!targetStat.isFile()
376
+ || targetStat.isSymbolicLink()
377
+ || targetStat.dev !== sourceStat.dev
378
+ || targetStat.ino !== sourceStat.ino) {
379
+ throw new Error(`${label} file changed identity while being moved: ${target}`);
380
+ }
381
+ return recorded;
382
+ }
383
+ catch (error) {
384
+ if (recorded && !identityTransferred)
385
+ await closeRecordedEntry(recorded);
386
+ throw error;
387
+ }
139
388
  }
140
389
  function manifestDigest(files) {
141
390
  return digestManifestFiles([...files].sort((left, right) => compareStableText(left.path, right.path)));
@@ -282,7 +531,7 @@ async function readOwnershipReceipt(destination, packageName) {
282
531
  if (!stat.isFile() || stat.isSymbolicLink()) {
283
532
  return { exists: true, issues: [`install receipt must be a regular file: ${receiptPath}`] };
284
533
  }
285
- const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
534
+ const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
286
535
  const issues = ownershipReceiptIssues(parsed, packageName, destination);
287
536
  return issues.length
288
537
  ? { exists: true, issues }
@@ -306,7 +555,7 @@ async function verifyReceiptOwnedSkill(root, skill, receipt) {
306
555
  if (!await exists(target))
307
556
  return [`receipt-owned skill is missing: ${skill}`];
308
557
  try {
309
- const actual = await collectTreeManifest(target, skill);
558
+ const actual = await collectTreeManifest(target, skill, root);
310
559
  return manifestIssues(expected, actual, `installed ${skill}`);
311
560
  }
312
561
  catch (error) {
@@ -382,36 +631,11 @@ async function preflightSafeUninstallOwnership(destination, packageName, skills)
382
631
  }
383
632
  return receipt;
384
633
  }
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
634
  async function captureReceiptMutationSnapshot(destination, packageName) {
411
635
  const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
412
636
  let bytes;
413
637
  try {
414
- bytes = await readRegularFileBytes(receiptPath);
638
+ bytes = await readRegularFileBytes(receiptPath, destination);
415
639
  }
416
640
  catch (error) {
417
641
  if (error.code === 'ENOENT')
@@ -440,7 +664,7 @@ async function captureMutationSnapshot(destination, packageName, names, explicit
440
664
  const receipt = await captureReceiptMutationSnapshot(destination, packageName);
441
665
  const trees = [];
442
666
  for (const name of names) {
443
- const files = await collectTreeManifest(path.join(destination, name), name);
667
+ const files = await collectTreeManifest(path.join(destination, name), name, destination);
444
668
  if (!explicitReplacements.includes(name)) {
445
669
  if (!receipt.exists) {
446
670
  throw new Error(`cannot capture receipt-owned skill without an install receipt: ${name}`);
@@ -468,7 +692,7 @@ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receipt
468
692
  continue;
469
693
  }
470
694
  try {
471
- const actual = await collectTreeManifest(movedEntry.backup, tree.name);
695
+ const actual = await collectTreeManifest(movedEntry.backup, tree.name, path.dirname(movedEntry.backup));
472
696
  issues.push(...manifestIssues(tree.files, actual, `moved backup ${tree.name}`));
473
697
  }
474
698
  catch (error) {
@@ -482,7 +706,7 @@ async function verifyMovedMutationSnapshot(destination, snapshot, moved, receipt
482
706
  }
483
707
  else if (snapshot.receipt.exists && receiptBackedUp) {
484
708
  try {
485
- const bytes = await readRegularFileBytes(receiptBackupPath);
709
+ const bytes = await readRegularFileBytes(receiptBackupPath, path.dirname(receiptBackupPath));
486
710
  const sha256 = createHash('sha256').update(bytes).digest('hex');
487
711
  if (sha256 !== snapshot.receipt.sha256 || !bytes.equals(snapshot.receipt.bytes)) {
488
712
  issues.push('moved install receipt differs from the captured bytes');
@@ -556,7 +780,7 @@ export async function inspectSkillInstallation(options) {
556
780
  continue;
557
781
  }
558
782
  try {
559
- const installed = (await collectTreeManifest(target, skill));
783
+ const installed = (await collectTreeManifest(target, skill, destination));
560
784
  const skillIssues = manifestIssues(expectedForSkill, installed, `installed ${skill}`);
561
785
  skillStatuses.push({ skill, status: skillIssues.length ? 'drift' : 'ok', issues: skillIssues });
562
786
  issues.push(...skillIssues);
@@ -574,7 +798,7 @@ export async function inspectSkillInstallation(options) {
574
798
  }
575
799
  else {
576
800
  try {
577
- const parsed = JSON.parse(await fsp.readFile(receiptPath, 'utf8'));
801
+ const parsed = JSON.parse((await readRegularFileBytes(receiptPath, destination)).toString('utf8'));
578
802
  const problems = receiptIssues(parsed, options, destination, expected);
579
803
  if (problems.length) {
580
804
  receiptStatus = 'drift';
@@ -604,6 +828,7 @@ export async function inspectSkillInstallation(options) {
604
828
  }
605
829
  async function acquireInstallLock(destination) {
606
830
  const lockPath = path.join(destination, INSTALL_LOCK_NAME);
831
+ const destinationIdentity = await captureRegularDirectoryPath(destination, destination, 'skill destination');
607
832
  let handle;
608
833
  try {
609
834
  handle = await fsp.open(lockPath, 'wx', 0o600);
@@ -614,25 +839,64 @@ async function acquireInstallLock(destination) {
614
839
  }
615
840
  throw error;
616
841
  }
842
+ let lockIdentity;
617
843
  try {
618
- await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
844
+ const lockContents = JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2);
845
+ await handle.writeFile(lockContents);
846
+ const opened = await handle.stat({ bigint: true });
847
+ if (!opened.isFile())
848
+ throw new Error(`install lock must be a regular file: ${lockPath}`);
849
+ lockIdentity = await captureLockIdentity(lockPath, opened);
850
+ lockIdentity.contentsSha256 = createHash('sha256').update(lockContents).digest('hex');
851
+ await revalidateRegularParentPath(destinationIdentity, 'skill destination');
852
+ const current = await fsp.lstat(lockPath, { bigint: true });
853
+ if (current.isSymbolicLink()
854
+ || !current.isFile()
855
+ || current.dev !== lockIdentity.dev
856
+ || current.ino !== lockIdentity.ino
857
+ || (lockIdentity.birthtimeNs !== undefined && current.birthtimeNs !== lockIdentity.birthtimeNs)) {
858
+ throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
859
+ }
619
860
  }
620
861
  catch (error) {
621
- await handle.close().catch(() => undefined);
622
- await fsp.rm(lockPath, { force: true }).catch(() => undefined);
862
+ const cleanupErrors = [];
863
+ cleanupErrors.push(...await closeFileHandle(handle, lockPath));
864
+ try {
865
+ await revalidateRegularParentPath(destinationIdentity, 'skill destination');
866
+ if (!lockIdentity)
867
+ throw new Error(`install lock identity was not captured: ${lockPath}`);
868
+ await removeRecordedEntry(lockIdentity, 'install lock');
869
+ }
870
+ catch (cleanupError) {
871
+ cleanupErrors.push(message(cleanupError));
872
+ }
873
+ finally {
874
+ const closeErrors = lockIdentity
875
+ ? await closeRecordedEntries([lockIdentity])
876
+ : [];
877
+ cleanupErrors.push(...closeErrors);
878
+ }
879
+ if (cleanupErrors.length) {
880
+ throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
881
+ }
623
882
  throw error;
624
883
  }
625
- return { handle, lockPath };
884
+ return { handle, lockPath, lockIdentity, destinationIdentity };
626
885
  }
627
886
  async function moveExistingEntries(root, names, backupRoot, moved) {
628
887
  await fsp.mkdir(backupRoot, { recursive: true });
888
+ const backupBoundary = path.dirname(backupRoot);
629
889
  for (const name of names) {
630
890
  const original = path.join(root, name);
631
891
  if (!await exists(original))
632
892
  continue;
633
893
  const backup = path.join(backupRoot, name);
634
- await fsp.rename(original, backup);
635
- moved.push({ original, backup });
894
+ await moveRegularDirectory(original, backup, root, backupBoundary, 'managed install backup', () => moved.push({
895
+ original,
896
+ backup,
897
+ originalBoundary: root,
898
+ backupBoundary
899
+ }));
636
900
  }
637
901
  }
638
902
  async function restoreMovedEntries(entries) {
@@ -643,7 +907,7 @@ async function restoreMovedEntries(entries) {
643
907
  throw new Error(`rollback target unexpectedly exists: ${entry.original}`);
644
908
  }
645
909
  await fsp.mkdir(path.dirname(entry.original), { recursive: true });
646
- await fsp.rename(entry.backup, entry.original);
910
+ await moveRegularDirectory(entry.backup, entry.original, entry.backupBoundary, entry.originalBoundary, 'managed install rollback');
647
911
  }
648
912
  catch (error) {
649
913
  errors.push(message(error));
@@ -655,14 +919,349 @@ async function removeInstalledEntries(entries) {
655
919
  const errors = [];
656
920
  for (const entry of [...entries].reverse()) {
657
921
  try {
658
- await fsp.rm(entry, { recursive: true, force: true });
922
+ await removeRecordedEntry(entry, 'rollback removal');
659
923
  }
660
924
  catch (error) {
661
- errors.push(`could not remove new install ${entry}: ${message(error)}`);
925
+ errors.push(`could not remove new install ${entry.path}: ${message(error)}`);
662
926
  }
663
927
  }
664
928
  return errors;
665
929
  }
930
+ async function captureRecordedEntry(target, kind, label, ownershipMarker) {
931
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
932
+ ? fsConstants.O_NOFOLLOW
933
+ : 0;
934
+ const directoryOnly = kind === 'directory'
935
+ && process.platform !== 'win32'
936
+ && typeof fsConstants.O_DIRECTORY === 'number'
937
+ ? fsConstants.O_DIRECTORY
938
+ : 0;
939
+ let handle;
940
+ try {
941
+ if (process.platform !== 'win32') {
942
+ handle = await fsp.open(target, fsConstants.O_RDONLY | noFollow | directoryOnly);
943
+ }
944
+ if (handle)
945
+ return await captureRecordedEntryFromHandle(target, kind, label, handle);
946
+ const stat = await fsp.lstat(target, { bigint: true });
947
+ const typeMatches = kind === 'directory'
948
+ ? stat.isDirectory() && !stat.isSymbolicLink()
949
+ : stat.isFile() && !stat.isSymbolicLink();
950
+ if (!typeMatches || stat.birthtimeNs <= 0n) {
951
+ throw new Error(`${label} must be one stable regular ${kind} with a positive birthtime: ${target}`);
952
+ }
953
+ const entry = {
954
+ path: target,
955
+ dev: stat.dev,
956
+ ino: stat.ino,
957
+ kind,
958
+ birthtimeNs: stat.birthtimeNs,
959
+ ownershipMarker
960
+ };
961
+ if (kind === 'file') {
962
+ const bytes = await readRegularFileBytes(target, path.dirname(target));
963
+ entry.contentsSha256 = createHash('sha256').update(bytes).digest('hex');
964
+ }
965
+ else if (!ownershipMarker) {
966
+ entry.treeDigest = manifestDigest(await collectTreeManifest(target));
967
+ }
968
+ return entry;
969
+ }
970
+ catch (error) {
971
+ if (handle)
972
+ await handle.close().catch(() => { });
973
+ throw error;
974
+ }
975
+ }
976
+ async function captureRecordedEntryFromHandle(target, kind, label, handle) {
977
+ const opened = await handle.stat({ bigint: true });
978
+ const current = await fsp.lstat(target, { bigint: true });
979
+ const typeMatches = kind === 'directory'
980
+ ? opened.isDirectory() && current.isDirectory() && !current.isSymbolicLink()
981
+ : opened.isFile() && current.isFile() && !current.isSymbolicLink();
982
+ const birthtimeAvailable = opened.birthtimeNs > 0n && current.birthtimeNs > 0n;
983
+ if (!typeMatches
984
+ || opened.dev !== current.dev
985
+ || opened.ino !== current.ino
986
+ || (birthtimeAvailable && opened.birthtimeNs !== current.birthtimeNs)) {
987
+ throw new Error(`${label} must be one stable regular ${kind}: ${target}`);
988
+ }
989
+ return {
990
+ path: target,
991
+ dev: opened.dev,
992
+ ino: opened.ino,
993
+ kind,
994
+ birthtimeNs: birthtimeAvailable ? opened.birthtimeNs : undefined,
995
+ handle
996
+ };
997
+ }
998
+ async function captureLockIdentity(target, created) {
999
+ const current = await fsp.lstat(target, { bigint: true });
1000
+ if (!created.isFile()
1001
+ || current.isSymbolicLink()
1002
+ || !current.isFile()
1003
+ || (process.platform !== 'win32' && (created.dev !== current.dev
1004
+ || created.ino !== current.ino))) {
1005
+ throw new Error(`install lock changed identity after exclusive creation: ${target}`);
1006
+ }
1007
+ let pinHandle;
1008
+ try {
1009
+ if (process.platform !== 'win32') {
1010
+ const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
1011
+ pinHandle = await fsp.open(target, fsConstants.O_RDONLY | noFollow);
1012
+ const pinned = await pinHandle.stat({ bigint: true });
1013
+ if (!pinned.isFile()
1014
+ || pinned.dev !== created.dev
1015
+ || pinned.ino !== created.ino) {
1016
+ throw new Error(`install lock pin descriptor does not match exclusive creation: ${target}`);
1017
+ }
1018
+ }
1019
+ else {
1020
+ const confirmed = await confirmWindowsPathForHandle(target, created, current, 'install lock');
1021
+ return {
1022
+ path: target,
1023
+ dev: confirmed.dev,
1024
+ ino: confirmed.ino,
1025
+ kind: 'file',
1026
+ birthtimeNs: confirmed.birthtimeNs
1027
+ };
1028
+ }
1029
+ return {
1030
+ path: target,
1031
+ dev: created.dev,
1032
+ ino: created.ino,
1033
+ kind: 'file',
1034
+ handle: pinHandle
1035
+ };
1036
+ }
1037
+ catch (error) {
1038
+ if (pinHandle)
1039
+ await pinHandle.close().catch(() => { });
1040
+ throw error;
1041
+ }
1042
+ }
1043
+ async function removeRecordedEntry(entry, label) {
1044
+ try {
1045
+ let current;
1046
+ try {
1047
+ current = await fsp.lstat(entry.path, { bigint: true });
1048
+ }
1049
+ catch (error) {
1050
+ if (error.code === 'ENOENT')
1051
+ return;
1052
+ throw error;
1053
+ }
1054
+ const opened = entry.handle ? await entry.handle.stat({ bigint: true }) : undefined;
1055
+ const typeMatches = entry.kind === 'directory'
1056
+ ? current.isDirectory() && !current.isSymbolicLink()
1057
+ : current.isFile() && !current.isSymbolicLink();
1058
+ if (!typeMatches
1059
+ || current.dev !== entry.dev
1060
+ || current.ino !== entry.ino
1061
+ || (entry.birthtimeNs !== undefined && current.birthtimeNs !== entry.birthtimeNs)
1062
+ || (opened && (opened.dev !== current.dev
1063
+ || opened.ino !== current.ino
1064
+ || (entry.birthtimeNs !== undefined && opened.birthtimeNs !== current.birthtimeNs)))) {
1065
+ throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
1066
+ }
1067
+ if (entry.contentsSha256) {
1068
+ try {
1069
+ const bytes = await readRegularFileBytes(entry.path, path.dirname(entry.path));
1070
+ const currentSha256 = createHash('sha256').update(bytes).digest('hex');
1071
+ if (currentSha256 !== entry.contentsSha256)
1072
+ throw new Error('content digest changed');
1073
+ }
1074
+ catch (error) {
1075
+ throw new Error(`${label} identity mismatch (content: ${message(error)}); preserved current path: ${entry.path}`);
1076
+ }
1077
+ }
1078
+ if (entry.treeDigest) {
1079
+ try {
1080
+ const currentTreeDigest = manifestDigest(await collectTreeManifest(entry.path));
1081
+ if (currentTreeDigest !== entry.treeDigest)
1082
+ throw new Error('tree digest changed');
1083
+ }
1084
+ catch (error) {
1085
+ throw new Error(`${label} identity mismatch (tree: ${message(error)}); preserved current path: ${entry.path}`);
1086
+ }
1087
+ }
1088
+ if (entry.ownershipMarker) {
1089
+ try {
1090
+ const markerPath = path.join(entry.path, entry.ownershipMarker.name);
1091
+ const markerBytes = await readRegularFileBytes(markerPath, entry.path);
1092
+ const markerSha256 = createHash('sha256').update(markerBytes).digest('hex');
1093
+ if (markerSha256 !== entry.ownershipMarker.sha256)
1094
+ throw new Error('ownership marker digest changed');
1095
+ }
1096
+ catch (error) {
1097
+ throw new Error(`${label} identity mismatch (ownership marker: ${message(error)}); preserved current path: ${entry.path}`);
1098
+ }
1099
+ }
1100
+ await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
1101
+ }
1102
+ finally {
1103
+ await closeRecordedEntry(entry);
1104
+ }
1105
+ }
1106
+ async function captureTransactionRoot(target, label) {
1107
+ const markerContents = `${randomUUID()}\n`;
1108
+ const markerPath = path.join(target, TRANSACTION_OWNER_MARKER);
1109
+ await fsp.writeFile(markerPath, markerContents, { flag: 'wx', mode: 0o600 });
1110
+ return captureRecordedEntry(target, 'directory', label, {
1111
+ name: TRANSACTION_OWNER_MARKER,
1112
+ sha256: createHash('sha256').update(markerContents).digest('hex')
1113
+ });
1114
+ }
1115
+ async function closeRecordedEntry(entry) {
1116
+ const handle = entry.handle;
1117
+ entry.handle = undefined;
1118
+ if (handle)
1119
+ await handle.close();
1120
+ }
1121
+ async function closeRecordedEntries(entries) {
1122
+ const errors = [];
1123
+ for (const entry of entries) {
1124
+ if (!entry)
1125
+ continue;
1126
+ try {
1127
+ await closeRecordedEntry(entry);
1128
+ }
1129
+ catch (error) {
1130
+ errors.push(`${entry.path}: ${message(error)}`);
1131
+ }
1132
+ }
1133
+ return errors;
1134
+ }
1135
+ async function closeFileHandle(handle, pathLabel) {
1136
+ try {
1137
+ await handle.close();
1138
+ return [];
1139
+ }
1140
+ catch (error) {
1141
+ return [`${pathLabel}: ${message(error)}`];
1142
+ }
1143
+ }
1144
+ export async function planSkillSetInstallation(options) {
1145
+ const sourceRoot = path.resolve(options.sourceRoot);
1146
+ const destination = safeRoot(options.destination, 'skill destination');
1147
+ const codexMirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : '';
1148
+ const skills = unique(options.skills);
1149
+ const cleanupCandidates = unique([...(options.legacySkills || []), ...(options.retiredSkills || [])]);
1150
+ const replaceSkills = unique(options.replaceSkills || []);
1151
+ validateManagedNames([...skills, ...cleanupCandidates, ...replaceSkills]);
1152
+ const expected = await expectedInstallManifest(sourceRoot, skills);
1153
+ const sourceDigest = manifestDigest(expected);
1154
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
1155
+ const operations = [];
1156
+ const blockers = await nonMutatingInstallGateBlockers(destination);
1157
+ let ownership;
1158
+ try {
1159
+ if (blockers.length)
1160
+ throw new Error(blockers[0]);
1161
+ ownership = await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
1162
+ }
1163
+ catch (error) {
1164
+ if (!blockers.length)
1165
+ blockers.push(message(error));
1166
+ }
1167
+ if (ownership) {
1168
+ const replaceNames = new Set(ownership.replaceNames);
1169
+ for (const skill of skills) {
1170
+ const target = path.join(destination, skill);
1171
+ const existing = await exists(target);
1172
+ operations.push({
1173
+ action: !existing
1174
+ ? 'create'
1175
+ : replaceSkills.includes(skill)
1176
+ ? 'replace_explicitly_authorized'
1177
+ : 'replace_managed',
1178
+ skill,
1179
+ path: target,
1180
+ reason: !existing
1181
+ ? 'active skill is not installed'
1182
+ : replaceSkills.includes(skill)
1183
+ ? 'operator supplied an explicit per-skill replacement override'
1184
+ : 'existing skill ownership and complete content integrity match the yam receipt'
1185
+ });
1186
+ }
1187
+ for (const skill of cleanupCandidates) {
1188
+ if (!replaceNames.has(skill) || skills.includes(skill))
1189
+ continue;
1190
+ operations.push({
1191
+ action: 'remove_retired',
1192
+ skill,
1193
+ path: path.join(destination, skill),
1194
+ reason: 'retired or legacy skill is still proven yam-owned by the current receipt'
1195
+ });
1196
+ }
1197
+ operations.push({
1198
+ action: 'write_receipt',
1199
+ skill: '',
1200
+ path: receiptPath,
1201
+ reason: ownership.receipt ? 'replace the verified yam ownership receipt' : 'create the yam ownership receipt'
1202
+ });
1203
+ }
1204
+ const preservedPaths = codexMirror && codexMirror !== destination ? [codexMirror] : [];
1205
+ const replaceNames = new Set(ownership?.replaceNames || []);
1206
+ for (const skill of cleanupCandidates) {
1207
+ if (replaceNames.has(skill) || !await exists(path.join(destination, skill)))
1208
+ continue;
1209
+ preservedPaths.push(path.join(destination, skill));
1210
+ }
1211
+ preservedPaths.sort(compareStableText);
1212
+ const planBasis = {
1213
+ package: { name: options.packageName, version: options.version },
1214
+ source: { root: sourceRoot, digest: sourceDigest, file_count: expected.length },
1215
+ destination,
1216
+ receipt_path: receiptPath,
1217
+ operations,
1218
+ preserved_paths: preservedPaths,
1219
+ blockers
1220
+ };
1221
+ const planDigest = createHash('sha256').update(JSON.stringify(planBasis)).digest('hex');
1222
+ const ready = blockers.length === 0;
1223
+ return {
1224
+ schema: 'yam.install-plan.v1',
1225
+ generated_at: (options.now || (() => new Date()))().toISOString(),
1226
+ mutation_authorized: false,
1227
+ ...planBasis,
1228
+ ready,
1229
+ truth_status: ready ? 'verified' : 'blocked',
1230
+ plan_digest: planDigest,
1231
+ next_action: ready
1232
+ ? 'review this non-mutating plan, then run `yam install` without --dry-run to authorize the transaction'
1233
+ : 'resolve the listed lock, recovery, destination, or ownership blocker without deleting unreviewed state, then regenerate the plan'
1234
+ };
1235
+ }
1236
+ async function nonMutatingInstallGateBlockers(destination) {
1237
+ let stat;
1238
+ try {
1239
+ stat = await fsp.lstat(destination);
1240
+ }
1241
+ catch (error) {
1242
+ if (error.code === 'ENOENT')
1243
+ return [];
1244
+ return [`skill destination cannot be inspected: ${message(error)}`];
1245
+ }
1246
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
1247
+ return [`skill destination must be a regular physical directory: ${destination}`];
1248
+ }
1249
+ const blockers = [];
1250
+ const lockPath = path.join(destination, INSTALL_LOCK_NAME);
1251
+ if (await exists(lockPath)) {
1252
+ blockers.push(`another yam install may be active; lock exists at ${lockPath}`);
1253
+ }
1254
+ try {
1255
+ const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
1256
+ if (recoveryArtifacts.length) {
1257
+ blockers.push(`unfinished yam install transaction requires inspection: ${recoveryArtifacts.join(', ')}`);
1258
+ }
1259
+ }
1260
+ catch (error) {
1261
+ blockers.push(`install recovery state cannot be inspected: ${message(error)}`);
1262
+ }
1263
+ return blockers;
1264
+ }
666
1265
  export async function installSkillSetTransactional(options) {
667
1266
  const sourceRoot = path.resolve(options.sourceRoot);
668
1267
  const destination = safeRoot(options.destination, 'skill destination');
@@ -678,6 +1277,7 @@ export async function installSkillSetTransactional(options) {
678
1277
  let result;
679
1278
  let primaryError;
680
1279
  try {
1280
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
681
1281
  const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
682
1282
  if (recoveryArtifacts.length) {
683
1283
  throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
@@ -687,23 +1287,25 @@ export async function installSkillSetTransactional(options) {
687
1287
  const expected = await expectedInstallManifest(sourceRoot, skills);
688
1288
  const transactionId = options.transactionId || randomUUID();
689
1289
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
1290
+ const transactionEntry = await captureTransactionRoot(transactionRoot, 'install transaction root');
690
1291
  const stagedRoot = path.join(transactionRoot, 'staged');
691
1292
  const backupRoot = path.join(transactionRoot, 'backup');
692
1293
  const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
693
1294
  const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
694
1295
  const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
695
1296
  const installedEntries = [];
1297
+ const receiptInstalledEntries = [];
696
1298
  let destinationBackups = [];
697
1299
  let receiptBackedUp = false;
698
- let receiptInstalled = false;
1300
+ let receiptInstalled;
699
1301
  let mutationStarted = false;
700
1302
  try {
701
1303
  await fsp.mkdir(stagedRoot, { recursive: true });
702
1304
  for (const skill of skills) {
703
1305
  const stagedSkill = path.join(stagedRoot, skill);
704
1306
  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'));
1307
+ await copyRegularFile(path.join(sourceRoot, 'skills', skill, 'SKILL.md'), path.join(stagedSkill, 'SKILL.md'), sourceRoot, stagedRoot);
1308
+ await copyTree(path.join(sourceRoot, 'references'), path.join(stagedSkill, 'references'), sourceRoot, stagedRoot);
707
1309
  }
708
1310
  const stagedManifest = await collectTreeManifest(stagedRoot);
709
1311
  const stagedIssues = manifestIssues(expected, stagedManifest, 'staged install');
@@ -729,24 +1331,31 @@ export async function installSkillSetTransactional(options) {
729
1331
  const mutationOwnership = await preflightInstallOwnership(destination, options.packageName, skills, cleanupCandidates, replaceSkills);
730
1332
  const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, mutationOwnership.replaceNames, replaceSkills);
731
1333
  await options.failpoint?.({ phase: 'before-mutation' });
1334
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
732
1335
  mutationStarted = true;
733
1336
  await moveExistingEntries(destination, mutationOwnership.replaceNames, backupRoot, destinationBackups);
734
1337
  if (await exists(destinationReceiptPath)) {
735
- await fsp.rename(destinationReceiptPath, receiptBackupPath);
736
- receiptBackedUp = true;
1338
+ await moveRegularFile(destinationReceiptPath, receiptBackupPath, destination, transactionRoot, 'install receipt backup', () => { receiptBackedUp = true; });
737
1339
  }
738
1340
  await verifyMovedMutationSnapshot(destination, mutationSnapshot, destinationBackups, receiptBackedUp, receiptBackupPath);
739
1341
  await options.failpoint?.({ phase: 'after-backup' });
1342
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
740
1343
  for (const skill of skills) {
1344
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
741
1345
  const target = path.join(destination, skill);
742
- await fsp.rename(path.join(stagedRoot, skill), target);
743
- installedEntries.push(target);
1346
+ const installedEntry = await moveRegularDirectory(path.join(stagedRoot, skill), target, stagedRoot, destination, 'staged skill install', undefined, installedEntries);
1347
+ if (!installedEntry)
1348
+ throw new Error(`staged skill install identity was not recorded: ${target}`);
744
1349
  await options.failpoint?.({ phase: 'skill-installed', skill });
745
1350
  }
746
- await fsp.rename(stagedReceiptPath, destinationReceiptPath);
747
- receiptInstalled = true;
1351
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1352
+ receiptInstalled = await moveRegularFile(stagedReceiptPath, destinationReceiptPath, transactionRoot, destination, 'staged install receipt', undefined, receiptInstalledEntries);
1353
+ if (!receiptInstalled) {
1354
+ throw new Error(`staged install receipt identity was not recorded: ${destinationReceiptPath}`);
1355
+ }
748
1356
  await options.failpoint?.({ phase: 'receipt-installed' });
749
1357
  await options.failpoint?.({ phase: 'before-verify' });
1358
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
750
1359
  const inspection = await inspectSkillInstallation({
751
1360
  sourceRoot,
752
1361
  destination,
@@ -757,15 +1366,15 @@ export async function installSkillSetTransactional(options) {
757
1366
  });
758
1367
  if (!inspection.ok)
759
1368
  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
- }
1369
+ const ownershipHandleErrors = await closeRecordedEntries([receiptInstalled, ...installedEntries]);
1370
+ if (ownershipHandleErrors.length) {
1371
+ cleanupWarnings.push(`verified install identity handle cleanup failed: ${ownershipHandleErrors.join('; ')}`);
1372
+ }
1373
+ try {
1374
+ await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
1375
+ }
1376
+ catch (error) {
1377
+ cleanupWarnings.push(`verified install retained cleanup artifact ${transactionRoot}: ${message(error)}`);
769
1378
  }
770
1379
  result = {
771
1380
  receipt,
@@ -777,16 +1386,25 @@ export async function installSkillSetTransactional(options) {
777
1386
  }
778
1387
  catch (error) {
779
1388
  const rollbackErrors = [];
780
- if (mutationStarted) {
1389
+ receiptInstalled ||= receiptInstalledEntries[0];
1390
+ let destinationStillOwned = true;
1391
+ try {
1392
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1393
+ }
1394
+ catch (identityError) {
1395
+ destinationStillOwned = false;
1396
+ rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
1397
+ }
1398
+ if (mutationStarted && destinationStillOwned) {
781
1399
  if (receiptInstalled)
782
- rollbackErrors.push(...await removeInstalledEntries([destinationReceiptPath]));
1400
+ rollbackErrors.push(...await removeInstalledEntries([receiptInstalled]));
783
1401
  rollbackErrors.push(...await removeInstalledEntries(installedEntries));
784
1402
  rollbackErrors.push(...await restoreMovedEntries(destinationBackups));
785
1403
  if (receiptBackedUp) {
786
1404
  try {
787
1405
  if (await exists(destinationReceiptPath))
788
1406
  throw new Error(`rollback target unexpectedly exists: ${destinationReceiptPath}`);
789
- await fsp.rename(receiptBackupPath, destinationReceiptPath);
1407
+ await moveRegularFile(receiptBackupPath, destinationReceiptPath, transactionRoot, destination, 'install receipt rollback');
790
1408
  }
791
1409
  catch (receiptError) {
792
1410
  rollbackErrors.push(message(receiptError));
@@ -795,15 +1413,11 @@ export async function installSkillSetTransactional(options) {
795
1413
  }
796
1414
  if (rollbackErrors.length === 0) {
797
1415
  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
- }
1416
+ try {
1417
+ await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
1418
+ }
1419
+ catch (cleanupError) {
1420
+ cleanupErrors.push(`${transactionRoot}: ${message(cleanupError)}`);
807
1421
  }
808
1422
  if (cleanupErrors.length) {
809
1423
  throw new Error(`yam install failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
@@ -811,6 +1425,8 @@ export async function installSkillSetTransactional(options) {
811
1425
  }
812
1426
  throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
813
1427
  }
1428
+ const retainedHandleErrors = await closeRecordedEntries([transactionEntry, receiptInstalled, ...installedEntries]);
1429
+ rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
814
1430
  throw new Error(`yam install failed and rollback is incomplete: ${message(error)}. `
815
1431
  + `Recovery artifacts were kept at ${transactionRoot}. `
816
1432
  + `Rollback errors: ${rollbackErrors.join('; ')}`);
@@ -820,17 +1436,16 @@ export async function installSkillSetTransactional(options) {
820
1436
  primaryError = error;
821
1437
  }
822
1438
  const lockCleanupErrors = [];
1439
+ lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
823
1440
  try {
824
- await lock.handle.close();
1441
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1442
+ await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
825
1443
  }
826
1444
  catch (error) {
827
1445
  lockCleanupErrors.push(message(error));
828
1446
  }
829
- try {
830
- await fsp.rm(lock.lockPath, { force: true });
831
- }
832
- catch (error) {
833
- lockCleanupErrors.push(message(error));
1447
+ finally {
1448
+ lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
834
1449
  }
835
1450
  if (lockCleanupErrors.length) {
836
1451
  const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
@@ -860,6 +1475,7 @@ export async function uninstallSkillSetSafely(options) {
860
1475
  let result;
861
1476
  let primaryError;
862
1477
  try {
1478
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
863
1479
  const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
864
1480
  if (recoveryArtifacts.length) {
865
1481
  throw new Error(`unfinished yam install transaction found: ${recoveryArtifacts.join(', ')}. `
@@ -867,6 +1483,7 @@ export async function uninstallSkillSetSafely(options) {
867
1483
  }
868
1484
  await preflightSafeUninstallOwnership(destination, options.packageName, skills);
869
1485
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
1486
+ const transactionEntry = await captureTransactionRoot(transactionRoot, 'uninstall transaction root');
870
1487
  const backupRoot = path.join(transactionRoot, 'backup');
871
1488
  const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
872
1489
  const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
@@ -877,13 +1494,15 @@ export async function uninstallSkillSetSafely(options) {
877
1494
  await preflightSafeUninstallOwnership(destination, options.packageName, skills);
878
1495
  const mutationSnapshot = await captureMutationSnapshot(destination, options.packageName, skills, []);
879
1496
  await options.failpoint?.({ phase: 'before-mutation' });
1497
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
880
1498
  mutationStarted = true;
881
1499
  await moveExistingEntries(destination, skills, backupRoot, moved);
882
- await fsp.rename(receiptPath, receiptBackup);
883
- receiptMoved = true;
1500
+ await moveRegularFile(receiptPath, receiptBackup, destination, transactionRoot, 'uninstall receipt backup', () => { receiptMoved = true; });
884
1501
  await verifyMovedMutationSnapshot(destination, mutationSnapshot, moved, receiptMoved, receiptBackup);
885
1502
  await options.failpoint?.({ phase: 'after-backup' });
1503
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
886
1504
  await options.failpoint?.({ phase: 'before-verify' });
1505
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
887
1506
  const remaining = [];
888
1507
  for (const skill of skills) {
889
1508
  if (await exists(path.join(destination, skill)))
@@ -895,7 +1514,7 @@ export async function uninstallSkillSetSafely(options) {
895
1514
  throw new Error(`post-uninstall verification found active entries: ${remaining.join(', ')}`);
896
1515
  }
897
1516
  try {
898
- await fsp.rm(transactionRoot, { recursive: true, force: true });
1517
+ await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
899
1518
  }
900
1519
  catch (error) {
901
1520
  cleanupWarnings.push(`verified uninstall retained cleanup artifact ${transactionRoot}: ${message(error)}`);
@@ -908,12 +1527,20 @@ export async function uninstallSkillSetSafely(options) {
908
1527
  }
909
1528
  catch (error) {
910
1529
  const rollbackErrors = [];
911
- if (mutationStarted) {
1530
+ let destinationStillOwned = true;
1531
+ try {
1532
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1533
+ }
1534
+ catch (identityError) {
1535
+ destinationStillOwned = false;
1536
+ rollbackErrors.push(`skill destination identity changed; path-based rollback was not attempted: ${message(identityError)}`);
1537
+ }
1538
+ if (mutationStarted && destinationStillOwned) {
912
1539
  if (receiptMoved) {
913
1540
  try {
914
1541
  if (await exists(receiptPath))
915
1542
  throw new Error(`rollback target unexpectedly exists: ${receiptPath}`);
916
- await fsp.rename(receiptBackup, receiptPath);
1543
+ await moveRegularFile(receiptBackup, receiptPath, transactionRoot, destination, 'uninstall receipt rollback');
917
1544
  }
918
1545
  catch (receiptError) {
919
1546
  rollbackErrors.push(message(receiptError));
@@ -923,7 +1550,7 @@ export async function uninstallSkillSetSafely(options) {
923
1550
  }
924
1551
  if (rollbackErrors.length === 0) {
925
1552
  try {
926
- await fsp.rm(transactionRoot, { recursive: true, force: true });
1553
+ await removeRecordedEntry(transactionEntry, 'uninstall transaction cleanup');
927
1554
  }
928
1555
  catch (cleanupError) {
929
1556
  throw new Error(`yam uninstall failed; previous installation state restored, but transaction cleanup is incomplete: ${message(error)}. `
@@ -931,6 +1558,8 @@ export async function uninstallSkillSetSafely(options) {
931
1558
  }
932
1559
  throw new Error(`yam uninstall failed; previous installation state restored: ${message(error)}`);
933
1560
  }
1561
+ const retainedHandleErrors = await closeRecordedEntries([transactionEntry]);
1562
+ rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
934
1563
  throw new Error(`yam uninstall failed and rollback is incomplete: ${message(error)}. `
935
1564
  + `Recovery artifacts were kept at ${transactionRoot}. Rollback errors: ${rollbackErrors.join('; ')}`);
936
1565
  }
@@ -939,17 +1568,16 @@ export async function uninstallSkillSetSafely(options) {
939
1568
  primaryError = error;
940
1569
  }
941
1570
  const lockCleanupErrors = [];
1571
+ lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
942
1572
  try {
943
- await lock.handle.close();
1573
+ await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1574
+ await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
944
1575
  }
945
1576
  catch (error) {
946
1577
  lockCleanupErrors.push(message(error));
947
1578
  }
948
- try {
949
- await fsp.rm(lock.lockPath, { force: true });
950
- }
951
- catch (error) {
952
- lockCleanupErrors.push(message(error));
1579
+ finally {
1580
+ lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
953
1581
  }
954
1582
  if (lockCleanupErrors.length) {
955
1583
  const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;