fix(installer): honor CODEX_HOME for the Codex global install (#1627)

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 <noreply@anthropic.com>
This commit is contained in:
Max Hsu
2026-08-28 12:53:33 +08:00
committed by Colby McHenry
parent e720f6ca53
commit cbb0823155
3 changed files with 101 additions and 4 deletions
+2
View File
@@ -145,6 +145,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Indexing no longer checks whether files outside your project exist. A relative import that points above the project directory (`../../something`) made CodeGraph probe that location on disk while resolving it. Nothing outside the project was ever read, and no such file was ever added to the index or linked to, but the check itself should not have happened — such an import now simply resolves to nothing. Symlinks inside your project that point at code kept elsewhere are unaffected and still index as before. Thanks @ErQrYfkrju. (#1631) - Indexing no longer checks whether files outside your project exist. A relative import that points above the project directory (`../../something`) made CodeGraph probe that location on disk while resolving it. Nothing outside the project was ever read, and no such file was ever added to the index or linked to, but the check itself should not have happened — such an import now simply resolves to nothing. Symlinks inside your project that point at code kept elsewhere are unaffected and still index as before. Thanks @ErQrYfkrju. (#1631)
- `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)
#### Screens, links and navigation #### Screens, links and navigation
- **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up. - **Where the app goes after login is a fork, not two always-es.** A navigation whose destination comes back from a helper — `router.replace(await resolvePostLoginRoute())` over `return (await hasSeenWelcome(…)) ? '/home/' : '/welcome/'` — drew both screens with no condition, reading as if the welcome screen always shows. The two arms share a line, and only a column can tell them apart; each synthesized edge now carries its literal's own position, so the guard reader says which arm it is: `WHEN await hasSeenWelcome(…)` → home, and its negation → welcome. And the scan starts at the helper's body, so a literal-union return type — `Promise<'/welcome/' | '/home/'>`, whose routes are string literals too, written first — no longer stands in for the navigation itself. Re-index after upgrading to pick the positions up.
+87
View File
@@ -40,6 +40,7 @@ function setHome(dir: string): { restore: () => void } {
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
HERMES_HOME: process.env.HERMES_HOME, HERMES_HOME: process.env.HERMES_HOME,
COPILOT_HOME: process.env.COPILOT_HOME, COPILOT_HOME: process.env.COPILOT_HOME,
CODEX_HOME: process.env.CODEX_HOME,
}; };
process.env.HOME = dir; process.env.HOME = dir;
process.env.USERPROFILE = dir; process.env.USERPROFILE = dir;
@@ -47,6 +48,7 @@ function setHome(dir: string): { restore: () => void } {
process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); process.env.XDG_CONFIG_HOME = path.join(dir, '.config');
delete process.env.HERMES_HOME; delete process.env.HERMES_HOME;
delete process.env.COPILOT_HOME; delete process.env.COPILOT_HOME;
delete process.env.CODEX_HOME;
return { return {
restore() { restore() {
if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; 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.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.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.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;
}, },
}; };
} }
@@ -2665,3 +2668,87 @@ describe('Installer targets — Copilot family', () => {
expect(jetbrains.detect('global').alreadyConfigured).toBe(true); 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);
});
});
+12 -4
View File
@@ -7,7 +7,8 @@
* - Instructions to `AGENTS.md`. * - Instructions to `AGENTS.md`.
* *
* Both locations are supported (#1531): * 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: `<cwd>/.codex/config.toml` + `<cwd>/AGENTS.md` * - local: `<cwd>/.codex/config.toml` + `<cwd>/AGENTS.md`
* *
* Codex has a first-class project config layer: `.codex/config.toml` * 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'; const TOML_HEADER = 'mcp_servers.codegraph';
function configDir(loc: Location): string { function configDir(loc: Location): string {
return loc === 'global' if (loc !== 'global') return path.join(process.cwd(), '.codex');
? path.join(os.homedir(), '.codex') // Codex resolves its user layer from `CODEX_HOME` and only falls back to
: path.join(process.cwd(), '.codex'); // `~/.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 { function tomlConfigPath(loc: Location): string {
return path.join(configDir(loc), 'config.toml'); return path.join(configDir(loc), 'config.toml');