Skip to content
Merged
2 changes: 2 additions & 0 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,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 @@ -1496,6 +1497,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/codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,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 @@ -379,3 +379,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 @@ -15,7 +15,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 @@ -655,7 +655,7 @@ 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.

Expand Down
4 changes: 3 additions & 1 deletion structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,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
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
21 changes: 21 additions & 0 deletions tests/server/audio-transcriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,27 @@ describe("standalone transcription API", () => {
expect((await captured[0]!.formData()).get("model")).toBeNull();
});

test("stored Direct credentials never inherit a caller account ID", async () => {
writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-main-access" } }));
clearMainAccountInfoCache();
const cfg = config();
cfg.defaultProvider = "openai";
cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } };
saveConfig(cfg);

for (const headers of [
{ authorization: "", "x-opencodex-api-key": KEY, "chatgpt-account-id": "caller-workspace" },
{ authorization: "", "x-api-key": KEY, "chatgpt-account-id": "caller-workspace" },
]) {
expect((await request(form(), headers)).status).toBe(200);
}
expect(captured).toHaveLength(2);
for (const upstream of captured) {
expect(upstream.headers.get("authorization")).toBe("Bearer fixture-main-access");
expect(upstream.headers.get("chatgpt-account-id")).toBeNull();
}
});

test("a missing stored Direct credential fails without paid-provider fallback", async () => {
const cfg = config();
cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" };
Expand Down
Loading