Skip to content
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 @@ -698,6 +698,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
7 changes: 6 additions & 1 deletion src/cli/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,14 @@ async function sidecar(argv: string[], deps: RuntimeApiDeps): Promise<void> {
const apply = (result as { codexWebSearch?: { applied?: boolean; reason?: string; detail?: string } } | null)?.codexWebSearch;
if (apply && apply.reason !== "not_requested") {
const detail = typeof apply.detail === "string" && apply.detail.length > 0 ? ` Details: ${apply.detail}` : "";
// `ocx sync` re-runs the same injection the external provider owns — the retry
// advice is meaningless on that outcome, same as the Desktop-switch report.
const retry = apply.reason === "external_provider"
? ""
: " Run 'ocx sync' to apply the stored settings.";
lines.push(apply.applied === true
? "Codex config: ~/.codex/config.toml was rewritten."
: `Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail} Run 'ocx sync' to apply the stored settings.`);
: `Codex config: ~/.codex/config.toml was not rewritten because ${desktopSwitchApplyReason(apply.reason)}.${detail}${retry}`);
}
printData(result, wantsJson, lines);
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/runtime-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ export 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";
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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 Down
13 changes: 11 additions & 2 deletions src/cli/system-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ function desktopSwitchInertReason(reason: unknown): string {
return "the stored setting is not effective in the current runtime configuration";
}


function settingsUpdateLines(
result: unknown,
changed: { desktopAuthless: boolean; clientCompaction: boolean },
Expand All @@ -68,8 +69,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 @@ -96,7 +102,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
75 changes: 67 additions & 8 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.
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,46 @@ 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 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");
let provider: string | null;
try {
provider = currentExternalCodexModelProvider();
} catch (error) {
// A present-but-unreadable config.toml (permissions, deletion racing existsSync)
// must not take down the whole settings report — ownership is simply undetermined.
return {
applied: false,
reason: "not_requested",
retryable: true,
detail: `config.toml ownership could not be determined: ${error instanceof Error ? error.message : String(error)}`,
};
}
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)}.`,
};
}

// The apply gates skip the injector entirely, so they run the same ownership read the
// observed path does — a disabled integration or an absent runtime must not make a
// switch PUT report local state the external provider still controls.
async function externalOwnershipApply(): Promise<CodexDesktopSwitchApply | null> {
const ownership = await observedCodexDesktopSwitchApply();
return !ownership.applied && ownership.reason === "external_provider" ? ownership : null;
}

/**
* Re-run the Codex config injection so a setting that lives in `~/.codex/config.toml` follows the
* stored config NOW rather than at the next `ocx sync`.
Expand All @@ -100,13 +149,15 @@ export async function applyCodexConfigInjection(
config: OcxConfig,
): Promise<CodexDesktopSwitchApply> {
if (!shouldSyncCodexOnStart(config)) {
return { applied: false, reason: "integration_disabled", retryable: false };
return (await externalOwnershipApply())
?? { applied: false, reason: "integration_disabled", retryable: false };
}

const { readRuntimePort } = await import("../config/process-state");
const runtime = readRuntimePort(process.pid);
if (!runtime) {
return { applied: false, reason: "proxy_not_running", retryable: true };
return (await externalOwnershipApply())
?? { applied: false, reason: "proxy_not_running", retryable: true };
}

try {
Expand All @@ -123,6 +174,14 @@ export async function applyCodexConfigInjection(
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 @@ -133,6 +133,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 {
applyCodexConfigInjection,
describeCodexDesktopSwitches,
observedCodexDesktopSwitchApply,
type CodexDesktopSwitchApply,
} from "../../codex/desktop-switches";
import {
Expand Down Expand Up @@ -360,11 +361,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()),
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
compactionRouting: config.compactionRouting ?? null,
startupHealth: await readStartupHealth(config),
codexRuntime: {
Expand Down Expand Up @@ -687,7 +684,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 applyCodexConfigInjection(config)
: { applied: false, reason: "not_requested", retryable: false };
: await observedCodexDesktopSwitchApply();
const codexDesktopSwitches = describeCodexDesktopSwitches(config, desktopSwitchApply);
const catalogRefreshPending = catalogRefresh
? catalogRefreshIsPending(catalogRefresh)
Expand Down
3 changes: 3 additions & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,9 @@ 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. A report that attempted no
rewrite checks the same `currentExternalCodexModelProvider` predicate via `observedCodexDesktopSwitchApply`.

Effective values come from `isEffectiveCodexDesktopAuthless` and
`isEffectiveCodexClientCompaction` in `src/codex/loopback-target.ts` rather than a second copy
Expand Down
55 changes: 55 additions & 0 deletions tests/cli/cli-headless-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,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 Expand Up @@ -367,6 +394,34 @@ describe("ocx agent sidecar --list (#2188)", () => {
logSpy.mockRestore();
}
});

test("an externally owned Codex config gets no 'ocx sync' retry advice", async () => {
const { deps } = fakeRuntime((req) => {
const url = new URL(req.url);
if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
return {
ok: true,
webSearch: { enabled: false },
codexWebSearch: {
applied: false,
reason: "external_provider",
retryable: false,
detail: 'config.toml selects the external model_provider "custom".',
},
};
}
return undefined;
});
const logSpy = spyOn(console, "log").mockImplementation(() => {});
try {
expect(await handleAgentCommand(["sidecar", "web", "--enabled", "off"], deps)).toBe(0);
const out = logSpy.mock.calls.map(call => String(call[0])).join("\n");
expect(out).toContain("was not rewritten because an external model provider owns config.toml");
expect(out).not.toContain("ocx sync");
} finally {
logSpy.mockRestore();
}
});
});

afterEach(() => {
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 @@ -1510,6 +1510,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
Loading
Loading