yam-flow 2.7.0 → 2.8.1

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 (49) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +41 -0
  3. package/COMMANDS.md +52 -5
  4. package/README.md +40 -8
  5. package/ROADMAP.md +17 -6
  6. package/dist/bin/yam.js +462 -74
  7. package/dist/lib/external-updates.d.ts +9 -0
  8. package/dist/lib/external-updates.js +232 -4
  9. package/dist/lib/next-step.d.ts +61 -0
  10. package/dist/lib/next-step.js +340 -0
  11. package/dist/lib/release-auth-readiness.d.ts +81 -0
  12. package/dist/lib/release-auth-readiness.js +106 -0
  13. package/dist/lib/release-registry-status.d.ts +18 -0
  14. package/dist/lib/release-registry-status.js +37 -0
  15. package/dist/lib/scout-receipt.d.ts +89 -0
  16. package/dist/lib/scout-receipt.js +542 -0
  17. package/dist/lib/security-check.d.ts +98 -0
  18. package/dist/lib/security-check.js +262 -0
  19. package/dist/lib/security-integration.d.ts +8 -0
  20. package/dist/lib/security-integration.js +428 -0
  21. package/dist/lib/skill-installation.d.ts +30 -0
  22. package/dist/lib/skill-installation.js +470 -82
  23. package/dist/lib/trust-kernel.d.ts +2 -0
  24. package/dist/lib/trust-kernel.js +21 -2
  25. package/package.json +26 -4
  26. package/references/final-report.md +17 -1
  27. package/references/next-step.md +67 -0
  28. package/references/scout-wide-scan.md +16 -0
  29. package/references/security-check.md +131 -0
  30. package/references/study-note.md +10 -2
  31. package/references/tool-trust-layer.md +2 -0
  32. package/skills/deep/SKILL.md +6 -0
  33. package/skills/mission/SKILL.md +6 -0
  34. package/skills/quick/SKILL.md +5 -0
  35. package/skills/scout/SKILL.md +19 -9
  36. package/skills/ueye/SKILL.md +5 -0
  37. package/src/bin/yam.ts +465 -72
  38. package/src/lib/external-updates.ts +262 -4
  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/security-check.ts +184 -0
  44. package/src/lib/security-integration.ts +299 -0
  45. package/src/lib/skill-installation.ts +542 -87
  46. package/src/lib/trust-kernel.ts +25 -2
  47. package/templates/security-check-note.md +42 -0
  48. package/templates/security-gate.yml +44 -0
  49. package/yam.manifest.json +4 -3
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { constants as fsConstants } from 'node:fs';
2
+ import { constants as fsConstants, type BigIntStats } from 'node:fs';
3
3
  import fsp from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { compareStableText } from './stable-order.js';
@@ -8,6 +8,7 @@ export const INSTALL_RECEIPT_NAME = '.yam-flow-install-receipt.json';
8
8
  export const INSTALL_LOCK_NAME = '.yam-flow-install.lock';
9
9
  export const INSTALL_RECEIPT_SCHEMA = 'yam.install-receipt.v1';
10
10
  export const INSTALL_TRANSACTION_PREFIX = '.yam-flow-install-';
11
+ const TRANSACTION_OWNER_MARKER = '.yam-flow-transaction-owner';
11
12
 
12
13
  export type InstallFileDigest = {
13
14
  path: string;
@@ -97,6 +98,37 @@ export type TransactionalInstallResult = {
97
98
  cleanupWarnings: string[];
98
99
  };
99
100
 
101
+ export type SkillInstallPlanOperation = {
102
+ action: 'create' | 'replace_managed' | 'replace_explicitly_authorized' | 'remove_retired' | 'write_receipt';
103
+ skill: string;
104
+ path: string;
105
+ reason: string;
106
+ };
107
+
108
+ export type SkillInstallPlan = {
109
+ schema: 'yam.install-plan.v1';
110
+ generated_at: string;
111
+ mutation_authorized: false;
112
+ package: {
113
+ name: string;
114
+ version: string;
115
+ };
116
+ source: {
117
+ root: string;
118
+ digest: string;
119
+ file_count: number;
120
+ };
121
+ destination: string;
122
+ receipt_path: string;
123
+ operations: SkillInstallPlanOperation[];
124
+ preserved_paths: string[];
125
+ blockers: string[];
126
+ ready: boolean;
127
+ truth_status: 'verified' | 'blocked';
128
+ plan_digest: string;
129
+ next_action: string;
130
+ };
131
+
100
132
  export type SafeUninstallOptions = {
101
133
  destination: string;
102
134
  codexMirror?: string;
@@ -152,8 +184,19 @@ type PathIdentity = {
152
184
  ino: number;
153
185
  };
154
186
 
155
- type InstalledEntry = PathIdentity & {
187
+ type InstalledEntry = {
188
+ path: string;
189
+ dev: bigint;
190
+ ino: bigint;
156
191
  kind: 'directory' | 'file';
192
+ birthtimeNs?: bigint;
193
+ handle?: fsp.FileHandle;
194
+ contentsSha256?: string;
195
+ treeDigest?: string;
196
+ ownershipMarker?: {
197
+ name: string;
198
+ sha256: string;
199
+ };
157
200
  };
158
201
 
159
202
  function message(error: unknown) {
@@ -267,31 +310,68 @@ function readOnlyNoFollowFlags() {
267
310
  return fsConstants.O_RDONLY | noFollow;
268
311
  }
269
312
 
313
+ async function confirmWindowsPathForHandle(
314
+ target: string,
315
+ opened: BigIntStats,
316
+ before: BigIntStats,
317
+ label: string
318
+ ) {
319
+ if (before.birthtimeNs <= 0n) {
320
+ throw new Error(`${label} requires a positive birthtime on Windows: ${target}`);
321
+ }
322
+ let pathHandle: fsp.FileHandle | undefined;
323
+ try {
324
+ pathHandle = await fsp.open(target, fsConstants.O_RDONLY);
325
+ const pathOpened = await pathHandle.stat({ bigint: true });
326
+ const confirmed = await fsp.lstat(target, { bigint: true });
327
+ if (
328
+ !opened.isFile()
329
+ || !pathOpened.isFile()
330
+ || pathOpened.dev !== opened.dev
331
+ || pathOpened.ino !== opened.ino
332
+ || confirmed.isSymbolicLink()
333
+ || !confirmed.isFile()
334
+ || confirmed.dev !== before.dev
335
+ || confirmed.ino !== before.ino
336
+ || confirmed.birthtimeNs !== before.birthtimeNs
337
+ ) {
338
+ throw new Error(`${label} changed identity while confirming Windows path ownership: ${target}`);
339
+ }
340
+ return confirmed;
341
+ } finally {
342
+ if (pathHandle) await pathHandle.close();
343
+ }
344
+ }
345
+
270
346
  async function captureRegularFile(file: string, boundary = path.dirname(file)) {
271
347
  const parents = await captureRegularParentPath(boundary, file, 'regular file read');
272
- const before = await fsp.lstat(file);
348
+ const before = await fsp.lstat(file, { bigint: true });
273
349
  if (!before.isFile() || before.isSymbolicLink()) throw new Error(`expected a regular file: ${file}`);
274
350
  const handle = await fsp.open(file, readOnlyNoFollowFlags());
275
351
  try {
276
- const opened = await handle.stat();
352
+ const opened = await handle.stat({ bigint: true });
277
353
  if (!opened.isFile()) throw new Error(`expected a regular file: ${file}`);
278
354
  const bytes = await handle.readFile();
279
- const afterOpened = await handle.stat();
280
- const after = await fsp.lstat(file);
355
+ const afterOpened = await handle.stat({ bigint: true });
356
+ const after = process.platform === 'win32'
357
+ ? await confirmWindowsPathForHandle(file, opened, before, 'regular file')
358
+ : await fsp.lstat(file, { bigint: true });
281
359
  await revalidateRegularParentPath(parents, 'regular file read');
282
360
  if (
283
361
  !after.isFile()
284
362
  || after.isSymbolicLink()
285
- || before.dev !== opened.dev
286
- || before.ino !== opened.ino
287
363
  || afterOpened.dev !== opened.dev
288
364
  || afterOpened.ino !== opened.ino
289
- || after.dev !== opened.dev
290
- || after.ino !== opened.ino
365
+ || (process.platform !== 'win32' && (
366
+ before.dev !== opened.dev
367
+ || before.ino !== opened.ino
368
+ || after.dev !== opened.dev
369
+ || after.ino !== opened.ino
370
+ ))
291
371
  ) {
292
372
  throw new Error(`regular file changed identity while being captured: ${file}`);
293
373
  }
294
- return { bytes, mode: opened.mode & 0o777 };
374
+ return { bytes, mode: Number(opened.mode & 0o777n) };
295
375
  } finally {
296
376
  await handle.close();
297
377
  }
@@ -319,20 +399,25 @@ async function copyRegularFile(
319
399
  );
320
400
  try {
321
401
  await handle.writeFile(sourceFile.bytes);
322
- const opened = await handle.stat();
402
+ const opened = await handle.stat({ bigint: true });
323
403
  if (!opened.isFile()) throw new Error(`install copy target must be a regular file: ${target}`);
324
404
  const openedIdentity = { dev: opened.dev, ino: opened.ino };
325
- const afterOpened = await handle.stat();
405
+ const afterOpened = await handle.stat({ bigint: true });
326
406
  if (afterOpened.dev !== openedIdentity.dev || afterOpened.ino !== openedIdentity.ino) {
327
407
  throw new Error(`install copy target changed descriptor identity: ${target}`);
328
408
  }
329
409
  await revalidateRegularParentPath(targetParents, 'install copy target');
330
- const installed = await fsp.lstat(target);
410
+ const beforeInstalled = await fsp.lstat(target, { bigint: true });
411
+ const installed = process.platform === 'win32'
412
+ ? await confirmWindowsPathForHandle(target, opened, beforeInstalled, 'install copy target')
413
+ : beforeInstalled;
331
414
  if (
332
415
  !installed.isFile()
333
416
  || installed.isSymbolicLink()
334
- || installed.dev !== openedIdentity.dev
335
- || installed.ino !== openedIdentity.ino
417
+ || (process.platform !== 'win32' && (
418
+ installed.dev !== openedIdentity.dev
419
+ || installed.ino !== openedIdentity.ino
420
+ ))
336
421
  ) {
337
422
  throw new Error(`install copy target changed path identity: ${target}`);
338
423
  }
@@ -413,8 +498,8 @@ async function expectedInstallManifest(sourceRoot: string, skills: string[]) {
413
498
  const skillFile = path.join(sourceRoot, 'skills', skill, 'SKILL.md');
414
499
  const skillBytes = await readRegularFileBytes(skillFile, sourceRoot);
415
500
  const skillText = skillBytes.toString('utf8');
416
- const frontmatter = skillText.match(/^---\n([\s\S]*?)\n---/);
417
- const name = frontmatter?.[1].match(/^name:\s*([^\n]+)\s*$/m)?.[1]
501
+ const frontmatter = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
502
+ const name = frontmatter?.[1].match(/^name:\s*([^\r\n]+)\s*$/m)?.[1]
418
503
  ?.trim()
419
504
  .replace(/^["']|["']$/g, '');
420
505
  if (name !== skill) {
@@ -471,8 +556,9 @@ async function moveRegularDirectory(
471
556
  sourceBoundary: string,
472
557
  targetBoundary: string,
473
558
  label: string,
474
- onMoved?: (entry: InstalledEntry) => void
475
- ) {
559
+ onMoved?: () => void,
560
+ identityOwner?: InstalledEntry[]
561
+ ): Promise<InstalledEntry | undefined> {
476
562
  const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
477
563
  const sourceStat = await fsp.lstat(source);
478
564
  if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
@@ -480,18 +566,31 @@ async function moveRegularDirectory(
480
566
  }
481
567
  const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
482
568
  if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
483
- await fsp.rename(source, target);
484
- onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'directory' });
485
- await revalidateRegularParentPath(sourceParents, `${label} source`);
486
- await revalidateRegularParentPath(targetParents, `${label} target`);
487
- const targetStat = await fsp.lstat(target);
488
- if (
489
- !targetStat.isDirectory()
490
- || targetStat.isSymbolicLink()
491
- || targetStat.dev !== sourceStat.dev
492
- || targetStat.ino !== sourceStat.ino
493
- ) {
494
- throw new Error(`${label} directory changed identity while being moved: ${target}`);
569
+ const recorded = identityOwner ? await captureRecordedEntry(source, 'directory', label) : undefined;
570
+ let identityTransferred = false;
571
+ try {
572
+ await fsp.rename(source, target);
573
+ if (!recorded) onMoved?.();
574
+ if (recorded) {
575
+ recorded.path = target;
576
+ identityOwner?.push(recorded);
577
+ identityTransferred = true;
578
+ }
579
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
580
+ await revalidateRegularParentPath(targetParents, `${label} target`);
581
+ const targetStat = await fsp.lstat(target);
582
+ if (
583
+ !targetStat.isDirectory()
584
+ || targetStat.isSymbolicLink()
585
+ || targetStat.dev !== sourceStat.dev
586
+ || targetStat.ino !== sourceStat.ino
587
+ ) {
588
+ throw new Error(`${label} directory changed identity while being moved: ${target}`);
589
+ }
590
+ return recorded;
591
+ } catch (error) {
592
+ if (recorded && !identityTransferred) await closeRecordedEntry(recorded);
593
+ throw error;
495
594
  }
496
595
  }
497
596
 
@@ -501,8 +600,9 @@ async function moveRegularFile(
501
600
  sourceBoundary: string,
502
601
  targetBoundary: string,
503
602
  label: string,
504
- onMoved?: (entry: InstalledEntry) => void
505
- ) {
603
+ onMoved?: () => void,
604
+ identityOwner?: InstalledEntry[]
605
+ ): Promise<InstalledEntry | undefined> {
506
606
  const sourceParents = await captureRegularParentPath(sourceBoundary, source, `${label} source`);
507
607
  const sourceStat = await fsp.lstat(source);
508
608
  if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
@@ -510,18 +610,31 @@ async function moveRegularFile(
510
610
  }
511
611
  const targetParents = await captureRegularParentPath(targetBoundary, target, `${label} target`);
512
612
  if (await exists(target)) throw new Error(`${label} target unexpectedly exists: ${target}`);
513
- await fsp.rename(source, target);
514
- onMoved?.({ path: target, dev: sourceStat.dev, ino: sourceStat.ino, kind: 'file' });
515
- await revalidateRegularParentPath(sourceParents, `${label} source`);
516
- await revalidateRegularParentPath(targetParents, `${label} target`);
517
- const targetStat = await fsp.lstat(target);
518
- if (
519
- !targetStat.isFile()
520
- || targetStat.isSymbolicLink()
521
- || targetStat.dev !== sourceStat.dev
522
- || targetStat.ino !== sourceStat.ino
523
- ) {
524
- throw new Error(`${label} file changed identity while being moved: ${target}`);
613
+ const recorded = identityOwner ? await captureRecordedEntry(source, 'file', label) : undefined;
614
+ let identityTransferred = false;
615
+ try {
616
+ await fsp.rename(source, target);
617
+ if (!recorded) onMoved?.();
618
+ if (recorded) {
619
+ recorded.path = target;
620
+ identityOwner?.push(recorded);
621
+ identityTransferred = true;
622
+ }
623
+ await revalidateRegularParentPath(sourceParents, `${label} source`);
624
+ await revalidateRegularParentPath(targetParents, `${label} target`);
625
+ const targetStat = await fsp.lstat(target);
626
+ if (
627
+ !targetStat.isFile()
628
+ || targetStat.isSymbolicLink()
629
+ || targetStat.dev !== sourceStat.dev
630
+ || targetStat.ino !== sourceStat.ino
631
+ ) {
632
+ throw new Error(`${label} file changed identity while being moved: ${target}`);
633
+ }
634
+ return recorded;
635
+ } catch (error) {
636
+ if (recorded && !identityTransferred) await closeRecordedEntry(recorded);
637
+ throw error;
525
638
  }
526
639
  }
527
640
 
@@ -1024,33 +1137,37 @@ async function acquireInstallLock(destination: string) {
1024
1137
  }
1025
1138
  let lockIdentity: InstalledEntry | undefined;
1026
1139
  try {
1027
- const opened = await handle.stat();
1140
+ const lockContents = JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2);
1141
+ await handle.writeFile(lockContents);
1142
+ const opened = await handle.stat({ bigint: true });
1028
1143
  if (!opened.isFile()) throw new Error(`install lock must be a regular file: ${lockPath}`);
1029
- lockIdentity = { path: lockPath, dev: opened.dev, ino: opened.ino, kind: 'file' };
1030
- await handle.writeFile(JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }, null, 2));
1144
+ lockIdentity = await captureLockIdentity(lockPath, opened);
1145
+ lockIdentity.contentsSha256 = createHash('sha256').update(lockContents).digest('hex');
1031
1146
  await revalidateRegularParentPath(destinationIdentity, 'skill destination');
1032
- const current = await fsp.lstat(lockPath);
1147
+ const current = await fsp.lstat(lockPath, { bigint: true });
1033
1148
  if (
1034
1149
  current.isSymbolicLink()
1035
1150
  || !current.isFile()
1036
1151
  || current.dev !== lockIdentity.dev
1037
1152
  || current.ino !== lockIdentity.ino
1153
+ || (lockIdentity.birthtimeNs !== undefined && current.birthtimeNs !== lockIdentity.birthtimeNs)
1038
1154
  ) {
1039
1155
  throw new Error(`install lock changed identity while being acquired: ${lockPath}`);
1040
1156
  }
1041
1157
  } catch (error) {
1042
1158
  const cleanupErrors: string[] = [];
1043
- try {
1044
- await handle.close();
1045
- } catch (closeError) {
1046
- cleanupErrors.push(message(closeError));
1047
- }
1159
+ cleanupErrors.push(...await closeFileHandle(handle, lockPath));
1048
1160
  try {
1049
1161
  await revalidateRegularParentPath(destinationIdentity, 'skill destination');
1050
1162
  if (!lockIdentity) throw new Error(`install lock identity was not captured: ${lockPath}`);
1051
1163
  await removeRecordedEntry(lockIdentity, 'install lock');
1052
1164
  } catch (cleanupError) {
1053
1165
  cleanupErrors.push(message(cleanupError));
1166
+ } finally {
1167
+ const closeErrors = lockIdentity
1168
+ ? await closeRecordedEntries([lockIdentity])
1169
+ : [];
1170
+ cleanupErrors.push(...closeErrors);
1054
1171
  }
1055
1172
  if (cleanupErrors.length) {
1056
1173
  throw new Error(`${message(error)}. Install lock cleanup failed: ${cleanupErrors.join('; ')}`);
@@ -1117,30 +1234,355 @@ async function removeInstalledEntries(entries: InstalledEntry[]) {
1117
1234
  return errors;
1118
1235
  }
1119
1236
 
1120
- async function captureRecordedEntry(target: string, kind: InstalledEntry['kind'], label: string): Promise<InstalledEntry> {
1121
- const stat = await fsp.lstat(target);
1237
+ async function captureRecordedEntry(
1238
+ target: string,
1239
+ kind: InstalledEntry['kind'],
1240
+ label: string,
1241
+ ownershipMarker?: InstalledEntry['ownershipMarker']
1242
+ ): Promise<InstalledEntry> {
1243
+ const noFollow = process.platform !== 'win32' && typeof fsConstants.O_NOFOLLOW === 'number'
1244
+ ? fsConstants.O_NOFOLLOW
1245
+ : 0;
1246
+ const directoryOnly = kind === 'directory'
1247
+ && process.platform !== 'win32'
1248
+ && typeof fsConstants.O_DIRECTORY === 'number'
1249
+ ? fsConstants.O_DIRECTORY
1250
+ : 0;
1251
+ let handle: fsp.FileHandle | undefined;
1252
+ try {
1253
+ if (process.platform !== 'win32') {
1254
+ handle = await fsp.open(target, fsConstants.O_RDONLY | noFollow | directoryOnly);
1255
+ }
1256
+ if (handle) return await captureRecordedEntryFromHandle(target, kind, label, handle);
1257
+ const stat = await fsp.lstat(target, { bigint: true });
1258
+ const typeMatches = kind === 'directory'
1259
+ ? stat.isDirectory() && !stat.isSymbolicLink()
1260
+ : stat.isFile() && !stat.isSymbolicLink();
1261
+ if (!typeMatches || stat.birthtimeNs <= 0n) {
1262
+ throw new Error(`${label} must be one stable regular ${kind} with a positive birthtime: ${target}`);
1263
+ }
1264
+ const entry: InstalledEntry = {
1265
+ path: target,
1266
+ dev: stat.dev,
1267
+ ino: stat.ino,
1268
+ kind,
1269
+ birthtimeNs: stat.birthtimeNs,
1270
+ ownershipMarker
1271
+ };
1272
+ if (kind === 'file') {
1273
+ const bytes = await readRegularFileBytes(target, path.dirname(target));
1274
+ entry.contentsSha256 = createHash('sha256').update(bytes).digest('hex');
1275
+ } else if (!ownershipMarker) {
1276
+ entry.treeDigest = manifestDigest(await collectTreeManifest(target));
1277
+ }
1278
+ return entry;
1279
+ } catch (error) {
1280
+ if (handle) await handle.close().catch(() => {});
1281
+ throw error;
1282
+ }
1283
+ }
1284
+
1285
+ async function captureRecordedEntryFromHandle(
1286
+ target: string,
1287
+ kind: InstalledEntry['kind'],
1288
+ label: string,
1289
+ handle: fsp.FileHandle
1290
+ ): Promise<InstalledEntry> {
1291
+ const opened = await handle.stat({ bigint: true });
1292
+ const current = await fsp.lstat(target, { bigint: true });
1122
1293
  const typeMatches = kind === 'directory'
1123
- ? stat.isDirectory() && !stat.isSymbolicLink()
1124
- : stat.isFile() && !stat.isSymbolicLink();
1125
- if (!typeMatches) throw new Error(`${label} must be a regular ${kind}: ${target}`);
1126
- return { path: target, dev: stat.dev, ino: stat.ino, kind };
1294
+ ? opened.isDirectory() && current.isDirectory() && !current.isSymbolicLink()
1295
+ : opened.isFile() && current.isFile() && !current.isSymbolicLink();
1296
+ const birthtimeAvailable = opened.birthtimeNs > 0n && current.birthtimeNs > 0n;
1297
+ if (
1298
+ !typeMatches
1299
+ || opened.dev !== current.dev
1300
+ || opened.ino !== current.ino
1301
+ || (birthtimeAvailable && opened.birthtimeNs !== current.birthtimeNs)
1302
+ ) {
1303
+ throw new Error(`${label} must be one stable regular ${kind}: ${target}`);
1304
+ }
1305
+ return {
1306
+ path: target,
1307
+ dev: opened.dev,
1308
+ ino: opened.ino,
1309
+ kind,
1310
+ birthtimeNs: birthtimeAvailable ? opened.birthtimeNs : undefined,
1311
+ handle
1312
+ };
1127
1313
  }
1128
1314
 
1129
- async function removeRecordedEntry(entry: InstalledEntry, label: string) {
1130
- let current;
1315
+ async function captureLockIdentity(target: string, created: BigIntStats): Promise<InstalledEntry> {
1316
+ const current = await fsp.lstat(target, { bigint: true });
1317
+ if (
1318
+ !created.isFile()
1319
+ || current.isSymbolicLink()
1320
+ || !current.isFile()
1321
+ || (process.platform !== 'win32' && (
1322
+ created.dev !== current.dev
1323
+ || created.ino !== current.ino
1324
+ ))
1325
+ ) {
1326
+ throw new Error(`install lock changed identity after exclusive creation: ${target}`);
1327
+ }
1328
+
1329
+ let pinHandle: fsp.FileHandle | undefined;
1131
1330
  try {
1132
- current = await fsp.lstat(entry.path);
1331
+ if (process.platform !== 'win32') {
1332
+ const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
1333
+ pinHandle = await fsp.open(target, fsConstants.O_RDONLY | noFollow);
1334
+ const pinned = await pinHandle.stat({ bigint: true });
1335
+ if (
1336
+ !pinned.isFile()
1337
+ || pinned.dev !== created.dev
1338
+ || pinned.ino !== created.ino
1339
+ ) {
1340
+ throw new Error(`install lock pin descriptor does not match exclusive creation: ${target}`);
1341
+ }
1342
+ } else {
1343
+ const confirmed = await confirmWindowsPathForHandle(target, created, current, 'install lock');
1344
+ return {
1345
+ path: target,
1346
+ dev: confirmed.dev,
1347
+ ino: confirmed.ino,
1348
+ kind: 'file',
1349
+ birthtimeNs: confirmed.birthtimeNs
1350
+ };
1351
+ }
1352
+ return {
1353
+ path: target,
1354
+ dev: created.dev,
1355
+ ino: created.ino,
1356
+ kind: 'file',
1357
+ handle: pinHandle
1358
+ };
1133
1359
  } catch (error) {
1134
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
1360
+ if (pinHandle) await pinHandle.close().catch(() => {});
1135
1361
  throw error;
1136
1362
  }
1137
- const typeMatches = entry.kind === 'directory'
1138
- ? current.isDirectory() && !current.isSymbolicLink()
1139
- : current.isFile() && !current.isSymbolicLink();
1140
- if (!typeMatches || current.dev !== entry.dev || current.ino !== entry.ino) {
1141
- throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
1363
+ }
1364
+
1365
+ async function removeRecordedEntry(entry: InstalledEntry, label: string) {
1366
+ try {
1367
+ let current;
1368
+ try {
1369
+ current = await fsp.lstat(entry.path, { bigint: true });
1370
+ } catch (error) {
1371
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
1372
+ throw error;
1373
+ }
1374
+ const opened = entry.handle ? await entry.handle.stat({ bigint: true }) : undefined;
1375
+ const typeMatches = entry.kind === 'directory'
1376
+ ? current.isDirectory() && !current.isSymbolicLink()
1377
+ : current.isFile() && !current.isSymbolicLink();
1378
+ if (
1379
+ !typeMatches
1380
+ || current.dev !== entry.dev
1381
+ || current.ino !== entry.ino
1382
+ || (entry.birthtimeNs !== undefined && current.birthtimeNs !== entry.birthtimeNs)
1383
+ || (opened && (
1384
+ opened.dev !== current.dev
1385
+ || opened.ino !== current.ino
1386
+ || (entry.birthtimeNs !== undefined && opened.birthtimeNs !== current.birthtimeNs)
1387
+ ))
1388
+ ) {
1389
+ throw new Error(`${label} identity mismatch; preserved current path: ${entry.path}`);
1390
+ }
1391
+ if (entry.contentsSha256) {
1392
+ try {
1393
+ const bytes = await readRegularFileBytes(entry.path, path.dirname(entry.path));
1394
+ const currentSha256 = createHash('sha256').update(bytes).digest('hex');
1395
+ if (currentSha256 !== entry.contentsSha256) throw new Error('content digest changed');
1396
+ } catch (error) {
1397
+ throw new Error(`${label} identity mismatch (content: ${message(error)}); preserved current path: ${entry.path}`);
1398
+ }
1399
+ }
1400
+ if (entry.treeDigest) {
1401
+ try {
1402
+ const currentTreeDigest = manifestDigest(await collectTreeManifest(entry.path));
1403
+ if (currentTreeDigest !== entry.treeDigest) throw new Error('tree digest changed');
1404
+ } catch (error) {
1405
+ throw new Error(`${label} identity mismatch (tree: ${message(error)}); preserved current path: ${entry.path}`);
1406
+ }
1407
+ }
1408
+ if (entry.ownershipMarker) {
1409
+ try {
1410
+ const markerPath = path.join(entry.path, entry.ownershipMarker.name);
1411
+ const markerBytes = await readRegularFileBytes(markerPath, entry.path);
1412
+ const markerSha256 = createHash('sha256').update(markerBytes).digest('hex');
1413
+ if (markerSha256 !== entry.ownershipMarker.sha256) throw new Error('ownership marker digest changed');
1414
+ } catch (error) {
1415
+ throw new Error(`${label} identity mismatch (ownership marker: ${message(error)}); preserved current path: ${entry.path}`);
1416
+ }
1417
+ }
1418
+ await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
1419
+ } finally {
1420
+ await closeRecordedEntry(entry);
1421
+ }
1422
+ }
1423
+
1424
+ async function captureTransactionRoot(target: string, label: string) {
1425
+ const markerContents = `${randomUUID()}\n`;
1426
+ const markerPath = path.join(target, TRANSACTION_OWNER_MARKER);
1427
+ await fsp.writeFile(markerPath, markerContents, { flag: 'wx', mode: 0o600 });
1428
+ return captureRecordedEntry(target, 'directory', label, {
1429
+ name: TRANSACTION_OWNER_MARKER,
1430
+ sha256: createHash('sha256').update(markerContents).digest('hex')
1431
+ });
1432
+ }
1433
+
1434
+ async function closeRecordedEntry(entry: InstalledEntry) {
1435
+ const handle = entry.handle;
1436
+ entry.handle = undefined;
1437
+ if (handle) await handle.close();
1438
+ }
1439
+
1440
+ async function closeRecordedEntries(entries: Array<InstalledEntry | undefined>) {
1441
+ const errors: string[] = [];
1442
+ for (const entry of entries) {
1443
+ if (!entry) continue;
1444
+ try {
1445
+ await closeRecordedEntry(entry);
1446
+ } catch (error) {
1447
+ errors.push(`${entry.path}: ${message(error)}`);
1448
+ }
1449
+ }
1450
+ return errors;
1451
+ }
1452
+
1453
+ async function closeFileHandle(handle: fsp.FileHandle, pathLabel: string) {
1454
+ try {
1455
+ await handle.close();
1456
+ return [];
1457
+ } catch (error) {
1458
+ return [`${pathLabel}: ${message(error)}`];
1459
+ }
1460
+ }
1461
+
1462
+ export async function planSkillSetInstallation(options: TransactionalInstallOptions): Promise<SkillInstallPlan> {
1463
+ const sourceRoot = path.resolve(options.sourceRoot);
1464
+ const destination = safeRoot(options.destination, 'skill destination');
1465
+ const codexMirror = options.codexMirror ? safeRoot(options.codexMirror, 'Codex mirror') : '';
1466
+ const skills = unique(options.skills);
1467
+ const cleanupCandidates = unique([...(options.legacySkills || []), ...(options.retiredSkills || [])]);
1468
+ const replaceSkills = unique(options.replaceSkills || []);
1469
+ validateManagedNames([...skills, ...cleanupCandidates, ...replaceSkills]);
1470
+
1471
+ const expected = await expectedInstallManifest(sourceRoot, skills);
1472
+ const sourceDigest = manifestDigest(expected);
1473
+ const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
1474
+ const operations: SkillInstallPlanOperation[] = [];
1475
+ const blockers = await nonMutatingInstallGateBlockers(destination);
1476
+
1477
+ let ownership: OwnershipPreflight | undefined;
1478
+ try {
1479
+ if (blockers.length) throw new Error(blockers[0]);
1480
+ ownership = await preflightInstallOwnership(
1481
+ destination,
1482
+ options.packageName,
1483
+ skills,
1484
+ cleanupCandidates,
1485
+ replaceSkills
1486
+ );
1487
+ } catch (error) {
1488
+ if (!blockers.length) blockers.push(message(error));
1489
+ }
1490
+
1491
+ if (ownership) {
1492
+ const replaceNames = new Set(ownership.replaceNames);
1493
+ for (const skill of skills) {
1494
+ const target = path.join(destination, skill);
1495
+ const existing = await exists(target);
1496
+ operations.push({
1497
+ action: !existing
1498
+ ? 'create'
1499
+ : replaceSkills.includes(skill)
1500
+ ? 'replace_explicitly_authorized'
1501
+ : 'replace_managed',
1502
+ skill,
1503
+ path: target,
1504
+ reason: !existing
1505
+ ? 'active skill is not installed'
1506
+ : replaceSkills.includes(skill)
1507
+ ? 'operator supplied an explicit per-skill replacement override'
1508
+ : 'existing skill ownership and complete content integrity match the yam receipt'
1509
+ });
1510
+ }
1511
+ for (const skill of cleanupCandidates) {
1512
+ if (!replaceNames.has(skill) || skills.includes(skill)) continue;
1513
+ operations.push({
1514
+ action: 'remove_retired',
1515
+ skill,
1516
+ path: path.join(destination, skill),
1517
+ reason: 'retired or legacy skill is still proven yam-owned by the current receipt'
1518
+ });
1519
+ }
1520
+ operations.push({
1521
+ action: 'write_receipt',
1522
+ skill: '',
1523
+ path: receiptPath,
1524
+ reason: ownership.receipt ? 'replace the verified yam ownership receipt' : 'create the yam ownership receipt'
1525
+ });
1526
+ }
1527
+
1528
+ const preservedPaths = codexMirror && codexMirror !== destination ? [codexMirror] : [];
1529
+ const replaceNames = new Set(ownership?.replaceNames || []);
1530
+ for (const skill of cleanupCandidates) {
1531
+ if (replaceNames.has(skill) || !await exists(path.join(destination, skill))) continue;
1532
+ preservedPaths.push(path.join(destination, skill));
1142
1533
  }
1143
- await fsp.rm(entry.path, { recursive: entry.kind === 'directory', force: entry.kind === 'file' });
1534
+ preservedPaths.sort(compareStableText);
1535
+ const planBasis = {
1536
+ package: { name: options.packageName, version: options.version },
1537
+ source: { root: sourceRoot, digest: sourceDigest, file_count: expected.length },
1538
+ destination,
1539
+ receipt_path: receiptPath,
1540
+ operations,
1541
+ preserved_paths: preservedPaths,
1542
+ blockers
1543
+ };
1544
+ const planDigest = createHash('sha256').update(JSON.stringify(planBasis)).digest('hex');
1545
+ const ready = blockers.length === 0;
1546
+ return {
1547
+ schema: 'yam.install-plan.v1',
1548
+ generated_at: (options.now || (() => new Date()))().toISOString(),
1549
+ mutation_authorized: false,
1550
+ ...planBasis,
1551
+ ready,
1552
+ truth_status: ready ? 'verified' : 'blocked',
1553
+ plan_digest: planDigest,
1554
+ next_action: ready
1555
+ ? 'review this non-mutating plan, then run `yam install` without --dry-run to authorize the transaction'
1556
+ : 'resolve the listed lock, recovery, destination, or ownership blocker without deleting unreviewed state, then regenerate the plan'
1557
+ };
1558
+ }
1559
+
1560
+ async function nonMutatingInstallGateBlockers(destination: string) {
1561
+ let stat;
1562
+ try {
1563
+ stat = await fsp.lstat(destination);
1564
+ } catch (error) {
1565
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
1566
+ return [`skill destination cannot be inspected: ${message(error)}`];
1567
+ }
1568
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
1569
+ return [`skill destination must be a regular physical directory: ${destination}`];
1570
+ }
1571
+
1572
+ const blockers: string[] = [];
1573
+ const lockPath = path.join(destination, INSTALL_LOCK_NAME);
1574
+ if (await exists(lockPath)) {
1575
+ blockers.push(`another yam install may be active; lock exists at ${lockPath}`);
1576
+ }
1577
+ try {
1578
+ const recoveryArtifacts = await findInstallRecoveryArtifacts(destination);
1579
+ if (recoveryArtifacts.length) {
1580
+ blockers.push(`unfinished yam install transaction requires inspection: ${recoveryArtifacts.join(', ')}`);
1581
+ }
1582
+ } catch (error) {
1583
+ blockers.push(`install recovery state cannot be inspected: ${message(error)}`);
1584
+ }
1585
+ return blockers;
1144
1586
  }
1145
1587
 
1146
1588
  export async function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult> {
@@ -1176,13 +1618,14 @@ export async function installSkillSetTransactional(options: TransactionalInstall
1176
1618
  const expected = await expectedInstallManifest(sourceRoot, skills);
1177
1619
  const transactionId = options.transactionId || randomUUID();
1178
1620
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-'));
1179
- const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'install transaction root');
1621
+ const transactionEntry = await captureTransactionRoot(transactionRoot, 'install transaction root');
1180
1622
  const stagedRoot = path.join(transactionRoot, 'staged');
1181
1623
  const backupRoot = path.join(transactionRoot, 'backup');
1182
1624
  const stagedReceiptPath = path.join(transactionRoot, 'receipt.json');
1183
1625
  const destinationReceiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
1184
1626
  const receiptBackupPath = path.join(transactionRoot, 'receipt.backup');
1185
1627
  const installedEntries: InstalledEntry[] = [];
1628
+ const receiptInstalledEntries: InstalledEntry[] = [];
1186
1629
  let destinationBackups: MovedEntry[] = [];
1187
1630
  let receiptBackedUp = false;
1188
1631
  let receiptInstalled: InstalledEntry | undefined;
@@ -1268,25 +1711,31 @@ export async function installSkillSetTransactional(options: TransactionalInstall
1268
1711
  for (const skill of skills) {
1269
1712
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1270
1713
  const target = path.join(destination, skill);
1271
- await moveRegularDirectory(
1714
+ const installedEntry = await moveRegularDirectory(
1272
1715
  path.join(stagedRoot, skill),
1273
1716
  target,
1274
1717
  stagedRoot,
1275
1718
  destination,
1276
1719
  'staged skill install',
1277
- (entry) => installedEntries.push(entry)
1720
+ undefined,
1721
+ installedEntries
1278
1722
  );
1723
+ if (!installedEntry) throw new Error(`staged skill install identity was not recorded: ${target}`);
1279
1724
  await options.failpoint?.({ phase: 'skill-installed', skill });
1280
1725
  }
1281
1726
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1282
- await moveRegularFile(
1727
+ receiptInstalled = await moveRegularFile(
1283
1728
  stagedReceiptPath,
1284
1729
  destinationReceiptPath,
1285
1730
  transactionRoot,
1286
1731
  destination,
1287
1732
  'staged install receipt',
1288
- (entry) => { receiptInstalled = entry; }
1733
+ undefined,
1734
+ receiptInstalledEntries
1289
1735
  );
1736
+ if (!receiptInstalled) {
1737
+ throw new Error(`staged install receipt identity was not recorded: ${destinationReceiptPath}`);
1738
+ }
1290
1739
  await options.failpoint?.({ phase: 'receipt-installed' });
1291
1740
 
1292
1741
  await options.failpoint?.({ phase: 'before-verify' });
@@ -1301,6 +1750,11 @@ export async function installSkillSetTransactional(options: TransactionalInstall
1301
1750
  });
1302
1751
  if (!inspection.ok) throw new Error(`post-install verification failed: ${inspection.issues.join('; ')}`);
1303
1752
 
1753
+ const ownershipHandleErrors = await closeRecordedEntries([receiptInstalled, ...installedEntries]);
1754
+ if (ownershipHandleErrors.length) {
1755
+ cleanupWarnings.push(`verified install identity handle cleanup failed: ${ownershipHandleErrors.join('; ')}`);
1756
+ }
1757
+
1304
1758
  try {
1305
1759
  await removeRecordedEntry(transactionEntry, 'install transaction cleanup');
1306
1760
  } catch (error) {
@@ -1315,6 +1769,7 @@ export async function installSkillSetTransactional(options: TransactionalInstall
1315
1769
  };
1316
1770
  } catch (error) {
1317
1771
  const rollbackErrors: string[] = [];
1772
+ receiptInstalled ||= receiptInstalledEntries[0];
1318
1773
  let destinationStillOwned = true;
1319
1774
  try {
1320
1775
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
@@ -1357,6 +1812,8 @@ export async function installSkillSetTransactional(options: TransactionalInstall
1357
1812
  }
1358
1813
  throw new Error(`yam install failed; previous installation state restored: ${message(error)}`);
1359
1814
  }
1815
+ const retainedHandleErrors = await closeRecordedEntries([transactionEntry, receiptInstalled, ...installedEntries]);
1816
+ rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
1360
1817
  throw new Error(
1361
1818
  `yam install failed and rollback is incomplete: ${message(error)}. `
1362
1819
  + `Recovery artifacts were kept at ${transactionRoot}. `
@@ -1368,16 +1825,14 @@ export async function installSkillSetTransactional(options: TransactionalInstall
1368
1825
  }
1369
1826
 
1370
1827
  const lockCleanupErrors: string[] = [];
1371
- try {
1372
- await lock.handle.close();
1373
- } catch (error) {
1374
- lockCleanupErrors.push(message(error));
1375
- }
1828
+ lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
1376
1829
  try {
1377
1830
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1378
1831
  await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
1379
1832
  } catch (error) {
1380
1833
  lockCleanupErrors.push(message(error));
1834
+ } finally {
1835
+ lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
1381
1836
  }
1382
1837
  if (lockCleanupErrors.length) {
1383
1838
  const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;
@@ -1416,7 +1871,7 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1416
1871
  await preflightSafeUninstallOwnership(destination, options.packageName, skills);
1417
1872
 
1418
1873
  const transactionRoot = await fsp.mkdtemp(path.join(destination, '.yam-flow-install-uninstall-'));
1419
- const transactionEntry = await captureRecordedEntry(transactionRoot, 'directory', 'uninstall transaction root');
1874
+ const transactionEntry = await captureTransactionRoot(transactionRoot, 'uninstall transaction root');
1420
1875
  const backupRoot = path.join(transactionRoot, 'backup');
1421
1876
  const receiptPath = path.join(destination, INSTALL_RECEIPT_NAME);
1422
1877
  const receiptBackup = path.join(transactionRoot, INSTALL_RECEIPT_NAME);
@@ -1511,6 +1966,8 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1511
1966
  }
1512
1967
  throw new Error(`yam uninstall failed; previous installation state restored: ${message(error)}`);
1513
1968
  }
1969
+ const retainedHandleErrors = await closeRecordedEntries([transactionEntry]);
1970
+ rollbackErrors.push(...retainedHandleErrors.map((item) => `identity handle cleanup failed: ${item}`));
1514
1971
  throw new Error(
1515
1972
  `yam uninstall failed and rollback is incomplete: ${message(error)}. `
1516
1973
  + `Recovery artifacts were kept at ${transactionRoot}. Rollback errors: ${rollbackErrors.join('; ')}`
@@ -1521,16 +1978,14 @@ export async function uninstallSkillSetSafely(options: SafeUninstallOptions): Pr
1521
1978
  }
1522
1979
 
1523
1980
  const lockCleanupErrors: string[] = [];
1524
- try {
1525
- await lock.handle.close();
1526
- } catch (error) {
1527
- lockCleanupErrors.push(message(error));
1528
- }
1981
+ lockCleanupErrors.push(...await closeFileHandle(lock.handle, lock.lockPath));
1529
1982
  try {
1530
1983
  await revalidateRegularParentPath(lock.destinationIdentity, 'skill destination');
1531
1984
  await removeRecordedEntry(lock.lockIdentity, 'install lock cleanup');
1532
1985
  } catch (error) {
1533
1986
  lockCleanupErrors.push(message(error));
1987
+ } finally {
1988
+ lockCleanupErrors.push(...await closeRecordedEntries([lock.lockIdentity]));
1534
1989
  }
1535
1990
  if (lockCleanupErrors.length) {
1536
1991
  const warning = `install lock cleanup failed at ${lock.lockPath}: ${lockCleanupErrors.join('; ')}`;