mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
committed by
GitHub
parent
c5d040bbea
commit
96c7702526
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -25,6 +25,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([
|
||||
"message",
|
||||
"channels",
|
||||
"directory",
|
||||
"agents",
|
||||
"configure",
|
||||
"onboard",
|
||||
]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
200
src/commands/agents.bind.commands.test.ts
Normal file
200
src/commands/agents.bind.commands.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
324
src/commands/agents.commands.bind.ts
Normal file
324
src/commands/agents.commands.bind.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user