diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 7520942040c..a556c38430f 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -298,7 +298,7 @@ describe("agent live run routes", () => { mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-1", companyId: "company-1", - executionRunId: "run-1", + executionRunId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", assigneeAgentId: "agent-1", status: "in_progress", }); @@ -329,7 +329,7 @@ describe("agent live run routes", () => { currentStatusUpdatedAt: null, })); mockHeartbeatService.getRunIssueSummary.mockResolvedValue({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "running", invocationSource: "on_demand", triggerDetail: "manual", @@ -346,20 +346,20 @@ describe("agent live run routes", () => { ); mockHeartbeatService.buildRunOutputSilence.mockResolvedValue(null); mockHeartbeatService.getRunLogAccess.mockResolvedValue({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", logStore: "local_file", - logRef: "logs/run-1.ndjson", + logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson", }); mockHeartbeatService.readLog.mockResolvedValue({ - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", store: "local_file", - logRef: "logs/run-1.ndjson", + logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson", content: "chunk", nextOffset: 5, }); mockHeartbeatService.wakeup.mockResolvedValue({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", agentId: "agent-1", status: "queued", @@ -367,7 +367,7 @@ describe("agent live run routes", () => { triggerDetail: "manual", }); mockHeartbeatService.getRun.mockResolvedValue({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", agentId: "agent-1", status: "succeeded", @@ -375,7 +375,7 @@ describe("agent live run routes", () => { mockWorkspaceOperationService.getById.mockResolvedValue({ id: "operation-1", companyId: "company-1", - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", }); mockQueueRuntimeRequestResolution.mockReturnValue({ commandId: "command-resolution-1", @@ -394,6 +394,82 @@ describe("agent live run routes", () => { }); }); + describe("heartbeat run ID validation", () => { + const routes = [ + ["get", ""], + ["post", "/cancel"], + ["post", "/runtime-requests/approval-1/resolve"], + ["post", "/watchdog-decisions"], + ["get", "/provider-trace"], + ["post", "/provider-trace/reproject-workspace-diffs"], + ["post", "/provider-trace/frames/1/reveal"], + ["get", "/provider-trace/download"], + ["delete", "/provider-trace"], + ["get", "/events"], + ["get", "/log"], + ["get", "/workspace-operations"], + ] as const; + + it.each(["undefined", "null", "not-a-uuid", " aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa "])( + "rejects malformed run ID %j before any run lookup", + async (runId) => { + const app = await createApp(); + for (const [method, suffix] of routes) { + const response = await requestApp(app, (url) => + request(url)[method](`/api/heartbeat-runs/${encodeURIComponent(runId)}${suffix}`).send({}), + ); + expect(response.status, `${method} ${suffix}: ${JSON.stringify(response.body)}`).toBe(400); + expect(response.body).toEqual({ error: "Invalid heartbeat run ID" }); + } + expect(mockHeartbeatService.getRun).not.toHaveBeenCalled(); + expect(mockHeartbeatService.getRunLogAccess).not.toHaveBeenCalled(); + expect(mockHeartbeatService.readLog).not.toHaveBeenCalled(); + expect(mockProviderTraceStore.inspect).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }, + ); + + it("accepts uppercase UUIDs without changing the lookup value", async () => { + const runId = "AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA"; + const response = await requestApp(await createApp(), (url) => + request(url).get(`/api/heartbeat-runs/${runId}/log`), + ); + expect(response.status).toBe(200); + expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith(runId); + }); + + it.each(["get", "log"])("keeps missing and cross-company %s lookups indistinguishable", async (kind) => { + const lookup = kind === "log" ? mockHeartbeatService.getRunLogAccess : mockHeartbeatService.getRun; + const suffix = kind === "log" ? "/log" : ""; + const app = await createApp({}, { + type: "board", userId: "test-user", source: "session", companyIds: ["company-1"], + }); + for (const result of [null, { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-2" }]) { + lookup.mockResolvedValueOnce(result); + const response = await requestApp(app, (url) => + request(url).get(`/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa${suffix}`), + ); + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: "Heartbeat run not found" }); + } + expect(lookup).toHaveBeenCalledTimes(2); + expect(mockHeartbeatService.readLog).not.toHaveBeenCalled(); + }); + + it("keeps board and instance-admin checks ahead of malformed-ID validation", async () => { + const app = await createApp({}, { type: "agent", companyId: "company-1", agentId: routeAgentId }); + for (const [method, suffix] of routes.filter(([, suffix]) => + suffix === "/cancel" || suffix === "/runtime-requests/approval-1/resolve" || suffix.startsWith("/provider-trace"), + )) { + const response = await requestApp(app, (url) => + request(url)[method](`/api/heartbeat-runs/undefined${suffix}`).send({}), + ); + expect(response.status, `${method} ${suffix}`).toBe(403); + } + expect(mockHeartbeatService.getRun).not.toHaveBeenCalled(); + }); + }); + it("returns a compact active run payload for issue polling", async () => { const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl).get("/api/issues/pc1a2-1295/active-run"), @@ -402,10 +478,10 @@ describe("agent live run routes", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1295"); expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith( - "run-1", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", ); expect(res.body).toMatchObject({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "running", invocationSource: "on_demand", triggerDetail: "manual", @@ -440,7 +516,7 @@ describe("agent live run routes", () => { issueId: "issue-2", }); mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "running", invocationSource: "on_demand", triggerDetail: "manual", @@ -457,13 +533,13 @@ describe("agent live run routes", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith( - "run-1", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", ); expect( mockHeartbeatService.getActiveRunIssueSummaryForAgent, ).toHaveBeenCalledWith("agent-1"); expect(res.body).toMatchObject({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", issueId: "issue-1", agentId: "agent-1", agentName: "Builder", @@ -487,11 +563,11 @@ describe("agent live run routes", () => { expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockHeartbeatService.decorateActiveRunStatus).toHaveBeenCalledWith( - expect.objectContaining({ id: "run-1", issueId: "issue-1" }), + expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", issueId: "issue-1" }), { companyId: "company-1", issueId: "issue-1" }, ); expect(mockExecutionProjection.executionProjectionForRun).toHaveBeenCalledWith( - expect.anything(), "company-1", "run-1", + expect.anything(), "company-1", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", ); expect(res.body).toMatchObject({ execution: null, @@ -506,18 +582,18 @@ describe("agent live run routes", () => { it("uses narrow run log metadata lookups for log polling", async () => { const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl).get( - "/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64", + "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/log?offset=12&limitBytes=64", ), ); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); expect(mockHeartbeatService.readLog).toHaveBeenCalledWith( { - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", logStore: "local_file", - logRef: "logs/run-1.ndjson", + logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson", }, { offset: 12, @@ -525,9 +601,9 @@ describe("agent live run routes", () => { }, ); expect(res.body).toEqual({ - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", store: "local_file", - logRef: "logs/run-1.ndjson", + logRef: "logs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.ndjson", content: "chunk", nextOffset: 5, }); @@ -557,10 +633,10 @@ describe("agent live run routes", () => { const paths = [ "/api/companies/company-1/heartbeat-runs", "/api/companies/company-1/live-runs", - "/api/heartbeat-runs/run-1", - "/api/heartbeat-runs/run-1/events", - "/api/heartbeat-runs/run-1/log", - "/api/heartbeat-runs/run-1/workspace-operations", + "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/events", + "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/log", + "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/workspace-operations", "/api/workspace-operations/operation-1/log", ]; @@ -1407,7 +1483,7 @@ describe("agent live run routes", () => { it("does not let an ordinary member downgrade a persisted approval into a question", async () => { mockHeartbeatService.getRun.mockResolvedValue({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", agentId: "agent-1", status: "running", @@ -1420,7 +1496,7 @@ describe("agent live run routes", () => { schema: "paperclip.prp.event.v1", eventType: "runtime_request.created", sourceKind: "runner", - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", turnId: "canonical-turn", payload: { request: { @@ -1442,7 +1518,7 @@ describe("agent live run routes", () => { }); const res = await requestApp(app, (baseUrl) => request(baseUrl) - .post("/api/heartbeat-runs/run-1/runtime-requests/approval-1/resolve") + .post("/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/runtime-requests/approval-1/resolve") .send({ requestKind: "runtime", turnId: "attacker-turn", @@ -1455,7 +1531,7 @@ describe("agent live run routes", () => { it("queues an admin resolution with canonical request and actor bindings", async () => { mockHeartbeatService.getRun.mockResolvedValue({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", agentId: "agent-1", status: "running", @@ -1468,7 +1544,7 @@ describe("agent live run routes", () => { schema: "paperclip.prp.event.v1", eventType: "runtime_request.created", sourceKind: "runner", - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", turnId: "canonical-turn", payload: { request: { @@ -1490,7 +1566,7 @@ describe("agent live run routes", () => { }); const res = await requestApp(app, (baseUrl) => request(baseUrl) - .post("/api/heartbeat-runs/run-1/runtime-requests/approval-1/resolve") + .post("/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/runtime-requests/approval-1/resolve") .send({ requestKind: "user_input", turnId: "attacker-turn", @@ -1500,10 +1576,10 @@ describe("agent live run routes", () => { expect(res.status, JSON.stringify(res.body)).toBe(202); expect(mockQueueRuntimeRequestResolution).toHaveBeenCalledWith({ companyId: "company-1", - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", pendingRequest: { companyId: "company-1", - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", requestId: "approval-1", requestKind: "permission_approval", turnId: "canonical-turn", @@ -1519,11 +1595,11 @@ describe("agent live run routes", () => { }); it.each([ - ["get", "/api/companies/company-1/provider-traces?runIds=run-1"], - ["get", "/api/heartbeat-runs/run-1/provider-trace"], - ["post", "/api/heartbeat-runs/run-1/provider-trace/frames/1/reveal"], - ["get", "/api/heartbeat-runs/run-1/provider-trace/download"], - ["delete", "/api/heartbeat-runs/run-1/provider-trace"], + ["get", "/api/companies/company-1/provider-traces?runIds=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], + ["get", "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/provider-trace"], + ["post", "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/provider-trace/frames/1/reveal"], + ["get", "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/provider-trace/download"], + ["delete", "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/provider-trace"], ] as const)( "requires instance administration to %s %s", async (method, path) => { @@ -1551,7 +1627,7 @@ describe("agent live run routes", () => { { schema: "paperclip.provider_trace_metadata.v1", id: "trace-1", - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", status: "complete", provider: "codex", @@ -1568,14 +1644,14 @@ describe("agent live run routes", () => { const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl).get( - "/api/companies/company-1/provider-traces?runIds=run-1", + "/api/companies/company-1/provider-traces?runIds=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", ), ); expect(res.status, JSON.stringify(res.body)).toBe(200); expect(mockProviderTraceStore.listMetadataForRuns).toHaveBeenCalledWith( "company-1", - ["run-1"], + ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"], ); expect(res.body[0]).not.toHaveProperty("rawBase64"); expect(mockLogActivity).toHaveBeenCalledWith( @@ -1600,7 +1676,7 @@ describe("agent live run routes", () => { ], }); const res = await requestApp(await createApp(), (baseUrl) => - request(baseUrl).get("/api/heartbeat-runs/run-1/provider-trace"), + request(baseUrl).get("/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/provider-trace"), ); expect(res.status, JSON.stringify(res.body)).toBe(200); @@ -1634,7 +1710,7 @@ describe("agent live run routes", () => { const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl).post( - "/api/heartbeat-runs/run-1/provider-trace/reproject-workspace-diffs", + "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/provider-trace/reproject-workspace-diffs", ), ); @@ -1644,7 +1720,7 @@ describe("agent live run routes", () => { expect.anything(), expect.objectContaining({ traceId: "trace-1", - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "company-1", agentId: "agent-1", projection, @@ -1688,7 +1764,7 @@ describe("agent live run routes", () => { const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl).post( - "/api/heartbeat-runs/run-1/provider-trace/reproject-workspace-diffs", + "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/provider-trace/reproject-workspace-diffs", ), ); @@ -1711,13 +1787,13 @@ describe("agent live run routes", () => { type: "agent", agentId: "agent-1", companyId: "company-1", - runId: "run-1", + runId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", source: "agent_key", }, ), (baseUrl) => request(baseUrl).post( - "/api/heartbeat-runs/run-1/provider-trace/reproject-workspace-diffs", + "/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/provider-trace/reproject-workspace-diffs", ), ); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index fb76747a1b4..2997c9b9282 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -2058,7 +2058,7 @@ describe.sequential("agent permission routes", () => { it("rejects heartbeat cancellation outside the caller company scope", async () => { mockHeartbeatService.getRun.mockResolvedValue({ - id: "run-1", + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", companyId: "33333333-3333-4333-8333-333333333333", agentId, status: "running", @@ -2072,7 +2072,7 @@ describe.sequential("agent permission routes", () => { companyIds: [companyId], }); - const res = await requestApp(app, (baseUrl) => request(baseUrl).post("/api/heartbeat-runs/run-1/cancel").send({})); + const res = await requestApp(app, (baseUrl) => request(baseUrl).post("/api/heartbeat-runs/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/cancel").send({})); expect(res.status).toBe(404); expect(res.body.error).toBe("Heartbeat run not found"); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index f7b4698803f..83c700df28b 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -912,3 +912,34 @@ describe("openapi routes", () => { expect(codes).toEqual(["200", "401", "403", "404"]); }); }); + + +describe("heartbeat run ID OpenAPI contract", () => { + it("publishes the runtime UUID constraint and 400 response on all agent-router run endpoints", async () => { + const response = await request(createApp()).get("/api/openapi.json"); + expect(response.status).toBe(200); + const paths = response.body.paths; + let checked = 0; + for (const [path, operations] of Object.entries(paths)) { + if (!path.startsWith("/api/heartbeat-runs/{runId}") || path.endsWith("/issues")) continue; + for (const operation of Object.values(operations as Record)) { + const parameter = operation.parameters.find((param: { name: string }) => param.name === "runId"); + expect(parameter.schema.pattern).toEqual(expect.any(String)); + const pattern = new RegExp(parameter.schema.pattern); + for (const id of [ + "aaaaaaaa-aaaa-1aaa-8aaa-aaaaaaaaaaaa", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "AAAAAAAA-AAAA-5AAA-BAAA-AAAAAAAAAAAA", + ]) expect(pattern.test(id), id).toBe(true); + for (const id of [ + "undefined", "not-a-uuid", " aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa ", + "aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa", "aaaaaaaa-aaaa-4aaa-0aaa-aaaaaaaaaaaa", + "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\n", + ]) expect(pattern.test(id), JSON.stringify(id)).toBe(false); + expect(operation.responses["400"]).toBeDefined(); + checked++; + } + } + expect(checked).toBe(12); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 85d9f1a173a..e72f983476a 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -6668,8 +6668,17 @@ export function agentRoutes( }))))); }); - router.get("/heartbeat-runs/:runId", async (req, res) => { + function readHeartbeatRunId(req: Request): string { const runId = req.params.runId as string; + // isUuidLike accepts surrounding whitespace, but PostgreSQL UUID inputs do not. + if (runId !== runId.trim() || !isUuidLike(runId)) { + throw badRequest("Invalid heartbeat run ID"); + } + return runId; + } + + router.get("/heartbeat-runs/:runId", async (req, res) => { + const runId = readHeartbeatRunId(req); const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; @@ -6687,7 +6696,7 @@ export function agentRoutes( router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; // Stamp the cancellation as operator-initiated (this route is board-only). @@ -6719,7 +6728,7 @@ export function agentRoutes( "/heartbeat-runs/:runId/runtime-requests/:requestId/resolve", async (req, res) => { assertBoard(req); - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const requestId = req.params.requestId as string; const existing = await getAccessibleResource( req, @@ -6924,7 +6933,7 @@ export function agentRoutes( ); router.post("/heartbeat-runs/:runId/watchdog-decisions", async (req, res) => { - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; const decision = typeof req.body?.decision === "string" ? req.body.decision : ""; @@ -6957,7 +6966,7 @@ export function agentRoutes( router.get("/heartbeat-runs/:runId/provider-trace", async (req, res) => { assertInstanceAdmin(req); - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const run = await getAccessibleResource( req, res, @@ -6986,7 +6995,7 @@ export function agentRoutes( "/heartbeat-runs/:runId/provider-trace/reproject-workspace-diffs", async (req, res) => { assertBoard(req); - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const run = await getAccessibleResource( req, res, @@ -7045,7 +7054,7 @@ export function agentRoutes( "/heartbeat-runs/:runId/provider-trace/frames/:frameId/reveal", async (req, res) => { assertInstanceAdmin(req); - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const frameId = Number(req.params.frameId); if (!Number.isSafeInteger(frameId) || frameId < 1) { throw badRequest("Invalid provider trace frame id"); @@ -7085,7 +7094,7 @@ export function agentRoutes( "/heartbeat-runs/:runId/provider-trace/download", async (req, res) => { assertInstanceAdmin(req); - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const run = await getAccessibleResource( req, res, @@ -7120,7 +7129,7 @@ export function agentRoutes( router.delete("/heartbeat-runs/:runId/provider-trace", async (req, res) => { assertInstanceAdmin(req); - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const run = await getAccessibleResource( req, res, @@ -7143,7 +7152,7 @@ export function agentRoutes( }); router.get("/heartbeat-runs/:runId/events", async (req, res) => { - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; @@ -7162,7 +7171,7 @@ export function agentRoutes( }); router.get("/heartbeat-runs/:runId/log", async (req, res) => { - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const run = await getAccessibleResource(req, res, heartbeat.getRunLogAccess(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; @@ -7179,7 +7188,7 @@ export function agentRoutes( }); router.get("/heartbeat-runs/:runId/workspace-operations", async (req, res) => { - const runId = req.params.runId as string; + const runId = readHeartbeatRunId(req); const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!run) return; if (!(await assertRunTelemetryReadAllowed(req, res, run.companyId))) return; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 5eac1bc5e35..7f5c5821530 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -621,6 +621,12 @@ const registry = new OpenAPIRegistry(); // ─── Common schemas ────────────────────────────────────────────────────────── +// Match the route's isUuidLike check without its whitespace trimming. Spell +// out both cases because OpenAPI patterns do not carry RegExp flags. +const heartbeatRunIdParamSchema = z.string() + .regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/) + .describe("Heartbeat run UUID; malformed values return 400"); + const ErrorSchema = registry.register("Error", z.object({ error: z.string() })); const responses = { @@ -6549,8 +6555,8 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}", tags: ["runs"], summary: "Get a heartbeat run", - request: { params: z.object({ runId: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, }); registry.registerPath({ @@ -6558,8 +6564,8 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}/cancel", tags: ["runs"], summary: "Cancel a heartbeat run", - request: { params: z.object({ runId: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); registry.registerPath({ @@ -6567,9 +6573,10 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}/provider-trace", tags: ["runs"], summary: "Inspect a redacted provider trace", - request: { params: z.object({ runId: z.string() }) }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, responses: { 200: r.ok(), + 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, @@ -6581,9 +6588,10 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}/provider-trace/reproject-workspace-diffs", tags: ["runs"], summary: "Reproject retained Codex workspace diffs into run events", - request: { params: z.object({ runId: z.string() }) }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, responses: { 200: r.ok(), + 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, @@ -6597,7 +6605,7 @@ registry.registerPath({ summary: "Reveal one exact provider trace frame", request: { params: z.object({ - runId: z.string(), + runId: heartbeatRunIdParamSchema, frameId: z.coerce.number().int().positive(), }), }, @@ -6615,9 +6623,10 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}/provider-trace/download", tags: ["runs"], summary: "Download an exact provider trace as NDJSON", - request: { params: z.object({ runId: z.string() }) }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, responses: { 200: r.ok(), + 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, @@ -6629,9 +6638,10 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}/provider-trace", tags: ["runs"], summary: "Permanently delete a provider trace", - request: { params: z.object({ runId: z.string() }) }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, responses: { 200: r.ok(), + 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, @@ -6774,7 +6784,7 @@ registry.registerPath({ tags: ["runs"], summary: "Resolve a pending Paperclip runner runtime request", request: { - params: z.object({ runId: z.string(), requestId: z.string() }), + params: z.object({ runId: heartbeatRunIdParamSchema, requestId: z.string() }), body: jsonBody( z.object({ turnId: z.string().min(1).max(160), @@ -6824,7 +6834,7 @@ registry.registerPath({ tags: ["runs"], summary: "Submit watchdog decisions for a run", request: { - params: z.object({ runId: z.string() }), + params: z.object({ runId: heartbeatRunIdParamSchema }), body: jsonBody( z.object({ decision: z.enum(["snooze", "continue", "dismissed_false_positive"]), @@ -6834,7 +6844,7 @@ registry.registerPath({ }), ), }, - responses: { 200: r.ok(), 401: r.unauthorized }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); registry.registerPath({ @@ -6842,8 +6852,8 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}/events", tags: ["runs"], summary: "Get events for a heartbeat run", - request: { params: z.object({ runId: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); registry.registerPath({ @@ -6851,8 +6861,8 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}/log", tags: ["runs"], summary: "Get log for a heartbeat run", - request: { params: z.object({ runId: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); registry.registerPath({ @@ -6860,8 +6870,8 @@ registry.registerPath({ path: "/api/heartbeat-runs/{runId}/workspace-operations", tags: ["runs"], summary: "List workspace operations for a run", - request: { params: z.object({ runId: z.string() }) }, - responses: { 200: r.ok(), 401: r.unauthorized }, + request: { params: z.object({ runId: heartbeatRunIdParamSchema }) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); registry.registerPath({