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:
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
105
packages/core/src/__tests__/agent-provisioning-policy.test.ts
Normal file
105
packages/core/src/__tests__/agent-provisioning-policy.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user