From 176f888ac9dcb57102b485c75ad55668610dc5ab Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Thu, 3 Sep 2026 20:11:27 +0200 Subject: [PATCH 1/3] fix(adapter-utils): accept the bridge token in X-API-Key The in-sandbox callback gateway read the bridge token only from "Authorization: Bearer ". The public Paperclip API documents the "X-API-Key: " header, so an agent that follows those docs sends that header first and gets a 401 with "Invalid bridge token." The agent then retries with a bearer header and the same token. Both gateways now read the token through one helper. The helper prefers a bearer token in the Authorization header. It falls back to the X-API-Key header. The token still goes to the same constant-time comparison, and the closed header allowlist still strips both headers before either gateway forwards a request. Claude-Session: https://claude.ai/code/session_01U9PF3d9SASC9tomDRjyeVt --- .../src/sandbox-callback-bridge.test.ts | 90 +++++++++++++++++++ .../src/sandbox-callback-bridge.ts | 24 ++++- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 6fdb44805b..3f35ac7223 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -265,6 +265,41 @@ describe("sandbox callback bridge", () => { expect(seenRequests[0]?.headers.authorization).toBeUndefined(); expect(seenRequests[0]?.headers["x-paperclip-run-id"]).toBeUndefined(); + // The public Paperclip API documents the "X-API-Key" header, so an agent in + // the sandbox sends that header before it sends "Authorization: Bearer". + // The gateway accepts the same per-run token through either header. + const apiKeyResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, { + headers: { + "x-api-key": bridgeToken, + accept: "application/json", + }, + }); + expect(apiKeyResponse.status).toBe(200); + await expect(apiKeyResponse.json()).resolves.toMatchObject({ + ok: true, + method: "GET", + path: "/api/agents/me", + }); + + // A wrong token in the same header still fails closed. + const apiKeyDeniedResponse = await fetch(`${bridge.baseUrl}/api/agents/me`, { + headers: { + "x-api-key": "wrong-token", + }, + }); + expect(apiKeyDeniedResponse.status).toBe(401); + await expect(apiKeyDeniedResponse.json()).resolves.toMatchObject({ + error: "Invalid bridge token.", + }); + + // The header allowlist strips the credential header, so the token never + // leaves the sandbox with the forwarded request. + expect(seenRequests).toHaveLength(2); + expect(seenRequests[1]).toMatchObject({ + method: "GET", + path: "/api/agents/me", + }); + expect(seenRequests[1]?.headers["x-api-key"]).toBeUndefined(); }); it("denies non-allowlisted requests by default", async () => { @@ -3434,4 +3469,59 @@ describe("sandbox callback bridge", () => { expect(typeof seenRequests[0]?.body).toBe("string"); expect(seenRequests[0]?.body).toBe(requestBodyText); }); + + it("accepts the bridge token in X-API-Key on the HTTP/2 path and strips the header", async () => { + // Both generated gateways share one token reader, so the HTTP/2 transport + // must accept "X-API-Key" exactly as the file-mode transport does. The + // header allowlist must still strip the credential before the gateway + // forwards the request to the host. + const bridgeToken = createSandboxCallbackBridgeToken(); + const seenHeaders: Array> = []; + const gateway = await startHttp2GatewayForTest({ + bridgeToken, + forwardRequest: async (request) => { + seenHeaders.push(request.headers); + return { + status: 200, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ok: true }), + }; + }, + }); + + const response = await fetch(`${gateway.baseUrl}/api/agents/me`, { + headers: { + "x-api-key": bridgeToken, + accept: "application/json", + }, + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ ok: true }); + expect(seenHeaders).toHaveLength(1); + expect(seenHeaders[0]?.["x-api-key"]).toBeUndefined(); + + const deniedResponse = await fetch(`${gateway.baseUrl}/api/agents/me`, { + headers: { + "x-api-key": "wrong-token", + }, + }); + expect(deniedResponse.status).toBe(401); + await expect(deniedResponse.json()).resolves.toMatchObject({ + error: "Invalid bridge token.", + }); + // The rejected request never opened a stream to the host. + expect(seenHeaders).toHaveLength(1); + + // A bearer token still works and still wins when both headers are present. + const bearerResponse = await fetch(`${gateway.baseUrl}/api/agents/me`, { + headers: { + authorization: `Bearer ${bridgeToken}`, + "x-api-key": "wrong-token", + }, + }); + expect(bearerResponse.status).toBe(200); + expect(seenHeaders).toHaveLength(2); + expect(seenHeaders[1]?.authorization).toBeUndefined(); + expect(seenHeaders[1]?.["x-api-key"]).toBeUndefined(); + }, 15_000); }); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 2ca6a8dec6..f48cf93ec0 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -2158,6 +2158,24 @@ function tokensMatch(received) { return timingSafeEqual(expected, actual); } +// Read the bridge token a local caller presented. The gateway accepts two +// header conventions for the same token. "Authorization: Bearer " is the +// bridge convention. "X-API-Key: " is the convention the public Paperclip +// API documents, so an agent that follows those docs sends it first. Both +// headers carry the same per-run token and both reach the same constant-time +// comparison in tokensMatch, so the second header removes a needless 401 without +// widening what the gateway trusts. The header allowlist strips both +// headers before the gateway forwards a request, so neither one leaves the +// sandbox. Authorization wins when a caller presents a bearer token in it. +function readBridgeToken(req) { + const auth = req.headers.authorization || ""; + if (auth.startsWith("Bearer ")) { + return auth.slice("Bearer ".length); + } + const apiKey = req.headers["x-api-key"]; + return typeof apiKey === "string" ? apiKey : ""; +} + function writeJsonResponse(res, status, body) { res.statusCode = status; res.setHeader("content-type", "application/json"); @@ -2209,8 +2227,7 @@ async function runFileGateway() { const server = createServer(async (req, res) => { try { - const auth = req.headers.authorization || ""; - const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : ""; + const receivedToken = readBridgeToken(req); if (!tokensMatch(receivedToken)) { writeJsonResponse(res, 401, { error: "Invalid bridge token." }); return; @@ -2463,8 +2480,7 @@ function runHttp2Gateway() { const server = createServer(async (req, res) => { try { - const auth = req.headers.authorization || ""; - const receivedToken = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : ""; + const receivedToken = readBridgeToken(req); if (!tokensMatch(receivedToken)) { writeJsonResponse(res, 401, { error: "Invalid bridge token." }); return; From 194ae17249fcc44ccda8a7f8e74d530e76328499 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 13 Sep 2026 12:33:53 +0200 Subject: [PATCH 2/3] test(adapter-utils): use binary HTTP/2 response body --- packages/adapter-utils/src/sandbox-callback-bridge.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 2222363199..4656c27c3a 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -605,7 +605,7 @@ describe("sandbox callback bridge", () => { return { status: 200, headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true }), + body: Buffer.from(JSON.stringify({ ok: true }), "utf8"), }; }, }); From 2b67242541ea01b60f2af0721c93c553402f3a85 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Sun, 13 Sep 2026 12:36:19 +0200 Subject: [PATCH 3/3] test(adapter-utils): keep queue response body textual --- packages/adapter-utils/src/sandbox-callback-bridge.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 4656c27c3a..2485a2ab38 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -605,7 +605,7 @@ describe("sandbox callback bridge", () => { return { status: 200, headers: { "content-type": "application/json" }, - body: Buffer.from(JSON.stringify({ ok: true }), "utf8"), + body: JSON.stringify({ ok: true }), }; }, }); @@ -3746,7 +3746,7 @@ describe("sandbox callback bridge", () => { return { status: 200, headers: { "content-type": "application/json" }, - body: JSON.stringify({ ok: true }), + body: Buffer.from(JSON.stringify({ ok: true }), "utf8"), }; }, });