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
35 changes: 1 addition & 34 deletions src/app/v1/_lib/codex/session-completer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -33,14 +32,6 @@ type CompleteArgs = {
userAgent: string | null;
};

type CodexMetadata = Record<string, unknown>;

function parseMetadata(requestBody: Record<string, unknown>): 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;
Expand Down Expand Up @@ -213,20 +204,6 @@ async function getOrCreateSessionIdFromFingerprint(
}
}

function ensureMetadataSessionId(requestBody: Record<string, unknown>, 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)
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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";
}
Expand Down
149 changes: 145 additions & 4 deletions tests/unit/codex/session-completer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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"
Expand Down
Loading