refactor(ui): split nodes exec approvals module

This commit is contained in:
Peter Steinberger
2026-02-13 18:38:55 +00:00
parent d443a73798
commit 9fab0d2ced
2 changed files with 654 additions and 651 deletions

View File

@@ -0,0 +1,651 @@
import { html, nothing } from "lit";
import type {
ExecApprovalsAllowlistEntry,
ExecApprovalsFile,
} from "../controllers/exec-approvals.ts";
import type { NodesProps } from "./nodes.ts";
import { clampText, formatRelativeTimestamp } from "../format.ts";
type ExecSecurity = "deny" | "allowlist" | "full";
type ExecAsk = "off" | "on-miss" | "always";
type ExecApprovalsResolvedDefaults = {
security: ExecSecurity;
ask: ExecAsk;
askFallback: ExecSecurity;
autoAllowSkills: boolean;
};
type ExecApprovalsAgentOption = {
id: string;
name?: string;
isDefault?: boolean;
};
type ExecApprovalsTargetNode = {
id: string;
label: string;
};
type ExecApprovalsState = {
ready: boolean;
disabled: boolean;
dirty: boolean;
loading: boolean;
saving: boolean;
form: ExecApprovalsFile | null;
defaults: ExecApprovalsResolvedDefaults;
selectedScope: string;
selectedAgent: Record<string, unknown> | null;
agents: ExecApprovalsAgentOption[];
allowlist: ExecApprovalsAllowlistEntry[];
target: "gateway" | "node";
targetNodeId: string | null;
targetNodes: ExecApprovalsTargetNode[];
onSelectScope: (agentId: string) => void;
onSelectTarget: (kind: "gateway" | "node", nodeId: string | null) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
onRemove: (path: Array<string | number>) => void;
onLoad: () => void;
onSave: () => void;
};
const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__";
const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [
{ value: "deny", label: "Deny" },
{ value: "allowlist", label: "Allowlist" },
{ value: "full", label: "Full" },
];
const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [
{ value: "off", label: "Off" },
{ value: "on-miss", label: "On miss" },
{ value: "always", label: "Always" },
];
function normalizeSecurity(value?: string): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") {
return value;
}
return "deny";
}
function normalizeAsk(value?: string): ExecAsk {
if (value === "always" || value === "off" || value === "on-miss") {
return value;
}
return "on-miss";
}
function resolveExecApprovalsDefaults(
form: ExecApprovalsFile | null,
): ExecApprovalsResolvedDefaults {
const defaults = form?.defaults ?? {};
return {
security: normalizeSecurity(defaults.security),
ask: normalizeAsk(defaults.ask),
askFallback: normalizeSecurity(defaults.askFallback ?? "deny"),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? false),
};
}
function resolveConfigAgents(config: Record<string, unknown> | null): ExecApprovalsAgentOption[] {
const agentsNode = (config?.agents ?? {}) as Record<string, unknown>;
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
const agents: ExecApprovalsAgentOption[] = [];
list.forEach((entry) => {
if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) {
return;
}
const name = typeof record.name === "string" ? record.name.trim() : undefined;
const isDefault = record.default === true;
agents.push({ id, name: name || undefined, isDefault });
});
return agents;
}
function resolveExecApprovalsAgents(
config: Record<string, unknown> | null,
form: ExecApprovalsFile | null,
): ExecApprovalsAgentOption[] {
const configAgents = resolveConfigAgents(config);
const approvalsAgents = Object.keys(form?.agents ?? {});
const merged = new Map<string, ExecApprovalsAgentOption>();
configAgents.forEach((agent) => merged.set(agent.id, agent));
approvalsAgents.forEach((id) => {
if (merged.has(id)) {
return;
}
merged.set(id, { id });
});
const agents = Array.from(merged.values());
if (agents.length === 0) {
agents.push({ id: "main", isDefault: true });
}
agents.sort((a, b) => {
if (a.isDefault && !b.isDefault) {
return -1;
}
if (!a.isDefault && b.isDefault) {
return 1;
}
const aLabel = a.name?.trim() ? a.name : a.id;
const bLabel = b.name?.trim() ? b.name : b.id;
return aLabel.localeCompare(bLabel);
});
return agents;
}
function resolveExecApprovalsScope(
selected: string | null,
agents: ExecApprovalsAgentOption[],
): string {
if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) {
return EXEC_APPROVALS_DEFAULT_SCOPE;
}
if (selected && agents.some((agent) => agent.id === selected)) {
return selected;
}
return EXEC_APPROVALS_DEFAULT_SCOPE;
}
export function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const form = props.execApprovalsForm ?? props.execApprovalsSnapshot?.file ?? null;
const ready = Boolean(form);
const defaults = resolveExecApprovalsDefaults(form);
const agents = resolveExecApprovalsAgents(props.configForm, form);
const targetNodes = resolveExecApprovalsNodes(props.nodes);
const target = props.execApprovalsTarget;
let targetNodeId =
target === "node" && props.execApprovalsTargetNodeId ? props.execApprovalsTargetNodeId : null;
if (target === "node" && targetNodeId && !targetNodes.some((node) => node.id === targetNodeId)) {
targetNodeId = null;
}
const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents);
const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? (((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ?? null)
: null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? [])
: [];
return {
ready,
disabled: props.execApprovalsSaving || props.execApprovalsLoading,
dirty: props.execApprovalsDirty,
loading: props.execApprovalsLoading,
saving: props.execApprovalsSaving,
form,
defaults,
selectedScope,
selectedAgent,
agents,
allowlist,
target,
targetNodeId,
targetNodes,
onSelectScope: props.onExecApprovalsSelectAgent,
onSelectTarget: props.onExecApprovalsTargetChange,
onPatch: props.onExecApprovalsPatch,
onRemove: props.onExecApprovalsRemove,
onLoad: props.onLoadExecApprovals,
onSave: props.onSaveExecApprovals,
};
}
export function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready;
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
return html`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec approvals</div>
<div class="card-sub">
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
</div>
</div>
<button
class="btn"
?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
</button>
</div>
${renderExecApprovalsTarget(state)}
${
!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
</button>
</div>`
: html`
${renderExecApprovalsTabs(state)}
${renderExecApprovalsPolicy(state)}
${
state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)
}
`
}
</section>
`;
}
function renderExecApprovalsTarget(state: ExecApprovalsState) {
const hasNodes = state.targetNodes.length > 0;
const nodeValue = state.targetNodeId ?? "";
return html`
<div class="list" style="margin-top: 12px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Target</div>
<div class="list-sub">
Gateway edits local approvals; node edits the selected node.
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Host</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (value === "node") {
const first = state.targetNodes[0]?.id ?? null;
state.onSelectTarget("node", nodeValue || first);
} else {
state.onSelectTarget("gateway", null);
}
}}
>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
</select>
</label>
${
state.target === "node"
? html`
<label class="field">
<span>Node</span>
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onSelectTarget("node", value ? value : null);
}}
>
<option value="" ?selected=${nodeValue === ""}>Select node</option>
${state.targetNodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${nodeValue === node.id}
>
${node.label}
</option>`,
)}
</select>
</label>
`
: nothing
}
</div>
</div>
${
state.target === "node" && !hasNodes
? html`
<div class="muted">No nodes advertise exec approvals yet.</div>
`
: nothing
}
</div>
`;
}
function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
>
Defaults
</button>
${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
return html`
<button
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}"
@click=${() => state.onSelectScope(agent.id)}
>
${label}
</button>
`;
})}
</div>
</div>
`;
}
function renderExecApprovalsPolicy(state: ExecApprovalsState) {
const isDefaults = state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE;
const defaults = state.defaults;
const agent = state.selectedAgent ?? {};
const basePath = isDefaults ? ["defaults"] : ["agents", state.selectedScope];
const agentSecurity = typeof agent.security === "string" ? agent.security : undefined;
const agentAsk = typeof agent.ask === "string" ? agent.ask : undefined;
const agentAskFallback = typeof agent.askFallback === "string" ? agent.askFallback : undefined;
const securityValue = isDefaults ? defaults.security : (agentSecurity ?? "__default__");
const askValue = isDefaults ? defaults.ask : (agentAsk ?? "__default__");
const askFallbackValue = isDefaults ? defaults.askFallback : (agentAskFallback ?? "__default__");
const autoOverride =
typeof agent.autoAllowSkills === "boolean" ? agent.autoAllowSkills : undefined;
const autoEffective = autoOverride ?? defaults.autoAllowSkills;
const autoIsDefault = autoOverride == null;
return html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Security</div>
<div class="list-sub">
${isDefaults ? "Default security mode." : `Default: ${defaults.security}.`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Mode</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "security"]);
} else {
state.onPatch([...basePath, "security"], value);
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
Use default (${defaults.security})
</option>`
: nothing
}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${securityValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Ask</div>
<div class="list-sub">
${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Mode</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "ask"]);
} else {
state.onPatch([...basePath, "ask"], value);
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
Use default (${defaults.ask})
</option>`
: nothing
}
${ASK_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${askValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Ask fallback</div>
<div class="list-sub">
${
isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`
}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Fallback</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "askFallback"]);
} else {
state.onPatch([...basePath, "askFallback"], value);
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
Use default (${defaults.askFallback})
</option>`
: nothing
}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${askFallbackValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Auto-allow skill CLIs</div>
<div class="list-sub">
${
isDefaults
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`
}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Enabled</span>
<input
type="checkbox"
?disabled=${state.disabled}
.checked=${autoEffective}
@change=${(event: Event) => {
const target = event.target as HTMLInputElement;
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
}}
/>
</label>
${
!isDefaults && !autoIsDefault
? html`<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
>
Use default
</button>`
: nothing
}
</div>
</div>
</div>
`;
}
function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
const allowlistPath = ["agents", state.selectedScope, "allowlist"];
const entries = state.allowlist;
return html`
<div class="row" style="margin-top: 18px; justify-content: space-between;">
<div>
<div class="card-title">Allowlist</div>
<div class="card-sub">Case-insensitive glob patterns.</div>
</div>
<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => {
const next = [...entries, { pattern: "" }];
state.onPatch(allowlistPath, next);
}}
>
Add pattern
</button>
</div>
<div class="list" style="margin-top: 12px;">
${
entries.length === 0
? html`
<div class="muted">No allowlist entries yet.</div>
`
: entries.map((entry, index) => renderAllowlistEntry(state, entry, index))
}
</div>
`;
}
function renderAllowlistEntry(
state: ExecApprovalsState,
entry: ExecApprovalsAllowlistEntry,
index: number,
) {
const lastUsed = entry.lastUsedAt ? formatRelativeTimestamp(entry.lastUsedAt) : "never";
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.pattern?.trim() ? entry.pattern : "New pattern"}</div>
<div class="list-sub">Last used: ${lastUsed}</div>
${lastCommand ? html`<div class="list-sub mono">${lastCommand}</div>` : nothing}
${lastPath ? html`<div class="list-sub mono">${lastPath}</div>` : nothing}
</div>
<div class="list-meta">
<label class="field">
<span>Pattern</span>
<input
type="text"
.value=${entry.pattern ?? ""}
?disabled=${state.disabled}
@input=${(event: Event) => {
const target = event.target as HTMLInputElement;
state.onPatch(
["agents", state.selectedScope, "allowlist", index, "pattern"],
target.value,
);
}}
/>
</label>
<button
class="btn btn--sm danger"
?disabled=${state.disabled}
@click=${() => {
if (state.allowlist.length <= 1) {
state.onRemove(["agents", state.selectedScope, "allowlist"]);
return;
}
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
}}
>
Remove
</button>
</div>
</div>
`;
}
function resolveExecApprovalsNodes(
nodes: Array<Record<string, unknown>>,
): ExecApprovalsTargetNode[] {
const list: ExecApprovalsTargetNode[] = [];
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some(
(cmd) =>
String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
);
if (!supports) {
continue;
}
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) {
continue;
}
const displayName =
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
}

View File

@@ -5,13 +5,9 @@ import type {
PairedDevice,
PendingDevice,
} from "../controllers/devices.ts";
import type {
ExecApprovalsAllowlistEntry,
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "../controllers/exec-approvals.ts";
import { clampText, formatRelativeTimestamp, formatList } from "../format.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "../controllers/exec-approvals.ts";
import { formatRelativeTimestamp, formatList } from "../format.ts";
import { renderExecApprovals, resolveExecApprovalsState } from "./nodes-exec-approvals.ts";
export type NodesProps = {
loading: boolean;
nodes: Array<Record<string, unknown>>;
@@ -248,64 +244,6 @@ type BindingState = {
formMode: "form" | "raw";
};
type ExecSecurity = "deny" | "allowlist" | "full";
type ExecAsk = "off" | "on-miss" | "always";
type ExecApprovalsResolvedDefaults = {
security: ExecSecurity;
ask: ExecAsk;
askFallback: ExecSecurity;
autoAllowSkills: boolean;
};
type ExecApprovalsAgentOption = {
id: string;
name?: string;
isDefault?: boolean;
};
type ExecApprovalsTargetNode = {
id: string;
label: string;
};
type ExecApprovalsState = {
ready: boolean;
disabled: boolean;
dirty: boolean;
loading: boolean;
saving: boolean;
form: ExecApprovalsFile | null;
defaults: ExecApprovalsResolvedDefaults;
selectedScope: string;
selectedAgent: Record<string, unknown> | null;
agents: ExecApprovalsAgentOption[];
allowlist: ExecApprovalsAllowlistEntry[];
target: "gateway" | "node";
targetNodeId: string | null;
targetNodes: ExecApprovalsTargetNode[];
onSelectScope: (agentId: string) => void;
onSelectTarget: (kind: "gateway" | "node", nodeId: string | null) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
onRemove: (path: Array<string | number>) => void;
onLoad: () => void;
onSave: () => void;
};
const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__";
const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [
{ value: "deny", label: "Deny" },
{ value: "allowlist", label: "Allowlist" },
{ value: "full", label: "Full" },
];
const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [
{ value: "off", label: "Off" },
{ value: "on-miss", label: "On miss" },
{ value: "always", label: "Always" },
];
function resolveBindingsState(props: NodesProps): BindingState {
const config = props.configForm;
const nodes = resolveExecNodes(props.nodes);
@@ -329,141 +267,6 @@ function resolveBindingsState(props: NodesProps): BindingState {
};
}
function normalizeSecurity(value?: string): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") {
return value;
}
return "deny";
}
function normalizeAsk(value?: string): ExecAsk {
if (value === "always" || value === "off" || value === "on-miss") {
return value;
}
return "on-miss";
}
function resolveExecApprovalsDefaults(
form: ExecApprovalsFile | null,
): ExecApprovalsResolvedDefaults {
const defaults = form?.defaults ?? {};
return {
security: normalizeSecurity(defaults.security),
ask: normalizeAsk(defaults.ask),
askFallback: normalizeSecurity(defaults.askFallback ?? "deny"),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? false),
};
}
function resolveConfigAgents(config: Record<string, unknown> | null): ExecApprovalsAgentOption[] {
const agentsNode = (config?.agents ?? {}) as Record<string, unknown>;
const list = Array.isArray(agentsNode.list) ? agentsNode.list : [];
const agents: ExecApprovalsAgentOption[] = [];
list.forEach((entry) => {
if (!entry || typeof entry !== "object") {
return;
}
const record = entry as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) {
return;
}
const name = typeof record.name === "string" ? record.name.trim() : undefined;
const isDefault = record.default === true;
agents.push({ id, name: name || undefined, isDefault });
});
return agents;
}
function resolveExecApprovalsAgents(
config: Record<string, unknown> | null,
form: ExecApprovalsFile | null,
): ExecApprovalsAgentOption[] {
const configAgents = resolveConfigAgents(config);
const approvalsAgents = Object.keys(form?.agents ?? {});
const merged = new Map<string, ExecApprovalsAgentOption>();
configAgents.forEach((agent) => merged.set(agent.id, agent));
approvalsAgents.forEach((id) => {
if (merged.has(id)) {
return;
}
merged.set(id, { id });
});
const agents = Array.from(merged.values());
if (agents.length === 0) {
agents.push({ id: "main", isDefault: true });
}
agents.sort((a, b) => {
if (a.isDefault && !b.isDefault) {
return -1;
}
if (!a.isDefault && b.isDefault) {
return 1;
}
const aLabel = a.name?.trim() ? a.name : a.id;
const bLabel = b.name?.trim() ? b.name : b.id;
return aLabel.localeCompare(bLabel);
});
return agents;
}
function resolveExecApprovalsScope(
selected: string | null,
agents: ExecApprovalsAgentOption[],
): string {
if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) {
return EXEC_APPROVALS_DEFAULT_SCOPE;
}
if (selected && agents.some((agent) => agent.id === selected)) {
return selected;
}
return EXEC_APPROVALS_DEFAULT_SCOPE;
}
function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const form = props.execApprovalsForm ?? props.execApprovalsSnapshot?.file ?? null;
const ready = Boolean(form);
const defaults = resolveExecApprovalsDefaults(form);
const agents = resolveExecApprovalsAgents(props.configForm, form);
const targetNodes = resolveExecApprovalsNodes(props.nodes);
const target = props.execApprovalsTarget;
let targetNodeId =
target === "node" && props.execApprovalsTargetNodeId ? props.execApprovalsTargetNodeId : null;
if (target === "node" && targetNodeId && !targetNodes.some((node) => node.id === targetNodeId)) {
targetNodeId = null;
}
const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents);
const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? (((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ?? null)
: null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? [])
: [];
return {
ready,
disabled: props.execApprovalsSaving || props.execApprovalsLoading,
dirty: props.execApprovalsDirty,
loading: props.execApprovalsLoading,
saving: props.execApprovalsSaving,
form,
defaults,
selectedScope,
selectedAgent,
agents,
allowlist,
target,
targetNodeId,
targetNodes,
onSelectScope: props.onExecApprovalsSelectAgent,
onSelectTarget: props.onExecApprovalsTargetChange,
onPatch: props.onExecApprovalsPatch,
onRemove: props.onExecApprovalsRemove,
onLoad: props.onLoadExecApprovals,
onSave: props.onSaveExecApprovals,
};
}
function renderBindings(state: BindingState) {
const supportsBinding = state.nodes.length > 0;
const defaultValue = state.defaultBinding ?? "";
@@ -557,427 +360,6 @@ function renderBindings(state: BindingState) {
`;
}
function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready;
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
return html`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec approvals</div>
<div class="card-sub">
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
</div>
</div>
<button
class="btn"
?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
</button>
</div>
${renderExecApprovalsTarget(state)}
${
!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
</button>
</div>`
: html`
${renderExecApprovalsTabs(state)}
${renderExecApprovalsPolicy(state)}
${
state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)
}
`
}
</section>
`;
}
function renderExecApprovalsTarget(state: ExecApprovalsState) {
const hasNodes = state.targetNodes.length > 0;
const nodeValue = state.targetNodeId ?? "";
return html`
<div class="list" style="margin-top: 12px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Target</div>
<div class="list-sub">
Gateway edits local approvals; node edits the selected node.
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Host</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (value === "node") {
const first = state.targetNodes[0]?.id ?? null;
state.onSelectTarget("node", nodeValue || first);
} else {
state.onSelectTarget("gateway", null);
}
}}
>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
</select>
</label>
${
state.target === "node"
? html`
<label class="field">
<span>Node</span>
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onSelectTarget("node", value ? value : null);
}}
>
<option value="" ?selected=${nodeValue === ""}>Select node</option>
${state.targetNodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${nodeValue === node.id}
>
${node.label}
</option>`,
)}
</select>
</label>
`
: nothing
}
</div>
</div>
${
state.target === "node" && !hasNodes
? html`
<div class="muted">No nodes advertise exec approvals yet.</div>
`
: nothing
}
</div>
`;
}
function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
>
Defaults
</button>
${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
return html`
<button
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}"
@click=${() => state.onSelectScope(agent.id)}
>
${label}
</button>
`;
})}
</div>
</div>
`;
}
function renderExecApprovalsPolicy(state: ExecApprovalsState) {
const isDefaults = state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE;
const defaults = state.defaults;
const agent = state.selectedAgent ?? {};
const basePath = isDefaults ? ["defaults"] : ["agents", state.selectedScope];
const agentSecurity = typeof agent.security === "string" ? agent.security : undefined;
const agentAsk = typeof agent.ask === "string" ? agent.ask : undefined;
const agentAskFallback = typeof agent.askFallback === "string" ? agent.askFallback : undefined;
const securityValue = isDefaults ? defaults.security : (agentSecurity ?? "__default__");
const askValue = isDefaults ? defaults.ask : (agentAsk ?? "__default__");
const askFallbackValue = isDefaults ? defaults.askFallback : (agentAskFallback ?? "__default__");
const autoOverride =
typeof agent.autoAllowSkills === "boolean" ? agent.autoAllowSkills : undefined;
const autoEffective = autoOverride ?? defaults.autoAllowSkills;
const autoIsDefault = autoOverride == null;
return html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Security</div>
<div class="list-sub">
${isDefaults ? "Default security mode." : `Default: ${defaults.security}.`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Mode</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "security"]);
} else {
state.onPatch([...basePath, "security"], value);
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
Use default (${defaults.security})
</option>`
: nothing
}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${securityValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Ask</div>
<div class="list-sub">
${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Mode</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "ask"]);
} else {
state.onPatch([...basePath, "ask"], value);
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
Use default (${defaults.ask})
</option>`
: nothing
}
${ASK_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${askValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Ask fallback</div>
<div class="list-sub">
${
isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`
}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Fallback</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (!isDefaults && value === "__default__") {
state.onRemove([...basePath, "askFallback"]);
} else {
state.onPatch([...basePath, "askFallback"], value);
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
Use default (${defaults.askFallback})
</option>`
: nothing
}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${askFallbackValue === option.value}
>
${option.label}
</option>`,
)}
</select>
</label>
</div>
</div>
<div class="list-item">
<div class="list-main">
<div class="list-title">Auto-allow skill CLIs</div>
<div class="list-sub">
${
isDefaults
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`
}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Enabled</span>
<input
type="checkbox"
?disabled=${state.disabled}
.checked=${autoEffective}
@change=${(event: Event) => {
const target = event.target as HTMLInputElement;
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
}}
/>
</label>
${
!isDefaults && !autoIsDefault
? html`<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
>
Use default
</button>`
: nothing
}
</div>
</div>
</div>
`;
}
function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
const allowlistPath = ["agents", state.selectedScope, "allowlist"];
const entries = state.allowlist;
return html`
<div class="row" style="margin-top: 18px; justify-content: space-between;">
<div>
<div class="card-title">Allowlist</div>
<div class="card-sub">Case-insensitive glob patterns.</div>
</div>
<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => {
const next = [...entries, { pattern: "" }];
state.onPatch(allowlistPath, next);
}}
>
Add pattern
</button>
</div>
<div class="list" style="margin-top: 12px;">
${
entries.length === 0
? html`
<div class="muted">No allowlist entries yet.</div>
`
: entries.map((entry, index) => renderAllowlistEntry(state, entry, index))
}
</div>
`;
}
function renderAllowlistEntry(
state: ExecApprovalsState,
entry: ExecApprovalsAllowlistEntry,
index: number,
) {
const lastUsed = entry.lastUsedAt ? formatRelativeTimestamp(entry.lastUsedAt) : "never";
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.pattern?.trim() ? entry.pattern : "New pattern"}</div>
<div class="list-sub">Last used: ${lastUsed}</div>
${lastCommand ? html`<div class="list-sub mono">${lastCommand}</div>` : nothing}
${lastPath ? html`<div class="list-sub mono">${lastPath}</div>` : nothing}
</div>
<div class="list-meta">
<label class="field">
<span>Pattern</span>
<input
type="text"
.value=${entry.pattern ?? ""}
?disabled=${state.disabled}
@input=${(event: Event) => {
const target = event.target as HTMLInputElement;
state.onPatch(
["agents", state.selectedScope, "allowlist", index, "pattern"],
target.value,
);
}}
/>
</label>
<button
class="btn btn--sm danger"
?disabled=${state.disabled}
@click=${() => {
if (state.allowlist.length <= 1) {
state.onRemove(["agents", state.selectedScope, "allowlist"]);
return;
}
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
}}
>
Remove
</button>
</div>
</div>
`;
}
function renderAgentBinding(agent: BindingAgent, state: BindingState) {
const bindingValue = agent.binding ?? "__default__";
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
@@ -1050,36 +432,6 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
return list;
}
function resolveExecApprovalsNodes(
nodes: Array<Record<string, unknown>>,
): ExecApprovalsTargetNode[] {
const list: ExecApprovalsTargetNode[] = [];
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some(
(cmd) =>
String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
);
if (!supports) {
continue;
}
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) {
continue;
}
const displayName =
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
}
function resolveAgentBindings(config: Record<string, unknown> | null): {
defaultBinding?: string | null;
agents: BindingAgent[];