diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index ae05ef3d0da..ca685a17d79 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1708,6 +1708,30 @@ describe("agent permission routes", () => { expect(mockAgentService.terminate).not.toHaveBeenCalled(); }); + it.each([ + ["PATCH status", (base: string) => request(base).patch(`/api/agents/${agentId}`).send({ status: "terminated" })], + ["POST terminate", (base: string) => request(base).post(`/api/agents/${agentId}/terminate`).send({})], + ])("keeps agent termination board-only through %s, even for an agent allowed to configure the target", async (_route, send) => { + mockAgentService.getById.mockResolvedValue({ ...baseAgent, status: "idle" }); + const app = await createApp({ + type: "agent", + agentId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await requestApp(app, send); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("only a board user can terminate an agent"); + expect(res.body.error).toContain("agent's profile"); + expect(mockAgentService.terminate).not.toHaveBeenCalled(); + expect(mockAgentService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelInvocationsForAgents).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("terminates directly when no open hire approval is linked", async () => { const idleAgent = { ...baseAgent, status: "idle" }; const terminatedAgent = { ...baseAgent, status: "terminated" }; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 4dd1f0c8490..8f9b3514ace 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1777,6 +1777,12 @@ export function agentRoutes( }; } + /** Terminating an agent is a board-only governed action; tell other actors where a person can do it. */ + function assertBoardCanTerminateAgent(req: Request) { + if (req.actor.type === "board") return; + throw forbidden("Board access required: only a board user can terminate an agent, using Terminate in the actions menu on the agent's profile."); + } + async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { if (!hasCompanyAccess(req, targetAgent.companyId)) { throw notFound("Agent not found"); @@ -5506,6 +5512,8 @@ export function agentRoutes( const requestedStatus = patchData.status; delete patchData.status; if (requestedStatus !== undefined && !["paused", "idle", "terminated"].includes(String(requestedStatus))) throw badRequest("Use an agent lifecycle command to change status"); + // Termination is board-only and irreversible on every route, not just POST /terminate. + if (requestedStatus === "terminated") assertBoardCanTerminateAgent(req); const replaceAdapterConfig = patchData.replaceAdapterConfig === true; delete patchData.replaceAdapterConfig; // The apply-existing flag is not an agent column. The server binds the fixed @@ -5943,7 +5951,7 @@ export function agentRoutes( }); router.post("/agents/:id/terminate", async (req, res) => { - assertBoard(req); + assertBoardCanTerminateAgent(req); const id = req.params.id as string; const existing = await getAccessibleAgent(req, res, id); if (!existing) {