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;
|
||||
}
|
||||
}
|
||||
|
||||
.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 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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user