mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 01:46:15 +02:00
Validate heartbeat run IDs before database lookups (#13657)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators inspect heartbeat runs, logs, and provider traces through the API. > - These routes use UUID database keys. > - A malformed path value such as `undefined` reaches the database and causes a server error. > - This pull request validates run IDs before those lookups. > - Valid requests keep the existing company and permission checks. ## Linked Issues or Issue Description Related: Refs #8135. That proposal guards actor run headers and activity writes; this fix covers heartbeat run path parameters. **What happened?** A request such as `GET /api/heartbeat-runs/undefined` passes a non-UUID value to a UUID lookup and returns a server error. Run logs and the other heartbeat run endpoints have the same unchecked path input. **Expected behavior** Reject malformed run IDs with HTTP 400 before any run lookup. Preserve valid run reads, company isolation, and the existing board and instance-admin checks. **Steps to reproduce** 1. Request a heartbeat run endpoint with `undefined`, `null`, another malformed ID, or a UUID with surrounding whitespace. 2. Observe the database UUID error. 3. Run the route regressions before and after this change. **Paperclip version or commit** Confirmed on master at `6d0342868`. **Deployment mode** The defect affects API deployments backed by PostgreSQL. Regression tests exercise the actual Express routes and authorization code with stubbed services. The historical request does not identify the originating client, so this change does not alter a guessed UI caller. ## What Changed - Share strict run-ID validation across the 12 heartbeat run endpoints in the agent router. - Keep existing board and instance-admin gates ahead of validation. Keep valid-run company and telemetry checks intact. - Reject surrounding whitespace, which the shared UUID helper accepts but PostgreSQL rejects. - Encode the UUID constraint and document the 400 response in OpenAPI. Test the generated parameter pattern on all 12 endpoints. - Cover malformed IDs on every affected endpoint, uppercase UUIDs, missing and cross-company runs, and permission precedence. Use UUID-shaped run fixtures in existing route tests. ## Verification - Before the fix: four malformed-ID regression cases fail; three access-control cases pass. - Focused agent route, permission, cross-company, and OpenAPI suites: 154 tests passed, including the final uppercase-UUID case. - Direct server typecheck passed: `pnpm --filter @paperclipai/server exec tsc --noEmit`. - The full local test attempt is still running; the complete Linux test suite passed in CI. Local results will be recorded when it finishes. - Complete Linux CI passed on the exact head: 53 checks passed, two non-applicable checks skipped. One untouched preview-service readiness test failed initially; its three targeted cases passed locally and the failed-jobs-only CI rerun passed. Greptile scored the final head 5/5 with no unresolved comments. - Full local `pnpm -r typecheck` and `pnpm build` reach the native runner step and stop because `cargo` is absent. The complete Linux CI checks passed. ## Risks Low risk: this changes malformed route inputs to HTTP 400. Valid UUID requests keep their existing lookup and authorization paths. There is no migration, dependency, provider operation, or configuration change. The separate activity router and actor run headers are outside this change. ## Model Used OpenAI GPT-6 through Codex, with code editing, shell execution, and test tools. The exact context window size was not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run focused tests locally and they pass; full-check limits are recorded above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6d03428682
commit
e18ed02a56
5 files changed
+206
-80
No files matched your search
@@ -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",
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, any>)) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+21
-12
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user