Skip to content
Closed
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
34 changes: 34 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1621,3 +1621,37 @@ The shared SSE decoder accepts LF, CRLF and standalone CR line endings, even whe
Choosing an account-qualified model does not enable credit spending. Stored accounts obey **Use credits after limit** during authentication, when credentials are prepared, and before Responses HTTP or WebSocket dispatch after pacing or retry waits. A held request reports the credit policy, not an authentication failure; wait for the reset, choose another account, or explicitly enable that account’s credit spending.

Stored-account vision and web-search helpers also recheck this policy before sending, including retries. If consent changes or a limit is reached after helper selection, the helper reports the policy refusal without sending that attempt. The standalone search relay returns a reset-bound 429. Caller-owned Direct credentials retain their existing behavior.

## Opt in a custom provider to managed local routing

A custom provider such as `chatgpt-http` may use `supports_websockets = false`
to keep HTTP transport. OpenCodex leaves custom providers untouched by default.
To delegate only that provider's URL to OpenCodex, run:

```sh
ocx codex-provider allow chatgpt-http
ocx start
```

The first command explains the scope and asks for confirmation, defaulting to No.
For an explicitly approved scripted operation, use `allow chatgpt-http --yes`.
Consent applies to this provider ID in the selected `CODEX_HOME`; it does not
approve other providers or other homes. Start and sync update only its `base_url`
to the local proxy. Its identity, WebSocket setting, other TOML fields and history
remain unchanged. Keep the proxy running while using this route.

This mode supports explicit Responses provider tables with `requires_openai_auth = true`
and no custom credentials or headers. Non-loopback targets, authless Desktop mode
and client compaction require the existing dedicated integration instead.

`ocx stop` and `ocx restore` restore recorded URLs while keeping consent for a later
start. Run `ocx codex-provider status` to list approvals, or
`ocx codex-provider revoke chatgpt-http` to restore its managed URL and remove consent.
A URL changed outside OpenCodex is preserved and requires revoke followed by a new
approval; OpenCodex does not silently adopt the replacement as a new baseline.
The original URL assignment is retained in private recovery metadata under
`OPENCODEX_HOME`, separately from the native config journal.

Startup safety recognizes an approved provider's recorded managed URL as OpenCodex routing.
This does not install restart protection: a manually started proxy still needs a viable
background service to recover automatically after a restart.
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/reference/cli/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -1139,3 +1139,13 @@ Use `ocx connect <url> --pairing-code-stdin`, `ocx connect status`, `ocx sync`,
On Linux, `ocx service install` checks `loginctl show-user <current-user> -p Linger` before printing boot advice. Enabled linger is reported as already enabled. Disabled linger gets the `loginctl enable-linger $USER` hint; missing or unreadable diagnostics state the uncertainty and do not fail installation.

On macOS, `ocx service status` distinguishes a loaded Desktop login item from a valid enabled registration awaiting the next login. `ocx status` credits the latter in restart safety when the same Desktop app currently supervises the proxy. An incorrect plist label or command, a disabled entry, an unverified load probe, or changed process identity still cannot earn protection.

## Custom Codex provider consent

`ocx codex-provider status` lists the provider IDs approved for the selected Codex home.
`ocx codex-provider allow <id>` asks before allowing URL-only management on start/sync;
`--yes` is explicit noninteractive consent for that named provider.
`ocx codex-provider revoke <id>` restores an unchanged managed URL and revokes permission.
These commands are local and do not require a running proxy. See
[custom provider routing](/guides/codex-integration/#opt-in-a-custom-provider-to-managed-local-routing)
for eligibility, restore behavior and transport preservation.
2 changes: 1 addition & 1 deletion scripts/generate-ocx-skill-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const PREAMBLE = `<!-- ${OWNER}. Do not edit by hand. -->\n<!-- Regenerate: bun

// Navigation groups, not command dispatch. New roots require an explicit assignment.
const DOMAINS = [
{ name: "lifecycle", roots: ["chatgpt", "status", "resolve", "capabilities", "sync", "start", "stop", "restart", "service", "gui"] },
{ name: "lifecycle", roots: ["codex-provider", "chatgpt", "status", "resolve", "capabilities", "sync", "start", "stop", "restart", "service", "gui"] },
{ name: "providers-models", roots: ["provider", "models", "alias"] },
{ name: "accounts", roots: ["account", "auth", "login", "logout"] },
{ name: "agents-routing", roots: ["agent", "combo", "route", "v2", "effort", "memory", "message", "advisor"] },
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"version": 1,
"root": "tests",
"explicitShards": ["layout-clients.json"],
"explicit": { "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration",
"explicit": { "codex-provider-routing.test.ts": "codex-integration", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration",
"owner-registry-isolation.test.ts": "config",
"codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses",
"messages-request-id-endpoint.test.ts": "claude-integration",
Expand Down
6 changes: 5 additions & 1 deletion skills/ocx/references/01_management_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin

| Chapter | Declared capabilities |
|---|---|
| [lifecycle](01_surface_lifecycle.md) | 12 |
| [lifecycle](01_surface_lifecycle.md) | 13 |
| [providers-models](01_surface_providers-models.md) | 47 |
| [accounts](01_surface_accounts.md) | 41 |
| [agents-routing](01_surface_agents-routing.md) | 51 |
Expand All @@ -47,6 +47,10 @@ Check the chapter's flags, the requested authority and the operating skill's con

Original invocation order. These headings preserve links to the previous single-file reference.

### `ocx codex-provider`

[State-changing task](01_surface_lifecycle.md#ocx-codex-provider)

### `ocx chatgpt`

[State-changing task](01_surface_lifecycle.md#ocx-chatgpt)
Expand Down
20 changes: 19 additions & 1 deletion skills/ocx/references/01_surface_lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,25 @@
Use these declarations to choose a task, then check its flags and authority before execution.
Non-mutating probes may still contact providers, consume quota or refresh caches.

Declared capabilities: 12.
Declared capabilities: 13.

### `ocx codex-provider`

Usage: `ocx codex-provider <status|allow <id> [--yes]|revoke <id>>`

Inspect, allow or revoke custom Codex provider routing consent.

State-changing: yes.

Drives no management route.

| Flag | Value | Meaning |
|---|---|---|
| `--yes` | boolean | Explicitly approve the named provider without an interactive prompt. |

JSON mode: `none`.

- Only local loopback routing for Responses providers using OpenAI authentication is supported. No custom credentials/headers, authless mode or client compaction. Unapproved providers are never changed.

### `ocx chatgpt`

Expand Down
9 changes: 9 additions & 0 deletions src/cli/capabilities-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [
* A capability must not name a route the command does not actually fetch.
*/
export const CAPABILITIES: readonly Capability[] = [
{
command: ["codex-provider"],
bannerLines: ["ocx codex-provider <sub> Confirm custom Codex provider routing: status|allow|revoke"],
summary: "Inspect, allow or revoke custom Codex provider routing consent.",
routes: [], flags: [{ name: "--yes", value: "boolean", summary: "Explicitly approve the named provider without an interactive prompt." }],
mutates: true, json: "none",
usage: "ocx codex-provider <status|allow <id> [--yes]|revoke <id>>",
details: ["Only local loopback routing for Responses providers using OpenAI authentication is supported. No custom credentials/headers, authless mode or client compaction. Unapproved providers are never changed."],
},
{
command: ["chatgpt"],
summary: "Experimental ChatGPT app-server shim: launch, restore or status (macOS only).",
Expand Down
44 changes: 44 additions & 0 deletions src/cli/codex-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { allowCodexProviderRouting, approvedCodexProviders, previewCodexProviderConsent, restoreApprovedCodexProviders } from "../codex/provider-routing";
import { interactiveConfirm } from "./interactive-confirm";
import { terminalSafeError, terminalSafeText } from "./runtime-api";

export const CODEX_PROVIDER_USAGE = "Usage: ocx codex-provider <status|allow <id> [--yes]|revoke <id>>";

export async function handleCodexProviderCommand(args: string[]): Promise<number> {
const [verb, id, flag] = args;
if (verb === "status" && args.length === 1) {
console.log(JSON.stringify({ approvedProviders: approvedCodexProviders() }));
return 0;
}
if ((verb !== "allow" && verb !== "revoke") || !id || args.length > 3
|| (flag !== undefined && (verb !== "allow" || flag !== "--yes"))) {
console.error(CODEX_PROVIDER_USAGE);
return 1;
}
try {
if (verb === "revoke") {
restoreApprovedCodexProviders(id);
console.log(`Routing consent revoked for ${terminalSafeText(id)}; any unchanged managed URL was restored.`);
return 0;
}
const expected = previewCodexProviderConsent(id);
console.log(`OpenCodex will manage only base_url for Codex provider ${terminalSafeText(id)} in the selected CODEX_HOME.`);
console.log("Start/sync will route it to the local proxy; stop/restore will restore its original URL. Provider identity, transport settings and history stay unchanged.");
if (flag !== "--yes") {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error("Explicit consent required: use an interactive terminal or pass --yes with the exact provider ID.");
return 1;
}
if (!await interactiveConfirm({ question: "Allow this provider's routing to be managed?", defaultYes: false })) {
console.log("Cancelled; no consent or routing changes were saved.");
return 0;
}
}
allowCodexProviderRouting(id, expected);
console.log("Consent saved. Run ocx start or ocx sync to apply routing; use ocx codex-provider revoke <id> to undo consent.");
return 0;
} catch (error) {
console.error(terminalSafeError(error));
return 1;
}
}
4 changes: 4 additions & 0 deletions src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,10 @@ const commandRunners: Record<string, CommandRunner> = {
const { handleLabCommand } = await import("./lab");
return await handleLabCommand(deps.args.slice(1));
},
"codex-provider": async deps => {
const { handleCodexProviderCommand } = await import("./codex-provider");
return await handleCodexProviderCommand(deps.args.slice(1));
},
chatgpt: async deps => {
const { handleChatgptCommand } = await import("./chatgpt-command");
return await handleChatgptCommand(deps.args.slice(1));
Expand Down
1 change: 1 addition & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ Usage:
ocx advisor <status|on|off|set|consent> Advisor sidecar settings and context-sharing consent
ocx companion <show|set|reset> Menu-bar and widget companion usage settings
ocx lab <sub> Inspect Lab evidence and control local automation
ocx codex-provider <sub> Confirm custom Codex provider routing: status|allow|revoke
ocx chatgpt <sub> Experimental app-server shim: launch|restore|status (macOS)
ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile
Expand Down
2 changes: 1 addition & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ async function handleStart(options: { block?: boolean } = {}) {
}
removePid(process.pid);
removeRuntimePort(process.pid);
if (teardown.restoreNativeCodex && !currentExternalCodexModelProvider()) {
if (teardown.restoreNativeCodex) {
try {
if (!codexHomeIsAbsent(getCodexHome())) {
const restored = restoreNativeCodex();
Expand Down
6 changes: 6 additions & 0 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export interface CliCommandEntry {
}

export const CLI_COMMANDS: CliCommandEntry[] = [
{
name: "codex-provider",
usage: "ocx codex-provider <status|allow <id> [--yes]|revoke <id>>",
summary: "Explicitly allow or revoke local routing management for a custom Codex provider.",
details: ["Consent is bound to the selected CODEX_HOME and provider ID. Only base_url is managed; transport settings and history are preserved."],
},
{
name: "message",
usage: "ocx message <sessions|send> [options]",
Expand Down
6 changes: 5 additions & 1 deletion src/codex/inject.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { injectApprovedCodexProvider } from "./provider-routing";
import { beginCodexWriteSection, cleanExternalProviderJournal, publishCodexArtifact } from "./inject/config-write-section";
import { closeSync, existsSync, openSync, readFileSync, statSync } from "node:fs";
import { dirname } from "node:path";
Expand Down Expand Up @@ -240,6 +241,9 @@ async function injectCodexConfigImpl(
const rawContent = missingConfig ? "" : readFileSync(CODEX_CONFIG_PATH, "utf-8");
const activeProvider = externalCodexModelProvider(rawContent);
if (activeProvider) {
if (injectApprovedCodexProvider(activeProvider, routingTarget, options.beforeClientWrite ? () => runClientWriteGuard(options.beforeClientWrite) : undefined, options.validateOnly)) {
return { success: true, ...(options.validateOnly ? { configApplied: false as const } : {}), message: `Approved Codex provider ${tomlString(activeProvider)} routes through OpenCodex; provider identity and transport settings preserved.` };
}
// A launcher may have journaled before the provider manager took ownership. Never let shutdown
// replay that stale snapshot over externally managed config.
if (!options.validateOnly) {
Expand All @@ -258,7 +262,7 @@ async function injectCodexConfigImpl(
: {}),
message:
`⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` +
` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` +
` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n To authorize URL-only management, run ocx codex-provider allow ${tomlString(activeProvider)} and confirm.\n` +
` Configure that provider for Responses passthrough at ${routingTarget.baseUrl}` +
`${routingTarget.requiresAdmissionToken ? ` with x-opencodex-api-key from ${routingTarget.tokenEnv}` : ""}.\n` +
` For direct injection, switch to the built-in openai provider, remove any user-owned root openai_base_url, and rerun 'ocx start'.`,
Expand Down
26 changes: 24 additions & 2 deletions src/codex/inject/restore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { restoreApprovedCodexProviders } from "../provider-routing";
import { beginCodexWriteSection, cleanExternalProviderJournal, publishCodexArtifact } from "./config-write-section";
import { assertCodexHomeOwner, codexHomeIsAbsent, codexHomeOwnerBlocksCompensation, CodexHomeOwnerRefusal, type CodexHomeOwnerRefusalReason } from "../codex-home-owner";
import { loadConfig } from "../../config";
Expand Down Expand Up @@ -115,6 +116,7 @@ export interface CodexRestoreConfigResult {
| "owned-fields-stripped"
| "routing-restored-provider-retained"
| "external-provider-preserved"
| "approved-provider-restored"
| "failed";
message: string;
retained?: RetainedCodexProviderTable;
Expand Down Expand Up @@ -236,6 +238,26 @@ export function failedHistoryRestoreFromOutcome(
return failedHistoryRestore(undefined, describeHistoryJobFailure(outcome, "restore"));
}

export function finishApprovedProviderRestore(result: CodexNativeRestoreResult, revalidateDesiredState = false): CodexNativeRestoreResult {
if (!result.success || revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) return result;
try {
if (!restoreApprovedCodexProviders()) return result;
} catch (error) {
if (error instanceof CodexHomeOwnerRefusal) throw error;
const message = `Approved Codex provider URL restoration failed: ${error instanceof Error ? error.message : String(error)}`;
return { ...result, success: false, message: `${result.message} ${message}`, artifacts: {
...result.artifacts, config: { ...result.artifacts.config, state: "failed", action: "failed", message },
} };
}
const message = "Approved Codex provider URLs restored; identity, other settings and history preserved.";
return { ...result, message: result.externalProvider ? message : `${result.message} ${message}`, artifacts: {
...result.artifacts,
config: { ...result.artifacts.config, state: result.artifacts.config.state === "partial" ? "partial" : "ok", changed: true,
action: result.artifacts.config.changed ? result.artifacts.config.action : "approved-provider-restored",
message: result.artifacts.config.state === "partial" ? `${result.artifacts.config.message} ${message}` : message },
} };
}

function externalProviderRestoreResult(activeProvider: string): CodexNativeRestoreResult {
const message = `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.`;
return {
Expand Down Expand Up @@ -553,7 +575,7 @@ export async function restoreNativeCodexAsync(
if (sibling) return sibling;
try {
assertCodexHomeOwner(getCodexHome());
return await restoreNativeCodexAsyncImpl(options);
return finishApprovedProviderRestore(await restoreNativeCodexAsyncImpl(options), options.revalidateDesiredState);
} catch (error) {
if (error instanceof CodexHomeOwnerRefusal) return homeOwnerRestoreRefusal(error);
if (!(error instanceof CodexRestoreRefusal)) throw error;
Expand Down Expand Up @@ -782,7 +804,7 @@ export function restoreNativeCodex(
if (sibling) return sibling;
try {
assertCodexHomeOwner(getCodexHome());
return restoreNativeCodexImpl(options);
return finishApprovedProviderRestore(restoreNativeCodexImpl(options), options.revalidateDesiredState);
} catch (error) {
if (error instanceof CodexHomeOwnerRefusal) return homeOwnerRestoreRefusal(error);
throw error;
Expand Down
7 changes: 4 additions & 3 deletions src/codex/inject/routing-classify.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isApprovedCodexProviderUrl } from "../provider-routing";
import { existsSync, readFileSync } from "node:fs";
import {
hasInjectedCodexRouting,
Expand Down Expand Up @@ -54,7 +55,7 @@ export function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
}

/** Classify actual routing dependency separately from opencodex ownership. */
export function classifyCodexRouting(content: string): CodexRoutingKind {
export function classifyCodexRouting(content: string, approvedProviderUrl?: (id: string, url: string) => boolean): CodexRoutingKind {
const rootBaseUrl = rootTomlString(content, "openai_base_url");
if (rootBaseUrl) {
const endpoint = classifyRoutingEndpoint(rootBaseUrl);
Expand All @@ -74,7 +75,7 @@ export function classifyCodexRouting(content: string): CodexRoutingKind {
if (providerBaseUrl) {
const endpoint = classifyRoutingEndpoint(providerBaseUrl);
if (endpoint === "unknown") return "unknown";
if (rootProvider === "opencodex") return "opencodex-local";
if (rootProvider === "opencodex" || endpoint === "local" && approvedProviderUrl?.(rootProvider, providerBaseUrl)) return "opencodex-local";
return endpoint === "local" ? "custom-local" : "custom-remote";
}
if (
Expand Down Expand Up @@ -102,7 +103,7 @@ export function getCodexRoutingKind(): CodexRoutingKind {
const path = CODEX_CONFIG_PATH;
if (!existsSync(path)) return "native";
try {
return classifyCodexRouting(readFileSync(path, "utf8"));
return classifyCodexRouting(readFileSync(path, "utf8"), isApprovedCodexProviderUrl);
} catch {
return "unknown";
}
Expand Down
Loading
Loading