From 91076c7bdec66f9d3456f7bb05442b2364956d71 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 24 Feb 2026 18:28:43 +0100 Subject: [PATCH] fix(server): rewrite devhook requests for subpath webhooks correctly --- .../api/src/routes/agent-request.server.ts | 28 +++++++++-- internal/api/src/routes/agent-request.test.ts | 49 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/internal/api/src/routes/agent-request.server.ts b/internal/api/src/routes/agent-request.server.ts index 9a4c8c33..aa7700dc 100644 --- a/internal/api/src/routes/agent-request.server.ts +++ b/internal/api/src/routes/agent-request.server.ts @@ -56,9 +56,31 @@ export default async function handleAgentRequest( const query = await db.selectAgentDeploymentByRequestID(id); if (!query) { // There is no agent for this request, check if it's a dev request. - const response = await c.env.devhook?.handleRequest(id, c.req.raw); - if (response) { - return response; + if (c.env.devhook) { + // Rewrite the URL so the devhook receives just the subpath, not the + // full `/api/webhook/{id}/...` prefix. This mirrors the rewriting + // done for deployed agents below (lines 99-106). + const incomingUrl = new URL(c.req.raw.url); + let devhookPath: string; + if (routing.mode === "webhook") { + devhookPath = routing.subpath || "/"; + } else { + devhookPath = incomingUrl.pathname; + } + const devhookUrl = new URL(devhookPath, incomingUrl.origin); + devhookUrl.search = incomingUrl.search; + + const devhookReq = new Request(devhookUrl.toString(), { + method: c.req.raw.method, + headers: c.req.raw.headers, + body: c.req.raw.body, + // @ts-expect-error - Required for Node.js streaming. + duplex: c.req.raw.body ? "half" : undefined, + }); + const response = await c.env.devhook.handleRequest(id, devhookReq); + if (response) { + return response; + } } return c.json({ message: "No agent exists for this webook" }, 404); } diff --git a/internal/api/src/routes/agent-request.test.ts b/internal/api/src/routes/agent-request.test.ts index f41df016..6a4c7a44 100644 --- a/internal/api/src/routes/agent-request.test.ts +++ b/internal/api/src/routes/agent-request.test.ts @@ -603,6 +603,55 @@ describe("subdomain requests", () => { }); }); +describe("devhook URL rewriting", () => { + const fakeId = "00000000-0000-0000-0000-000000000000"; + + test("devhook receives subpath for webhook requests", async () => { + let receivedUrl: string | undefined; + const { url: apiUrl } = await serve({ + bindings: { + devhook: { + handleRequest: async (_id: string, req: Request) => { + receivedUrl = req.url; + return new Response("devhook OK"); + }, + }, + }, + }); + + // Request with subpath /slack — devhook should receive just /slack + const response = await fetch( + `${apiUrl}/api/webhook/${fakeId}/slack?foo=bar` + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("devhook OK"); + + const parsed = new URL(receivedUrl!); + expect(parsed.pathname).toBe("/slack"); + expect(parsed.searchParams.get("foo")).toBe("bar"); + }); + + test("devhook receives / for webhook requests without subpath", async () => { + let receivedUrl: string | undefined; + const { url: apiUrl } = await serve({ + bindings: { + devhook: { + handleRequest: async (_id: string, req: Request) => { + receivedUrl = req.url; + return new Response("devhook OK"); + }, + }, + }, + }); + + const response = await fetch(`${apiUrl}/api/webhook/${fakeId}`); + expect(response.status).toBe(200); + + const parsed = new URL(receivedUrl!); + expect(parsed.pathname).toBe("/"); + }); +}); + describe("Slack verification expiration", () => { test("clears expired slack_verification and skips verification processing", async () => { // Set expiresAt to 1 hour ago (already expired)