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
15 changes: 15 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,21 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
| `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. |
| `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. |

## Codex catalog and root `config.toml` settings

These settings belong in the root of `$CODEX_HOME/config.toml`, alongside
`approvals_reviewer`; they are not provider fields.

| Field | Type | Meaning |
| --- | --- | --- |
| `auto_review_model` | `string` | Public catalog selector in `provider/model` form, for example `opencode-go/deepseek-v4-flash`. After each catalog merge, OpenCodex resolves it against the final catalog and stamps the trimmed value as `auto_review_model_override` on catalog entries. Boundary whitespace is removed; the selector's slash-delimited components are otherwise unchanged. If the value is absent or blank, existing routed overrides are cleared and normal upstream auto-review selection is preserved. If it is syntactically invalid or absent from the final catalog (including after provider/model removal), OpenCodex fails closed for the override only: it clears the dead override, preserves normal upstream behavior, and emits a diagnostic. Re-adding the provider/model on a later sync allows the configured selector to be stamped again. |

The setting is evaluated after provider discovery, model filtering, native/account-row
projection, and merge precedence, so only a selector present in the catalog produced by
that sync can become an override. Native upstream values are preserved when the setting is
cleared or unresolved. The persisted catalog field is read by Codex for the current turn's
model, which is why a valid configured selector is copied to each applicable entry.

### FastWire B1 capability migration

Fast capability and arbitrary Chat caller-tier forwarding are independent after FastWire B1. The
Expand Down
27 changes: 26 additions & 1 deletion src/cli/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export type ClaudeEnvDeps = {
authDetect?: Omit<Partial<AuthDetectDeps>, "env" | "ownTokens">;
/** Test seam; production uses the authenticated Node-launcher context. */
preBunAnthropicSlots?: readonly AnthropicParentEnvSlot[] | null;
/** Explicit unsafe opt-in from a root `--dangerously-skip-permissions` launch. */
allowRootSkipPermissions?: boolean;
};

function isClaudeLoopbackHostname(hostname: string): boolean {
Expand Down Expand Up @@ -115,6 +117,9 @@ export function buildClaudeEnv(
if (env[name] !== undefined && env[name] !== "") return; // user wins
env[name] = value;
};
if (deps.allowRootSkipPermissions === true) {
setDefault("IS_SANDBOX", "1");
}
setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`);
const existingBaseUrl = env.ANTHROPIC_BASE_URL;
if (existingBaseUrl) {
Expand Down Expand Up @@ -301,6 +306,22 @@ export function claudeNotFoundHint(
return platform === "win32" && code === 9009 && !signal ? CLAUDE_INSTALL_HINT : null;
}

export function shouldAllowRootSkipPermissions(
args: readonly string[],
getuid: (() => number) | null | undefined = process.getuid,
): boolean {
return args.includes("--dangerously-skip-permissions")
&& typeof getuid === "function"
&& getuid() === 0;
}

export function rootSkipPermissionsNotice(env: ClaudeLaunchEnv): string {
if (env.IS_SANDBOX === "1") {
return "⚠ Root --dangerously-skip-permissions requested: OpenCodex set IS_SANDBOX=1 to bypass Claude Code's root guard. OpenCodex did not create an OS sandbox; prefer running as a non-root user.";
}
return `⚠ Root --dangerously-skip-permissions requested: preserving user IS_SANDBOX=${env.IS_SANDBOX}; Claude Code's root guard remains in control.`;
}

export async function cmdClaude(args: string[]): Promise<number> {
const config = loadConfig();
if (config.claudeCode?.enabled === false) {
Expand All @@ -313,7 +334,11 @@ export async function cmdClaude(args: string[]): Promise<number> {
return 1;
}
const contextWindows = await fetchClaudeContextWindows(config, port);
const env = buildClaudeEnv(config, port, process.env, contextWindows);
const allowRootSkipPermissions = shouldAllowRootSkipPermissions(args);
const env = buildClaudeEnv(config, port, process.env, contextWindows, { allowRootSkipPermissions });
if (allowRootSkipPermissions) {
console.error(rootSkipPermissionsNotice(env));
}
// Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI
// never refreshes it, so the picker would keep showing yesterday's aliases.
try {
Expand Down
2 changes: 1 addition & 1 deletion src/codex/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync";
export type { ObservedCatalogMergeInput } from "./catalog/sync";
export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";
export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models";
16 changes: 16 additions & 0 deletions src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@ export function readCodexCatalogPathForHome(codexHome: string): string {
return join(codexHome, "opencodex-catalog.json");
}

/**
* Read the configured auto-review model from the root of Codex's config.toml (issue #1225).
* Stamped onto catalog entries as `auto_review_model_override` during sync so the auto-review
* subagent uses the operator's chosen model across catalog regenerations.
*/
export function readConfiguredAutoReviewModel(): string | null {
try {
const configPath = activeCodexConfigPath();
if (existsSync(configPath)) {
const toml = readFileSync(configPath, "utf-8");
return readRootTomlString(toml, "auto_review_model");
}
} catch { /* ignore */ }
return null;
}

export function parseCatalogJson(raw: string): RawCatalog | null {
try {
const cat = JSON.parse(raw);
Expand Down
129 changes: 127 additions & 2 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
} from "../model-entitlements";


import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readNativeBaseline } from "./parsing";
import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, readConfiguredAutoReviewModel, readNativeBaseline } from "./parsing";
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
import { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, nativeContextLimits, observedAccountBoundNativeEntries, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry, type NativeContextLimitsInput } from "./metadata";
import {
Expand Down Expand Up @@ -1403,6 +1403,130 @@ function catalogModelsForMergeWithNativeRecovery(
]);
}

const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/;

export function isValidAutoReviewModel(value: unknown): value is string {
if (typeof value !== "string") return false;
const trimmed = value.trim();
return Boolean(trimmed)
&& trimmed.length <= 1024
&& !AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed);
}

export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved";

function isRoutedCatalogEntry(entry: RawEntry): boolean {
const slug = typeof entry.slug === "string" ? entry.slug : "";
return slug.includes("/")
|| (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → "));
}

function clearAutoReviewModelOverride(
models: readonly RawEntry[],
sourceModels: readonly RawEntry[] = [],
): void {
const observedModels = [...models, ...sourceModels];
const configuredValues = new Set(observedModels.flatMap(entry => {
const value = entry?.auto_review_model_override;
return typeof value === "string" && value.trim() ? [value] : [];
}));
const globalStamp = configuredValues.size === 1
&& observedModels.some(entry => {
const value = entry.auto_review_model_override;
return isRoutedCatalogEntry(entry)
&& typeof value === "string"
&& value.trim().length > 0
&& configuredValues.has(value);
})
&& observedModels.every(entry => {
const value = entry?.auto_review_model_override;
return value === null
|| value === undefined
|| (typeof value === "string" && configuredValues.has(value));
});
for (const entry of models) {
if (!entry || typeof entry !== "object") continue;
const current = entry.auto_review_model_override;
if (isRoutedCatalogEntry(entry)
|| (globalStamp && typeof current === "string" && configuredValues.has(current))) {
entry.auto_review_model_override = null;
Comment on lines +1450 to +1452

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore native overrides when removing the global override

When a valid auto_review_model is applied, every native row's original auto_review_model_override is replaced by the configured value; if the setting is then removed or becomes unresolved, sourceModels contains only that global value, so globalStamp is true and this branch changes the native rows to null rather than recovering their original upstream overrides. Consequently, an apply-then-clear sequence permanently changes native auto-review selection across later syncs, contrary to the documented preservation behavior in providers.md; retain the original value from a pristine/upstream source or persist ownership metadata for OpenCodex stamps.

AGENTS.md reference: AGENTS.md:L279-L280

Useful? React with 👍 / 👎.

}
}
}

function warnAutoReviewModelDiagnostic(
reason: "invalid" | "unresolved",
configured: string,
): void {
const safeConfigured = JSON.stringify(redactSecretString(configured));
const detail = reason === "unresolved"
? "the selector was not found in the final catalog"
: "the selector format is invalid";
console.warn(
`[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`,
);
}

function preserveNativeAutoReviewModelOverrides(
models: readonly RawEntry[],
sourceModels: readonly RawEntry[],
): void {
const existing = new Map<string, string | null>();
for (const entry of sourceModels) {
const slug = typeof entry.slug === "string" ? entry.slug : undefined;
const value = entry.auto_review_model_override;
if (!slug || isRoutedCatalogEntry(entry)) continue;
if (typeof value === "string" || value === null) existing.set(slug, value);
}
for (const entry of models) {
const slug = typeof entry.slug === "string" ? entry.slug : undefined;
if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue;
entry.auto_review_model_override = existing.get(slug) ?? null;
}
}

export function applyAutoReviewModelOverride(
models: RawEntry[] | undefined,
autoReviewModel: string | null | undefined,
sourceModels: readonly RawEntry[] = [],
): AutoReviewModelOverrideResult {
if (!models || !Array.isArray(models)) return "absent";
if (autoReviewModel === null || autoReviewModel === undefined) {
clearAutoReviewModelOverride(models, sourceModels);
return "absent";
}
const trimmed = autoReviewModel.trim();
if (!trimmed) {
clearAutoReviewModelOverride(models, sourceModels);
return "absent";
}
if (!isValidAutoReviewModel(trimmed)) {
clearAutoReviewModelOverride(models, sourceModels);
warnAutoReviewModelDiagnostic("invalid", trimmed);
return "invalid";
}
if (!configuredCatalogEntry(models, trimmed)) {
clearAutoReviewModelOverride(models, sourceModels);
warnAutoReviewModelDiagnostic("unresolved", trimmed);
return "unresolved";
}
for (const entry of models) {
if (entry && typeof entry === "object") {
entry.auto_review_model_override = trimmed;
}
}
return "applied";
}

/** Apply the root Codex auto-review selector after the final catalog merge. */
export function finalizeAutoReviewModelOverride(
models: RawEntry[] | undefined,
sourceModels: readonly RawEntry[] = [],
): AutoReviewModelOverrideResult {
if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels);
return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels);
}

function writeRetainedCatalogSync({
config,
goModels,
Expand Down Expand Up @@ -1596,6 +1720,7 @@ function writeRetainedCatalogSync({
},
});
clampCatalogModelsToCodexSupport(catalog.models);
finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge);

const added = goEntries.length + accountBoundEntries.length;
const content = `${JSON.stringify(catalog, null, 2)}\n`;
Expand Down Expand Up @@ -1833,10 +1958,10 @@ export function invalidateCodexModelsCacheWithPermit(
// keeps the cache consistent with the catalog it just wrote.
if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
const cachePath = join(owningCodexHome, "models_cache.json");
if (!existsSync(catalogPath)) return false;
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
const models = catalog.models ?? catalog;
const cachePath = join(owningCodexHome, "models_cache.json");
const currentCache = readCatalog(cachePath);
const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
typeof entry.slug === "string" ? [entry.slug] : []));
Expand Down
2 changes: 2 additions & 0 deletions src/codex/convergence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import {
buildCatalogEntriesFromObservedState,
CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
finalizeAutoReviewModelOverride,
mergeCatalogEntriesFromObservedState,
mergeCatalogModelsWithNativeRecovery,
orderForSubagents,
Expand Down Expand Up @@ -371,6 +372,7 @@ function prepareCatalog(
? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog)
: null,
);
finalizeAutoReviewModelOverride(mergedModels, catalogModels);
catalog.models = mergedModels;
return catalog;
}
Expand Down
37 changes: 35 additions & 2 deletions tests/claude-cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import { claudeNotFoundHint } from "../src/cli/claude";
import { buildClaudeEnv, claudeNotFoundHint, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../src/cli/claude";
import { commandInvocation } from "../src/lib/win-exec";
import { buildClaudeEnv } from "../src/cli/claude";
import type { OcxConfig } from "../src/types";

function cfg(extra?: Partial<OcxConfig>): OcxConfig {
Expand All @@ -27,6 +26,40 @@ const AUTH_PRESENT = {
};

describe("ocx claude env assembly", () => {
test("root skip-permissions bypass requires both the explicit flag and uid 0", () => {
expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], () => 0)).toBe(true);
expect(shouldAllowRootSkipPermissions([], () => 0)).toBe(false);
expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], () => 1000)).toBe(false);
expect(shouldAllowRootSkipPermissions(["--dangerously-skip-permissions"], null)).toBe(false);
});

test("root skip-permissions opt-in marks only that launch as sandboxed", () => {
const bypass = buildClaudeEnv(cfg(), 10100, {}, {}, {
...AUTH_PRESENT,
allowRootSkipPermissions: true,
});
expect(bypass.IS_SANDBOX).toBe("1");

const ordinary = buildClaudeEnv(cfg(), 10100, {}, {}, AUTH_PRESENT);
expect(ordinary.IS_SANDBOX).toBeUndefined();
});

test("an explicit user sandbox value wins over the root skip-permissions opt-in", () => {
const env = buildClaudeEnv(cfg(), 10100, { IS_SANDBOX: "0" }, {}, {
...AUTH_PRESENT,
allowRootSkipPermissions: true,
});
expect(env.IS_SANDBOX).toBe("0");
expect(rootSkipPermissionsNotice(env)).toContain("preserving user IS_SANDBOX=0");
expect(rootSkipPermissionsNotice(env)).toContain("root guard remains in control");
});

test("the unsafe root bypass notice discloses that no OS sandbox was created", () => {
const notice = rootSkipPermissionsNotice({ IS_SANDBOX: "1" });
expect(notice).toContain("set IS_SANDBOX=1");
expect(notice).toContain("did not create an OS sandbox");
});

test("injects base URL, discovery flag and model slots — NO auth token by default (subscription mode)", () => {
const env = buildClaudeEnv(cfg({
claudeCode: { model: "claude-ocx-gemini--gemini-3-pro", smallFastModel: "gemini/gemini-3-flash" },
Expand Down
Loading
Loading