@skillsmith/core 0.12.3 → 0.12.5

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 (188) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +2 -2
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/src/config/owned-lock.acquire.d.ts +8 -0
  5. package/dist/src/config/owned-lock.acquire.d.ts.map +1 -1
  6. package/dist/src/config/owned-lock.acquire.js +19 -4
  7. package/dist/src/config/owned-lock.acquire.js.map +1 -1
  8. package/dist/src/config/owned-lock.claim.d.ts +4 -2
  9. package/dist/src/config/owned-lock.claim.d.ts.map +1 -1
  10. package/dist/src/config/owned-lock.claim.js +42 -8
  11. package/dist/src/config/owned-lock.claim.js.map +1 -1
  12. package/dist/src/config/owned-lock.d.ts.map +1 -1
  13. package/dist/src/config/owned-lock.js +1 -0
  14. package/dist/src/config/owned-lock.js.map +1 -1
  15. package/dist/src/config/owned-lock.test.js +126 -2
  16. package/dist/src/config/owned-lock.test.js.map +1 -1
  17. package/dist/src/config/owned-lock.types.d.ts +9 -0
  18. package/dist/src/config/owned-lock.types.d.ts.map +1 -1
  19. package/dist/src/db/createDatabase.d.ts.map +1 -1
  20. package/dist/src/db/createDatabase.js +0 -2
  21. package/dist/src/db/createDatabase.js.map +1 -1
  22. package/dist/src/exports/services.install.d.ts +1 -0
  23. package/dist/src/exports/services.install.d.ts.map +1 -1
  24. package/dist/src/exports/services.install.js +5 -0
  25. package/dist/src/exports/services.install.js.map +1 -1
  26. package/dist/src/index.d.ts +1 -1
  27. package/dist/src/index.js +1 -1
  28. package/dist/src/install/fan-out.cleanup.d.ts +37 -0
  29. package/dist/src/install/fan-out.cleanup.d.ts.map +1 -0
  30. package/dist/src/install/fan-out.cleanup.js +75 -0
  31. package/dist/src/install/fan-out.cleanup.js.map +1 -0
  32. package/dist/src/install/fan-out.d.ts +41 -31
  33. package/dist/src/install/fan-out.d.ts.map +1 -1
  34. package/dist/src/install/fan-out.js +213 -112
  35. package/dist/src/install/fan-out.js.map +1 -1
  36. package/dist/src/install/fan-out.leftovers.d.ts +60 -0
  37. package/dist/src/install/fan-out.leftovers.d.ts.map +1 -0
  38. package/dist/src/install/fan-out.leftovers.js +251 -0
  39. package/dist/src/install/fan-out.leftovers.js.map +1 -0
  40. package/dist/src/install/fan-out.locks.d.ts +12 -0
  41. package/dist/src/install/fan-out.locks.d.ts.map +1 -0
  42. package/dist/src/install/fan-out.locks.js +114 -0
  43. package/dist/src/install/fan-out.locks.js.map +1 -0
  44. package/dist/src/install/fan-out.manifest.d.ts +64 -0
  45. package/dist/src/install/fan-out.manifest.d.ts.map +1 -0
  46. package/dist/src/install/fan-out.manifest.js +130 -0
  47. package/dist/src/install/fan-out.manifest.js.map +1 -0
  48. package/dist/src/install/fan-out.overwrite.d.ts +51 -0
  49. package/dist/src/install/fan-out.overwrite.d.ts.map +1 -0
  50. package/dist/src/install/fan-out.overwrite.js +343 -0
  51. package/dist/src/install/fan-out.overwrite.js.map +1 -0
  52. package/dist/src/install/remove-if-same.d.ts +58 -0
  53. package/dist/src/install/remove-if-same.d.ts.map +1 -0
  54. package/dist/src/install/remove-if-same.js +222 -0
  55. package/dist/src/install/remove-if-same.js.map +1 -0
  56. package/dist/src/provenance/backfill.d.ts.map +1 -1
  57. package/dist/src/provenance/backfill.js +15 -2
  58. package/dist/src/provenance/backfill.js.map +1 -1
  59. package/dist/src/security/scanner/SecurityScanner.scanners.d.ts.map +1 -1
  60. package/dist/src/security/scanner/SecurityScanner.scanners.js +8 -1
  61. package/dist/src/security/scanner/SecurityScanner.scanners.js.map +1 -1
  62. package/dist/src/security/scanner/SecurityScanner.value-gate.d.ts +22 -2
  63. package/dist/src/security/scanner/SecurityScanner.value-gate.d.ts.map +1 -1
  64. package/dist/src/security/scanner/SecurityScanner.value-gate.js +143 -6
  65. package/dist/src/security/scanner/SecurityScanner.value-gate.js.map +1 -1
  66. package/dist/src/security/scanner/SecurityScanner.weak-passwords.d.ts +2 -2
  67. package/dist/src/security/scanner/SecurityScanner.weak-passwords.d.ts.map +1 -1
  68. package/dist/src/security/scanner/SecurityScanner.weak-passwords.js +185 -190
  69. package/dist/src/security/scanner/SecurityScanner.weak-passwords.js.map +1 -1
  70. package/dist/src/security/scanner/patterns.d.ts +47 -2
  71. package/dist/src/security/scanner/patterns.d.ts.map +1 -1
  72. package/dist/src/security/scanner/patterns.js +48 -3
  73. package/dist/src/security/scanner/patterns.js.map +1 -1
  74. package/dist/src/security/scanner/patterns.sensitive-path.d.ts +31 -3
  75. package/dist/src/security/scanner/patterns.sensitive-path.d.ts.map +1 -1
  76. package/dist/src/security/scanner/patterns.sensitive-path.js +59 -4
  77. package/dist/src/security/scanner/patterns.sensitive-path.js.map +1 -1
  78. package/dist/src/services/agent-pack/prompt-source.d.ts.map +1 -1
  79. package/dist/src/services/agent-pack/prompt-source.js +3 -2
  80. package/dist/src/services/agent-pack/prompt-source.js.map +1 -1
  81. package/dist/src/services/skill-installation.content.d.ts +7 -2
  82. package/dist/src/services/skill-installation.content.d.ts.map +1 -1
  83. package/dist/src/services/skill-installation.content.js +75 -28
  84. package/dist/src/services/skill-installation.content.js.map +1 -1
  85. package/dist/src/services/skill-installation.helpers.d.ts.map +1 -1
  86. package/dist/src/services/skill-installation.helpers.js +8 -0
  87. package/dist/src/services/skill-installation.helpers.js.map +1 -1
  88. package/dist/src/services/skill-installation.io.d.ts +1 -29
  89. package/dist/src/services/skill-installation.io.d.ts.map +1 -1
  90. package/dist/src/services/skill-installation.io.dirs.d.ts +15 -0
  91. package/dist/src/services/skill-installation.io.dirs.d.ts.map +1 -0
  92. package/dist/src/services/skill-installation.io.dirs.js +84 -0
  93. package/dist/src/services/skill-installation.io.dirs.js.map +1 -0
  94. package/dist/src/services/skill-installation.io.js +6 -176
  95. package/dist/src/services/skill-installation.io.js.map +1 -1
  96. package/dist/src/services/skill-installation.io.rollback.d.ts +138 -0
  97. package/dist/src/services/skill-installation.io.rollback.d.ts.map +1 -0
  98. package/dist/src/services/skill-installation.io.rollback.js +218 -0
  99. package/dist/src/services/skill-installation.io.rollback.js.map +1 -0
  100. package/dist/src/services/skill-installation.io.write.d.ts +69 -0
  101. package/dist/src/services/skill-installation.io.write.d.ts.map +1 -0
  102. package/dist/src/services/skill-installation.io.write.js +417 -0
  103. package/dist/src/services/skill-installation.io.write.js.map +1 -0
  104. package/dist/src/services/skill-installation.service.d.ts.map +1 -1
  105. package/dist/src/services/skill-installation.service.finalize.d.ts +62 -0
  106. package/dist/src/services/skill-installation.service.finalize.d.ts.map +1 -0
  107. package/dist/src/services/skill-installation.service.finalize.js +136 -0
  108. package/dist/src/services/skill-installation.service.finalize.js.map +1 -0
  109. package/dist/src/services/skill-installation.service.js +66 -80
  110. package/dist/src/services/skill-installation.service.js.map +1 -1
  111. package/dist/src/services/skill-installation.target-guard.d.ts +82 -0
  112. package/dist/src/services/skill-installation.target-guard.d.ts.map +1 -0
  113. package/dist/src/services/skill-installation.target-guard.js +410 -0
  114. package/dist/src/services/skill-installation.target-guard.js.map +1 -0
  115. package/dist/src/services/skill-installation.types.d.ts +13 -1
  116. package/dist/src/services/skill-installation.types.d.ts.map +1 -1
  117. package/dist/src/services/skill-installation.types.js.map +1 -1
  118. package/dist/src/services/skill-installation.uninstall.d.ts +3 -1
  119. package/dist/src/services/skill-installation.uninstall.d.ts.map +1 -1
  120. package/dist/src/services/skill-installation.uninstall.helpers.d.ts +59 -0
  121. package/dist/src/services/skill-installation.uninstall.helpers.d.ts.map +1 -0
  122. package/dist/src/services/skill-installation.uninstall.helpers.js +124 -0
  123. package/dist/src/services/skill-installation.uninstall.helpers.js.map +1 -0
  124. package/dist/src/services/skill-installation.uninstall.js +148 -25
  125. package/dist/src/services/skill-installation.uninstall.js.map +1 -1
  126. package/dist/src/utils/safe-fs.d.ts +69 -0
  127. package/dist/src/utils/safe-fs.d.ts.map +1 -1
  128. package/dist/src/utils/safe-fs.js +113 -2
  129. package/dist/src/utils/safe-fs.js.map +1 -1
  130. package/dist/tests/install/fan-out.test.js +1951 -13
  131. package/dist/tests/install/fan-out.test.js.map +1 -1
  132. package/dist/tests/install/remove-if-same.test.d.ts +2 -0
  133. package/dist/tests/install/remove-if-same.test.d.ts.map +1 -0
  134. package/dist/tests/install/remove-if-same.test.js +298 -0
  135. package/dist/tests/install/remove-if-same.test.js.map +1 -0
  136. package/dist/tests/integration/fan-out-lock-contention.test.d.ts +11 -0
  137. package/dist/tests/integration/fan-out-lock-contention.test.d.ts.map +1 -0
  138. package/dist/tests/integration/fan-out-lock-contention.test.js +53 -0
  139. package/dist/tests/integration/fan-out-lock-contention.test.js.map +1 -0
  140. package/dist/tests/provenance/backfill.test.js +43 -0
  141. package/dist/tests/provenance/backfill.test.js.map +1 -1
  142. package/dist/tests/security/scanner-regression-guard.test.js +33 -5
  143. package/dist/tests/security/scanner-regression-guard.test.js.map +1 -1
  144. package/dist/tests/security/sensitive-path-adversarial-review.test.js +120 -0
  145. package/dist/tests/security/sensitive-path-adversarial-review.test.js.map +1 -1
  146. package/dist/tests/security/sensitive-path-boolean-flag.test.d.ts +2 -0
  147. package/dist/tests/security/sensitive-path-boolean-flag.test.d.ts.map +1 -0
  148. package/dist/tests/security/sensitive-path-boolean-flag.test.js +151 -0
  149. package/dist/tests/security/sensitive-path-boolean-flag.test.js.map +1 -0
  150. package/dist/tests/security/sensitive-path-fp.test.js +71 -5
  151. package/dist/tests/security/sensitive-path-fp.test.js.map +1 -1
  152. package/dist/tests/security/sensitive-path-prefixed-secrets.test.d.ts +2 -0
  153. package/dist/tests/security/sensitive-path-prefixed-secrets.test.d.ts.map +1 -0
  154. package/dist/tests/security/sensitive-path-prefixed-secrets.test.js +176 -0
  155. package/dist/tests/security/sensitive-path-prefixed-secrets.test.js.map +1 -0
  156. package/dist/tests/security/weak-password-lexicon.test.js +14 -4
  157. package/dist/tests/security/weak-password-lexicon.test.js.map +1 -1
  158. package/dist/tests/unit/services/skill-installation.content.test.js +23 -0
  159. package/dist/tests/unit/services/skill-installation.content.test.js.map +1 -1
  160. package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.js +35 -14
  161. package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.js.map +1 -1
  162. package/dist/tests/unit/services/skill-installation.io.rollback.test.d.ts +2 -0
  163. package/dist/tests/unit/services/skill-installation.io.rollback.test.d.ts.map +1 -0
  164. package/dist/tests/unit/services/skill-installation.io.rollback.test.js +984 -0
  165. package/dist/tests/unit/services/skill-installation.io.rollback.test.js.map +1 -0
  166. package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.d.ts +2 -0
  167. package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.d.ts.map +1 -0
  168. package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.js +148 -0
  169. package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.js.map +1 -0
  170. package/dist/tests/unit/services/skill-installation.service.error-codes.test.js +19 -0
  171. package/dist/tests/unit/services/skill-installation.service.error-codes.test.js.map +1 -1
  172. package/dist/tests/unit/services/skill-installation.service.finalize.test.d.ts +2 -0
  173. package/dist/tests/unit/services/skill-installation.service.finalize.test.d.ts.map +1 -0
  174. package/dist/tests/unit/services/skill-installation.service.finalize.test.js +147 -0
  175. package/dist/tests/unit/services/skill-installation.service.finalize.test.js.map +1 -0
  176. package/dist/tests/unit/services/skill-installation.service.test.js +43 -0
  177. package/dist/tests/unit/services/skill-installation.service.test.js.map +1 -1
  178. package/dist/tests/unit/services/skill-installation.target-guard.test.d.ts +2 -0
  179. package/dist/tests/unit/services/skill-installation.target-guard.test.d.ts.map +1 -0
  180. package/dist/tests/unit/services/skill-installation.target-guard.test.js +590 -0
  181. package/dist/tests/unit/services/skill-installation.target-guard.test.js.map +1 -0
  182. package/dist/tests/unit/services/skill-installation.uninstall.guard.test.d.ts +2 -0
  183. package/dist/tests/unit/services/skill-installation.uninstall.guard.test.d.ts.map +1 -0
  184. package/dist/tests/unit/services/skill-installation.uninstall.guard.test.js +473 -0
  185. package/dist/tests/unit/services/skill-installation.uninstall.guard.test.js.map +1 -0
  186. package/dist/tests/utils/safe-fs.test.js +129 -2
  187. package/dist/tests/utils/safe-fs.test.js.map +1 -1
  188. package/package.json +1 -1
@@ -0,0 +1,984 @@
1
+ /**
2
+ * @fileoverview writeInstallFiles() rollback-on-failure safety.
3
+ * @see SMI-6529 Wave A0
4
+ *
5
+ * On a real user machine, a rollback in `writeInstallFiles` unconditionally
6
+ * unlinked every file it had written on failure — including files that
7
+ * ALREADY EXISTED before the install started (a real user's git-cloned
8
+ * skill directory, `.git` included) — and, when the whole install path
9
+ * pre-existed, could recursively force-delete that directory outright.
10
+ * These tests prove the fix: a pre-existing file is restored to its
11
+ * original bytes, and a pre-existing directory is never removed.
12
+ */
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import * as fs from 'fs/promises';
15
+ import * as os from 'os';
16
+ import * as path from 'path';
17
+ // F1 (review round 1): `safeWriteFile` opens with O_TRUNC and writes in place —
18
+ // NOT atomic. A write that fails mid-way (ENOSPC/EIO after truncation) must
19
+ // leave a snapshot the rollback can restore from. `truncateThenThrowTarget` lets
20
+ // one specific test target ONE specific file path with this exact failure mode.
21
+ //
22
+ // F3 (review round 1): `delayTarget` lets one specific test delay ONE specific
23
+ // file's write by N ms before delegating to the real implementation, so a
24
+ // sibling's immediate failure and this write's completion can be raced
25
+ // deterministically.
26
+ //
27
+ // Every other call (every other test in this file) passes through to the real
28
+ // implementation unchanged — neither hook fires unless a test explicitly sets it.
29
+ const truncateThenThrowTarget = vi.hoisted(() => ({ path: null }));
30
+ const delayTarget = vi.hoisted(() => ({ path: null, ms: 0 }));
31
+ // SMI-6529 N2 (round 4): fails EVERY call to safeWriteFile for this exact
32
+ // path once (`armed`), then behaves normally — lets a test force the FIRST
33
+ // queued write for a shared key to fail outright (simulating an
34
+ // ExclusiveCreateRaceError / snapshot EACCES) while a second queued write
35
+ // (chained after via `previous.catch(() => {})`) still runs for real.
36
+ const failOnceTarget = vi.hoisted(() => ({ path: null, armed: false }));
37
+ // SMI-6529 N3 (round 4): when active, simulates an fd wrapper that reports a
38
+ // successful write while only actually committing `data.length - 1` bytes to
39
+ // disk — proves restoreSnapshots()'s own post-write size verification (not
40
+ // just writeFullBuffer's retry loop) catches an incomplete restore.
41
+ const shortRestoreTarget = vi.hoisted(() => ({ active: false }));
42
+ // SMI-6529 N9 (round 4): one-shot — the NEXT call to safeCreateFile for this
43
+ // exact path genuinely exclusive-creates the file (real O_EXCL open, real
44
+ // onCreated() fire) then throws BEFORE writing any content byte, simulating
45
+ // a write that fails immediately after the create succeeds (e.g. ENOSPC on
46
+ // the first write() call).
47
+ const createFailAfterOpenTarget = vi.hoisted(() => ({ path: null }));
48
+ // SMI-6529 N8 (round 4): one-shot — the NEXT non-recursive `fs.mkdir(p)` call
49
+ // for this exact path (io.write.ts's own final-segment installPath mkdir)
50
+ // simulates a concurrent installer WINNING the race: it really creates the
51
+ // directory, then throws EEXIST, matching the exact race io.write.ts's own
52
+ // `preExisted = true` flip must survive without rollback ever touching it.
53
+ const mkdirRaceTarget = vi.hoisted(() => ({ path: null, armed: false }));
54
+ // SMI-6529 R5 (round 5): one-shot — the NEXT call to safeCreateFile for this
55
+ // exact path really creates the file and reports its (dev, ino), then
56
+ // "another process" renames its own file over it and the write throws. The
57
+ // rollback must leave that other process's file alone.
58
+ const replaceAfterCreateTarget = vi.hoisted(() => ({ path: null }));
59
+ // SMI-6529 round 13 (cross-model review): one-shot — the NEXT safeCreateFile
60
+ // for this path first has "another process" move the fresh install directory
61
+ // away and put its own directory there, then the write throws. Rollback must
62
+ // leave that directory alone.
63
+ const swapInstallDirTarget = vi.hoisted(() => ({
64
+ path: null,
65
+ installPath: null,
66
+ }));
67
+ // SMI-6529 round 13: one-shot — the NEXT fs.unlink of this path fails with
68
+ // EACCES, so rollback can't remove a file it created.
69
+ const unlinkFailTarget = vi.hoisted(() => ({ path: null }));
70
+ // SMI-6529 round 27: one-shot — the NEXT fs.lstat of this path fails with
71
+ // EACCES. Used for the companion-agent directory check, which recorded ANY
72
+ // failure as "this call created it" and so licensed rollback to rmdir it.
73
+ const lstatFailForDir = vi.hoisted(() => ({ path: null }));
74
+ // SMI-6529 round 25 (cross-model review): one-shot — the NEXT fs.lstat of this
75
+ // path fails with EACCES, so rollback cannot tell whether the file it created
76
+ // is still the one at that path. It is armed by `armLstatFailOnWrite` rather
77
+ // than set directly, because `safeCreateFile` lstats a path before creating it
78
+ // and would otherwise consume the one-shot during the write, long before the
79
+ // rollback this exists to test.
80
+ const lstatFailTarget = vi.hoisted(() => ({ path: null }));
81
+ // Arms `lstatFailTarget` when `trigger`'s write begins — the write that then
82
+ // fails for real, so the only lstat left to fire on is rollback's own.
83
+ const armLstatFailOnWrite = vi.hoisted(() => ({
84
+ trigger: null,
85
+ target: null,
86
+ }));
87
+ vi.mock('fs/promises', async (importOriginal) => {
88
+ const actual = await importOriginal();
89
+ return {
90
+ ...actual,
91
+ mkdir: async (p, opts) => {
92
+ const isNonRecursive = !(opts &&
93
+ typeof opts === 'object' &&
94
+ opts.recursive);
95
+ if (p === mkdirRaceTarget.path && mkdirRaceTarget.armed && isNonRecursive) {
96
+ mkdirRaceTarget.armed = false;
97
+ // The "concurrent installer" really creates it, right now.
98
+ await actual.mkdir(p, { recursive: true });
99
+ const err = new Error(`EEXIST: file already exists, mkdir '${String(p)}'`);
100
+ err.code = 'EEXIST';
101
+ throw err;
102
+ }
103
+ return actual.mkdir(p, opts);
104
+ },
105
+ unlink: async (p) => {
106
+ if (p === unlinkFailTarget.path) {
107
+ unlinkFailTarget.path = null;
108
+ const err = new Error(`EACCES: permission denied, unlink '${String(p)}'`);
109
+ err.code = 'EACCES';
110
+ throw err;
111
+ }
112
+ return actual.unlink(p);
113
+ },
114
+ lstat: async (p) => {
115
+ if (p === lstatFailForDir.path) {
116
+ lstatFailForDir.path = null;
117
+ const err = new Error(`EACCES: permission denied, lstat '${String(p)}'`);
118
+ err.code = 'EACCES';
119
+ throw err;
120
+ }
121
+ if (p === lstatFailTarget.path) {
122
+ lstatFailTarget.path = null;
123
+ const err = new Error(`EACCES: permission denied, lstat '${String(p)}'`);
124
+ err.code = 'EACCES';
125
+ throw err;
126
+ }
127
+ return actual.lstat(p);
128
+ },
129
+ };
130
+ });
131
+ vi.mock('../../../src/utils/safe-fs.js', async (importOriginal) => {
132
+ const actual = await importOriginal();
133
+ return {
134
+ ...actual,
135
+ safeCreateFile: async (filePath, content, options, onCreated) => {
136
+ if (filePath === swapInstallDirTarget.path && swapInstallDirTarget.installPath) {
137
+ const installPath = swapInstallDirTarget.installPath;
138
+ swapInstallDirTarget.path = null;
139
+ swapInstallDirTarget.installPath = null;
140
+ const real = await vi.importActual('fs/promises');
141
+ await real.rename(installPath, `${installPath}.moved`);
142
+ await real.mkdir(installPath);
143
+ await real.writeFile(`${installPath}/KEEP.md`, 'not the install');
144
+ throw new Error('Simulated failure after another process replaced the install directory');
145
+ }
146
+ if (filePath === replaceAfterCreateTarget.path) {
147
+ replaceAfterCreateTarget.path = null;
148
+ const real = await vi.importActual('fs/promises');
149
+ const fd = await real.open(filePath, real.constants.O_WRONLY | real.constants.O_CREAT | real.constants.O_EXCL, 0o644);
150
+ const created = await fd.stat();
151
+ onCreated?.({ dev: created.dev, ino: created.ino });
152
+ await fd.close();
153
+ const theirs = filePath + '.theirs';
154
+ await real.writeFile(theirs, 'ANOTHER PROCESS FILE');
155
+ await real.rename(theirs, filePath);
156
+ throw new Error('Simulated failure after another process replaced the file (R5)');
157
+ }
158
+ if (filePath === createFailAfterOpenTarget.path) {
159
+ createFailAfterOpenTarget.path = null;
160
+ const { open, constants } = await vi.importActual('fs/promises');
161
+ const fd = await open(filePath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
162
+ onCreated?.();
163
+ await fd.close();
164
+ throw new Error('Simulated failure after exclusive create (N9)');
165
+ }
166
+ return actual.safeCreateFile(filePath, content, options, onCreated);
167
+ },
168
+ safeWriteFile: async (filePath, content, options) => {
169
+ if (filePath === armLstatFailOnWrite.trigger) {
170
+ armLstatFailOnWrite.trigger = null;
171
+ lstatFailTarget.path = armLstatFailOnWrite.target;
172
+ }
173
+ if (filePath === truncateThenThrowTarget.path) {
174
+ // Simulate safeWriteFile's own O_TRUNC truncating the file to empty,
175
+ // then the write itself failing (e.g. ENOSPC/EIO) before any new
176
+ // bytes land — the exact non-atomic failure mode F1 exists to survive.
177
+ await fs.writeFile(filePath, '');
178
+ throw new Error('Simulated ENOSPC after truncation');
179
+ }
180
+ if (filePath === failOnceTarget.path && failOnceTarget.armed) {
181
+ failOnceTarget.armed = false;
182
+ throw new Error('Simulated write failure (N2: first write for this key fails)');
183
+ }
184
+ if (filePath === delayTarget.path) {
185
+ await new Promise((resolve) => setTimeout(resolve, delayTarget.ms));
186
+ }
187
+ return actual.safeWriteFile(filePath, content, options);
188
+ },
189
+ // SMI-6529 N3 (round 4): only `skill-installation.io.rollback.ts`'s
190
+ // `restoreSnapshots()` imports `writeFullBuffer` directly — `io.write.ts`
191
+ // (this file's `writeInstallFiles` under test) only ever imports
192
+ // `safeWriteFile`/`safeCreateFile`, so this override is isolated to the
193
+ // restore path and never affects the writes above.
194
+ writeFullBuffer: async (fd, data, position = null) => {
195
+ if (shortRestoreTarget.active) {
196
+ const truncated = data.subarray(0, Math.max(0, data.length - 1));
197
+ await actual.writeFullBuffer(fd, truncated, position);
198
+ return;
199
+ }
200
+ return actual.writeFullBuffer(fd, data, position);
201
+ },
202
+ };
203
+ });
204
+ import { writeInstallFiles } from '../../../src/services/skill-installation.io.js';
205
+ import { resolveCompanionAgentPath } from '../../../src/install/paths.js';
206
+ import { InstallRestoreError } from '../../../src/services/skill-installation.io.rollback.js';
207
+ describe('writeInstallFiles rollback-on-failure (SMI-6529)', () => {
208
+ it('restores a pre-existing SKILL.md to its ORIGINAL bytes and leaves .git/an unrelated file/the directory itself intact, when a later sub-skill write fails', async () => {
209
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-rollback-preexist-'));
210
+ try {
211
+ const skillsDir = path.join(root, 'skills');
212
+ await fs.mkdir(skillsDir, { recursive: true });
213
+ const installPath = path.join(skillsDir, 'my-skill');
214
+ await fs.mkdir(installPath, { recursive: true });
215
+ const originalSkillMd = '# Original skill content — must survive';
216
+ const skillMdPath = path.join(installPath, 'SKILL.md');
217
+ await fs.writeFile(skillMdPath, originalSkillMd);
218
+ const unrelatedPath = path.join(installPath, 'notes.txt');
219
+ const unrelatedContent = "the user's own notes — must survive untouched";
220
+ await fs.writeFile(unrelatedPath, unrelatedContent);
221
+ await fs.mkdir(path.join(installPath, '.git'), { recursive: true });
222
+ await fs.writeFile(path.join(installPath, '.git', 'HEAD'), 'ref: refs/heads/main\n');
223
+ // Force a write failure on a LATER file: plant the sub-skill's target
224
+ // filename as a pre-existing symlink — safeWriteFile's own O_NOFOLLOW
225
+ // open() refuses it (SymlinkError) before touching a byte, exactly the
226
+ // reachable, non-mocked failure mode skill-installation.io.symlink
227
+ // .test.ts already exercises for a different call site.
228
+ const examplesPath = path.join(installPath, 'examples.md');
229
+ await fs.symlink('/nonexistent-target', examplesPath);
230
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# NEW content that must NOT survive', [{ filename: 'examples.md', content: 'malicious or just unlucky new content' }], undefined)).rejects.toThrow(/symlink/i);
231
+ // The directory itself was never removed — it pre-existed this call.
232
+ const stats = await fs.lstat(installPath);
233
+ expect(stats.isDirectory()).toBe(true);
234
+ // SKILL.md restored to its ORIGINAL bytes, not the new content.
235
+ expect(await fs.readFile(skillMdPath, 'utf8')).toBe(originalSkillMd);
236
+ // .git and the unrelated user file were never touched.
237
+ expect(await fs.readFile(path.join(installPath, '.git', 'HEAD'), 'utf8')).toBe('ref: refs/heads/main\n');
238
+ expect(await fs.readFile(unrelatedPath, 'utf8')).toBe(unrelatedContent);
239
+ }
240
+ finally {
241
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
242
+ }
243
+ });
244
+ it('restores the ORIGINAL file mode along with its bytes', async () => {
245
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-rollback-mode-'));
246
+ try {
247
+ const skillsDir = path.join(root, 'skills');
248
+ await fs.mkdir(skillsDir, { recursive: true });
249
+ const installPath = path.join(skillsDir, 'my-skill');
250
+ await fs.mkdir(installPath, { recursive: true });
251
+ const skillMdPath = path.join(installPath, 'SKILL.md');
252
+ await fs.writeFile(skillMdPath, '# original', { mode: 0o600 });
253
+ const examplesPath = path.join(installPath, 'examples.md');
254
+ await fs.symlink('/nonexistent-target', examplesPath);
255
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# new content', [{ filename: 'examples.md', content: 'x' }], undefined)).rejects.toThrow(/symlink/i);
256
+ const stat = await fs.stat(skillMdPath);
257
+ // 0o600 -> only owner read/write; mask off the file-type bits.
258
+ expect(stat.mode & 0o777).toBe(0o600);
259
+ }
260
+ finally {
261
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
262
+ }
263
+ });
264
+ it('removes a FRESHLY CREATED (did not pre-exist) install directory on failure — old behavior preserved', async () => {
265
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-rollback-fresh-'));
266
+ try {
267
+ const skillsDir = path.join(root, 'skills');
268
+ await fs.mkdir(skillsDir, { recursive: true });
269
+ // installPath is deliberately NEVER pre-created — writeInstallFiles
270
+ // itself creates it fresh via fs.mkdir(installPath, {recursive:true}).
271
+ const installPath = path.join(skillsDir, 'my-skill');
272
+ // Force a LATE failure (after installPath is freshly created, before
273
+ // any content lands) via `createFailAfterOpenTarget` (defined above
274
+ // for N9): SMI-6529 N11 (round 4) made a symlinked COMPANION-agent
275
+ // path — this test's original failure trigger — no longer throw on a
276
+ // fresh install (it's now correctly SKIPPED, `companionSkipped`), so
277
+ // this test (whose actual point is installPath removal on ANY
278
+ // failure, not the companion agent specifically) now forces the
279
+ // SKILL.md write itself to fail right after its own exclusive create.
280
+ const mainSkillPath = path.join(installPath, 'SKILL.md');
281
+ createFailAfterOpenTarget.path = mainSkillPath;
282
+ try {
283
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [], undefined)).rejects.toThrow(/Simulated failure after exclusive create/);
284
+ }
285
+ finally {
286
+ createFailAfterOpenTarget.path = null;
287
+ }
288
+ // installPath pre-existed NOTHING before this call — the old
289
+ // (pre-SMI-6529) AND new behavior agree it must be fully removed.
290
+ await expect(fs.access(installPath)).rejects.toThrow();
291
+ }
292
+ finally {
293
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
294
+ }
295
+ });
296
+ // F1 (review round 1): the snapshot must be taken BEFORE safeWriteFile is
297
+ // ever called, never after it "succeeds" — a write that truncates the file
298
+ // then throws mid-write must still be recoverable.
299
+ it('F1: restores original bytes when a write truncates the file then throws mid-write (non-atomic O_TRUNC failure)', async () => {
300
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-f1-truncate-'));
301
+ try {
302
+ const skillsDir = path.join(root, 'skills');
303
+ await fs.mkdir(skillsDir, { recursive: true });
304
+ const installPath = path.join(skillsDir, 'my-skill');
305
+ await fs.mkdir(installPath, { recursive: true });
306
+ const skillMdPath = path.join(installPath, 'SKILL.md');
307
+ const originalContent = '# Original — must survive a truncate-then-throw write';
308
+ await fs.writeFile(skillMdPath, originalContent);
309
+ truncateThenThrowTarget.path = skillMdPath;
310
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# new content', [], undefined)).rejects.toThrow('Simulated ENOSPC');
311
+ // Confirm the mock actually truncated it first (proving the failure mode
312
+ // is the one F1 targets, not a no-op mock).
313
+ expect(await fs.readFile(skillMdPath, 'utf8')).toBe(originalContent);
314
+ }
315
+ finally {
316
+ truncateThenThrowTarget.path = null;
317
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
318
+ }
319
+ });
320
+ // F3 (review round 1): Promise.all rejects on the FIRST failure while
321
+ // sibling writes are still in flight — a slow sibling that finishes AFTER
322
+ // rollback ran would never get its own overwrite restored. Promise
323
+ // .allSettled must wait for every write to finish before rollback runs.
324
+ it('F3: a delayed sibling sub-skill write that overwrites an existing file is still restored after a sibling fails immediately', async () => {
325
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-f3-race-'));
326
+ try {
327
+ const skillsDir = path.join(root, 'skills');
328
+ await fs.mkdir(skillsDir, { recursive: true });
329
+ const installPath = path.join(skillsDir, 'my-skill');
330
+ await fs.mkdir(installPath, { recursive: true });
331
+ const delayedPath = path.join(installPath, 'delayed.md');
332
+ const originalDelayedContent = '# Original delayed content — must survive';
333
+ await fs.writeFile(delayedPath, originalDelayedContent);
334
+ const failFastPath = path.join(installPath, 'fail-fast.md');
335
+ await fs.symlink('/nonexistent-target', failFastPath); // fails immediately, no delay
336
+ // Delay ONLY the write to delayedPath so it lands strictly after
337
+ // fail-fast.md's write has already rejected.
338
+ delayTarget.path = delayedPath;
339
+ delayTarget.ms = 50;
340
+ try {
341
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [
342
+ { filename: 'fail-fast.md', content: 'irrelevant — write refused as a symlink' },
343
+ { filename: 'delayed.md', content: 'new delayed content that must NOT survive' },
344
+ ], undefined)).rejects.toThrow(/symlink/i);
345
+ // Under the OLD Promise.all-based bug, writeInstallFiles' own promise
346
+ // rejects (and rollback runs) as soon as fail-fast.md rejects — LONG
347
+ // before the delayed write's background completion at +50ms, which
348
+ // then clobbers the just-restored file with no second rollback pass
349
+ // to catch it. Wait well past that window before asserting, so this
350
+ // test can actually tell the two implementations apart (asserting
351
+ // immediately after the rejection would pass even under the bug,
352
+ // since the clobbering write hasn't happened yet at that instant).
353
+ await new Promise((resolve) => setTimeout(resolve, 150));
354
+ expect(await fs.readFile(delayedPath, 'utf8')).toBe(originalDelayedContent);
355
+ }
356
+ finally {
357
+ delayTarget.path = null;
358
+ delayTarget.ms = 0;
359
+ }
360
+ }
361
+ finally {
362
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
363
+ }
364
+ });
365
+ // F4 (review round 1): a nested sub-skill directory created FRESH inside a
366
+ // PRE-EXISTING installPath must be removed on rollback (non-recursively,
367
+ // deepest first) — it must not be left behind just because installPath
368
+ // itself is never removed.
369
+ it('F4: removes freshly-created nested sub-skill directories on rollback, deepest first, while the pre-existing installPath survives', async () => {
370
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-f4-nested-'));
371
+ try {
372
+ const skillsDir = path.join(root, 'skills');
373
+ await fs.mkdir(skillsDir, { recursive: true });
374
+ const installPath = path.join(skillsDir, 'my-skill');
375
+ // installPath PRE-EXISTS — the case that matters: a fresh install's
376
+ // whole directory gets recursively removed regardless (already covered
377
+ // above), so this test isolates the pre-existing-directory path.
378
+ await fs.mkdir(installPath, { recursive: true });
379
+ const unrelatedPath = path.join(installPath, 'unrelated.txt');
380
+ await fs.writeFile(unrelatedPath, 'must survive');
381
+ // A pre-existing symlink forces a failure on a LATER sub-skill write,
382
+ // after the nested "scripts/nested/" directories have already been
383
+ // created fresh for an EARLIER one.
384
+ const failPath = path.join(installPath, 'fail.md');
385
+ await fs.symlink('/nonexistent-target', failPath);
386
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [
387
+ { filename: 'scripts/nested/foo.sh', content: 'echo hi' },
388
+ { filename: 'fail.md', content: 'never written — symlink refused' },
389
+ ], undefined)).rejects.toThrow(/symlink/i);
390
+ // The freshly-created nested directories are gone...
391
+ await expect(fs.access(path.join(installPath, 'scripts', 'nested'))).rejects.toThrow();
392
+ await expect(fs.access(path.join(installPath, 'scripts'))).rejects.toThrow();
393
+ // ...and so is the file created fresh inside them...
394
+ await expect(fs.access(path.join(installPath, 'scripts', 'nested', 'foo.sh'))).rejects.toThrow();
395
+ // ...but installPath itself (pre-existing) and its unrelated content survive.
396
+ const stats = await fs.lstat(installPath);
397
+ expect(stats.isDirectory()).toBe(true);
398
+ expect(await fs.readFile(unrelatedPath, 'utf8')).toBe('must survive');
399
+ }
400
+ finally {
401
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
402
+ }
403
+ });
404
+ // SMI-6529 M4 (round 2, reviewer probe-write.mjs W2): a literal-string
405
+ // duplicate ("examples.md" and "./examples.md" both resolve to the same
406
+ // path) must never let the SECOND write's classification race ahead of
407
+ // the FIRST's and capture an intermediate (already-overwritten) state as
408
+ // if it were the true original.
409
+ it('M4: two sub-skill keys normalizing to the same path restore to the TRUE original, not an intermediate state', async () => {
410
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-m4-dup-path-'));
411
+ try {
412
+ const skillsDir = path.join(root, 'skills');
413
+ await fs.mkdir(skillsDir, { recursive: true });
414
+ const installPath = path.join(skillsDir, 'my-skill');
415
+ await fs.mkdir(installPath, { recursive: true });
416
+ const examplesPath = path.join(installPath, 'examples.md');
417
+ await fs.writeFile(examplesPath, 'ORIGINAL');
418
+ const failPath = path.join(installPath, 'zfail.md');
419
+ await fs.symlink('/nonexistent-target', failPath);
420
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [
421
+ { filename: 'examples.md', content: 'NEW-A' },
422
+ { filename: './examples.md', content: 'NEW-B' },
423
+ { filename: 'zfail.md', content: 'never written' },
424
+ ], undefined)).rejects.toThrow(/symlink/i);
425
+ expect(await fs.readFile(examplesPath, 'utf8')).toBe('ORIGINAL');
426
+ }
427
+ finally {
428
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
429
+ }
430
+ });
431
+ // SMI-6529 N1 (round 4, reviewer probe-linux-case.mjs): the round-2 "only
432
+ // the FIRST occurrence for a queue key classifies" optimization was itself
433
+ // broken on a case-SENSITIVE filesystem (Linux/CI) — `README.md` and
434
+ // `readme.md` share the same lowercased queue key there too, even though
435
+ // they are genuinely TWO DIFFERENT files; the second was silently
436
+ // overwritten with NO snapshot at all. This test — unlike its round-2
437
+ // predecessor above (kept for the M4 exact-duplicate/./-prefixed-duplicate
438
+ // scenario, which is unaffected by N1) — NEVER early-returns: it detects
439
+ // aliasing at runtime and asserts the correct outcome either way. On a
440
+ // case-sensitive filesystem (the container/CI) it proves the exact
441
+ // "two differently-cased files, both restored" scenario N1 requires; on a
442
+ // case-insensitive filesystem it proves the aliased pair still converges
443
+ // to the single TRUE original (reviewer measured NFC/NFD and σ/ς aliasing
444
+ // 450/450 with the fixed approach, probe-unicode.mjs).
445
+ it('N1: two differently-cased sub-skill filenames each restore to their OWN true original on a case-sensitive filesystem, or converge to the true original on a case-insensitive alias', async () => {
446
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-n1-case-'));
447
+ try {
448
+ const skillsDir = path.join(root, 'skills');
449
+ await fs.mkdir(skillsDir, { recursive: true });
450
+ const installPath = path.join(skillsDir, 'my-skill');
451
+ await fs.mkdir(installPath, { recursive: true });
452
+ const upperPath = path.join(installPath, 'README.md');
453
+ await fs.writeFile(upperPath, 'ORIGINAL-UPPER');
454
+ const lowerPath = path.join(installPath, 'readme.md');
455
+ const isAliased = await fs
456
+ .lstat(lowerPath)
457
+ .then(() => true)
458
+ .catch(() => false);
459
+ if (!isAliased) {
460
+ // Genuinely two distinct files on this (case-sensitive) filesystem —
461
+ // give the second one its OWN distinct original content.
462
+ await fs.writeFile(lowerPath, 'ORIGINAL-LOWER');
463
+ }
464
+ const failPath = path.join(installPath, 'zfail.md');
465
+ await fs.symlink('/nonexistent-target', failPath);
466
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [
467
+ { filename: 'README.md', content: 'NEW-UPPER' },
468
+ { filename: 'readme.md', content: 'NEW-LOWER' },
469
+ { filename: 'zfail.md', content: 'never written' },
470
+ ], undefined)).rejects.toThrow(/symlink/i);
471
+ if (isAliased) {
472
+ // Single real file — must converge to the TRUE original regardless
473
+ // of how many aliasing writes hit it along the way.
474
+ expect(await fs.readFile(upperPath, 'utf8')).toBe('ORIGINAL-UPPER');
475
+ }
476
+ else {
477
+ // Two genuinely distinct real files — EACH must independently
478
+ // restore to its OWN original, not be lost or swapped.
479
+ expect(await fs.readFile(upperPath, 'utf8')).toBe('ORIGINAL-UPPER');
480
+ expect(await fs.readFile(lowerPath, 'utf8')).toBe('ORIGINAL-LOWER');
481
+ }
482
+ }
483
+ finally {
484
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
485
+ }
486
+ });
487
+ // SMI-6529 N2 (round 4, reviewer probe-queue.mjs): a queued write whose
488
+ // PREDECESSOR (same lowercased key) already failed must still be
489
+ // independently classified and snapshotted — the queue chain's
490
+ // `previous.catch(() => {})` only swallows the earlier failure so the
491
+ // CHAIN doesn't break, it must never skip the later write's own
492
+ // classification. Forces the FIRST queued write (`README.md`, processed
493
+ // first in array order) to fail outright, then proves the SECOND
494
+ // (`readme.md`, a genuinely different real file on a case-sensitive
495
+ // filesystem sharing the same lowercased queue key) still restores to its
496
+ // own true original.
497
+ it('N2: a queued write whose predecessor (same queue key) failed is still independently snapshotted and restored', async () => {
498
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-n2-firstfails-'));
499
+ try {
500
+ const skillsDir = path.join(root, 'skills');
501
+ await fs.mkdir(skillsDir, { recursive: true });
502
+ const installPath = path.join(skillsDir, 'my-skill');
503
+ await fs.mkdir(installPath, { recursive: true });
504
+ const upperPath = path.join(installPath, 'README.md');
505
+ await fs.writeFile(upperPath, 'ORIGINAL-UPPER');
506
+ const lowerPath = path.join(installPath, 'readme.md');
507
+ const isAliased = await fs
508
+ .lstat(lowerPath)
509
+ .then(() => true)
510
+ .catch(() => false);
511
+ if (!isAliased) {
512
+ await fs.writeFile(lowerPath, 'ORIGINAL-LOWER');
513
+ }
514
+ // Force the FIRST write (README.md) to fail outright — simulating an
515
+ // ExclusiveCreateRaceError / snapshot EACCES on the predecessor.
516
+ failOnceTarget.path = upperPath;
517
+ failOnceTarget.armed = true;
518
+ try {
519
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [
520
+ { filename: 'README.md', content: 'NEW-UPPER' },
521
+ { filename: 'readme.md', content: 'NEW-LOWER' },
522
+ ], undefined)).rejects.toThrow(/Simulated write failure/);
523
+ if (isAliased) {
524
+ expect(await fs.readFile(upperPath, 'utf8')).toBe('ORIGINAL-UPPER');
525
+ }
526
+ else {
527
+ // The SECOND write must have been independently classified and
528
+ // snapshotted DESPITE the first write's failure — restoring
529
+ // correctly to its OWN true original, not left as the
530
+ // unsnapshotted 'NEW-LOWER'.
531
+ expect(await fs.readFile(upperPath, 'utf8')).toBe('ORIGINAL-UPPER');
532
+ expect(await fs.readFile(lowerPath, 'utf8')).toBe('ORIGINAL-LOWER');
533
+ }
534
+ }
535
+ finally {
536
+ failOnceTarget.path = null;
537
+ failOnceTarget.armed = false;
538
+ }
539
+ }
540
+ finally {
541
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
542
+ }
543
+ });
544
+ // SMI-6529 N3 (round 4, reviewer probe-short-write.mjs): restoreSnapshots()
545
+ // must verify the FINAL on-disk size against the snapshot length, not just
546
+ // trust that writeFullBuffer's own retry loop "resolved without throwing."
547
+ // Simulates an fd wrapper that silently commits ONE BYTE FEWER than
548
+ // requested while still resolving normally (the measured live hazard:
549
+ // `FileHandle.write()` can short-write without throwing) and asserts the
550
+ // restore is reported as a FAILURE, never as a clean restore.
551
+ it('N3: restoreSnapshots reports a failure when the final on-disk size does not match the snapshot (short-write defense-in-depth)', async () => {
552
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-n3-shortrestore-'));
553
+ try {
554
+ const skillsDir = path.join(root, 'skills');
555
+ await fs.mkdir(skillsDir, { recursive: true });
556
+ const installPath = path.join(skillsDir, 'my-skill');
557
+ await fs.mkdir(installPath, { recursive: true });
558
+ const skillMdPath = path.join(installPath, 'SKILL.md');
559
+ const originalContent = '# ORIGINAL CONTENT THAT MUST BE FULLY RESTORED';
560
+ await fs.writeFile(skillMdPath, originalContent);
561
+ const result = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# NEW CONTENT', [], undefined);
562
+ expect(await fs.readFile(skillMdPath, 'utf8')).toBe('# NEW CONTENT');
563
+ shortRestoreTarget.active = true;
564
+ try {
565
+ const causeError = new Error('simulated later-step failure');
566
+ await expect(result.rollback(causeError)).rejects.toThrow(InstallRestoreError);
567
+ await expect(result.rollback(causeError)).rejects.toThrow(/could not restore 1 pre-existing/);
568
+ }
569
+ finally {
570
+ shortRestoreTarget.active = false;
571
+ }
572
+ }
573
+ finally {
574
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
575
+ }
576
+ });
577
+ // SMI-6529 M7 (round 2): writeInstallFiles() returns a `rollback()` closure
578
+ // so a caller whose LATER step fails (after the write itself succeeded)
579
+ // can undo it via the SAME logic the internal catch block uses.
580
+ describe('M7: rollback() closure', () => {
581
+ it('removes a FRESH install directory when rollback() is invoked after success', async () => {
582
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-m7-fresh-'));
583
+ try {
584
+ const skillsDir = path.join(root, 'skills');
585
+ await fs.mkdir(skillsDir, { recursive: true });
586
+ const installPath = path.join(skillsDir, 'my-skill'); // never pre-created
587
+ const result = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [], undefined);
588
+ expect(await fs.readFile(path.join(installPath, 'SKILL.md'), 'utf8')).toBe('# hello');
589
+ // Simulate a LATER step failing (e.g. the manifest update) after the
590
+ // write itself already succeeded.
591
+ await result.rollback(new Error('simulated manifest-update failure'));
592
+ await expect(fs.access(installPath)).rejects.toThrow();
593
+ }
594
+ finally {
595
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
596
+ }
597
+ });
598
+ it("restores a FORCED REINSTALL's pre-existing files when rollback() is invoked after success", async () => {
599
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-m7-forced-'));
600
+ try {
601
+ const skillsDir = path.join(root, 'skills');
602
+ await fs.mkdir(skillsDir, { recursive: true });
603
+ const installPath = path.join(skillsDir, 'my-skill');
604
+ await fs.mkdir(installPath, { recursive: true });
605
+ const skillMdPath = path.join(installPath, 'SKILL.md');
606
+ await fs.writeFile(skillMdPath, '# ORIGINAL', { mode: 0o600 });
607
+ const unrelatedPath = path.join(installPath, 'notes.txt');
608
+ await fs.writeFile(unrelatedPath, 'user notes');
609
+ const result = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# NEW CONTENT', [], undefined);
610
+ expect(await fs.readFile(skillMdPath, 'utf8')).toBe('# NEW CONTENT');
611
+ await result.rollback(new Error('simulated manifest-update failure'));
612
+ // Restored to the ORIGINAL content/mode; the directory itself and
613
+ // unrelated content survive (it pre-existed this call).
614
+ expect(await fs.readFile(skillMdPath, 'utf8')).toBe('# ORIGINAL');
615
+ const stat = await fs.stat(skillMdPath);
616
+ expect(stat.mode & 0o777).toBe(0o600);
617
+ expect(await fs.readFile(unrelatedPath, 'utf8')).toBe('user notes');
618
+ await expect(fs.access(installPath)).resolves.toBeUndefined();
619
+ }
620
+ finally {
621
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
622
+ }
623
+ });
624
+ it('throws InstallRestoreError (naming the cause) when the restore itself also fails', async () => {
625
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-m7-restore-fail-'));
626
+ try {
627
+ const skillsDir = path.join(root, 'skills');
628
+ await fs.mkdir(skillsDir, { recursive: true });
629
+ const installPath = path.join(skillsDir, 'my-skill');
630
+ await fs.mkdir(installPath, { recursive: true });
631
+ const skillMdPath = path.join(installPath, 'SKILL.md');
632
+ await fs.writeFile(skillMdPath, '# ORIGINAL');
633
+ const result = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# NEW CONTENT', [], undefined);
634
+ // Sabotage the restore: replace SKILL.md with a symlink between the
635
+ // successful write and the rollback call — restoreSnapshots' own
636
+ // O_NOFOLLOW refuses to write through it (F2, round 1), so this
637
+ // specific restore fails.
638
+ await fs.unlink(skillMdPath);
639
+ await fs.symlink('/nonexistent-target', skillMdPath);
640
+ const causeError = new Error('simulated manifest-update failure');
641
+ await expect(result.rollback(causeError)).rejects.toThrow(InstallRestoreError);
642
+ await expect(result.rollback(causeError)).rejects.toThrow(/simulated manifest-update failure/);
643
+ await expect(result.rollback(causeError)).rejects.toThrow(/could not restore 1 pre-existing/);
644
+ }
645
+ finally {
646
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
647
+ }
648
+ });
649
+ });
650
+ // SMI-6529 M10 (round 2): a companion agent file already occupying the
651
+ // target path must never be silently overwritten by a FRESH install, but
652
+ // a forced reinstall of an already-tracked skill still overwrites it.
653
+ describe('M10: companion-agent-file overwrite policy', () => {
654
+ it('FRESH install: skips an existing companion file and reports companionSkipped', async () => {
655
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-m10-fresh-'));
656
+ try {
657
+ const skillsDir = path.join(root, 'skills');
658
+ await fs.mkdir(skillsDir, { recursive: true });
659
+ const installPath = path.join(skillsDir, 'my-skill'); // fresh — never pre-created
660
+ const companionBaseDir = path.join(root, 'project');
661
+ // Antigravity's directory-package mode fully redirects via
662
+ // companionBaseDir (claude-code's own 'flat' mode is always
663
+ // homedir()-anchored and can't be redirected — see paths.ts).
664
+ const agentPath = path.join(companionBaseDir, '.agents', 'agents', 'my-skill', 'agent.md');
665
+ await fs.mkdir(path.dirname(agentPath), { recursive: true });
666
+ await fs.writeFile(agentPath, 'unrelated pre-existing agent content');
667
+ const result = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [], '---\nname: my-skill-specialist\n---\nbody', 'antigravity', companionBaseDir);
668
+ expect(result.companionSkipped).toBe(true);
669
+ expect(result.subagentPath).toBeUndefined();
670
+ expect(await fs.readFile(agentPath, 'utf8')).toBe('unrelated pre-existing agent content');
671
+ }
672
+ finally {
673
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
674
+ }
675
+ });
676
+ it('FORCED REINSTALL (preExisted): still overwrites an existing companion file', async () => {
677
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-m10-forced-'));
678
+ try {
679
+ const skillsDir = path.join(root, 'skills');
680
+ await fs.mkdir(skillsDir, { recursive: true });
681
+ const installPath = path.join(skillsDir, 'my-skill');
682
+ await fs.mkdir(installPath, { recursive: true }); // preExisted = true
683
+ const companionBaseDir = path.join(root, 'project');
684
+ const agentPath = path.join(companionBaseDir, '.agents', 'agents', 'my-skill', 'agent.md');
685
+ await fs.mkdir(path.dirname(agentPath), { recursive: true });
686
+ await fs.writeFile(agentPath, 'previously-generated agent content');
687
+ const result = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [], '---\nname: my-skill-specialist\n---\nregenerated body', 'antigravity', companionBaseDir);
688
+ expect(result.companionSkipped).toBeFalsy();
689
+ expect(result.subagentPath).toBe(agentPath);
690
+ expect(await fs.readFile(agentPath, 'utf8')).toBe('---\nname: my-skill-specialist\n---\nregenerated body');
691
+ }
692
+ finally {
693
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
694
+ }
695
+ });
696
+ // SMI-6529 N11 (round 4, reviewer probe-companion.mjs): the round-2 check
697
+ // above only skipped a 'regular' pre-existing file — a SYMLINKED
698
+ // companion path (e.g. a stale/planted link) fell to the `else` branch,
699
+ // whose `writeTracked()` -> `safeWriteFile()` throws SymlinkError,
700
+ // rolling back the ENTIRE install (SKILL.md, every sub-skill) over an
701
+ // unrelated pre-existing symlink at a completely different path. A fresh
702
+ // install must skip ('other' or 'regular') exactly the same way.
703
+ it('N11: FRESH install skips a SYMLINKED companion-agent path too — no SymlinkError, no whole-install rollback', async () => {
704
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-n11-symlink-companion-'));
705
+ try {
706
+ const skillsDir = path.join(root, 'skills');
707
+ await fs.mkdir(skillsDir, { recursive: true });
708
+ const installPath = path.join(skillsDir, 'my-skill'); // fresh — never pre-created
709
+ const companionBaseDir = path.join(root, 'project');
710
+ const agentPath = path.join(companionBaseDir, '.agents', 'agents', 'my-skill', 'agent.md');
711
+ await fs.mkdir(path.dirname(agentPath), { recursive: true });
712
+ await fs.symlink('/nonexistent-target', agentPath);
713
+ const result = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [], '---\nname: my-skill-specialist\n---\nbody', 'antigravity', companionBaseDir);
714
+ // Install succeeds — the symlinked companion is skipped, never
715
+ // triggers a SymlinkError, never rolls back the (successful) SKILL.md
716
+ // write.
717
+ expect(result.companionSkipped).toBe(true);
718
+ expect(result.subagentPath).toBeUndefined();
719
+ expect(await fs.readFile(path.join(installPath, 'SKILL.md'), 'utf8')).toBe('# hello');
720
+ const linkStat = await fs.lstat(agentPath);
721
+ expect(linkStat.isSymbolicLink()).toBe(true);
722
+ }
723
+ finally {
724
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
725
+ }
726
+ });
727
+ });
728
+ // SMI-6529 L15 (round 2): the directory-package per-skill agent directory
729
+ // is only ever rmdir'd on rollback if THIS call created it — a
730
+ // pre-existing one must survive even if this call's own cleanup leaves it
731
+ // empty.
732
+ it("L15: a PRE-EXISTING directory-package agent dir is never rmdir'd on rollback, even once emptied", async () => {
733
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-l15-preexist-agentdir-'));
734
+ try {
735
+ const skillsDir = path.join(root, 'skills');
736
+ await fs.mkdir(skillsDir, { recursive: true });
737
+ const installPath = path.join(skillsDir, 'my-skill');
738
+ await fs.mkdir(installPath, { recursive: true }); // preExisted = true
739
+ const companionBaseDir = path.join(root, 'project');
740
+ // Antigravity's directory-package mode: <agentsDir>/<skillName>/agent.md.
741
+ // Pre-create the per-skill directory itself (empty) to prove it survives.
742
+ const agentDir = path.join(companionBaseDir, '.agents', 'agents', 'my-skill');
743
+ await fs.mkdir(agentDir, { recursive: true });
744
+ // Force a LATE failure via a pre-existing symlink at a SIBLING sub-skill
745
+ // path so the agent.md write (which succeeds, since the dir was
746
+ // otherwise empty) still gets rolled back afterward.
747
+ const failPath = path.join(installPath, 'fail.md');
748
+ await fs.symlink('/nonexistent-target', failPath);
749
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [{ filename: 'fail.md', content: 'never written' }], '---\nname: my-skill-specialist\n---\nbody', 'antigravity', companionBaseDir)).rejects.toThrow(/symlink/i);
750
+ // agent.md itself was created fresh by this call and IS rolled back
751
+ // (unlinked)...
752
+ await expect(fs.access(path.join(agentDir, 'agent.md'))).rejects.toThrow();
753
+ // ...but the per-skill directory it lived in PRE-EXISTED this call, so
754
+ // it must survive even though it's now empty.
755
+ const stat = await fs.lstat(agentDir);
756
+ expect(stat.isDirectory()).toBe(true);
757
+ }
758
+ finally {
759
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
760
+ }
761
+ });
762
+ // SMI-6529 N8 (round 4, reviewer probe-mkdir-race.mjs): on the final-
763
+ // segment mkdir EEXIST race — something ELSE creates `installPath` between
764
+ // this call's own ENOENT `preExisted` check and its own non-recursive
765
+ // `fs.mkdir(installPath)` — rollback must never touch the directory the
766
+ // OTHER process created. Reviewer measured 299/300 races without the
767
+ // `preExisted = true` flip having rollback `rmdir` the WINNER's freshly
768
+ // -created directory out from under it.
769
+ it("N8: an EEXIST race on the final mkdir segment never lets rollback remove the WINNER's directory", async () => {
770
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-n8-mkdirrace-'));
771
+ try {
772
+ const skillsDir = path.join(root, 'skills');
773
+ await fs.mkdir(skillsDir, { recursive: true });
774
+ const installPath = path.join(skillsDir, 'my-skill'); // never pre-created
775
+ mkdirRaceTarget.path = installPath;
776
+ mkdirRaceTarget.armed = true;
777
+ try {
778
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [], undefined)).rejects.toThrow(/appeared during install \(race\)/);
779
+ // The WINNER's directory (created by the mock's own real mkdir call
780
+ // right before throwing EEXIST) must SURVIVE — rollback never owns
781
+ // it, since this call did not create it.
782
+ const stat = await fs.lstat(installPath);
783
+ expect(stat.isDirectory()).toBe(true);
784
+ }
785
+ finally {
786
+ mkdirRaceTarget.path = null;
787
+ mkdirRaceTarget.armed = false;
788
+ }
789
+ }
790
+ finally {
791
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
792
+ }
793
+ });
794
+ // SMI-6529 N9 (round 4, reviewer probe-create-orphan.mjs): a write that
795
+ // fails right AFTER the exclusive `open()` already created the file (but
796
+ // before any content byte lands) must still get its 0-byte orphan unlinked
797
+ // by rollback — the fresh-creation marker is now recorded the INSTANT
798
+ // `open()` succeeds (via `onCreated`), not after the whole write resolves.
799
+ it('N9: a write that fails right after the exclusive create still gets its 0-byte orphan unlinked by rollback', async () => {
800
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-n9-orphan-'));
801
+ try {
802
+ const skillsDir = path.join(root, 'skills');
803
+ await fs.mkdir(skillsDir, { recursive: true });
804
+ const installPath = path.join(skillsDir, 'my-skill');
805
+ await fs.mkdir(installPath, { recursive: true });
806
+ const examplesPath = path.join(installPath, 'examples.md');
807
+ createFailAfterOpenTarget.path = examplesPath;
808
+ try {
809
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [{ filename: 'examples.md', content: 'never fully written' }], undefined)).rejects.toThrow(/Simulated failure after exclusive create/);
810
+ // The 0-byte orphan created by the exclusive open() must be gone —
811
+ // never left behind as an untracked, empty file.
812
+ await expect(fs.access(examplesPath)).rejects.toThrow();
813
+ }
814
+ finally {
815
+ createFailAfterOpenTarget.path = null;
816
+ }
817
+ }
818
+ finally {
819
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
820
+ }
821
+ });
822
+ // SMI-6529 R5 (round 5): rollback unlinks a fresh file only while its path
823
+ // still names the file this call created.
824
+ it('R5: rollback never unlinks a fresh file that another process has since replaced', async () => {
825
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-r5-identity-'));
826
+ try {
827
+ const skillsDir = path.join(root, 'skills');
828
+ await fs.mkdir(skillsDir, { recursive: true });
829
+ const installPath = path.join(skillsDir, 'my-skill');
830
+ await fs.mkdir(installPath, { recursive: true });
831
+ const examplesPath = path.join(installPath, 'examples.md');
832
+ replaceAfterCreateTarget.path = examplesPath;
833
+ try {
834
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [{ filename: 'examples.md', content: 'ours' }], undefined)).rejects.toThrow(/another process replaced the file/);
835
+ expect(await fs.readFile(examplesPath, 'utf-8')).toBe('ANOTHER PROCESS FILE');
836
+ // This call's own fresh SKILL.md is still rolled back.
837
+ await expect(fs.access(path.join(installPath, 'SKILL.md'))).rejects.toThrow();
838
+ }
839
+ finally {
840
+ replaceAfterCreateTarget.path = null;
841
+ }
842
+ }
843
+ finally {
844
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
845
+ }
846
+ });
847
+ // SMI-6529 N12 (round 4, reviewer probe-companion2.mjs): a sub-skill
848
+ // filename like "x/../../../escape.md" normalizes (via `path.join`, which
849
+ // collapses ".." segments) to a path OUTSIDE `installPath` entirely.
850
+ // `mkdirNoFollow()` alone only guards a pre-existing SYMLINK at an
851
+ // intermediate segment — it does nothing to stop a purely lexical escape.
852
+ it('N12: a sub-skill filename that lexically escapes installPath is refused and never written outside it', async () => {
853
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-n12-escape-'));
854
+ try {
855
+ const skillsDir = path.join(root, 'skills');
856
+ await fs.mkdir(skillsDir, { recursive: true });
857
+ const installPath = path.join(skillsDir, 'my-skill');
858
+ await fs.mkdir(installPath, { recursive: true });
859
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# hello', [{ filename: 'x/../../../escape.md', content: 'malicious content' }], undefined)).rejects.toThrow(/escapes install directory/);
860
+ // path.join(installPath, 'x/../../../escape.md') normalizes to
861
+ // path.dirname(skillsDir) + '/escape.md' — three levels up from
862
+ // installPath/x. Never written there.
863
+ await expect(fs.access(path.join(root, 'escape.md'))).rejects.toThrow();
864
+ // installPath itself (pre-existing) survives untouched.
865
+ const stat = await fs.lstat(installPath);
866
+ expect(stat.isDirectory()).toBe(true);
867
+ }
868
+ finally {
869
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
870
+ }
871
+ });
872
+ });
873
+ // SMI-6529 round 13 (cross-model review): rollback's recursive delete of a
874
+ // fresh install directory checks it is still the directory the install
875
+ // created, and a cleanup step that fails is reported, not swallowed.
876
+ describe('writeInstallFiles rollback cleanup is identity-checked and reported (SMI-6529 round 13)', () => {
877
+ it('never removes a directory that replaced the fresh install directory before rollback', async () => {
878
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-rollback-swapdir-'));
879
+ try {
880
+ const skillsDir = path.join(root, 'skills');
881
+ await fs.mkdir(skillsDir, { recursive: true });
882
+ const installPath = path.join(skillsDir, 'fresh-skill');
883
+ swapInstallDirTarget.path = path.join(installPath, 'SKILL.md');
884
+ swapInstallDirTarget.installPath = installPath;
885
+ const err = await writeInstallFiles(installPath, skillsDir, 'fresh-skill', '# new content', [], undefined).catch((e) => e);
886
+ expect(err).toBeInstanceOf(InstallRestoreError);
887
+ expect(err.cleanupFailures).toEqual([
888
+ expect.stringContaining('replaced by something else'),
889
+ ]);
890
+ expect(await fs.readFile(path.join(installPath, 'KEEP.md'), 'utf8')).toBe('not the install');
891
+ }
892
+ finally {
893
+ swapInstallDirTarget.path = null;
894
+ swapInstallDirTarget.installPath = null;
895
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
896
+ }
897
+ });
898
+ it('reports a file it created but could not remove, instead of swallowing the error', async () => {
899
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-rollback-unlinkfail-'));
900
+ try {
901
+ const skillsDir = path.join(root, 'skills');
902
+ const installPath = path.join(skillsDir, 'my-skill');
903
+ await fs.mkdir(installPath, { recursive: true });
904
+ await fs.writeFile(path.join(installPath, 'notes.txt'), 'user notes');
905
+ // SKILL.md is absent, so the install creates it fresh; the symlinked
906
+ // examples.md then makes the install fail and roll back.
907
+ const skillMdPath = path.join(installPath, 'SKILL.md');
908
+ await fs.symlink('/nonexistent-target', path.join(installPath, 'examples.md'));
909
+ unlinkFailTarget.path = skillMdPath;
910
+ const err = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# new content', [{ filename: 'examples.md', content: 'x' }], undefined).catch((e) => e);
911
+ expect(err).toBeInstanceOf(InstallRestoreError);
912
+ const cleanup = err.cleanupFailures;
913
+ expect(cleanup).toEqual([expect.stringContaining(skillMdPath)]);
914
+ expect(cleanup[0]).toContain('EACCES');
915
+ expect(err.message).toMatch(/could not remove 1 path\(s\) it created/);
916
+ expect(await fs.readFile(path.join(installPath, 'notes.txt'), 'utf8')).toBe('user notes');
917
+ }
918
+ finally {
919
+ unlinkFailTarget.path = null;
920
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
921
+ }
922
+ });
923
+ // SMI-6529 round 27 (cross-model review): the companion-agent directory check
924
+ // recorded ANY lstat failure as "did not pre-exist", and rollback rmdirs that
925
+ // directory precisely when that is false — so one unreadable moment could
926
+ // cost the user a directory that was already there.
927
+ it('keeps a companion agent directory it could not check', async () => {
928
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-agentdir-'));
929
+ try {
930
+ const skillsDir = path.join(root, 'skills');
931
+ const installPath = path.join(skillsDir, 'my-skill');
932
+ await fs.mkdir(installPath, { recursive: true });
933
+ const companionBaseDir = path.join(root, 'base');
934
+ const subagentPath = resolveCompanionAgentPath('my-skill', 'antigravity', companionBaseDir);
935
+ const agentsDir = path.dirname(subagentPath);
936
+ // The user's directory, already there and empty.
937
+ await fs.mkdir(agentsDir, { recursive: true });
938
+ // Its check fails, and the companion write then fails for real, so a
939
+ // rollback runs while the directory's provenance is in doubt.
940
+ lstatFailForDir.path = agentsDir;
941
+ createFailAfterOpenTarget.path = subagentPath;
942
+ await expect(writeInstallFiles(installPath, skillsDir, 'my-skill', '# new content', [], '# companion agent', 'antigravity', companionBaseDir)).rejects.toThrow();
943
+ // Still there: a directory this call cannot prove it created is never removed.
944
+ expect((await fs.lstat(agentsDir)).isDirectory()).toBe(true);
945
+ }
946
+ finally {
947
+ lstatFailForDir.path = null;
948
+ createFailAfterOpenTarget.path = null;
949
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
950
+ }
951
+ });
952
+ // SMI-6529 round 25 (cross-model review): the identity check skipped a path
953
+ // on ANY lstat failure, treating "I could not tell what is here" as "it is
954
+ // already gone" — so a rollback that left a file behind reported success.
955
+ it('reports a file it created but could not identify, instead of counting it cleaned up', async () => {
956
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'wif-rollback-lstatfail-'));
957
+ try {
958
+ const skillsDir = path.join(root, 'skills');
959
+ const installPath = path.join(skillsDir, 'my-skill');
960
+ await fs.mkdir(installPath, { recursive: true });
961
+ await fs.writeFile(path.join(installPath, 'notes.txt'), 'user notes');
962
+ const skillMdPath = path.join(installPath, 'SKILL.md');
963
+ const examplesPath = path.join(installPath, 'examples.md');
964
+ await fs.symlink('/nonexistent-target', examplesPath);
965
+ // SKILL.md is created fresh first; examples.md then fails for real, and
966
+ // arms the lstat failure so it lands on rollback's identity check.
967
+ armLstatFailOnWrite.trigger = examplesPath;
968
+ armLstatFailOnWrite.target = skillMdPath;
969
+ const err = await writeInstallFiles(installPath, skillsDir, 'my-skill', '# new content', [{ filename: 'examples.md', content: 'x' }], undefined).catch((e) => e);
970
+ expect(err).toBeInstanceOf(InstallRestoreError);
971
+ const cleanup = err.cleanupFailures;
972
+ expect(cleanup).toEqual([expect.stringContaining(skillMdPath)]);
973
+ expect(cleanup[0]).toContain('EACCES');
974
+ expect(await fs.readFile(path.join(installPath, 'notes.txt'), 'utf8')).toBe('user notes');
975
+ }
976
+ finally {
977
+ lstatFailTarget.path = null;
978
+ armLstatFailOnWrite.trigger = null;
979
+ armLstatFailOnWrite.target = null;
980
+ await fs.rm(root, { recursive: true, force: true }).catch(() => { });
981
+ }
982
+ });
983
+ });
984
+ //# sourceMappingURL=skill-installation.io.rollback.test.js.map