diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 5a4984b1bb..a60bb4bec7 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -558,7 +558,7 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and |---|---|---| | Create company | yes | no | | Hire/create agent | yes (direct) | request via approval | -| Pause/resume agent | yes | no | +| Pause/resume agent | yes | pause: no; resume: direct `agents:configure` grant only | | Create/update task | yes | yes | | Force reassign task | yes | limited | | Approve strategy/hire requests | yes | no | @@ -569,6 +569,13 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and | Manage another user's inbox state | yes | saved target-user opt-in or scoped `inbox:manage` grant | | Set work-object visibility (issue/project) | no | no (pro gate) | +Agent resume is the only grant-gated exception in the lifecycle-route group. An +agent actor calling `POST /agents/:agentId/resume` must pass the protected +`agent_config:update` decision with `scope.requiresChangeGrant: true`; self +access does not bypass that decision, and `agents:suggest-changes` alone cannot +apply the lifecycle change. Pause, clear-error, terminate, approval, and +key-management routes remain board-only. + ### 9.3.1 Shared default-open issue writes For standard-trust agents, issue comments, issue field/status updates, child diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index 256024e328..ddb3f40da0 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -180,6 +180,7 @@ vi.mock("../routes/authz.js", async () => { actorId: req.actor.agentId ?? "unknown-agent", agentId: req.actor.agentId ?? null, runId: req.actor.runId ?? null, + agentApiKeyId: req.actor.keyId ?? null, }; } return { @@ -187,6 +188,7 @@ vi.mock("../routes/authz.js", async () => { actorId: req.actor.userId ?? "board", agentId: null, runId: req.actor.runId ?? null, + agentApiKeyId: null, }; } @@ -437,6 +439,220 @@ describe.sequential("agent cross-tenant route authorization", () => { expect(mockAgentService.clearError).not.toHaveBeenCalled(); }); + it("preserves board resume access", async () => { + const pausedAgent = { ...baseAgent, status: "paused", pauseReason: "manual", pausedAt: new Date() }; + mockAgentService.getById.mockResolvedValue(pausedAgent); + mockAgentService.resume.mockResolvedValue({ + ...pausedAgent, + status: "idle", + pauseReason: null, + pausedAt: null, + }); + const app = await createApp({ + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}), + ); + + expect(res.status).toBe(200); + expect(mockAgentService.resume).toHaveBeenCalledWith(agentId); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, + actorType: "user", + actorId: "board-user", + agentId: null, + runId: null, + agentApiKeyId: null, + action: "agent.resumed", + entityType: "agent", + entityId: agentId, + })); + }); + + it("allows a same-company agent with a direct agents:configure grant to resume", async () => { + mockAccessService.decide.mockResolvedValue({ + allowed: true, + action: "agent_config:update", + reason: "allow_direct_change", + explanation: "Allowed by direct configuration grant.", + grant: { permissionKey: "agents:configure" }, + }); + const app = await createApp({ + type: "agent", + agentId: "44444444-4444-4444-8444-444444444444", + companyId, + runId: "55555555-5555-4555-8555-555555555555", + keyId: "66666666-6666-4666-8666-666666666666", + source: "agent_key", + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}), + ); + + expect(res.status).toBe(200); + expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "agent_config:update", + resource: { type: "agent", companyId, agentId }, + scope: { requiresChangeGrant: true }, + })); + expect(mockAgentService.resume).toHaveBeenCalledWith(agentId); + }); + + it.each([ + ["an ungranted peer", "44444444-4444-4444-8444-444444444444"], + ["an ungranted self", agentId], + ])("denies resume for %s", async (_label, actorAgentId) => { + mockAccessService.decide.mockResolvedValue({ + allowed: false, + action: "agent_config:update", + reason: "deny_no_grant", + explanation: "No direct agent configuration grant.", + }); + const app = await createApp({ + type: "agent", + agentId: actorAgentId, + companyId, + runId: "55555555-5555-4555-8555-555555555555", + source: "agent_key", + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}), + ); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ + error: "No direct agent configuration grant.", + details: { reason: "deny_no_grant" }, + }); + expect(mockAgentService.resume).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("denies resume when the agent only has agents:suggest-changes", async () => { + mockAccessService.decide.mockResolvedValue({ + allowed: false, + action: "agent_config:update", + reason: "deny_missing_consent", + explanation: "Accepted consent is required for this suggested change.", + grant: { permissionKey: "agents:suggest-changes" }, + }); + const app = await createApp({ + type: "agent", + agentId: "44444444-4444-4444-8444-444444444444", + companyId, + runId: "55555555-5555-4555-8555-555555555555", + source: "agent_key", + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}), + ); + + expect(res.status).toBe(403); + expect(res.body.details).toEqual({ reason: "deny_missing_consent" }); + expect(mockAgentService.resume).not.toHaveBeenCalled(); + }); + + it("does not disclose a cross-company resume target", async () => { + const app = await createApp({ + type: "agent", + agentId: "44444444-4444-4444-8444-444444444444", + companyId: "77777777-7777-4777-8777-777777777777", + runId: "55555555-5555-4555-8555-555555555555", + source: "agent_key", + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}), + ); + + expect(res.status).toBe(404); + expect(res.body.error).toBe("Agent not found"); + expect(mockAccessService.decide).not.toHaveBeenCalled(); + expect(mockAgentService.resume).not.toHaveBeenCalled(); + }); + + it("keeps the invalid-org-chain guard for granted agent resume", async () => { + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + status: "paused", + orgChainHealth: { + status: "invalid_org_chain", + reason: "missing_manager", + repairGuidance: "Repair the reporting chain first.", + }, + }); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + action: "agent_config:update", + reason: "allow_direct_change", + explanation: "Allowed by direct configuration grant.", + grant: { permissionKey: "agents:configure" }, + }); + const app = await createApp({ + type: "agent", + agentId: "44444444-4444-4444-8444-444444444444", + companyId, + runId: "55555555-5555-4555-8555-555555555555", + source: "agent_key", + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}), + ); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Repair the reporting chain first."); + expect(mockAgentService.resume).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("attributes agent resume activity to the acting agent, run, and API key", async () => { + const actorAgentId = "44444444-4444-4444-8444-444444444444"; + const runId = "55555555-5555-4555-8555-555555555555"; + const actorKeyId = "66666666-6666-4666-8666-666666666666"; + mockAccessService.decide.mockResolvedValue({ + allowed: true, + action: "agent_config:update", + reason: "allow_direct_change", + explanation: "Allowed by direct configuration grant.", + grant: { permissionKey: "agents:configure" }, + }); + const app = await createApp({ + type: "agent", + agentId: actorAgentId, + companyId, + runId, + keyId: actorKeyId, + source: "agent_key", + }); + + const res = await requestApp(app, (baseUrl) => + request(baseUrl).post(`/api/agents/${agentId}/resume`).send({}), + ); + + expect(res.status).toBe(200); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), { + companyId, + actorType: "agent", + actorId: actorAgentId, + agentId: actorAgentId, + runId, + agentApiKeyId: actorKeyId, + action: "agent.resumed", + entityType: "agent", + entityId: agentId, + }); + }); + it("clears error agents and records a distinct audit action", async () => { const errorAgent = { ...baseAgent, diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts index ed512a8d65..a0deff4915 100644 --- a/server/src/__tests__/plugin-managed-agents.test.ts +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -22,6 +22,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { buildHostServices } from "../services/plugin-host-services.js"; +import { agentService } from "../services/agents.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -69,6 +70,15 @@ function manifest(): PaperclipPluginManifestV1 { }; } +function pausedManifest(): PaperclipPluginManifestV1 { + const pluginManifest = manifest(); + pluginManifest.agents![0] = { + ...pluginManifest.agents![0]!, + status: "paused", + }; + return pluginManifest; +} + if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, @@ -190,6 +200,64 @@ describeEmbeddedPostgres("plugin-managed agents", () => { }); }); + it("records plugin provenance when a manifest creates a paused managed agent", async () => { + const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() }); + + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + + expect(created.agent).toMatchObject({ + status: "paused", + pauseReason: "Provisioned paused by plugin paperclip.managed-agents-test; requires explicit activation.", + }); + expect(created.agent?.pausedAt).toBeInstanceOf(Date); + }); + + it("backfills a legacy null pause reason while the managed declaration and agent remain paused", async () => { + const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() }); + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + await db + .update(agents) + .set({ pauseReason: null, updatedAt: new Date() }) + .where(eq(agents.id, created.agentId!)); + + const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + + expect(reconciled.agent).toMatchObject({ + status: "paused", + pauseReason: "Provisioned paused by plugin paperclip.managed-agents-test; requires explicit activation.", + }); + }); + + it.each(["manual", "budget", "system", "maintenance"])( + "preserves the existing %s pause reason during reconcile", + async (pauseReason) => { + const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() }); + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + await db + .update(agents) + .set({ pauseReason, updatedAt: new Date() }) + .where(eq(agents.id, created.agentId!)); + + const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + + expect(reconciled.agent).toMatchObject({ status: "paused", pauseReason }); + }, + ); + + it("keeps an explicit resume durable across managed-agent reconcile", async () => { + const { companyId, services } = await seedCompanyAndPlugin({ manifest: pausedManifest() }); + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + await agentService(db).resume(created.agentId!); + + const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + + expect(reconciled.agent).toMatchObject({ + status: "idle", + pauseReason: null, + pausedAt: null, + }); + }); + it("creates managed agents with the most-used compatible company adapter", async () => { const pluginManifest = manifest(); pluginManifest.agents![0] = { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index ff335ad896..5b290d4152 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2133,6 +2133,22 @@ export function agentRoutes( ); } + async function assertCanResumeAgent( + req: Request, + targetAgent: { id: string; companyId: string }, + ) { + if (req.actor.type !== "agent") return; + + const decision = await access.decide({ + actor: req.actor, + action: "agent_config:update", + resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, + scope: { requiresChangeGrant: true }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + } + function assertNoAgentInstructionsConfigMutation( req: Request, adapterConfig: Record | null | undefined, @@ -4111,12 +4127,12 @@ export function agentRoutes( }); router.post("/agents/:id/resume", async (req, res) => { - assertBoard(req); const id = req.params.id as string; const existing = await getAccessibleAgent(req, res, id); if (!existing) { return; } + await assertCanResumeAgent(req, existing); if (existing.orgChainHealth?.status === "invalid_org_chain") { res.status(409).json({ error: existing.orgChainHealth?.repairGuidance ?? "Repair this agent's reporting chain before resuming it", @@ -4129,10 +4145,14 @@ export function agentRoutes( return; } + const actor = getActorInfo(req); await logActivity(db, { companyId: agent.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, action: "agent.resumed", entityType: "agent", entityId: agent.id, diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts index ea95b59ce8..feb725c0b3 100644 --- a/server/src/services/plugin-managed-agents.ts +++ b/server/src/services/plugin-managed-agents.ts @@ -1,4 +1,4 @@ -import { and, eq, ne } from "drizzle-orm"; +import { and, eq, isNull, ne } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -21,6 +21,10 @@ import { agentInstructionsService } from "./agent-instructions.js"; const MANAGED_AGENT_ENTITY_TYPE = "managed_agent"; const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process"; +function managedAgentPauseReason(pluginKey: string) { + return `Provisioned paused by plugin ${pluginKey}; requires explicit activation.`; +} + interface PluginManagedAgentServiceOptions { pluginId: string; pluginKey: string; @@ -416,9 +420,12 @@ export function pluginManagedAgentService( const requiresApproval = company.requireBoardApprovalForNewAgents; const adapterType = await resolveManagedAdapterType(companyId, declaration); + const initialStatus = requiresApproval ? "pending_approval" : declaration.status ?? "idle"; let created = await agentSvc.create(companyId, { ...declarationPatch(declaration, { adapterType }), - status: requiresApproval ? "pending_approval" : declaration.status ?? "idle", + status: initialStatus, + pauseReason: initialStatus === "paused" ? managedAgentPauseReason(options.pluginKey) : null, + pausedAt: initialStatus === "paused" ? new Date() : null, metadata: managedMetadata(options.pluginId, options.pluginKey, declaration), spentMonthlyCents: 0, lastHeartbeatAt: null, @@ -490,6 +497,57 @@ export function pluginManagedAgentService( return resolution(companyId, declaration, created as Agent, "created", approvalId); } + async function backfillManagedPauseReason( + companyId: string, + declaration: PluginManagedAgentDeclaration, + agent: Agent, + ) { + if ( + declaration.status !== "paused" + || agent.status !== "paused" + || agent.pauseReason !== null + ) { + return agent; + } + + const updated = await db + .update(agents) + .set({ + pauseReason: managedAgentPauseReason(options.pluginKey), + updatedAt: new Date(), + }) + .where(and( + eq(agents.id, agent.id), + eq(agents.companyId, companyId), + eq(agents.status, "paused"), + isNull(agents.pauseReason), + )) + .returning({ id: agents.id }) + .then((rows) => rows[0] ?? null); + + if (!updated) { + const current = await agentSvc.getById(agent.id) as Agent | null; + return current ?? agent; + } + + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_agent.pause_reason_backfilled", + entityType: "agent", + entityId: updated.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.agentKey, + pauseReason: managedAgentPauseReason(options.pluginKey), + }, + }); + + const refreshed = await agentSvc.getById(updated.id) as Agent | null; + return refreshed ?? agent; + } + async function get(agentKey: string, companyId: string) { const declaration = declarationFor(agentKey); const binding = await getBinding(companyId, agentKey); @@ -506,15 +564,22 @@ export function pluginManagedAgentService( const declaration = declarationFor(agentKey); const current = await get(agentKey, companyId); if (current.agent) { - await upsertBinding(companyId, declaration, current.agent.id); - return current; + const agent = await backfillManagedPauseReason(companyId, declaration, current.agent); + await upsertBinding(companyId, declaration, agent.id); + return resolution(companyId, declaration, agent, current.status, current.approvalId); } const relinkCandidate = await findRelinkCandidate(companyId, declaration); if (relinkCandidate) { await upsertBinding(companyId, declaration, relinkCandidate.id); - const agent = await agentSvc.getById(relinkCandidate.id); - return resolution(companyId, declaration, agent as Agent, "relinked"); + const relinkedAgent = await agentSvc.getById(relinkCandidate.id) as Agent | null; + if (!relinkedAgent) throw notFound("Managed agent not found"); + const agent = await backfillManagedPauseReason( + companyId, + declaration, + relinkedAgent, + ); + return resolution(companyId, declaration, agent, "relinked"); } return createManagedAgent(companyId, declaration);