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
16 changes: 16 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,22 @@ has reached Codex the turn is never sent again. If every attempt is declined, Co
the same error it would have received without the retries. Native compact requests move to
another Pool account on a server error in the same way.

### Runtime agent instances (Codex 0.164+)

Codex 0.164 can sign each request with a runtime agent assertion when
`features.runtime_agent_instances` is enabled (it is off by default). opencodex supports one of
its two modes:

- `mode = "dual_header"` keeps the normal `Authorization: Bearer` credential and adds an
`x-openai-agent-assertion` header. opencodex treats the request exactly as before. The assertion
is forwarded only when the turn goes to ChatGPT Direct with your own credential. It is removed
when a Pool account, a stored main credential, a routed provider, or a sidecar serves the request,
because it belongs to the credential Codex sent.
- `mode = "assertion_only"` replaces the bearer with `Authorization: AgentAssertion <jwt>`.
opencodex refuses these requests with HTTP 400 and error code `agent_assertion_unsupported`.
It never substitutes a Pool or provider credential for the assertion, and it never forwards or
logs the assertion. Use `dual_header`, or turn the feature off, when Codex runs through opencodex.

### Codex autostart shim

Run `ocx codex-shim install` to install the optional launcher wrapper. It runs
Expand Down
5 changes: 5 additions & 0 deletions scripts/test-layout/layout-agent-assertion.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"agent-assertion-auth.test.ts": "server",
"agent-assertion-routes.test.ts": "responses",
"authorization-scheme.test.ts": "lib"
}
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": 1,
"root": "tests",
"explicitShards": ["layout-clients.json", "layout-codex-collision.json"],
"explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json"],
"explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration",
"openai-chat-standalone-bare-block.test.ts": "adapters/openai",
"owner-registry-isolation.test.ts": "config",
Expand Down
6 changes: 6 additions & 0 deletions src/adapters/openai-responses/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { OcxProviderConfig } from "../../types";
import { CODEX_RESPONSES_LITE_HEADER } from "../../codex/forward-transport-headers";
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers-destination";
import { normalizeForwardedClientHeaderName } from "../../lib/provider-client-headers";
import { AGENT_ASSERTION_HEADER } from "../../lib/authorization-scheme";
import { openaiResponsesUrl } from "../openai-responses-url";

// Caller credentials are forwarded only to the canonical Codex destination, never by capability.
Expand Down Expand Up @@ -69,6 +70,11 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P
if (override && mayForwardCallerCredentials) {
headers.authorization = `Bearer ${override.accessToken}`;
headers["chatgpt-account-id"] = override.chatgptAccountId;
} else if (mayForwardCallerCredentials) {
// dual_header runtime assertion: first-party Direct only, beside the caller's own Bearer.
// A pool override above never carries it, and non-canonical transports never see it.
const assertion = incoming.headers.get(AGENT_ASSERTION_HEADER);
if (assertion) headers[AGENT_ASSERTION_HEADER] = assertion;
}
} else {
url = provider.responsesPath === undefined
Expand Down
16 changes: 10 additions & 6 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUser
import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate } from "./collision-identity";
import { AgentAssertionUnsupportedError, authorizationCredential, hasAgentAssertionAuthorization } from "../lib/authorization-scheme";

/**
* A request-owned bearer cannot inspect the physical main credential for its plan, but cached
Expand Down Expand Up @@ -701,7 +702,7 @@ async function authorizeReserveCredential(

function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAccountId: string } {
return {
accessToken: headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "",
accessToken: authorizationCredential(headers) ?? "",
chatgptAccountId: headers.get("chatgpt-account-id") ?? "",
};
}
Expand All @@ -716,7 +717,7 @@ function selectedCodexToken(headers: Headers): { accessToken: string; chatgptAcc
export function callerCodexWorkspaceAccountId(headers: Headers): string | undefined {
const explicit = headers.get("chatgpt-account-id");
if (explicit) return explicit;
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const bearer = authorizationCredential(headers);
return bearer ? extractAccountId(undefined, bearer) : undefined;
}

Expand Down Expand Up @@ -807,7 +808,7 @@ function assertMainAccountPolicy(

/** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */
function callerMatchesObservedMain(headers: Headers): boolean {
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const bearer = authorizationCredential(headers);
if (!bearer) return false;
const effectiveAccountId = headers.get("chatgpt-account-id")
?? extractAccountId(undefined, bearer);
Expand All @@ -831,7 +832,7 @@ function sameCredentialMaterial(a: string, b: string): boolean {
* cooldown — the safe direction.
*/
function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const bearer = authorizationCredential(headers);
if (!bearer) return true;
const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
if (callerAccountId === undefined) return true;
Expand Down Expand Up @@ -1767,7 +1768,7 @@ export class CodexMainSubstitutionUnavailableError extends Error {

/** Dispatch identity comes only from an owned observation of the selected credential. */
function selectedMainQuotaDispatch(selected: Headers): MainQuotaDispatch | undefined {
const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const bearer = authorizationCredential(selected);
if (!bearer) return undefined;
const accountId = selected.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
return captureMainQuotaDispatch(bearer, accountId, captureConfigGeneration());
Expand All @@ -1792,6 +1793,8 @@ export function materializeCodexUpstreamAuth(
ctx: CodexAuthContext,
options: CodexAuthMaterializationOptions = {},
): Headers {
// assertion_only must never be answered with a pool, stored-main, or other substituted Bearer.
if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
const selected = new Headers();
for (const name of FORWARD_HEADERS) {
Expand All @@ -1814,7 +1817,7 @@ export function materializeCodexUpstreamAuth(
}
if (ctx.kind === "main" && options.substituteMainCredential !== true
&& !selected.has("chatgpt-account-id")) {
const bearer = selected.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const bearer = authorizationCredential(selected);
const accountId = bearer ? extractAccountId(undefined, bearer) : undefined;
if (accountId) selected.set("chatgpt-account-id", accountId);
}
Expand Down Expand Up @@ -1892,6 +1895,7 @@ export async function materializeCodexUpstreamAuthAsync(
ctx: CodexAuthContext,
options: CodexAuthMaterializationOptions = {},
): Promise<Headers> {
if (hasAgentAssertionAuthorization(headers)) throw new AgentAssertionUnsupportedError();
if (ctx.kind === "main") ctx.mainQuotaDispatch = undefined;
if (requiresReserveAuthorization(options.config, options.modelId, options.admission)) {
return materializeReserveUpstreamAuth(headers, ctx, options);
Expand Down
95 changes: 95 additions & 0 deletions src/lib/authorization-scheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* One parser for the inbound `Authorization` scheme.
*
* Bearer reads in the proxy used `replace(/^Bearer\s+/i, "")`, which returns any other value
* unchanged. Codex 0.164 runtime agent instances (`features.runtime_agent_instances`) can send
* `Authorization: AgentAssertion <jwt>`, and that replace handed the whole value on as an opaque
* bearer. An agent assertion is never a bearer: it is not an opencodex admission secret, it is not
* a ChatGPT credential, and it must never be forwarded as `Bearer`.
*
* The legacy read is preserved for every other value, including a scheme-less token, so this file
* changes nothing except the AgentAssertion case.
*/

/** The companion header Codex sends in `dual_header` mode next to an ordinary Bearer. */
export const AGENT_ASSERTION_HEADER = "x-openai-agent-assertion";

/** Stable error code for `Authorization: AgentAssertion`, which opencodex does not route. */
export const AGENT_ASSERTION_UNSUPPORTED_CODE = "agent_assertion_unsupported";

export const AGENT_ASSERTION_UNSUPPORTED_MESSAGE = "Codex runtime agent assertion_only authentication "
+ "(Authorization: AgentAssertion) is not supported through opencodex pooling or routing. "
+ "Use dual_header mode, or turn off features.runtime_agent_instances.";

export type AuthorizationScheme =
| { kind: "none" }
| { kind: "bearer"; token: string }
| { kind: "agent-assertion" }
| { kind: "other"; credential: string };

const BEARER_PREFIX = /^Bearer\s+/i;
// RFC 9110 auth schemes are case-insensitive; the scheme ends at whitespace or end of value.
const AGENT_ASSERTION_SCHEME = /^\s*AgentAssertion(?:\s|$)/i;

/** Classify an Authorization value. The assertion's own bytes are deliberately not returned. */
export function parseAuthorizationScheme(value: string | null | undefined): AuthorizationScheme {
if (value == null) return { kind: "none" };
if (AGENT_ASSERTION_SCHEME.test(value)) return { kind: "agent-assertion" };
if (BEARER_PREFIX.test(value)) {
const token = value.replace(BEARER_PREFIX, "").trim();
return token ? { kind: "bearer", token } : { kind: "none" };
}
const credential = value.trim();
return credential ? { kind: "other", credential } : { kind: "none" };
}

/**
* The credential the legacy `replace(/^Bearer\s+/i, "").trim()` read produced, except that an
* AgentAssertion value yields nothing. Use this wherever that replace used to be.
*/
export function authorizationCredential(headers: Pick<Headers, "get">): string | undefined {
const parsed = parseAuthorizationScheme(headers.get("authorization"));
if (parsed.kind === "bearer") return parsed.token;
if (parsed.kind === "other") return parsed.credential;
return undefined;
}

/** Whether the request authenticates with `Authorization: AgentAssertion` (assertion_only). */
export function hasAgentAssertionAuthorization(headers: Pick<Headers, "get">): boolean {
return parseAuthorizationScheme(headers.get("authorization")).kind === "agent-assertion";
}

export class AgentAssertionUnsupportedError extends Error {
readonly code = AGENT_ASSERTION_UNSUPPORTED_CODE;
constructor() {
super(AGENT_ASSERTION_UNSUPPORTED_MESSAGE);
this.name = "AgentAssertionUnsupportedError";
}
}

/** The typed refusal. It never echoes the request's Authorization or assertion header. */
export function agentAssertionUnsupportedResponse(): Response {
return Response.json({
error: {
message: AGENT_ASSERTION_UNSUPPORTED_MESSAGE,
type: "invalid_request_error",
code: AGENT_ASSERTION_UNSUPPORTED_CODE,
},
}, { status: 400, headers: { "cache-control": "no-store" } });
}

/**
* Carry the dual_header assertion onto first-party Direct headers, and only when the outbound
* Authorization is the caller's own Bearer, byte for byte. A pool account, a substituted main
* credential, or a stripped Authorization means the assertion would accompany a credential it was
* not sent with, so it is dropped instead.
*/
export function carryAgentAssertionForCallerBearer(target: Headers, caller: Pick<Headers, "get">): void {
target.delete(AGENT_ASSERTION_HEADER);
const assertion = caller.get(AGENT_ASSERTION_HEADER);
if (!assertion) return;
const callerAuth = parseAuthorizationScheme(caller.get("authorization"));
const outboundAuth = parseAuthorizationScheme(target.get("authorization"));
if (callerAuth.kind !== "bearer" || outboundAuth.kind !== "bearer" || callerAuth.token !== outboundAuth.token) return;
target.set(AGENT_ASSERTION_HEADER, assertion);
}
1 change: 1 addition & 0 deletions src/lib/provider-client-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const BLOCKED_FORWARDED_CLIENT_HEADERS = new Set([
"api-key",
"x-goog-api-key",
"x-oai-attestation",
"x-openai-agent-assertion",
]);

const ALLOWED_FORWARDED_CLIENT_HEADERS = new Set([
Expand Down
4 changes: 2 additions & 2 deletions src/lib/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const REDACTED_SECRET = "[REDACTED]";
* credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than
* re-deriving a narrower local list.
*/
export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|x-openai-agent-assertion|copilot[-_]?session[-_]?token|session[-_]?token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;

/**
* Colon-labelled credential headers echoed back inside an error body
Expand Down Expand Up @@ -40,7 +40,7 @@ export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cook
// Every letter position also accepts \u0001, the placeholder the fold emits for
// an unresolved HTML named reference: `author&ii;zation` is the label with one
// character we cannot name, and that is still the label.
const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
const CREDENTIAL_HEADER_LABEL_RAW = "copilot[_-]?session[_-]?token|session[_-]?token|sessionToken|x-openai-agent-assertion|x-api-key|x-goog-api-key|x-amz-security-token|x-dsh-auth-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";

const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW
.replace(/(?<![\[\\])([A-Za-z])(?![\]\-])/g, "[$1\u0001]");
Expand Down
3 changes: 2 additions & 1 deletion src/server/audio-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { OcxConfig } from "../types";
import { captureExplicitOpenAiCallerAuth, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
import { isProxyAdmissionSecret, type DataPlaneAdmission, type DataPlaneAdmissionOptions } from "./auth-cors";
import { resolveAudioAdmission } from "./audio-upstream";
import { authorizationCredential } from "../lib/authorization-scheme";

export const AUDIO_WEBSOCKET_PROTOCOL = "opencodex-audio";
const KEY_PROTOCOL_PREFIX = "opencodex-key.";
Expand Down Expand Up @@ -61,7 +62,7 @@ export function resolveAudioClient(
: null;
}
const credential = admission.source === "dedicated" ? headers.get("x-opencodex-api-key")
: admission.source === "x-api-key" ? headers.get("x-api-key") : headers.get("authorization")?.replace(/^Bearer\s+/i, "");
: admission.source === "x-api-key" ? headers.get("x-api-key") : authorizationCredential(headers);
const owner = admission.kind === "configured"
? JSON.stringify(["configured", admission.keyId])
: JSON.stringify(["environment", createHash("sha256").update(credential?.trim() ?? "").digest("hex")]);
Expand Down
3 changes: 2 additions & 1 deletion src/server/audio-dictation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { OcxConfig } from "../types";
import type { AudioClient } from "./audio-client";
import { resolveAudioUpstream, TRANSCRIPTION_MODEL, type AudioUpstream } from "./audio-upstream";
import type { RequestLogContext } from "./request-log";
import { authorizationCredential } from "../lib/authorization-scheme";

export const DICTATION_SESSION_MAX_MS = 300_000;
const DICTATION_FRAME_MAX_BYTES = 64 * 1024;
Expand Down Expand Up @@ -74,7 +75,7 @@ export async function resolveDictationSocket(
return formatErrorResponse(400, "invalid_request_error", "Streaming dictation requires a connected ChatGPT account");
}
const headers = new Headers(relay.headers);
const token = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const token = authorizationCredential(headers);
if (!token || /[\s,]/.test(token)) {
relay.release();
return formatErrorResponse(401, "authentication_error", "Dictation account authentication unavailable");
Expand Down
3 changes: 2 additions & 1 deletion src/server/audio-upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "./auth-cors";
import { codexAccountSelectionForTurn } from "./lifecycle";
import type { RequestLogContext } from "./request-log";
import { authorizationCredential } from "../lib/authorization-scheme";

export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
export const LIVE_AUDIO_MODEL = "gpt-live-1-codex";
Expand Down Expand Up @@ -77,7 +78,7 @@ export async function resolveAudioUpstream(
if (candidates.keyed?.providerName !== options.providerName) delete candidates.keyed;
}
const headers = new Headers(incoming);
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
const bearer = authorizationCredential(headers);
if (bearer && isProxyAdmissionSecret(bearer, config)) {
headers.delete("authorization");
headers.delete("chatgpt-account-id");
Expand Down
Loading
Loading