diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a60109a094..b890896f0d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/agents.md b/docs/cli/agents.md index 39679265f14..5bdc8a68bf2 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -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 `), 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: diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 4213efb3eb7..0f9c3fecb77 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -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 diff --git a/docs/cli/index.md b/docs/cli/index.md index 32eb31b5eb3..1394d83db0e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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 ` +- `--json` + +#### `agents bind` + +Add routing bindings for an agent. + +Options: + +- `--agent ` +- `--bind ` (repeatable) +- `--json` + +#### `agents unbind` + +Remove routing bindings for an agent. + +Options: + +- `--agent ` +- `--bind ` (repeatable) +- `--all` +- `--json` #### `agents delete ` diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 069fcfb6367..842531cc2a6 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -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 diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 113df6ad5cd..ead7f68b2fa 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -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; diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index bf4184d362a..caa9dd24869 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -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"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 6a9abc3e99e..6a232386b14 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -25,6 +25,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([ "message", "channels", "directory", + "agents", "configure", "onboard", ]); diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 9ad1fa19d52..2d37e56a702 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -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( diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 4f112403c14..fdb45a0960a 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -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 ", "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 ", "Agent id (defaults to current default agent)") + .option( + "--bind ", + "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 ", "Agent id (defaults to current default agent)") + .option("--bind ", "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") diff --git a/src/commands/agents.bind.commands.test.ts b/src/commands/agents.bind.commands.test.ts new file mode 100644 index 00000000000..0fe03173be6 --- /dev/null +++ b/src/commands/agents.bind.commands.test.ts @@ -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()), + readConfigFileSnapshot: readConfigFileSnapshotMock, + writeConfigFile: writeConfigFileMock, +})); + +vi.mock("../channels/plugins/index.js", async (importOriginal) => { + const actual = await importOriginal(); + 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(); + }); +}); diff --git a/src/commands/agents.bindings.ts b/src/commands/agents.bindings.ts index f0eaf959e1e..ca0c0ee649c 100644 --- a/src/commands/agents.bindings.ts +++ b/src/commands/agents.bindings.ts @@ -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(); 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(); + 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; diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 807ecca0b20..61c45392f59 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -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})`, diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts new file mode 100644 index 00000000000..b7a021053c6 --- /dev/null +++ b/src/commands/agents.commands.bind.ts @@ -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>, + 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>, 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 ."); + 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 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); + } +} diff --git a/src/commands/agents.test.ts b/src/commands/agents.test.ts index 1becb77548f..dfb339e4384 100644 --- a/src/commands/agents.test.ts +++ b/src/commands/agents.test.ts @@ -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: { diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 6679bb853da..5f5bdcd3c7b 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -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"; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index a23fb2428e2..eaa6fc53397 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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,