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

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

Prepared head SHA: ad35a458a55427614a35c9d0713a7386172464ad
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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1133 additions and 24 deletions

View File

@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
## 2026.2.26 (Unreleased) ## 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 ### 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. - 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: read_when:
- You want multiple isolated agents (workspaces + routing + auth) - You want multiple isolated agents (workspaces + routing + auth)
title: "agents" title: "agents"
@ -19,11 +19,59 @@ Related:
```bash ```bash
openclaw agents list openclaw agents list
openclaw agents add work --workspace ~/.openclaw/workspace-work 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 --workspace ~/.openclaw/workspace --from-identity
openclaw agents set-identity --agent main --avatar avatars/openclaw.png openclaw agents set-identity --agent main --avatar avatars/openclaw.png
openclaw agents delete work 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 ## Identity files
Each agent workspace can include an `IDENTITY.md` at the workspace root: 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). 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) ## Login / logout (interactive)
```bash ```bash

View File

@ -574,7 +574,37 @@ Options:
- `--non-interactive` - `--non-interactive`
- `--json` - `--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>` #### `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 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). 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 ## Multiple accounts / phone numbers
Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify

View File

@ -21,7 +21,16 @@ import type {
} from "./types.core.js"; } from "./types.core.js";
export type ChannelSetupAdapter = { 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: { applyAccountName?: (params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
accountId: string; accountId: string;

View File

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

View File

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

View File

@ -3,9 +3,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const agentCliCommandMock = vi.fn(); const agentCliCommandMock = vi.fn();
const agentsAddCommandMock = vi.fn(); const agentsAddCommandMock = vi.fn();
const agentsBindingsCommandMock = vi.fn();
const agentsBindCommandMock = vi.fn();
const agentsDeleteCommandMock = vi.fn(); const agentsDeleteCommandMock = vi.fn();
const agentsListCommandMock = vi.fn(); const agentsListCommandMock = vi.fn();
const agentsSetIdentityCommandMock = vi.fn(); const agentsSetIdentityCommandMock = vi.fn();
const agentsUnbindCommandMock = vi.fn();
const setVerboseMock = vi.fn(); const setVerboseMock = vi.fn();
const createDefaultDepsMock = vi.fn(() => ({ deps: true })); const createDefaultDepsMock = vi.fn(() => ({ deps: true }));
@ -21,9 +24,12 @@ vi.mock("../../commands/agent-via-gateway.js", () => ({
vi.mock("../../commands/agents.js", () => ({ vi.mock("../../commands/agents.js", () => ({
agentsAddCommand: agentsAddCommandMock, agentsAddCommand: agentsAddCommandMock,
agentsBindingsCommand: agentsBindingsCommandMock,
agentsBindCommand: agentsBindCommandMock,
agentsDeleteCommand: agentsDeleteCommandMock, agentsDeleteCommand: agentsDeleteCommandMock,
agentsListCommand: agentsListCommandMock, agentsListCommand: agentsListCommandMock,
agentsSetIdentityCommand: agentsSetIdentityCommandMock, agentsSetIdentityCommand: agentsSetIdentityCommandMock,
agentsUnbindCommand: agentsUnbindCommandMock,
})); }));
vi.mock("../../globals.js", () => ({ vi.mock("../../globals.js", () => ({
@ -55,9 +61,12 @@ describe("registerAgentCommands", () => {
vi.clearAllMocks(); vi.clearAllMocks();
agentCliCommandMock.mockResolvedValue(undefined); agentCliCommandMock.mockResolvedValue(undefined);
agentsAddCommandMock.mockResolvedValue(undefined); agentsAddCommandMock.mockResolvedValue(undefined);
agentsBindingsCommandMock.mockResolvedValue(undefined);
agentsBindCommandMock.mockResolvedValue(undefined);
agentsDeleteCommandMock.mockResolvedValue(undefined); agentsDeleteCommandMock.mockResolvedValue(undefined);
agentsListCommandMock.mockResolvedValue(undefined); agentsListCommandMock.mockResolvedValue(undefined);
agentsSetIdentityCommandMock.mockResolvedValue(undefined); agentsSetIdentityCommandMock.mockResolvedValue(undefined);
agentsUnbindCommandMock.mockResolvedValue(undefined);
createDefaultDepsMock.mockReturnValue({ deps: true }); 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 () => { it("forwards agents delete options", async () => {
await runCli(["agents", "delete", "worker-a", "--force", "--json"]); await runCli(["agents", "delete", "worker-a", "--force", "--json"]);
expect(agentsDeleteCommandMock).toHaveBeenCalledWith( expect(agentsDeleteCommandMock).toHaveBeenCalledWith(

View File

@ -2,9 +2,12 @@ import type { Command } from "commander";
import { agentCliCommand } from "../../commands/agent-via-gateway.js"; import { agentCliCommand } from "../../commands/agent-via-gateway.js";
import { import {
agentsAddCommand, agentsAddCommand,
agentsBindingsCommand,
agentsBindCommand,
agentsDeleteCommand, agentsDeleteCommand,
agentsListCommand, agentsListCommand,
agentsSetIdentityCommand, agentsSetIdentityCommand,
agentsUnbindCommand,
} from "../../commands/agents.js"; } from "../../commands/agents.js";
import { setVerbose } from "../../globals.js"; import { setVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.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 agents
.command("add [name]") .command("add [name]")
.description("Add a new isolated agent") .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"]) { function bindingMatchKey(match: AgentBinding["match"]) {
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID; 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 [ return [
match.channel, match.channel,
accountId,
match.peer?.kind ?? "", match.peer?.kind ?? "",
match.peer?.id ?? "", match.peer?.id ?? "",
match.guildId ?? "", match.guildId ?? "",
match.teamId ?? "", match.teamId ?? "",
roles.join(","),
].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) { export function describeBinding(binding: AgentBinding) {
const match = binding.match; const match = binding.match;
const parts = [match.channel]; const parts = [match.channel];
@ -42,10 +77,11 @@ export function applyAgentBindings(
): { ): {
config: OpenClawConfig; config: OpenClawConfig;
added: AgentBinding[]; added: AgentBinding[];
updated: AgentBinding[];
skipped: AgentBinding[]; skipped: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>; conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
} { } {
const existing = cfg.bindings ?? []; const existing = [...(cfg.bindings ?? [])];
const existingMatchMap = new Map<string, string>(); const existingMatchMap = new Map<string, string>();
for (const binding of existing) { for (const binding of existing) {
const key = bindingMatchKey(binding.match); const key = bindingMatchKey(binding.match);
@ -55,6 +91,7 @@ export function applyAgentBindings(
} }
const added: AgentBinding[] = []; const added: AgentBinding[] = [];
const updated: AgentBinding[] = [];
const skipped: AgentBinding[] = []; const skipped: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = []; const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
@ -70,12 +107,41 @@ export function applyAgentBindings(
} }
continue; 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); existingMatchMap.set(key, agentId);
added.push({ ...binding, agentId }); added.push({ ...binding, agentId });
} }
if (added.length === 0) { if (added.length === 0 && updated.length === 0) {
return { config: cfg, added, skipped, conflicts }; return { config: cfg, added, updated, skipped, conflicts };
} }
return { return {
@ -84,11 +150,78 @@ export function applyAgentBindings(
bindings: [...existing, ...added], bindings: [...existing, ...added],
}, },
added, added,
updated,
skipped, skipped,
conflicts, 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 { function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string {
const plugin = getChannelPlugin(provider); const plugin = getChannelPlugin(provider);
if (!plugin) { if (!plugin) {
@ -97,6 +230,33 @@ function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): stri
return resolveChannelDefaultAccountId({ plugin, cfg }); 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: { export function buildChannelBindings(params: {
agentId: string; agentId: string;
selection: ChannelChoice[]; selection: ChannelChoice[];
@ -107,14 +267,14 @@ export function buildChannelBindings(params: {
const agentId = normalizeAgentId(params.agentId); const agentId = normalizeAgentId(params.agentId);
for (const channel of params.selection) { for (const channel of params.selection) {
const match: AgentBinding["match"] = { channel }; 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) { if (accountId) {
match.accountId = accountId; match.accountId = accountId;
} else {
const plugin = getChannelPlugin(channel);
if (plugin?.meta.forceAccountBinding) {
match.accountId = resolveDefaultAccountId(params.config, channel);
}
} }
bindings.push({ agentId, match }); bindings.push({ agentId, match });
} }
@ -141,17 +301,17 @@ export function parseBindingSpecs(params: {
errors.push(`Unknown channel "${channelRaw}".`); errors.push(`Unknown channel "${channelRaw}".`);
continue; continue;
} }
let accountId = accountRaw?.trim(); let accountId: string | undefined = accountRaw?.trim();
if (accountRaw !== undefined && !accountId) { if (accountRaw !== undefined && !accountId) {
errors.push(`Invalid binding "${trimmed}" (empty account id).`); errors.push(`Invalid binding "${trimmed}" (empty account id).`);
continue; continue;
} }
if (!accountId) { accountId = resolveBindingAccountId({
const plugin = getChannelPlugin(channel); channel,
if (plugin?.meta.forceAccountBinding) { config: params.config,
accountId = resolveDefaultAccountId(params.config, channel); agentId,
} explicitAccountId: accountId,
} });
const match: AgentBinding["match"] = { channel }; const match: AgentBinding["match"] = { channel };
if (accountId) { if (accountId) {
match.accountId = accountId; match.accountId = accountId;

View File

@ -125,7 +125,7 @@ export async function agentsAddCommand(
const bindingResult = const bindingResult =
bindingParse.bindings.length > 0 bindingParse.bindings.length > 0
? applyAgentBindings(nextConfig, bindingParse.bindings) ? applyAgentBindings(nextConfig, bindingParse.bindings)
: { config: nextConfig, added: [], skipped: [], conflicts: [] }; : { config: nextConfig, added: [], updated: [], skipped: [], conflicts: [] };
await writeConfigFile(bindingResult.config); await writeConfigFile(bindingResult.config);
if (!opts.json) { if (!opts.json) {
@ -145,6 +145,7 @@ export async function agentsAddCommand(
model, model,
bindings: { bindings: {
added: bindingResult.added.map(describeBinding), added: bindingResult.added.map(describeBinding),
updated: bindingResult.updated.map(describeBinding),
skipped: bindingResult.skipped.map(describeBinding), skipped: bindingResult.skipped.map(describeBinding),
conflicts: bindingResult.conflicts.map( conflicts: bindingResult.conflicts.map(
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, (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, applyAgentConfig,
buildAgentSummaries, buildAgentSummaries,
pruneAgentConfig, pruneAgentConfig,
removeAgentBindings,
} from "./agents.js"; } from "./agents.js";
describe("agents helpers", () => { describe("agents helpers", () => {
@ -111,6 +112,114 @@ describe("agents helpers", () => {
expect(result.config.bindings).toHaveLength(2); 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", () => { it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
agents: { agents: {

View File

@ -1,4 +1,5 @@
export * from "./agents.bindings.js"; export * from "./agents.bindings.js";
export * from "./agents.commands.bind.js";
export * from "./agents.commands.add.js"; export * from "./agents.commands.add.js";
export * from "./agents.commands.delete.js"; export * from "./agents.commands.delete.js";
export * from "./agents.commands.identity.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 { resolveTelegramAccount } from "../../telegram/accounts.js";
import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js"; import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js";
import { createClackPrompter } from "../../wizard/clack-prompter.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 { setupChannels } from "../onboard-channels.js";
import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelChoice } from "../onboard-types.js";
import { 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 writeConfigFile(nextConfig);
await prompter.outro("Channels updated."); await prompter.outro("Channels updated.");
return; return;
@ -153,9 +217,6 @@ export async function channelsAddCommand(
runtime.exit(1); runtime.exit(1);
return; return;
} }
const accountId =
plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ??
normalizeAccountId(opts.account);
const useEnv = opts.useEnv === true; const useEnv = opts.useEnv === true;
const initialSyncLimit = const initialSyncLimit =
typeof opts.initialSyncLimit === "number" typeof opts.initialSyncLimit === "number"
@ -199,6 +260,12 @@ export async function channelsAddCommand(
dmAllowlist, dmAllowlist,
autoDiscoverChannels: opts.autoDiscoverChannels, autoDiscoverChannels: opts.autoDiscoverChannels,
}; };
const accountId =
plugin.setup.resolveAccountId?.({
cfg: nextConfig,
accountId: opts.account,
input,
}) ?? normalizeAccountId(opts.account);
const validationError = plugin.setup.validateInput?.({ const validationError = plugin.setup.validateInput?.({
cfg: nextConfig, cfg: nextConfig,