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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 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,20 @@ 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 --json` return a description of 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
Comment thread
luvs01 marked this conversation as resolved.
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`.

If `config.toml` exists but cannot be read (permissions, or a delete racing the read), the same
report withholds the effective values and the sign-in answer as undetermined instead of showing
the state OpenCodex would compute locally. The apply record keeps that explanation — including on
a save whose injection gate is already closed by a disabled integration or a stopped proxy — and
marks it retryable, so a later `ocx system settings --json` read reports the settled answer once
the file is readable again. With Codex integration disabled, `ocx sync` is catalog-only and cannot apply it.

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
11 changes: 10 additions & 1 deletion src/cli/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,18 @@ 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"
? ""
: apply.reason === "ownership_undetermined"
? " Resolve the reported config.toml read error, then inspect 'ocx system settings --json'."
: apply.reason === "integration_disabled"
? " Enable Codex integration before applying the stored settings."
: " 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
2 changes: 2 additions & 0 deletions src/cli/runtime-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ 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";
if (reason === "ownership_undetermined") return "config.toml ownership could not be determined";
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
23 changes: 21 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,19 @@ 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) {
// `null` is reported for both withheld cases; the apply reason is the only place
// that still distinguishes them, so the line has to read it rather than claim
// external control over an ownership the server could not determine.
const withheld = recordValue(switches.apply)?.reason === "ownership_undetermined"
? "effective state could not be determined"
: "effective state is controlled by the external model provider";
lines.push(`${label}: ${withheld}.`);
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 +108,14 @@ 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"
? ""
: apply.reason === "ownership_undetermined"
? " Resolve the reported config.toml read error, then inspect 'ocx system settings --json'."
: apply.reason === "integration_disabled"
? " Enable Codex integration before applying the stored settings."
: " 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
96 changes: 88 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,16 @@ 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"
| "ownership_undetermined"
| "write_lock_busy"
| "injection_refused";

Expand All @@ -35,7 +38,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 +53,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 +72,31 @@ export function describeCodexDesktopSwitches(
apply: CodexDesktopSwitchApply,
): CodexDesktopSwitchReport {
const authlessStored = config.codexDesktopAuthless === true;
const authlessEffective = isEffectiveCodexDesktopAuthless(config);
const externallyOwned = !apply.applied && apply.reason === "external_provider";
const ownershipUndetermined = !apply.applied && apply.reason === "ownership_undetermined";
// An unreadable config.toml leaves ownership undetermined, so the local effective values are
// withheld exactly like the externally owned case: reporting them would present OpenCodex's
// stored-versus-computed state as live while the file may belong to another provider.
const effectiveWithheld = externallyOwned || ownershipUndetermined;
const authlessEffective = effectiveWithheld ? null : isEffectiveCodexDesktopAuthless(config);
const compactionStored = config.codexClientCompaction === true;
const compactionEffective = isEffectiveCodexClientCompaction(config);
const compactionEffective = effectiveWithheld ? null : isEffectiveCodexClientCompaction(config);

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.",
}
: ownershipUndetermined
? {
presentsCodexAccount: null,
summary: "Whether the Codex app requires its own account sign-in is undetermined; config.toml ownership could not be read.",
}
: authlessEffective
? {
presentsCodexAccount: false,
summary: "The Codex app will not require its own account sign-in.",
Expand All @@ -88,6 +108,53 @@ 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. 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 {
applied: false,
reason: "ownership_undetermined",
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. An undetermined
// read is kept for the same reason: replacing it with the gate's reason would drop the
// "ownership could not be determined" explanation the locked save still owes.
async function observedOwnershipApply(): Promise<CodexDesktopSwitchApply | null> {
const ownership = await observedCodexDesktopSwitchApply();
return !ownership.applied
&& (ownership.reason === "external_provider" || ownership.reason === "ownership_undetermined")
? 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 +167,15 @@ export async function applyCodexConfigInjection(
config: OcxConfig,
): Promise<CodexDesktopSwitchApply> {
if (!shouldSyncCodexOnStart(config)) {
return { applied: false, reason: "integration_disabled", retryable: false };
return (await observedOwnershipApply())
?? { 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 observedOwnershipApply())
?? { applied: false, reason: "proxy_not_running", retryable: true };
}

try {
Expand All @@ -123,6 +192,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 All @@ -143,6 +220,9 @@ export async function applyCodexConfigInjection(
detail: result.message,
};
} catch (error) {
// The injector may fail its ownership read after the initial apply gates passed.
const ownership = await observedOwnershipApply();
if (ownership) return ownership;
return {
applied: false,
reason: "injection_refused",
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()),
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
18 changes: 9 additions & 9 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,18 +346,21 @@ returns true.

## Desktop compatibility switches report three things, not one

`codexDesktopAuthless` and `codexClientCompaction` only mean anything through the injected
`config.toml`, so persisting them is not applying them. `PUT /api/settings` used to persist
and then converge the catalog, and a comment there claimed the injector rewrote the form;
`convergeCodexCatalog` rejects any scope but `catalog` and never reaches `injectCodexConfig`,
so the injected shape stayed as it was until a separate `ocx sync`.
`codexDesktopAuthless` and `codexClientCompaction` take effect through injected `config.toml`; persisting
them is not applying them, and `convergeCodexCatalog` (catalog scope only) never calls `injectCodexConfig`.

The route now runs the real injection after catalog convergence and after the config mutation
`PUT /api/settings` runs the real injection after catalog convergence and after the config mutation
lock has closed — coordinated Codex writes take the Codex write lock before the config mutation
lock, so awaiting the injector inside that transaction would invert the order — and reports
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
applies the same `currentExternalCodexModelProvider` predicate via `observedCodexDesktopSwitchApply`.
A present-but-unreadable `config.toml` reports `ownership_undetermined` with `null` effective values and
sign-in answer, since a foreign provider may still control them; both apply gates and injector-error
observation keep that record, and recovery advice asks for a later settings read, not sync.

Effective values come from `isEffectiveCodexDesktopAuthless` and
`isEffectiveCodexClientCompaction` in `src/codex/loopback-target.ts` rather than a second copy
Expand Down Expand Up @@ -422,9 +425,6 @@ Provider seed/enrichment and request routing consume the same field-level resolv
still stores operator intent rather than the frozen result; registry-only policy is applied at
capture/route time and explicit false or empty declarations retain their field-specific meaning.




## Provider validation ownership

`src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config,
Expand Down
Loading
Loading