Merge 2b67242541 into c9e3bb7ca4
This commit is contained in:
commit
9e20d6907c
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue