fix(agents): give the terminate denial a ready link to the agent's profile

An agent refused termination relayed the profile path as plain text. The
denial now carries a Markdown link to /<prefix>/agents/<id>, so the agent
can send the person straight to the Terminate control. POST /terminate now
resolves the agent before the board check, so the link names the target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-07 22:32:43 -06:00
1 parent ff9ee74c46
commit 952636e1d2
2 files changed
+13 -6

No files matched your search

@@ -244,6 +244,7 @@ function createDbStub(options: { requireBoardApprovalForNewAgents?: boolean } =
Promise.resolve(resolve([{
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
requireBoardApprovalForNewAgents: options.requireBoardApprovalForNewAgents ?? false,
}])),
),
@@ -1725,7 +1726,7 @@ describe("agent permission routes", () => {
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(res.body.error).toContain(`[the agent's profile](/PAP/agents/${agentId})`);
expect(mockAgentService.terminate).not.toHaveBeenCalled();
expect(mockAgentService.update).not.toHaveBeenCalled();
expect(mockHeartbeatService.cancelInvocationsForAgents).not.toHaveBeenCalled();
+11 -5
View File
@@ -1777,10 +1777,16 @@ 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) {
/**
* Terminating an agent is a board-only governed action. The denial gives
* other actors a ready profile link, so they can send a person to the control.
*/
async function assertBoardCanTerminateAgent(req: Request, target: { id: string; companyId: string }) {
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.");
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies)
.where(eq(companies.id, target.companyId));
const profile = company?.issuePrefix ? `[the agent's profile](/${company.issuePrefix}/agents/${target.id})` : "the agent's profile";
throw forbidden(`Board access required: only a board user can terminate an agent. Ask a person with board access to open ${profile} and choose Terminate from its actions menu.`);
}
async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) {
@@ -5513,7 +5519,7 @@ export function agentRoutes(
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);
if (requestedStatus === "terminated") await assertBoardCanTerminateAgent(req, existing);
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
delete patchData.replaceAdapterConfig;
// The apply-existing flag is not an agent column. The server binds the fixed
@@ -5951,12 +5957,12 @@ export function agentRoutes(
});
router.post("/agents/:id/terminate", async (req, res) => {
assertBoardCanTerminateAgent(req);
const id = req.params.id as string;
const existing = await getAccessibleAgent(req, res, id);
if (!existing) {
return;
}
await assertBoardCanTerminateAgent(req, existing);
const agent = existing.status === "pending_approval"
? (await approvalsSvc.rejectHire(id, req.actor.userId ?? "board"))?.agent