From b327331747dea19a2e758f5ae5c6ca15a86453a9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 5 Sep 2026 08:06:19 -0400 Subject: [PATCH] fix(#4086): resolve skills/ manifest keys at the runtime's actual skills root (#4311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#4086): resolve skills/ manifest keys at the runtime's actual skills root Codex installs skills to ~/.agents/skills (skills-kind home override), but saveLocalPatches() and verify-reapply-patches.cjs resolved every manifest key config-dir-relative only — every skills/ key missed, so user modifications to Codex skills were never hash-compared, never backed up, and silently overwritten on update; the reapply verifier false-failed the same keys with fail_installed_missing. configDir stays first (non-override runtimes byte-identical); the skills root (same _resolveSkillsRootDir / skillsManifestPrefix seams the write side uses) is a containment-guarded fallback when the config-dir path is absent. * fix(#4086): drop unused test param; add changeset fragment * chore(#4086): backfill PR number in changeset fragment --------- Co-authored-by: agent-4086 --- .changeset/patient-ravens-cheer.md | 5 + bin/install.js | 44 ++++- gsd-core/bin/verify-reapply-patches.cjs | 73 +++++++- tests/install-write-confinement.test.cjs | 202 +++++++++++++++++++++++ tests/reapply-verify-hunks.test.cjs | 110 ++++++++++++ 5 files changed, 428 insertions(+), 6 deletions(-) create mode 100644 .changeset/patient-ravens-cheer.md diff --git a/.changeset/patient-ravens-cheer.md b/.changeset/patient-ravens-cheer.md new file mode 100644 index 000000000..15a84340c --- /dev/null +++ b/.changeset/patient-ravens-cheer.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4311 +--- +**Codex skill edits are backed up on update** — `gsd-file-manifest.json` skills paths now resolve at the runtime's real skills root (`~/.agents/skills`), so user modifications to Codex skills are detected, backed up to `gsd-local-patches/`, and verified by the reapply gate instead of being silently overwritten. (#4086) diff --git a/bin/install.js b/bin/install.js index dca6318f4..e312c8966 100755 --- a/bin/install.js +++ b/bin/install.js @@ -10170,19 +10170,57 @@ function saveLocalPatches(configDir, pristineCtx) { const modified = []; const pristineHashes = {}; + // #4086: skills/ manifest keys may live OUTSIDE configDir at the runtime's + // ACTUAL skills root — codex global installs to $HOME/.agents/skills (the + // ADR-1239 skills-kind `home` override), which writeManifest() already + // hashes from via _resolveSkillsRootDir (#2088/#3738). Resolving every key + // against configDir alone made every skills/ key miss here, so user + // modifications to Codex skills were never hash-compared, never backed up, + // and were silently overwritten by the next update. configDir stays FIRST + // (Postel: runtimes whose skills genuinely live under configDir resolve + // byte-identically to before); the skills root is a fallback, only for keys + // under the SAME descriptor-driven manifest prefix writeManifest uses, and + // only when that root resolves outside configDir. Containment + symlink + // guards apply to the alternate root too (resolveInstallRelativePath). + const patchRuntime = (pristineCtx && pristineCtx.runtime) || manifest.runtime || null; + const patchScope = manifest.scope === 'local' ? 'local' : 'global'; + let skillsRedirect = null; + if (patchRuntime) { + const skillsRoot = _resolveSkillsRootDir(patchRuntime, configDir, patchScope); + const resolvedConfig = path.resolve(configDir); + if ( + skillsRoot && + skillsRoot !== resolvedConfig && + !skillsRoot.startsWith(resolvedConfig + path.sep) + ) { + const prefix = _hostBehaviors(patchRuntime).skillsManifestPrefix || 'skills/'; + skillsRedirect = { root: skillsRoot, prefix }; + } + } + for (const [relPath, originalHash] of Object.entries(manifest.files || {})) { const safeRef = resolveInstallRelativePath(configDir, relPath); if (!safeRef) continue; const { relPath: safeRelPath, fullPath } = safeRef; - if (!fs.existsSync(fullPath)) continue; - const currentHash = fileHash(fullPath); + let installedPath = fullPath; + if (!fs.existsSync(installedPath) && skillsRedirect && safeRelPath.startsWith(skillsRedirect.prefix)) { + const altRef = resolveInstallRelativePath( + skillsRedirect.root, + safeRelPath.slice(skillsRedirect.prefix.length) + ); + if (altRef && fs.existsSync(altRef.fullPath)) { + installedPath = altRef.fullPath; + } + } + if (!fs.existsSync(installedPath)) continue; + const currentHash = fileHash(installedPath); if (currentHash !== originalHash) { // Back up the user's modified version const backupRef = resolveInstallRelativePath(patchesDir, safeRelPath); if (!backupRef) continue; const backupPath = backupRef.fullPath; fs.mkdirSync(path.dirname(backupPath), { recursive: true }); - fs.copyFileSync(fullPath, backupPath); + fs.copyFileSync(installedPath, backupPath); modified.push(safeRelPath); pristineHashes[safeRelPath] = originalHash; } diff --git a/gsd-core/bin/verify-reapply-patches.cjs b/gsd-core/bin/verify-reapply-patches.cjs index a495337c6..08170a0c1 100755 --- a/gsd-core/bin/verify-reapply-patches.cjs +++ b/gsd-core/bin/verify-reapply-patches.cjs @@ -184,9 +184,72 @@ const REASON = Object.freeze({ FAIL_USER_LINES_MISSING: 'fail_user_lines_missing', }); -function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashes }) { +/** + * #4086: resolve where a backed-up file's INSTALLED counterpart lives. + * Primary is the config-dir-relative join (the manifest key's native form). + * For skills/ keys of a runtime whose skills kind declares a `home` override + * (codex global → $HOME/.agents/skills), the file lives OUTSIDE configDir, so + * when the primary path is absent we fall back to the runtime's ACTUAL skills + * root — derived from the same `resolveRuntimeArtifactLayout` seam the + * installer writes through. Returns the primary path unchanged whenever the + * fallback is impossible (no manifest, no runtime, no override, no file). + */ +function resolveInstalledPath(configDir, relPath, skillsRedirect) { + const primary = path.join(configDir, relPath); + if (!skillsRedirect) return primary; + const hashKey = relPath.replace(/\\/g, '/'); + if (!hashKey.startsWith(skillsRedirect.prefix)) return primary; + if (fs.existsSync(primary)) return primary; + const alt = path.join(skillsRedirect.root, hashKey.slice(skillsRedirect.prefix.length)); + return fs.existsSync(alt) ? alt : primary; +} + +/** + * #4086: build the skills-root fallback descriptor for a config dir from its + * own gsd-file-manifest.json (runtime + scope) and the runtime-artifact + * layout seam. Returns null whenever any step is unavailable — the verifier + * then behaves exactly as before (config-dir-relative only). Lazy + guarded + * require: runtime-artifact-layout.cjs is a built lib; a layout-unaware + * invocation must never crash the gate. + */ +function resolveSkillsRedirect(configDir) { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(configDir, 'gsd-file-manifest.json'), 'utf8'), + ); + if (!manifest || typeof manifest.runtime !== 'string' || !manifest.runtime) return null; + const scope = manifest.scope === 'local' ? 'local' : 'global'; + const { resolveRuntimeArtifactLayout } = require('./lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout(manifest.runtime, configDir, scope); + const kind = layout.kinds.find((k) => k.kind === 'skills'); + if (!kind) return null; + const root = path.resolve(path.join(kind.home || configDir, kind.destSubpath)); + const resolvedConfig = path.resolve(configDir); + if (root === resolvedConfig || root.startsWith(resolvedConfig + path.sep)) return null; + // Same descriptor-driven manifest prefix writeManifest uses for skills + // keys (hermes nests under 'skills/gsd/', everyone else 'skills/'), read + // from the same shipped registry the installer's _resolveHostBehaviors + // consults — never a re-derived literal (generative-fix-divergence guard). + let prefix = 'skills/'; + try { + const { runtimes: registryRuntimes } = require('./lib/capability-registry.cjs'); + const declared = registryRuntimes + && registryRuntimes[manifest.runtime] + && registryRuntimes[manifest.runtime].runtime + && registryRuntimes[manifest.runtime].runtime.hostBehaviors; + if (declared && typeof declared.skillsManifestPrefix === 'string') { + prefix = declared.skillsManifestPrefix; + } + } catch { /* keep default prefix */ } + return { root, prefix }; + } catch { + return null; + } +} + +function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect }) { const backupPath = path.join(patchesDir, relPath); - const installedPath = path.join(configDir, relPath); + const installedPath = resolveInstalledPath(configDir, relPath, skillsRedirect); const result = { file: relPath, status: 'ok', missing: [], reason: null }; if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isFile()) { @@ -336,6 +399,9 @@ function main() { // Bug #3657: read pristine_hashes from backup-meta.json once and share // across all per-file verifications so each can detect drift independently. const pristineHashes = readPristineHashes(opts.patchesDir); + // #4086: skills-root fallback for runtimes whose skills kind declares a + // `home` override outside configDir (codex global → $HOME/.agents/skills). + const skillsRedirect = resolveSkillsRedirect(opts.configDir); const results = files.map((relPath) => verifyFile({ relPath, @@ -343,6 +409,7 @@ function main() { configDir: opts.configDir, pristineDir: opts.pristineDir, pristineHashes, + skillsRedirect, }), ); @@ -396,4 +463,4 @@ if (require.main === module) { runMain(main); } -module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON, readPristineHashes, sha256 }; +module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect }; diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index a7fcda4e0..4d16689e6 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -3811,3 +3811,205 @@ describe('#3712 in-process home confinement', () => { `codex skills must NOT resolve inside the ambient home ${ambientHome}, got ${dest}`); }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded regression block — #4086 (Codex skills manifest keys never resolve) +// Codex installs skills to the skills-kind `home` override (~/.agents/skills), +// outside configDir (~/.codex). writeManifest() hashes skills from the real +// location, but saveLocalPatches() resolved every manifest key against +// configDir only — every skills/ key missed, so user modifications to Codex +// skills were never hash-compared, never backed up, silently overwritten. +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:bug-4086-codex-skills-manifest-paths', () => { +'use strict'; + +const { test, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const ROOT = path.join(__dirname, '..'); +const INSTALL = require(path.join(ROOT, 'bin', 'install.js')); +const { cleanup, sandboxHome, scrubConfigLocationEnv } = require('./helpers.cjs'); + +const MANIFEST_NAME = 'gsd-file-manifest.json'; +const PATCHES_DIR_NAME = 'gsd-local-patches'; + +function sha256(content) { + return crypto.createHash('sha256').update(content, 'utf8').digest('hex'); +} + +function writeManifestFile(configDir, files, extra = {}) { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, MANIFEST_NAME), + JSON.stringify({ version: '1.12.0', timestamp: new Date().toISOString(), files, ...extra }, null, 2), + ); +} + +describe('Bug #4086: saveLocalPatches resolves skills/ manifest keys at the runtime skills root', () => { + let tmpDir; + let homeDir; + let configDir; + let skillsRoot; + let restoreConfigEnv; + let fakeSrcDir; + + beforeEach((t) => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4086-')); + homeDir = path.join(tmpDir, 'home'); + fs.mkdirSync(homeDir, { recursive: true }); + sandboxHome(t, homeDir); + restoreConfigEnv = scrubConfigLocationEnv(); + configDir = path.join(homeDir, '.codex'); + // Same join the layout uses for codex's skills-kind home override + // (#3712 block above pins that this is $HOME/.agents/skills). + skillsRoot = path.join(homeDir, '.agents', 'skills'); + fakeSrcDir = path.join(tmpDir, 'pkg-src'); + fs.mkdirSync(fakeSrcDir, { recursive: true }); + t.after(() => { + restoreConfigEnv(); + cleanup(tmpDir); + }); + }); + + test('saveLocalPatches backs up a modified Codex skill installed under ~/.agents/skills (#4086)', () => { + const pristine = '---\nname: gsd-x\ndescription: stock skill body line for hashing\n---\nstock\n'; + const modified = pristine + '\n'; + const relKey = 'skills/gsd-x/SKILL.md'; + fs.mkdirSync(path.join(skillsRoot, 'gsd-x'), { recursive: true }); + fs.writeFileSync(path.join(skillsRoot, 'gsd-x', 'SKILL.md'), modified); + writeManifestFile(configDir, { [relKey]: sha256(pristine) }, { runtime: 'codex', scope: 'global' }); + + const modifiedList = INSTALL.saveLocalPatches(configDir, { + packageSrc: fakeSrcDir, + runtime: 'codex', + pathPrefix: '', + isGlobal: true, + }); + + assert.deepEqual(modifiedList, [relKey], 'the modified skill must be detected via the skills root'); + const backup = path.join(configDir, PATCHES_DIR_NAME, relKey); + assert.ok(fs.existsSync(backup), `backup must exist at ${PATCHES_DIR_NAME}/${relKey}`); + assert.equal(fs.readFileSync(backup, 'utf8'), modified, 'backup must hold the user-modified bytes'); + const meta = JSON.parse(fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8')); + assert.deepEqual(meta.files, [relKey]); + }); + + test('unmodified Codex skill under ~/.agents/skills produces no patch (#4086)', () => { + const pristine = '---\nname: gsd-x\ndescription: stock skill body line for hashing\n---\nstock\n'; + const relKey = 'skills/gsd-x/SKILL.md'; + fs.mkdirSync(path.join(skillsRoot, 'gsd-x'), { recursive: true }); + fs.writeFileSync(path.join(skillsRoot, 'gsd-x', 'SKILL.md'), pristine); + writeManifestFile(configDir, { [relKey]: sha256(pristine) }, { runtime: 'codex', scope: 'global' }); + + const modifiedList = INSTALL.saveLocalPatches(configDir, { + packageSrc: fakeSrcDir, + runtime: 'codex', + pathPrefix: '', + isGlobal: true, + }); + + assert.deepEqual(modifiedList, [], 'no false positive for an unmodified skill at the skills root'); + }); + + test('config-dir-relative skills keep resolving against configDir first (#4086)', () => { + const pristine = '---\nname: gsd-x\ndescription: stock claude skill body line\n---\nstock\n'; + const modified = pristine + '\n'; + const relKey = 'skills/gsd-x/SKILL.md'; + // claude has NO skills home override — skills live under configDir itself. + const claudeConfig = path.join(homeDir, '.claude'); + fs.mkdirSync(path.join(claudeConfig, 'skills', 'gsd-x'), { recursive: true }); + fs.writeFileSync(path.join(claudeConfig, 'skills', 'gsd-x', 'SKILL.md'), modified); + writeManifestFile(claudeConfig, { [relKey]: sha256(pristine) }, { runtime: 'claude', scope: 'global' }); + + const modifiedList = INSTALL.saveLocalPatches(claudeConfig, { + packageSrc: fakeSrcDir, + runtime: 'claude', + pathPrefix: '', + isGlobal: true, + }); + + assert.deepEqual(modifiedList, [relKey], 'config-dir-relative skills are detected exactly as before'); + }); + + test('configDir copy wins when both locations exist (#4086)', () => { + const pristine = '---\nname: gsd-x\ndescription: stock skill body line for hashing\n---\nstock\n'; + const configCopy = pristine + '\n'; + const rootCopy = pristine + '\n'; + const relKey = 'skills/gsd-x/SKILL.md'; + fs.mkdirSync(path.join(configDir, 'skills', 'gsd-x'), { recursive: true }); + fs.writeFileSync(path.join(configDir, 'skills', 'gsd-x', 'SKILL.md'), configCopy); + fs.mkdirSync(path.join(skillsRoot, 'gsd-x'), { recursive: true }); + fs.writeFileSync(path.join(skillsRoot, 'gsd-x', 'SKILL.md'), rootCopy); + writeManifestFile(configDir, { [relKey]: sha256(pristine) }, { runtime: 'codex', scope: 'global' }); + + const modifiedList = INSTALL.saveLocalPatches(configDir, { + packageSrc: fakeSrcDir, + runtime: 'codex', + pathPrefix: '', + isGlobal: true, + }); + + assert.deepEqual(modifiedList, [relKey]); + const backup = path.join(configDir, PATCHES_DIR_NAME, relKey); + assert.equal(fs.readFileSync(backup, 'utf8'), configCopy, 'configDir copy must be hashed/backed up, not the skills-root copy'); + }); + + test('legacy runtime-less manifest is tolerated (#4086)', () => { + const pristine = '---\nname: gsd-x\ndescription: stock skill body line for hashing\n---\nstock\n'; + const relKey = 'skills/gsd-x/SKILL.md'; + fs.mkdirSync(path.join(skillsRoot, 'gsd-x'), { recursive: true }); + fs.writeFileSync(path.join(skillsRoot, 'gsd-x', 'SKILL.md'), pristine + 'user line\n'); + // No runtime field, and the caller passes no pristineCtx.runtime either + // (legacy callers) — the redirect is impossible; behave as before (skip). + writeManifestFile(configDir, { [relKey]: sha256(pristine) }); + + let modifiedList; + assert.doesNotThrow(() => { + modifiedList = INSTALL.saveLocalPatches(configDir, {}); + }, 'a runtime-less manifest must not crash saveLocalPatches'); + assert.deepEqual(modifiedList, [], 'without a runtime the old skip behavior applies'); + }); + + test('end-to-end codex reinstall backs up the modified skill (#4086)', { timeout: 120_000 }, () => { + const origLog = console.log; + const origWarn = console.warn; + console.log = () => {}; + console.warn = () => {}; + try { + INSTALL.install(true, 'codex'); + } finally { + console.log = origLog; + console.warn = origWarn; + } + const manifest = JSON.parse(fs.readFileSync(path.join(configDir, MANIFEST_NAME), 'utf8')); + const skillKeys = Object.keys(manifest.files).filter((k) => k.startsWith('skills/')); + assert.ok(skillKeys.length > 0, 'codex global install must record skills/ manifest keys'); + const skillAbs = skillKeys.map((k) => path.join(homeDir, '.agents', k)); + assert.ok( + skillKeys.every((k, i) => !fs.existsSync(path.join(configDir, k)) && fs.existsSync(skillAbs[i])), + 'installed skills must live under ~/.agents, not under configDir (the #4086 premise)', + ); + // User modifies one installed skill, then reinstalls. + fs.appendFileSync(skillAbs[0], '\n\n'); + try { + console.log = () => {}; + console.warn = () => {}; + INSTALL.install(true, 'codex'); + } finally { + console.log = origLog; + console.warn = origWarn; + } + const backup = path.join(configDir, PATCHES_DIR_NAME, skillKeys[0]); + assert.ok(fs.existsSync(backup), `reinstall must back the modified skill up at ${PATCHES_DIR_NAME}/${skillKeys[0]}`); + assert.match(fs.readFileSync(backup, 'utf8'), /user edit 4086 e2e marker line/); + }); +}); + }); +} diff --git a/tests/reapply-verify-hunks.test.cjs b/tests/reapply-verify-hunks.test.cjs index 785c1ee47..748e56ad5 100644 --- a/tests/reapply-verify-hunks.test.cjs +++ b/tests/reapply-verify-hunks.test.cjs @@ -1060,3 +1060,113 @@ describe('Bug #934: OK_NO_BASELINE when recordedHash present but pristine file a }); }); } + + +// ──────────────────────────────────────────────────────────────────────── +// Folded regression block — #4086 (verifier resolves skills/ entries at the +// runtime's ACTUAL skills root). Codex installs skills to ~/.agents/skills; +// verifyFile() joined every relPath against configDir only, so legacy +// Codex skills/ patch entries reported fail_installed_missing even though +// the file existed at its real location. +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:bug-4086-verify-reapply-skills-root', () => { +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { cleanup, scrubConfigLocationEnv } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); + +const ROOT = path.join(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'gsd-core', 'bin', 'verify-reapply-patches.cjs'); +const { REASON } = require(SCRIPT); + +let tmpRoot; +let patchesDir; +let configDir; +let savedHome; +let savedUserProfile; +let restoreConfigEnv; + +function writeFile(absPath, content) { + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content); +} + +function runVerifier() { + const r = runNode([ + SCRIPT, + '--patches-dir', patchesDir, + '--config-dir', configDir, + '--json', + ], { timeoutMs: 60_000 }); + return { + status: r.exitCode, + report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, + }; +} + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4086-vfy-')); + patchesDir = path.join(tmpRoot, 'patches'); + configDir = path.join(tmpRoot, 'home', '.codex'); + fs.mkdirSync(patchesDir, { recursive: true }); + fs.mkdirSync(configDir, { recursive: true }); + // Sandbox HOME so the codex skills-kind home override resolves inside the + // fixture (~/.agents/skills), not the developer's real home. + const home = path.join(tmpRoot, 'home'); + savedHome = process.env.HOME; + savedUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + restoreConfigEnv = scrubConfigLocationEnv(); +}); + +afterEach(() => { + restoreConfigEnv(); + process.env.HOME = savedHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + cleanup(tmpRoot); +}); + +describe('Bug #4086: verifyFile resolves skills entries at the runtime skills root', () => { + test('verifyFile resolves skills entries at the runtime\'s skills root (#4086)', () => { + const key = 'skills/gsd-x/SKILL.md'; + writeFile(path.join(patchesDir, key), 'stock body line for the patch backup\nuser-added line that must survive merges\n'); + writeFile(path.join(tmpRoot, 'home', '.agents', key), 'stock body line for the patch backup\nuser-added line that must survive merges\n'); + // Manifest tells the verifier which runtime/scope owns this configDir. + writeFile(path.join(configDir, 'gsd-file-manifest.json'), JSON.stringify({ + version: '1.12.0', runtime: 'codex', scope: 'global', files: {}, + })); + + const { status, report } = runVerifier(); + assert.equal(status, 0, `gate must pass; got report ${JSON.stringify(report)}`); + assert.equal(report.failures, 0); + const r0 = report.results[0]; + assert.equal(r0.status, 'ok'); + assert.notEqual(r0.reason, REASON.FAIL_INSTALLED_MISSING); + }); + + test('verifyFile still fails when the skill file is genuinely missing (#4086)', () => { + const key = 'skills/gsd-gone/SKILL.md'; + writeFile(path.join(patchesDir, key), 'stock body line for the patch backup\nuser-added line that must survive merges\n'); + writeFile(path.join(configDir, 'gsd-file-manifest.json'), JSON.stringify({ + version: '1.12.0', runtime: 'codex', scope: 'global', files: {}, + })); + + const { status, report } = runVerifier(); + assert.equal(status, 1); + const r0 = report.results[0]; + assert.equal(r0.file.replace(/\\/g, '/'), key); + assert.equal(r0.status, 'fail'); + assert.equal(r0.reason, REASON.FAIL_INSTALLED_MISSING); + }); +}); + }); +}