From 136e1b27f8d9d03a549753d3ab63c6e8d0dbd9be Mon Sep 17 00:00:00 2001 From: Max Hsu Date: Fri, 28 Aug 2026 12:53:33 +0800 Subject: [PATCH] fix(installer): honor CODEX_HOME for the Codex global install (#1627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex resolves its user layer from `CODEX_HOME` and only falls back to `~/.codex`. The target hardcoded the fallback, so a user on a custom profile got a correct install into a directory Codex never reads — the MCP entry, the AGENTS.md block, and detect() all pointed at the wrong profile, and the failure is silent. Resolve the global config dir from `CODEX_HOME` when set and non-blank, mirroring what the copilot-cli target already does for `COPILOT_HOME`. Only the user layer moves; the project layer (#1531) stays anchored to the project. The test harness now also clears `CODEX_HOME` in setHome() alongside HERMES_HOME/COPILOT_HOME — without that, the existing codex tests fail on a developer machine that has the variable exported. Note this is only half of #1627: the CLAUDE_CONFIG_DIR half is already covered by the open PR #1029, which this deliberately does not touch. Co-Authored-By: Claude --- CHANGELOG.md | 4 ++ __tests__/installer-targets.test.ts | 87 +++++++++++++++++++++++++++++ src/installer/targets/codex.ts | 16 ++++-- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..8975508e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- `codegraph install` now writes Codex's global setup where Codex actually reads it. If you point Codex at a custom profile directory with `CODEX_HOME`, the MCP entry and the instructions block went to `~/.codex` instead — written correctly, and then never read, so CodeGraph silently never loaded for you. `codegraph install --location=local` is unchanged: a project's Codex config still lives beside the project. Thanks @seanchann. (#1627) + ## [1.6.0] - 2026-08-26 diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 4ec3e5903..7e52500c9 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -40,6 +40,7 @@ function setHome(dir: string): { restore: () => void } { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, HERMES_HOME: process.env.HERMES_HOME, COPILOT_HOME: process.env.COPILOT_HOME, + CODEX_HOME: process.env.CODEX_HOME, }; process.env.HOME = dir; process.env.USERPROFILE = dir; @@ -47,6 +48,7 @@ function setHome(dir: string): { restore: () => void } { process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); delete process.env.HERMES_HOME; delete process.env.COPILOT_HOME; + delete process.env.CODEX_HOME; return { restore() { if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; @@ -55,6 +57,7 @@ function setHome(dir: string): { restore: () => void } { if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME; if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME; if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME; + if (prev.CODEX_HOME === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = prev.CODEX_HOME; }, }; } @@ -2481,3 +2484,87 @@ describe('Installer targets — Copilot family', () => { expect(jetbrains.detect('global').alreadyConfigured).toBe(true); }); }); + +describe('Installer targets — Codex CODEX_HOME override (#1627)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + const defaultDir = () => path.join(tmpHome, '.codex'); + + it('global install writes to $CODEX_HOME, not ~/.codex', () => { + const custom = path.join(tmpHome, 'codex-profile'); + process.env.CODEX_HOME = custom; + + const codex = getTarget('codex')!; + const result = codex.install('global', { autoAllow: false }); + + const toml = result.files.find((f) => f.path.endsWith('config.toml'))!; + expect(path.resolve(toml.path)).toBe(path.resolve(path.join(custom, 'config.toml'))); + expect(fs.readFileSync(path.join(custom, 'config.toml'), 'utf-8')).toContain('[mcp_servers.codegraph]'); + // The global AGENTS.md follows the config dir. + expect(fs.existsSync(path.join(custom, 'AGENTS.md'))).toBe(true); + // Nothing of ours may land in the default profile Codex is not reading. + expect(fs.existsSync(defaultDir())).toBe(false); + }); + + it('detect and uninstall follow $CODEX_HOME too', () => { + const custom = path.join(tmpHome, 'codex-profile'); + process.env.CODEX_HOME = custom; + const codex = getTarget('codex')!; + + expect(codex.detect('global').alreadyConfigured).toBe(false); + codex.install('global', { autoAllow: false }); + + const detected = codex.detect('global'); + expect(detected.alreadyConfigured).toBe(true); + expect(path.resolve(detected.configPath!)).toBe(path.resolve(path.join(custom, 'config.toml'))); + + const removed = codex.uninstall('global'); + expect(path.resolve(removed.files.find((f) => f.path.endsWith('config.toml'))!.path)) + .toBe(path.resolve(path.join(custom, 'config.toml'))); + // Our table was the only content, so the file goes with it. + expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false); + }); + + it('falls back to ~/.codex when CODEX_HOME is unset or blank', () => { + const codex = getTarget('codex')!; + codex.install('global', { autoAllow: false }); + expect(fs.existsSync(path.join(defaultDir(), 'config.toml'))).toBe(true); + + fs.rmSync(defaultDir(), { recursive: true, force: true }); + process.env.CODEX_HOME = ' '; // set-but-empty must not become the config dir + codex.install('global', { autoAllow: false }); + expect(fs.existsSync(path.join(defaultDir(), 'config.toml'))).toBe(true); + }); + + it('leaves the local install alone — CODEX_HOME is the user layer only (#1531)', () => { + const custom = path.join(tmpHome, 'codex-profile'); + process.env.CODEX_HOME = custom; + + const codex = getTarget('codex')!; + const result = codex.install('local', { autoAllow: false }); + + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + expect(paths.some((p) => p.endsWith('/.codex/config.toml'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), '.codex', 'config.toml'))).toBe(true); + // The project layer lives beside the project, never under the user profile. + expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false); + }); +}); diff --git a/src/installer/targets/codex.ts b/src/installer/targets/codex.ts index d5e361ce3..797acff1e 100644 --- a/src/installer/targets/codex.ts +++ b/src/installer/targets/codex.ts @@ -7,7 +7,8 @@ * - Instructions to `AGENTS.md`. * * Both locations are supported (#1531): - * - global: `~/.codex/config.toml` + `~/.codex/AGENTS.md` + * - global: `$CODEX_HOME/config.toml` + `$CODEX_HOME/AGENTS.md`, + * falling back to `~/.codex` when the env var is unset (#1627) * - local: `/.codex/config.toml` + `/AGENTS.md` * * Codex has a first-class project config layer: `.codex/config.toml` @@ -53,9 +54,16 @@ import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml'; const TOML_HEADER = 'mcp_servers.codegraph'; function configDir(loc: Location): string { - return loc === 'global' - ? path.join(os.homedir(), '.codex') - : path.join(process.cwd(), '.codex'); + if (loc !== 'global') return path.join(process.cwd(), '.codex'); + // Codex resolves its user layer from `CODEX_HOME` and only falls back to + // `~/.codex` (#1627). Installing to the fallback while Codex reads the + // override is a silent no-op: the files are written, and Codex never looks + // at them. Same resolution the copilot-cli target already does for + // `COPILOT_HOME`. Only the user layer moves — the project layer below is + // anchored to the project, not the profile. + const override = process.env.CODEX_HOME; + if (override && override.trim().length > 0) return override; + return path.join(os.homedir(), '.codex'); } function tomlConfigPath(loc: Location): string { return path.join(configDir(loc), 'config.toml');