feat(FN-4488): complete Step 5 — wire settings and agent permission panels
Fusion-Task-Id: FN-4488 Fusion-Task-Lineage: 3fc1ac43-490f-43f2-a27e-4fdceb64d9c4
This commit is contained in:
@@ -2112,3 +2112,15 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-permission-inherit-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import {
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
|
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
|
||||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
|
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, updateGlobalSettings, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
|
||||||
import type { Agent } from "../api";
|
import type { Agent } from "../api";
|
||||||
import type { AgentLogEntry, Task, Message, ParticipantType } from "@fusion/core";
|
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules } from "@fusion/core";
|
||||||
import { getErrorMessage, isEphemeralAgent } from "@fusion/core";
|
import { getErrorMessage, isEphemeralAgent } from "@fusion/core";
|
||||||
import { AgentLogViewer } from "./AgentLogViewer";
|
import { AgentLogViewer } from "./AgentLogViewer";
|
||||||
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
import { AgentReflectionsTab } from "./AgentReflectionsTab";
|
||||||
@@ -30,6 +30,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
|||||||
import { AgentAvatar } from "./AgentAvatar";
|
import { AgentAvatar } from "./AgentAvatar";
|
||||||
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
|
||||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||||
|
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple className utility - joins class names conditionally
|
* Simple className utility - joins class names conditionally
|
||||||
@@ -3706,6 +3707,8 @@ function ConfigTab({
|
|||||||
const [runtimeMode, setRuntimeMode] = useState<"model" | "runtime">(initialRuntimeHint ? "runtime" : "model");
|
const [runtimeMode, setRuntimeMode] = useState<"model" | "runtime">(initialRuntimeHint ? "runtime" : "model");
|
||||||
const [modelValue, setModelValue] = useState(initialModelValue);
|
const [modelValue, setModelValue] = useState(initialModelValue);
|
||||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntimeHint);
|
const [selectedRuntimeId, setSelectedRuntimeId] = useState(initialRuntimeHint);
|
||||||
|
const [permissionPolicyValue, setPermissionPolicyValue] = useState<AgentPermissionPolicy | undefined>(agent.permissionPolicy);
|
||||||
|
const [projectDefaultPermissionPolicy, setProjectDefaultPermissionPolicy] = useState<Partial<AgentPermissionPolicyRules> | undefined>(undefined);
|
||||||
|
|
||||||
const managerSelection = reportsToValue.trim();
|
const managerSelection = reportsToValue.trim();
|
||||||
const availableManagers = useMemo(
|
const availableManagers = useMemo(
|
||||||
@@ -3798,6 +3801,27 @@ function ConfigTab({
|
|||||||
addToast("Interview draft applied. Review and save when ready.", "success");
|
addToast("Interview draft applied. Review and save when ready.", "success");
|
||||||
}, [addToast, onAgentDraftApplied]);
|
}, [addToast, onAgentDraftApplied]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPermissionPolicyValue(agent.permissionPolicy);
|
||||||
|
}, [agent.permissionPolicy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettingsByScope(projectId)
|
||||||
|
.then((scoped) => setProjectDefaultPermissionPolicy(scoped.project?.defaultAgentPermissionPolicy?.rules))
|
||||||
|
.catch(() => setProjectDefaultPermissionPolicy(undefined));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handlePermissionPolicyChange = async (next: AgentPermissionPolicy | undefined) => {
|
||||||
|
setPermissionPolicyValue(next);
|
||||||
|
try {
|
||||||
|
await updateAgent(agent.id, { permissionPolicy: next }, projectId);
|
||||||
|
await onSaved();
|
||||||
|
addToast("Permission policy updated", "success");
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to update permission policy: ${getErrorMessage(err)}`, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Load candidate managers for reports-to dropdown
|
// Load candidate managers for reports-to dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -4666,6 +4690,40 @@ function ConfigTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="config-section">
|
||||||
|
<h3>Permissions</h3>
|
||||||
|
<p className="config-description">
|
||||||
|
Per-agent settings override project defaults. Each category controls a separate approval gate.
|
||||||
|
</p>
|
||||||
|
{permissionPolicyValue === undefined ? (
|
||||||
|
<div className="agent-permission-inherit-banner">
|
||||||
|
<span>Inheriting project default — no per-agent override set</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => void handlePermissionPolicyChange({
|
||||||
|
presetId: "custom",
|
||||||
|
rules: {
|
||||||
|
git_write: projectDefaultPermissionPolicy?.git_write ?? "allow",
|
||||||
|
file_write_delete: projectDefaultPermissionPolicy?.file_write_delete ?? "allow",
|
||||||
|
command_execution: projectDefaultPermissionPolicy?.command_execution ?? "allow",
|
||||||
|
network_api: projectDefaultPermissionPolicy?.network_api ?? "allow",
|
||||||
|
task_agent_mutation: projectDefaultPermissionPolicy?.task_agent_mutation ?? "allow",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Customize for this agent
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<AgentPermissionPolicyEditor
|
||||||
|
mode="agent-override"
|
||||||
|
value={permissionPolicyValue}
|
||||||
|
projectDefault={projectDefaultPermissionPolicy}
|
||||||
|
onChange={(next) => { void handlePermissionPolicyChange(next); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="config-section">
|
<div className="config-section">
|
||||||
<h3>Heartbeat Settings</h3>
|
<h3>Heartbeat Settings</h3>
|
||||||
<p className="config-description">
|
<p className="config-description">
|
||||||
|
|||||||
@@ -41,8 +41,20 @@ function getPresetRules(presetId: AgentPermissionPolicy["presetId"]): AgentPermi
|
|||||||
return normalizeAgentPermissionPolicyFromPreset(presetId).rules;
|
return normalizeAgentPermissionPolicyFromPreset(presetId).rules;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesRules(a: AgentPermissionPolicyRules, b: AgentPermissionPolicyRules): boolean {
|
||||||
|
return AGENT_PERMISSION_POLICY_ACTION_CATEGORIES.every((category) => a[category] === b[category]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePresetFromRules(rules: AgentPermissionPolicyRules): AgentPermissionPolicy["presetId"] {
|
||||||
|
if (matchesRules(rules, getPresetRules("unrestricted"))) return "unrestricted";
|
||||||
|
if (matchesRules(rules, getPresetRules("approval-required"))) return "approval-required";
|
||||||
|
if (matchesRules(rules, getPresetRules("locked-down"))) return "locked-down";
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onChange, disabled = false }: Props) {
|
export function AgentPermissionPolicyEditor({ value, projectDefault, mode, onChange, disabled = false }: Props) {
|
||||||
const currentPreset = mode === "agent-override" && !value ? "inherit" : (value?.presetId ?? "unrestricted");
|
const derivedPreset = value ? derivePresetFromRules(value.rules) : "unrestricted";
|
||||||
|
const currentPreset = mode === "agent-override" && !value ? "inherit" : (value?.presetId === "custom" ? derivedPreset : (value?.presetId ?? "unrestricted"));
|
||||||
const rules = value?.rules ?? buildAllowRules();
|
const rules = value?.rules ?? buildAllowRules();
|
||||||
|
|
||||||
const setPreset = (preset: string) => {
|
const setPreset = (preset: string) => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { LoginInstructions } from "./LoginInstructions";
|
|||||||
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
|
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { CustomProvidersSection } from "./CustomProvidersSection";
|
import { CustomProvidersSection } from "./CustomProvidersSection";
|
||||||
|
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
|
||||||
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
||||||
import { appendTokenQuery } from "../auth";
|
import { appendTokenQuery } from "../auth";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
@@ -228,6 +229,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
|||||||
{ id: "worktrees", label: "Worktrees", scope: "project" },
|
{ id: "worktrees", label: "Worktrees", scope: "project" },
|
||||||
{ id: "commands", label: "Commands", scope: "project" },
|
{ id: "commands", label: "Commands", scope: "project" },
|
||||||
{ id: "merge", label: "Merge", scope: "project" },
|
{ id: "merge", label: "Merge", scope: "project" },
|
||||||
|
{ id: "agent-permissions", label: "Agent Permissions", scope: "project" },
|
||||||
{ id: "memory", label: "Memory", scope: "project" },
|
{ id: "memory", label: "Memory", scope: "project" },
|
||||||
{ id: "research-project", label: "Research", scope: "project" },
|
{ id: "research-project", label: "Research", scope: "project" },
|
||||||
{ id: "prompts", label: "Prompts", scope: "project" },
|
{ id: "prompts", label: "Prompts", scope: "project" },
|
||||||
@@ -4186,6 +4188,26 @@ export function SettingsModal({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
case "agent-permissions":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
|
<h4 className="settings-section-heading">Agent Permissions</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<small className="settings-muted">Per-agent settings override project defaults. Each category controls a separate approval gate.</small>
|
||||||
|
</div>
|
||||||
|
<AgentPermissionPolicyEditor
|
||||||
|
mode="project-default"
|
||||||
|
value={form.defaultAgentPermissionPolicy ? { presetId: "custom", rules: form.defaultAgentPermissionPolicy.rules ?? {} } : { presetId: "custom", rules: {} }}
|
||||||
|
onChange={(next) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
defaultAgentPermissionPolicy: { rules: next?.rules ?? {} },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
case "memory": {
|
case "memory": {
|
||||||
// Use memory backend status from top-level hook call
|
// Use memory backend status from top-level hook call
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -42,6 +42,26 @@ describe("AgentPermissionPolicyEditor", () => {
|
|||||||
expect(payload.presetId).toBe("custom");
|
expect(payload.presetId).toBe("custom");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("agent override inherit preset emits undefined", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<AgentPermissionPolicyEditor
|
||||||
|
mode="agent-override"
|
||||||
|
value={{ presetId: "custom", rules: {
|
||||||
|
git_write: "allow",
|
||||||
|
file_write_delete: "allow",
|
||||||
|
command_execution: "allow",
|
||||||
|
network_api: "allow",
|
||||||
|
task_agent_mutation: "allow",
|
||||||
|
} }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Preset"), { target: { value: "inherit" } });
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows inherit annotation from project default", () => {
|
it("shows inherit annotation from project default", () => {
|
||||||
render(
|
render(
|
||||||
<AgentPermissionPolicyEditor
|
<AgentPermissionPolicyEditor
|
||||||
|
|||||||
Reference in New Issue
Block a user