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
9 changes: 7 additions & 2 deletions src/frontdoor/headroomHop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ describe("the headroom hop", () => {
}
});

it("re-sets the project identity and the session attribution id on the hop, replacing any inbound copy, and forwards no launcher session header beyond it", async () => {
it("re-sets the project identity on the hop, replacing any inbound copy, and never forwards headroom's own session header, which opts a session into mid-turn queueing", async () => {
const headroom = await fakeHeadroom();
const door = await startDoor({ files: { [`${PROVIDERS_DIR}/codex.json`]: codexProvider } });
try {
Expand All @@ -364,7 +364,12 @@ describe("the headroom hop", () => {
});
const seen = headroom.seen()[0];
expect(seen?.headers["x-headroom-project-id"]).toBe("/repo");
expect(seen?.headers["x-headroom-session-id"]).toBe("session-1");
expect(seen?.headers["x-headroom-session-id"]).toBeUndefined();
// The client sent no session header of its own, so the launch's id stands in for it under Claude Code's name.
expect(seen?.headers["x-claude-code-session-id"]).toBe("session-1");
// A client that identifies itself keeps its own id.
await fetch(`${door.url}/providers/codex/v1/messages`, { dispatcher: trusting, method: "POST", headers: { "content-type": "application/json", [SESSION_HEADER]: "session-1", [HEADROOM_FLAG_HEADER]: "1", [AUTH_HEADER]: LAUNCH_TOKEN, "x-claude-code-session-id": "the-clients-own" }, body: MESSAGES_BODY });
expect(headroom.seen()[1]?.headers["x-claude-code-session-id"]).toBe("the-clients-own");
expect(seen?.headers[IDENTITY_HEADER]).toBeUndefined();
expect(seen?.headers[SESSION_HEADER]).toBeUndefined();
} finally {
Expand Down
7 changes: 4 additions & 3 deletions src/frontdoor/headroomHop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { HTTP_STATUS } from "../codex/http";
import type { HeadroomSocketTarget } from "../headroom/socket";
import { forwardableHeaders } from "./connect";
import type { CredentialCustody } from "./custody";
import { HEADROOM_BASE_URL_HEADER, HOP_ID_HEADER, HOP_SECRET_HEADER, PROJECT_ID_HEADER, PROVIDER_HEADER, SESSION_ATTRIBUTION_HEADER, parseProviderPath, type RoutedRequest, type RoutedResponse } from "./route";
import { HEADROOM_BASE_URL_HEADER, HOP_ID_HEADER, HOP_SECRET_HEADER, CLIENT_SESSION_HEADER, PROJECT_ID_HEADER, PROVIDER_HEADER, parseProviderPath, type RoutedRequest, type RoutedResponse } from "./route";
import { lineageOf, type Lineage } from "./lineage";
import { upstreamChunks } from "./server";

Expand Down Expand Up @@ -82,8 +82,9 @@ export async function applyHeadroomHop(request: RoutedRequest, response: RoutedR
if (request.session.projectId !== undefined) {
headers[PROJECT_ID_HEADER] = request.session.projectId;
}
if (request.session.sessionId !== undefined) {
headers[SESSION_ATTRIBUTION_HEADER] = request.session.sessionId;
// The client's own session header wins at headroom and is kept as it is. A client that sends none is attributed from the launch's id under that same header, never under headroom's own session header: that one opts the session into mid-turn queueing, which answers a concurrent streaming request with a 202 JSON body the client cannot parse as a stream.
if (request.session.sessionId !== undefined && !Object.keys(headers).some((name) => name.toLowerCase() === CLIENT_SESSION_HEADER)) {
headers[CLIENT_SESSION_HEADER] = request.session.sessionId;
}
headers[HOP_SECRET_HEADER] = deps.hopSecret;
try {
Expand Down
11 changes: 8 additions & 3 deletions src/frontdoor/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { Readable } from "node:stream";
import type { IncomingHttpHeaders } from "node:http";

/**
* Headroom's own session header, which the door never sends and strips from every inbound request. Headroom reads it as an opt-in to mid-turn queueing: while one stream is active for a session, another streaming request carrying it is answered `202 {"event":"headroom_queued"}` without reaching the upstream, which a client expecting an event stream reports as "Streaming response ended before any complete data was received". Nothing a client sends may choose it.
*/
const HEADROOM_SESSION_HEADER = "x-headroom-session-id";

/**
* The agent-shim-internal headers the launcher injects through `ANTHROPIC_CUSTOM_HEADERS` so the front door can identify and route a request's session. None of them may ever leave the machine: the identity step strips every one before a route sees the request, so no upstream, and not even the headroom hop, learns what they say.
*/
Expand All @@ -13,7 +18,7 @@ export const INTERNAL_HEADER_NAMES = [
"x-agent-shim-hop",
"x-agent-shim-hop-id",
"x-headroom-project-id",
"x-headroom-session-id",
HEADROOM_SESSION_HEADER,
"x-headroom-base-url",
] as const;

Expand All @@ -35,9 +40,9 @@ export const HEADROOM_FLAG_HEADER = "x-agent-shim-headroom";
export const PROJECT_ID_HEADER = "x-headroom-project-id";

/**
* Headroom's per-session attribution id, the key its `/stats/sessions/<id>` serves: the daemon attributes a request only when it carries this header (or the client's own `x-claude-code-session-id`, which no routed client sends). Re-set by the headroom hop from the launch's own session id, so the daemon attributes traffic the client itself never identifies; inbound copies are stripped at the identity step like every other internal header.
* The session id headroom attributes savings to and keys its per-session state by, and the one it prefers over its own header: Claude Code stamps every request with it. The hop fills it from the launch's own session id only when the client sent none, so traffic from a client that does not identify itself is still attributed, without opting the session into queueing.
*/
export const SESSION_ATTRIBUTION_HEADER = "x-headroom-session-id";
export const CLIENT_SESSION_HEADER = "x-claude-code-session-id";

/**
* Headroom's per-request upstream selector, which the child used to carry and the door now owns: the door strips any inbound copy (a leftover from an older launcher's environment would otherwise redirect the daemon behind the door's back) and its hop sets it fresh when a route names an upstream.
Expand Down