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
8 changes: 6 additions & 2 deletions src/server/chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { responseWithDeferredRequestLog } from "./relay";
import { handleResponses } from "./responses";
import type { AdmissionLease } from "../lib/admission";
import type { DataPlaneAdmission } from "./auth-cors";
import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
import {
createTranslatorBudget,
Expand Down Expand Up @@ -64,7 +65,7 @@ export async function handleChatCompletions(
req: Request,
config: OcxConfig,
logCtx: RequestLogContext,
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission },
): Promise<Response> {
const translatorBudget = createTranslatorBudget();
try {
Expand All @@ -83,7 +84,7 @@ async function handleChatCompletionsWithBudget(
config: OcxConfig,
logCtx: RequestLogContext,
translatorBudget: TranslatorBudget,
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission },
): Promise<Response> {
let chatBody: Rec;
try {
Expand Down Expand Up @@ -251,6 +252,9 @@ async function handleChatCompletionsWithBudget(
};
const upstream = await handleResponses(internalReq, config, logCtx, {
...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
// #1686: the Chat surface translates its body and replays here, so the admission fact has
// to ride along or a bearer-admitted Chat caller would still be refused by Direct.
...(logIds?.admission ? { admission: logIds.admission } : {}),
abortSignal: req.signal,
// Body is Responses-shaped by now, but the client spoke Chat Completions.
inboundWire: "chat",
Expand Down
6 changes: 4 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1092,7 +1092,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
let response: Response;
try {
response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease);
response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission);
} catch {
response = formatErrorResponse(500, "server_error", "Unexpected compact request failure");
}
Expand Down Expand Up @@ -1214,6 +1214,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
const response = await handleResponses(req, config, logCtx, {
turnAdmissionLease,
admission,
onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
abortSignal: req.signal,
onFirstOutput: () => recordFirstOutput(logCtx, start),
Expand Down Expand Up @@ -1302,7 +1303,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
inboundProtocol: "chat",
};
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease }),
await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
req,
config,
));
Expand Down Expand Up @@ -1569,6 +1570,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
try {
let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
const response = await handleResponses(req, config, logCtx, {
...(wsAdmission ? { admission: wsAdmission } : {}),
forceEmptyResponseId: true,
inboundTransport: "websocket",
abortSignal: turnAbort.signal,
Expand Down
13 changes: 10 additions & 3 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import {
CodexPoolAuthenticationError,
CodexThreadAffinityExpiredError,
headersForCodexAuthContext,
materializeCodexUpstreamAuth,
CodexMainSubstitutionUnavailableError,
isCodexAuthContextUsable,
resolveCodexAuthContext,
codexProbeLeaseId,
Expand Down Expand Up @@ -81,6 +83,7 @@ import {
type UpstreamHostAdmissionLease,
} from "../../codex/upstream-host-health";
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
import type { DataPlaneAdmission } from "../auth-cors";
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
import { slugsEquivalent } from "../../providers/slug-codec";
Expand Down Expand Up @@ -269,6 +272,7 @@ export async function handleResponsesCompact(
config: OcxConfig,
logCtx: RequestLogContext,
turnAdmissionLease?: AdmissionLease,
admission?: DataPlaneAdmission,
): Promise<Response> {
let body: unknown;
try {
Expand Down Expand Up @@ -316,7 +320,10 @@ export async function handleResponsesCompact(
logCtx.resolvedModel = route.modelId;
}

if (route.codexAccountMode === "direct") {
// #1686: a bearer-presented admission secret is one of ours, so the stored main credential
// is substituted below instead of the caller bearer being forwarded.
const substituteMainCredential = admission?.source === "bearer";
if (route.codexAccountMode === "direct" && !substituteMainCredential) {
try { validateForwardAdmissionCredential(req.headers, config); }
catch (err) {
if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
Expand Down Expand Up @@ -364,7 +371,7 @@ export async function handleResponsesCompact(
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
});
logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
const selected = headersForCodexAuthContext(req.headers, authCtx);
const selected = materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential });

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 Catch missing-main substitution errors in compact requests

For a remotely admitted /v1/responses/compact Direct request using a proxy bearer, materializeCodexUpstreamAuth throws CodexMainSubstitutionUnavailableError when the stored main credential is absent or expired. The surrounding compact-auth catch does not handle that newly imported error and rethrows it, after which the server-level wrapper converts it to a generic 500, whereas the regular Responses path returns the intended 401. Catch this error here and return the same authentication response while continuing to fail before upstream I/O.

Useful? React with 👍 / 👎.

compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
for (const name of FORWARD_HEADERS) {
const value = selected.get(name);
Expand Down Expand Up @@ -671,7 +678,7 @@ export async function handleResponsesCompact(
headers: internalHeaders,
body: JSON.stringify(internalBody),
});
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease });
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
if (!response.ok) return response;
let json: { output?: unknown[]; status?: unknown; error?: unknown };
try {
Expand Down
30 changes: 28 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ import {
CodexPoolAuthenticationError,
CodexThreadAffinityExpiredError,
headersForCodexAuthContext,
materializeCodexUpstreamAuth,
CodexMainSubstitutionUnavailableError,
isCodexAuthContextUsable,
resolveCodexAuthContext,
codexProbeLeaseId,
Expand All @@ -114,6 +116,7 @@ import {
prepareSameTarget429Wait,
} from "../../lib/upstream-retry";
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
import type { DataPlaneAdmission } from "../auth-cors";
import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
Expand Down Expand Up @@ -755,6 +758,15 @@ export interface ConsumedComboFailure {

export interface HandleResponsesOptions {
turnAdmissionLease?: AdmissionLease;
/**
* How the caller proved data-plane admission (#1686).
*
* A bearer-presented admission secret is one of OUR OWN secrets, so a Direct turn must
* SUBSTITUTE the stored main credential rather than forward it. Without this fact at the
* decision point, Direct cannot tell an admission bearer from the user own ChatGPT bearer,
* which is why it refused the whole env_key flow instead of serving it.
*/
admission?: DataPlaneAdmission;
/** Called at most once after the complete client body is read and accepted for dispatch. */
onRequestBodyRead?: () => void;
forceEmptyResponseId?: boolean;
Expand Down Expand Up @@ -988,7 +1000,14 @@ async function resolveResponsesCodexAuth(
options: HandleResponsesOptions,
): Promise<ResponsesAuthResolution> {
try {
if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
// #1686: a caller that proved admission with a BEARER presented one of our own secrets.
// Refusing it here is what made the codex-cli `env_key` contract unusable against Direct.
// Admitting it is only safe because the stored main credential is substituted below, so
// the admission secret still never leaves this process.
const substituteMainCredential = options.admission?.source === "bearer";
if (route.codexAccountMode === "direct" && !substituteMainCredential) {
validateForwardAdmissionCredential(req.headers, config);
Comment on lines +1007 to +1009

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve bearer presentation for loopback admission

With the default loopback hostname, resolveResponsesApiAuth returns { source: "loopback" } without inspecting the presented bearer, so an env_key sent as Authorization: Bearer <proxy-secret> makes substituteMainCredential false. A Direct request therefore still reaches validateForwardAdmissionCredential, recognizes the configured proxy secret, and returns 401; the new flow only works for remote binds, despite local proxy use being the normal env_key scenario. Preserve a recognized bearer presentation even when loopback admission itself is waived, or derive the substitution decision from the actual Authorization credential.

Useful? React with 👍 / 👎.

}
let authCtx: CodexAuthContext;
if (route.codexAccountMode) {
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
Expand All @@ -1011,7 +1030,7 @@ async function resolveResponsesCodexAuth(
return {
ok: true,
authCtx,
headers: headersForCodexAuthContext(req.headers, authCtx),
headers: materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential }),
};
} catch (err) {
if (err instanceof CodexAccountCooldownError) {
Expand Down Expand Up @@ -1045,6 +1064,13 @@ async function resolveResponsesCodexAuth(
if (err instanceof ForwardAdmissionCredentialError) {
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
}
if (err instanceof CodexMainSubstitutionUnavailableError) {
// Fail BEFORE any upstream I/O. The alternative is forwarding the admission secret.
return {
ok: false,
response: formatErrorResponse(401, "authentication_error", "No usable Codex main credential to serve this request"),
};
}
throw err;
}
}
Expand Down
159 changes: 159 additions & 0 deletions tests/codex-envkey-admission-substitution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveConfig } from "../src/config";
import { startServer } from "../src/server";
import type { OcxConfig } from "../src/types";

/**
* #1686 end to end: a Codex client injected with `env_key` presents the proxy admission
* secret as `Authorization: Bearer`. Direct must ADMIT that request and substitute the
* stored main credential; the admission secret must never appear upstream.
*
* The unit matrix in tests/codex-auth-context.test.ts proves the materializer in isolation.
* It cannot prove the HTTP handler passes the presentation source down to it, which is the
* half that was missing: the source was resolved at the door and dropped one frame later.
*/

const originalFetch = globalThis.fetch;
const previousOcxHome = process.env.OPENCODEX_HOME;
const previousCodexHome = process.env.CODEX_HOME;
const previousDataToken = process.env.OPENCODEX_API_AUTH_TOKEN;

let ocxHome = "";
let codexHome = "";
let upstreamAuth: Array<string | null> = [];

const ADMISSION_SECRET = "ocx_data_envkeysecret";

/** A JWT whose `exp` is far in the future, so the stored main token reads as live. */
function liveJwt(): string {
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 86_400 })).toString("base64url");
return `header.${payload}.signature`;
}

function directConfig(): OcxConfig {
return {
port: 0,
// Remote bind, so admission is actually required rather than loopback-waived.
hostname: "0.0.0.0",
defaultProvider: "openai",
openaiProviderTierVersion: 2,
providers: {
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
codexAccountMode: "direct",
defaultModel: "gpt-5.6-luna",
},
},
apiKeys: [
{ id: "env-key", name: "env_key", key: ADMISSION_SECRET, createdAt: "2026-08-16T00:00:00.000Z" },
],
} as OcxConfig;
}

function writeStoredMain(accessToken: string): void {
writeFileSync(
join(codexHome, "auth.json"),
JSON.stringify({ tokens: { access_token: accessToken, account_id: "stored_main_acc" } }),
);
}

beforeEach(() => {
ocxHome = mkdtempSync(join(tmpdir(), "ocx-1686-home-"));
codexHome = mkdtempSync(join(tmpdir(), "ocx-1686-codex-"));
process.env.OPENCODEX_HOME = ocxHome;
process.env.CODEX_HOME = codexHome;
delete process.env.OPENCODEX_API_AUTH_TOKEN;
upstreamAuth = [];
globalThis.fetch = (async (input, init) => {
const raw = input instanceof Request ? input.url : String(input);
const url = new URL(raw);
if (url.hostname === "chatgpt.com" || url.hostname === "api.openai.com") {
const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
upstreamAuth.push(headers.get("authorization"));
return Response.json({ id: "resp_1686", object: "response", status: "completed", output: [] });
}
return originalFetch(input, init);
}) as typeof fetch;
});

afterEach(() => {
globalThis.fetch = originalFetch;
if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousOcxHome;
if (previousCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = previousCodexHome;
if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN;
else process.env.OPENCODEX_API_AUTH_TOKEN = previousDataToken;
if (ocxHome) rmSync(ocxHome, { recursive: true, force: true });
if (codexHome) rmSync(codexHome, { recursive: true, force: true });
ocxHome = "";
codexHome = "";
});

async function postResponses(url: string | URL, authorization: string): Promise<Response> {
return originalFetch(new URL("/v1/responses", url), {
method: "POST",
headers: { "content-type": "application/json", authorization },
body: JSON.stringify({ model: "gpt-5.6-luna", input: "hi", stream: false }),
});
}

describe("#1686 env_key bearer admission reaches Direct with substitution", () => {
test("an admission bearer is served and the stored main credential goes upstream", async () => {
saveConfig(directConfig());
const stored = liveJwt();
writeStoredMain(stored);

const server = startServer(0);
try {
const response = await postResponses(server.url, `Bearer ${ADMISSION_SECRET}`);

// Before this change the same request answered 401: admission accepted the bearer at the
// door, then Direct refused it because it could not tell it from a user's own credential.
expect(response.status).toBe(200);
expect(upstreamAuth).toEqual([`Bearer ${stored}`]);
// The proof that matters: our own secret never reached the wire.
expect(upstreamAuth.join("|")).not.toContain(ADMISSION_SECRET);
} finally {
await server.stop(true);
}
});

test("substitution fails closed before any upstream I/O when no main credential is stored", async () => {
saveConfig(directConfig());
writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: {} }));

const server = startServer(0);
try {
const response = await postResponses(server.url, `Bearer ${ADMISSION_SECRET}`);

expect(response.status).toBe(401);
// Falling through would have forwarded the admission secret, which is the leak the
// forward guard exists to prevent. Nothing may reach an upstream on this path.
expect(upstreamAuth).toHaveLength(0);
} finally {
await server.stop(true);
}
});

test("a foreign bearer is still Codex Direct passthrough, not admission", async () => {
saveConfig(directConfig());
writeStoredMain(liveJwt());

const server = startServer(0);
try {
// A real ChatGPT credential is NOT one of our secrets, so it must not be admitted as one.
const response = await postResponses(server.url, "Bearer sk-user-chatgpt-token");
expect(response.status).toBe(401);
expect(upstreamAuth).toHaveLength(0);
} finally {
await server.stop(true);
}
});
});

Loading