From 68d79e56c2ef91555ccd596cf520a85dd34c83fb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 08:26:28 +0000 Subject: [PATCH] feat: add node binding controls in control ui --- docs/nodes/index.md | 25 ++++ docs/tools/exec.md | 2 + ui/src/ui/app-render.ts | 23 ++++ ui/src/ui/app-settings.ts | 5 +- ui/src/ui/views/nodes.ts | 265 +++++++++++++++++++++++++++++++++++++- 5 files changed, 317 insertions(+), 3 deletions(-) diff --git a/docs/nodes/index.md b/docs/nodes/index.md index bf1af742b..f0ab9ec98 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -168,6 +168,31 @@ Notes: Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`. - On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`). +## Exec node binding + +When multiple nodes are available, you can bind exec to a specific node. +This sets the default node for `exec host=node` (and can be overridden per agent). + +Global default: + +```bash +clawdbot config set tools.exec.node "node-id-or-name" +``` + +Per-agent override: + +```bash +clawdbot config get agents.list +clawdbot config set agents.list[0].tools.exec.node "node-id-or-name" +``` + +Unset to allow any node: + +```bash +clawdbot config unset tools.exec.node +clawdbot config unset agents.list[0].tools.exec.node +``` + ## Permissions map Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted). diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 72cafe869..a66f2a53f 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -48,6 +48,8 @@ clawdbot config get agents.list clawdbot config set agents.list[0].tools.exec.node "node-id-or-name" ``` +Control UI: the Nodes tab includes a small “Exec node binding” panel for the same settings. + ## Session overrides (`/exec`) Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`. diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 60cadb0f2..7320e3d09 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -59,6 +59,7 @@ import { runUpdate, saveConfig, updateConfigFormValue, + removeConfigFormValue, } from "./controllers/config"; import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadDebug, callDebugMethod } from "./controllers/debug"; @@ -292,7 +293,29 @@ export function renderApp(state: AppViewState) { ? renderNodes({ loading: state.nodesLoading, nodes: state.nodes, + configForm: state.configForm ?? (state.configSnapshot?.config as Record | null), + configLoading: state.configLoading, + configSaving: state.configSaving, + configDirty: state.configFormDirty, + configFormMode: state.configFormMode, onRefresh: () => loadNodes(state), + onLoadConfig: () => loadConfig(state), + onBindDefault: (nodeId) => { + if (nodeId) { + updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); + } else { + removeConfigFormValue(state, ["tools", "exec", "node"]); + } + }, + onBindAgent: (agentIndex, nodeId) => { + const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"]; + if (nodeId) { + updateConfigFormValue(state, basePath, nodeId); + } else { + removeConfigFormValue(state, basePath); + } + }, + onSaveBindings: () => saveConfig(state), }) : nothing} diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 5cf41677c..b47895e00 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -130,7 +130,10 @@ export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "sessions") await loadSessions(host as unknown as ClawdbotApp); if (host.tab === "cron") await loadCron(host); if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp); - if (host.tab === "nodes") await loadNodes(host as unknown as ClawdbotApp); + if (host.tab === "nodes") { + await loadNodes(host as unknown as ClawdbotApp); + await loadConfig(host as unknown as ClawdbotApp); + } if (host.tab === "chat") { await refreshChat(host as unknown as Parameters[0]); scheduleChatScroll( diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index 44880c01d..0579b4ffa 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -1,13 +1,24 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; export type NodesProps = { loading: boolean; nodes: Array>; + configForm: Record | null; + configLoading: boolean; + configSaving: boolean; + configDirty: boolean; + configFormMode: "form" | "raw"; onRefresh: () => void; + onLoadConfig: () => void; + onBindDefault: (nodeId: string | null) => void; + onBindAgent: (agentIndex: number, nodeId: string | null) => void; + onSaveBindings: () => void; }; export function renderNodes(props: NodesProps) { + const bindingState = resolveBindingsState(props); return html` + ${renderBindings(bindingState)}
@@ -27,6 +38,257 @@ export function renderNodes(props: NodesProps) { `; } +type BindingAgent = { + id: string; + name?: string; + index: number; + isDefault: boolean; + binding?: string | null; +}; + +type BindingNode = { + id: string; + label: string; +}; + +type BindingState = { + ready: boolean; + disabled: boolean; + configDirty: boolean; + configLoading: boolean; + configSaving: boolean; + defaultBinding?: string | null; + agents: BindingAgent[]; + nodes: BindingNode[]; + onBindDefault: (nodeId: string | null) => void; + onBindAgent: (agentIndex: number, nodeId: string | null) => void; + onSave: () => void; + onLoadConfig: () => void; + formMode: "form" | "raw"; +}; + +function resolveBindingsState(props: NodesProps): BindingState { + const config = props.configForm; + const nodes = resolveExecNodes(props.nodes); + const { defaultBinding, agents } = resolveAgentBindings(config); + const ready = Boolean(config); + const disabled = props.configSaving || props.configFormMode === "raw"; + return { + ready, + disabled, + configDirty: props.configDirty, + configLoading: props.configLoading, + configSaving: props.configSaving, + defaultBinding, + agents, + nodes, + onBindDefault: props.onBindDefault, + onBindAgent: props.onBindAgent, + onSave: props.onSaveBindings, + onLoadConfig: props.onLoadConfig, + formMode: props.configFormMode, + }; +} + +function renderBindings(state: BindingState) { + const supportsBinding = state.nodes.length > 0; + const defaultValue = state.defaultBinding ?? ""; + return html` +
+
+
+
Exec node binding
+
+ Pin agents to a specific node when using exec host=node. +
+
+ +
+ + ${state.formMode === "raw" + ? html`
+ Switch the Config tab to Form mode to edit bindings here. +
` + : nothing} + + ${!state.ready + ? html`
+
Load config to edit bindings.
+ +
` + : html` +
+
+
+
Default binding
+
Used when agents do not override a node binding.
+
+
+ + ${!supportsBinding + ? html`
No nodes with system.run available.
` + : nothing} +
+
+ + ${state.agents.length === 0 + ? html`
No agents found.
` + : state.agents.map((agent) => + renderAgentBinding(agent, state), + )} +
+ `} +
+ `; +} + +function renderAgentBinding(agent: BindingAgent, state: BindingState) { + const bindingValue = agent.binding ?? "__default__"; + const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; + const supportsBinding = state.nodes.length > 0; + return html` +
+
+
${label}
+
+ ${agent.isDefault ? "default agent" : "agent"} · + ${bindingValue === "__default__" + ? `uses default (${state.defaultBinding ?? "any"})` + : `override: ${agent.binding}`} +
+
+
+ +
+
+ `; +} + +function resolveExecNodes(nodes: Array>): BindingNode[] { + const list: BindingNode[] = []; + for (const node of nodes) { + const commands = Array.isArray(node.commands) ? node.commands : []; + const supports = commands.some((cmd) => String(cmd) === "system.run"); + 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 | null): { + defaultBinding?: string | null; + agents: BindingAgent[]; +} { + const fallbackAgent: BindingAgent = { + id: "main", + name: undefined, + index: 0, + isDefault: true, + binding: null, + }; + if (!config || typeof config !== "object") { + return { defaultBinding: null, agents: [fallbackAgent] }; + } + const tools = (config.tools ?? {}) as Record; + const exec = (tools.exec ?? {}) as Record; + const defaultBinding = + typeof exec.node === "string" && exec.node.trim() ? exec.node.trim() : null; + + const agentsNode = (config.agents ?? {}) as Record; + const list = Array.isArray(agentsNode.list) ? agentsNode.list : []; + if (list.length === 0) { + return { defaultBinding, agents: [fallbackAgent] }; + } + + const agents: BindingAgent[] = []; + list.forEach((entry, index) => { + if (!entry || typeof entry !== "object") return; + const record = entry as Record; + 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; + const toolsEntry = (record.tools ?? {}) as Record; + const execEntry = (toolsEntry.exec ?? {}) as Record; + const binding = + typeof execEntry.node === "string" && execEntry.node.trim() + ? execEntry.node.trim() + : null; + agents.push({ + id, + name: name || undefined, + index, + isDefault, + binding, + }); + }); + + if (agents.length === 0) { + agents.push(fallbackAgent); + } + + return { defaultBinding, agents }; +} + function renderNode(node: Record) { const connected = Boolean(node.connected); const paired = Boolean(node.paired); @@ -58,4 +320,3 @@ function renderNode(node: Record) {
`; } -