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
2 changes: 2 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1639,6 +1639,8 @@ 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.
Turning the Codex integration OFF also prevents approved-provider URL updates on
start or sync; the saved provider consent remains available when you turn it back on.

This mode supports explicit Responses provider tables with `requires_openai_auth = true`
and no custom credentials or headers. Non-loopback targets, authless Desktop mode
Expand Down
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/reference/cli/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,9 @@ twice: the named range ending now, then the same range evaluated one equal windo
filters apply to both reads. Human output prints current, previous, signed delta, and percent
change. `--json` is `{schemaVersion,range,current,previous,delta,change,incomplete}`. The two
reads are sequential, not an atomic snapshot. Custom `since`/`until` on each response must
match the requested window.
match the requested window. Both reads use the same connection and credential. If the
connection, account, or credential changes during the command, it reports an owner
mismatch and asks you to retry instead of printing a comparison.

### Page indexed request history

Expand Down
64 changes: 47 additions & 17 deletions src/cli/usage-compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*/
import { readClientConnectionState, sameClientConnectionOwner } from "../client/state";
import { fetchHubUsage } from "../client/hub-client";
import { readServiceApiTokenState } from "../lib/service-secrets";
import { readServiceApiTokenState, serviceApiTokenFingerprint } from "../lib/service-secrets";
import { runningProxyUpdateHeaders } from "../oauth/login-cli";
import { redactSecretString } from "../lib/redact";
import { rangeWindow, USAGE_SURFACES } from "../usage/summary";
import type { UsageTimeWindow } from "../usage/time-range";
Expand All @@ -16,6 +17,7 @@ import {
rejectArgs,
RuntimeApiError,
runtimeRequest,
runtimeBaseUrl,
takeFlag,
takeOption,
takeOptionWithSyntax,
Expand Down Expand Up @@ -174,22 +176,54 @@ function formatCompare(input: {
return lines;
}

// Private command-local authority; neither credentials nor fingerprints enter output.
function captureUsageOwner() {
const connection = readClientConnectionState();
if (connection.kind === "invalid" || connection.kind === "mismatched") {
throw new Error(`Client usage unavailable: ${connection.reason}`);
}
const token = readServiceApiTokenState();
if (connection.kind === "connected" && (token.kind !== "present" || token.fingerprint !== connection.value.tokenFingerprint)) {
throw new Error("Client usage unavailable: the enrolled data key is missing or changed; repair the client connection");
}
const headers = runningProxyUpdateHeaders();
const fingerprint = connection.kind === "connected" ? connection.value.tokenFingerprint
: serviceApiTokenFingerprint(headers.get("X-OpenCodex-API-Key") ?? "");
return { connection, token, headers, fingerprint };
}
type UsageOwner = ReturnType<typeof captureUsageOwner>;

function assertUsageOwner(owner: UsageOwner): void {
const current = readClientConnectionState();
const expected = owner.connection;
let same = current.kind === expected.kind;
if (expected.kind === "connected") {
const token = readServiceApiTokenState();
same = current.kind === "connected" && sameClientConnectionOwner(current.value, expected.value)
&& current.value.transport === expected.value.transport
&& JSON.stringify(current.value.link) === JSON.stringify(expected.value.link)
&& current.value.tokenFingerprint === owner.fingerprint
&& token.kind === "present" && token.fingerprint === owner.fingerprint;
} else {
same &&= serviceApiTokenFingerprint(runningProxyUpdateHeaders().get("X-OpenCodex-API-Key") ?? "") === owner.fingerprint;
}
if (!same) throw new Error("Usage owner changed during comparison; no comparison was produced. Retry for the current connection and credential.");
}

async function readUsage(
suffix: string,
window: UsageTimeWindow,
apiKeyId: string | undefined,
deps: RuntimeApiDeps,
owner: UsageOwner,
): Promise<ScopedUsage> {
const connection = readClientConnectionState();
if (connection.kind === "invalid" || connection.kind === "mismatched") {
throw new Error(`Client usage unavailable: ${connection.reason}`);
}
assertUsageOwner(owner);
const { connection, token } = owner;
let result: ScopedUsage;
if (connection.kind === "connected") {
if (apiKeyId !== undefined) {
throw new CliUsageError("--api-key-id is unavailable on connected clients; run on the hub for a selected key, or omit it for this client's usage", USAGE_COMPARE_USAGE);
}
const token = readServiceApiTokenState();
if (token.kind !== "present" || token.fingerprint !== connection.value.tokenFingerprint) {
throw new Error("Client usage unavailable: the enrolled data key is missing or changed; repair the client connection");
}
Expand All @@ -199,18 +233,11 @@ async function readUsage(
result = await fetchHubUsage(connection.value.serverUrl, token.token, new URLSearchParams(suffix.replace(/^\?/, "")), {
fetchImpl: peerFetch, timeoutMs: 60_000, ...(link ? { peer: "Home" as const } : {}),
});
const current = readClientConnectionState();
const currentToken = readServiceApiTokenState();
if (current.kind !== "connected" || !sameClientConnectionOwner(current.value, connection.value)
|| current.value.tokenFingerprint !== token.fingerprint
|| currentToken.kind !== "present" || currentToken.fingerprint !== token.fingerprint) {
throw new Error("Client connection changed while reading usage; retry for the current connection");
}
} else if (apiKeyId === undefined) {
result = await runtimeRequest(`/api/usage${suffix}`, {}, deps);
result = await runtimeRequest(`/api/usage${suffix}`, { headers: owner.headers }, deps);
} else {
try {
result = await runtimeRequest(`/api/usage${suffix}`, { redirect: "error", credentials: "omit" }, deps);
result = await runtimeRequest(`/api/usage${suffix}`, { redirect: "error", credentials: "omit", headers: owner.headers }, deps);
} catch (error) {
if (error instanceof RuntimeApiError && error.code === "proxy_not_running") throw error;
throw new Error("Key-scoped usage could not be read. Check runtime access and retry.");
Expand All @@ -222,6 +249,7 @@ async function readUsage(
if (result?.customWindow !== true || result.since !== window.since || result.until !== window.until) {
throw new Error("The server did not confirm the requested custom usage window. Upgrade and restart the proxy, then retry.");
}
assertUsageOwner(owner);
return result;
}

Expand All @@ -247,8 +275,10 @@ export async function handleUsageCompareCommand(argv: string[], deps: UsageCompa
if (!Number.isSafeInteger(now) || now < 0) throw new CliUsageError("compare clock is invalid", USAGE_COMPARE_USAGE);
const windows = compareUsageWindows(range, now);
const filters = { range, surface, provider, model, apiKeyId };
const currentReport = await readUsage(query({ ...filters, since: windows.current.since, until: windows.current.until }), windows.current, apiKeyId, deps);
const previousReport = await readUsage(query({ ...filters, since: windows.previous.since, until: windows.previous.until }), windows.previous, apiKeyId, deps);
const owner = captureUsageOwner();
const readDeps = owner.connection.kind === "disconnected" ? { ...deps, baseUrl: await runtimeBaseUrl(deps) } : deps;
const currentReport = await readUsage(query({ ...filters, since: windows.current.since, until: windows.current.until }), windows.current, apiKeyId, readDeps, owner);
const previousReport = await readUsage(query({ ...filters, since: windows.previous.since, until: windows.previous.until }), windows.previous, apiKeyId, readDeps, owner);
const current = totalsOf(currentReport);
const previous = totalsOf(previousReport);
const deltaTotals: Totals = {};
Expand Down
25 changes: 23 additions & 2 deletions src/codex/config-write-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,29 @@ function acquire(configPath: string): ConfigWriteAcquireResult {
throw error;
}
const acquired = tryAcquire(`${target.canonical}.ocx-write.lock`);
if (acquired.ok) destinations.set(acquired.handle, target);
return acquired;
if (!acquired.ok) return acquired;
try {
// A preceding writer may atomically replace the file before releasing the lock.
// Capture the inode only after acquisition, while retaining the canonical-path gate.
const current = destination(configPath);
if (current.canonical !== target.canonical || !stillHeld(acquired.handle)) {
release(acquired.handle);
return { ok: false, error: "unsafe", detail: acquired.handle.path };
}
// Preserve caller-owned unreadable/non-regular errors, but do not mistake a
// dangling link (realpath's fallback) or a fresh replacement for proven absence.
try {
const entry = lstatSync(current.canonical, { bigint: true });
if (entry.isSymbolicLink() || `${entry.dev}:${entry.ino}` !== current.inode) throw new ConfigWriteDestinationChanged();
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || current.inode !== null) throw error;
}
destinations.set(acquired.handle, current);
return acquired;
} catch (error) {
release(acquired.handle);
throw error;
}
}
export function withConfigWriteLock<T>(configPath: string, run: (handle: LockHandle) => T): ConfigWriteLockOutcome<T> {
const acquired = acquire(configPath);
Expand Down
25 changes: 14 additions & 11 deletions src/codex/provider-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";
import { loadConfig } from "../config";
import { shouldSyncCodexOnStart } from "./desired-state";
import { atomicWriteFile } from "../config/atomic-write";
import { withConfigMutationLockSync } from "../config/mutation-lock";
import { getConfigDir } from "../config/paths";
Expand All @@ -10,7 +12,7 @@ import { ConfigWriteLockRefusal, publishConfigWriteTarget, watchConfigWriteTarge
import { providerTableStart } from "./injected-marker";
import { CODEX_CONFIG_PATH, getCodexHome, tomlString } from "./paths";
import { rootSourceLines, sourceAssignment, sourceText } from "./toml-source-lines";
import { cleanExternalProviderJournal } from "./inject/config-write-section";
import { JOURNAL_PATH, removeJournal } from "./journal";
import { externalCodexModelProvider } from "./inject/config-toml";
import { readBoundedCodexConfig } from "./inject/bounded-config-reader";
import { isLoopbackHostname } from "./loopback-target";
Expand Down Expand Up @@ -75,7 +77,7 @@ function providerUrl(content: string, id: string, checkCredentials = true) {
function inWriteSection<T>(run: (held: LockHandle, content: string, record: RoutingRecord) => T): T {
assertCodexHomeOwner(getCodexHome());
const result = withConfigWriteLockHeld(CODEX_CONFIG_PATH, undefined, held => {
watchConfigWriteTargets(held, [CODEX_CONFIG_PATH, recordPath()]);
watchConfigWriteTargets(held, [CODEX_CONFIG_PATH, recordPath(), JOURNAL_PATH]);
const content = readFileSync(CODEX_CONFIG_PATH, "utf8");
return run(held, content, readRecord());
});
Expand Down Expand Up @@ -122,21 +124,22 @@ function accepts(fieldUrl: string, saved: Approval): boolean {

/** Called by start/sync, never prompts and never broadens the approved provider set. */
export function injectApprovedCodexProvider(id: string, target: CodexRoutingTarget, beforeWrite?: () => void, validateOnly = false): false | { configChanged: boolean } {
if (!Object.hasOwn(readRecord().providers, id)) return false;
target = validateCodexRoutingTarget(target);
if (target.requiresAdmissionToken || target.desktopAuthless || target.clientCompaction || !isLoopbackHostname(new URL(target.baseUrl).hostname)) {
throw new Error("Approved custom providers support only authenticated Codex on a local loopback proxy without client compaction.");
}
if (!validateOnly) cleanExternalProviderJournal(beforeWrite);
return inWriteSection((held, content, record) => {
if (!Object.hasOwn(record.providers, id)) return false;
const apply = () => {
if (!Object.hasOwn(record.providers, id)) return false;
target = validateCodexRoutingTarget(target);
if (target.requiresAdmissionToken || target.desktopAuthless || target.clientCompaction || !isLoopbackHostname(new URL(target.baseUrl).hostname)) {
throw new Error("Approved custom providers support only authenticated Codex on a local loopback proxy without client compaction.");
}
if (externalCodexModelProvider(content) !== id) throw new Error("Active provider changed; routing was not applied.");
const field = providerUrl(content, id), saved = record.providers[id]!;
if (!accepts(field.url, saved)) throw new Error("Approved provider base_url changed outside OpenCodex; revoke and approve it again.");
if (validateOnly || field.url === target.baseUrl) return { configChanged: false };
if (validateOnly) return { configChanged: false };
beforeWrite?.();
if (!shouldSyncCodexOnStart(loadConfig())) return false;

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 Propagate the late opt-out as a skipped injection

When the Codex switch turns OFF after syncModelsToCodex's initial gate but before publication, returning false here is indistinguishable from “this provider was never approved.” injectCodexConfigImpl consequently follows its unapproved-external-provider branch and returns a successful applied result instead of status: "skipped"; syncModelsToCodex then preserves any catalog/cache-write flags, so ocx sync can restart Codex and report that the user should approve a provider whose consent is still present. Return a distinct desired-state skip result and propagate it through the injector, with a caller-level regression for this race.

Useful? React with 👍 / 👎.

if (readFileSync(CODEX_CONFIG_PATH, "utf8") !== content) throw new Error("Codex config changed before publication; retry sync.");
publishConfigWriteTarget(CODEX_CONFIG_PATH, held, JOURNAL_PATH, (_destination, hooks) => removeJournal(hooks));
if (field.url === target.baseUrl) return { configChanged: false };
// Write recovery evidence first. Both the previous and candidate URL remain restorable
// if publication fails or the process exits between these two atomic renames.
saved.managedUrls = [...new Set([field.url, target.baseUrl])].filter(url => url !== saved.originalUrl);
Expand All @@ -145,7 +148,7 @@ export function injectApprovedCodexProvider(id: string, target: CodexRoutingTarg
publish(CODEX_CONFIG_PATH, field.replace(`base_url = ${tomlString(target.baseUrl)}`), held);
return { configChanged: true };
};
return beforeWrite ? withConfigMutationLockSync(apply) : apply();
return validateOnly ? apply() : withConfigMutationLockSync(apply);
});
}

Expand Down
2 changes: 1 addition & 1 deletion structure/cli-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Storage policy flags in `src/cli/storage.ts` preserve omitted enable/settings an

`src/cli/log-view-filter.ts` owns the additive logs-filter snapshot, separate from existing follow/event transport. It fetches only the fixed log endpoint with a scan limit of at most 2000, applies GUI-equivalent local selectors, then returns the newest matches in source order without deduplicating. Model/provider matching covers resolved/served/attempt identities; protocol and conversation matching reuse their actual owners. Speed bounds accept observed value-kind metrics only. The versioned JSON view separates loaded, matched and returned counts from configured scan/output limits; JSONL is rows only. Neither a match count nor a cursor claims historical completeness or resumable search. Invalid options fail before discovery; malformed windows never become empty success.

`src/cli/usage-compare.ts` reads `GET /api/usage` twice: the named range at now, then the same range shifted back one equal window. `--range all` is refused. Both responses must acknowledge the derived custom bounds. Key-scoped reads keep redirect-error transport and echoed `apiKeyId`. Connected clients reuse the existing hub usage path and refuse caller `--api-key-id`. Default range is 30d. The two reads are sequential, not an atomic snapshot.
`src/cli/usage-compare.ts` reads `GET /api/usage` twice: the named range at now, then the same range shifted back one equal window. `--range all` is refused. Both responses must acknowledge the derived custom bounds. Key-scoped reads keep redirect-error transport and echoed `apiKeyId`. Connected clients reuse the existing hub usage path and refuse caller `--api-key-id`. Default range is 30d. A command-local snapshot pins connection mode, usage owner, destination and credential fingerprint for both reads. Each read verifies that the owner and credential still match; a change discards the comparison with a retry message. Credentials and fingerprints never enter output. The two reads are sequential, not an atomic snapshot.

`src/cli/usage-model-search.ts` applies optional substring search only after the existing usage target/scope acknowledgment. A copied model list sorts stably by total tokens, searches model/provider/resolvedModel and caps at 100 by default. The report's other fields remain intact; explicit modelView metadata distinguishes a view miss from no usage. `src/cli/usage-report.ts` retains the legacy renderer when no view is supplied and renders the selected rows when one is supplied. `--top` accepts 1–1000 human model rows (default 10 without search, 100 with search), applies after search and is never sent to the API. JSON ignores `--top`, including the existing 100-row search view and its metadata. Search never becomes a server attribution query or widens connected-client/key scope.

Expand Down
2 changes: 1 addition & 1 deletion structure/codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ batches, injection, journal replay, removal, and restore/compensation read and w
holding it. Injection and coordinated restore take config before SQLite N; prompt writes
take prompt-store before config. Nested writers receive an explicit live handle for the
same canonical config destination; implicit nesting refuses fast. Symlink aliases contend
on the real destination's lock. Each publication revalidates canonical identity and inode immediately before rename,
on the real destination's lock. Acquisition revalidates the canonical path and captures the inode after the lock is held, admitting a preceding writer's completed atomic replacement while refusing a canonical retarget before entering the section. Each publication revalidates canonical identity and inode immediately before rename,
including each Windows retry; the confirmed publication hook advances that witness. Async acquisition uses a monotonic
deadline, defaults to two seconds, and accepts only integer timeouts from zero to ten
seconds. Zero permits one immediate attempt.
Expand Down
1 change: 1 addition & 0 deletions structure/codex-provider-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ lock and destination guards; recovery evidence precedes URL publication and reta
previous and candidate local URL across an interrupted write. Changed foreign URLs refuse
injection and survive restore. Native restore completes its existing preflight before
restoring approved URLs. Foreground signal/exit cleanup delegates to the same native restore, which restores approved URLs and preserves unapproved external configurations. Partial native outcomes remain partial, and recovery failures are reported in the config artifact. Consent survives stop/restore; explicit revocation removes it. Successful URL publication returns a committed configuration-change receipt through injection/sync; validation-only and unchanged URL operations report no change, so explicit config-change restarts retain the same authority.
Approval-record inspection, stale native-journal cleanup and URL publication share one Codex write section, so a cooperating concurrent sync cannot replace the record during its bounded read. The shared lock witnesses the config inode after acquisition, so a preceding writer’s completed publication does not invalidate the next holder. URL injection rechecks the current integration intent under both the Codex write lock and config mutation lock, after the client-write guard. Integration OFF skips URL and recovery-record publication without revoking provider consent. Validation-only inspection does not acquire the config mutation lock or create its persistent coordination metadata.
Only Responses/OpenAI-auth provider tables without separate credentials or headers are
eligible, and only ordinary loopback routing is admitted. Authless, client-compaction and
admission-token targets are refused rather than silently changing authentication policy.
Expand Down
Loading
Loading