fix(adapter-utils): accept the bridge token in X-API-Key
The in-sandbox callback gateway read the bridge token only from "Authorization: Bearer <token>". The public Paperclip API documents the "X-API-Key: <token>" 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
This commit is contained in:
parent
06c0e883fa
commit
176f888ac9
|
|
@ -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<Record<string, string>> = [];
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 <token>" is the
|
||||
// bridge convention. "X-API-Key: <token>" 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue