* 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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user