From 8ba63950b204130fb494c9ca01dc8634714bbb8b Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sat, 26 Sep 2026 09:40:22 +0000 Subject: [PATCH 1/6] fix(settings): bound Codex ownership config reads --- src/codex/inject/bounded-config-reader.ts | 45 +++++++++++++++++++ src/codex/inject/config-toml.ts | 7 +-- structure/config.md | 12 ++--- .../settings-desktop-switch-apply.test.ts | 36 +++++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 src/codex/inject/bounded-config-reader.ts diff --git a/src/codex/inject/bounded-config-reader.ts b/src/codex/inject/bounded-config-reader.ts new file mode 100644 index 00000000000..513116041ac --- /dev/null +++ b/src/codex/inject/bounded-config-reader.ts @@ -0,0 +1,45 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; + +const MAX_CODEX_CONFIG_BYTES = 1024 * 1024; + +/** Read config.toml without following links, blocking on special files, or buffering without bound. */ +export function readBoundedCodexConfig(path: string): string | null { + let fd: number | undefined; + try { + const namedBefore = lstatSync(path); + if (namedBefore.isSymbolicLink() || !namedBefore.isFile() + || namedBefore.size > MAX_CODEX_CONFIG_BYTES) { + throw new Error("config.toml is not a bounded regular file"); + } + const guardedFlags = process.platform === "win32" + ? 0 + : (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); + fd = openSync(path, constants.O_RDONLY | guardedFlags); + const before = fstatSync(fd); + 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 = lstatSync(path); + if (bytesRead !== before.size || after.size !== before.size + || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs + || namedAfter.isSymbolicLink() || !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) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } finally { + if (fd !== undefined) closeSync(fd); + } +} diff --git a/src/codex/inject/config-toml.ts b/src/codex/inject/config-toml.ts index e91c055bb26..f87b658ea7b 100644 --- a/src/codex/inject/config-toml.ts +++ b/src/codex/inject/config-toml.ts @@ -1,5 +1,5 @@ // Holds INV-TOML-01 from structure/overview.md; keep the id here if this file is split or renamed. -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { contextCompatibleBaseLine } from "../context-compat"; import { resolveEffectiveProjectModelProvider } from "../project-config-warnings"; import { @@ -18,6 +18,7 @@ import { resolveCodexConfigPath, tomlString, } from "../paths"; +import { readBoundedCodexConfig } from "./bounded-config-reader"; import { type CodexRoutingTarget, providerBaseHost, @@ -34,8 +35,8 @@ export function externalCodexModelProvider(content: string): string | null { } export function currentExternalCodexModelProvider(): string | null { - if (!existsSync(CODEX_CONFIG_PATH)) return null; - return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8")); + const content = readBoundedCodexConfig(CODEX_CONFIG_PATH); + return content === null ? null : externalCodexModelProvider(content); } /** diff --git a/structure/config.md b/structure/config.md index 5866cef58e2..a079e0b7d65 100644 --- a/structure/config.md +++ b/structure/config.md @@ -202,12 +202,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 ownership, doctor, and project-routing diagnostics use bounded regular-file reads rather +than mutating or normalizing the user's file; ownership observation also refuses links and opens +nonblocking before inspecting the descriptor. The TOML 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 formatted doctor lines pass it through user-path redaction. > Decision record: [ADR-0017](decisions/ADR-0017-config-injection.md) diff --git a/tests/config/settings-desktop-switch-apply.test.ts b/tests/config/settings-desktop-switch-apply.test.ts index a72a28cd184..b04ec0238a5 100644 --- a/tests/config/settings-desktop-switch-apply.test.ts +++ b/tests/config/settings-desktop-switch-apply.test.ts @@ -214,6 +214,42 @@ test("GET /api/settings survives an unreadable config.toml during ownership dete } }, 15_000); +test.skipIf(process.platform === "win32")( + "GET /api/settings refuses a config.toml FIFO without blocking", + () => { + const root = mkdtempSync(join(tmpdir(), "ocx-settings-fifo-cfg-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + const fifo = spawnSync("mkfifo", [join(codexHome, "config.toml")], { encoding: "utf8" }); + expect(fifo.status).toBe(0); + + try { + const response = runIsolatedSettingsRequest({ + root, + codexHome, + routeConfig: ISOLATED_PROVIDER_CONFIG, + scriptBody: ` + const request = new Request("http://127.0.0.1:10100/api/settings", { + headers: { host: "127.0.0.1:10100" }, + }); + const response = await handleManagementAPI(request, new URL(request.url), config, { + getCachedStartupHealth: async () => startupHealthFixture(), + }); + `, + }); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + codexDesktopSwitches: { + apply: { applied: false, reason: "ownership_undetermined", retryable: true }, + }, + }); + } finally { + removeTreeWithRetry(root); + } + }, + 15_000, +); + test("PUT /api/settings keeps the undetermined-ownership explanation on a locked save", () => { // clientIntegrations.codex = false trips the apply gate before the injector runs, and an // unreadable config.toml leaves ownership undetermined. The locked save must still report From 125aa6d27f58fa91791521490e43c2fdd12f9c3f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:50:18 +0000 Subject: [PATCH 2/6] fix(codex): keep the ownership probe honest for linked or large configs The bounded settings reader disagreed with the paths it stands in front of: it refused symlinks that Codex and the injector read through, threw on valid configs over 1 MiB inside restore's ownership check, and mapped a mid-read ENOENT to "absent" so settings reported local state for a config it watched disappear. - readBoundedCodexConfig resolves links to a bounded regular target (stat/fstat identity, O_NONBLOCK open) and reserves null for absent at the initial lookup; a later ENOENT/ENOTDIR is a changed-file error. - currentExternalCodexModelProvider returns to Codex's own full read for the read/write paths (inject, sync, connect, restore, shutdown) so a large or link-mediated config classifies exactly; the bounded read now serves observedExternalCodexModelProvider on the settings GET path. - doctor / project-routing diagnostics read the global config through the same bounded reader instead of an unbounded readFileSync. Co-Authored-By: Epinephrine --- src/codex/desktop-switches.ts | 12 +- src/codex/inject/bounded-config-reader.ts | 39 ++++- src/codex/inject/config-toml.ts | 19 ++- src/codex/project-config-warnings.ts | 16 +- structure/config.md | 10 +- .../settings-desktop-switch-apply.test.ts | 160 +++++++++++++++++- 6 files changed, 227 insertions(+), 29 deletions(-) diff --git a/src/codex/desktop-switches.ts b/src/codex/desktop-switches.ts index 3bd7384b156..a4b99a85fd8 100644 --- a/src/codex/desktop-switches.ts +++ b/src/codex/desktop-switches.ts @@ -116,14 +116,16 @@ export function describeCodexDesktopSwitches( */ export async function observedCodexDesktopSwitchApply(): Promise { // 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 { diff --git a/src/codex/inject/bounded-config-reader.ts b/src/codex/inject/bounded-config-reader.ts index 513116041ac..6da870fb4ae 100644 --- a/src/codex/inject/bounded-config-reader.ts +++ b/src/codex/inject/bounded-config-reader.ts @@ -1,19 +1,37 @@ -import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { closeSync, constants, fstatSync, openSync, readSync, statSync, type Stats } from "node:fs"; const MAX_CODEX_CONFIG_BYTES = 1024 * 1024; -/** Read config.toml without following links, blocking on special files, or buffering without bound. */ +/** + * 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 { - const namedBefore = lstatSync(path); - if (namedBefore.isSymbolicLink() || !namedBefore.isFile() - || namedBefore.size > MAX_CODEX_CONFIG_BYTES) { + 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_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); + : (constants.O_NONBLOCK ?? 0); fd = openSync(path, constants.O_RDONLY | guardedFlags); const before = fstatSync(fd); if (!before.isFile() || before.size > MAX_CODEX_CONFIG_BYTES) { @@ -28,16 +46,19 @@ export function readBoundedCodexConfig(path: string): string | null { bytesRead += count; } const after = fstatSync(fd); - const namedAfter = lstatSync(path); + const namedAfter = statSync(path); if (bytesRead !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs - || namedAfter.isSymbolicLink() || !namedAfter.isFile() + || !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) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + 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); diff --git a/src/codex/inject/config-toml.ts b/src/codex/inject/config-toml.ts index f87b658ea7b..761c09549dd 100644 --- a/src/codex/inject/config-toml.ts +++ b/src/codex/inject/config-toml.ts @@ -1,5 +1,5 @@ // Holds INV-TOML-01 from structure/overview.md; keep the id here if this file is split or renamed. -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { contextCompatibleBaseLine } from "../context-compat"; import { resolveEffectiveProjectModelProvider } from "../project-config-warnings"; import { @@ -34,7 +34,24 @@ 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); } diff --git a/src/codex/project-config-warnings.ts b/src/codex/project-config-warnings.ts index 41a11f78649..1539534f985 100644 --- a/src/codex/project-config-warnings.ts +++ b/src/codex/project-config-warnings.ts @@ -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"; @@ -269,12 +269,12 @@ export function isGlobalOpencodexRoutingActive( ): 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 (hasInjectedOpenaiBaseUrl(text)) return true; if (readRootTomlString(text, "model_provider") === "opencodex") return true; @@ -416,15 +416,15 @@ export function discoverProjectCodexConfigPaths(options: { cwd = parent; } - if (existsSync(codexConfigPath)) { - try { - const global = readFileSync(codexConfigPath, "utf-8"); + try { + const global = readBoundedCodexConfig(codexConfigPath); + if (global !== null) { for (const projectPath of parseTrustedProjectPathsFromCodexConfig(global)) { addIfExists(projectPath); } - } catch { - /* ignore unreadable global config */ } + } catch { + /* ignore unreadable global config */ } return [...found]; diff --git a/structure/config.md b/structure/config.md index a079e0b7d65..7a316484ae2 100644 --- a/structure/config.md +++ b/structure/config.md @@ -203,11 +203,11 @@ than the branding it would remove — so a blank, over-length, or control-charac to the default instead of being written. Read-only ownership, doctor, and project-routing diagnostics use bounded regular-file reads rather -than mutating or normalizing the user's file; ownership observation also refuses links and opens -nonblocking before inspecting the descriptor. The TOML 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 formatted doctor lines pass it through user-path redaction. +than mutating or normalizing the user's file: the read resolves links to a bounded regular target, +opens nonblocking, and verifies the descriptor before and after — absent at lookup reads as none, +changed or unreadable reports undetermined ownership. The TOML reader must lexically skip basic and +literal multiline string bodies (prose holds key-shaped examples, `[table]` snippets — data, not +configuration), and formatted doctor lines pass retained real paths through user-path redaction. > Decision record: [ADR-0017](decisions/ADR-0017-config-injection.md) diff --git a/tests/config/settings-desktop-switch-apply.test.ts b/tests/config/settings-desktop-switch-apply.test.ts index b04ec0238a5..297189fffe6 100644 --- a/tests/config/settings-desktop-switch-apply.test.ts +++ b/tests/config/settings-desktop-switch-apply.test.ts @@ -1,8 +1,9 @@ import { expect, spyOn, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { readBoundedCodexConfig } from "../../src/codex/inject/bounded-config-reader"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; @@ -59,6 +60,35 @@ function runIsolatedSettingsRequest(options: { expect(line).toBeDefined(); return JSON.parse(line!) as { status: number; body: Record }; } + +/** + * Same isolation boundary as the settings cases, for the restore path: CODEX_HOME must + * be fixed before the module graph binds CODEX_CONFIG_PATH. The child's last stdout line + * is the JSON result; earlier lines may be the restore machinery's own logs. + */ +function runIsolatedCodexScript(options: { + root: string; + codexHome: string; + script: string; +}): Record { + const child = spawnSync(process.execPath, ["--eval", options.script], { + cwd: repoRoot(), + env: { + ...process.env, + CODEX_HOME: options.codexHome, + OPENCODEX_HOME: join(options.root, "opencodex"), + }, + encoding: "utf8", + timeout: 10_000, + }); + if (child.status !== 0) { + const cause = child.error ? ` (${child.error.name}: ${child.error.message})` : ""; + throw new Error(`isolated codex script failed (status=${child.status} signal=${child.signal})${cause}: ${child.stderr || child.stdout}`); + } + const line = child.stdout.trim().split("\n").filter(Boolean).at(-1); + expect(line).toBeDefined(); + return JSON.parse(line!) as Record; +} test("PUT /api/settings reports Codex write-lock contention as retryable", async () => { const root = mkdtempSync(join(tmpdir(), "ocx-settings-desktop-switch-")); const codexHome = join(root, "codex"); @@ -298,6 +328,134 @@ test("PUT /api/settings keeps the undetermined-ownership explanation on a locked } }, 15_000); +test("readBoundedCodexConfig returns null only for a config absent at lookup", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-bounded-reader-")); + try { + expect(readBoundedCodexConfig(join(root, "config.toml"))).toBeNull(); + writeFileSync(join(root, "config.toml"), 'model_provider = "custom"\n'); + expect(readBoundedCodexConfig(join(root, "config.toml"))).toContain('"custom"'); + // Present but unreadable-as-a-bounded-regular-file is a throw, not a null. + mkdirSync(join(root, "as-dir.toml")); + expect(() => readBoundedCodexConfig(join(root, "as-dir.toml"))).toThrow(); + writeFileSync(join(root, "big.toml"), `# ${"x".repeat(1024 * 1024)}\nmodel = "gpt-5.5"\n`); + expect(() => readBoundedCodexConfig(join(root, "big.toml"))).toThrow(); + } finally { + removeTreeWithRetry(root); + } +}); + +test.skipIf(process.platform === "win32")( + "readBoundedCodexConfig resolves a symlinked config to a bounded regular target", + () => { + const root = mkdtempSync(join(tmpdir(), "ocx-bounded-link-")); + try { + writeFileSync(join(root, "dotfiles-codex.toml"), 'model_provider = "custom"\n'); + symlinkSync(join(root, "dotfiles-codex.toml"), join(root, "config.toml")); + expect(readBoundedCodexConfig(join(root, "config.toml"))).toContain('"custom"'); + // A link does not launder an unsafe target: the descriptor check still refuses it. + symlinkSync("/dev/null", join(root, "null.toml")); + expect(() => readBoundedCodexConfig(join(root, "null.toml"))).toThrow(); + symlinkSync(join(root, "missing.toml"), join(root, "dangling.toml")); + expect(readBoundedCodexConfig(join(root, "dangling.toml"))).toBeNull(); + } finally { + removeTreeWithRetry(root); + } + }, +); + +test.skipIf(process.platform === "win32")( + "GET /api/settings reads ownership through a symlinked config.toml", + () => { + // Codex and the injector read the link's target, so the bounded observation must + // too — otherwise settings reports undetermined for a config that plainly selects + // an external provider. + const root = mkdtempSync(join(tmpdir(), "ocx-settings-link-cfg-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(root, "dotfiles-codex.toml"), 'model_provider = "custom"\n'); + symlinkSync(join(root, "dotfiles-codex.toml"), join(codexHome, "config.toml")); + + try { + const response = runIsolatedSettingsRequest({ + root, + codexHome, + routeConfig: ISOLATED_PROVIDER_CONFIG, + scriptBody: ` + const request = new Request("http://127.0.0.1:10100/api/settings", { + headers: { host: "127.0.0.1:10100" }, + }); + const response = await handleManagementAPI(request, new URL(request.url), config, { + getCachedStartupHealth: async () => startupHealthFixture(), + }); + `, + }); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + codexDesktopSwitches: { + apply: { applied: false, reason: "external_provider", retryable: false }, + }, + }); + } finally { + removeTreeWithRetry(root); + } + }, + 15_000, +); + +test.skipIf(process.platform === "win32")( + "native restore still classifies a symlinked config.toml's target", + () => { + // Regression for the shared ownership probe: a link to a small regular config must + // produce the external-provider result, not an early exit that leaves injected + // routing pointed at a stopped proxy. + const root = mkdtempSync(join(tmpdir(), "ocx-restore-link-cfg-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(root, "dotfiles-codex.toml"), 'model_provider = "custom"\n'); + symlinkSync(join(root, "dotfiles-codex.toml"), join(codexHome, "config.toml")); + + try { + const result = runIsolatedCodexScript({ + root, + codexHome, + script: ` + const { restoreNativeCodex } = await import("./src/codex/inject"); + const result = restoreNativeCodex(); + console.log(JSON.stringify({ success: result.success, externalProvider: result.externalProvider ?? null })); + `, + }); + expect(result).toMatchObject({ success: true, externalProvider: "custom" }); + } finally { + removeTreeWithRetry(root); + } + }, + 15_000, +); + +test("native restore tolerates a config.toml over the observation bound", () => { + // A valid config larger than the 1 MiB observation bound must still classify as + // external — the read/write ownership probe is not the bounded settings reader. + const root = mkdtempSync(join(tmpdir(), "ocx-restore-big-cfg-")); + const codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); + writeFileSync(join(codexHome, "config.toml"), `# ${"x".repeat(1024 * 1024)}\nmodel_provider = "custom"\n`); + + try { + const result = runIsolatedCodexScript({ + root, + codexHome, + script: ` + const { restoreNativeCodex } = await import("./src/codex/inject"); + const result = restoreNativeCodex(); + console.log(JSON.stringify({ success: result.success, externalProvider: result.externalProvider ?? null })); + `, + }); + expect(result).toMatchObject({ success: true, externalProvider: "custom" }); + } finally { + removeTreeWithRetry(root); + } +}, 15_000); + test("PUT /api/settings reports external Codex ownership when the integration is disabled", () => { // clientIntegrations.codex = false trips the apply gate before the injector runs, so // the ownership classification has to happen inside applyCodexConfigInjection itself. From 70b8a77cf0784d2bd0b793c25826c6f51072926c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:58:34 +0000 Subject: [PATCH 3/6] fix(codex): report unreadable global config and catch stat/open swaps The bounded reader now compares the opened descriptor's dev/ino with the initial named stat, so a config.toml replaced between lookup and open reports a changed file instead of stable ownership of the replacement. collectProjectCodexConfigWarnings treats an oversized or unreadable global config as indeterminate: it emits a global_config_unreadable diagnostic and still reports project bypasses discovered by walking parents, instead of silently returning no coverage. Co-Authored-By: Epinephrine --- src/codex/inject/bounded-config-reader.ts | 3 + src/codex/project-config-warnings.ts | 69 ++++++++++++++++--- .../project-config-warnings.test.ts | 21 ++++++ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/codex/inject/bounded-config-reader.ts b/src/codex/inject/bounded-config-reader.ts index 6da870fb4ae..78a7ce95030 100644 --- a/src/codex/inject/bounded-config-reader.ts +++ b/src/codex/inject/bounded-config-reader.ts @@ -34,6 +34,9 @@ export function readBoundedCodexConfig(path: string): string | null { : (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"); } diff --git a/src/codex/project-config-warnings.ts b/src/codex/project-config-warnings.ts index 1539534f985..09ae1a8263e 100644 --- a/src/codex/project-config-warnings.ts +++ b/src/codex/project-config-warnings.ts @@ -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; @@ -437,9 +438,31 @@ 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 | undefined; + let globalUnreadable = false; + try { + globalContent = readBoundedCodexConfig(codexConfigPath); + } catch { + globalUnreadable = true; + } + if (requireRouting && !globalUnreadable + && !isGlobalOpencodexRoutingActive(codexConfigPath, globalContent ?? undefined)) { + return []; + } const warnings: ProjectCodexConfigWarning[] = []; + 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 })) { const content = readBoundedProjectConfig(path); if (content !== null) warnings.push(...analyzeProjectCodexConfig(content, path)); @@ -480,6 +503,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"; } } @@ -501,6 +526,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( @@ -512,22 +539,34 @@ 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; } @@ -535,11 +574,19 @@ export function formatProjectCodexConfigWarningsForConsole(warnings: ProjectCode const grouped = groupProjectCodexConfigWarningsByPath(warnings); if (grouped.length === 0) return []; const lines = ["⚠️ Project Codex config bypasses OpenCodex:"]; - 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; } diff --git a/tests/codex-integration/project-config-warnings.test.ts b/tests/codex-integration/project-config-warnings.test.ts index f130522c684..c4ebe5cff97 100644 --- a/tests/codex-integration/project-config-warnings.test.ts +++ b/tests/codex-integration/project-config-warnings.test.ts @@ -393,6 +393,27 @@ name = "anthropic" .filter(warning => warning.path === projectConfigPath); expect(second.length).toBe(0); }); + + test("an oversized global config is reported as unreadable instead of silently inactive", () => { + const codexConfigPath = join(process.env.CODEX_HOME!, "config.toml"); + mkdirSync(process.env.CODEX_HOME!, { recursive: true }); + writeFileSync(codexConfigPath, `# ${"x".repeat(1024 * 1024)}`); + const warnings = collectProjectCodexConfigWarnings({ cwd: testDir, codexConfigPath }); + const global = warnings.find(warning => warning.code === "global_config_unreadable"); + expect(global?.path).toBe(codexConfigPath); + }); + + test("an oversized global config still surfaces bypasses found by walking parents", () => { + const codexConfigPath = join(process.env.CODEX_HOME!, "config.toml"); + mkdirSync(process.env.CODEX_HOME!, { recursive: true }); + writeFileSync(codexConfigPath, `# ${"x".repeat(1024 * 1024)}`); + const projectConfigPath = join(testDir, ".codex", "config.toml"); + mkdirSync(join(testDir, ".codex"), { recursive: true }); + writeFileSync(projectConfigPath, `model_provider = "anthropic"`); + const warnings = collectProjectCodexConfigWarnings({ cwd: testDir, codexConfigPath }); + expect(warnings.some(warning => warning.code === "global_config_unreadable")).toBe(true); + expect(warnings.some(warning => warning.path === projectConfigPath)).toBe(true); + }); }); describe("explainProjectConfigBypass", () => { From f5af81d66224558912d34466b950576e7dbf7874 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:49:10 +0000 Subject: [PATCH 4/6] fix(diagnostics): reuse one bounded global config observation per warning scan --- scripts/test-layout/layout.json | 34 +-------------- src/codex/project-config-warnings.ts | 14 ++++--- structure/config.md | 12 +++--- .../project-config-warning-snapshot.test.ts | 42 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 40 +++--------------- 5 files changed, 64 insertions(+), 78 deletions(-) create mode 100644 tests/codex-integration/project-config-warning-snapshot.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 35a7e5f60ac..cb0d6982e5e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -405,7 +405,6 @@ "chat-native-spend.test.ts": "responses", "chat-refusal-scope.test.ts": "responses", "chat-refusal.test.ts": "responses", - "chat-responses-control-integration.test.ts": "responses", "chat-responses-control-scope.test.ts": "responses", "chat-tool-choice-allowed-tools.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", @@ -501,7 +500,6 @@ "cli-codex-log-guard-compact.test.ts": "cli", "cli-codex-log-guard-protection.test.ts": "cli", "cli-codex-log-guard.test.ts": "cli", - "cli-companion.test.ts": "cli", "cli-config-command.test.ts": "cli", "cli-config-show-client.test.ts": "cli", "cli-connect-readiness.test.ts": "cli", @@ -537,7 +535,6 @@ "cli-storage-inspect.test.ts": "cli", "cli-transport-honesty.test.ts": "cli", "cli-update-badge.test.ts": "cli", - "cli-usage-hub.test.ts": "cli", "cli-usage-report.test.ts": "cli", "cli-version-skew.test.ts": "cli", "client-catalog-compatibility.test.ts": "clients", @@ -549,7 +546,6 @@ "client-fingerprint.test.ts": "clients", "client-hub-relay.test.ts": "clients", "client-hub-state.test.ts": "clients", - "client-hub-usage.test.ts": "clients", "client-injection-guard.test.ts": "codex-integration", "client-lifecycle-lock.test.ts": "clients", "client-machine-listener.test.ts": "clients", @@ -725,8 +721,6 @@ "codex-web-search-switch.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", "codex-write-lock.test.ts": "codex-integration", - "coding-agent-tool-result-images.test.ts": "adapters", - "cold-spawn-warmup.test.ts": "ci-workflows", "combo-authoritative-reset.test.ts": "codex-integration", "combo-child-headers.test.ts": "routing", "combo-last-resort.test.ts": "codex-integration", @@ -999,7 +993,6 @@ "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", "gui-codex-usage-score-parity.test.ts": "gui", - "gui-desktop-sidecar-script.test.ts": "gui", "gui-management-session.test.ts": "gui", "gui-pair-capability.test.ts": "gui", "gui-pair-client.test.ts": "gui", @@ -1012,7 +1005,6 @@ "history-paginated-transition-destinations.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", - "hub-usage.test.ts": "server", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", "identity-subagent.test.ts": "adapters", @@ -1024,7 +1016,6 @@ "inline-think-boundaries.test.ts": "adapters/openai", "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", - "installed-gate-drivers.test.ts": "ci-workflows", "integrations-current-store.test.ts": "clients", "integrations-hermes-affinity.test.ts": "clients", "integrations-invariants.test.ts": "gui", @@ -1039,7 +1030,6 @@ "issue-452-empty-503.test.ts": "codex-integration", "issue-702-expired-replay-state.test.ts": "codex-integration", "issue-914-transport-attribution.test.ts": "codex-integration", - "key-attribution.test.ts": "usage", "key-failover.test.ts": "adapters", "key-login-live-update.test.ts": "oauth", "key-login-preserves-model-costs.test.ts": "oauth", @@ -1379,10 +1369,6 @@ "provider-connection-test.test.ts": "providers", "provider-cost-overlay-config.test.ts": "providers", "provider-discovery-log-suppression.test.ts": "providers", - "provider-egress-fetch.test.ts": "responses", - "provider-egress-management-validation.test.ts": "server", - "provider-egress-outbound.test.ts": "providers", - "provider-egress.test.ts": "lib", "provider-id-rewrite.test.ts": "providers", "provider-key-store.test.ts": "providers", "provider-live-models.test.ts": "providers", @@ -1395,7 +1381,6 @@ "provider-quota-observed-marker.test.ts": "providers", "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", - "provider-send-path-import.test.ts": "server", "provider-static-model-discovery.test.ts": "providers", "provider-workspace-auth.test.ts": "gui", "provider-workspace-data.test.ts": "gui", @@ -1433,7 +1418,6 @@ "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", "relay-eager.test.ts": "server", - "release-desktop-scripts.test.ts": "ci-workflows", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", "release-outcome-report.test.ts": "ci-workflows", @@ -1496,7 +1480,6 @@ "resolved-model-policy.test.ts": "providers", "response-log-inspection.test.ts": "server", "response-model-identity.test.ts": "server", - "responses-4546-incident-regression.test.ts": "responses", "responses-account-change-scrub.test.ts": "responses", "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", @@ -1514,7 +1497,6 @@ "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", - "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-historical-replay.test.ts": "responses", "responses-custom-tool-repair-dispatch.test.ts": "responses", @@ -1542,7 +1524,6 @@ "responses-parser-agent-message.test.ts": "responses", "responses-parser-malformed-content.test.ts": "responses", "responses-parser.test.ts": "responses", - "responses-passthrough-transient-policy.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-preview-main-read-fence.test.ts": "responses", @@ -1552,13 +1533,11 @@ "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", - "responses-send-budget-errors.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-sparse-terminal-tool-scope.test.ts": "responses", - "responses-spend-ledger-wiring.test.ts": "responses", "responses-spill-acl-recovery.test.ts": "responses", "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", @@ -1611,7 +1590,6 @@ "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", - "server-combo-held-response.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", @@ -1674,8 +1652,6 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", - "socks5-fetch.test.ts": "lib", - "socks5-upload-lifecycle.test.ts": "lib", "spend-ceiling-enforcement.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", "spend-ledger-file-journal.test.ts": "lib", @@ -1694,18 +1670,12 @@ "stale-state-purge.test.ts": "service", "stall-subprocess-exit.test.ts": "lib", "stall-timeout.test.ts": "lib", - "standalone-build-script.test.ts": "gui", - "standalone-service.test.ts": "service", - "standalone.test.ts": "lib", "star-deferral.test.ts": "cli", - "start-args.test.ts": "cli", - "start-ownership-publication.test.ts": "cli", "startup-action-control-elevation.test.ts": "server", "startup-action-control.test.ts": "server", "startup-health-ui.test.ts": "gui", "startup-prompt.test.ts": "server", "state-store-sweeper.test.ts": "oauth", - "stepfun-provider.test.ts": "providers", "stop-deferred-teardown.test.ts": "service", "storage-cleanup.test.ts": "storage", "storage-mutation-race.test.ts": "storage", @@ -1827,7 +1797,6 @@ "vision-text-only-predicate.test.ts": "vision", "volcengine-ark-assistant-content.test.ts": "providers", "volcengine-providers.test.ts": "gui", - "warmup-registration.test.ts": "ci-workflows", "warmup.test.ts": "codex-integration", "web-search-anthropic.test.ts": "web-search", "devin-web-search.test.ts": "web-search", @@ -1917,7 +1886,8 @@ "link-compensation.test.ts": "clients", "web-search-run-turn-loop.test.ts": "web-search", "responses-run-turn-web-search.test.ts": "responses", - "server-combo-cooldown-recording.test.ts": "server" + "server-combo-cooldown-recording.test.ts": "server", + "project-config-warning-snapshot.test.ts": "codex-integration" }, "migrated": [ "adapters", diff --git a/src/codex/project-config-warnings.ts b/src/codex/project-config-warnings.ts index 09ae1a8263e..183f76c4e83 100644 --- a/src/codex/project-config-warnings.ts +++ b/src/codex/project-config-warnings.ts @@ -266,7 +266,7 @@ 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) { @@ -277,6 +277,7 @@ export function isGlobalOpencodexRoutingActive( } 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; @@ -380,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(); const codexConfigPath = options.codexConfigPath ?? resolveCodexConfigPath(); @@ -418,7 +421,8 @@ export function discoverProjectCodexConfigPaths(options: { } try { - const global = readBoundedCodexConfig(codexConfigPath); + const global = options.globalContent === undefined + ? readBoundedCodexConfig(codexConfigPath) : options.globalContent; if (global !== null) { for (const projectPath of parseTrustedProjectPathsFromCodexConfig(global)) { addIfExists(projectPath); @@ -442,7 +446,7 @@ export function collectProjectCodexConfigWarnings(options: { // 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 | undefined; + let globalContent: string | null = null; let globalUnreadable = false; try { globalContent = readBoundedCodexConfig(codexConfigPath); @@ -450,7 +454,7 @@ export function collectProjectCodexConfigWarnings(options: { globalUnreadable = true; } if (requireRouting && !globalUnreadable - && !isGlobalOpencodexRoutingActive(codexConfigPath, globalContent ?? undefined)) { + && !isGlobalOpencodexRoutingActive(codexConfigPath, globalContent)) { return []; } @@ -463,7 +467,7 @@ export function collectProjectCodexConfigWarnings(options: { 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 })) { + for (const path of discoverProjectCodexConfigPaths({ cwd: options.cwd, codexConfigPath, globalContent })) { const content = readBoundedProjectConfig(path); if (content !== null) warnings.push(...analyzeProjectCodexConfig(content, path)); } diff --git a/structure/config.md b/structure/config.md index 7a316484ae2..c0af4a2db70 100644 --- a/structure/config.md +++ b/structure/config.md @@ -202,12 +202,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 ownership, doctor, and project-routing diagnostics use bounded regular-file reads rather -than mutating or normalizing the user's file: the read resolves links to a bounded regular target, -opens nonblocking, and verifies the descriptor before and after — absent at lookup reads as none, -changed or unreadable reports undetermined ownership. The TOML reader must lexically skip basic and -literal multiline string bodies (prose holds key-shaped examples, `[table]` snippets — data, not -configuration), and formatted doctor lines pass retained real paths through user-path redaction. +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) diff --git a/tests/codex-integration/project-config-warning-snapshot.test.ts b/tests/codex-integration/project-config-warning-snapshot.test.ts new file mode 100644 index 00000000000..e3e1996902e --- /dev/null +++ b/tests/codex-integration/project-config-warning-snapshot.test.ts @@ -0,0 +1,42 @@ +import { expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as bounded from "../../src/codex/inject/bounded-config-reader"; +import { collectProjectCodexConfigWarnings, discoverProjectCodexConfigPaths, isGlobalOpencodexRoutingActive } from "../../src/codex/project-config-warnings"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +for (const kind of ["present", "absent", "unreadable"] as const) { + test(`project warnings read exactly one global ${kind} snapshot`, () => { + const root = mkdtempSync(join(tmpdir(), "ocx-warning-snapshot-")); + const global = join(root, "global.toml"); + const project = join(root, "project"); + mkdirSync(join(project, ".codex"), { recursive: true }); + const projectConfig = join(project, ".codex", "config.toml"); + writeFileSync(projectConfig, 'model_provider = "external"\n'); + const text = 'model_provider = "opencodex"\n'; + writeFileSync(global, text); + const read = spyOn(bounded, "readBoundedCodexConfig").mockImplementation(() => { + if (kind === "unreadable") throw new Error("fixture read failure"); + return kind === "absent" ? null : text; + }); + try { + const warnings = collectProjectCodexConfigWarnings({ cwd: project, codexConfigPath: global }); + expect(read).toHaveBeenCalledTimes(1); + expect(warnings.some(w => w.code === "global_config_unreadable")).toBe(kind === "unreadable"); + expect(warnings.some(w => w.path === projectConfig)).toBe(kind !== "absent"); + } finally { read.mockRestore(); removeTreeWithRetry(root); } + }); +} + +test("explicit absent snapshots never become fresh global reads", () => { + const root = mkdtempSync(join(tmpdir(), "ocx-warning-absent-")); + const global = join(root, "global.toml"); + writeFileSync(global, 'model_provider = "opencodex"\n'); + const read = spyOn(bounded, "readBoundedCodexConfig").mockImplementation(() => { throw new Error("unexpected reread"); }); + try { + expect(isGlobalOpencodexRoutingActive(global, null)).toBe(false); + expect(discoverProjectCodexConfigPaths({ cwd: root, codexConfigPath: global, maxWalkParents: 1, globalContent: null })).toEqual([]); + expect(read).not.toHaveBeenCalled(); + } finally { read.mockRestore(); removeTreeWithRetry(root); } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fdb563d16f1..2068eef640c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -2,7 +2,7 @@ "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", - "release-desktop-scripts.test.ts": "ci-workflows", + "release-desktop-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", "gui-desktop-sidecar-script.test.ts": "gui", "standalone-build-script.test.ts": "gui", @@ -34,8 +34,8 @@ "warmup-registration.test.ts": "ci-workflows", "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", - "cli-usage-hub.test.ts": "cli", - "cli-companion.test.ts": "cli", + "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", @@ -231,7 +231,6 @@ "chat-native-spend.test.ts": "responses", "chat-refusal-scope.test.ts": "responses", "chat-refusal.test.ts": "responses", - "chat-responses-control-integration.test.ts": "responses", "chat-responses-control-scope.test.ts": "responses", "chat-tool-choice-allowed-tools.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", @@ -327,7 +326,6 @@ "cli-codex-log-guard-compact.test.ts": "cli", "cli-codex-log-guard-protection.test.ts": "cli", "cli-codex-log-guard.test.ts": "cli", - "cli-companion.test.ts": "cli", "cli-config-command.test.ts": "cli", "cli-config-show-client.test.ts": "cli", "cli-connect-readiness.test.ts": "cli", @@ -363,7 +361,6 @@ "cli-storage-inspect.test.ts": "cli", "cli-transport-honesty.test.ts": "cli", "cli-update-badge.test.ts": "cli", - "cli-usage-hub.test.ts": "cli", "cli-usage-report.test.ts": "cli", "cli-version-skew.test.ts": "cli", "client-catalog-compatibility.test.ts": "clients", @@ -375,7 +372,6 @@ "client-fingerprint.test.ts": "clients", "client-hub-relay.test.ts": "clients", "client-hub-state.test.ts": "clients", - "client-hub-usage.test.ts": "clients", "client-injection-guard.test.ts": "codex-integration", "client-lifecycle-lock.test.ts": "clients", "client-machine-listener.test.ts": "clients", @@ -551,8 +547,6 @@ "codex-web-search-switch.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", "codex-write-lock.test.ts": "codex-integration", - "coding-agent-tool-result-images.test.ts": "adapters", - "cold-spawn-warmup.test.ts": "ci-workflows", "combo-authoritative-reset.test.ts": "codex-integration", "combo-child-headers.test.ts": "routing", "combo-last-resort.test.ts": "codex-integration", @@ -820,7 +814,6 @@ "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", "gui-codex-usage-score-parity.test.ts": "gui", - "gui-desktop-sidecar-script.test.ts": "gui", "gui-management-session.test.ts": "gui", "gui-pair-capability.test.ts": "gui", "gui-pair-client.test.ts": "gui", @@ -833,7 +826,6 @@ "history-paginated-transition-destinations.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", - "hub-usage.test.ts": "server", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", "identity-subagent.test.ts": "adapters", @@ -845,7 +837,6 @@ "inline-think-boundaries.test.ts": "adapters/openai", "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", - "installed-gate-drivers.test.ts": "ci-workflows", "integrations-current-store.test.ts": "clients", "integrations-hermes-affinity.test.ts": "clients", "integrations-invariants.test.ts": "gui", @@ -860,7 +851,6 @@ "issue-452-empty-503.test.ts": "codex-integration", "issue-702-expired-replay-state.test.ts": "codex-integration", "issue-914-transport-attribution.test.ts": "codex-integration", - "key-attribution.test.ts": "usage", "key-failover.test.ts": "adapters", "key-login-live-update.test.ts": "oauth", "key-login-preserves-model-costs.test.ts": "oauth", @@ -1205,10 +1195,6 @@ "provider-connection-test.test.ts": "providers", "provider-cost-overlay-config.test.ts": "providers", "provider-discovery-log-suppression.test.ts": "providers", - "provider-egress-fetch.test.ts": "responses", - "provider-egress-management-validation.test.ts": "server", - "provider-egress-outbound.test.ts": "providers", - "provider-egress.test.ts": "lib", "provider-id-rewrite.test.ts": "providers", "provider-key-store.test.ts": "providers", "provider-live-models.test.ts": "providers", @@ -1221,7 +1207,6 @@ "provider-quota-observed-marker.test.ts": "providers", "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", - "provider-send-path-import.test.ts": "server", "provider-static-model-discovery.test.ts": "providers", "provider-workspace-auth.test.ts": "gui", "provider-workspace-data.test.ts": "gui", @@ -1259,7 +1244,6 @@ "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", "relay-eager.test.ts": "server", - "release-desktop-scripts.test.ts": "ci-workflows", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", "release-outcome-report.test.ts": "ci-workflows", @@ -1322,7 +1306,6 @@ "resolved-model-policy.test.ts": "providers", "response-log-inspection.test.ts": "server", "response-model-identity.test.ts": "server", - "responses-4546-incident-regression.test.ts": "responses", "responses-account-change-scrub.test.ts": "responses", "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", @@ -1340,7 +1323,6 @@ "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", - "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-historical-replay.test.ts": "responses", "responses-custom-tool-repair-dispatch.test.ts": "responses", @@ -1368,7 +1350,6 @@ "responses-parser-agent-message.test.ts": "responses", "responses-parser-malformed-content.test.ts": "responses", "responses-parser.test.ts": "responses", - "responses-passthrough-transient-policy.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-preview-main-read-fence.test.ts": "responses", @@ -1378,13 +1359,11 @@ "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", - "responses-send-budget-errors.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-sparse-terminal-tool-scope.test.ts": "responses", - "responses-spend-ledger-wiring.test.ts": "responses", "responses-spill-acl-recovery.test.ts": "responses", "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", @@ -1437,7 +1416,6 @@ "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", - "server-combo-held-response.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", @@ -1500,8 +1478,6 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", - "socks5-fetch.test.ts": "lib", - "socks5-upload-lifecycle.test.ts": "lib", "spend-ceiling-enforcement.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", "spend-ledger-file-journal.test.ts": "lib", @@ -1520,18 +1496,12 @@ "stale-state-purge.test.ts": "service", "stall-subprocess-exit.test.ts": "lib", "stall-timeout.test.ts": "lib", - "standalone-build-script.test.ts": "gui", - "standalone-service.test.ts": "service", - "standalone.test.ts": "lib", "star-deferral.test.ts": "cli", - "start-args.test.ts": "cli", - "start-ownership-publication.test.ts": "cli", "startup-action-control-elevation.test.ts": "server", "startup-action-control.test.ts": "server", "startup-health-ui.test.ts": "gui", "startup-prompt.test.ts": "server", "state-store-sweeper.test.ts": "oauth", - "stepfun-provider.test.ts": "providers", "stop-deferred-teardown.test.ts": "service", "storage-cleanup.test.ts": "storage", "storage-mutation-race.test.ts": "storage", @@ -1653,7 +1623,6 @@ "vision-text-only-predicate.test.ts": "vision", "volcengine-ark-assistant-content.test.ts": "providers", "volcengine-providers.test.ts": "gui", - "warmup-registration.test.ts": "ci-workflows", "warmup.test.ts": "codex-integration", "web-search-anthropic.test.ts": "web-search", "devin-web-search.test.ts": "web-search", @@ -1748,5 +1717,6 @@ "link-compensation.test.ts": "clients", "web-search-run-turn-loop.test.ts": "web-search", "responses-run-turn-web-search.test.ts": "responses", - "server-combo-cooldown-recording.test.ts": "server" + "server-combo-cooldown-recording.test.ts": "server", + "project-config-warning-snapshot.test.ts": "codex-integration" } From cc8cfacf71a5131dd23f4a01294ab007847f3a2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:01:36 +0000 Subject: [PATCH 5/6] test: preserve existing registration text around the new regression --- scripts/test-layout/layout.json | 35 ++++++++++++++++++-- tests/fixtures/test-layout-expected.json | 41 +++++++++++++++++++++--- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cb0d6982e5e..c30a1dd9278 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -168,6 +168,7 @@ } }, "explicit": { + "project-config-warning-snapshot.test.ts": "codex-integration", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", @@ -405,6 +406,7 @@ "chat-native-spend.test.ts": "responses", "chat-refusal-scope.test.ts": "responses", "chat-refusal.test.ts": "responses", + "chat-responses-control-integration.test.ts": "responses", "chat-responses-control-scope.test.ts": "responses", "chat-tool-choice-allowed-tools.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", @@ -500,6 +502,7 @@ "cli-codex-log-guard-compact.test.ts": "cli", "cli-codex-log-guard-protection.test.ts": "cli", "cli-codex-log-guard.test.ts": "cli", + "cli-companion.test.ts": "cli", "cli-config-command.test.ts": "cli", "cli-config-show-client.test.ts": "cli", "cli-connect-readiness.test.ts": "cli", @@ -535,6 +538,7 @@ "cli-storage-inspect.test.ts": "cli", "cli-transport-honesty.test.ts": "cli", "cli-update-badge.test.ts": "cli", + "cli-usage-hub.test.ts": "cli", "cli-usage-report.test.ts": "cli", "cli-version-skew.test.ts": "cli", "client-catalog-compatibility.test.ts": "clients", @@ -546,6 +550,7 @@ "client-fingerprint.test.ts": "clients", "client-hub-relay.test.ts": "clients", "client-hub-state.test.ts": "clients", + "client-hub-usage.test.ts": "clients", "client-injection-guard.test.ts": "codex-integration", "client-lifecycle-lock.test.ts": "clients", "client-machine-listener.test.ts": "clients", @@ -721,6 +726,8 @@ "codex-web-search-switch.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", "codex-write-lock.test.ts": "codex-integration", + "coding-agent-tool-result-images.test.ts": "adapters", + "cold-spawn-warmup.test.ts": "ci-workflows", "combo-authoritative-reset.test.ts": "codex-integration", "combo-child-headers.test.ts": "routing", "combo-last-resort.test.ts": "codex-integration", @@ -993,6 +1000,7 @@ "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", "gui-codex-usage-score-parity.test.ts": "gui", + "gui-desktop-sidecar-script.test.ts": "gui", "gui-management-session.test.ts": "gui", "gui-pair-capability.test.ts": "gui", "gui-pair-client.test.ts": "gui", @@ -1005,6 +1013,7 @@ "history-paginated-transition-destinations.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", + "hub-usage.test.ts": "server", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", "identity-subagent.test.ts": "adapters", @@ -1016,6 +1025,7 @@ "inline-think-boundaries.test.ts": "adapters/openai", "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", + "installed-gate-drivers.test.ts": "ci-workflows", "integrations-current-store.test.ts": "clients", "integrations-hermes-affinity.test.ts": "clients", "integrations-invariants.test.ts": "gui", @@ -1030,6 +1040,7 @@ "issue-452-empty-503.test.ts": "codex-integration", "issue-702-expired-replay-state.test.ts": "codex-integration", "issue-914-transport-attribution.test.ts": "codex-integration", + "key-attribution.test.ts": "usage", "key-failover.test.ts": "adapters", "key-login-live-update.test.ts": "oauth", "key-login-preserves-model-costs.test.ts": "oauth", @@ -1369,6 +1380,10 @@ "provider-connection-test.test.ts": "providers", "provider-cost-overlay-config.test.ts": "providers", "provider-discovery-log-suppression.test.ts": "providers", + "provider-egress-fetch.test.ts": "responses", + "provider-egress-management-validation.test.ts": "server", + "provider-egress-outbound.test.ts": "providers", + "provider-egress.test.ts": "lib", "provider-id-rewrite.test.ts": "providers", "provider-key-store.test.ts": "providers", "provider-live-models.test.ts": "providers", @@ -1381,6 +1396,7 @@ "provider-quota-observed-marker.test.ts": "providers", "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", + "provider-send-path-import.test.ts": "server", "provider-static-model-discovery.test.ts": "providers", "provider-workspace-auth.test.ts": "gui", "provider-workspace-data.test.ts": "gui", @@ -1418,6 +1434,7 @@ "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", "relay-eager.test.ts": "server", + "release-desktop-scripts.test.ts": "ci-workflows", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", "release-outcome-report.test.ts": "ci-workflows", @@ -1480,6 +1497,7 @@ "resolved-model-policy.test.ts": "providers", "response-log-inspection.test.ts": "server", "response-model-identity.test.ts": "server", + "responses-4546-incident-regression.test.ts": "responses", "responses-account-change-scrub.test.ts": "responses", "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", @@ -1497,6 +1515,7 @@ "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", + "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-historical-replay.test.ts": "responses", "responses-custom-tool-repair-dispatch.test.ts": "responses", @@ -1524,6 +1543,7 @@ "responses-parser-agent-message.test.ts": "responses", "responses-parser-malformed-content.test.ts": "responses", "responses-parser.test.ts": "responses", + "responses-passthrough-transient-policy.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-preview-main-read-fence.test.ts": "responses", @@ -1533,11 +1553,13 @@ "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", + "responses-send-budget-errors.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-sparse-terminal-tool-scope.test.ts": "responses", + "responses-spend-ledger-wiring.test.ts": "responses", "responses-spill-acl-recovery.test.ts": "responses", "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", @@ -1590,6 +1612,7 @@ "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", + "server-combo-held-response.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", @@ -1652,6 +1675,8 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", + "socks5-fetch.test.ts": "lib", + "socks5-upload-lifecycle.test.ts": "lib", "spend-ceiling-enforcement.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", "spend-ledger-file-journal.test.ts": "lib", @@ -1670,12 +1695,18 @@ "stale-state-purge.test.ts": "service", "stall-subprocess-exit.test.ts": "lib", "stall-timeout.test.ts": "lib", + "standalone-build-script.test.ts": "gui", + "standalone-service.test.ts": "service", + "standalone.test.ts": "lib", "star-deferral.test.ts": "cli", + "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", "startup-action-control-elevation.test.ts": "server", "startup-action-control.test.ts": "server", "startup-health-ui.test.ts": "gui", "startup-prompt.test.ts": "server", "state-store-sweeper.test.ts": "oauth", + "stepfun-provider.test.ts": "providers", "stop-deferred-teardown.test.ts": "service", "storage-cleanup.test.ts": "storage", "storage-mutation-race.test.ts": "storage", @@ -1797,6 +1828,7 @@ "vision-text-only-predicate.test.ts": "vision", "volcengine-ark-assistant-content.test.ts": "providers", "volcengine-providers.test.ts": "gui", + "warmup-registration.test.ts": "ci-workflows", "warmup.test.ts": "codex-integration", "web-search-anthropic.test.ts": "web-search", "devin-web-search.test.ts": "web-search", @@ -1886,8 +1918,7 @@ "link-compensation.test.ts": "clients", "web-search-run-turn-loop.test.ts": "web-search", "responses-run-turn-web-search.test.ts": "responses", - "server-combo-cooldown-recording.test.ts": "server", - "project-config-warning-snapshot.test.ts": "codex-integration" + "server-combo-cooldown-recording.test.ts": "server" }, "migrated": [ "adapters", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2068eef640c..46d841543c1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,8 +1,9 @@ { + "project-config-warning-snapshot.test.ts": "codex-integration", "openai-chat-serialized-tool-call-scaling.test.ts": "adapters/openai", "coding-agent-json-lines-scaling.test.ts": "providers", "usage-snapshot-digest-reuse.test.ts": "usage", - "release-desktop-scripts.test.ts": "ci-workflows", + "release-desktop-scripts.test.ts": "ci-workflows", "installed-gate-drivers.test.ts": "ci-workflows", "gui-desktop-sidecar-script.test.ts": "gui", "standalone-build-script.test.ts": "gui", @@ -34,8 +35,8 @@ "warmup-registration.test.ts": "ci-workflows", "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", - "cli-usage-hub.test.ts": "cli", - "cli-companion.test.ts": "cli", + "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "tool-envelope-echo-whole-line.test.ts": "adapters", "abort-race.test.ts": "adapters", @@ -231,6 +232,7 @@ "chat-native-spend.test.ts": "responses", "chat-refusal-scope.test.ts": "responses", "chat-refusal.test.ts": "responses", + "chat-responses-control-integration.test.ts": "responses", "chat-responses-control-scope.test.ts": "responses", "chat-tool-choice-allowed-tools.test.ts": "responses", "chatgpt-device-auth.test.ts": "oauth", @@ -326,6 +328,7 @@ "cli-codex-log-guard-compact.test.ts": "cli", "cli-codex-log-guard-protection.test.ts": "cli", "cli-codex-log-guard.test.ts": "cli", + "cli-companion.test.ts": "cli", "cli-config-command.test.ts": "cli", "cli-config-show-client.test.ts": "cli", "cli-connect-readiness.test.ts": "cli", @@ -361,6 +364,7 @@ "cli-storage-inspect.test.ts": "cli", "cli-transport-honesty.test.ts": "cli", "cli-update-badge.test.ts": "cli", + "cli-usage-hub.test.ts": "cli", "cli-usage-report.test.ts": "cli", "cli-version-skew.test.ts": "cli", "client-catalog-compatibility.test.ts": "clients", @@ -372,6 +376,7 @@ "client-fingerprint.test.ts": "clients", "client-hub-relay.test.ts": "clients", "client-hub-state.test.ts": "clients", + "client-hub-usage.test.ts": "clients", "client-injection-guard.test.ts": "codex-integration", "client-lifecycle-lock.test.ts": "clients", "client-machine-listener.test.ts": "clients", @@ -547,6 +552,8 @@ "codex-web-search-switch.test.ts": "codex-integration", "codex-websocket-registry.test.ts": "codex-integration", "codex-write-lock.test.ts": "codex-integration", + "coding-agent-tool-result-images.test.ts": "adapters", + "cold-spawn-warmup.test.ts": "ci-workflows", "combo-authoritative-reset.test.ts": "codex-integration", "combo-child-headers.test.ts": "routing", "combo-last-resort.test.ts": "codex-integration", @@ -814,6 +821,7 @@ "grok-writer-boundary.test.ts": "providers/xai", "gui-api-error.test.ts": "gui", "gui-codex-usage-score-parity.test.ts": "gui", + "gui-desktop-sidecar-script.test.ts": "gui", "gui-management-session.test.ts": "gui", "gui-pair-capability.test.ts": "gui", "gui-pair-client.test.ts": "gui", @@ -826,6 +834,7 @@ "history-paginated-transition-destinations.test.ts": "codex-integration", "hub-gated-local-clients.test.ts": "cli", "hub-invite.test.ts": "cli", + "hub-usage.test.ts": "server", "hyperbolic-provider.test.ts": "providers", "identity-neutralize.test.ts": "adapters", "identity-subagent.test.ts": "adapters", @@ -837,6 +846,7 @@ "inline-think-boundaries.test.ts": "adapters/openai", "input-admission.test.ts": "server", "install-scripts.test.ts": "ci-workflows", + "installed-gate-drivers.test.ts": "ci-workflows", "integrations-current-store.test.ts": "clients", "integrations-hermes-affinity.test.ts": "clients", "integrations-invariants.test.ts": "gui", @@ -851,6 +861,7 @@ "issue-452-empty-503.test.ts": "codex-integration", "issue-702-expired-replay-state.test.ts": "codex-integration", "issue-914-transport-attribution.test.ts": "codex-integration", + "key-attribution.test.ts": "usage", "key-failover.test.ts": "adapters", "key-login-live-update.test.ts": "oauth", "key-login-preserves-model-costs.test.ts": "oauth", @@ -1195,6 +1206,10 @@ "provider-connection-test.test.ts": "providers", "provider-cost-overlay-config.test.ts": "providers", "provider-discovery-log-suppression.test.ts": "providers", + "provider-egress-fetch.test.ts": "responses", + "provider-egress-management-validation.test.ts": "server", + "provider-egress-outbound.test.ts": "providers", + "provider-egress.test.ts": "lib", "provider-id-rewrite.test.ts": "providers", "provider-key-store.test.ts": "providers", "provider-live-models.test.ts": "providers", @@ -1207,6 +1222,7 @@ "provider-quota-observed-marker.test.ts": "providers", "provider-quota.test.ts": "providers", "provider-registry-parity.test.ts": "providers", + "provider-send-path-import.test.ts": "server", "provider-static-model-discovery.test.ts": "providers", "provider-workspace-auth.test.ts": "gui", "provider-workspace-data.test.ts": "gui", @@ -1244,6 +1260,7 @@ "reasoning-replay-scope-source.test.ts": "lib", "redact.test.ts": "lib", "relay-eager.test.ts": "server", + "release-desktop-scripts.test.ts": "ci-workflows", "release-helper.test.ts": "ci-workflows", "release-notes.test.ts": "ci-workflows", "release-outcome-report.test.ts": "ci-workflows", @@ -1306,6 +1323,7 @@ "resolved-model-policy.test.ts": "providers", "response-log-inspection.test.ts": "server", "response-model-identity.test.ts": "server", + "responses-4546-incident-regression.test.ts": "responses", "responses-account-change-scrub.test.ts": "responses", "responses-account-label.test.ts": "responses", "responses-anthropic-fast-downgrade.test.ts": "responses", @@ -1323,6 +1341,7 @@ "responses-console-go-upload-retry.test.ts": "responses", "responses-context-overflow.test.ts": "responses", "responses-continuation-boundaries.test.ts": "responses", + "responses-core-modules.test.ts": "responses", "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-historical-replay.test.ts": "responses", "responses-custom-tool-repair-dispatch.test.ts": "responses", @@ -1350,6 +1369,7 @@ "responses-parser-agent-message.test.ts": "responses", "responses-parser-malformed-content.test.ts": "responses", "responses-parser.test.ts": "responses", + "responses-passthrough-transient-policy.test.ts": "responses", "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-preview-main-read-fence.test.ts": "responses", @@ -1359,11 +1379,13 @@ "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-send-budget-counts.test.ts": "responses", + "responses-send-budget-errors.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", "responses-show-thinking-summary.test.ts": "responses", "responses-snapshot-repair-server.test.ts": "responses", "responses-snapshot-repair.test.ts": "responses", "responses-sparse-terminal-tool-scope.test.ts": "responses", + "responses-spend-ledger-wiring.test.ts": "responses", "responses-spill-acl-recovery.test.ts": "responses", "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", @@ -1416,6 +1438,7 @@ "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", + "server-combo-held-response.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", @@ -1478,6 +1501,8 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", + "socks5-fetch.test.ts": "lib", + "socks5-upload-lifecycle.test.ts": "lib", "spend-ceiling-enforcement.test.ts": "lib", "spend-instrumentation-log.test.ts": "server", "spend-ledger-file-journal.test.ts": "lib", @@ -1496,12 +1521,18 @@ "stale-state-purge.test.ts": "service", "stall-subprocess-exit.test.ts": "lib", "stall-timeout.test.ts": "lib", + "standalone-build-script.test.ts": "gui", + "standalone-service.test.ts": "service", + "standalone.test.ts": "lib", "star-deferral.test.ts": "cli", + "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", "startup-action-control-elevation.test.ts": "server", "startup-action-control.test.ts": "server", "startup-health-ui.test.ts": "gui", "startup-prompt.test.ts": "server", "state-store-sweeper.test.ts": "oauth", + "stepfun-provider.test.ts": "providers", "stop-deferred-teardown.test.ts": "service", "storage-cleanup.test.ts": "storage", "storage-mutation-race.test.ts": "storage", @@ -1623,6 +1654,7 @@ "vision-text-only-predicate.test.ts": "vision", "volcengine-ark-assistant-content.test.ts": "providers", "volcengine-providers.test.ts": "gui", + "warmup-registration.test.ts": "ci-workflows", "warmup.test.ts": "codex-integration", "web-search-anthropic.test.ts": "web-search", "devin-web-search.test.ts": "web-search", @@ -1717,6 +1749,5 @@ "link-compensation.test.ts": "clients", "web-search-run-turn-loop.test.ts": "web-search", "responses-run-turn-web-search.test.ts": "responses", - "server-combo-cooldown-recording.test.ts": "server", - "project-config-warning-snapshot.test.ts": "codex-integration" + "server-combo-cooldown-recording.test.ts": "server" } From e598ebc56c4f041d0899d63e9531760284e5b69a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 06:04:02 +0000 Subject: [PATCH 6/6] fix(codex): use neutral global ownership warning headings --- src/codex/project-config-warnings.ts | 4 +++- .../project-config-warning-snapshot.test.ts | 17 ++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/codex/project-config-warnings.ts b/src/codex/project-config-warnings.ts index 183f76c4e83..e2bef14f29a 100644 --- a/src/codex/project-config-warnings.ts +++ b/src/codex/project-config-warnings.ts @@ -577,7 +577,9 @@ export function formatProjectCodexConfigWarningsForDoctor(warnings: ProjectCodex export function formatProjectCodexConfigWarningsForConsole(warnings: ProjectCodexConfigWarning[]): string[] { const grouped = groupProjectCodexConfigWarningsByPath(warnings); if (grouped.length === 0) return []; - const lines = ["⚠️ Project Codex config bypasses OpenCodex:"]; + 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(", ")}`); diff --git a/tests/codex-integration/project-config-warning-snapshot.test.ts b/tests/codex-integration/project-config-warning-snapshot.test.ts index e3e1996902e..f654317981c 100644 --- a/tests/codex-integration/project-config-warning-snapshot.test.ts +++ b/tests/codex-integration/project-config-warning-snapshot.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import * as bounded from "../../src/codex/inject/bounded-config-reader"; -import { collectProjectCodexConfigWarnings, discoverProjectCodexConfigPaths, isGlobalOpencodexRoutingActive } from "../../src/codex/project-config-warnings"; +import { collectProjectCodexConfigWarnings, discoverProjectCodexConfigPaths, formatProjectCodexConfigWarningsForConsole, isGlobalOpencodexRoutingActive } from "../../src/codex/project-config-warnings"; import { removeTreeWithRetry } from "../helpers/remove-tree"; for (const kind of ["present", "absent", "unreadable"] as const) { @@ -40,3 +40,18 @@ test("explicit absent snapshots never become fresh global reads", () => { expect(read).not.toHaveBeenCalled(); } finally { read.mockRestore(); removeTreeWithRetry(root); } }); + +test("console diagnostics do not describe an unreadable global config as a project bypass", () => { + const global = { path: "/fixture/global/config.toml", code: "global_config_unreadable" as const, + detail: "unreadable", message: "Global ownership could not be determined" }; + const project = { path: "/fixture/project/.codex/config.toml", code: "model_provider_root" as const, + detail: "external", message: "Project selects an external provider" }; + for (const warnings of [[global], [global, project]]) { + const lines = formatProjectCodexConfigWarningsForConsole(warnings); + expect(lines[0]).toBe("⚠️ Codex configuration warnings:"); + expect(lines.join("\n")).toContain("readable regular file"); + } + expect(formatProjectCodexConfigWarningsForConsole([project])[0]) + .toBe("⚠️ Project Codex config bypasses OpenCodex:"); + expect(formatProjectCodexConfigWarningsForConsole([])).toEqual([]); +});