@skillsmith/core 0.12.2 → 0.12.4
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.
- package/CHANGELOG.md +173 -0
- package/README.md +2 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/src/config/owned-lock.acquire.d.ts +8 -0
- package/dist/src/config/owned-lock.acquire.d.ts.map +1 -1
- package/dist/src/config/owned-lock.acquire.js +19 -4
- package/dist/src/config/owned-lock.acquire.js.map +1 -1
- package/dist/src/config/owned-lock.claim.d.ts +4 -2
- package/dist/src/config/owned-lock.claim.d.ts.map +1 -1
- package/dist/src/config/owned-lock.claim.js +42 -8
- package/dist/src/config/owned-lock.claim.js.map +1 -1
- package/dist/src/config/owned-lock.d.ts.map +1 -1
- package/dist/src/config/owned-lock.js +1 -0
- package/dist/src/config/owned-lock.js.map +1 -1
- package/dist/src/config/owned-lock.test.js +126 -2
- package/dist/src/config/owned-lock.test.js.map +1 -1
- package/dist/src/config/owned-lock.types.d.ts +9 -0
- package/dist/src/config/owned-lock.types.d.ts.map +1 -1
- package/dist/src/db/createDatabase.d.ts.map +1 -1
- package/dist/src/db/createDatabase.js +0 -2
- package/dist/src/db/createDatabase.js.map +1 -1
- package/dist/src/exports/services.install.d.ts +1 -0
- package/dist/src/exports/services.install.d.ts.map +1 -1
- package/dist/src/exports/services.install.js +5 -0
- package/dist/src/exports/services.install.js.map +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/install/fan-out.cleanup.d.ts +37 -0
- package/dist/src/install/fan-out.cleanup.d.ts.map +1 -0
- package/dist/src/install/fan-out.cleanup.js +75 -0
- package/dist/src/install/fan-out.cleanup.js.map +1 -0
- package/dist/src/install/fan-out.d.ts +41 -31
- package/dist/src/install/fan-out.d.ts.map +1 -1
- package/dist/src/install/fan-out.js +213 -112
- package/dist/src/install/fan-out.js.map +1 -1
- package/dist/src/install/fan-out.leftovers.d.ts +60 -0
- package/dist/src/install/fan-out.leftovers.d.ts.map +1 -0
- package/dist/src/install/fan-out.leftovers.js +251 -0
- package/dist/src/install/fan-out.leftovers.js.map +1 -0
- package/dist/src/install/fan-out.locks.d.ts +12 -0
- package/dist/src/install/fan-out.locks.d.ts.map +1 -0
- package/dist/src/install/fan-out.locks.js +114 -0
- package/dist/src/install/fan-out.locks.js.map +1 -0
- package/dist/src/install/fan-out.manifest.d.ts +64 -0
- package/dist/src/install/fan-out.manifest.d.ts.map +1 -0
- package/dist/src/install/fan-out.manifest.js +130 -0
- package/dist/src/install/fan-out.manifest.js.map +1 -0
- package/dist/src/install/fan-out.overwrite.d.ts +51 -0
- package/dist/src/install/fan-out.overwrite.d.ts.map +1 -0
- package/dist/src/install/fan-out.overwrite.js +343 -0
- package/dist/src/install/fan-out.overwrite.js.map +1 -0
- package/dist/src/install/remove-if-same.d.ts +58 -0
- package/dist/src/install/remove-if-same.d.ts.map +1 -0
- package/dist/src/install/remove-if-same.js +222 -0
- package/dist/src/install/remove-if-same.js.map +1 -0
- package/dist/src/provenance/backfill.d.ts.map +1 -1
- package/dist/src/provenance/backfill.js +15 -2
- package/dist/src/provenance/backfill.js.map +1 -1
- package/dist/src/scripts/__tests__/scan-imported-skills.test.js +9 -1
- package/dist/src/scripts/__tests__/scan-imported-skills.test.js.map +1 -1
- package/dist/src/scripts/scan-imported-skills.d.ts +7 -1
- package/dist/src/scripts/scan-imported-skills.d.ts.map +1 -1
- package/dist/src/scripts/scan-imported-skills.js +15 -2
- package/dist/src/scripts/scan-imported-skills.js.map +1 -1
- package/dist/src/scripts/skill-scanner/allowlist.d.ts +10 -0
- package/dist/src/scripts/skill-scanner/allowlist.d.ts.map +1 -1
- package/dist/src/scripts/skill-scanner/allowlist.js +10 -0
- package/dist/src/scripts/skill-scanner/allowlist.js.map +1 -1
- package/dist/src/scripts/skill-scanner/index.d.ts +8 -0
- package/dist/src/scripts/skill-scanner/index.d.ts.map +1 -1
- package/dist/src/scripts/skill-scanner/index.js +13 -5
- package/dist/src/scripts/skill-scanner/index.js.map +1 -1
- package/dist/src/scripts/skill-scanner/trust-scorer.d.ts.map +1 -1
- package/dist/src/scripts/skill-scanner/trust-scorer.js +6 -0
- package/dist/src/scripts/skill-scanner/trust-scorer.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.action-context.d.ts +15 -0
- package/dist/src/security/scanner/SecurityScanner.action-context.d.ts.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.action-context.js +209 -0
- package/dist/src/security/scanner/SecurityScanner.action-context.js.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.d.ts +25 -0
- package/dist/src/security/scanner/SecurityScanner.d.ts.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.helpers.d.ts +40 -1
- package/dist/src/security/scanner/SecurityScanner.helpers.d.ts.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.helpers.js +88 -51
- package/dist/src/security/scanner/SecurityScanner.helpers.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.js +39 -6
- package/dist/src/security/scanner/SecurityScanner.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.pii.d.ts +30 -0
- package/dist/src/security/scanner/SecurityScanner.pii.d.ts.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.pii.js +8 -1
- package/dist/src/security/scanner/SecurityScanner.pii.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.prose-lexicon.d.ts +60 -0
- package/dist/src/security/scanner/SecurityScanner.prose-lexicon.d.ts.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.prose-lexicon.js +228 -0
- package/dist/src/security/scanner/SecurityScanner.prose-lexicon.js.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.scanners.d.ts.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.scanners.js +42 -5
- package/dist/src/security/scanner/SecurityScanner.scanners.js.map +1 -1
- package/dist/src/security/scanner/SecurityScanner.value-gate.d.ts +69 -0
- package/dist/src/security/scanner/SecurityScanner.value-gate.d.ts.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.value-gate.js +271 -0
- package/dist/src/security/scanner/SecurityScanner.value-gate.js.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.weak-passwords.d.ts +55 -0
- package/dist/src/security/scanner/SecurityScanner.weak-passwords.d.ts.map +1 -0
- package/dist/src/security/scanner/SecurityScanner.weak-passwords.js +308 -0
- package/dist/src/security/scanner/SecurityScanner.weak-passwords.js.map +1 -0
- package/dist/src/security/scanner/patterns.d.ts +61 -6
- package/dist/src/security/scanner/patterns.d.ts.map +1 -1
- package/dist/src/security/scanner/patterns.js +70 -61
- package/dist/src/security/scanner/patterns.js.map +1 -1
- package/dist/src/security/scanner/patterns.sensitive-path.d.ts +73 -0
- package/dist/src/security/scanner/patterns.sensitive-path.d.ts.map +1 -0
- package/dist/src/security/scanner/patterns.sensitive-path.js +192 -0
- package/dist/src/security/scanner/patterns.sensitive-path.js.map +1 -0
- package/dist/src/security/scanner/regex-utils.d.ts +31 -17
- package/dist/src/security/scanner/regex-utils.d.ts.map +1 -1
- package/dist/src/security/scanner/regex-utils.js +31 -17
- package/dist/src/security/scanner/regex-utils.js.map +1 -1
- package/dist/src/security/scanner/types.d.ts +9 -0
- package/dist/src/security/scanner/types.d.ts.map +1 -1
- package/dist/src/services/agent-pack/prompt-source.d.ts.map +1 -1
- package/dist/src/services/agent-pack/prompt-source.js +3 -2
- package/dist/src/services/agent-pack/prompt-source.js.map +1 -1
- package/dist/src/services/skill-installation.content.d.ts +7 -2
- package/dist/src/services/skill-installation.content.d.ts.map +1 -1
- package/dist/src/services/skill-installation.content.js +75 -28
- package/dist/src/services/skill-installation.content.js.map +1 -1
- package/dist/src/services/skill-installation.helpers.d.ts.map +1 -1
- package/dist/src/services/skill-installation.helpers.js +8 -0
- package/dist/src/services/skill-installation.helpers.js.map +1 -1
- package/dist/src/services/skill-installation.io.d.ts +1 -29
- package/dist/src/services/skill-installation.io.d.ts.map +1 -1
- package/dist/src/services/skill-installation.io.dirs.d.ts +15 -0
- package/dist/src/services/skill-installation.io.dirs.d.ts.map +1 -0
- package/dist/src/services/skill-installation.io.dirs.js +84 -0
- package/dist/src/services/skill-installation.io.dirs.js.map +1 -0
- package/dist/src/services/skill-installation.io.js +6 -176
- package/dist/src/services/skill-installation.io.js.map +1 -1
- package/dist/src/services/skill-installation.io.rollback.d.ts +138 -0
- package/dist/src/services/skill-installation.io.rollback.d.ts.map +1 -0
- package/dist/src/services/skill-installation.io.rollback.js +218 -0
- package/dist/src/services/skill-installation.io.rollback.js.map +1 -0
- package/dist/src/services/skill-installation.io.write.d.ts +69 -0
- package/dist/src/services/skill-installation.io.write.d.ts.map +1 -0
- package/dist/src/services/skill-installation.io.write.js +417 -0
- package/dist/src/services/skill-installation.io.write.js.map +1 -0
- package/dist/src/services/skill-installation.service.d.ts.map +1 -1
- package/dist/src/services/skill-installation.service.finalize.d.ts +62 -0
- package/dist/src/services/skill-installation.service.finalize.d.ts.map +1 -0
- package/dist/src/services/skill-installation.service.finalize.js +136 -0
- package/dist/src/services/skill-installation.service.finalize.js.map +1 -0
- package/dist/src/services/skill-installation.service.js +66 -80
- package/dist/src/services/skill-installation.service.js.map +1 -1
- package/dist/src/services/skill-installation.target-guard.d.ts +82 -0
- package/dist/src/services/skill-installation.target-guard.d.ts.map +1 -0
- package/dist/src/services/skill-installation.target-guard.js +410 -0
- package/dist/src/services/skill-installation.target-guard.js.map +1 -0
- package/dist/src/services/skill-installation.types.d.ts +13 -1
- package/dist/src/services/skill-installation.types.d.ts.map +1 -1
- package/dist/src/services/skill-installation.types.js.map +1 -1
- package/dist/src/services/skill-installation.uninstall.d.ts +3 -1
- package/dist/src/services/skill-installation.uninstall.d.ts.map +1 -1
- package/dist/src/services/skill-installation.uninstall.helpers.d.ts +59 -0
- package/dist/src/services/skill-installation.uninstall.helpers.d.ts.map +1 -0
- package/dist/src/services/skill-installation.uninstall.helpers.js +124 -0
- package/dist/src/services/skill-installation.uninstall.helpers.js.map +1 -0
- package/dist/src/services/skill-installation.uninstall.js +148 -25
- package/dist/src/services/skill-installation.uninstall.js.map +1 -1
- package/dist/src/utils/index.d.ts +1 -0
- package/dist/src/utils/index.d.ts.map +1 -1
- package/dist/src/utils/index.js +2 -0
- package/dist/src/utils/index.js.map +1 -1
- package/dist/src/utils/safe-fs.d.ts +69 -0
- package/dist/src/utils/safe-fs.d.ts.map +1 -1
- package/dist/src/utils/safe-fs.js +113 -2
- package/dist/src/utils/safe-fs.js.map +1 -1
- package/dist/src/utils/skill-name.d.ts +19 -0
- package/dist/src/utils/skill-name.d.ts.map +1 -0
- package/dist/src/utils/skill-name.js +26 -0
- package/dist/src/utils/skill-name.js.map +1 -0
- package/dist/src/utils/skill-name.test.d.ts +2 -0
- package/dist/src/utils/skill-name.test.d.ts.map +1 -0
- package/dist/src/utils/skill-name.test.js +34 -0
- package/dist/src/utils/skill-name.test.js.map +1 -0
- package/dist/tests/install/fan-out.test.js +1951 -13
- package/dist/tests/install/fan-out.test.js.map +1 -1
- package/dist/tests/install/remove-if-same.test.d.ts +2 -0
- package/dist/tests/install/remove-if-same.test.d.ts.map +1 -0
- package/dist/tests/install/remove-if-same.test.js +298 -0
- package/dist/tests/install/remove-if-same.test.js.map +1 -0
- package/dist/tests/integration/fan-out-lock-contention.test.d.ts +11 -0
- package/dist/tests/integration/fan-out-lock-contention.test.d.ts.map +1 -0
- package/dist/tests/integration/fan-out-lock-contention.test.js +53 -0
- package/dist/tests/integration/fan-out-lock-contention.test.js.map +1 -0
- package/dist/tests/provenance/backfill.test.js +43 -0
- package/dist/tests/provenance/backfill.test.js.map +1 -1
- package/dist/tests/security/ContinuousSecurity.performance.test.js +12 -7
- package/dist/tests/security/ContinuousSecurity.performance.test.js.map +1 -1
- package/dist/tests/security/ContinuousSecurity.test.js +14 -2
- package/dist/tests/security/ContinuousSecurity.test.js.map +1 -1
- package/dist/tests/security/ai-defence-multiline-promotion.test.d.ts +10 -12
- package/dist/tests/security/ai-defence-multiline-promotion.test.d.ts.map +1 -1
- package/dist/tests/security/ai-defence-multiline-promotion.test.js +26 -33
- package/dist/tests/security/ai-defence-multiline-promotion.test.js.map +1 -1
- package/dist/tests/security/nested-instruction-long-attribute.test.js +20 -10
- package/dist/tests/security/nested-instruction-long-attribute.test.js.map +1 -1
- package/dist/tests/security/scanner-evidence-tiers.test.js +15 -0
- package/dist/tests/security/scanner-evidence-tiers.test.js.map +1 -1
- package/dist/tests/security/scanner-multiline-cap.test.d.ts +21 -0
- package/dist/tests/security/scanner-multiline-cap.test.d.ts.map +1 -0
- package/dist/tests/security/scanner-multiline-cap.test.js +152 -0
- package/dist/tests/security/scanner-multiline-cap.test.js.map +1 -0
- package/dist/tests/security/scanner-regression-guard.test.js +66 -1
- package/dist/tests/security/scanner-regression-guard.test.js.map +1 -1
- package/dist/tests/security/scanner-witness-sw1.test.d.ts +32 -0
- package/dist/tests/security/scanner-witness-sw1.test.d.ts.map +1 -0
- package/dist/tests/security/scanner-witness-sw1.test.js +255 -0
- package/dist/tests/security/scanner-witness-sw1.test.js.map +1 -0
- package/dist/tests/security/sensitive-path-adversarial-review.test.d.ts +2 -0
- package/dist/tests/security/sensitive-path-adversarial-review.test.d.ts.map +1 -0
- package/dist/tests/security/sensitive-path-adversarial-review.test.js +313 -0
- package/dist/tests/security/sensitive-path-adversarial-review.test.js.map +1 -0
- package/dist/tests/security/sensitive-path-boolean-flag.test.d.ts +2 -0
- package/dist/tests/security/sensitive-path-boolean-flag.test.d.ts.map +1 -0
- package/dist/tests/security/sensitive-path-boolean-flag.test.js +151 -0
- package/dist/tests/security/sensitive-path-boolean-flag.test.js.map +1 -0
- package/dist/tests/security/sensitive-path-fp.test.js +314 -0
- package/dist/tests/security/sensitive-path-fp.test.js.map +1 -1
- package/dist/tests/security/sensitive-path-prefixed-secrets.test.d.ts +2 -0
- package/dist/tests/security/sensitive-path-prefixed-secrets.test.d.ts.map +1 -0
- package/dist/tests/security/sensitive-path-prefixed-secrets.test.js +176 -0
- package/dist/tests/security/sensitive-path-prefixed-secrets.test.js.map +1 -0
- package/dist/tests/security/smi5879-corroboration.core.test.js +44 -0
- package/dist/tests/security/smi5879-corroboration.core.test.js.map +1 -1
- package/dist/tests/security/weak-password-lexicon.test.d.ts +18 -0
- package/dist/tests/security/weak-password-lexicon.test.d.ts.map +1 -0
- package/dist/tests/security/weak-password-lexicon.test.js +132 -0
- package/dist/tests/security/weak-password-lexicon.test.js.map +1 -0
- package/dist/tests/skill-scanner/allowlist.test.js +58 -4
- package/dist/tests/skill-scanner/allowlist.test.js.map +1 -1
- package/dist/tests/skill-scanner/categorizer.test.d.ts +2 -0
- package/dist/tests/skill-scanner/categorizer.test.d.ts.map +1 -0
- package/dist/tests/skill-scanner/categorizer.test.js +71 -0
- package/dist/tests/skill-scanner/categorizer.test.js.map +1 -0
- package/dist/tests/skill-scanner/cli-guard.test.d.ts +23 -0
- package/dist/tests/skill-scanner/cli-guard.test.d.ts.map +1 -0
- package/dist/tests/skill-scanner/cli-guard.test.js +71 -0
- package/dist/tests/skill-scanner/cli-guard.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.content.test.js +59 -0
- package/dist/tests/unit/services/skill-installation.content.test.js.map +1 -1
- package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.js +35 -14
- package/dist/tests/unit/services/skill-installation.io.client-agent-path.test.js.map +1 -1
- package/dist/tests/unit/services/skill-installation.io.rollback.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.test.js +984 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.js +148 -0
- package/dist/tests/unit/services/skill-installation.io.rollback.unit.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.error-codes.test.js +19 -0
- package/dist/tests/unit/services/skill-installation.service.error-codes.test.js.map +1 -1
- package/dist/tests/unit/services/skill-installation.service.finalize.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.service.finalize.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.finalize.test.js +147 -0
- package/dist/tests/unit/services/skill-installation.service.finalize.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.sensitive-path-fp.test.d.ts +29 -0
- package/dist/tests/unit/services/skill-installation.service.sensitive-path-fp.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.sensitive-path-fp.test.js +200 -0
- package/dist/tests/unit/services/skill-installation.service.sensitive-path-fp.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.service.test.js +43 -0
- package/dist/tests/unit/services/skill-installation.service.test.js.map +1 -1
- package/dist/tests/unit/services/skill-installation.target-guard.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.target-guard.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.target-guard.test.js +590 -0
- package/dist/tests/unit/services/skill-installation.target-guard.test.js.map +1 -0
- package/dist/tests/unit/services/skill-installation.uninstall.guard.test.d.ts +2 -0
- package/dist/tests/unit/services/skill-installation.uninstall.guard.test.d.ts.map +1 -0
- package/dist/tests/unit/services/skill-installation.uninstall.guard.test.js +473 -0
- package/dist/tests/unit/services/skill-installation.uninstall.guard.test.js.map +1 -0
- package/dist/tests/utils/safe-fs.test.js +129 -2
- package/dist/tests/utils/safe-fs.test.js.map +1 -1
- package/package.json +6 -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
|