feat: add exec approvals editor in control ui and mac app
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
123
ui/src/ui/controllers/exec-approvals.ts
Normal file
123
ui/src/ui/controllers/exec-approvals.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user