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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,13 @@ Codex history metadata restoration. Tools that manage a custom provider often ta
provider id; replacing the active id can make those intact sessions disappear from Codex's history
view. The same protection applies to an external provider selected by a legacy root profile.

While an external provider owns `config.toml`, the settings report that
`GET /api/settings` and `ocx system settings` return describes the Desktop authless and
client-compaction switches — and the Codex sign-in requirement — as controlled by that
provider instead of showing the effective state OpenCodex would produce. Flipping either
switch still stores the preference, but `config.toml` is not rewritten; the stored value
takes effect if you switch Codex back to a provider OpenCodex manages and rerun `ocx start`.

Keep one tool as the owner of Codex provider configuration. To use OpenCodex behind an existing
provider manager, point that provider at `http://127.0.0.1:10100/v1` with Responses passthrough
(`wire_api = "responses"` in Codex TOML), not Chat Completions translation. When proxy API auth is
Expand Down
13 changes: 11 additions & 2 deletions src/cli/system-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function desktopSwitchApplyReason(reason: unknown): string {
if (reason === "not_requested") return "no desktop switch rewrite was requested";
if (reason === "proxy_not_running") return "the proxy is not running";
if (reason === "integration_disabled") return "Codex integration is disabled";
if (reason === "external_provider") return "an external model provider owns config.toml";
if (reason === "write_lock_busy") return "the Codex config write lock is busy";
if (reason === "injection_refused") return "Codex config injection was refused";
return "the rewrite could not be completed";
Expand All @@ -76,8 +77,13 @@ function settingsUpdateLines(
const lines: string[] = [];
const appendSwitch = (key: string, label: string): boolean => {
const state = recordValue(switches[key]);
if (!state || typeof state.stored !== "boolean" || typeof state.effective !== "boolean") return false;
if (!state || typeof state.stored !== "boolean"
|| (typeof state.effective !== "boolean" && state.effective !== null)) return false;
lines.push(`${label}: stored ${state.stored ? "on" : "off"}.`);
if (state.effective === null) {
lines.push(`${label}: effective state is controlled by the external model provider.`);
return true;
}
// The effective value is always stated, even when it matches. Printing it only on a
// mismatch would make silence ambiguous — the reader could not tell "the stored value is
// in force" from "this build does not report effective state", and that ambiguity is a
Expand All @@ -104,7 +110,10 @@ function settingsUpdateLines(
lines.push("Codex config: ~/.codex/config.toml was rewritten.");
} else {
const detail = typeof apply.detail === "string" && apply.detail.length > 0 ? ` Details: ${apply.detail}` : "";
lines.push(`Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail} Run 'ocx sync' to apply the stored settings.`);
const retry = apply.reason === "external_provider"
? ""
: " Run 'ocx sync' to apply the stored settings.";
lines.push(`Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail}${retry}`);
}
lines.push(`Auth source: ${authSource.summary}`);
return lines;
Expand Down
49 changes: 43 additions & 6 deletions src/codex/desktop-switches.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { OcxConfig } from "../types";
import { shouldSyncCodexOnStart } from "./desired-state";
import { tomlString } from "./paths";
import {
isEffectiveCodexClientCompaction,
isEffectiveCodexDesktopAuthless,
Expand All @@ -11,14 +12,15 @@ export type CodexDesktopSwitchInertReason =

export interface CodexDesktopSwitchState {
stored: boolean;
effective: boolean;
effective: boolean | null;
inertReason?: CodexDesktopSwitchInertReason;
}

export type CodexDesktopSwitchApplyReason =
| "not_requested"
| "proxy_not_running"
| "integration_disabled"
| "external_provider"
| "write_lock_busy"
| "injection_refused";

Expand All @@ -35,7 +37,7 @@ export interface CodexDesktopSwitchReport {
codexDesktopAuthless: CodexDesktopSwitchState;
codexClientCompaction: CodexDesktopSwitchState;
apply: CodexDesktopSwitchApply;
authSource: { presentsCodexAccount: boolean; summary: string };
authSource: { presentsCodexAccount: boolean | null; summary: string };
}

type DesktopSwitchConfig = Pick<
Expand All @@ -50,9 +52,10 @@ type DesktopSwitchConfig = Pick<

function describeSwitch(
stored: boolean,
effective: boolean,
effective: boolean | null,
config: Pick<OcxConfig, "runtimeRole">,
): CodexDesktopSwitchState {
if (effective === null) return { stored, effective };
if (!stored || effective) return { stored, effective };
return {
stored,
Expand All @@ -68,15 +71,21 @@ export function describeCodexDesktopSwitches(
apply: CodexDesktopSwitchApply,
): CodexDesktopSwitchReport {
const authlessStored = config.codexDesktopAuthless === true;
const authlessEffective = isEffectiveCodexDesktopAuthless(config);
const externallyOwned = !apply.applied && apply.reason === "external_provider";
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
const authlessEffective = externallyOwned ? null : isEffectiveCodexDesktopAuthless(config);
const compactionStored = config.codexClientCompaction === true;
const compactionEffective = isEffectiveCodexClientCompaction(config);
const compactionEffective = externallyOwned ? null : isEffectiveCodexClientCompaction(config);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

return {
codexDesktopAuthless: describeSwitch(authlessStored, authlessEffective, config),
codexClientCompaction: describeSwitch(compactionStored, compactionEffective, config),
apply,
authSource: authlessEffective
authSource: externallyOwned
? {
presentsCodexAccount: null,
summary: "An external model provider owns Codex sign-in behavior; its account requirement was not changed.",
}
: authlessEffective
? {
presentsCodexAccount: false,
summary: "The Codex app will not require its own account sign-in.",
Expand All @@ -88,6 +97,26 @@ export function describeCodexDesktopSwitches(
};
}

/**
* The apply record for a report that attempted no rewrite. `not_requested` alone would have
* the report claiming OpenCodex's stored-versus-effective state as live, so the read path
* consults the same ownership predicate the injector does and reports external ownership
* instead — a settings GET and a switch-free PUT then agree with an attempted apply.
*/
export async function observedCodexDesktopSwitchApply(): Promise<CodexDesktopSwitchApply> {
// Same lazy boundary as applyCodexDesktopSwitches: 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");
const provider = currentExternalCodexModelProvider();
Comment on lines +109 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unreadable Codex configuration breaks all settings requests

When config.toml exists but cannot be read or disappears during detection, ownership detection throws. Every settings GET and unrelated PUT then fails instead of returning its normal report.

Learn more

This new read runs outside the error handling used by applyCodexDesktopSwitches. currentExternalCodexModelProvider performs an existsSync followed by readFileSync, so permission errors and deletion between those calls propagate through the management route. The route-level handler only converts specific known errors, leaving this ordinary filesystem failure to escape the settings request.

Example: An external provider manager temporarily replaces ~/.codex/config.toml while the dashboard refreshes settings. The existence check succeeds, the subsequent read receives ENOENT, and /api/settings fails instead of returning the remaining settings.

Recommended fix: Catch import/read failures inside observedCodexDesktopSwitchApply and return a non-applied diagnostic state with the error in detail. Add a focused regression test that injects a failing ownership read or creates an existence/read race.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

if (!provider) return { applied: false, reason: "not_requested", retryable: false };
return {
applied: false,
reason: "external_provider",
retryable: false,
detail: `config.toml selects the external model_provider ${tomlString(provider)}.`,
};
}

export async function applyCodexDesktopSwitches(
config: OcxConfig,
): Promise<CodexDesktopSwitchApply> {
Expand Down Expand Up @@ -115,6 +144,14 @@ export async function applyCodexDesktopSwitches(
detail: result.message,
};
}
if (result.success && result.configApplied === false) {
return {
applied: false,
reason: "external_provider",
retryable: false,
detail: result.message,
};
}
if (result.success) {
// history_paginated_requires_native_writer stands down only the legacy relabel;
// apply still writes the routing and catalog half for paginated Codex homes.
Expand Down
3 changes: 3 additions & 0 deletions src/codex/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ function runClientWriteGuard(guard: InjectCodexOptions["beforeClientWrite"]): vo
export interface CodexInjectResult {
success: boolean;
message: string;
/** False when injection intentionally preserves configuration owned by another provider. */
configApplied?: false;
/**
* Structured read-only history preflight refusal; never parsed from display text.
*
Expand Down Expand Up @@ -240,6 +242,7 @@ async function injectCodexConfigImpl(
: undefined;
return {
success: true,
configApplied: false,
...(nativeSubagentDefaultsWarning
? { nativeSubagentDefaultsWarning }
: {}),
Expand Down
9 changes: 3 additions & 6 deletions src/server/management/config-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nati
import {
applyCodexDesktopSwitches,
describeCodexDesktopSwitches,
observedCodexDesktopSwitchApply,
type CodexDesktopSwitchApply,
} from "../../codex/desktop-switches";
import {
Expand Down Expand Up @@ -339,11 +340,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
codexDesktopAuthless: config.codexDesktopAuthless === true,
// Absent keeps Design B remote compaction; true selects the dedicated provider identity.
codexClientCompaction: config.codexClientCompaction === true,
codexDesktopSwitches: describeCodexDesktopSwitches(config, {
applied: false,
reason: "not_requested",
retryable: false,
}),
codexDesktopSwitches: describeCodexDesktopSwitches(config, await observedCodexDesktopSwitchApply()),
compactionRouting: config.compactionRouting ?? null,
startupHealth: await readStartupHealth(config),
codexRuntime: {
Expand Down Expand Up @@ -664,7 +661,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
// lock C — awaiting N while still holding C would invert that order.
const desktopSwitchApply: CodexDesktopSwitchApply = desktopSwitchesChanged
? await applyCodexDesktopSwitches(config)
: { applied: false, reason: "not_requested", retryable: false };
: await observedCodexDesktopSwitchApply();
const codexDesktopSwitches = describeCodexDesktopSwitches(config, desktopSwitchApply);
const catalogRefreshPending = catalogRefresh
? catalogRefreshIsPending(catalogRefresh)
Expand Down
6 changes: 6 additions & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ lock, so awaiting the injector inside that transaction would invert the order
three separate facts per switch: the **stored** value in `config.json`, the **effective** value
this bind and role will actually produce, and whether `config.toml` was **applied**, with the
reason and retryability when it was not. `src/codex/desktop-switches.ts` owns that projection.
When an external `model_provider` owns `config.toml`, injection preserves the file and reports the
effective switch and authentication source as externally controlled rather than claiming a rewrite.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
A report that attempted no rewrite — the settings GET, or a PUT that changed no desktop switch —
checks the same `currentExternalCodexModelProvider` ownership predicate through
`observedCodexDesktopSwitchApply`, so read reports describe observed ownership instead of
re-deriving it only from a completed apply.

Effective values come from `isEffectiveCodexDesktopAuthless` and
`isEffectiveCodexClientCompaction` in `src/codex/loopback-target.ts` rather than a second copy
Expand Down
4 changes: 4 additions & 0 deletions tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const provider: OcxProviderConfig = {
baseUrl: "https://example.test/v1",
apiKey: "sk-test",
authMode: "key",
// The wire role folds to `system` unless a destination is recorded as accepting
// `developer`; this suite is about tool-result repair ordering, so it declares the
// destination rather than asserting the default.
foldDeveloperRoleToSystem: false,
};

interface ChatMsg {
Expand Down
27 changes: 27 additions & 0 deletions tests/cli/cli-headless-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,33 @@ describe("ocx system settings desktop switches", () => {
}
});

test("reports externally owned switch and authentication state without claiming a rewrite", async () => {
const { deps } = fakeRuntime(() => ({
ok: true,
codexDesktopSwitches: {
codexDesktopAuthless: { stored: true, effective: null },
codexClientCompaction: { stored: false, effective: null },
apply: { applied: false, reason: "external_provider", retryable: false },
authSource: {
presentsCodexAccount: null,
summary: "An external model provider owns Codex sign-in behavior; its account requirement was not changed.",
},
},
}));
const logSpy = spyOn(console, "log").mockImplementation(() => {});
try {
expect(await handleSystemCommand(["settings", "--desktop-authless", "on"], deps)).toBe(0);
const output = logSpy.mock.calls.flat().join("\n");
expect(output).toContain("effective state is controlled by the external model provider");
expect(output).toContain("was not rewritten because an external model provider owns config.toml");
expect(output).toContain("Auth source: An external model provider owns Codex sign-in behavior");
expect(output).not.toContain("was rewritten.");
expect(output).not.toContain("ocx sync");
} finally {
logSpy.mockRestore();
}
});

test("keeps the legacy success line when an older server omits the switch report", async () => {
const { deps } = fakeRuntime((_req, body) => ({ ok: true, ...body }));
const logSpy = spyOn(console, "log").mockImplementation(() => {});
Expand Down
1 change: 1 addition & 0 deletions tests/codex-integration/codex-inject-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1488,6 +1488,7 @@ describe("injectCodexConfig integration (Design B)", () => {
expect(result.success).toBe(true);
expect(result.message).toContain("routing NOT injected");
expect(result.message).toContain('external model_provider "custom"');
expect(result.configApplied).toBe(false);
expect(result.message).toContain("http://127.0.0.1:10100/v1");
expect(result.message).toContain("Responses passthrough");
expect(result.nativeSubagentDefaultsWarning).toContain("external model_provider");
Expand Down
106 changes: 104 additions & 2 deletions tests/config/settings-desktop-switch-apply.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { expect, spyOn, test } from "bun:test";
import { mkdirSync, mkdtempSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { removeTreeWithRetry } from "../helpers/remove-tree";
import { repoRoot } from "../helpers/repo-root";

test("PUT /api/settings reports Codex write-lock contention as retryable", async () => {
test("PUT /api/settings reports why Codex desktop switches were not applied", async () => {
const root = mkdtempSync(join(tmpdir(), "ocx-settings-desktop-switch-"));
const codexHome = join(root, "codex");
mkdirSync(codexHome, { recursive: true });
Expand Down Expand Up @@ -67,6 +69,38 @@ test("PUT /api/settings reports Codex write-lock contention as retryable", async
},
});
expect(injectionSpy).toHaveBeenCalledTimes(1);

injectionSpy.mockResolvedValue({
success: true,
configApplied: false,
message: 'Codex routing NOT injected: external model_provider "custom" owns config.toml.',
});
const externalRequest = new Request("http://127.0.0.1:10100/api/settings", {
method: "PUT",
headers: { host: "127.0.0.1:10100", "content-type": "application/json" },
body: JSON.stringify({ codexClientCompaction: true }),
});
const externalResponse = await handleManagementAPI(
externalRequest,
new URL(externalRequest.url),
config,
{
saveConfigPreservingClaudeCode: () => {},
getCachedStartupHealth: async () => startupHealthFixture(),
createManagementConvergeCodex: catalogConvergenceFactory(() => {}),
},
);

expect(externalResponse!.status).toBe(200);
expect(await externalResponse!.json()).toMatchObject({
codexDesktopSwitches: {
codexDesktopAuthless: { effective: null },
codexClientCompaction: { effective: null },
apply: { applied: false, reason: "external_provider", retryable: false },
authSource: { presentsCodexAccount: null },
},
});
expect(injectionSpy).toHaveBeenCalledTimes(2);
} finally {
injectionSpy.mockRestore();
if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME;
Expand All @@ -76,3 +110,71 @@ test("PUT /api/settings reports Codex write-lock contention as retryable", async
removeTreeWithRetry(root);
}
});

test("GET /api/settings reports external Codex ownership without an apply attempt", () => {
// The ownership predicate reads CODEX_CONFIG_PATH, which is bound to CODEX_HOME at module
// load, so an externally owned config.toml must live in a home fixed before the child
// process starts — mutating process.env here would not move the already-bound path.
const root = mkdtempSync(join(tmpdir(), "ocx-settings-external-get-"));
const codexHome = join(root, "codex");
mkdirSync(codexHome, { recursive: true });
writeFileSync(join(codexHome, "config.toml"), 'model_provider = "custom"\n', "utf8");

const script = `
const { handleManagementAPI } = await import("./src/server/management-api");
const { startupHealthFixture } = await import("./tests/helpers/startup-health");
const config = JSON.parse(process.env.OCX_TEST_ROUTE_CONFIG);
const request = new Request("http://127.0.0.1:10100/api/settings", {
// Same requirement as the in-process cases: managementRequestOrigin derives the
// allowed origin from the Host header, and a constructed Request carries none.
headers: { host: "127.0.0.1:10100" },
});
const response = await handleManagementAPI(request, new URL(request.url), config, {
getCachedStartupHealth: async () => startupHealthFixture(),
});
console.log(JSON.stringify({ status: response.status, body: await response.json() }));
`;
const child = spawnSync(process.execPath, ["--eval", script], {
cwd: repoRoot(),
env: {
...process.env,
CODEX_HOME: codexHome,
OPENCODEX_HOME: join(root, "opencodex"),
OCX_TEST_ROUTE_CONFIG: JSON.stringify({
port: 10100,
defaultProvider: "openai",
codexDesktopAuthless: true,
codexClientCompaction: true,
providers: {
openai: {
adapter: "openai-chat",
baseUrl: "https://api.example.test/v1",
apiKey: "sk-secret-value",
defaultModel: "gpt-test",
},
},
}),
},
encoding: "utf8",
timeout: 30_000,
});
try {
if (child.status !== 0) {
throw new Error(`isolated settings GET failed: ${child.stderr || child.stdout}`);
}
const line = child.stdout.trim().split("\n").filter(Boolean).at(-1);
expect(line).toBeDefined();
const response = JSON.parse(line!) as { status: number; body: Record<string, unknown> };
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
codexDesktopSwitches: {
codexDesktopAuthless: { stored: true, effective: null },
codexClientCompaction: { stored: true, effective: null },
apply: { applied: false, reason: "external_provider", retryable: false },
authSource: { presentsCodexAccount: null },
},
});
} finally {
removeTreeWithRetry(root);
}
}, 15_000);
Loading
Loading