diff --git a/.changeset/curious-hawks-forage.md b/.changeset/curious-hawks-forage.md new file mode 100644 index 000000000..70b9f34ef --- /dev/null +++ b/.changeset/curious-hawks-forage.md @@ -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) diff --git a/bin/install.js b/bin/install.js index 71ed04e28..8295b6392 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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; diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 5b2080792..f3dfb0203 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -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)' + ); + }); +}); diff --git a/tests/release-tarball-smoke.install.test.cjs b/tests/release-tarball-smoke.install.test.cjs index 7259e6193..1949ae624 100644 --- a/tests/release-tarball-smoke.install.test.cjs +++ b/tests/release-tarball-smoke.install.test.cjs @@ -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;