feat: add exec approvals editor in control ui and mac app

This commit is contained in:
Peter Steinberger
2026-01-18 08:54:34 +00:00
parent b739a3897f
commit 4de3c3a028
18 changed files with 1116 additions and 45 deletions

View File

@@ -61,6 +61,12 @@ import {
updateConfigFormValue,
removeConfigFormValue,
} from "./controllers/config";
import {
loadExecApprovals,
removeExecApprovalsFormValue,
saveExecApprovals,
updateExecApprovalsFormValue,
} from "./controllers/exec-approvals";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
@@ -298,8 +304,15 @@ export function renderApp(state: AppViewState) {
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
onRefresh: () => loadNodes(state),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => loadExecApprovals(state),
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
@@ -316,6 +329,14 @@ export function renderApp(state: AppViewState) {
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => saveExecApprovals(state),
})
: nothing}

View File

@@ -4,6 +4,7 @@ import { loadChannels } from "./controllers/channels";
import { loadDebug } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
import { loadNodes } from "./controllers/nodes";
import { loadExecApprovals } from "./controllers/exec-approvals";
import { loadPresence } from "./controllers/presence";
import { loadSessions } from "./controllers/sessions";
import { loadSkills } from "./controllers/skills";
@@ -133,6 +134,7 @@ export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "nodes") {
await loadNodes(host as unknown as ClawdbotApp);
await loadConfig(host as unknown as ClawdbotApp);
await loadExecApprovals(host as unknown as ClawdbotApp);
}
if (host.tab === "chat") {
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);

View File

@@ -20,6 +20,10 @@ import type {
import type { ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
export type AppViewState = {
settings: UiSettings;
@@ -44,6 +48,12 @@ export type AppViewState = {
chatQueue: ChatQueueItem[];
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;
@@ -160,4 +170,3 @@ export type AppViewState = {
handleLogsAutoFollowToggle: (next: boolean) => void;
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
};

View File

@@ -24,6 +24,10 @@ import type {
import { type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import {
resetToolStream as resetToolStreamInternal,
toggleToolOutput as toggleToolOutputInternal,
@@ -104,6 +108,12 @@ export class ClawdbotApp extends LitElement {
@state() nodesLoading = false;
@state() nodes: Array<Record<string, unknown>> = [];
@state() execApprovalsLoading = false;
@state() execApprovalsSaving = false;
@state() execApprovalsDirty = false;
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
@state() execApprovalsForm: ExecApprovalsFile | null = null;
@state() execApprovalsSelectedAgent: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";

View File

@@ -0,0 +1,123 @@
import type { GatewayBrowserClient } from "../gateway";
import { cloneConfigObject, removePathValue, setPathValue } from "./config/form-utils";
export type ExecApprovalsDefaults = {
security?: string;
ask?: string;
askFallback?: string;
autoAllowSkills?: boolean;
};
export type ExecApprovalsAllowlistEntry = {
pattern: string;
lastUsedAt?: number;
lastUsedCommand?: string;
lastResolvedPath?: string;
};
export type ExecApprovalsAgent = ExecApprovalsDefaults & {
allowlist?: ExecApprovalsAllowlistEntry[];
};
export type ExecApprovalsFile = {
version?: number;
socket?: { path?: string };
defaults?: ExecApprovalsDefaults;
agents?: Record<string, ExecApprovalsAgent>;
};
export type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
export type ExecApprovalsState = {
client: GatewayBrowserClient | null;
connected: boolean;
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
lastError: string | null;
};
export async function loadExecApprovals(state: ExecApprovalsState) {
if (!state.client || !state.connected) return;
if (state.execApprovalsLoading) return;
state.execApprovalsLoading = true;
state.lastError = null;
try {
const res = (await state.client.request(
"exec.approvals.get",
{},
)) as ExecApprovalsSnapshot;
applyExecApprovalsSnapshot(state, res);
} catch (err) {
state.lastError = String(err);
} finally {
state.execApprovalsLoading = false;
}
}
export function applyExecApprovalsSnapshot(
state: ExecApprovalsState,
snapshot: ExecApprovalsSnapshot,
) {
state.execApprovalsSnapshot = snapshot;
if (!state.execApprovalsDirty) {
state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {});
}
}
export async function saveExecApprovals(state: ExecApprovalsState) {
if (!state.client || !state.connected) return;
state.execApprovalsSaving = true;
state.lastError = null;
try {
const baseHash = state.execApprovalsSnapshot?.hash;
if (!baseHash) {
state.lastError = "Exec approvals hash missing; reload and retry.";
return;
}
const file =
state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ??
{};
await state.client.request("exec.approvals.set", { file, baseHash });
state.execApprovalsDirty = false;
await loadExecApprovals(state);
} catch (err) {
state.lastError = String(err);
} finally {
state.execApprovalsSaving = false;
}
}
export function updateExecApprovalsFormValue(
state: ExecApprovalsState,
path: Array<string | number>,
value: unknown,
) {
const base = cloneConfigObject(
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
);
setPathValue(base, path, value);
state.execApprovalsForm = base;
state.execApprovalsDirty = true;
}
export function removeExecApprovalsFormValue(
state: ExecApprovalsState,
path: Array<string | number>,
) {
const base = cloneConfigObject(
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
);
removePathValue(base, path);
state.execApprovalsForm = base;
state.execApprovalsDirty = true;
}

View File

@@ -1,5 +1,12 @@
import { html, nothing } from "lit";
import { clampText, formatAgo } from "../format";
import type {
ExecApprovalsAllowlistEntry,
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "../controllers/exec-approvals";
export type NodesProps = {
loading: boolean;
nodes: Array<Record<string, unknown>>;
@@ -8,16 +15,29 @@ export type NodesProps = {
configSaving: boolean;
configDirty: boolean;
configFormMode: "form" | "raw";
execApprovalsLoading: boolean;
execApprovalsSaving: boolean;
execApprovalsDirty: boolean;
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
onRefresh: () => void;
onLoadConfig: () => void;
onLoadExecApprovals: () => void;
onBindDefault: (nodeId: string | null) => void;
onBindAgent: (agentIndex: number, nodeId: string | null) => void;
onSaveBindings: () => void;
onExecApprovalsSelectAgent: (agentId: string) => void;
onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void;
onExecApprovalsRemove: (path: Array<string | number>) => void;
onSaveExecApprovals: () => void;
};
export function renderNodes(props: NodesProps) {
const bindingState = resolveBindingsState(props);
const approvalsState = resolveExecApprovalsState(props);
return html`
${renderExecApprovals(approvalsState)}
${renderBindings(bindingState)}
<section class="card">
<div class="row" style="justify-content: space-between;">
@@ -67,6 +87,55 @@ 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 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[];
onSelectScope: (agentId: string) => 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);
@@ -90,6 +159,114 @@ 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 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,
onSelectScope: props.onExecApprovalsSelectAgent,
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 ?? "";
@@ -171,6 +348,342 @@ function renderBindings(state: BindingState) {
`;
}
function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready;
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}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
</button>
</div>
${!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} @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 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 ? formatAgo(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;