Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/codex/project-config-warnings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import path, { dirname, join, resolve } from "node:path";
import { expandUserPath } from "../config";
import { defaultCodexHome } from "./home";
Expand Down Expand Up @@ -252,9 +252,23 @@ export function discoverProjectCodexConfigPaths(options: {
} = {}): string[] {
const found = new Set<string>();
const codexConfigPath = options.codexConfigPath ?? resolveCodexConfigPath();
// A parent walk can reach the user's home and rediscover this global file as
// `$HOME/.codex/config.toml`; compare real paths so symlink aliases are excluded too.
const normalizeExistingPath = (candidate: string): string | null => {
if (!existsSync(candidate)) return null;
let canonical: string;
try {
canonical = realpathSync.native(candidate);
} catch {
canonical = resolve(candidate);
}
return process.platform === "win32" ? canonical.toLowerCase() : canonical;
};
const globalConfigIdentity = normalizeExistingPath(codexConfigPath);
const addIfExists = (projectRoot: string) => {
const path = join(resolve(projectRoot), ".codex", "config.toml");
if (existsSync(path)) found.add(path);
const candidate = join(resolve(projectRoot), ".codex", "config.toml");
const candidateIdentity = normalizeExistingPath(candidate);
if (candidateIdentity && candidateIdentity !== globalConfigIdentity) found.add(candidate);
};

let cwd = resolve(options.cwd ?? process.cwd());
Expand Down Expand Up @@ -339,7 +353,7 @@ export function summarizeProjectCodexIssue(warning: ProjectCodexConfigWarning):

function humanizeProviderDetail(detail: string): string {
if (detail === "opencode_go") return "OpenCode Go";
if (detail.startsWith("opencode")) return "OpenCode";
if (/^opencode(?:$|[-_.:/])/.test(detail)) return "OpenCode";
if (detail === "opencodex") return "OpenCodex";
return detail;
}
Expand Down
60 changes: 58 additions & 2 deletions tests/project-config-warnings.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, posix, win32 } from "node:path";
import {
analyzeProjectCodexConfig,
collectProjectCodexConfigWarnings,
discoverProjectCodexConfigPaths,
explainProjectConfigBypass,
isGlobalOpencodexRoutingActive,
invalidateProjectConfigDiagnosticsCache,
parseTrustedProjectPathsFromCodexConfig,
Expand Down Expand Up @@ -210,9 +212,41 @@ model_provider = "openai"
});

describe("collectProjectCodexConfigWarnings", () => {
test("does not discover the global config when walking through its parent directory", () => {
const userHome = join(testDir, "user-home");
const codexConfigPath = join(userHome, ".codex", "config.toml");
const projectDir = join(userHome, "work", "project");
const projectConfigPath = join(projectDir, ".codex", "config.toml");
const nestedCwd = join(projectDir, "nested");
mkdirSync(join(userHome, ".codex"), { recursive: true });
mkdirSync(join(projectDir, ".codex"), { recursive: true });
mkdirSync(nestedCwd, { recursive: true });
writeFileSync(codexConfigPath, `model_provider = "opencodex-retry"`);
writeFileSync(projectConfigPath, `model_provider = "anthropic"`);

expect(discoverProjectCodexConfigPaths({ cwd: nestedCwd, codexConfigPath }))
.toEqual([projectConfigPath]);
});

test("does not discover a project candidate that aliases the global config through a symlink", () => {
if (process.platform === "win32") return;
const userHome = join(testDir, "symlink-home");
const candidatePath = join(userHome, ".codex", "config.toml");
const globalAlias = join(testDir, "global-config-link.toml");
const projectDir = join(userHome, "work", "project");
mkdirSync(join(userHome, ".codex"), { recursive: true });
mkdirSync(projectDir, { recursive: true });
writeFileSync(candidatePath, `model_provider = "opencodex-retry"`);
symlinkSync(candidatePath, globalAlias);

expect(discoverProjectCodexConfigPaths({ cwd: projectDir, codexConfigPath: globalAlias }))
.not.toContain(candidatePath);
});

test("skips untrusted projects even when they define bypass config", () => {
const escaped = testDir.replace(/\\/g, "\\\\");
const projectDir = join(testDir, "proj");
const codexConfigPath = join(process.env.CODEX_HOME!, "config.toml");
writeGlobalRoutingConfig(`
[projects.'${escaped}\\proj']
trust_level = "untrusted"
Expand All @@ -223,7 +257,7 @@ model_provider = "anthropic"
[model_providers.anthropic]
name = "anthropic"
`);
expect(collectProjectCodexConfigWarnings()).toEqual([]);
expect(collectProjectCodexConfigWarnings({ cwd: testDir, codexConfigPath })).toEqual([]);
});

test("uncached collection reflects project config changes", () => {
Expand Down Expand Up @@ -252,3 +286,25 @@ name = "anthropic"
expect(second.length).toBe(0);
});
});

describe("explainProjectConfigBypass", () => {
const warningFor = (detail: string) => [{
path: "/repo/.codex/config.toml",
code: "model_provider_root" as const,
detail,
message: "fixture",
}];

test("humanizes OpenCode provider families only at an identifier boundary", () => {
expect(explainProjectConfigBypass(warningFor("opencode"))).toContain("uses OpenCode ");
expect(explainProjectConfigBypass(warningFor("opencode-go"))).toContain("uses OpenCode ");
expect(explainProjectConfigBypass(warningFor("opencode_go"))).toContain("uses OpenCode Go ");
});

test("does not mislabel OpenCodex-prefixed provider ids as OpenCode", () => {
expect(explainProjectConfigBypass(warningFor("opencodex"))).toContain("uses OpenCodex ");
expect(explainProjectConfigBypass(warningFor("opencodex-retry")))
.toContain("uses opencodex-retry ");
expect(explainProjectConfigBypass(warningFor("opencodeish"))).toContain("uses opencodeish ");
});
});
Loading