feat: add exec approvals tooling and service status

This commit is contained in:
Peter Steinberger
2026-01-18 15:23:36 +00:00
parent 9c06689569
commit 3686bde783
39 changed files with 1472 additions and 35 deletions

View File

@@ -310,9 +310,17 @@ export function renderApp(state: AppViewState) {
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => loadExecApprovals(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
@@ -329,6 +337,14 @@ export function renderApp(state: AppViewState) {
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
@@ -336,7 +352,13 @@ export function renderApp(state: AppViewState) {
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => saveExecApprovals(state),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}

View File

@@ -54,6 +54,8 @@ export type AppViewState = {
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;

View File

@@ -114,6 +114,8 @@ export class ClawdbotApp extends LitElement {
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
@state() execApprovalsForm: ExecApprovalsFile | null = null;
@state() execApprovalsSelectedAgent: string | null = null;
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
@state() execApprovalsTargetNodeId: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";

View File

@@ -33,6 +33,10 @@ export type ExecApprovalsSnapshot = {
file: ExecApprovalsFile;
};
export type ExecApprovalsTarget =
| { kind: "gateway" }
| { kind: "node"; nodeId: string };
export type ExecApprovalsState = {
client: GatewayBrowserClient | null;
connected: boolean;
@@ -45,16 +49,45 @@ export type ExecApprovalsState = {
lastError: string | null;
};
export async function loadExecApprovals(state: ExecApprovalsState) {
function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): {
method: string;
params: Record<string, unknown>;
} | null {
if (!target || target.kind === "gateway") {
return { method: "exec.approvals.get", params: {} };
}
const nodeId = target.nodeId.trim();
if (!nodeId) return null;
return { method: "exec.approvals.node.get", params: { nodeId } };
}
function resolveExecApprovalsSaveRpc(
target: ExecApprovalsTarget | null | undefined,
params: { file: ExecApprovalsFile; baseHash: string },
): { method: string; params: Record<string, unknown> } | null {
if (!target || target.kind === "gateway") {
return { method: "exec.approvals.set", params };
}
const nodeId = target.nodeId.trim();
if (!nodeId) return null;
return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
}
export async function loadExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
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;
const rpc = resolveExecApprovalsRpc(target);
if (!rpc) {
state.lastError = "Select a node before loading exec approvals.";
return;
}
const res = (await state.client.request(rpc.method, rpc.params)) as ExecApprovalsSnapshot;
applyExecApprovalsSnapshot(state, res);
} catch (err) {
state.lastError = String(err);
@@ -73,7 +106,10 @@ export function applyExecApprovalsSnapshot(
}
}
export async function saveExecApprovals(state: ExecApprovalsState) {
export async function saveExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
if (!state.client || !state.connected) return;
state.execApprovalsSaving = true;
state.lastError = null;
@@ -87,9 +123,14 @@ export async function saveExecApprovals(state: ExecApprovalsState) {
state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ??
{};
await state.client.request("exec.approvals.set", { file, baseHash });
const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash });
if (!rpc) {
state.lastError = "Select a node before saving exec approvals.";
return;
}
await state.client.request(rpc.method, rpc.params);
state.execApprovalsDirty = false;
await loadExecApprovals(state);
await loadExecApprovals(state, target);
} catch (err) {
state.lastError = String(err);
} finally {

View File

@@ -21,12 +21,15 @@ export type NodesProps = {
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
onRefresh: () => void;
onLoadConfig: () => void;
onLoadExecApprovals: () => void;
onBindDefault: (nodeId: string | null) => void;
onBindAgent: (agentIndex: number, nodeId: string | null) => void;
onSaveBindings: () => void;
onExecApprovalsTargetChange: (kind: "gateway" | "node", nodeId: string | null) => void;
onExecApprovalsSelectAgent: (agentId: string) => void;
onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void;
onExecApprovalsRemove: (path: Array<string | number>) => void;
@@ -103,6 +106,11 @@ type ExecApprovalsAgentOption = {
isDefault?: boolean;
};
type ExecApprovalsTargetNode = {
id: string;
label: string;
};
type ExecApprovalsState = {
ready: boolean;
disabled: boolean;
@@ -115,7 +123,11 @@ type ExecApprovalsState = {
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;
@@ -237,6 +249,15 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
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
@@ -259,7 +280,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
selectedAgent,
agents,
allowlist,
target,
targetNodeId,
targetNodes,
onSelectScope: props.onExecApprovalsSelectAgent,
onSelectTarget: props.onExecApprovalsTargetChange,
onPatch: props.onExecApprovalsPatch,
onRemove: props.onExecApprovalsRemove,
onLoad: props.onLoadExecApprovals,
@@ -350,6 +375,7 @@ 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;">
@@ -361,17 +387,19 @@ function renderExecApprovals(state: ExecApprovalsState) {
</div>
<button
class="btn"
?disabled=${state.disabled || !state.dirty}
?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} @click=${state.onLoad}>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
</button>
</div>`
@@ -386,6 +414,73 @@ function renderExecApprovals(state: ExecApprovalsState) {
`;
}
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;">
@@ -747,6 +842,26 @@ 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[];