fix(#4667): rewrite codex @ includes to the codex install root (#4858)

* 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:
Tom Boucher
2026-09-18 11:36:36 -04:00
committed by GitHub
parent e1f72cd324
commit 58c7bbb16a
4 changed files with 181 additions and 2 deletions

View 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)

View File

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

View File

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

View File

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