Skip to content
2 changes: 2 additions & 0 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,7 @@ export function materializeCodexUpstreamAuth(
if (!stored?.accessToken || !isMainAccountTokenLive()) {
throw new CodexMainSubstitutionUnavailableError();
}
selected.delete("chatgpt-account-id");
selected.set("authorization", `Bearer ${stored.accessToken}`);
if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
observeSelectedMainCredential(stored, writer);
Expand Down Expand Up @@ -1462,6 +1463,7 @@ export async function materializeCodexUpstreamAuthAsync(
...(options.nativeMainRefreshDependencies ?? {}),
});
if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError();
selected.delete("chatgpt-account-id");
selected.set("authorization", `Bearer ${stored.accessToken}`);
if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
observeSelectedMainCredential(stored, writer);
Expand Down
2 changes: 2 additions & 0 deletions structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,5 @@ Exact [model input declarations](config.md#explicit-per-model-capability-declara
`src/providers/derive.ts` fills missing reasoning tables for renamed providers accepted by the existing fixed-key destination matcher. Model entries are cloned and explicit user entries (including empty arrays) win. Provider-wide effort defaults fill only when undefined; Command Code unknown models therefore keep the registry's empty picker policy unless overridden. Identity, transport and other capability axes are unchanged. The gathered row drives client exports; this metadata contract does not prove arbitrary gateway routing.

Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged.

Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged.
2 changes: 2 additions & 0 deletions structure/codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,5 @@ The account history response can include a [low-confidence effective capacity es
Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity.

Upstream API-key usage follows the [physical-attempt account attribution contract](gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations.

Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged.
2 changes: 2 additions & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,5 @@ The text-only consumer reads exact inputModalities declarations before legacy hi
## Catalog auto-refresh

`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail.

Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged.
3 changes: 2 additions & 1 deletion structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ is scoped to canonical ChatGPT Responses forwarding; other source-area behavior
`src/server/audio-transcriptions.ts` owns `POST /v1/audio/transcriptions`, independently of
Responses and Chat conversion. `src/server/audio-upstream.ts` resolves explicit data-plane keys
on both listeners and substitutes stored OpenAI credentials. Direct stored-main access claims
the enclosing admission lease; Pool uses the existing sidecar account resolver. A selected
the enclosing admission lease and derives its account header only from that stored credential;
caller-supplied account selection is never retained. Pool uses the existing sidecar account resolver. A selected
ChatGPT authentication failure never falls through to the paid OpenAI provider.

The bounded multipart input accepts one nonempty file up to 25,000,000 bytes within a 32 MiB
Expand Down
2 changes: 1 addition & 1 deletion structure/gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,6 @@ The [explicit model-capability contract](config.md#explicit-per-model-capability

Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send.

The raw provider editor round-trips `autoReviewModel` and `autoReviewModelOverrides` through editor-owned DTO fields. POST/PATCH/PUT share validation; PUT copies schema-normalized values into the persisted and live candidate before adoption. Canonical `openai` rejects these fields, including clear forms. Field-masked writes (PATCH, editor PUT, reload) pin every registry-seed key and ignore operator overlays the seed never defines, most commonly `selectedModels`; POST keeps the exact-key comparison. Canonical `openai` still rejects `allowPrivateNetwork`, which must not short-circuit destination DNS checks on the ChatGPT forward row. Existing authentication, origin checks and stale-baseline protection still govern the writes. See [reviewer projection](catalog.md#provider-scoped-approval-reviewer).
The raw provider editor round-trips `autoReviewModel` and `autoReviewModelOverrides` through editor-owned DTO fields. POST/PATCH/PUT share validation; PUT copies schema-normalized values into the persisted and live candidate before adoption. Canonical `openai` rejects these fields, including clear forms. Field-masked writes (PATCH, editor PUT, reload) pin every registry-seed key and ignore operator overlays the seed never defines, most commonly `selectedModels`; POST keeps the exact-key comparison. Canonical `openai` still rejects `allowPrivateNetwork`, which must not short-circuit destination DNS checks on the ChatGPT forward row. Existing authentication, origin checks and stale-baseline protection still govern the writes. See [reviewer projection](catalog.md#provider-scoped-approval-reviewer). Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged.

Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged.
2 changes: 2 additions & 0 deletions structure/ops/docs-and-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,3 +394,5 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner](
Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence.

Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](../transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged.

Stored Direct substitution follows the [credential identity contract](../providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged.
4 changes: 3 additions & 1 deletion structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,9 @@ sidecar candidate and cannot hide a failed Codex credential with separately bill

`src/server/audio-upstream.ts` uses the same selection for standalone transcription. Explicit
native Direct auth remains caller-owned; proxy-key-only Direct claims stored main before
materialization. `src/providers/openai-sidecar.ts` releases quota-probe ownership on every
materialization, replacing both bearer and account identity exclusively from that credential.
Both synchronous and asynchronous stored-main substitution in `src/codex/auth-context.ts` remove a caller account header before copying the stored identity; an absent stored account ID leaves no account header. Caller-owned native Direct authentication retains its existing passthrough behavior.
`src/providers/openai-sidecar.ts` releases quota-probe ownership on every
materialization or usability failure before transferring a resolved context to its caller.
Audio reports one terminal upstream outcome after validating the response body; redirects remain
neutral and client/shutdown cancellation does not manufacture an account failure.
Expand Down
2 changes: 2 additions & 0 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,5 @@ stamps the configured key selected for the physical request. `src/server/request
retains per-key attempt usage, and `src/usage/log.ts` validates and persists labels. The
[account attribution contract](gui-and-management-api.md#upstream-key-account-attribution)
defines identity, unknown records, and aggregation boundaries.

Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged.
2 changes: 2 additions & 0 deletions structure/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,5 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner](
Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence.

Shared response-log retention and native SSE inspection pacing follow the [bounded inspection contract](transports/byte-accounting.md#response-log-inspection); other subsystem behavior remains unchanged.

Stored Direct substitution follows the [credential identity contract](providers/openai-tiers.md#sidecars-management-and-ui): both synchronous and asynchronous materializers discard the caller account header before applying the stored credential; ordinary native Direct passthrough is unchanged.
23 changes: 2 additions & 21 deletions tests/codex-integration/codex-auth-context.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { registerStoredDirectIdentityTests } from "../helpers/stored-direct-identity";
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
Expand Down Expand Up @@ -1660,27 +1661,7 @@ describe("Codex auth context", () => {
});


test("an admission bearer on main substitutes the stored credential, never forwards it (#1686)", () => {
// The caller proved admission with one of OUR secrets. That secret must never leave the
// process, so the only acceptable outcome is the stored main credential in its place.
const admissionSecret = "ocx_data_localsecret";
const storedCredential = liveJwt();
writeFileSync(join(testDir, "auth.json"), JSON.stringify({
tokens: { access_token: storedCredential, account_id: "stored_main_acc" },
}));

const headers = materializeCodexUpstreamAuth(
new Headers({ authorization: `Bearer ${admissionSecret}`, "openai-beta": "responses=experimental" }),
{ kind: "main", accountId: null },
{ substituteMainCredential: true },
);

expect(headers.get("authorization")).not.toContain(admissionSecret);
expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`);
expect(headers.get("chatgpt-account-id")).toBe("stored_main_acc");
// Unrelated forwarded headers still ride along.
expect(headers.get("openai-beta")).toBe("responses=experimental");
});
registerStoredDirectIdentityTests(() => testDir, liveJwt);

test("substitution fails closed when no usable main credential exists (#1686)", () => {
// Falling through here would forward the admission secret upstream, which is exactly
Expand Down
79 changes: 79 additions & 0 deletions tests/helpers/stored-direct-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect, test } from "bun:test";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { materializeCodexUpstreamAuth, materializeCodexUpstreamAuthAsync } from "../../src/codex/auth-context";

export function registerStoredDirectIdentityTests(getTestDir: () => string, liveJwt: () => string): void {
test("an admission bearer on main substitutes the stored credential, never forwards it (#1686)", () => {
// The caller proved admission with one of OUR secrets. That secret must never leave the
// process, so the only acceptable outcome is the stored main credential in its place.
const admissionSecret = "ocx_data_localsecret";
const storedCredential = liveJwt();
writeFileSync(join(getTestDir(), "auth.json"), JSON.stringify({
tokens: { access_token: storedCredential, account_id: "stored_main_acc" },
}));

const headers = materializeCodexUpstreamAuth(
new Headers({ authorization: `Bearer ${admissionSecret}`, "openai-beta": "responses=experimental" }),
Comment thread
luvs01 marked this conversation as resolved.
{ kind: "main", accountId: null },
{ substituteMainCredential: true },
);

expect(headers.get("authorization")).not.toContain(admissionSecret);
expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`);
expect(headers.get("chatgpt-account-id")).toBe("stored_main_acc");
// Unrelated forwarded headers still ride along.
expect(headers.get("openai-beta")).toBe("responses=experimental");
});

test("sync stored Direct substitution clears a missing account ID without changing inbound headers", () => {
const storedCredential = liveJwt();
writeFileSync(join(getTestDir(), "auth.json"), JSON.stringify({
tokens: { access_token: storedCredential },
}));
const inbound = new Headers({
authorization: "Bearer ocx_data_localsecret",
"chatgpt-account-id": "caller-account",
"openai-beta": "responses=experimental",
});
const originalHeaders = [...inbound.entries()];

const headers = materializeCodexUpstreamAuth(
inbound,
{ kind: "main", accountId: null },
{ substituteMainCredential: true },
);

expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`);
expect(headers.get("chatgpt-account-id")).toBeNull();
expect(headers.get("openai-beta")).toBe("responses=experimental");
expect([...inbound.entries()]).toEqual(originalHeaders);
});

test.each([
["absent", undefined, null],
["present", "stored_main_acc", "stored_main_acc"],
])("async stored Direct substitution owns account identity when %s", async (_label, accountId, expectedAccountId) => {
const storedCredential = liveJwt();
writeFileSync(join(getTestDir(), "auth.json"), JSON.stringify({
tokens: { access_token: storedCredential, account_id: accountId },
}));
const inbound = new Headers({
authorization: "Bearer ocx_data_localsecret",
"chatgpt-account-id": "caller-account",
"openai-beta": "responses=experimental",
});

const headers = await materializeCodexUpstreamAuthAsync(
inbound,
{ kind: "main", accountId: null },
{ substituteMainCredential: true },
);

expect(headers.get("authorization")).toBe(`Bearer ${storedCredential}`);
expect(headers.get("chatgpt-account-id")).toBe(expectedAccountId);
expect(headers.get("openai-beta")).toBe("responses=experimental");
expect(inbound.get("chatgpt-account-id")).toBe("caller-account");
});

}
50 changes: 50 additions & 0 deletions tests/responses/responses-native-main-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "../../src/codex
import { withNativeMainSharedClaim } from "../../src/codex/native-main-claim";
import type { NativeProfileContext } from "../../src/codex/native-profile-store";
import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing";
import { resolveResponsesApiAuth } from "../../src/server/auth-cors";
import { tryAdmitTurn } from "../../src/server/lifecycle";
import { handleResponses, handleResponsesCompact } from "../../src/server/responses";
import type { RequestLogContext } from "../../src/server/request-log";
import type { OcxConfig } from "../../src/types";
Expand Down Expand Up @@ -109,6 +111,54 @@ function install401ThenRefreshHarness(): { sends: string[]; refreshes: string[]
}

describe("native main 401 refresh and replay", () => {
test.each(["/v1/responses", "/v1/responses/compact"] as const)(
"%s strips caller account identity when a bearer key selects stored Direct",
async path => {
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400 })).toString("base64url");
const storedCredential = `header.${payload}.signature`;
writeFileSync(join(home, "auth.json"), JSON.stringify({
tokens: { access_token: storedCredential },
}));
const cfg = config();
cfg.hostname = "0.0.0.0";
cfg.providers.openai!.codexAccountMode = "direct";
cfg.apiKeys = [{
id: "direct-test", name: "direct-test", key: "ocx_data_direct_ingress",
createdAt: "2026-09-14T00:00:00.000Z",
}];
const req = request(path);
req.headers.set("authorization", "Bearer ocx_data_direct_ingress");
req.headers.set("chatgpt-account-id", "caller-account");
const admission = resolveResponsesApiAuth(req, cfg);
expect(admission?.source).toBe("bearer");
const sent: Headers[] = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.pathname.endsWith("/responses") || url.pathname.endsWith("/responses/compact")) {
sent.push(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
return Response.json({ id: "resp_direct", object: "response", status: "completed", output: [] });
}
return Response.json({ rate_limit: { primary_window: { used_percent: 10 } } });
}) as typeof fetch;

const turn = tryAdmitTurn();
expect(turn).not.toBeNull();
try {
const log = { model: "", provider: "" } as RequestLogContext;
const response = path === "/v1/responses"
? await handleResponses(req, cfg, log, { admission: admission!, turnAdmissionLease: turn! })
: await handleResponsesCompact(req, cfg, log, turn!, admission!);
expect(response.status).toBe(200);
await response.text();
expect(sent).toHaveLength(1);
expect(sent[0]!.get("authorization")).toBe(`Bearer ${storedCredential}`);
expect(sent[0]!.get("chatgpt-account-id")).toBeNull();
} finally {
turn?.release();
}
},
);

test("refreshes a refresh-only native main credential before upstream I/O", async () => {
writeFileSync(join(home, "auth.json"), JSON.stringify({
tokens: { refresh_token: "refresh-grant", account_id: "account-main" },
Expand Down
Loading
Loading