diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index bbb0c2a9da2..eed083b573f 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -34,6 +34,7 @@ const mockAgentService = vi.hoisted(() => ({ getById: vi.fn(), list: vi.fn(), create: vi.fn(), + activatePendingApproval: vi.fn(), updatePermissions: vi.fn(), getChainOfCommand: vi.fn(), resolveByReference: vi.fn(), @@ -166,6 +167,7 @@ describe("agent permission routes", () => { mockAgentService.getChainOfCommand.mockResolvedValue([]); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: baseAgent }); mockAgentService.create.mockResolvedValue(baseAgent); + mockAgentService.activatePendingApproval.mockResolvedValue(baseAgent); mockAgentService.updatePermissions.mockResolvedValue(baseAgent); mockAccessService.getMembership.mockResolvedValue({ id: "membership-1", @@ -523,6 +525,63 @@ describe("agent permission routes", () => { ); }); + it("allows board users to directly approve pending agents", async () => { + const pendingAgent = { + ...baseAgent, + status: "pending_approval", + }; + const approvedAgent = { + ...baseAgent, + status: "idle", + }; + mockAgentService.getById.mockResolvedValue(pendingAgent); + mockAgentService.activatePendingApproval.mockResolvedValue(approvedAgent); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/approve`) + .send({}); + + expect(res.status).toBe(200); + expect(mockAgentService.activatePendingApproval).toHaveBeenCalledWith(agentId); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "user", + actorId: "board-user", + action: "agent.approved", + entityType: "agent", + entityId: agentId, + details: { source: "agent_detail" }, + })); + }); + + it("rejects direct approval for agents that are not pending approval", async () => { + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await request(app) + .post(`/api/agents/${agentId}/approve`) + .send({}); + + expect(res.status).toBe(409); + expect(mockAgentService.activatePendingApproval).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "agent.approved", + })); + }); + it("exposes explicit task assignment access on agent detail", async () => { mockAccessService.listPrincipalGrants.mockResolvedValue([ { diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index f7a6e88be16..d702d469a02 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1539,13 +1539,13 @@ describe("company portability", () => { expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ projectId: "project-created", title: "Monday Review", - assigneeAgentId: null, + assigneeAgentId: "agent-created", priority: "high", status: "paused", concurrencyPolicy: "always_enqueue", catchUpPolicy: "enqueue_missed_with_cap", }), expect.any(Object)); - expect(result.warnings).toContain( + expect(result.warnings).not.toContain( "Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.", ); expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2); @@ -2489,7 +2489,7 @@ describe("company portability", () => { expect(agentSvc.create).not.toHaveBeenCalled(); }); - it("imports new agents through approval and adapter-config normalization", async () => { + it("imports new agents as active while preserving future hire approval settings", async () => { const portability = companyPortabilityService({} as any); const exported = await portability.exportBundle("company-1", { include: { @@ -2549,7 +2549,10 @@ describe("company portability", () => { adapterConfig: expect.objectContaining({ normalized: true, }), - status: "pending_approval", + status: "idle", + })); + expect(companySvc.create).toHaveBeenCalledWith(expect.objectContaining({ + requireBoardApprovalForNewAgents: true, })); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f5e06dd62aa..2ef48962e22 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2098,6 +2098,37 @@ export function agentRoutes(db: Db) { res.json(agent); }); + router.post("/agents/:id/approve", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const existing = await getAccessibleAgent(req, res, id); + if (!existing) { + return; + } + if (existing.status !== "pending_approval") { + res.status(409).json({ error: "Only pending approval agents can be approved" }); + return; + } + + const agent = await svc.activatePendingApproval(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.approved", + entityType: "agent", + entityId: agent.id, + details: { source: "agent_detail" }, + }); + + res.json(agent); + }); + router.post("/agents/:id/terminate", async (req, res) => { assertBoard(req); const id = req.params.id as string; diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 305f6a761b5..26638e757d0 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -4209,13 +4209,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { continue; } - const requiresApproval = - typeof targetCompany.requireBoardApprovalForNewAgents === "boolean" - ? targetCompany.requireBoardApprovalForNewAgents - : include.company - ? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true) - : true; - const createdStatus = requiresApproval ? "pending_approval" : "idle"; + const createdStatus = "idle"; let created = await agents.create(targetCompany.id, { ...patch, status: createdStatus, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index fcd38604209..9f7c8ba87f0 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -146,6 +146,7 @@ export const agentsApi = { ), pause: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/pause"), {}), resume: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/resume"), {}), + approve: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/approve"), {}), terminate: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/terminate"), {}), remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)), listKeys: (id: string, companyId?: string) => api.get(agentPath(id, companyId, "/keys")), diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2777cb07626..378636e9949 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -762,12 +762,13 @@ export function AgentDetail() { }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]); const agentAction = useMutation({ - mutationFn: async (action: "invoke" | "pause" | "resume" | "terminate") => { + mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => { if (!agentLookupRef) return Promise.reject(new Error("No agent reference")); switch (action) { case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined); case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined); case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined); + case "approve": return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined); case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined); } }, @@ -1021,9 +1022,18 @@ export function AgentDetail() { {actionError &&

{actionError}

} {isPendingApproval && ( -

- This agent is pending board approval and cannot be invoked yet. -

+
+ This agent is pending board approval and cannot be invoked yet. + +
)} {/* Floating Save/Cancel (desktop) */}