This commit is contained in:
Michel Tomas 2026-09-13 10:36:24 +00:00 committed by GitHub
commit 9e20d6907c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 113 additions and 6 deletions

View File

@ -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<Record<string, string>> = [];
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 {

View File

@ -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 <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");
@ -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;