Agents: add account-scoped bind and routing commands (#27195)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: ad35a458a5
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-02-26 02:36:56 -05:00
committed by GitHub
parent c5d040bbea
commit 96c7702526
17 changed files with 1133 additions and 24 deletions

View File

@@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
## 2026.2.26 (Unreleased)
### Changes
- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras.
### Fixes
- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.

View File

@@ -1,5 +1,5 @@
---
summary: "CLI reference for `openclaw agents` (list/add/delete/set identity)"
summary: "CLI reference for `openclaw agents` (list/add/delete/bindings/bind/unbind/set identity)"
read_when:
- You want multiple isolated agents (workspaces + routing + auth)
title: "agents"
@@ -19,11 +19,59 @@ Related:
```bash
openclaw agents list
openclaw agents add work --workspace ~/.openclaw/workspace-work
openclaw agents bindings
openclaw agents bind --agent work --bind telegram:ops
openclaw agents unbind --agent work --bind telegram:ops
openclaw agents set-identity --workspace ~/.openclaw/workspace --from-identity
openclaw agents set-identity --agent main --avatar avatars/openclaw.png
openclaw agents delete work
```
## Routing bindings
Use routing bindings to pin inbound channel traffic to a specific agent.
List bindings:
```bash
openclaw agents bindings
openclaw agents bindings --agent work
openclaw agents bindings --json
```
Add bindings:
```bash
openclaw agents bind --agent work --bind telegram:ops --bind discord:guild-a
```
If you omit `accountId` (`--bind <channel>`), OpenClaw resolves it from channel defaults and plugin setup hooks when available.
### Binding scope behavior
- A binding without `accountId` matches the channel default account only.
- `accountId: "*"` is the channel-wide fallback (all accounts) and is less specific than an explicit account binding.
- If the same agent already has a matching channel binding without `accountId`, and you later bind with an explicit or resolved `accountId`, OpenClaw upgrades that existing binding in place instead of adding a duplicate.
Example:
```bash
# initial channel-only binding
openclaw agents bind --agent work --bind telegram
# later upgrade to account-scoped binding
openclaw agents bind --agent work --bind telegram:ops
```
After the upgrade, routing for that binding is scoped to `telegram:ops`. If you also want default-account routing, add it explicitly (for example `--bind telegram:default`).
Remove bindings:
```bash
openclaw agents unbind --agent work --bind telegram:ops
openclaw agents unbind --agent work --all
```
## Identity files
Each agent workspace can include an `IDENTITY.md` at the workspace root:

View File

@@ -35,6 +35,16 @@ openclaw channels remove --channel telegram --delete
Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc).
When you run `openclaw channels add` without flags, the interactive wizard can prompt:
- account ids per selected channel
- optional display names for those accounts
- `Bind configured channel accounts to agents now?`
If you confirm bind now, the wizard asks which agent should own each configured channel account and writes account-scoped routing bindings.
You can also manage the same routing rules later with `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` (see [agents](/cli/agents)).
## Login / logout (interactive)
```bash

View File

@@ -574,7 +574,37 @@ Options:
- `--non-interactive`
- `--json`
Binding specs use `channel[:accountId]`. When `accountId` is omitted for WhatsApp, the default account id is used.
Binding specs use `channel[:accountId]`. When `accountId` is omitted, OpenClaw may resolve account scope via channel defaults/plugin hooks; otherwise it is a channel binding without explicit account scope.
#### `agents bindings`
List routing bindings.
Options:
- `--agent <id>`
- `--json`
#### `agents bind`
Add routing bindings for an agent.
Options:
- `--agent <id>`
- `--bind <channel[:accountId]>` (repeatable)
- `--json`
#### `agents unbind`
Remove routing bindings for an agent.
Options:
- `--agent <id>`
- `--bind <channel[:accountId]>` (repeatable)
- `--all`
- `--json`
#### `agents delete <id>`

View File

@@ -185,6 +185,12 @@ Bindings are **deterministic** and **most-specific wins**:
If multiple bindings match in the same tier, the first one in config order wins.
If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics).
Important account-scope detail:
- A binding that omits `accountId` matches the default account only.
- Use `accountId: "*"` for a channel-wide fallback across all accounts.
- If you later add the same binding for the same agent with an explicit account id, OpenClaw upgrades the existing channel-only binding to account-scoped instead of duplicating it.
## Multiple accounts / phone numbers
Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify

View File

@@ -21,7 +21,16 @@ import type {
} from "./types.core.js";
export type ChannelSetupAdapter = {
resolveAccountId?: (params: { cfg: OpenClawConfig; accountId?: string }) => string;
resolveAccountId?: (params: {
cfg: OpenClawConfig;
accountId?: string;
input?: ChannelSetupInput;
}) => string;
resolveBindingAccountId?: (params: {
cfg: OpenClawConfig;
agentId: string;
accountId?: string;
}) => string | undefined;
applyAccountName?: (params: {
cfg: OpenClawConfig;
accountId: string;

View File

@@ -80,6 +80,7 @@ describe("registerPreActionHooks", () => {
program.command("update").action(async () => {});
program.command("channels").action(async () => {});
program.command("directory").action(async () => {});
program.command("agents").action(async () => {});
program.command("configure").action(async () => {});
program.command("onboard").action(async () => {});
program
@@ -145,6 +146,15 @@ describe("registerPreActionHooks", () => {
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1);
});
it("loads plugin registry for agents command", async () => {
await runCommand({
parseArgv: ["agents"],
processArgv: ["node", "openclaw", "agents"],
});
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1);
});
it("skips config guard for doctor and completion commands", async () => {
await runCommand({
parseArgv: ["doctor"],

View File

@@ -25,6 +25,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([
"message",
"channels",
"directory",
"agents",
"configure",
"onboard",
]);

View File

@@ -3,9 +3,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const agentCliCommandMock = vi.fn();
const agentsAddCommandMock = vi.fn();
const agentsBindingsCommandMock = vi.fn();
const agentsBindCommandMock = vi.fn();
const agentsDeleteCommandMock = vi.fn();
const agentsListCommandMock = vi.fn();
const agentsSetIdentityCommandMock = vi.fn();
const agentsUnbindCommandMock = vi.fn();
const setVerboseMock = vi.fn();
const createDefaultDepsMock = vi.fn(() => ({ deps: true }));
@@ -21,9 +24,12 @@ vi.mock("../../commands/agent-via-gateway.js", () => ({
vi.mock("../../commands/agents.js", () => ({
agentsAddCommand: agentsAddCommandMock,
agentsBindingsCommand: agentsBindingsCommandMock,
agentsBindCommand: agentsBindCommandMock,
agentsDeleteCommand: agentsDeleteCommandMock,
agentsListCommand: agentsListCommandMock,
agentsSetIdentityCommand: agentsSetIdentityCommandMock,
agentsUnbindCommand: agentsUnbindCommandMock,
}));
vi.mock("../../globals.js", () => ({
@@ -55,9 +61,12 @@ describe("registerAgentCommands", () => {
vi.clearAllMocks();
agentCliCommandMock.mockResolvedValue(undefined);
agentsAddCommandMock.mockResolvedValue(undefined);
agentsBindingsCommandMock.mockResolvedValue(undefined);
agentsBindCommandMock.mockResolvedValue(undefined);
agentsDeleteCommandMock.mockResolvedValue(undefined);
agentsListCommandMock.mockResolvedValue(undefined);
agentsSetIdentityCommandMock.mockResolvedValue(undefined);
agentsUnbindCommandMock.mockResolvedValue(undefined);
createDefaultDepsMock.mockReturnValue({ deps: true });
});
@@ -147,6 +156,61 @@ describe("registerAgentCommands", () => {
);
});
it("forwards agents bindings options", async () => {
await runCli(["agents", "bindings", "--agent", "ops", "--json"]);
expect(agentsBindingsCommandMock).toHaveBeenCalledWith(
{
agent: "ops",
json: true,
},
runtime,
);
});
it("forwards agents bind options", async () => {
await runCli([
"agents",
"bind",
"--agent",
"ops",
"--bind",
"matrix-js:ops",
"--bind",
"telegram",
"--json",
]);
expect(agentsBindCommandMock).toHaveBeenCalledWith(
{
agent: "ops",
bind: ["matrix-js:ops", "telegram"],
json: true,
},
runtime,
);
});
it("documents bind accountId resolution behavior in help text", () => {
const program = new Command();
registerAgentCommands(program, { agentChannelOptions: "last|telegram|discord" });
const agents = program.commands.find((command) => command.name() === "agents");
const bind = agents?.commands.find((command) => command.name() === "bind");
const help = bind?.helpInformation() ?? "";
expect(help).toContain("accountId is resolved by channel defaults/hooks");
});
it("forwards agents unbind options", async () => {
await runCli(["agents", "unbind", "--agent", "ops", "--all", "--json"]);
expect(agentsUnbindCommandMock).toHaveBeenCalledWith(
{
agent: "ops",
bind: [],
all: true,
json: true,
},
runtime,
);
});
it("forwards agents delete options", async () => {
await runCli(["agents", "delete", "worker-a", "--force", "--json"]);
expect(agentsDeleteCommandMock).toHaveBeenCalledWith(

View File

@@ -2,9 +2,12 @@ import type { Command } from "commander";
import { agentCliCommand } from "../../commands/agent-via-gateway.js";
import {
agentsAddCommand,
agentsBindingsCommand,
agentsBindCommand,
agentsDeleteCommand,
agentsListCommand,
agentsSetIdentityCommand,
agentsUnbindCommand,
} from "../../commands/agents.js";
import { setVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
@@ -102,6 +105,68 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
});
});
agents
.command("bindings")
.description("List routing bindings")
.option("--agent <id>", "Filter by agent id")
.option("--json", "Output JSON instead of text", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await agentsBindingsCommand(
{
agent: opts.agent as string | undefined,
json: Boolean(opts.json),
},
defaultRuntime,
);
});
});
agents
.command("bind")
.description("Add routing bindings for an agent")
.option("--agent <id>", "Agent id (defaults to current default agent)")
.option(
"--bind <channel[:accountId]>",
"Binding to add (repeatable). If omitted, accountId is resolved by channel defaults/hooks.",
collectOption,
[],
)
.option("--json", "Output JSON summary", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await agentsBindCommand(
{
agent: opts.agent as string | undefined,
bind: Array.isArray(opts.bind) ? (opts.bind as string[]) : undefined,
json: Boolean(opts.json),
},
defaultRuntime,
);
});
});
agents
.command("unbind")
.description("Remove routing bindings for an agent")
.option("--agent <id>", "Agent id (defaults to current default agent)")
.option("--bind <channel[:accountId]>", "Binding to remove (repeatable)", collectOption, [])
.option("--all", "Remove all bindings for this agent", false)
.option("--json", "Output JSON summary", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await agentsUnbindCommand(
{
agent: opts.agent as string | undefined,
bind: Array.isArray(opts.bind) ? (opts.bind as string[]) : undefined,
all: Boolean(opts.all),
json: Boolean(opts.json),
},
defaultRuntime,
);
});
});
agents
.command("add [name]")
.description("Add a new isolated agent")

View File

@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("../config/config.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../config/config.js")>()),
readConfigFileSnapshot: readConfigFileSnapshotMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../channels/plugins/index.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../channels/plugins/index.js")>();
return {
...actual,
getChannelPlugin: (channel: string) => {
if (channel === "matrix-js") {
return {
id: "matrix-js",
setup: {
resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(),
},
};
}
return actual.getChannelPlugin(channel);
},
normalizeChannelId: (channel: string) => {
if (channel.trim().toLowerCase() === "matrix-js") {
return "matrix-js";
}
return actual.normalizeChannelId(channel);
},
};
});
import { agentsBindCommand, agentsBindingsCommand, agentsUnbindCommand } from "./agents.js";
const runtime = createTestRuntime();
describe("agents bind/unbind commands", () => {
beforeEach(() => {
readConfigFileSnapshotMock.mockClear();
writeConfigFileMock.mockClear();
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
});
it("lists all bindings by default", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
bindings: [
{ agentId: "main", match: { channel: "matrix-js" } },
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
],
},
});
await agentsBindingsCommand({}, runtime);
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js"));
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("ops <- telegram accountId=work"),
);
});
it("binds routes to default agent when --agent is omitted", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
await agentsBindCommand({ bind: ["telegram"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "telegram" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("defaults matrix-js accountId to the target agent id when omitted", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("upgrades existing channel-only binding when accountId is later provided", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
bindings: [{ agentId: "main", match: { channel: "telegram" } }],
},
});
await agentsBindCommand({ bind: ["telegram:work"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "work" } }],
}),
);
expect(runtime.log).toHaveBeenCalledWith("Updated bindings:");
expect(runtime.exit).not.toHaveBeenCalled();
});
it("unbinds all routes for an agent", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] },
bindings: [
{ agentId: "main", match: { channel: "matrix-js" } },
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
],
},
});
await agentsUnbindCommand({ agent: "ops", all: true }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("reports ownership conflicts during unbind and exits 1", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] },
bindings: [{ agentId: "main", match: { channel: "telegram", accountId: "ops" } }],
},
});
await agentsUnbindCommand({ agent: "ops", bind: ["telegram:ops"] }, runtime);
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith("Bindings are owned by another agent:");
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("keeps role-based bindings when removing channel-level discord binding", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {
bindings: [
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
roles: ["111", "222"],
},
},
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
},
},
],
},
});
await agentsUnbindCommand({ bind: ["discord:guild-a"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
roles: ["111", "222"],
},
},
],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
});

View File

@@ -8,16 +8,51 @@ import type { ChannelChoice } from "./onboard-types.js";
function bindingMatchKey(match: AgentBinding["match"]) {
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
const identityKey = bindingMatchIdentityKey(match);
return [identityKey, accountId].join("|");
}
function bindingMatchIdentityKey(match: AgentBinding["match"]) {
const roles = Array.isArray(match.roles)
? Array.from(
new Set(
match.roles
.map((role) => role.trim())
.filter(Boolean)
.toSorted(),
),
)
: [];
return [
match.channel,
accountId,
match.peer?.kind ?? "",
match.peer?.id ?? "",
match.guildId ?? "",
match.teamId ?? "",
roles.join(","),
].join("|");
}
function canUpgradeBindingAccountScope(params: {
existing: AgentBinding;
incoming: AgentBinding;
normalizedIncomingAgentId: string;
}): boolean {
if (!params.incoming.match.accountId?.trim()) {
return false;
}
if (params.existing.match.accountId?.trim()) {
return false;
}
if (normalizeAgentId(params.existing.agentId) !== params.normalizedIncomingAgentId) {
return false;
}
return (
bindingMatchIdentityKey(params.existing.match) ===
bindingMatchIdentityKey(params.incoming.match)
);
}
export function describeBinding(binding: AgentBinding) {
const match = binding.match;
const parts = [match.channel];
@@ -42,10 +77,11 @@ export function applyAgentBindings(
): {
config: OpenClawConfig;
added: AgentBinding[];
updated: AgentBinding[];
skipped: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
} {
const existing = cfg.bindings ?? [];
const existing = [...(cfg.bindings ?? [])];
const existingMatchMap = new Map<string, string>();
for (const binding of existing) {
const key = bindingMatchKey(binding.match);
@@ -55,6 +91,7 @@ export function applyAgentBindings(
}
const added: AgentBinding[] = [];
const updated: AgentBinding[] = [];
const skipped: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
@@ -70,12 +107,41 @@ export function applyAgentBindings(
}
continue;
}
const upgradeIndex = existing.findIndex((candidate) =>
canUpgradeBindingAccountScope({
existing: candidate,
incoming: binding,
normalizedIncomingAgentId: agentId,
}),
);
if (upgradeIndex >= 0) {
const current = existing[upgradeIndex];
if (!current) {
continue;
}
const previousKey = bindingMatchKey(current.match);
const upgradedBinding: AgentBinding = {
...current,
agentId,
match: {
...current.match,
accountId: binding.match.accountId?.trim(),
},
};
existing[upgradeIndex] = upgradedBinding;
existingMatchMap.delete(previousKey);
existingMatchMap.set(bindingMatchKey(upgradedBinding.match), agentId);
updated.push(upgradedBinding);
continue;
}
existingMatchMap.set(key, agentId);
added.push({ ...binding, agentId });
}
if (added.length === 0) {
return { config: cfg, added, skipped, conflicts };
if (added.length === 0 && updated.length === 0) {
return { config: cfg, added, updated, skipped, conflicts };
}
return {
@@ -84,11 +150,78 @@ export function applyAgentBindings(
bindings: [...existing, ...added],
},
added,
updated,
skipped,
conflicts,
};
}
export function removeAgentBindings(
cfg: OpenClawConfig,
bindings: AgentBinding[],
): {
config: OpenClawConfig;
removed: AgentBinding[];
missing: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
} {
const existing = cfg.bindings ?? [];
const removeIndexes = new Set<number>();
const removed: AgentBinding[] = [];
const missing: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
for (const binding of bindings) {
const desiredAgentId = normalizeAgentId(binding.agentId);
const key = bindingMatchKey(binding.match);
let matchedIndex = -1;
let conflictingAgentId: string | null = null;
for (let i = 0; i < existing.length; i += 1) {
if (removeIndexes.has(i)) {
continue;
}
const current = existing[i];
if (!current || bindingMatchKey(current.match) !== key) {
continue;
}
const currentAgentId = normalizeAgentId(current.agentId);
if (currentAgentId === desiredAgentId) {
matchedIndex = i;
break;
}
conflictingAgentId = currentAgentId;
}
if (matchedIndex >= 0) {
const matched = existing[matchedIndex];
if (matched) {
removeIndexes.add(matchedIndex);
removed.push(matched);
}
continue;
}
if (conflictingAgentId) {
conflicts.push({ binding, existingAgentId: conflictingAgentId });
continue;
}
missing.push(binding);
}
if (removeIndexes.size === 0) {
return { config: cfg, removed, missing, conflicts };
}
const nextBindings = existing.filter((_, index) => !removeIndexes.has(index));
return {
config: {
...cfg,
bindings: nextBindings.length > 0 ? nextBindings : undefined,
},
removed,
missing,
conflicts,
};
}
function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string {
const plugin = getChannelPlugin(provider);
if (!plugin) {
@@ -97,6 +230,33 @@ function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): stri
return resolveChannelDefaultAccountId({ plugin, cfg });
}
function resolveBindingAccountId(params: {
channel: ChannelId;
config: OpenClawConfig;
agentId: string;
explicitAccountId?: string;
}): string | undefined {
const explicitAccountId = params.explicitAccountId?.trim();
if (explicitAccountId) {
return explicitAccountId;
}
const plugin = getChannelPlugin(params.channel);
const pluginAccountId = plugin?.setup?.resolveBindingAccountId?.({
cfg: params.config,
agentId: params.agentId,
});
if (pluginAccountId?.trim()) {
return pluginAccountId.trim();
}
if (plugin?.meta.forceAccountBinding) {
return resolveDefaultAccountId(params.config, params.channel);
}
return undefined;
}
export function buildChannelBindings(params: {
agentId: string;
selection: ChannelChoice[];
@@ -107,14 +267,14 @@ export function buildChannelBindings(params: {
const agentId = normalizeAgentId(params.agentId);
for (const channel of params.selection) {
const match: AgentBinding["match"] = { channel };
const accountId = params.accountIds?.[channel]?.trim();
const accountId = resolveBindingAccountId({
channel,
config: params.config,
agentId,
explicitAccountId: params.accountIds?.[channel],
});
if (accountId) {
match.accountId = accountId;
} else {
const plugin = getChannelPlugin(channel);
if (plugin?.meta.forceAccountBinding) {
match.accountId = resolveDefaultAccountId(params.config, channel);
}
}
bindings.push({ agentId, match });
}
@@ -141,17 +301,17 @@ export function parseBindingSpecs(params: {
errors.push(`Unknown channel "${channelRaw}".`);
continue;
}
let accountId = accountRaw?.trim();
let accountId: string | undefined = accountRaw?.trim();
if (accountRaw !== undefined && !accountId) {
errors.push(`Invalid binding "${trimmed}" (empty account id).`);
continue;
}
if (!accountId) {
const plugin = getChannelPlugin(channel);
if (plugin?.meta.forceAccountBinding) {
accountId = resolveDefaultAccountId(params.config, channel);
}
}
accountId = resolveBindingAccountId({
channel,
config: params.config,
agentId,
explicitAccountId: accountId,
});
const match: AgentBinding["match"] = { channel };
if (accountId) {
match.accountId = accountId;

View File

@@ -125,7 +125,7 @@ export async function agentsAddCommand(
const bindingResult =
bindingParse.bindings.length > 0
? applyAgentBindings(nextConfig, bindingParse.bindings)
: { config: nextConfig, added: [], skipped: [], conflicts: [] };
: { config: nextConfig, added: [], updated: [], skipped: [], conflicts: [] };
await writeConfigFile(bindingResult.config);
if (!opts.json) {
@@ -145,6 +145,7 @@ export async function agentsAddCommand(
model,
bindings: {
added: bindingResult.added.map(describeBinding),
updated: bindingResult.updated.map(describeBinding),
skipped: bindingResult.skipped.map(describeBinding),
conflicts: bindingResult.conflicts.map(
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,

View File

@@ -0,0 +1,324 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import type { AgentBinding } from "../config/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import {
applyAgentBindings,
describeBinding,
parseBindingSpecs,
removeAgentBindings,
} from "./agents.bindings.js";
import { requireValidConfig } from "./agents.command-shared.js";
import { buildAgentSummaries } from "./agents.config.js";
type AgentsBindingsListOptions = {
agent?: string;
json?: boolean;
};
type AgentsBindOptions = {
agent?: string;
bind?: string[];
json?: boolean;
};
type AgentsUnbindOptions = {
agent?: string;
bind?: string[];
all?: boolean;
json?: boolean;
};
function resolveAgentId(
cfg: Awaited<ReturnType<typeof requireValidConfig>>,
agentInput: string | undefined,
params?: { fallbackToDefault?: boolean },
): string | null {
if (!cfg) {
return null;
}
if (agentInput?.trim()) {
return normalizeAgentId(agentInput);
}
if (params?.fallbackToDefault) {
return resolveDefaultAgentId(cfg);
}
return null;
}
function hasAgent(cfg: Awaited<ReturnType<typeof requireValidConfig>>, agentId: string): boolean {
if (!cfg) {
return false;
}
return buildAgentSummaries(cfg).some((summary) => summary.id === agentId);
}
function formatBindingOwnerLine(binding: AgentBinding): string {
return `${normalizeAgentId(binding.agentId)} <- ${describeBinding(binding)}`;
}
export async function agentsBindingsCommand(
opts: AgentsBindingsListOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) {
return;
}
const filterAgentId = resolveAgentId(cfg, opts.agent?.trim());
if (opts.agent && !filterAgentId) {
runtime.error("Agent id is required.");
runtime.exit(1);
return;
}
if (filterAgentId && !hasAgent(cfg, filterAgentId)) {
runtime.error(`Agent "${filterAgentId}" not found.`);
runtime.exit(1);
return;
}
const filtered = (cfg.bindings ?? []).filter(
(binding) => !filterAgentId || normalizeAgentId(binding.agentId) === filterAgentId,
);
if (opts.json) {
runtime.log(
JSON.stringify(
filtered.map((binding) => ({
agentId: normalizeAgentId(binding.agentId),
match: binding.match,
description: describeBinding(binding),
})),
null,
2,
),
);
return;
}
if (filtered.length === 0) {
runtime.log(
filterAgentId ? `No routing bindings for agent "${filterAgentId}".` : "No routing bindings.",
);
return;
}
runtime.log(
[
"Routing bindings:",
...filtered.map((binding) => `- ${formatBindingOwnerLine(binding)}`),
].join("\n"),
);
}
export async function agentsBindCommand(
opts: AgentsBindOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) {
return;
}
const agentId = resolveAgentId(cfg, opts.agent?.trim(), { fallbackToDefault: true });
if (!agentId) {
runtime.error("Unable to resolve agent id.");
runtime.exit(1);
return;
}
if (!hasAgent(cfg, agentId)) {
runtime.error(`Agent "${agentId}" not found.`);
runtime.exit(1);
return;
}
const specs = (opts.bind ?? []).map((value) => value.trim()).filter(Boolean);
if (specs.length === 0) {
runtime.error("Provide at least one --bind <channel[:accountId]>.");
runtime.exit(1);
return;
}
const parsed = parseBindingSpecs({ agentId, specs, config: cfg });
if (parsed.errors.length > 0) {
runtime.error(parsed.errors.join("\n"));
runtime.exit(1);
return;
}
const result = applyAgentBindings(cfg, parsed.bindings);
if (result.added.length > 0 || result.updated.length > 0) {
await writeConfigFile(result.config);
if (!opts.json) {
logConfigUpdated(runtime);
}
}
const payload = {
agentId,
added: result.added.map(describeBinding),
updated: result.updated.map(describeBinding),
skipped: result.skipped.map(describeBinding),
conflicts: result.conflicts.map(
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
),
};
if (opts.json) {
runtime.log(JSON.stringify(payload, null, 2));
if (result.conflicts.length > 0) {
runtime.exit(1);
}
return;
}
if (result.added.length > 0) {
runtime.log("Added bindings:");
for (const binding of result.added) {
runtime.log(`- ${describeBinding(binding)}`);
}
} else if (result.updated.length === 0) {
runtime.log("No new bindings added.");
}
if (result.updated.length > 0) {
runtime.log("Updated bindings:");
for (const binding of result.updated) {
runtime.log(`- ${describeBinding(binding)}`);
}
}
if (result.skipped.length > 0) {
runtime.log("Already present:");
for (const binding of result.skipped) {
runtime.log(`- ${describeBinding(binding)}`);
}
}
if (result.conflicts.length > 0) {
runtime.error("Skipped bindings already claimed by another agent:");
for (const conflict of result.conflicts) {
runtime.error(`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`);
}
runtime.exit(1);
}
}
export async function agentsUnbindCommand(
opts: AgentsUnbindOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) {
return;
}
const agentId = resolveAgentId(cfg, opts.agent?.trim(), { fallbackToDefault: true });
if (!agentId) {
runtime.error("Unable to resolve agent id.");
runtime.exit(1);
return;
}
if (!hasAgent(cfg, agentId)) {
runtime.error(`Agent "${agentId}" not found.`);
runtime.exit(1);
return;
}
if (opts.all && (opts.bind?.length ?? 0) > 0) {
runtime.error("Use either --all or --bind, not both.");
runtime.exit(1);
return;
}
if (opts.all) {
const existing = cfg.bindings ?? [];
const removed = existing.filter((binding) => normalizeAgentId(binding.agentId) === agentId);
const kept = existing.filter((binding) => normalizeAgentId(binding.agentId) !== agentId);
if (removed.length === 0) {
runtime.log(`No bindings to remove for agent "${agentId}".`);
return;
}
const next = {
...cfg,
bindings: kept.length > 0 ? kept : undefined,
};
await writeConfigFile(next);
if (!opts.json) {
logConfigUpdated(runtime);
}
const payload = {
agentId,
removed: removed.map(describeBinding),
missing: [] as string[],
conflicts: [] as string[],
};
if (opts.json) {
runtime.log(JSON.stringify(payload, null, 2));
return;
}
runtime.log(`Removed ${removed.length} binding(s) for "${agentId}".`);
return;
}
const specs = (opts.bind ?? []).map((value) => value.trim()).filter(Boolean);
if (specs.length === 0) {
runtime.error("Provide at least one --bind <channel[:accountId]> or use --all.");
runtime.exit(1);
return;
}
const parsed = parseBindingSpecs({ agentId, specs, config: cfg });
if (parsed.errors.length > 0) {
runtime.error(parsed.errors.join("\n"));
runtime.exit(1);
return;
}
const result = removeAgentBindings(cfg, parsed.bindings);
if (result.removed.length > 0) {
await writeConfigFile(result.config);
if (!opts.json) {
logConfigUpdated(runtime);
}
}
const payload = {
agentId,
removed: result.removed.map(describeBinding),
missing: result.missing.map(describeBinding),
conflicts: result.conflicts.map(
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
),
};
if (opts.json) {
runtime.log(JSON.stringify(payload, null, 2));
if (result.conflicts.length > 0) {
runtime.exit(1);
}
return;
}
if (result.removed.length > 0) {
runtime.log("Removed bindings:");
for (const binding of result.removed) {
runtime.log(`- ${describeBinding(binding)}`);
}
} else {
runtime.log("No bindings removed.");
}
if (result.missing.length > 0) {
runtime.log("Not found:");
for (const binding of result.missing) {
runtime.log(`- ${describeBinding(binding)}`);
}
}
if (result.conflicts.length > 0) {
runtime.error("Bindings are owned by another agent:");
for (const conflict of result.conflicts) {
runtime.error(`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`);
}
runtime.exit(1);
}
}

View File

@@ -8,6 +8,7 @@ import {
applyAgentConfig,
buildAgentSummaries,
pruneAgentConfig,
removeAgentBindings,
} from "./agents.js";
describe("agents helpers", () => {
@@ -111,6 +112,114 @@ describe("agents helpers", () => {
expect(result.config.bindings).toHaveLength(2);
});
it("applyAgentBindings upgrades channel-only binding to account-specific binding for same agent", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "main",
match: { channel: "telegram" },
},
],
};
const result = applyAgentBindings(cfg, [
{
agentId: "main",
match: { channel: "telegram", accountId: "work" },
},
]);
expect(result.added).toHaveLength(0);
expect(result.updated).toHaveLength(1);
expect(result.conflicts).toHaveLength(0);
expect(result.config.bindings).toEqual([
{
agentId: "main",
match: { channel: "telegram", accountId: "work" },
},
]);
});
it("applyAgentBindings treats role-based bindings as distinct routes", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
guildId: "123",
roles: ["111", "222"],
},
},
],
};
const result = applyAgentBindings(cfg, [
{
agentId: "work",
match: {
channel: "discord",
accountId: "guild-a",
guildId: "123",
},
},
]);
expect(result.added).toHaveLength(1);
expect(result.conflicts).toHaveLength(0);
expect(result.config.bindings).toHaveLength(2);
});
it("removeAgentBindings does not remove role-based bindings when removing channel-level routes", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
guildId: "123",
roles: ["111", "222"],
},
},
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
guildId: "123",
},
},
],
};
const result = removeAgentBindings(cfg, [
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
guildId: "123",
},
},
]);
expect(result.removed).toHaveLength(1);
expect(result.conflicts).toHaveLength(0);
expect(result.config.bindings).toEqual([
{
agentId: "main",
match: {
channel: "discord",
accountId: "guild-a",
guildId: "123",
roles: ["111", "222"],
},
},
]);
});
it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => {
const cfg: OpenClawConfig = {
agents: {

View File

@@ -1,4 +1,5 @@
export * from "./agents.bindings.js";
export * from "./agents.commands.bind.js";
export * from "./agents.commands.add.js";
export * from "./agents.commands.delete.js";
export * from "./agents.commands.identity.js";

View File

@@ -8,6 +8,8 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
import { buildAgentSummaries } from "../agents.config.js";
import { setupChannels } from "../onboard-channels.js";
import type { ChannelChoice } from "../onboard-types.js";
import {
@@ -111,6 +113,68 @@ export async function channelsAddCommand(
}
}
const bindTargets = selection
.map((channel) => ({
channel,
accountId: accountIds[channel]?.trim(),
}))
.filter(
(
value,
): value is {
channel: ChannelChoice;
accountId: string;
} => Boolean(value.accountId),
);
if (bindTargets.length > 0) {
const bindNow = await prompter.confirm({
message: "Bind configured channel accounts to agents now?",
initialValue: true,
});
if (bindNow) {
const agentSummaries = buildAgentSummaries(nextConfig);
const defaultAgentId = resolveDefaultAgentId(nextConfig);
for (const target of bindTargets) {
const targetAgentId = await prompter.select({
message: `Route ${target.channel} account "${target.accountId}" to agent`,
options: agentSummaries.map((agent) => ({
value: agent.id,
label: agent.isDefault ? `${agent.id} (default)` : agent.id,
})),
initialValue: defaultAgentId,
});
const bindingResult = applyAgentBindings(nextConfig, [
{
agentId: targetAgentId,
match: { channel: target.channel, accountId: target.accountId },
},
]);
nextConfig = bindingResult.config;
if (bindingResult.added.length > 0 || bindingResult.updated.length > 0) {
await prompter.note(
[
...bindingResult.added.map((binding) => `Added: ${describeBinding(binding)}`),
...bindingResult.updated.map((binding) => `Updated: ${describeBinding(binding)}`),
].join("\n"),
"Routing bindings",
);
}
if (bindingResult.conflicts.length > 0) {
await prompter.note(
[
"Skipped bindings already claimed by another agent:",
...bindingResult.conflicts.map(
(conflict) =>
`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
),
].join("\n"),
"Routing bindings",
);
}
}
}
}
await writeConfigFile(nextConfig);
await prompter.outro("Channels updated.");
return;
@@ -153,9 +217,6 @@ export async function channelsAddCommand(
runtime.exit(1);
return;
}
const accountId =
plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ??
normalizeAccountId(opts.account);
const useEnv = opts.useEnv === true;
const initialSyncLimit =
typeof opts.initialSyncLimit === "number"
@@ -199,6 +260,12 @@ export async function channelsAddCommand(
dmAllowlist,
autoDiscoverChannels: opts.autoDiscoverChannels,
};
const accountId =
plugin.setup.resolveAccountId?.({
cfg: nextConfig,
accountId: opts.account,
input,
}) ?? normalizeAccountId(opts.account);
const validationError = plugin.setup.validateInput?.({
cfg: nextConfig,