diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 6dd84dcce22..bfcfdb64e39 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -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 `. + 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 diff --git a/scripts/test-layout/layout-agent-assertion.json b/scripts/test-layout/layout-agent-assertion.json new file mode 100644 index 00000000000..d3a0675e38e --- /dev/null +++ b/scripts/test-layout/layout-agent-assertion.json @@ -0,0 +1,5 @@ +{ + "agent-assertion-auth.test.ts": "server", + "agent-assertion-routes.test.ts": "responses", + "authorization-scheme.test.ts": "lib" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 60eee142abe..b90fdf48324 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", diff --git a/src/adapters/openai-responses/transport.ts b/src/adapters/openai-responses/transport.ts index 4250e167b0e..424d9f021f6 100644 --- a/src/adapters/openai-responses/transport.ts +++ b/src/adapters/openai-responses/transport.ts @@ -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. @@ -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 diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index f804912e339..5afdd2230fd 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -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 @@ -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") ?? "", }; } @@ -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; } @@ -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); @@ -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; @@ -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()); @@ -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) { @@ -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); } @@ -1892,6 +1895,7 @@ export async function materializeCodexUpstreamAuthAsync( ctx: CodexAuthContext, options: CodexAuthMaterializationOptions = {}, ): Promise { + 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); diff --git a/src/lib/authorization-scheme.ts b/src/lib/authorization-scheme.ts new file mode 100644 index 00000000000..eab24026622 --- /dev/null +++ b/src/lib/authorization-scheme.ts @@ -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 `, 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): 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): 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): 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); +} diff --git a/src/lib/provider-client-headers.ts b/src/lib/provider-client-headers.ts index 600173860ec..37208a29598 100644 --- a/src/lib/provider-client-headers.ts +++ b/src/lib/provider-client-headers.ts @@ -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([ diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 76d80641b9b..fcde66a7361 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -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 @@ -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ⅈ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(/(? { let authCtx: CodexAuthContext | undefined; + // Ingress already refuses assertion_only; a later internal hop must not reach a pool or + // routed credential with it either, because that would be a silent credential swap. + if (hasAgentAssertionAuthorization(req.headers)) return { ok: false, response: agentAssertionUnsupportedResponse() }; try { let authInputHeaders = codexRouteCredentialDomainHeaders( req, @@ -332,7 +336,7 @@ export async function resolveResponsesCodexAuth( } else { // Even adapters that ignore caller auth must not retain a proxy secret for // a later internal hop or a future transport change. - 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"); @@ -343,6 +347,11 @@ export async function resolveResponsesCodexAuth( if (options.compactionRecoveryKind === "compaction-v1") { copyResponsesClientMetadata(headers, authInputHeaders, route.provider); } + // dual_header: the assertion is first-party only and travels only beside the caller's own + // unchanged Bearer, compared with the ORIGINAL request (injected credentials do not count). + if (isCanonicalOpenAiForwardProvider(route.provider) && authCtx?.kind === "main" && !substituteMainCredential) { + carryAgentAssertionForCallerBearer(headers, req.headers); + } return { ok: true, authCtx, @@ -364,6 +373,7 @@ export async function resolveResponsesCodexAuth( if (err instanceof ForwardAdmissionCredentialError) { return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) }; } + if (err instanceof AgentAssertionUnsupportedError) return { ok: false, response: agentAssertionUnsupportedResponse() }; const response = mapCodexAuthContextErrorToResponse(err, { accountSelector: route.codexAccountNamespace, now: Date.now(), diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 4009a48c1d7..6a10afe729e 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -12,6 +12,7 @@ import { sseDataPayload as parseSseBlock } from "./sse-payload-rewrite"; import { safeResponseHeaders } from "./safe-response-headers"; import type { AudioSocketTarget } from "./audio-dictation"; import type { CodexWebSocketActivity } from "../codex/websocket-activity"; +import { AGENT_ASSERTION_HEADER } from "../lib/authorization-scheme"; export { safeResponseHeaders } from "./safe-response-headers"; @@ -131,6 +132,10 @@ export function selectForwardHeaders( if (codexOverride) { selected.set("authorization", `Bearer ${codexOverride.accessToken}`); selected.set("chatgpt-account-id", codexOverride.chatgptAccountId); + } else { + // Kept for the WS turn pipeline, which drops it again everywhere except first-party Direct. + const assertion = headers.get(AGENT_ASSERTION_HEADER); + if (assertion) selected.set(AGENT_ASSERTION_HEADER, assertion); } return selected; } diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index eb85a03718a..c658d8881c7 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -561,3 +561,16 @@ Missing identity, a restarted/expired/evicted cache, and another route receive o placeholder; they never recover another conversation's raw text. SSE capture retains at most 256 KiB and 64 calls within the translator budget and releases on terminal/error/cancellation. Unfinished tool streams cannot seed replay. This leaves the native wire and response fields intact. + +## Codex runtime agent assertions + +Authorization scheme parsing lives in `src/lib/authorization-scheme.ts`, and every former +`replace(/^Bearer\s+/i, "")` read uses it. Codex 0.164 runtime agent assertions add a value that is +neither an admission secret nor a caller credential. `Authorization: AgentAssertion` (assertion_only) +is never a bearer, never admitted, and never forwarded: the request fetch handler refuses it with +`agent_assertion_unsupported` before admission or routing, and Responses auth plus +`materializeCodexUpstreamAuth` refuse it again so no pool or substituted credential can stand in for +it. The dual_header `x-openai-agent-assertion` is outside `FORWARD_HEADERS` and blocked from +`forwardClientHeaders`. It is carried only for canonical-forward Direct when the outbound Bearer is +the caller's original one, and is dropped under a pool override. Coverage: +`tests/server/agent-assertion-auth.test.ts` and `tests/responses/agent-assertion-routes.test.ts`. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 563b2d53550..73572bfa3b4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -217,6 +217,7 @@ "adapter-usage.test.ts": "adapters", "agent-driven.test.ts": "cli", "agent-task-recovery-cache.test.ts": "server", "agent-task-recovery-combo.test.ts": "server", "agent-task-recovery-fallback.test.ts": "server", "agent-task-recovery-security.test.ts": "server", + "agent-assertion-auth.test.ts": "server", "agent-assertion-routes.test.ts": "responses", "authorization-scheme.test.ts": "lib", "agent-task-recovery.test.ts": "server", "alias-management-api.test.ts": "server", "alibaba-intl-token-plan.test.ts": "gui", "alibaba-region-backup.test.ts": "providers", "alibaba-region-migration.test.ts": "providers", "alibaba-region-startup.test.ts": "providers", diff --git a/tests/lib/authorization-scheme.test.ts b/tests/lib/authorization-scheme.test.ts new file mode 100644 index 00000000000..237362f3d04 --- /dev/null +++ b/tests/lib/authorization-scheme.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_ASSERTION_HEADER, + AGENT_ASSERTION_UNSUPPORTED_CODE, + agentAssertionUnsupportedResponse, + authorizationCredential, + carryAgentAssertionForCallerBearer, + hasAgentAssertionAuthorization, + parseAuthorizationScheme, +} from "../../src/lib/authorization-scheme"; +import { redactHeaders, redactSecretString, REDACTED_SECRET } from "../../src/lib/redact"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; + +// Codex 0.164 runtime agent assertions are JWT-shaped; the bytes only have to be recognisable. +const ASSERTION = fakeChatGptJwt({ agent: "runtime-agent-assertion-fixture" }); + +describe("Authorization scheme parsing", () => { + test("AgentAssertion is its own scheme in any case and never yields a credential", () => { + for (const value of [`AgentAssertion ${ASSERTION}`, `agentassertion ${ASSERTION}`, `AGENTASSERTION\t${ASSERTION}`, "AgentAssertion"]) { + expect(parseAuthorizationScheme(value)).toEqual({ kind: "agent-assertion" }); + const headers = new Headers({ authorization: value }); + expect(authorizationCredential(headers)).toBeUndefined(); + expect(hasAgentAssertionAuthorization(headers)).toBe(true); + } + }); + + test("Bearer and scheme-less values keep the legacy read", () => { + expect(authorizationCredential(new Headers({ authorization: "Bearer abc.def" }))).toBe("abc.def"); + expect(authorizationCredential(new Headers({ authorization: "bearer spaced " }))).toBe("spaced"); + expect(authorizationCredential(new Headers({ authorization: "ocx_data_bare" }))).toBe("ocx_data_bare"); + expect(authorizationCredential(new Headers())).toBeUndefined(); + // A longer scheme that merely starts with the name is not an agent assertion. + expect(parseAuthorizationScheme("AgentAssertionX token")).toEqual({ kind: "other", credential: "AgentAssertionX token" }); + expect(hasAgentAssertionAuthorization(new Headers({ authorization: `Bearer ${ASSERTION}` }))).toBe(false); + }); + + test("the dual_header assertion is carried only beside the caller's own unchanged Bearer", () => { + const caller = new Headers({ authorization: "Bearer caller-token", [AGENT_ASSERTION_HEADER]: ASSERTION }); + const same = new Headers({ authorization: "Bearer caller-token" }); + carryAgentAssertionForCallerBearer(same, caller); + expect(same.get(AGENT_ASSERTION_HEADER)).toBe(ASSERTION); + + const pooled = new Headers({ authorization: "Bearer pool-token", [AGENT_ASSERTION_HEADER]: ASSERTION }); + carryAgentAssertionForCallerBearer(pooled, caller); + expect(pooled.has(AGENT_ASSERTION_HEADER)).toBe(false); + + const stripped = new Headers(); + carryAgentAssertionForCallerBearer(stripped, caller); + expect(stripped.has(AGENT_ASSERTION_HEADER)).toBe(false); + + const assertionOnly = new Headers({ authorization: `AgentAssertion ${ASSERTION}`, [AGENT_ASSERTION_HEADER]: ASSERTION }); + const target = new Headers({ authorization: `AgentAssertion ${ASSERTION}` }); + carryAgentAssertionForCallerBearer(target, assertionOnly); + expect(target.has(AGENT_ASSERTION_HEADER)).toBe(false); + }); + + test("the typed refusal is stable and does not echo the assertion", async () => { + const response = agentAssertionUnsupportedResponse(); + expect(response.status).toBe(400); + const text = await response.text(); + expect(JSON.parse(text).error.code).toBe(AGENT_ASSERTION_UNSUPPORTED_CODE); + expect(text).not.toContain(ASSERTION); + }); +}); + +describe("agent assertion redaction", () => { + test("header records redact both the AgentAssertion Authorization and the dual_header header", () => { + const redacted = redactHeaders(new Headers({ + authorization: `AgentAssertion ${ASSERTION}`, + [AGENT_ASSERTION_HEADER]: ASSERTION, + })); + expect(redacted.authorization).toBe(REDACTED_SECRET); + expect(redacted[AGENT_ASSERTION_HEADER]).toBe(REDACTED_SECRET); + }); + + test("diagnostic strings never keep the assertion bytes", () => { + for (const raw of [ + `upstream echoed authorization: AgentAssertion ${ASSERTION}`, + `upstream echoed ${AGENT_ASSERTION_HEADER}: ${ASSERTION}`, + JSON.stringify({ [AGENT_ASSERTION_HEADER]: ASSERTION }), + ]) { + expect(redactSecretString(raw)).not.toContain(ASSERTION); + } + }); +}); diff --git a/tests/responses/agent-assertion-routes.test.ts b/tests/responses/agent-assertion-routes.test.ts new file mode 100644 index 00000000000..83d015227f2 --- /dev/null +++ b/tests/responses/agent-assertion-routes.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { saveConfig } from "../../src/config"; +import { AGENT_ASSERTION_HEADER, AGENT_ASSERTION_UNSUPPORTED_CODE } from "../../src/lib/authorization-scheme"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; + +// Every case starts a real server; on Windows its config-dir ACL work alone can exceed 5 s. +setDefaultTimeout(SERVER_BUDGET_MS); + +/** + * Codex 0.164 runtime agent instances (openai/codex#55402, #55636) end to end. dual_header keeps + * working with its Bearer and forwards x-openai-agent-assertion only on first-party Direct. + * assertion_only (Authorization: AgentAssertion) is refused with a typed error before any pool, + * routed, or admission decision, so no other credential is ever sent in its place. + */ +const inspectNativeCodexOwnership = ownedServiceHomeInspection("agent assertion routes test"); +const ACCOUNT = "agent-assertion-route-account"; +const CALLER = fakeChatGptJwt({ exp: 4_000_000_000, "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT } }); +const ASSERTION = fakeChatGptJwt({ agent: "runtime-agent-assertion-fixture", "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT } }); +const ADMISSION_KEY = "ocx_data_agent_assertion_route_fixture"; + +type Seen = { path: string; authorization: string | null; assertion: string | null }; + +function completed(id: string): Response { + return Response.json({ + id, object: "response", status: "completed", model: "gpt-5.5", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }], + usage: { input_tokens: 1, output_tokens: 1 }, + }); +} + +function filesUnder(root: string): string[] { + const out: string[] = []; + for (const name of readdirSync(root)) { + const path = join(root, name); + if (statSync(path).isDirectory()) out.push(...filesUnder(path)); + else out.push(path); + } + return out; +} + +describe("Codex runtime agent assertion routes", () => { + const originalFetch = globalThis.fetch; + let testDir = ""; + let previousHome: string | undefined; + let isolatedCodexHome: IsolatedCodexHome | null = null; + let seen: Seen[] = []; + let upstream: ReturnType | undefined; + let server: ReturnType | undefined; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-agent-assertion-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-agent-assertion-")); + process.env.OPENCODEX_HOME = testDir; + seen = []; + upstream = Bun.serve({ + port: 0, + fetch(req) { + seen.push({ + path: new URL(req.url).pathname, + authorization: req.headers.get("authorization"), + assertion: req.headers.get(AGENT_ASSERTION_HEADER), + }); + return completed("resp_agent_assertion"); + }, + }); + const target = upstream.url; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url); + if (url.hostname === "chatgpt.com" && url.pathname.startsWith("/backend-api/codex")) { + return originalFetch(new URL(`/chatgpt${url.pathname.slice("/backend-api/codex".length)}`, target), init); + } + if (url.hostname === "gateway.example.test") return originalFetch(new URL(`/gateway${url.pathname}`, target), init); + return originalFetch(input, init); + }) as typeof fetch; + }); + + afterEach(async () => { + if (server) await server.stop(true); + server = undefined; + upstream?.stop(true); + globalThis.fetch = originalFetch; + process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) await removeTreeWithRetry(testDir); + }); + + function start(config: Partial): URL { + saveConfig({ port: 0, ...config } as OcxConfig); + server = startServer(0, { inspectNativeCodexOwnership }); + return new URL(server.url); + } + + function post(base: URL, headers: Record, model = "gpt-5.5"): Promise { + return fetch(new URL("/v1/responses", base), { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ model, stream: false, store: false, input: "hello" }), + }); + } + + async function expectRefused(response: Response): Promise { + expect(response.status).toBe(400); + const text = await response.text(); + expect(JSON.parse(text).error.code).toBe(AGENT_ASSERTION_UNSUPPORTED_CODE); + expect(text).not.toContain(ASSERTION); + } + + function expectNoAssertionPersisted(): void { + for (const path of filesUnder(testDir)) expect(readFileSync(path, "latin1")).not.toContain(ASSERTION); + } + + const direct = { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + codexAccountMode: "direct", upstreamWebsocket: false, + } } as unknown as OcxConfig["providers"]; + const pooled = { openai: { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + codexAccountMode: "pool", upstreamWebsocket: false, + } } as unknown as OcxConfig["providers"]; + + function savePoolAccount(): Partial { + saveCodexAccountCredential("pool-account", { + accessToken: "pool-account-token", refreshToken: "pool-account-refresh", + expiresAt: Date.now() + 3_600_000, chatgptAccountId: "pool-account-uuid", + }); + return { + activeCodexAccountId: "pool-account", + codexAccounts: [{ id: "pool-account", email: "pool@example.test", chatgptAccountId: "pool-account-uuid", isMain: false }], + } as Partial; + } + + test("dual_header on Direct forwards the caller Bearer with its assertion", async () => { + const base = start({ defaultProvider: "openai", providers: direct }); + const response = await post(base, { + authorization: `Bearer ${CALLER}`, "chatgpt-account-id": ACCOUNT, [AGENT_ASSERTION_HEADER]: ASSERTION, + }); + expect(response.status).toBe(200); + await response.text(); + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: `Bearer ${CALLER}`, assertion: ASSERTION }]); + expectNoAssertionPersisted(); + }); + + test("dual_header on a pooled account sends the pool Bearer and strips the assertion", async () => { + const base = start({ defaultProvider: "openai", providers: pooled, ...savePoolAccount() }); + const response = await post(base, { + authorization: `Bearer ${CALLER}`, "chatgpt-account-id": ACCOUNT, [AGENT_ASSERTION_HEADER]: ASSERTION, + }); + expect(response.status).toBe(200); + await response.text(); + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: "Bearer pool-account-token", assertion: null }]); + expectNoAssertionPersisted(); + }); + + test("dual_header with an admission Bearer substitutes stored main and drops the assertion", async () => { + const stored = fakeChatGptJwt({ exp: 4_000_000_000, "https://api.openai.com/auth": { chatgpt_account_id: "stored-main-account" } }); + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), + JSON.stringify({ tokens: { access_token: stored, account_id: "stored-main-account" } })); + const base = start({ + hostname: "0.0.0.0", + defaultProvider: "openai", + providers: direct, + apiKeys: [{ id: "agent-key", name: "agent", key: ADMISSION_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], + } as unknown as Partial); + await waitForNativeMainStartupGate(); + const response = await post(base, { authorization: `Bearer ${ADMISSION_KEY}`, [AGENT_ASSERTION_HEADER]: ASSERTION }); + expect(response.status).toBe(200); + await response.text(); + // The assertion was sent beside the admission key, so it never rides with the stored credential. + expect(seen).toEqual([{ path: "/chatgpt/responses", authorization: `Bearer ${stored}`, assertion: null }]); + expectNoAssertionPersisted(); + }); + + test("assertion_only on a pooled account is refused without spending the pool credential", async () => { + const base = start({ defaultProvider: "openai", providers: pooled, ...savePoolAccount() }); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}`, "chatgpt-account-id": ACCOUNT })); + expect(seen).toEqual([]); + expectNoAssertionPersisted(); + }); + + test("assertion_only on Direct and on a routed provider is refused before any upstream call", async () => { + const base = start({ + defaultProvider: "openai", + providers: { + ...direct, + gateway: { + adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", + apiKey: "sk-routed-provider-key", models: ["gateway-model"], allowPrivateNetwork: true, + }, + } as unknown as OcxConfig["providers"], + }); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}` })); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ASSERTION}` }, "gateway/gateway-model")); + expect(seen).toEqual([]); + expectNoAssertionPersisted(); + }); + + test("a remote bind refuses assertion_only with the typed error whether or not the caller is admitted", async () => { + const base = start({ + hostname: "0.0.0.0", + defaultProvider: "openai", + providers: pooled, + ...savePoolAccount(), + apiKeys: [{ id: "agent-key", name: "agent", key: ADMISSION_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], + } as unknown as Partial); + await expectRefused(await post(base, { "x-opencodex-api-key": ADMISSION_KEY, authorization: `AgentAssertion ${ASSERTION}` })); + await expectRefused(await post(base, { authorization: `AgentAssertion ${ADMISSION_KEY}` })); + expect(seen).toEqual([]); + }); +}); diff --git a/tests/server/agent-assertion-auth.test.ts b/tests/server/agent-assertion-auth.test.ts new file mode 100644 index 00000000000..b97427a190f --- /dev/null +++ b/tests/server/agent-assertion-auth.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { buildResponsesTransport, FORWARD_HEADERS } from "../../src/adapters/openai-responses/transport"; +import { + headersForCodexAuthContext, + materializeCodexUpstreamAuth, + materializeCodexUpstreamAuthAsync, + type CodexAuthContext, +} from "../../src/codex/auth-context"; +import { providerForwardClientHeadersConfigError } from "../../src/lib/provider-client-headers"; +import { AGENT_ASSERTION_HEADER, AgentAssertionUnsupportedError } from "../../src/lib/authorization-scheme"; +import { + hasForwardableCodexBearer, + resolveApiAuth, + resolveContextPrincipal, + resolveResponsesApiAuth, + validateForwardAdmissionCredential, +} from "../../src/server/auth-cors"; +import { selectForwardHeaders } from "../../src/server/ws-bridge"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; + +/** + * Codex 0.164 runtime agent instances (openai/codex#55402, #55636) authenticate in two modes: + * dual_header sends an ordinary Bearer plus x-openai-agent-assertion, and assertion_only sends + * Authorization: AgentAssertion . The legacy replace(/^Bearer\s+/i) read passed the second + * one through as an opaque bearer. + */ +const ACCOUNT = "agent-assertion-account"; +const ASSERTION = fakeChatGptJwt({ "https://api.openai.com/auth": { chatgpt_account_id: ACCOUNT } }); +const ADMISSION_KEY = "ocx_data_agent_assertion_fixture"; +const remote = { + hostname: "0.0.0.0", + providers: {}, + apiKeys: [{ id: "agent-key", name: "agent", key: ADMISSION_KEY, createdAt: "2026-10-21T00:00:00.000Z" }], +} as unknown as OcxConfig; +const canonical: OcxProviderConfig = { + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", +} as OcxProviderConfig; + +function assertionOnly(extra: Record = {}): Headers { + return new Headers({ authorization: `AgentAssertion ${ASSERTION}`, "chatgpt-account-id": ACCOUNT, ...extra }); +} + +function dualHeader(bearer = "caller-direct-token"): Headers { + return new Headers({ + authorization: `Bearer ${bearer}`, "chatgpt-account-id": ACCOUNT, [AGENT_ASSERTION_HEADER]: ASSERTION, + }); +} + +describe("assertion_only admission", () => { + test("an AgentAssertion is never admitted, even when its bytes equal a configured proxy key", () => { + for (const value of [`AgentAssertion ${ASSERTION}`, `AgentAssertion ${ADMISSION_KEY}`]) { + const req = new Request("http://proxy.test/v1/responses", { headers: { authorization: value } }); + expect(resolveApiAuth(req, remote)).toBeNull(); + expect(resolveResponsesApiAuth(req, remote)).toBeNull(); + expect(resolveContextPrincipal(req, remote, { kind: "loopback", source: "loopback" })).toBeUndefined(); + } + }); + + test("an AgentAssertion is not a forwardable caller Codex bearer", () => { + // The legacy read saw "AgentAssertion " plus an account header as a caller credential. + expect(hasForwardableCodexBearer(assertionOnly(), remote)).toBe(false); + expect(hasForwardableCodexBearer(dualHeader(), remote)).toBe(true); + expect(() => validateForwardAdmissionCredential(assertionOnly(), remote)).not.toThrow(); + expect(() => validateForwardAdmissionCredential(new Headers({ authorization: `Bearer ${ADMISSION_KEY}` }), remote)) + .toThrow(); + }); +}); + +describe("assertion_only credential materialization", () => { + const pool = { kind: "pool", accountId: "pool-account", accessToken: "pool-token", chatgptAccountId: "pool-uuid" } as unknown as CodexAuthContext; + const main = { kind: "main", accountId: null } as CodexAuthContext; + + test("pool, stored-main and Direct materialization refuse instead of substituting a Bearer", async () => { + expect(() => materializeCodexUpstreamAuth(assertionOnly(), pool)).toThrow(AgentAssertionUnsupportedError); + expect(() => materializeCodexUpstreamAuth(assertionOnly(), main)).toThrow(AgentAssertionUnsupportedError); + expect(() => materializeCodexUpstreamAuth(assertionOnly(), main, { substituteMainCredential: true })) + .toThrow(AgentAssertionUnsupportedError); + await expect(materializeCodexUpstreamAuthAsync(assertionOnly(), main, { substituteMainCredential: true })) + .rejects.toBeInstanceOf(AgentAssertionUnsupportedError); + }); +}); + +describe("dual_header assertion forwarding", () => { + test("first-party Direct passthrough forwards the caller's Bearer and the assertion", () => { + const { headers } = buildResponsesTransport(canonical, { headers: dualHeader() }); + expect(headers.authorization).toBe("Bearer caller-direct-token"); + expect(headers[AGENT_ASSERTION_HEADER]).toBe(ASSERTION); + }); + + test("a pooled account override strips the assertion", () => { + const pooled = { ...canonical, _codexAccountOverride: { accessToken: "pool-token", chatgptAccountId: "pool-uuid" } }; + const { headers } = buildResponsesTransport(pooled, { headers: dualHeader() }); + expect(headers.authorization).toBe("Bearer pool-token"); + expect(headers[AGENT_ASSERTION_HEADER]).toBeUndefined(); + }); + + test("routed transports never forward the assertion, even when configured to", () => { + const routed: OcxProviderConfig[] = [ + { adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "forward" } as OcxProviderConfig, + { adapter: "openai-responses", baseUrl: "https://gateway.example.test/v1", authMode: "key", apiKey: "sk-routed" } as OcxProviderConfig, + { ...canonical, baseUrl: "https://gateway.example.test/v1", forwardClientHeaders: [AGENT_ASSERTION_HEADER] } as OcxProviderConfig, + ]; + for (const provider of routed) { + const { headers } = buildResponsesTransport(provider, { headers: dualHeader() }); + expect(Object.keys(headers).map(name => name.toLowerCase())).not.toContain(AGENT_ASSERTION_HEADER); + } + expect(providerForwardClientHeadersConfigError([AGENT_ASSERTION_HEADER])).toContain("credential"); + }); + + test("Codex materialization and sidecar allowlists drop the assertion", () => { + expect(FORWARD_HEADERS).not.toContain(AGENT_ASSERTION_HEADER); + const main = { kind: "main", accountId: null } as CodexAuthContext; + expect(materializeCodexUpstreamAuth(dualHeader(), main).has(AGENT_ASSERTION_HEADER)).toBe(false); + // openai-sidecar.ts selects sidecar credentials through this wrapper. + expect(headersForCodexAuthContext(dualHeader(), main).has(AGENT_ASSERTION_HEADER)).toBe(false); + }); + + test("the WebSocket handshake keeps the assertion for Direct and drops it for a pool override", () => { + expect(selectForwardHeaders(dualHeader()).get(AGENT_ASSERTION_HEADER)).toBe(ASSERTION); + const pooled = selectForwardHeaders(dualHeader(), { accessToken: "pool-token", chatgptAccountId: "pool-uuid" }); + expect(pooled.get("authorization")).toBe("Bearer pool-token"); + expect(pooled.has(AGENT_ASSERTION_HEADER)).toBe(false); + }); +});