feat: add node binding controls in control ui
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user