feat: add node binding controls in control ui

This commit is contained in:
Peter Steinberger
2026-01-18 08:26:28 +00:00
parent d3862ae30a
commit 68d79e56c2
5 changed files with 317 additions and 3 deletions

View File

@@ -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).

View File

@@ -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`.

View File

@@ -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<string, unknown> | 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}

View File

@@ -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<typeof refreshChat>[0]);
scheduleChatScroll(

View File

@@ -1,13 +1,24 @@
import { html } from "lit";
import { html, nothing } from "lit";
export type NodesProps = {
loading: boolean;
nodes: Array<Record<string, unknown>>;
configForm: Record<string, unknown> | 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)}
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
@@ -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`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec node binding</div>
<div class="card-sub">
Pin agents to a specific node when using <span class="mono">exec host=node</span>.
</div>
</div>
<button
class="btn"
?disabled=${state.disabled || !state.configDirty}
@click=${state.onSave}
>
${state.configSaving ? "Saving…" : "Save"}
</button>
</div>
${state.formMode === "raw"
? html`<div class="callout warn" style="margin-top: 12px;">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
</div>`
: nothing}
${!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load config to edit bindings.</div>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"}
</button>
</div>`
: html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Default binding</div>
<div class="list-sub">Used when agents do not override a node binding.</div>
</div>
<div class="list-meta">
<label class="field">
<span>Node</span>
<select
?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onBindDefault(value ? value : null);
}}
>
<option value="" ?selected=${defaultValue === ""}>Any node</option>
${state.nodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${defaultValue === node.id}
>
${node.label}
</option>`,
)}
</select>
</label>
${!supportsBinding
? html`<div class="muted">No nodes with system.run available.</div>`
: nothing}
</div>
</div>
${state.agents.length === 0
? html`<div class="muted">No agents found.</div>`
: state.agents.map((agent) =>
renderAgentBinding(agent, state),
)}
</div>
`}
</section>
`;
}
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`
<div class="list-item">
<div class="list-main">
<div class="list-title">${label}</div>
<div class="list-sub">
${agent.isDefault ? "default agent" : "agent"} ·
${bindingValue === "__default__"
? `uses default (${state.defaultBinding ?? "any"})`
: `override: ${agent.binding}`}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Binding</span>
<select
?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onBindAgent(agent.index, value === "__default__" ? null : value);
}}
>
<option value="__default__" ?selected=${bindingValue === "__default__"}>
Use default
</option>
${state.nodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${bindingValue === node.id}
>
${node.label}
</option>`,
)}
</select>
</label>
</div>
</div>
`;
}
function resolveExecNodes(nodes: Array<Record<string, unknown>>): 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<string, unknown> | 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<string, unknown>;
const exec = (tools.exec ?? {}) as Record<string, unknown>;
const defaultBinding =
typeof exec.node === "string" && exec.node.trim() ? exec.node.trim() : null;
const agentsNode = (config.agents ?? {}) as Record<string, unknown>;
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<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;
const toolsEntry = (record.tools ?? {}) as Record<string, unknown>;
const execEntry = (toolsEntry.exec ?? {}) as Record<string, unknown>;
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<string, unknown>) {
const connected = Boolean(node.connected);
const paired = Boolean(node.paired);
@@ -58,4 +320,3 @@ function renderNode(node: Record<string, unknown>) {
</div>
`;
}