feat: add exec approvals tooling and service status
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user