Skip to content
Closed
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@
}
},
"explicit": {
"project-config-warning-snapshot.test.ts": "codex-integration",
"pnpm-command-isolation.test.ts": "update", "provider-antigravity-quota-retry.test.ts": "providers",
"responses-compaction-recovery.test.ts": "responses", "compaction-recovery-settings.test.ts": "config", "responses-compaction-recovery-policy.test.ts": "responses", "plugin-loader.test.ts": "lib", "plugin-upstream-hooks.test.ts": "lib",
"cli-kiro-auto-selection.test.ts": "cli", "codebuddy-live-models.test.ts": "providers", "kiro-auto-selection.test.ts": "providers/kiro", "kiro-quota-metrics.test.ts": "providers/kiro", "management-provider-request-pacing.test.ts": "server",
Expand Down
12 changes: 7 additions & 5 deletions src/codex/desktop-switches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,16 @@ export function describeCodexDesktopSwitches(
*/
export async function observedCodexDesktopSwitchApply(): Promise<CodexDesktopSwitchApply> {
// Same lazy boundary as applyCodexConfigInjection: the ownership predicate lives in the
// injection graph, which the settings read path must not pull in at module scope.
const { currentExternalCodexModelProvider } = await import("./inject/config-toml");
// injection graph, which the settings read path must not pull in at module scope. This
// path uses the bounded variant — a special or oversized config.toml must answer
// "undetermined", never stall a settings read the way an unbounded readFileSync would.
const { observedExternalCodexModelProvider } = await import("./inject/config-toml");
let provider: string | null;
try {
provider = currentExternalCodexModelProvider();
provider = observedExternalCodexModelProvider();
} catch (error) {
// A present-but-unreadable config.toml (permissions, deletion racing existsSync)
// must not take down the whole settings report. The undetermined reason keeps the
// A present-but-unreadable config.toml (permissions, deletion racing the bounded
// read) must not take down the whole settings report. The undetermined reason keeps the
// reporting contract honest: effective values and the sign-in answer stay null instead
// of presenting local state a foreign provider may still control.
return {
Expand Down
69 changes: 69 additions & 0 deletions src/codex/inject/bounded-config-reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { closeSync, constants, fstatSync, openSync, readSync, statSync, type Stats } from "node:fs";

const MAX_CODEX_CONFIG_BYTES = 1024 * 1024;

/**
* Read config.toml the way Codex and the injector resolve it — a symlink's target IS the
* config — without blocking on a special file or buffering without bound.
*
* `null` means only "absent at the initial lookup". A path that vanishes or is swapped
* underneath the read throws the changed-file error instead, because the observation is
* then undetermined rather than negative: the caller must not report a config the probe
* watched disappear as simply not there.
*/
export function readBoundedCodexConfig(path: string): string | null {
let fd: number | undefined;
try {
let namedBefore: Stats;
try {
namedBefore = statSync(path);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") return null;
throw error;
}
if (!namedBefore.isFile() || namedBefore.size > MAX_CODEX_CONFIG_BYTES) {
throw new Error("config.toml is not a bounded regular file");
}
// Deliberately no O_NOFOLLOW: Codex and the injector read through a symlinked
// config.toml, so refusing the link here would disagree with the writes this probe
// stands in front of. O_NONBLOCK is what keeps a FIFO — linked or direct — from
// stalling the open; the descriptor checks below still reject anything non-regular.
const guardedFlags = process.platform === "win32"
? 0
: (constants.O_NONBLOCK ?? 0);
fd = openSync(path, constants.O_RDONLY | guardedFlags);
const before = fstatSync(fd);
if (before.dev !== namedBefore.dev || before.ino !== namedBefore.ino) {
throw new Error("config.toml changed while it was read");
}
if (!before.isFile() || before.size > MAX_CODEX_CONFIG_BYTES) {
throw new Error("config.toml is not a bounded regular file");
}

const buffer = Buffer.allocUnsafe(before.size + 1);
let bytesRead = 0;
while (bytesRead < buffer.length) {
const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, null);
if (count === 0) break;
bytesRead += count;
}
const after = fstatSync(fd);
const namedAfter = statSync(path);
if (bytesRead !== before.size || after.size !== before.size
|| after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
|| !namedAfter.isFile()
|| namedAfter.dev !== before.dev || namedAfter.ino !== before.ino) {
throw new Error("config.toml changed while it was read");
}
return buffer.toString("utf8", 0, bytesRead);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
throw new Error("config.toml changed while it was read");
}
throw error;
} finally {
if (fd !== undefined) closeSync(fd);
}
}
18 changes: 18 additions & 0 deletions src/codex/inject/config-toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
resolveCodexConfigPath,
tomlString,
} from "../paths";
import { readBoundedCodexConfig } from "./bounded-config-reader";
import {
type CodexRoutingTarget,
providerBaseHost,
Expand All @@ -33,11 +34,28 @@ export function externalCodexModelProvider(content: string): string | null {
: null;
}

/**
* The ownership answer for read/write paths — inject, sync, connect, restore, and the
* shutdown gate. It deliberately reads the whole file like Codex does (links included):
* a large or link-mediated config is still a valid config, and these callers must
* classify it exactly rather than degrade to "undetermined".
*/
export function currentExternalCodexModelProvider(): string | null {
if (!existsSync(CODEX_CONFIG_PATH)) return null;
return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8"));
}

/**
* The same ownership answer for read-only observation (the settings GET / poll path),
* through a bounded read so a special or oversized config.toml cannot stall a request.
* A present-but-unreadable config throws so the caller reports undetermined ownership
* instead of "none".
*/
export function observedExternalCodexModelProvider(): string | null {
const content = readBoundedCodexConfig(CODEX_CONFIG_PATH);
return content === null ? null : externalCodexModelProvider(content);
}

/**
* Detect the file's dominant line ending. Every transform in this module is LF-pure
* (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are
Expand Down
97 changes: 75 additions & 22 deletions src/codex/project-config-warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import {
fstatSync,
lstatSync,
openSync,
readFileSync,
readSync,
realpathSync,
} from "node:fs";
import path, { dirname, join, resolve } from "node:path";
import { expandUserPath } from "../config";
import { defaultCodexHome } from "./home";
import { readBoundedCodexConfig } from "./inject/bounded-config-reader";
import { readRootTomlString } from "./paths";
import { truncateRetainedUtf8 } from "../lib/admission";

Expand Down Expand Up @@ -53,7 +53,8 @@ function resolveCodexConfigPath(): string {
return join(home, "config.toml");
}

export type ProjectCodexConfigIssueCode = "model_providers_table" | "profile_selector" | "model_provider_root";
export type ProjectCodexConfigIssueCode = "model_providers_table" | "profile_selector" | "model_provider_root"
| "global_config_unreadable";

export interface ProjectCodexConfigWarning {
path: string;
Expand Down Expand Up @@ -265,17 +266,18 @@ export function resolveEffectiveProjectModelProvider(content: string): Effective
/** True when global Codex config routes through the opencodex proxy. */
export function isGlobalOpencodexRoutingActive(
codexConfigPath: string = resolveCodexConfigPath(),
content?: string,
content?: string | null,
): boolean {
let text = content;
if (text === undefined) {
if (!existsSync(codexConfigPath)) return false;
try {
text = readFileSync(codexConfigPath, "utf-8");
text = readBoundedCodexConfig(codexConfigPath) ?? undefined;
} catch {
return false;
}
if (text === undefined) return false;
}
if (text === null) return false;
if (hasInjectedOpenaiBaseUrl(text)) return true;
if (readRootTomlString(text, "model_provider") === "opencodex") return true;
return false;
Expand Down Expand Up @@ -379,6 +381,8 @@ export function discoverProjectCodexConfigPaths(options: {
cwd?: string;
codexConfigPath?: string;
maxWalkParents?: number;
/** Explicit null keeps an absent/unreadable observation; undefined permits a fresh read. */
globalContent?: string | null;
} = {}): string[] {
const found = new Set<string>();
const codexConfigPath = options.codexConfigPath ?? resolveCodexConfigPath();
Expand Down Expand Up @@ -416,15 +420,16 @@ export function discoverProjectCodexConfigPaths(options: {
cwd = parent;
}

if (existsSync(codexConfigPath)) {
try {
const global = readFileSync(codexConfigPath, "utf-8");
try {
const global = options.globalContent === undefined
? readBoundedCodexConfig(codexConfigPath) : options.globalContent;
if (global !== null) {
for (const projectPath of parseTrustedProjectPathsFromCodexConfig(global)) {
addIfExists(projectPath);
}
} catch {
/* ignore unreadable global config */
}
} catch {
/* ignore unreadable global config */
}

return [...found];
Expand All @@ -437,10 +442,32 @@ export function collectProjectCodexConfigWarnings(options: {
} = {}): ProjectCodexConfigWarning[] {
const codexConfigPath = options.codexConfigPath ?? resolveCodexConfigPath();
const requireRouting = options.requireOpencodexRouting ?? true;
if (requireRouting && !isGlobalOpencodexRoutingActive(codexConfigPath)) return [];

// The routing question has three answers: active, inactive, and unreadable. An oversized
// or swapped-underneath global config must not silently collapse to "inactive" — that
// would erase both project-bypass coverage and trusted-path discovery without a trace.
let globalContent: string | null = null;
let globalUnreadable = false;
try {
globalContent = readBoundedCodexConfig(codexConfigPath);
} catch {
globalUnreadable = true;
}
if (requireRouting && !globalUnreadable
&& !isGlobalOpencodexRoutingActive(codexConfigPath, globalContent)) {
return [];
}

const warnings: ProjectCodexConfigWarning[] = [];
for (const path of discoverProjectCodexConfigPaths({ cwd: options.cwd, codexConfigPath })) {
if (globalUnreadable) {
warnings.push({
path: codexConfigPath,
code: "global_config_unreadable",
detail: "unreadable",
message: "The global Codex config could not be read within the 1 MiB bound — whether it routes through OpenCodex, and which projects it declares trusted, is undetermined.",
});
}
for (const path of discoverProjectCodexConfigPaths({ cwd: options.cwd, codexConfigPath, globalContent })) {
const content = readBoundedProjectConfig(path);
if (content !== null) warnings.push(...analyzeProjectCodexConfig(content, path));
}
Expand Down Expand Up @@ -480,6 +507,8 @@ export function summarizeProjectCodexIssue(warning: ProjectCodexConfigWarning):
return warning.profileName ? `profile="${warning.profileName}"` : `model_provider="${warning.detail}"`;
case "model_provider_root":
return `model_provider="${warning.detail}"`;
case "global_config_unreadable":
return "config.toml unreadable or oversized";
}
}

Expand All @@ -501,6 +530,8 @@ export interface ProjectCodexConfigWarningGroup {
path: string;
issues: string[];
bypass: string;
/** True when the group is the global-config-unreadable caveat, not a project bypass. */
globalUnreadable?: boolean;
}

export function groupProjectCodexConfigWarningsByPath(
Expand All @@ -512,34 +543,56 @@ export function groupProjectCodexConfigWarningsByPath(
list.push(warning);
grouped.set(warning.path, list);
}
return [...grouped.entries()].map(([path, pathWarnings]) => ({
path,
issues: pathWarnings.map(summarizeProjectCodexIssue),
bypass: explainProjectConfigBypass(pathWarnings),
}));
return [...grouped.entries()].map(([path, pathWarnings]) => {
const globalUnreadable = pathWarnings.every(warning => warning.code === "global_config_unreadable");
return {
path,
issues: pathWarnings.map(summarizeProjectCodexIssue),
bypass: globalUnreadable ? pathWarnings[0]!.message : explainProjectConfigBypass(pathWarnings),
...(globalUnreadable ? { globalUnreadable } : {}),
};
});
}

export function formatProjectCodexConfigWarningsForDoctor(warnings: ProjectCodexConfigWarning[]): string[] {
const grouped = groupProjectCodexConfigWarningsByPath(warnings);
if (grouped.length === 0) return [];
const lines: string[] = [];
for (const { path, issues, bypass } of grouped) {
let hasBypassEntries = false;
for (const { path, issues, bypass, globalUnreadable } of grouped) {
lines.push(` -- ${relPath(path)} — ${issues.join(", ")}`);
lines.push(` ${bypass}`);
if (globalUnreadable) {
lines.push(" fix: keep the global config.toml a readable regular file within the 1 MiB bound");
} else {
hasBypassEntries = true;
}
}
if (hasBypassEntries) {
lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project");
}
lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project");
return lines;
}

export function formatProjectCodexConfigWarningsForConsole(warnings: ProjectCodexConfigWarning[]): string[] {
const grouped = groupProjectCodexConfigWarningsByPath(warnings);
if (grouped.length === 0) return [];
const lines = ["⚠️ Project Codex config bypasses OpenCodex:"];
for (const { path, issues, bypass } of grouped) {
const lines = [grouped.some(entry => entry.globalUnreadable)
? "⚠️ Codex configuration warnings:"
: "⚠️ Project Codex config bypasses OpenCodex:"];
let hasBypassEntries = false;
for (const { path, issues, bypass, globalUnreadable } of grouped) {
lines.push(` ${relPath(path)} — ${issues.join(", ")}`);
lines.push(` ${bypass}`);
if (globalUnreadable) {
lines.push(" fix: keep the global config.toml a readable regular file within the 1 MiB bound");
} else {
hasBypassEntries = true;
}
}
if (hasBypassEntries) {
lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project");
}
lines.push(" fix: remove those entries so OpenCodex proxy routing applies in this project");
return lines;
}

Expand Down
12 changes: 6 additions & 6 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,12 @@ provider with no name and rejects the whole config rather than one thread, which
than the branding it would remove — so a blank, over-length, or control-character value falls back
to the default instead of being written.

Read-only doctor and project-routing diagnostics use a lightweight root/table TOML reader rather
than mutating or normalizing the user's file. That reader must lexically skip both basic and literal
multiline string bodies: instruction prose can contain key-shaped examples and `[table]` snippets,
which are data rather than configuration. Diagnostic result objects may retain the real path for
local correlation, but every formatted doctor line must pass it through the shared user-path
redaction boundary before display.
Read-only global ownership/doctor diagnostics follow links only to bounded regular files; an absent
lookup reads as none and an unreadable/changed observation reports undetermined ownership.
Project discovery instead skips links/oversized entries, and its guarded reader skips unsafe files.
Each project-warning collection shares one global snapshot for routing and trusted-path discovery,
including explicit absence or read failure. TOML parsing skips multiline string bodies rather than
reading prose as configuration; formatted doctor paths pass through user-path redaction.

> Decision record: [ADR-0017](decisions/ADR-0017-config-injection.md)

Expand Down
Loading
Loading