fix(#4086): resolve skills/ manifest keys at the runtime's actual skills root (#4311)

* 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 <agent-4086@local>
This commit is contained in:
Tom Boucher
2026-09-05 08:06:19 -04:00
committed by GitHub
parent 70f22e4643
commit b327331747
5 changed files with 428 additions and 6 deletions

View File

@@ -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 + '<!-- user edit 4086: a substantial marker line -->\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 + '<!-- user edit 4086: claude skill marker line -->\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 + '<!-- user edit at configDir copy 4086 -->\n';
const rootCopy = pristine + '<!-- DIFFERENT user edit at skills root 4086 -->\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<!-- user edit 4086 e2e marker line -->\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/);
});
});
});
}

View File

@@ -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);
});
});
});
}