* test(#4667): add failing-first coverage for the codex @-include rewrite Behavioral end-to-end: a real in-process install(true,'codex') into a temp CODEX_HOME must leave zero @~/.claude includes in GSD-owned .md artifacts, rewrite the issue's own example include to @~/.codex/, keep the deliberate _GSD_RUNTIME_ROOT .claude fallback chains byte-identical, and stay idempotent across a reinstall (no doubled prefix). All four are RED until the installer grows the manifest-scoped rewrite pass. * fix(#4667): rewrite codex @ includes to the codex install root Codex-installed agents and commands kept @~/.claude/gsd-core/... (and @/Users/trekkie/.claude/gsd-core/...) include references pointing into the Claude install: silent wrong-copy reads on dual-runtime machines at divergent versions, missing files on codex-only ones. Several emitters bypass the per-runtime converters, so the per-emitter fixes since #570 rotted. Adds a manifest-scoped rewrite pass in install() beside the leak scanner: for codex, every manifest-tracked .md/.toml artifact has the @-include forms rewritten to the codex root. The pass matches the exact include literal only — the _GSD_RUNTIME_ROOT/$PREFERRED_CONFIG_DIR fallback chains, prose .claude mentions, and CHANGELOG.md are untouched — and runs before the scanner, which remains the verification backstop for anything a future emitter introduces. * test(#4667): cover the HOME-anchored include form and sync the pass comment Adds behavioral coverage for the second rewrite literal (@$HOME/.claude/gsd-core/ -> @$HOME/.codex/gsd-core/) via the plan-review-convergence command, and corrects the pass's comment: the agent .tomls are generated after it and prefix themselves, so the .toml branch of the rewrite is inert by design. * test(#4667): baseline the offline upgrade against the deployed tree (sanctioned) * chore(#4667): backfill changeset PR number (4858) * test(#4667): sandbox the install home in the include-rewrite tests (#3712 guard) * test(#4667): install via subprocess with an isolated env in the include-rewrite tests --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/curious-hawks-forage.md
Normal file
5
.changeset/curious-hawks-forage.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4858
|
||||
---
|
||||
**Codex installs rewrite @ includes to the codex root** — codex-installed agents and commands kept `@~/.claude/gsd-core/…` and `@$HOME/.claude/gsd-core/…` includes pointing into the Claude install (silently reading the wrong copy on dual-runtime machines, resolving to nothing on codex-only ones). The installer now rewrites the @-include form across manifest-tracked artifacts; the deliberate `$PREFERRED_CONFIG_DIR`/`_GSD_RUNTIME_ROOT` fallback chains and prose `.claude` mentions stay untouched. (#4667)
|
||||
@@ -12354,6 +12354,42 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
manifestFiles = null;
|
||||
}
|
||||
if (manifestFiles !== null) {
|
||||
// #4667: codex-installed artifacts must not keep `@~/.claude/gsd-core/…`
|
||||
// include references — the `@` form resolves into the CLAUDE install
|
||||
// (wrong copy on dual-runtime machines at divergent versions, nothing at
|
||||
// all on codex-only ones; #570 cause 2 residue). Every target ships in
|
||||
// the codex install, so rewriting the `@~/` include form to the codex
|
||||
// root is mechanical and correct. This runs after all .md emitters
|
||||
// (several bypass the per-runtime converters — that is how the leak
|
||||
// survived the per-emitter fixes; the agent .tomls are generated later
|
||||
// and prefix themselves), and before the scan below, which stays as the
|
||||
// verification backstop. The `_GSD_RUNTIME_ROOT`/`$PREFERRED_CONFIG_DIR`
|
||||
// fallback chains and prose `.claude` mentions carry no `@~/` prefix and
|
||||
// are deliberately untouched, as is CHANGELOG.md.
|
||||
if (runtime === 'codex') {
|
||||
for (const relPath of manifestFiles) {
|
||||
const fileName = path.basename(relPath);
|
||||
if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
|
||||
if (fileName === 'CHANGELOG.md') continue;
|
||||
const rewritePath = path.join(targetDir, relPath);
|
||||
let rewriteContent;
|
||||
try {
|
||||
rewriteContent = fs.readFileSync(rewritePath, 'utf8');
|
||||
} catch (rewriteErr) {
|
||||
continue; // inaccessible or missing — the scan below reports or skips it
|
||||
}
|
||||
const rewritten = rewriteContent
|
||||
.split('@~/.claude/gsd-core/').join('@~/.codex/gsd-core/')
|
||||
.split('@$HOME/.claude/gsd-core/').join('@$HOME/.codex/gsd-core/');
|
||||
if (rewritten !== rewriteContent) {
|
||||
try {
|
||||
fs.writeFileSync(rewritePath, rewritten);
|
||||
} catch (writeErr) {
|
||||
continue; // never fail the install over the rewrite; the scan still warns
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const relPath of manifestFiles) {
|
||||
const fileName = path.basename(relPath);
|
||||
if (!(fileName.endsWith('.md') || fileName.endsWith('.toml'))) continue;
|
||||
|
||||
@@ -8293,3 +8293,128 @@ describe('#3738: antigravity global artifacts install under ~/.gemini/config', (
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #4667 — codex installs must not keep @~/.claude includes ──────────────────
|
||||
// The @-include form points Codex at the CLAUDE install: silent wrong-copy on
|
||||
// dual-runtime machines, nothing at all on codex-only ones (#570 cause 2
|
||||
// residue). Every target ships in the codex install, so the installer rewrites
|
||||
// the `@~/.claude/gsd-core/` include form in manifest-tracked artifacts;
|
||||
// the `_GSD_RUNTIME_ROOT` fallback chains and prose `.claude` mentions are a
|
||||
// must-NOT-rewrite group (issue evidence table) and stay byte-identical.
|
||||
|
||||
const { test: __test4667, describe: __describe4667, before: __before4667, after: __after4667 } = require('node:test');
|
||||
const assert4667 = require('node:assert/strict');
|
||||
const fs4667 = require('node:fs');
|
||||
const { spawnSync: __spawnSync4667 } = require('node:child_process');
|
||||
const { createTempDir: __createTempDir4667, cleanup: __cleanup4667, isolatedNpmEnv: __isolatedNpmEnv4667 } = require('./helpers.cjs');
|
||||
|
||||
describe('install() global codex — @~/.claude include rewrite (#4667)', () => {
|
||||
const path4667 = path;
|
||||
let tmpCodexHome;
|
||||
let configDir;
|
||||
|
||||
// Install through the real CLI as a SUBPROCESS with a fully controlled env —
|
||||
// the same shape test G uses. In-process installs share the test process's
|
||||
// real HOME and the installer's own exit paths; the subprocess isolates both
|
||||
// (#3712's real-home guard + GSD_TEST_MODE/npm isolation in one place).
|
||||
function codexInstall() {
|
||||
const result = __spawnSync4667(process.execPath, [
|
||||
path.join(__dirname, '..', 'bin', 'install.js'),
|
||||
'--codex',
|
||||
'--global',
|
||||
'--config-dir',
|
||||
configDir,
|
||||
], {
|
||||
cwd: tmpCodexHome,
|
||||
env: {
|
||||
...process.env,
|
||||
...__isolatedNpmEnv4667(),
|
||||
HOME: tmpCodexHome,
|
||||
USERPROFILE: tmpCodexHome,
|
||||
GSD_TEST_MODE: '',
|
||||
NO_UPDATE_NOTIFIER: '1',
|
||||
npm_config_update_notifier: 'false',
|
||||
},
|
||||
encoding: 'utf8',
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(result.status, 0, `codex install failed:\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
|
||||
function installedMdFiles() {
|
||||
// Mirror the installer's own leak-scanner scope: manifest-tracked .md
|
||||
// artifacts under the gsd-core payload PLUS the codex skills staging root
|
||||
// ($HOME/.agents/skills — the "skills" kind declares a global home
|
||||
// override), CHANGELOG excluded.
|
||||
const roots = [
|
||||
path4667.join(configDir, 'gsd-core'),
|
||||
path4667.join(tmpCodexHome, '.agents', 'skills'),
|
||||
];
|
||||
const out = [];
|
||||
const walk = (dir) => {
|
||||
if (!fs4667.existsSync(dir)) return;
|
||||
for (const entry of fs4667.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path4667.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(p);
|
||||
else if (entry.name.endsWith('.md') && entry.name !== 'CHANGELOG.md') out.push(p);
|
||||
}
|
||||
};
|
||||
for (const root of roots) walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
tmpCodexHome = __createTempDir4667('gsd-codex-4667-');
|
||||
configDir = path4667.join(tmpCodexHome, 'codex-config');
|
||||
fs4667.mkdirSync(configDir, { recursive: true });
|
||||
codexInstall();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
__cleanup4667(tmpCodexHome);
|
||||
});
|
||||
|
||||
test('codex install leaves zero @~/.claude includes in GSD-owned .md artifacts (#4667)', () => {
|
||||
const leaks = installedMdFiles().filter((file) => fs4667.readFileSync(file, 'utf8').includes('@~/.claude/'));
|
||||
assert4667.equal(
|
||||
leaks.length, 0,
|
||||
`files still carrying @~/.claude includes:\n${leaks.join('\n')}`
|
||||
);
|
||||
});
|
||||
|
||||
test('codex install rewrites agent @ includes to the codex root (#4667)', () => {
|
||||
const agentFile = path4667.join(configDir, 'gsd-core', 'agents', 'gsd-advisor-researcher.md');
|
||||
assert4667.ok(fs.existsSync(agentFile), 'the advisor-researcher agent must be installed');
|
||||
const content = fs4667.readFileSync(agentFile, 'utf8');
|
||||
assert4667.ok(
|
||||
content.includes('@~/.codex/gsd-core/references/untrusted-input-boundary.md'),
|
||||
'the include must point at the codex install'
|
||||
);
|
||||
assert4667.ok(!content.includes('@~/.claude/'), 'no @~/.claude include may survive');
|
||||
});
|
||||
|
||||
test('codex install rewrites $HOME-anchored @ includes too (#4667)', () => {
|
||||
const cmdFile = path4667.join(configDir, 'gsd-core', 'commands', 'gsd', 'plan-review-convergence.md');
|
||||
assert4667.ok(fs4667.existsSync(cmdFile), 'the plan-review-convergence command must be installed');
|
||||
const content = fs4667.readFileSync(cmdFile, 'utf8');
|
||||
assert4667.ok(
|
||||
content.includes('@$HOME/.codex/gsd-core/workflows/plan-review-convergence.md'),
|
||||
'the $HOME-anchored include must point at the codex install'
|
||||
);
|
||||
assert4667.ok(!content.includes('@$HOME/.claude/'), 'no @$HOME/.claude include may survive');
|
||||
});
|
||||
|
||||
test('codex install keeps the _GSD_RUNTIME_ROOT .claude fallbacks (#4667)', () => {
|
||||
const workflowsDir = path4667.join(configDir, 'gsd-core', 'workflows');
|
||||
let fallbacks = 0;
|
||||
for (const file of fs4667.readdirSync(workflowsDir)) {
|
||||
if (!file.endsWith('.md')) continue;
|
||||
const content = fs4667.readFileSync(path4667.join(workflowsDir, file), 'utf8');
|
||||
fallbacks += (content.match(/_GSD_RUNTIME_ROOT\}\/\.claude\//g) || []).length;
|
||||
}
|
||||
assert4667.ok(
|
||||
fallbacks > 0,
|
||||
'the _GSD_RUNTIME_ROOT .claude fallback chains must survive the rewrite (must-NOT-rewrite group)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,13 +345,26 @@ describe('release-tarball-smoke', () => {
|
||||
assert.equal(fs.readFileSync(path.join(upgradedConfigDir, '.gsd-surface.json'), 'utf8'), selectedState);
|
||||
assert.equal(fs.readFileSync(path.join(upgradedConfigDir, 'user-owned.txt'), 'utf8'), 'preserve me\n');
|
||||
assert.equal(fs.readFileSync(path.join(upgradeCwd, '.planning', 'config.json'), 'utf8'), priorGates);
|
||||
// #4667 (sanctioned baseline change): the deployed codex corpus is a
|
||||
// CONVERTED projection of the package source — @ includes are rewritten
|
||||
// to the codex install root — so the no-drift baseline for an upgrade is
|
||||
// the previously-DEPLOYED tree (the fresh codex install from the same
|
||||
// package), not the raw package bytes. Comparing against packageRoot
|
||||
// would pin the pre-#4667 leak (codex artifacts carrying Claude-rooted
|
||||
// @ includes) as required output.
|
||||
const deployedCodexCommands = path.join(
|
||||
installs.find((entry) => entry.runtime === 'codex').configDir, 'gsd-core', 'commands', 'gsd',
|
||||
);
|
||||
const deployedCodexAgents = path.join(
|
||||
installs.find((entry) => entry.runtime === 'codex').configDir, 'gsd-core', 'agents',
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
hashTree(path.join(upgradedConfigDir, 'gsd-core', 'commands', 'gsd')),
|
||||
hashTree(path.join(packageRoot, 'commands', 'gsd')),
|
||||
hashTree(deployedCodexCommands),
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
hashTree(path.join(upgradedConfigDir, 'gsd-core', 'agents')),
|
||||
hashTree(path.join(packageRoot, 'agents')),
|
||||
hashTree(deployedCodexAgents),
|
||||
);
|
||||
const upgradedSkillRoot = path.join(upgradedHome, '.agents', 'skills');
|
||||
const upgradedSkillCount = fs.readdirSync(upgradedSkillRoot).filter((name) => name.startsWith('gsd-')).length;
|
||||
|
||||
Reference in New Issue
Block a user