mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(agents): keep agent termination board-only on the PATCH status route
PATCH /api/agents/:id with status "terminated" ran the lifecycle terminate command after only the agent-config update check, so an agent that could configure a peer (or itself) could terminate it after POST /terminate returned 403. Both routes now share one board-only gate whose denial names where a person can terminate the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
031aab4041
commit
fd29465e1a
2 files changed
+33
-1
No files matched your search
@@ -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" };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user