diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 5bac8e218b..2485a2ab38 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -267,6 +267,42 @@ 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(); + const githubResponse = await fetch(`${bridge.baseUrl}/runtime-tools/github/credentials`, { method: "POST", headers: { @@ -278,11 +314,11 @@ describe("sandbox callback bridge", () => { }); expect(githubResponse.status).toBe(200); await githubResponse.arrayBuffer(); - expect(seenRequests[1]).toMatchObject({ + expect(seenRequests[2]).toMatchObject({ method: "POST", path: "/runtime-tools/github/credentials", body: "{}", headers: { "x-paperclip-github-capability": "test-run-scoped-capability" }, }); - expect(seenRequests[1]?.headers.authorization).toBeUndefined(); + expect(seenRequests[2]?.headers.authorization).toBeUndefined(); }); @@ -3695,6 +3731,61 @@ 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: Buffer.from(JSON.stringify({ ok: true }), "utf8"), + }; + }, + }); + + 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); }); interface EmbeddedBridgeProcessBodyLedger { diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 05f27e3fa9..6b65c360dd 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -2292,6 +2292,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"); @@ -2349,8 +2367,7 @@ async function runFileGateway() { // timeout all reach the same finally. let releaseBodyReservation = null; 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; @@ -2616,8 +2633,7 @@ function runHttp2Gateway() { // deadline timeout all reach the same finally. let releaseBodyReservation = null; 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;