diff --git a/src/app/v1/_lib/codex/__tests__/session-extractor.test.ts b/src/app/v1/_lib/codex/__tests__/session-extractor.test.ts new file mode 100644 index 000000000..76f7f67d4 --- /dev/null +++ b/src/app/v1/_lib/codex/__tests__/session-extractor.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "vitest"; +import { extractCodexSessionId, isCodexClient } from "../session-extractor"; + +describe("Codex session extractor", () => { + test("extracts from header session_id", () => { + const headerSessionId = "sess_123456789012345678901"; + const result = extractCodexSessionId( + new Headers({ session_id: headerSessionId }), + { + metadata: { session_id: "sess_aaaaaaaaaaaaaaaaaaaaa" }, + previous_response_id: "resp_123456789012345678901", + }, + "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)" + ); + + expect(result.sessionId).toBe(headerSessionId); + expect(result.source).toBe("header_session_id"); + }); + + test("extracts from header x-session-id", () => { + const headerSessionId = "sess_123456789012345678902"; + const result = extractCodexSessionId( + new Headers({ "x-session-id": headerSessionId }), + { + metadata: { session_id: "sess_aaaaaaaaaaaaaaaaaaaaa" }, + previous_response_id: "resp_123456789012345678901", + }, + "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)" + ); + + expect(result.sessionId).toBe(headerSessionId); + expect(result.source).toBe("header_x_session_id"); + }); + + test("extracts from body metadata.session_id", () => { + const bodySessionId = "sess_123456789012345678903"; + const result = extractCodexSessionId( + new Headers(), + { metadata: { session_id: bodySessionId } }, + "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)" + ); + + expect(result.sessionId).toBe(bodySessionId); + expect(result.source).toBe("body_metadata_session_id"); + }); + + test("falls back to previous_response_id", () => { + const previousResponseId = "resp_123456789012345678901"; + const result = extractCodexSessionId( + new Headers(), + { previous_response_id: previousResponseId }, + "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)" + ); + + expect(result.sessionId).toBe(`codex_prev_${previousResponseId}`); + expect(result.source).toBe("body_previous_response_id"); + }); + + test("rejects previous_response_id that would exceed 256 after prefix", () => { + const longId = "a".repeat(250); // 250 + 11 (prefix) = 261 > 256 + const result = extractCodexSessionId(new Headers(), { previous_response_id: longId }, null); + expect(result.sessionId).toBe(null); + expect(result.source).toBe(null); + }); + + test("respects extraction priority", () => { + const sessionIdFromHeader = "sess_123456789012345678904"; + const xSessionIdFromHeader = "sess_123456789012345678905"; + const sessionIdFromBody = "sess_123456789012345678906"; + const previousResponseId = "resp_123456789012345678901"; + + const result = extractCodexSessionId( + new Headers({ + session_id: sessionIdFromHeader, + "x-session-id": xSessionIdFromHeader, + }), + { + metadata: { session_id: sessionIdFromBody }, + previous_response_id: previousResponseId, + }, + "codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)" + ); + + expect(result.sessionId).toBe(sessionIdFromHeader); + expect(result.source).toBe("header_session_id"); + }); + + test("detects Codex client User-Agent", () => { + expect(isCodexClient("codex_cli_rs/0.50.0 (Mac OS 26.0.1; arm64)")).toBe(true); + expect(isCodexClient("codex_vscode/0.35.0 (Windows 10.0.26100; x86_64)")).toBe(true); + expect(isCodexClient("Mozilla/5.0")).toBe(false); + expect(isCodexClient(null)).toBe(false); + }); + + test("rejects session_id shorter than 21 characters", () => { + const result = extractCodexSessionId( + new Headers({ session_id: "short_id_12345" }), // 14 chars + {}, + null + ); + expect(result.sessionId).toBe(null); + expect(result.source).toBe(null); + }); + + test("accepts session_id with exactly 21 characters (minimum)", () => { + const minId = "a".repeat(21); + const result = extractCodexSessionId(new Headers({ session_id: minId }), {}, null); + expect(result.sessionId).toBe(minId); + expect(result.source).toBe("header_session_id"); + }); + + test("accepts session_id with exactly 256 characters (maximum)", () => { + const maxId = "a".repeat(256); + const result = extractCodexSessionId(new Headers({ session_id: maxId }), {}, null); + expect(result.sessionId).toBe(maxId); + expect(result.source).toBe("header_session_id"); + }); + + test("rejects session_id longer than 256 characters", () => { + const longId = "a".repeat(300); + const result = extractCodexSessionId(new Headers({ session_id: longId }), {}, null); + expect(result.sessionId).toBe(null); + expect(result.source).toBe(null); + }); + + test("rejects session_id with invalid characters", () => { + // Test with body metadata to avoid Headers normalization + const result = extractCodexSessionId( + new Headers(), + { metadata: { session_id: "sess_123456789@#$%^&*()!" } }, + null + ); + expect(result.sessionId).toBe(null); + }); + + test("accepts session_id with allowed special characters", () => { + const validId = "sess-123_456.789:abc012345"; + const result = extractCodexSessionId(new Headers({ session_id: validId }), {}, null); + expect(result.sessionId).toBe(validId); + }); + + test("returns null when no valid session_id found", () => { + const result = extractCodexSessionId(new Headers(), {}, "codex_cli_rs/0.50.0"); + expect(result.sessionId).toBe(null); + expect(result.source).toBe(null); + expect(result.isCodexClient).toBe(true); + }); +}); diff --git a/src/app/v1/_lib/codex/session-extractor.ts b/src/app/v1/_lib/codex/session-extractor.ts new file mode 100644 index 000000000..6a82d064f --- /dev/null +++ b/src/app/v1/_lib/codex/session-extractor.ts @@ -0,0 +1,112 @@ +import "server-only"; + +export type CodexSessionIdSource = + | "header_session_id" + | "header_x_session_id" + | "body_metadata_session_id" + | "body_previous_response_id" + | null; + +export interface CodexSessionExtractionResult { + sessionId: string | null; + source: CodexSessionIdSource; + isCodexClient: boolean; +} + +// Session ID validation constants +const CODEX_SESSION_ID_MIN_LENGTH = 21; // Codex session_id typically > 20 chars (UUID-like) +const CODEX_SESSION_ID_MAX_LENGTH = 256; // Prevent Redis key bloat from malicious input +const SESSION_ID_PATTERN = /^[\w\-.:]+$/; // Alphanumeric, dash, dot, colon only + +// Codex CLI User-Agent pattern (pre-compiled for performance) +const CODEX_CLI_PATTERN = /^(codex_vscode|codex_cli_rs)\/[\d.]+/i; + +export function normalizeCodexSessionId(value: unknown): string | null { + if (typeof value !== "string") return null; + + const trimmed = value.trim(); + if (!trimmed) return null; + + if (trimmed.length < CODEX_SESSION_ID_MIN_LENGTH) return null; + if (trimmed.length > CODEX_SESSION_ID_MAX_LENGTH) return null; + if (!SESSION_ID_PATTERN.test(trimmed)) return null; + + return trimmed; +} + +function parseMetadata(requestBody: Record): Record | null { + const metadata = requestBody.metadata; + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null; + return metadata as Record; +} + +/** + * Detect official Codex CLI clients by User-Agent. + * + * Examples: + * - codex_vscode/0.35.0 (...) + * - codex_cli_rs/0.50.0 (...) + */ +export function isCodexClient(userAgent: string | null): boolean { + if (!userAgent) return false; + return CODEX_CLI_PATTERN.test(userAgent); +} + +/** + * Extract Codex session id from headers/body with priority: + * 1) headers["session_id"] + * 2) headers["x-session-id"] + * 3) body.metadata.session_id + * 4) body.previous_response_id (fallback, prefixed with "codex_prev_") + * + * Only accept session ids with length > 20. + */ +export function extractCodexSessionId( + headers: Headers, + requestBody: Record, + userAgent: string | null +): CodexSessionExtractionResult { + const officialClient = isCodexClient(userAgent); + + const headerSessionId = normalizeCodexSessionId(headers.get("session_id")); + if (headerSessionId) { + return { + sessionId: headerSessionId, + source: "header_session_id", + isCodexClient: officialClient, + }; + } + + const headerXSessionId = normalizeCodexSessionId(headers.get("x-session-id")); + if (headerXSessionId) { + return { + sessionId: headerXSessionId, + source: "header_x_session_id", + isCodexClient: officialClient, + }; + } + + const metadata = parseMetadata(requestBody); + const bodyMetadataSessionId = metadata ? normalizeCodexSessionId(metadata.session_id) : null; + if (bodyMetadataSessionId) { + return { + sessionId: bodyMetadataSessionId, + source: "body_metadata_session_id", + isCodexClient: officialClient, + }; + } + + const prevResponseId = normalizeCodexSessionId(requestBody.previous_response_id); + if (prevResponseId) { + const sessionId = `codex_prev_${prevResponseId}`; + if (sessionId.length <= CODEX_SESSION_ID_MAX_LENGTH) { + return { + sessionId, + source: "body_previous_response_id", + isCodexClient: officialClient, + }; + } + } + + return { sessionId: null, source: null, isCodexClient: officialClient }; +} diff --git a/src/app/v1/_lib/converters/codex-to-claude/request.ts b/src/app/v1/_lib/converters/codex-to-claude/request.ts index 3cc024215..1e28ed56e 100644 --- a/src/app/v1/_lib/converters/codex-to-claude/request.ts +++ b/src/app/v1/_lib/converters/codex-to-claude/request.ts @@ -18,6 +18,7 @@ */ import { randomBytes } from "node:crypto"; +import { normalizeCodexSessionId } from "@/app/v1/_lib/codex/session-extractor"; import { logger } from "@/lib/logger"; /** @@ -26,6 +27,7 @@ import { logger } from "@/lib/logger"; interface ResponseAPIRequest { model?: string; instructions?: string; + metadata?: Record; input?: Array<{ type?: string; role?: string; @@ -115,7 +117,12 @@ function generateToolCallID(): string { /** * 生成用户 ID(基于 account 和 session) */ -function generateUserID(): string { +function generateUserID(originalMetadata?: Record): string { + const sessionId = normalizeCodexSessionId(originalMetadata?.session_id); + if (sessionId) { + return `codex_session_${sessionId}`; + } + // 简化实现:使用随机 UUID const account = randomBytes(16).toString("hex"); const session = randomBytes(16).toString("hex"); @@ -144,7 +151,7 @@ export function transformCodexRequestToClaude( max_tokens: 32000, messages: [], metadata: { - user_id: generateUserID(), + user_id: generateUserID(req.metadata), }, stream, }; diff --git a/src/app/v1/_lib/proxy/session-guard.ts b/src/app/v1/_lib/proxy/session-guard.ts index 0e0889cce..7acd1a795 100644 --- a/src/app/v1/_lib/proxy/session-guard.ts +++ b/src/app/v1/_lib/proxy/session-guard.ts @@ -48,8 +48,11 @@ export class ProxySessionGuard { try { // 1. 尝试从客户端提取 session_id(metadata.session_id) const clientSessionId = - SessionManager.extractClientSessionId(session.request.message) || - session.generateDeterministicSessionId(); + SessionManager.extractClientSessionId( + session.request.message, + session.headers, + session.userAgent + ) || session.generateDeterministicSessionId(); // 2. 获取 messages 数组 const messages = session.getMessages(); diff --git a/src/lib/session-manager.ts b/src/lib/session-manager.ts index 730e6a397..a58c43bf4 100644 --- a/src/lib/session-manager.ts +++ b/src/lib/session-manager.ts @@ -1,6 +1,7 @@ import "server-only"; import crypto from "node:crypto"; +import { extractCodexSessionId } from "@/app/v1/_lib/codex/session-extractor"; import { sanitizeHeaders } from "@/app/v1/_lib/proxy/errors"; import { logger } from "@/lib/logger"; import { normalizeRequestSequence } from "@/lib/utils/request-sequence"; @@ -82,7 +83,26 @@ export class SessionManager { * 1. metadata.user_id (Claude Code 主要方式,格式: "{user}_session_{sessionId}") * 2. metadata.session_id (备选方式) */ - static extractClientSessionId(requestMessage: Record): string | null { + static extractClientSessionId( + requestMessage: Record, + headers?: Headers | null, + userAgent?: string | null + ): string | null { + // Codex 请求:优先尝试从 headers/body 提取稳定的 session_id + if (headers && Array.isArray(requestMessage.input)) { + const result = extractCodexSessionId(headers, requestMessage, userAgent ?? null); + if (result.sessionId) { + logger.trace("SessionManager: Extracted session from Codex request", { + sessionId: result.sessionId, + source: result.source, + isCodexClient: result.isCodexClient, + }); + return result.sessionId; + } + + return null; + } + const metadata = requestMessage.metadata; if (!metadata || typeof metadata !== "object") { return null;