diff --git a/src/app/v1/_lib/codex/session-completer.ts b/src/app/v1/_lib/codex/session-completer.ts index c146a9b8f..d6fceaa6d 100644 --- a/src/app/v1/_lib/codex/session-completer.ts +++ b/src/app/v1/_lib/codex/session-completer.ts @@ -15,7 +15,6 @@ export type CodexSessionCompletionSource = | "header_session_id" | "header_x_session_id" | "body_prompt_cache_key" - | "body_metadata_session_id" | "fingerprint_cache" | "generated_uuid_v7"; @@ -33,14 +32,6 @@ type CompleteArgs = { userAgent: string | null; }; -type CodexMetadata = Record; - -function parseMetadata(requestBody: Record): CodexMetadata | null { - const metadata = requestBody.metadata; - if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null; - return metadata as CodexMetadata; -} - function getSessionTtlSeconds(): number { const raw = process.env.SESSION_TTL; const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; @@ -213,20 +204,6 @@ async function getOrCreateSessionIdFromFingerprint( } } -function ensureMetadataSessionId(requestBody: Record, sessionId: string): void { - const metadata = parseMetadata(requestBody); - if (metadata) { - metadata.session_id = sessionId; - requestBody.metadata = metadata; - return; - } - - // Only create metadata when the current value is missing. - if (requestBody.metadata === undefined) { - requestBody.metadata = { session_id: sessionId } satisfies CodexMetadata; - } -} - /** * Ensure Codex session identifiers exist in both: * - Header: `session_id` (+ `x-session-id` for compatibility) @@ -242,8 +219,6 @@ export async function completeCodexSessionIdentifiers( const headerSessionId = normalizeCodexSessionId(args.headers.get("session_id")); const headerXSessionId = normalizeCodexSessionId(args.headers.get("x-session-id")); const bodyPromptCacheKey = normalizeCodexSessionId(args.requestBody.prompt_cache_key); - const metadata = parseMetadata(args.requestBody); - const bodyMetadataSessionId = metadata ? normalizeCodexSessionId(metadata.session_id) : null; const missingHeader = !headerSessionId && !headerXSessionId; const missingBody = !bodyPromptCacheKey; @@ -255,9 +230,7 @@ export async function completeCodexSessionIdentifiers( ? { sessionId: headerXSessionId, source: "header_x_session_id" } : bodyPromptCacheKey ? { sessionId: bodyPromptCacheKey, source: "body_prompt_cache_key" } - : bodyMetadataSessionId - ? { sessionId: bodyMetadataSessionId, source: "body_metadata_session_id" } - : null; + : null; // Both required fields present: keep as-is (idempotent) // Note: x-session-id is treated as a compatibility header and does not satisfy the requirement @@ -311,12 +284,6 @@ export async function completeCodexSessionIdentifiers( changedHeaderOrBody = true; } - // Only touch metadata when we have applied completion/generation. - if (applied && (!bodyMetadataSessionId || bodyMetadataSessionId !== sessionId)) { - ensureMetadataSessionId(args.requestBody, sessionId); - applied = true; - } - if (existing) { action = changedHeaderOrBody ? "completed_missing_fields" : "none"; } diff --git a/tests/unit/codex/session-completer.test.ts b/tests/unit/codex/session-completer.test.ts index 35bc9d66b..311c81bc2 100644 --- a/tests/unit/codex/session-completer.test.ts +++ b/tests/unit/codex/session-completer.test.ts @@ -88,6 +88,7 @@ describe("Codex session completer", () => { expect(result.applied).toBe(true); expect(result.sessionId).toBe(sessionId); expect(body.prompt_cache_key).toBe(sessionId); + expect(body.metadata).toBeUndefined(); expect(headers.get("session_id")).toBe(sessionId); }); @@ -111,6 +112,7 @@ describe("Codex session completer", () => { expect(result.sessionId).toBe(promptCacheKey); expect(headers.get("session_id")).toBe(promptCacheKey); expect(body.prompt_cache_key).toBe(promptCacheKey); + expect(body.metadata).toBeUndefined(); }); test("no-op when both session_id and prompt_cache_key already exist", async () => { @@ -155,6 +157,7 @@ describe("Codex session completer", () => { expect(result.sessionId).toMatch(UUID_V7_PATTERN); expect(headers.get("session_id")).toBe(result.sessionId); expect(body.prompt_cache_key).toBe(result.sessionId); + expect(body.metadata).toBeUndefined(); }); test("reuses the same generated session id for the same fingerprint when Redis is available", async () => { @@ -211,6 +214,7 @@ describe("Codex session completer", () => { expect(headers.get("session_id")).toBe(xSessionId); expect(headers.get("x-session-id")).toBe(xSessionId); expect(body.prompt_cache_key).toBe(xSessionId); + expect(body.metadata).toBeUndefined(); }); test("completes canonical session_id when x-session-id and prompt_cache_key are provided", async () => { @@ -235,17 +239,19 @@ describe("Codex session completer", () => { expect(headers.get("session_id")).toBe(xSessionId); expect(headers.get("x-session-id")).toBe(xSessionId); expect(body.prompt_cache_key).toBe(xSessionId); + expect(body.metadata).toBeUndefined(); }); - test("updates metadata.session_id in-place when metadata exists", async () => { + test("does not mutate metadata when metadata exists (metadata is not allowed for Codex upstream)", async () => { const { completeCodexSessionIdentifiers } = await import( "@/app/v1/_lib/codex/session-completer" ); const sessionId = "sess_123456789012345678903"; const headers = new Headers({ session_id: sessionId }); + const metadata = { session_id: "sess_aaaaaaaaaaaaaaaaaaaaa", other: "value" }; const body = makeCodexRequestBody({ - metadata: { session_id: "sess_aaaaaaaaaaaaaaaaaaaaa", other: "value" }, + metadata, }); const result = await completeCodexSessionIdentifiers({ @@ -257,8 +263,7 @@ describe("Codex session completer", () => { expect(result.applied).toBe(true); expect(result.action).toBe("completed_missing_fields"); - expect((body.metadata as any).session_id).toBe(sessionId); - expect((body.metadata as any).other).toBe("value"); + expect(body.metadata).toEqual(metadata); }); test("uses x-real-ip when x-forwarded-for is absent (fingerprint stability)", async () => { @@ -371,6 +376,142 @@ describe("Codex session completer", () => { expect(result.sessionId).toBe(existing); }); + test("fingerprint treats empty input as unknown and still reuses stable session id", async () => { + const { completeCodexSessionIdentifiers } = await import( + "@/app/v1/_lib/codex/session-completer" + ); + + const { client: fakeRedis } = makeFakeRedis(); + redisClientRef = fakeRedis; + + const headers = new Headers({ + "x-forwarded-for": "203.0.113.77", + "user-agent": "codex_cli_rs/0.50.0", + }); + + const first = await completeCodexSessionIdentifiers({ + keyId: 42, + headers: new Headers(headers), + requestBody: makeCodexRequestBody({ input: [] }), + userAgent: "codex_cli_rs/0.50.0", + }); + + const second = await completeCodexSessionIdentifiers({ + keyId: 42, + headers: new Headers(headers), + requestBody: makeCodexRequestBody({ input: [] }), + userAgent: "codex_cli_rs/0.50.0", + }); + + expect(first.action).toBe("generated_uuid_v7"); + expect(second.action).toBe("reused_fingerprint_cache"); + expect(first.sessionId).toBe(second.sessionId); + }); + + test("fingerprint only uses first 3 message texts (extra messages do not affect reuse)", async () => { + const { completeCodexSessionIdentifiers } = await import( + "@/app/v1/_lib/codex/session-completer" + ); + + const { client: fakeRedis } = makeFakeRedis(); + redisClientRef = fakeRedis; + + const headers = new Headers({ + "x-forwarded-for": "203.0.113.88", + "user-agent": "codex_cli_rs/0.50.0", + }); + + const firstBody = makeCodexRequestBody({ + input: [ + { type: "message", role: "user", content: "m1" }, + { type: "message", role: "user", content: "m2" }, + { type: "message", role: "user", content: "m3" }, + { type: "message", role: "user", content: "m4-first" }, + ], + }); + + const secondBody = makeCodexRequestBody({ + input: [ + { type: "message", role: "user", content: "m1" }, + { type: "message", role: "user", content: "m2" }, + { type: "message", role: "user", content: "m3" }, + { type: "message", role: "user", content: "m4-changed" }, + ], + }); + + const first = await completeCodexSessionIdentifiers({ + keyId: 43, + headers: new Headers(headers), + requestBody: firstBody, + userAgent: "codex_cli_rs/0.50.0", + }); + + const second = await completeCodexSessionIdentifiers({ + keyId: 43, + headers: new Headers(headers), + requestBody: secondBody, + userAgent: "codex_cli_rs/0.50.0", + }); + + expect(first.action).toBe("generated_uuid_v7"); + expect(second.action).toBe("reused_fingerprint_cache"); + expect(first.sessionId).toBe(second.sessionId); + }); + + test("handles Redis NX fallback by setting without NX when second read is still missing", async () => { + const { completeCodexSessionIdentifiers } = await import( + "@/app/v1/_lib/codex/session-completer" + ); + + redisClientRef = { + status: "ready", + get: vi.fn(async () => null), + set: vi.fn(async (_key: string, _value: string, _ex: string, _ttl: number, nx?: string) => { + if (nx === "NX") return null; + return "OK"; + }), + }; + + const result = await completeCodexSessionIdentifiers({ + keyId: 1, + headers: new Headers({ "x-forwarded-for": "203.0.113.10" }), + requestBody: makeCodexRequestBody(), + userAgent: "codex_cli_rs/0.50.0", + }); + + expect(result.action).toBe("generated_uuid_v7"); + expect(result.sessionId).toMatch(UUID_V7_PATTERN); + expect(redisClientRef.set).toHaveBeenCalledTimes(2); + expect(redisClientRef.set.mock.calls[0]?.[4]).toBe("NX"); + expect(redisClientRef.set.mock.calls[1]?.[4]).toBeUndefined(); + }); + + test("falls back to UUID v7 when Redis throws", async () => { + const { completeCodexSessionIdentifiers } = await import( + "@/app/v1/_lib/codex/session-completer" + ); + const { logger } = await import("@/lib/logger"); + + redisClientRef = { + status: "ready", + get: vi.fn(async () => { + throw new Error("boom"); + }), + set: vi.fn(async () => "OK"), + }; + + const result = await completeCodexSessionIdentifiers({ + keyId: 1, + headers: new Headers({ "x-forwarded-for": "203.0.113.10" }), + requestBody: makeCodexRequestBody(), + userAgent: "codex_cli_rs/0.50.0", + }); + + expect(result.action).toBe("generated_uuid_v7"); + expect(result.sessionId).toMatch(UUID_V7_PATTERN); + expect(logger.warn).toHaveBeenCalled(); + }); + test("uses SESSION_TTL when it is a valid integer", async () => { const { completeCodexSessionIdentifiers } = await import( "@/app/v1/_lib/codex/session-completer"