diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index 383a9dbbf2..af8dbce4a9 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -1,10 +1,11 @@ const DEFAULT_CODEX_CLIENT_VERSION = "0.125.0"; -const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26100"; +const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200"; const DEFAULT_CODEX_USER_AGENT_ARCH = "x64"; const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION"; const CODEX_USER_AGENT_OVERRIDE_ENV = "CODEX_USER_AGENT"; const SAFE_HEADER_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/; const SAFE_HEADER_VALUE_PATTERN = /^[\x20-\x7E]{1,200}$/; +const SAFE_CODEX_SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/; function getSafeEnvValue(name: string, pattern: RegExp): string | null { const raw = process.env[name]; @@ -40,3 +41,9 @@ export function getCodexDefaultHeaders(): Record { "User-Agent": getCodexUserAgent(), }; } + +export function normalizeCodexSessionId(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return SAFE_CODEX_SESSION_ID_PATTERN.test(normalized) ? normalized : null; +} diff --git a/open-sse/config/codexIdentity.ts b/open-sse/config/codexIdentity.ts new file mode 100644 index 0000000000..c8b0fb878f --- /dev/null +++ b/open-sse/config/codexIdentity.ts @@ -0,0 +1,83 @@ +import { createHash, randomUUID } from "node:crypto"; + +import { normalizeCodexSessionId } from "./codexClient.ts"; + +const CODEX_INSTALLATION_SALT = "omniroute-codex-installation"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type CodexClientIdentity = { + sessionId: string; + turnId: string; + windowId: string; + installationId: string; +}; + +function normalizeUuid(value: unknown): string | null { + return typeof value === "string" && UUID_PATTERN.test(value.trim()) ? value.trim() : null; +} + +function uuidFromStableValue(value: string): string { + const hash = createHash("sha256").update(value).digest("hex"); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`; +} + +export function getCodexInstallationId(providerSpecificData?: Record | null): string { + const explicit = normalizeUuid(providerSpecificData?.codexInstallationId); + if (explicit) return explicit; + + const stableSource = + typeof providerSpecificData?.workspaceId === "string" && providerSpecificData.workspaceId.trim() + ? providerSpecificData.workspaceId.trim() + : typeof providerSpecificData?.accountId === "string" && providerSpecificData.accountId.trim() + ? providerSpecificData.accountId.trim() + : typeof providerSpecificData?.email === "string" && providerSpecificData.email.trim() + ? providerSpecificData.email.trim() + : "default"; + + return uuidFromStableValue(`${CODEX_INSTALLATION_SALT}:${stableSource}`); +} + +export function createCodexClientIdentity( + sessionId: string | null, + providerSpecificData?: Record | null +): CodexClientIdentity | null { + const normalizedSessionId = normalizeCodexSessionId(sessionId); + if (!normalizedSessionId) return null; + return { + sessionId: normalizedSessionId, + turnId: randomUUID(), + windowId: `${normalizedSessionId}:0`, + installationId: getCodexInstallationId(providerSpecificData), + }; +} + +export function applyCodexClientIdentityHeaders( + headers: Record, + identity?: CodexClientIdentity | null +): void { + if (!identity) return; + headers["session_id"] = identity.sessionId; + headers["x-client-request-id"] = identity.sessionId; + headers["x-codex-window-id"] = identity.windowId; + headers["x-codex-turn-metadata"] = JSON.stringify({ + session_id: identity.sessionId, + thread_source: "user", + turn_id: identity.turnId, + sandbox: "none", + }); +} + +export function applyCodexClientMetadata( + body: Record, + identity?: CodexClientIdentity | null +): void { + if (!identity) return; + const existing = + body.client_metadata && typeof body.client_metadata === "object" && !Array.isArray(body.client_metadata) + ? (body.client_metadata as Record) + : {}; + body.client_metadata = { + ...existing, + "x-codex-installation-id": identity.installationId, + }; +} diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 2e0a99f156..7edbbf2536 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -10,7 +10,16 @@ import { CODEX_DEFAULT_INSTRUCTIONS, } from "../config/codexInstructions.ts"; import { PROVIDERS } from "../config/constants.ts"; -import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; +import { + getCodexClientVersion, + getCodexUserAgent, + normalizeCodexSessionId, +} from "../config/codexClient.ts"; +import { + applyCodexClientIdentityHeaders, + applyCodexClientMetadata, + createCodexClientIdentity, +} from "../config/codexIdentity.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { getRememberedResponseFunctionCalls } from "../services/responsesToolCallState.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; @@ -254,68 +263,11 @@ export function getCodexUpstreamModel(model: unknown): string { return splitCodexReasoningSuffix(model).baseModel; } -function stringifyCodexInstructionContent(content: unknown): string { - if (typeof content === "string") { - return content.trim(); - } - - if (Array.isArray(content)) { - return content - .map((part) => { - if (typeof part === "string") return part.trim(); - if (!part || typeof part !== "object") return ""; - const record = part as Record; - if (typeof record.text === "string") return record.text.trim(); - if (typeof record.content === "string") return record.content.trim(); - return ""; - }) - .filter(Boolean) - .join("\n") - .trim(); - } - - return ""; -} - -function hoistSystemMessagesToInstructions(body: Record): void { - if (!Array.isArray(body.input)) return; - - const systemChunks: string[] = []; - const filteredInput = body.input.filter((itemValue) => { - if (!itemValue || typeof itemValue !== "object" || Array.isArray(itemValue)) { - return true; - } - - const item = itemValue as Record; - const role = typeof item.role === "string" ? item.role : ""; - const type = typeof item.type === "string" ? item.type : ""; - const isSystemMessage = role === "system" && (!type || type === "message"); - if (!isSystemMessage) { - return true; - } - - const text = stringifyCodexInstructionContent(item.content); - if (text) { - systemChunks.push(text); - } - return false; - }); - - if (systemChunks.length === 0) return; - - const existingInstructions = - typeof body.instructions === "string" ? body.instructions.trim() : ""; - body.instructions = existingInstructions - ? `${systemChunks.join("\n\n")}\n\n${existingInstructions}` - : systemChunks.join("\n\n"); - body.input = filteredInput; -} - /** * Convert role=system messages in `input` to role=developer. * * GPT-5 models support the `developer` role in input, but reject `system`. - * Unlike hoistSystemMessagesToInstructions(), this keeps the content inside + * This keeps the content inside * the `input` array where it benefits from OpenAI's automatic prompt caching. * * OpenAI's prompt caching matches on the serialized prefix of the `input` array @@ -758,21 +710,37 @@ export class CodexExecutor extends BaseExecutor { } async execute(input: ExecuteInput) { - if (!isCodexResponsesWebSocketRequired(input.model, input.credentials)) { - return super.execute(input); + const sessionId = this.getPromptCacheSessionId( + input.credentials, + input.body as Record | null + ); + const identity = createCodexClientIdentity(sessionId, input.credentials?.providerSpecificData ?? null); + const credentials = identity + ? { + ...input.credentials, + providerSpecificData: { + ...(input.credentials?.providerSpecificData || {}), + codexClientIdentity: identity, + }, + } + : input.credentials; + const nextInput = { ...input, credentials }; + + if (!isCodexResponsesWebSocketRequired(nextInput.model, nextInput.credentials)) { + return super.execute(nextInput); } const url = CODEX_RESPONSES_WS_URL; - const headers = normalizeCodexWsHeaders(this.buildHeaders(input.credentials, true)); - mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders); + const headers = normalizeCodexWsHeaders(this.buildHeaders(nextInput.credentials, true)); + mergeUpstreamExtraHeaders(headers, nextInput.upstreamExtraHeaders); const transformedBody = (await this.transformRequest( - input.model, - input.body, + nextInput.model, + nextInput.body, true, - input.credentials + nextInput.credentials )) as Record; - transformedBody.model = getCodexUpstreamModel(transformedBody.model || input.model); + transformedBody.model = getCodexUpstreamModel(transformedBody.model || nextInput.model); delete transformedBody.stream; delete transformedBody.stream_options; @@ -807,7 +775,7 @@ export class CodexExecutor extends BaseExecutor { let abortHandler: (() => void) | null = null; const removeAbortListener = () => { if (!abortHandler) return; - input.signal?.removeEventListener("abort", abortHandler); + nextInput.signal?.removeEventListener("abort", abortHandler); abortHandler = null; }; @@ -868,7 +836,7 @@ export class CodexExecutor extends BaseExecutor { abortHandler = () => { finishStream({ reason: "client_aborted" }); }; - input.signal?.addEventListener("abort", abortHandler, { once: true }); + nextInput.signal?.addEventListener("abort", abortHandler, { once: true }); try { ws = await websocketFn(toWebSocketUrl(url), { @@ -877,7 +845,7 @@ export class CodexExecutor extends BaseExecutor { headers, }); if (closed) return; - if (input.signal?.aborted) { + if (nextInput.signal?.aborted) { finishStream({ reason: "client_aborted" }); return; } @@ -975,6 +943,7 @@ export class CodexExecutor extends BaseExecutor { if (workspaceId) { headers["chatgpt-account-id"] = workspaceId; } + const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity; // Originator header — identifies the client type to the Codex backend. // Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs" @@ -987,6 +956,7 @@ export class CodexExecutor extends BaseExecutor { if (cacheSessionId) { headers["session_id"] = cacheSessionId; } + applyCodexClientIdentityHeaders(headers, clientIdentity); return headers; } @@ -1003,13 +973,17 @@ export class CodexExecutor extends BaseExecutor { credentials, body: Record | null ): string | null { + const promptCacheKey = normalizeCodexSessionId(body?.prompt_cache_key); + if (promptCacheKey) return promptCacheKey; + // Prefer per-session identifiers from the client request body const sessionId = body?.session_id ?? body?.conversation_id; - if (typeof sessionId === "string" && sessionId.length > 0) { - return sessionId; + const normalizedSessionId = normalizeCodexSessionId(sessionId); + if (normalizedSessionId) { + return normalizedSessionId; } // Fall back to workspaceId (account-wide) — better than nothing - return credentials?.providerSpecificData?.workspaceId || null; + return normalizeCodexSessionId(credentials?.providerSpecificData?.workspaceId) || null; } /** @@ -1198,6 +1172,7 @@ export class CodexExecutor extends BaseExecutor { body.prompt_cache_key = cacheSessionId; } } + applyCodexClientMetadata(body, credentials?.providerSpecificData?.codexClientIdentity); // Delete session_id and conversation_id from the body. // These are often injected by OmniRoute's fallback logic for store=true, diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index a396233252..2afaed2fd9 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -143,7 +143,10 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for assert.equal(standardHeaders.Version, "0.125.0"); assert.equal(standardHeaders["Openai-Beta"], "responses=experimental"); assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets"); - assert.equal(standardHeaders["User-Agent"], "codex-cli/0.125.0 (Windows 10.0.26100; x64)"); + assert.equal( + standardHeaders["User-Agent"], + "codex-cli/0.125.0 (Windows 10.0.26200; x64)" + ); assert.equal(compactHeaders.Accept, "application/json"); }); @@ -152,13 +155,16 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User- await withEnv( { - CODEX_CLIENT_VERSION: "0.120.0-alpha.3", + CODEX_CLIENT_VERSION: "0.125.0", CODEX_USER_AGENT: undefined, }, () => { const headers = executor.buildHeaders({ accessToken: "codex-token" }, true); - assert.equal(headers.Version, "0.120.0-alpha.3"); - assert.equal(headers["User-Agent"], "codex-cli/0.120.0-alpha.3 (Windows 10.0.26100; x64)"); + assert.equal(headers.Version, "0.125.0"); + assert.equal( + headers["User-Agent"], + "codex-cli/0.125.0 (Windows 10.0.26200; x64)" + ); } ); @@ -420,6 +426,34 @@ test("CodexExecutor.transformRequest keeps gpt-5.5 as the model and applies xhig assert.equal(result.reasoning.effort, "xhigh"); }); +test("CodexExecutor.transformRequest merges Codex installation metadata", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.5", + { + model: "gpt-5.5", + input: [], + client_metadata: { existing: "keep" }, + }, + true, + { + providerSpecificData: { + codexClientIdentity: { + sessionId: "session-1", + turnId: "turn-1", + windowId: "session-1:0", + installationId: "11111111-1111-4111-a111-111111111111", + }, + }, + } + ); + + assert.deepEqual(result.client_metadata, { + existing: "keep", + "x-codex-installation-id": "11111111-1111-4111-a111-111111111111", + }); +}); + test("CodexExecutor.execute falls back to HTTP when websocket transport is unavailable", async () => { __setCodexWebSocketTransportForTesting(null); const executor = new CodexExecutor(); @@ -451,6 +485,88 @@ test("CodexExecutor.execute falls back to HTTP when websocket transport is unava } }); +test("CodexExecutor.execute adds CLI-like session identity headers without changing response flow", async () => { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + let capturedBody: Record | null = null; + let capturedHeaders: Headers | null = null; + + globalThis.fetch = async (_url, init) => { + capturedHeaders = new Headers(init?.headers as HeadersInit); + capturedBody = JSON.parse(String(init?.body || "{}")); + return new Response(JSON.stringify({ id: "resp_identity", object: "response" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await executor.execute({ + model: "gpt-5.5", + body: { + model: "gpt-5.5", + session_id: "conversation-1", + input: [{ role: "user", content: "hello" }], + }, + stream: true, + credentials: { + accessToken: "codex-token", + providerSpecificData: { workspaceId: "workspace-1" }, + }, + }); + + assert.equal(result.response.status, 200); + assert.equal(capturedHeaders?.get("session_id"), "conversation-1"); + assert.equal(capturedHeaders?.get("x-client-request-id"), "conversation-1"); + assert.equal(capturedHeaders?.get("x-codex-window-id"), "conversation-1:0"); + const turnMetadata = JSON.parse(capturedHeaders?.get("x-codex-turn-metadata") || "{}"); + assert.equal(turnMetadata.session_id, "conversation-1"); + assert.equal(turnMetadata.thread_source, "user"); + assert.equal(turnMetadata.sandbox, "none"); + assert.equal(typeof turnMetadata.turn_id, "string"); + assert.equal(capturedBody?.prompt_cache_key, "conversation-1"); + assert.equal( + (capturedBody?.client_metadata as Record)?.["x-codex-installation-id"], + "7f06a8ee-2981-4c81-a4ca-e443b5400a63" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("CodexExecutor.execute skips identity headers for unsafe session ids", async () => { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + let capturedHeaders: Headers | null = null; + + globalThis.fetch = async (_url, init) => { + capturedHeaders = new Headers(init?.headers as HeadersInit); + return new Response(JSON.stringify({ id: "resp_identity", object: "response" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + await executor.execute({ + model: "gpt-5.5", + body: { + model: "gpt-5.5", + session_id: "bad\r\nheader", + input: [{ role: "user", content: "hello" }], + }, + stream: true, + credentials: { accessToken: "codex-token" }, + }); + + assert.equal(capturedHeaders?.get("x-client-request-id"), null); + assert.equal(capturedHeaders?.get("x-codex-window-id"), null); + assert.equal(capturedHeaders?.get("x-codex-turn-metadata"), null); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted tool types", () => { // Regression: PR #1581 đã vô tình xoá nhánh `namespace` + whitelist hosted tools // trong normalizeCodexTools, khiến MCP tool group (vd. mcp__atlassian__) bị strip