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:
Fusion
2026-05-14 12:55:57 -07:00
committed by gsxdsm
parent 96773cbb0d
commit 6a1923a2dc
5 changed files with 127 additions and 3 deletions

View File

@@ -2112,3 +2112,15 @@
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);
}

View File

@@ -12,9 +12,9 @@ import {
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
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 { 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 { AgentLogViewer } from "./AgentLogViewer";
import { AgentReflectionsTab } from "./AgentReflectionsTab";
@@ -30,6 +30,7 @@ import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { AgentAvatar } from "./AgentAvatar";
import { AgentErrorIndicator } from "./AgentErrorDetailsModal";
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
/**
* Simple className utility - joins class names conditionally
@@ -3706,6 +3707,8 @@ function ConfigTab({
const [runtimeMode, setRuntimeMode] = useState<"model" | "runtime">(initialRuntimeHint ? "runtime" : "model");
const [modelValue, setModelValue] = useState(initialModelValue);
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 availableManagers = useMemo(
@@ -3798,6 +3801,27 @@ function ConfigTab({
addToast("Interview draft applied. Review and save when ready.", "success");
}, [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
useEffect(() => {
let cancelled = false;
@@ -4666,6 +4690,40 @@ function ConfigTab({
</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">
<h3>Heartbeat Settings</h3>
<p className="config-description">

View File

@@ -41,8 +41,20 @@ function getPresetRules(presetId: AgentPermissionPolicy["presetId"]): AgentPermi
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) {
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 setPreset = (preset: string) => {

View File

@@ -39,6 +39,7 @@ import { LoginInstructions } from "./LoginInstructions";
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
import { ProviderIcon } from "./ProviderIcon";
import { CustomProvidersSection } from "./CustomProvidersSection";
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { appendTokenQuery } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
@@ -228,6 +229,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "worktrees", label: "Worktrees", scope: "project" },
{ id: "commands", label: "Commands", scope: "project" },
{ id: "merge", label: "Merge", scope: "project" },
{ id: "agent-permissions", label: "Agent Permissions", scope: "project" },
{ id: "memory", label: "Memory", scope: "project" },
{ id: "research-project", label: "Research", 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": {
// Use memory backend status from top-level hook call
const {

View File

@@ -42,6 +42,26 @@ describe("AgentPermissionPolicyEditor", () => {
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", () => {
render(
<AgentPermissionPolicyEditor