Files
codegraph/src/installer/targets/codex.ts
T
2f8cce5c57 fix(installer): honor CLAUDE_CONFIG_DIR and CODEX_HOME (#1627) (#1783)
* 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>

* fix(installer): honor CLAUDE_CONFIG_DIR for global Claude installs

Build on #1633 by @maxmilian and port the CLAUDE_CONFIG_DIR approach
from #1029 by @borfast onto the current installer. Keep the CODEX_HOME
cherry-pick cbb08231 intact.

Resolve non-blank Claude profile paths with path.resolve. Put the global
MCP JSON inside a custom profile while preserving ~/.claude.json for the
default profile. Settings, instructions, detection, and uninstall follow
the selected profile; local installs keep their existing paths.

Clear and restore CLAUDE_CONFIG_DIR in the setHome test harness. Cover
absolute and relative profiles, idempotency, unset/empty/blank fallback,
default-profile preservation, detection/uninstall, and local installs.
Combine the Unreleased note for both environment variables.

Thanks @seanchann for reporting the issue.

Validation on Linux with Node 22.19.0:
- npx tsc -p tsconfig.json
- npx vitest run __tests__/installer-targets.test.ts: 245 passed, 3 skipped
- Reproduced both failures against main e720f6ca; the rebuilt CLI writes
  all files into CLAUDE_CONFIG_DIR and CODEX_HOME with no ~/.claude,
  ~/.claude.json, or ~/.codex created.

Fixes #1627

---------

Co-authored-by: Max Hsu <maxmilian@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
2026-09-08 12:17:06 -05:00

204 lines
7.4 KiB
TypeScript

/**
* OpenAI Codex CLI target.
*
* - MCP server entry to `config.toml` as the dotted-key table
* `[mcp_servers.codegraph]`. TOML — not JSON — handled by the
* narrow serializer in `./toml.ts`.
* - Instructions to `AGENTS.md`.
*
* Both locations are supported (#1531):
* - 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`
*
* Codex has a first-class project config layer: `.codex/config.toml`
* is layer 4 of the loader's stack, above the user config (layer 6),
* merged recursively top-over-bottom
* (`codex-rs/config/src/loader/README.md` in openai/codex). It landed
* in openai/codex#8354 (2025-12-22), so the "Codex has no
* project-local config" note this file used to carry was never
* accurate. The project layer strips a denylist of settings that
* repo contents shouldn't get to choose (base URLs, model providers,
* `notify`, profiles, otel — `loader/mod.rs`), and `mcp_servers` is
* NOT on it, so a project-scoped `[mcp_servers.codegraph]` is honored.
*
* Caveat surfaced as an install note: project layers are "loaded but
* disabled when untrusted," so a local install only takes effect in a
* project the user has marked trusted.
*
* No permissions concept.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
atomicWriteFileSync,
getMcpServerConfig,
removeMarkedSection,
upsertInstructionsEntry,
} from './shared';
import {
CODEGRAPH_SECTION_END,
CODEGRAPH_SECTION_START,
} from '../instructions-template';
import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml';
const TOML_HEADER = 'mcp_servers.codegraph';
function configDir(loc: Location): string {
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');
}
function instructionsPath(loc: Location): string {
// Global AGENTS.md lives under ~/.codex/; project-local AGENTS.md
// lives at the project root (NOT under .codex/) — that's the file
// Codex reads for repo instructions, and it matches the local
// layout the opencode and gemini targets already use.
return loc === 'global'
? path.join(configDir('global'), 'AGENTS.md')
: path.join(process.cwd(), 'AGENTS.md');
}
/**
* Project layers are "loaded but disabled when untrusted" (openai/codex
* `loader/mod.rs`), so a local install can be written correctly and
* still do nothing. Say so rather than reporting silent success.
*/
function trustNote(): string {
return `Codex applies ${tomlConfigPath('local')} only in a project marked trusted — otherwise the layer is loaded but disabled. Trust this project in Codex to activate it.`;
}
class CodexTarget implements AgentTarget {
readonly id = 'codex' as const;
readonly displayName = 'Codex CLI';
readonly docsUrl = 'https://github.com/openai/codex';
supportsLocation(_loc: Location): boolean {
return true;
}
detect(loc: Location): DetectionResult {
const tomlPath = tomlConfigPath(loc);
let alreadyConfigured = false;
if (fs.existsSync(tomlPath)) {
try {
const content = fs.readFileSync(tomlPath, 'utf-8');
alreadyConfigured = content.includes(`[${TOML_HEADER}]`);
} catch { /* ignore */ }
}
// Global: ~/.codex/ existing means Codex has run here. Local: the
// project only counts as "Codex-enabled" once it actually has a
// .codex/ dir or config file of its own.
const installed = fs.existsSync(configDir(loc)) || fs.existsSync(tomlPath);
return { installed, alreadyConfigured, configPath: tomlPath };
}
install(loc: Location, _opts: InstallOptions): WriteResult {
const files: WriteResult['files'] = [];
files.push(writeMcpEntry(loc));
// AGENTS.md gets the short marker-fenced CodeGraph block (#704):
// subagents and non-MCP harnesses read AGENTS.md but never the MCP
// initialize instructions. Upsert self-heals a stale pre-#529 block.
files.push(upsertInstructionsEntry(instructionsPath(loc)));
return loc === 'local' ? { files, notes: [trustNote()] } : { files };
}
uninstall(loc: Location): WriteResult {
const files: WriteResult['files'] = [];
const tomlPath = tomlConfigPath(loc);
if (fs.existsSync(tomlPath)) {
const content = fs.readFileSync(tomlPath, 'utf-8');
const { content: nextContent, action } = removeTomlTable(content, TOML_HEADER);
if (action === 'removed') {
if (nextContent.trim() === '') {
try { fs.unlinkSync(tomlPath); } catch { /* ignore */ }
} else {
atomicWriteFileSync(tomlPath, nextContent.trimEnd() + '\n');
}
files.push({ path: tomlPath, action: 'removed' });
} else {
files.push({ path: tomlPath, action: 'not-found' });
}
} else {
files.push({ path: tomlPath, action: 'not-found' });
}
files.push(removeInstructionsEntry(loc));
return { files };
}
printConfig(loc: Location): string {
const block = buildCodegraphBlock();
return `# Add to ${tomlConfigPath(loc)}\n\n${block}\n`;
}
describePaths(loc: Location): string[] {
return [tomlConfigPath(loc), instructionsPath(loc)];
}
}
function buildCodegraphBlock(): string {
const mcp = getMcpServerConfig();
return buildTomlTable(TOML_HEADER, {
command: mcp.command,
args: mcp.args,
});
}
function writeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = tomlConfigPath(loc);
const dir = path.dirname(file);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const block = buildCodegraphBlock();
// Single read — `existing === ''` derives both "is the file empty
// or absent" and "what was its content," avoiding a TOCTOU window
// between two `fs.existsSync` calls.
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
const created = existing.length === 0;
const { content: nextContent, action } = upsertTomlTable(existing, TOML_HEADER, block);
if (action === 'unchanged') {
return { path: file, action: 'unchanged' };
}
atomicWriteFileSync(file, nextContent);
return { path: file, action: created ? 'created' : 'updated' };
}
/**
* Strip the marker-delimited CodeGraph block from this location's
* AGENTS.md if a prior install wrote one. Used by both install
* (self-heal on upgrade) and uninstall — see issue #529.
*/
function removeInstructionsEntry(loc: Location): WriteResult['files'][number] {
const file = instructionsPath(loc);
const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
return { path: file, action };
}
export const codexTarget: AgentTarget = new CodexTarget();