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
36 changes: 35 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,37 @@ function attachLiveSidebandUpstream(
// trackSseForRequestLog(
// export function relaySseWithHeartbeat

const REQUEST_LOG_ID_RESPONSE_HEADER = "x-opencodex-request-id";

function withRequestLogId(response: Response, requestId: string): Response {
const headers = new Headers(response.headers);
headers.set(REQUEST_LOG_ID_RESPONSE_HEADER, requestId);
Comment on lines +445 to +449

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new response-header contract

This adds a user-visible wire contract, but the commit does not update docs-site/, so API clients have no reference describing the header name, its authentication/origin-admission boundary, or that browser callers may read it through CORS. Add it to the canonical Responses API documentation and keep translations non-contradictory.

AGENTS.md reference: src/AGENTS.md:L28-L29

Useful? React with 👍 / 👎.

// A custom `x-` header is not CORS-safelisted, so cross-origin JavaScript gets null from
// `response.headers.get()` even though the header is on the wire. Naming it here is what
// makes the id readable by a browser client — the only caller that needs a correlation id
// it did not send itself.
//
// Appending to whatever `withCors` already set, rather than overwriting, keeps this
// independent of the CORS layer: if the data plane later exposes another header, both
// survive. Duplicate names are harmless, and the header stays absent from responses that
// never reach this wrapper, so no management or rejected-origin response is widened.
const exposed = headers.get("Access-Control-Expose-Headers");
const already = (exposed ?? "")
.split(",")
.some(name => name.trim().toLowerCase() === REQUEST_LOG_ID_RESPONSE_HEADER);
if (!already) {
headers.set(
"Access-Control-Expose-Headers",
exposed ? `${exposed}, ${REQUEST_LOG_ID_RESPONSE_HEADER}` : REQUEST_LOG_ID_RESPONSE_HEADER,
);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

export interface StartServerDeps {
/** Test-only seam; production always initializes its own management credential state. */
managementAuthState?: ManagementAuthState;
Expand Down Expand Up @@ -1426,7 +1457,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
},
});
return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy);
return withRequestLogId(
withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy),
requestId,
);
Comment on lines +1460 to +1463

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return request IDs on capacity rejections

When the active-turn limit is reached, runAdmittedHttpTurn returns the 503 serverBusyResponse without invoking this callback, so an authenticated, origin-admitted POST /v1/responses receives neither x-opencodex-request-id nor a corresponding request-log entry even though the ID was already generated. Apply the identity/logging handoff to the capacity-rejection result as well so callers can correlate this production failure mode.

Useful? React with 👍 / 👎.

});
}

Expand Down
8 changes: 3 additions & 5 deletions src/server/request-log.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import type { ResponsesTerminalStatus } from "../bridge";
import {
classifyError,
Expand Down Expand Up @@ -201,7 +202,6 @@ const requestLog: RequestLogEntry[] = [];
const MAX_LOG_SIZE = 2000;
const requestLogEntryBytes = new WeakMap<RequestLogEntry, number>();
let requestLogBytes = 0;
let requestLogSeq = 0;
/** True after hydrateRequestLogsFromDisk ran once in this process. */
let requestLogsHydratedFromDisk = false;

Expand Down Expand Up @@ -430,9 +430,8 @@ export function addRequestLog(entry: RequestLogEntry) {
}
}

export function nextRequestLogId(timestamp = Date.now()): string {
requestLogSeq = (requestLogSeq % 1_000_000) + 1;
return `ocx-${timestamp.toString(36)}-${requestLogSeq.toString(36)}`;
export function nextRequestLogId(_timestamp = Date.now()): string {
return `ocx-${randomBytes(16).toString("hex")}`;
}

/**
Expand Down Expand Up @@ -1308,6 +1307,5 @@ export function getRequestLogEntries(): RequestLogEntry[] { return requestLog; }
export function clearRequestLogsForTests(): void {
requestLog.length = 0;
requestLogBytes = 0;
requestLogSeq = 0;
requestLogsHydratedFromDisk = false;
}
2 changes: 1 addition & 1 deletion tests/request-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ describe("request log metadata", () => {
});

test("generates compact request ids", () => {
expect(nextRequestLogId(1_700_000_000_000)).toMatch(/^ocx-[a-z0-9]+-[a-z0-9]+$/);
expect(nextRequestLogId(1_700_000_000_000)).toMatch(/^ocx-[a-f0-9]{32}$/);
expect(nextRequestLogId(1_700_000_000_000)).not.toBe(nextRequestLogId(1_700_000_000_000));
});

Expand Down
170 changes: 170 additions & 0 deletions tests/server-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
startServer,
} from "../src/server";
import { clearRequestLogsForTests, getRequestLogEntries } from "../src/server/request-log";
import { readUsageEntries } from "../src/usage/log";
import { handleManagementAPI } from "../src/server/management-api";
import { handleResponses } from "../src/server/responses";
import type { OcxConfig } from "../src/types";
Expand Down Expand Up @@ -167,6 +168,7 @@ type PoolRetryHarness = {
model?: string;
path?: "/v1/responses" | "/v1/responses/compact";
callerBearer?: boolean;
headers?: Record<string, string>;
extraBody?: Record<string, unknown>;
}) => Promise<Response>;
restoreFetch: () => void;
Expand Down Expand Up @@ -314,12 +316,14 @@ async function startPoolRetryHarness(
model = POOL_RETRY_MODEL,
path = "/v1/responses",
callerBearer = true,
headers = {},
extraBody = {},
} = {}) => originalGlobalFetch(new URL(path, server.url), {
method: "POST",
headers: {
"content-type": "application/json",
...(callerBearer ? { authorization: "Bearer inbound-token" } : {}),
...headers,
},
body: JSON.stringify({ model, input: path.endsWith("/compact") ? [] : "hello", stream, ...extraBody }),
signal,
Expand Down Expand Up @@ -347,6 +351,172 @@ async function expectOriginal400(response: Response, body: string): Promise<void
expect(await response.text()).toBe(body);
}

describe("Responses request identity handoff", () => {
test("returns the generated request id and overwrites an upstream value", async () => {
const harness = await startPoolRetryHarness(() => Response.json({
id: "resp_request_identity",
object: "response",
status: "completed",
output: [],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}, {
headers: { "x-opencodex-request-id": "upstream-spoofed-value" },
}), { secondAccount: false });
try {
const response = await harness.request({
headers: { "x-opencodex-request-id": "caller-injected-value" },
});
const requestId = response.headers.get("x-opencodex-request-id");
expect(requestId).toMatch(/^ocx-[a-f0-9]{32}$/);
expect(requestId).not.toBe("upstream-spoofed-value");
expect(requestId).not.toBe("caller-injected-value");
await response.text();
expect(getRequestLogEntries().filter(entry => entry.requestId === requestId)).toHaveLength(1);
expect(readUsageEntries().filter(entry => entry.requestId === requestId)).toHaveLength(1);
} finally {
await stopPoolRetryHarness(harness);
}
}, { timeout: SERVER_BUDGET_MS });

test("names the request id in Access-Control-Expose-Headers so browser JS can read it", async () => {
const harness = await startPoolRetryHarness(() => Response.json({
id: "resp_request_identity_expose",
object: "response",
status: "completed",
output: [],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}), { secondAccount: false });
try {
const response = await harness.request();
const requestId = response.headers.get("x-opencodex-request-id");
expect(requestId).toMatch(/^ocx-[a-f0-9]{32}$/);

// The header being present above is not enough: cross-origin JavaScript may read only
// the CORS-safelisted response headers plus whatever the expose-list names, so without
// this the id ships on every response and no browser caller can ever see it.
const exposed = (response.headers.get("Access-Control-Expose-Headers") ?? "")
.split(",")
.map(name => name.trim().toLowerCase());
expect(exposed).toContain("x-opencodex-request-id");
await response.text();
} finally {
await stopPoolRetryHarness(harness);
}
}, { timeout: SERVER_BUDGET_MS });

test("binds the same generated request id on a streaming terminal", async () => {
let releaseTerminal!: () => void;
let terminalReleased = false;
const terminalGate = new Promise<void>(resolve => {
releaseTerminal = () => {
terminalReleased = true;
resolve();
};
});
const createdPayload = JSON.stringify({
type: "response.created",
response: { id: "resp_request_identity_sse", object: "response", status: "in_progress", output: [] },
});
const completedPayload = JSON.stringify({
type: "response.completed",
response: {
id: "resp_request_identity_sse",
object: "response",
status: "completed",
output: [],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
});
const encoder = new TextEncoder();
const harness = await startPoolRetryHarness(() => new Response(
new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(`event: response.created\ndata: ${createdPayload}\n\n`));
await terminalGate;
controller.enqueue(encoder.encode(`event: response.completed\ndata: ${completedPayload}\n\n`));
controller.close();
},
}),
{ headers: { "content-type": "text/event-stream", "x-opencodex-request-id": "upstream-spoofed-value" } },
), { secondAccount: false });
try {
const response = await harness.request({ stream: true });
const requestId = response.headers.get("x-opencodex-request-id");
expect(requestId).toMatch(/^ocx-[a-f0-9]{32}$/);
expect(requestId).not.toBe("upstream-spoofed-value");
const reader = response.body!.getReader();
const first = await reader.read();
expect(new TextDecoder().decode(first.value)).toContain("response.created");
expect(terminalReleased).toBe(false);
releaseTerminal();
while (!(await reader.read()).done) { /* drain */ }
expect(getRequestLogEntries().filter(entry => entry.requestId === requestId)).toHaveLength(1);
expect(readUsageEntries().filter(entry => entry.requestId === requestId)).toHaveLength(1);
} finally {
await stopPoolRetryHarness(harness);
}
}, { timeout: SERVER_BUDGET_MS });

test("binds the same generated request id on an upstream error", async () => {
const harness = await startPoolRetryHarness(() => Response.json(
{ error: { type: "upstream_error", message: "bounded test error" } },
{
status: 503,
headers: { "x-opencodex-request-id": "upstream-spoofed-value" },
},
), { secondAccount: false });
try {
const response = await harness.request();
const requestId = response.headers.get("x-opencodex-request-id");
expect(requestId).toMatch(/^ocx-[a-f0-9]{32}$/);
expect(requestId).not.toBe("upstream-spoofed-value");
await response.text();
expect(getRequestLogEntries().filter(entry => entry.requestId === requestId)).toHaveLength(1);
expect(readUsageEntries().filter(entry => entry.requestId === requestId)).toHaveLength(1);
} finally {
await stopPoolRetryHarness(harness);
}
}, { timeout: SERVER_BUDGET_MS });

test("does not issue a request id before authentication and origin admission", async () => {
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
mkdirSync(TEST_DIR, { recursive: true });
process.env.OPENCODEX_HOME = TEST_DIR;
process.env.OPENCODEX_API_AUTH_TOKEN = "local-secret";
clearRequestLogsForTests();
saveConfig({ ...config("0.0.0.0"), port: 0 });

const server = startServer(0);
const url = `http://127.0.0.1:${server.port}/v1/responses`;
const body = JSON.stringify({ model: "gpt-test", input: "hello" });
try {
const missingAuth = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body,
});
expect(missingAuth.status).toBe(401);
expect(missingAuth.headers.get("x-opencodex-request-id")).toBeNull();

const rejectedOrigin = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-opencodex-api-key": "local-secret",
origin: "https://attacker.test",
},
body,
});
expect(rejectedOrigin.status).toBe(403);
expect(rejectedOrigin.headers.get("x-opencodex-request-id")).toBeNull();
expect(getRequestLogEntries()).toHaveLength(0);
expect(readUsageEntries()).toHaveLength(0);
} finally {
await server.stop(true);
}
}, { timeout: SERVER_BUDGET_MS });
});

describe("server local API auth", () => {
test("responses timeout helper disables Bun request timeout when available", () => {
const req = new Request("http://localhost/v1/responses", { method: "POST" });
Expand Down
Loading