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`.
|
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`).
|
- 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
|
## 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).
|
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"
|
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`)
|
## Session overrides (`/exec`)
|
||||||
|
|
||||||
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
|
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import {
|
|||||||
runUpdate,
|
runUpdate,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
updateConfigFormValue,
|
updateConfigFormValue,
|
||||||
|
removeConfigFormValue,
|
||||||
} from "./controllers/config";
|
} from "./controllers/config";
|
||||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||||
@@ -292,7 +293,29 @@ export function renderApp(state: AppViewState) {
|
|||||||
? renderNodes({
|
? renderNodes({
|
||||||
loading: state.nodesLoading,
|
loading: state.nodesLoading,
|
||||||
nodes: state.nodes,
|
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),
|
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}
|
: 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 === "sessions") await loadSessions(host as unknown as ClawdbotApp);
|
||||||
if (host.tab === "cron") await loadCron(host);
|
if (host.tab === "cron") await loadCron(host);
|
||||||
if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp);
|
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") {
|
if (host.tab === "chat") {
|
||||||
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||||
scheduleChatScroll(
|
scheduleChatScroll(
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { html } from "lit";
|
import { html, nothing } from "lit";
|
||||||
|
|
||||||
export type NodesProps = {
|
export type NodesProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
|
configForm: Record<string, unknown> | null;
|
||||||
|
configLoading: boolean;
|
||||||
|
configSaving: boolean;
|
||||||
|
configDirty: boolean;
|
||||||
|
configFormMode: "form" | "raw";
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onLoadConfig: () => void;
|
||||||
|
onBindDefault: (nodeId: string | null) => void;
|
||||||
|
onBindAgent: (agentIndex: number, nodeId: string | null) => void;
|
||||||
|
onSaveBindings: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderNodes(props: NodesProps) {
|
export function renderNodes(props: NodesProps) {
|
||||||
|
const bindingState = resolveBindingsState(props);
|
||||||
return html`
|
return html`
|
||||||
|
${renderBindings(bindingState)}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
<div>
|
<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>) {
|
function renderNode(node: Record<string, unknown>) {
|
||||||
const connected = Boolean(node.connected);
|
const connected = Boolean(node.connected);
|
||||||
const paired = Boolean(node.paired);
|
const paired = Boolean(node.paired);
|
||||||
@@ -58,4 +320,3 @@ function renderNode(node: Record<string, unknown>) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user