feat(FN-3956): execute provisioning on approval decisions

The merge completes Step 1 of agent provisioning approval followthrough, executing provisioning actions when approval decisions are made, with supporting documentation in AGENTS.md and docs/agents.md. Test coverage spans provisioning policy, approval routes, gating classifications, extension integra

Fusion-Task-Id: FN-3956
This commit is contained in:
Fusion
2026-05-10 18:35:13 -07:00
committed by gsxdsm
parent 5b32c45393
commit 7d67dc3597
11 changed files with 392 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Wire dashboard approval decisions for `agent_provisioning` requests to execute deferred agent create/delete actions.
Add focused test coverage for provisioning decision routing, policy/gating contracts, and approval request category round-trips.

View File

@@ -311,10 +311,14 @@ Six tools enable inter-agent coordination — discovering agents, provisioning/d
Create a non-ephemeral agent that reports to the caller (or, for CEO-level callers, any `reportsTo` target). Create a non-ephemeral agent that reports to the caller (or, for CEO-level callers, any `reportsTo` target).
Provisioning can be policy-gated (`projectSettings.agentProvisioning`). Tool responses include `details.outcome` of `created`, `pending_approval`, or `denied`. Pending requests are resolved via dashboard/API approval decision route (`POST /api/approvals/:id/decision`), which executes deferred creation on approve.
### `agent_delete` Tool ### `agent_delete` Tool
Delete a non-ephemeral direct report. If the target holds a task checkout lease, deletion is blocked unless `force: true`. Assigned tasks can be reassigned via `reassign_to` or released/unassigned. Delete a non-ephemeral direct report. If the target holds a task checkout lease, deletion is blocked unless `force: true`. Assigned tasks can be reassigned via `reassign_to` or released/unassigned.
Provisioning policy also applies to deletes (`details.outcome`: `deleted`, `pending_approval`, or `denied`). Approval decisions emit provisioning audit events (`agent:create:approved|denied`, `agent:delete:approved|denied`) tied to the original run/task metadata.
### `list_agents` Tool ### `list_agents` Tool
List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment. List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment.

View File

@@ -102,6 +102,14 @@ Approval pause/resume lifecycle (FN-3548):
- Dashboard mailbox entry points (Header/Mobile nav) display pending-approval indicators so waiting approvals are visible before opening Mailbox. - Dashboard mailbox entry points (Header/Mobile nav) display pending-approval indicators so waiting approvals are visible before opening Mailbox.
- Agents list/board cards and Agent Detail summary display per-agent `pendingApprovalCount` badges to show which agents are blocked by waiting approvals. - Agents list/board cards and Agent Detail summary display per-agent `pendingApprovalCount` badges to show which agents are blocked by waiting approvals.
Agent provisioning approvals (`agent_provisioning` category):
- `fn_agent_create` / `fn_agent_delete` can return `pending_approval` under `projectSettings.agentProvisioning` policy (`approvalMode`, trusted roles/IDs, `alwaysApproveDelete`).
- Approval request is persisted with provisioning context (`tool` + `params`) and visible in mailbox/API approval queues.
- Dashboard/API decision route `POST /api/approvals/:id/decision` executes deferred provisioning on `approve` via engine dispatcher (`executeApprovedAgentProvisioning`) and never executes on `deny`.
- Decision handling emits run-audit mutations: `agent:create:approved`, `agent:create:denied`, `agent:delete:approved`, `agent:delete:denied` using original request task/run/requester linkage.
- Malformed provisioning context or failed execution returns 500 from the decision route (no silent approval).
Default and legacy fallback behavior: Default and legacy fallback behavior:
- New **non-ephemeral/permanent** agents persist a normalized `permissionPolicy` using preset `unrestricted` when not explicitly provided. - New **non-ephemeral/permanent** agents persist a normalized `permissionPolicy` using preset `unrestricted` when not explicitly provided.

View File

@@ -188,6 +188,32 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
expect(persisted?.description).toBe("Ship the packed CLI contract"); expect(persisted?.description).toBe("Ship the packed CLI contract");
}); });
it("runs provisioning tools through the built extension", async () => {
const createTool = api.tools.get("fn_agent_create")!;
const created = await createTool.execute(
"create-agent-1",
{ name: "built-ext-agent", role: "executor" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(created.details.outcome).toBe("created");
expect(created.details.agentId).toMatch(/^agent-/);
const deleteTool = api.tools.get("fn_agent_delete")!;
const deleted = await deleteTool.execute(
"delete-agent-1",
{ id: created.details.agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(deleted.details.outcome).toBe("deleted");
expect(deleted.details.agentId).toBe(created.details.agentId);
});
it("delegates to real non-ephemeral agents and rejects runtime workers", async () => { it("delegates to real non-ephemeral agents and rejects runtime workers", async () => {
const agent = await seedAgent(tmpDir, { name: "release-agent" }); const agent = await seedAgent(tmpDir, { name: "release-agent" });
const runtimeWorker = await seedAgent(tmpDir, { name: "runtime-worker", ephemeral: true }); const runtimeWorker = await seedAgent(tmpDir, { name: "runtime-worker", ephemeral: true });

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";
import { extractAgentProvisioningRequest, resolveAgentProvisioningPolicy } from "../agent-provisioning-policy.js";
describe("resolveAgentProvisioningPolicy", () => {
it("denies missing caller", () => {
const decision = resolveAgentProvisioningPolicy({ tool: "fn_agent_create", caller: undefined, settings: undefined });
expect(decision.decision).toBe("deny");
expect(decision.matchedRule).toBe("missing-caller");
});
it("allows privileged caller", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "a1", role: "executor", isPrivileged: true },
settings: { agentProvisioning: { approvalMode: "always", alwaysApproveDelete: true } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("privileged-caller");
});
it("allows trusted agent id in trusted-only mode", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_create",
caller: { id: "trusted-id" },
settings: { agentProvisioning: { approvalMode: "trusted-only", trustedAgentIds: ["trusted-id"] } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("trusted-agent-id");
});
it("matches trusted role case-insensitively", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_create",
caller: { id: "a1", role: "CEO" },
settings: { agentProvisioning: { approvalMode: "trusted-only", trustedRoles: ["ceo"] } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("trusted-role");
});
it("requires approval for untrusted caller in trusted-only mode", () => {
const decision = resolveAgentProvisioningPolicy({ tool: "fn_agent_create", caller: { id: "a1" }, settings: undefined });
expect(decision.decision).toBe("require-approval");
expect(decision.matchedRule).toBe("approval-mode-trusted-only");
expect(decision.effectiveMode).toBe("trusted-only");
});
it("requires approval in always mode", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_create",
caller: { id: "a1" },
settings: { agentProvisioning: { approvalMode: "always" } },
});
expect(decision.decision).toBe("require-approval");
expect(decision.matchedRule).toBe("approval-mode-always");
});
it("alwaysApproveDelete forces approval by default", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "trusted", role: "ceo" },
settings: { agentProvisioning: { approvalMode: "trusted-only", trustedAgentIds: ["trusted"] } },
});
expect(decision.decision).toBe("require-approval");
expect(decision.matchedRule).toBe("delete-always-approve");
});
it("allows trusted delete when alwaysApproveDelete is false", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "trusted" },
settings: { agentProvisioning: { approvalMode: "trusted-only", trustedAgentIds: ["trusted"], alwaysApproveDelete: false } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("trusted-agent-id");
});
it("never mode short-circuits delete approval", () => {
const decision = resolveAgentProvisioningPolicy({
tool: "fn_agent_delete",
caller: { id: "a1" },
settings: { agentProvisioning: { approvalMode: "never", alwaysApproveDelete: true } },
});
expect(decision.decision).toBe("allow");
expect(decision.matchedRule).toBe("approval-mode-never");
});
});
describe("extractAgentProvisioningRequest", () => {
it("extracts tool and params from provisioning request", () => {
const request: any = {
id: "apr-1",
targetAction: {
category: "agent_provisioning",
context: { tool: "fn_agent_create", params: { name: "helper" } },
},
};
expect(extractAgentProvisioningRequest(request)).toEqual({ tool: "fn_agent_create", params: { name: "helper" } });
});
it("throws for malformed context", () => {
const request: any = { id: "apr-1", targetAction: { category: "agent_provisioning", context: {} } };
expect(() => extractAgentProvisioningRequest(request)).toThrow("invalid provisioning tool");
});
});

View File

@@ -126,6 +126,23 @@ describe("ApprovalRequestStore", () => {
expect(fetched?.runId).toBe("run-abc"); expect(fetched?.runId).toBe("run-abc");
}); });
it("round-trips agent_provisioning category unchanged", () => {
const created = store.create({
requester: REQUESTER,
targetAction: {
category: "agent_provisioning",
action: "create",
summary: "Create helper",
resourceType: "agent",
resourceId: "",
},
});
const fetched = store.get(created.id);
expect(fetched?.targetAction.category).toBe("agent_provisioning");
expect(store.list({ status: "pending" }).some((row) => row.id === created.id && row.targetAction.category === "agent_provisioning")).toBe(true);
});
it("normalizes legacy category aliases on create/read", () => { it("normalizes legacy category aliases on create/read", () => {
const created = store.create({ const created = store.create({
requester: REQUESTER, requester: REQUESTER,

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PROJECT_SETTINGS, PROJECT_SETTINGS_KEYS } from "../settings-schema.js";
import { AGENT_PROVISIONING_APPROVAL_MODES } from "../types.js";
describe("agentProvisioning settings schema contract", () => {
it("includes agentProvisioning key with object default", () => {
expect(PROJECT_SETTINGS_KEYS).toContain("agentProvisioning");
expect(DEFAULT_PROJECT_SETTINGS.agentProvisioning).toEqual({});
});
it("exposes valid approval mode vocabulary", () => {
expect(AGENT_PROVISIONING_APPROVAL_MODES).toEqual(["always", "trusted-only", "never"]);
});
it("supports omitted block defaults", () => {
const settings = DEFAULT_PROJECT_SETTINGS.agentProvisioning ?? {};
expect(settings).toEqual({});
});
});

View File

@@ -7,6 +7,8 @@ const state = {
audits: new Map<string, any[]>(), audits: new Map<string, any[]>(),
task: { id: "FN-1", paused: true, pausedByAgentId: "agent-1" }, task: { id: "FN-1", paused: true, pausedByAgentId: "agent-1" },
agent: { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" }, agent: { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" },
runAuditEvents: [] as any[],
provisionedAgents: new Set<string>(),
}; };
class MockApprovalRequestStore { class MockApprovalRequestStore {
@@ -59,11 +61,31 @@ class MockAgentStore {
} }
} }
const executeApprovedAgentProvisioning = vi.fn(async (request: any) => {
const tool = request?.targetAction?.context?.tool;
if (!tool) throw new Error("Malformed agent provisioning request: missing tool");
if (tool === "fn_agent_create") {
const id = String(request?.targetAction?.context?.params?.name ?? "created-agent");
state.provisionedAgents.add(id);
return { id };
}
if (tool === "fn_agent_delete") {
const id = String(request?.targetAction?.resourceId ?? "");
state.provisionedAgents.delete(id);
return { deletedId: id };
}
throw new Error(`Unsupported provisioning tool: ${tool}`);
});
vi.mock("@fusion/core", () => ({ vi.mock("@fusion/core", () => ({
ApprovalRequestStore: MockApprovalRequestStore, ApprovalRequestStore: MockApprovalRequestStore,
AgentStore: MockAgentStore, AgentStore: MockAgentStore,
})); }));
vi.mock("@fusion/engine", () => ({
executeApprovedAgentProvisioning,
}));
describe("approval routes", async () => { describe("approval routes", async () => {
const { registerApprovalRoutes } = await import("../routes/register-approval-routes.js"); const { registerApprovalRoutes } = await import("../routes/register-approval-routes.js");
@@ -81,6 +103,10 @@ describe("approval routes", async () => {
pauseTask: async (_id: string, paused: boolean) => { pauseTask: async (_id: string, paused: boolean) => {
state.task = { ...state.task, paused, pausedByAgentId: paused ? state.task.pausedByAgentId : undefined }; state.task = { ...state.task, paused, pausedByAgentId: paused ? state.task.pausedByAgentId : undefined };
}, },
recordRunAuditEvent: (event: any) => {
state.runAuditEvents.push(event);
return event;
},
}, },
engine: undefined, engine: undefined,
projectId: "p1", projectId: "p1",
@@ -101,6 +127,9 @@ describe("approval routes", async () => {
beforeEach(() => { beforeEach(() => {
updateAgent.mockClear(); updateAgent.mockClear();
const now = new Date().toISOString(); const now = new Date().toISOString();
executeApprovedAgentProvisioning.mockClear();
state.runAuditEvents = [];
state.provisionedAgents = new Set(["target-1"]);
state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" }; state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" };
state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" }; state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" };
state.requests = new Map([ state.requests = new Map([
@@ -124,6 +153,60 @@ describe("approval routes", async () => {
updatedAt: now, updatedAt: now,
requestedAt: now, requestedAt: now,
}], }],
["apr-3", {
id: "apr-3",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: {
category: "agent_provisioning",
summary: "Create provisioned agent",
action: "create",
resourceType: "agent",
resourceId: "",
context: { tool: "fn_agent_create", params: { name: "created-agent", role: "executor" } },
},
taskId: "FN-1",
runId: "run-1",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
["apr-4", {
id: "apr-4",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: {
category: "agent_provisioning",
summary: "Delete provisioned agent",
action: "delete",
resourceType: "agent",
resourceId: "target-1",
context: { tool: "fn_agent_delete", params: { agent_id: "target-1" } },
},
taskId: "FN-1",
runId: "run-2",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
["apr-5", {
id: "apr-5",
status: "pending",
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
targetAction: {
category: "agent_provisioning",
summary: "Malformed",
action: "create",
resourceType: "agent",
resourceId: "",
context: {},
},
taskId: "FN-1",
runId: "run-3",
createdAt: now,
updatedAt: now,
requestedAt: now,
}],
]); ]);
state.audits = new Map([ state.audits = new Map([
["apr-1", [{ id: "evt-created", eventType: "created", actor: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" }, createdAt: now }]], ["apr-1", [{ id: "evt-created", eventType: "created", actor: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" }, createdAt: now }]],
@@ -135,9 +218,9 @@ describe("approval routes", async () => {
const app = createApp(); const app = createApp();
const res = await get(app, "/api/approvals?status=pending"); const res = await get(app, "/api/approvals?status=pending");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.total).toBe(1); expect(res.body.total).toBe(4);
expect(res.body.pendingCount).toBe(1); expect(res.body.pendingCount).toBe(4);
expect(res.body.requests).toHaveLength(1); expect(res.body.requests).toHaveLength(4);
expect(res.body.requests[0]).toMatchObject({ expect(res.body.requests[0]).toMatchObject({
id: "apr-1", id: "apr-1",
actionCategory: "command_execution", actionCategory: "command_execution",
@@ -191,8 +274,65 @@ describe("approval routes", async () => {
expect(res.body.status).toBe("denied"); expect(res.body.status).toBe("denied");
}); });
it("returns 409 for invalid transition", async () => { it("approves provisioning create and records audit", async () => {
const app = createApp(); const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-3/decision",
JSON.stringify({ decision: "approve" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(executeApprovedAgentProvisioning).toHaveBeenCalledTimes(1);
expect(state.provisionedAgents.has("created-agent")).toBe(true);
expect(state.runAuditEvents.at(-1)).toMatchObject({ mutationType: "agent:create:approved", runId: "run-1" });
expect(state.task.paused).toBe(false);
});
it("denies provisioning create without execution and records denied audit", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-3/decision",
JSON.stringify({ decision: "deny" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(executeApprovedAgentProvisioning).not.toHaveBeenCalled();
expect(state.provisionedAgents.has("created-agent")).toBe(false);
expect(state.runAuditEvents.at(-1)).toMatchObject({ mutationType: "agent:create:denied", runId: "run-1" });
});
it("approves provisioning delete and records audit", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-4/decision",
JSON.stringify({ decision: "approve" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(state.provisionedAgents.has("target-1")).toBe(false);
expect(state.runAuditEvents.at(-1)).toMatchObject({ mutationType: "agent:delete:approved", runId: "run-2" });
});
it("returns 500 for malformed provisioning request context", async () => {
const app = createApp();
const res = await request(
app,
"POST",
"/api/approvals/apr-5/decision",
JSON.stringify({ decision: "approve" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Malformed agent provisioning request");
});
it("returns 409 for invalid transition", async () => { const app = createApp();
const res = await request( const res = await request(
app, app,
"POST", "POST",

View File

@@ -1,4 +1,5 @@
import { AgentStore, ApprovalRequestStore, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core"; import { AgentStore, ApprovalRequestStore, type ApprovalRequest, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core";
import { executeApprovedAgentProvisioning } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js"; import type { ApiRoutesContext } from "./types.js";
import { emitApprovalSseEvent } from "../sse.js"; import { emitApprovalSseEvent } from "../sse.js";
@@ -97,6 +98,34 @@ function toDetailDto(
}; };
} }
function emitProvisioningDecisionAudit(params: {
scopedStore: import("@fusion/core").TaskStore;
request: ApprovalRequest;
decision: "approved" | "denied";
}): void {
const { scopedStore, request, decision } = params;
if (request.targetAction.category !== "agent_provisioning") return;
const action = request.targetAction.action === "delete" ? "delete" : "create";
const mutationType = `agent:${action}:${decision}` as const;
const event: Parameters<typeof scopedStore.recordRunAuditEvent>[0] = {
agentId: request.requester.actorId,
domain: "database",
mutationType,
target: request.targetAction.resourceId || request.requester.actorId,
metadata: {
approvalRequestId: request.id,
action,
resourceId: request.targetAction.resourceId,
requesterAgentId: request.requester.actorId,
},
runId: request.id,
};
if (request.taskId) event.taskId = request.taskId;
if (request.runId) event.runId = request.runId;
scopedStore.recordRunAuditEvent(event);
}
async function resumeAfterDecision(params: { async function resumeAfterDecision(params: {
scopedStore: import("@fusion/core").TaskStore; scopedStore: import("@fusion/core").TaskStore;
request: import("@fusion/core").ApprovalRequest; request: import("@fusion/core").ApprovalRequest;
@@ -210,6 +239,17 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
throw error; throw error;
} }
if (updated.targetAction.category === "agent_provisioning") {
if (body.decision === "approve") {
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
await executeApprovedAgentProvisioning(updated, { agentStore });
emitProvisioningDecisionAudit({ scopedStore, request: updated, decision: "approved" });
} else {
emitProvisioningDecisionAudit({ scopedStore, request: updated, decision: "denied" });
}
}
await resumeAfterDecision({ scopedStore, request: updated, runtimeLogger }); await resumeAfterDecision({ scopedStore, request: updated, runtimeLogger });
const history = approvalStore.getAuditHistory(requestId); const history = approvalStore.getAuditHistory(requestId);
const detail = toDetailDto(updated, history); const detail = toDetailDto(updated, history);

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import {
ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS,
PERMANENT_AGENT_TASK_MUTATION_TOOLS,
TASK_AGENT_MUTATION_TOOLS,
} from "../gating-classifications.js";
describe("gating classifications provisioning split", () => {
it("keeps provisioning tools out of action-gate set", () => {
expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has("fn_agent_create")).toBe(false);
expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has("fn_agent_delete")).toBe(false);
});
it("retains provisioning tools in permanent/task mutation sets", () => {
expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has("fn_agent_create")).toBe(true);
expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has("fn_agent_delete")).toBe(true);
expect(TASK_AGENT_MUTATION_TOOLS.has("fn_agent_create")).toBe(true);
expect(TASK_AGENT_MUTATION_TOOLS.has("fn_agent_delete")).toBe(true);
});
});

View File

@@ -11,6 +11,7 @@ export {
taskDocumentReadParams, taskDocumentReadParams,
taskDocumentWriteParams, taskDocumentWriteParams,
taskLogParams, taskLogParams,
executeApprovedAgentProvisioning,
} from "./agent-tools.js"; } from "./agent-tools.js";
export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js"; export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js"; export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";