diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md index 52516eeb486..f091a126d4e 100644 --- a/maps/agent-access-map/agents.md +++ b/maps/agent-access-map/agents.md @@ -59,7 +59,7 @@ Normal agent-authored hiring keeps its existing approval policy. An agent-owned plugin may prepare the draft and leave its lifecycle in `verifying` while that setup hold remains. Only bounded Core-issued setup probes can run there. The creator's company creation permission also covers configuring -and retrying their draft during preparation; other actors still require the +and retrying their draft during preparation or stopping; other actors still require the ordinary configuration permission. This is a board wizard path, not new agent execution or probe authority. Lifecycle and component regressions cover these transitions; no permitted-agent or live-provider journey is claimed here. diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index 49946277268..aa21b2e887a 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -233,6 +233,30 @@ const support = await getEmbeddedPostgresTestSupport(); expect((await current(setup.id)).lifecycleHolds).toEqual(["setup"]); }); + it("lets only the setup creator configure and retry a stopping draft with creation permission", async () => { + await db.insert(companyMemberships).values(["setup-owner", "setup-other"].map(principalId => ({ companyId, + principalType: "user", principalId, membershipRole: "member" }))); + await db.insert(principalPermissionGrants).values(["setup-owner", "setup-other"].map(principalId => ({ companyId, + principalType: "user", principalId, permissionKey: "agents:create" }))); + const lifecycle = createAgentLifecycle(db); + const draft = await lifecycle.requestHire(companyId, { name: "Stopping setup", adapterType: "process" }, + { setupOnly: true, createdByUserId: "setup-owner" }); + await db.update(agents).set({ lifecycleState: "pausing" }).where(eq(agents.id, draft.id)); + function app(userId: string) { + const value = express(); value.use(express.json()); + value.use((req, _res, next) => { req.actor = { type: "board", source: "session", userId, + companyIds: [companyId], memberships: [{ companyId, membershipRole: "member", status: "active" }] }; next(); }); + value.use("/api", agentRoutes(db)); value.use(errorHandler); return value; + } + const updated = await request(app("setup-owner")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: false } } }); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + await request(app("setup-owner")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(200); + await request(app("setup-other")).patch(`/api/agents/${draft.id}`).send({ runtimeConfig: { heartbeat: { enabled: true } } }).expect(403); + await request(app("setup-other")).post(`/api/agents/${draft.id}/lifecycle/retry`).send({}).expect(403); + await expect(lifecycle.requestHire(companyId, { name: draft.name }, { setupAgentId: draft.id })).rejects.toThrow("no longer available"); + expect((await current(draft.id)).lifecycleState).toBe("pausing"); + }); + it("creates a board-owned setup before requesting approval for its final configuration", async () => { await db.update(companies).set({ requireBoardApprovalForNewAgents: true }).where(eq(companies.id, companyId)); await db.insert(companyMemberships).values(["board", "other-creator"].map(principalId => ({ companyId, principalType: "user", principalId, membershipRole: "member" }))); diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts index 176894eeb7b..a6ccd8a0dbe 100644 --- a/server/src/modules/agent-lifecycle/adapters/records.ts +++ b/server/src/modules/agent-lifecycle/adapters/records.ts @@ -26,7 +26,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) { if (!existing) throw notFound("Agent setup not found"); return existing; } - if (setup && (!isAgentSetupDraft(setup) || !setup.lifecycleHolds.includes("setup"))) { + if (setup && (!isAgentSetupDraft(setup) || setup.lifecycleState === "pausing" || !setup.lifecycleHolds.includes("setup"))) { throw conflict("Agent setup is no longer available"); } if (options?.setupAgentId && !setup) throw notFound("Agent setup not found"); diff --git a/server/src/modules/agent-lifecycle/domain/policy.ts b/server/src/modules/agent-lifecycle/domain/policy.ts index 458c1d729b5..043206479bf 100644 --- a/server/src/modules/agent-lifecycle/domain/policy.ts +++ b/server/src/modules/agent-lifecycle/domain/policy.ts @@ -6,7 +6,7 @@ export class AgentLifecycleConflict extends Error { } export function isAgentSetupDraft(agent: { lifecycleState?: string; pauseReason?: string | null }) { - return agent.pauseReason === "setup" && ["paused", "preparing", "verifying"].includes(agent.lifecycleState ?? ""); + return agent.pauseReason === "setup" && ["paused", "preparing", "verifying", "pausing"].includes(agent.lifecycleState ?? ""); } export function canConfigureAgentConnection(agent: { lifecycleState: string; status: string }): boolean {