Agents: add account-scoped bind and routing commands (#27195)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: ad35a458a55427614a35c9d0713a7386172464ad Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
This commit is contained in:
parent
c5d040bbea
commit
96c7702526
@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
## 2026.2.26 (Unreleased)
|
## 2026.2.26 (Unreleased)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Agents/Routing CLI: add `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in `openclaw channels add`. (#27195) thanks @gumadeiras.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.
|
- Android/Node invoke: remove native gateway WebSocket `Origin` header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `openclaw agents` (list/add/delete/set identity)"
|
summary: "CLI reference for `openclaw agents` (list/add/delete/bindings/bind/unbind/set identity)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want multiple isolated agents (workspaces + routing + auth)
|
- You want multiple isolated agents (workspaces + routing + auth)
|
||||||
title: "agents"
|
title: "agents"
|
||||||
@ -19,11 +19,59 @@ Related:
|
|||||||
```bash
|
```bash
|
||||||
openclaw agents list
|
openclaw agents list
|
||||||
openclaw agents add work --workspace ~/.openclaw/workspace-work
|
openclaw agents add work --workspace ~/.openclaw/workspace-work
|
||||||
|
openclaw agents bindings
|
||||||
|
openclaw agents bind --agent work --bind telegram:ops
|
||||||
|
openclaw agents unbind --agent work --bind telegram:ops
|
||||||
openclaw agents set-identity --workspace ~/.openclaw/workspace --from-identity
|
openclaw agents set-identity --workspace ~/.openclaw/workspace --from-identity
|
||||||
openclaw agents set-identity --agent main --avatar avatars/openclaw.png
|
openclaw agents set-identity --agent main --avatar avatars/openclaw.png
|
||||||
openclaw agents delete work
|
openclaw agents delete work
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Routing bindings
|
||||||
|
|
||||||
|
Use routing bindings to pin inbound channel traffic to a specific agent.
|
||||||
|
|
||||||
|
List bindings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw agents bindings
|
||||||
|
openclaw agents bindings --agent work
|
||||||
|
openclaw agents bindings --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Add bindings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw agents bind --agent work --bind telegram:ops --bind discord:guild-a
|
||||||
|
```
|
||||||
|
|
||||||
|
If you omit `accountId` (`--bind <channel>`), OpenClaw resolves it from channel defaults and plugin setup hooks when available.
|
||||||
|
|
||||||
|
### Binding scope behavior
|
||||||
|
|
||||||
|
- A binding without `accountId` matches the channel default account only.
|
||||||
|
- `accountId: "*"` is the channel-wide fallback (all accounts) and is less specific than an explicit account binding.
|
||||||
|
- If the same agent already has a matching channel binding without `accountId`, and you later bind with an explicit or resolved `accountId`, OpenClaw upgrades that existing binding in place instead of adding a duplicate.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# initial channel-only binding
|
||||||
|
openclaw agents bind --agent work --bind telegram
|
||||||
|
|
||||||
|
# later upgrade to account-scoped binding
|
||||||
|
openclaw agents bind --agent work --bind telegram:ops
|
||||||
|
```
|
||||||
|
|
||||||
|
After the upgrade, routing for that binding is scoped to `telegram:ops`. If you also want default-account routing, add it explicitly (for example `--bind telegram:default`).
|
||||||
|
|
||||||
|
Remove bindings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw agents unbind --agent work --bind telegram:ops
|
||||||
|
openclaw agents unbind --agent work --all
|
||||||
|
```
|
||||||
|
|
||||||
## Identity files
|
## Identity files
|
||||||
|
|
||||||
Each agent workspace can include an `IDENTITY.md` at the workspace root:
|
Each agent workspace can include an `IDENTITY.md` at the workspace root:
|
||||||
|
|||||||
@ -35,6 +35,16 @@ openclaw channels remove --channel telegram --delete
|
|||||||
|
|
||||||
Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc).
|
Tip: `openclaw channels add --help` shows per-channel flags (token, app token, signal-cli paths, etc).
|
||||||
|
|
||||||
|
When you run `openclaw channels add` without flags, the interactive wizard can prompt:
|
||||||
|
|
||||||
|
- account ids per selected channel
|
||||||
|
- optional display names for those accounts
|
||||||
|
- `Bind configured channel accounts to agents now?`
|
||||||
|
|
||||||
|
If you confirm bind now, the wizard asks which agent should own each configured channel account and writes account-scoped routing bindings.
|
||||||
|
|
||||||
|
You can also manage the same routing rules later with `openclaw agents bindings`, `openclaw agents bind`, and `openclaw agents unbind` (see [agents](/cli/agents)).
|
||||||
|
|
||||||
## Login / logout (interactive)
|
## Login / logout (interactive)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -574,7 +574,37 @@ Options:
|
|||||||
- `--non-interactive`
|
- `--non-interactive`
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
Binding specs use `channel[:accountId]`. When `accountId` is omitted for WhatsApp, the default account id is used.
|
Binding specs use `channel[:accountId]`. When `accountId` is omitted, OpenClaw may resolve account scope via channel defaults/plugin hooks; otherwise it is a channel binding without explicit account scope.
|
||||||
|
|
||||||
|
#### `agents bindings`
|
||||||
|
|
||||||
|
List routing bindings.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- `--agent <id>`
|
||||||
|
- `--json`
|
||||||
|
|
||||||
|
#### `agents bind`
|
||||||
|
|
||||||
|
Add routing bindings for an agent.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- `--agent <id>`
|
||||||
|
- `--bind <channel[:accountId]>` (repeatable)
|
||||||
|
- `--json`
|
||||||
|
|
||||||
|
#### `agents unbind`
|
||||||
|
|
||||||
|
Remove routing bindings for an agent.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- `--agent <id>`
|
||||||
|
- `--bind <channel[:accountId]>` (repeatable)
|
||||||
|
- `--all`
|
||||||
|
- `--json`
|
||||||
|
|
||||||
#### `agents delete <id>`
|
#### `agents delete <id>`
|
||||||
|
|
||||||
|
|||||||
@ -185,6 +185,12 @@ Bindings are **deterministic** and **most-specific wins**:
|
|||||||
If multiple bindings match in the same tier, the first one in config order wins.
|
If multiple bindings match in the same tier, the first one in config order wins.
|
||||||
If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics).
|
If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics).
|
||||||
|
|
||||||
|
Important account-scope detail:
|
||||||
|
|
||||||
|
- A binding that omits `accountId` matches the default account only.
|
||||||
|
- Use `accountId: "*"` for a channel-wide fallback across all accounts.
|
||||||
|
- If you later add the same binding for the same agent with an explicit account id, OpenClaw upgrades the existing channel-only binding to account-scoped instead of duplicating it.
|
||||||
|
|
||||||
## Multiple accounts / phone numbers
|
## Multiple accounts / phone numbers
|
||||||
|
|
||||||
Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify
|
Channels that support **multiple accounts** (e.g. WhatsApp) use `accountId` to identify
|
||||||
|
|||||||
@ -21,7 +21,16 @@ import type {
|
|||||||
} from "./types.core.js";
|
} from "./types.core.js";
|
||||||
|
|
||||||
export type ChannelSetupAdapter = {
|
export type ChannelSetupAdapter = {
|
||||||
resolveAccountId?: (params: { cfg: OpenClawConfig; accountId?: string }) => string;
|
resolveAccountId?: (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
input?: ChannelSetupInput;
|
||||||
|
}) => string;
|
||||||
|
resolveBindingAccountId?: (params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
agentId: string;
|
||||||
|
accountId?: string;
|
||||||
|
}) => string | undefined;
|
||||||
applyAccountName?: (params: {
|
applyAccountName?: (params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|||||||
@ -80,6 +80,7 @@ describe("registerPreActionHooks", () => {
|
|||||||
program.command("update").action(async () => {});
|
program.command("update").action(async () => {});
|
||||||
program.command("channels").action(async () => {});
|
program.command("channels").action(async () => {});
|
||||||
program.command("directory").action(async () => {});
|
program.command("directory").action(async () => {});
|
||||||
|
program.command("agents").action(async () => {});
|
||||||
program.command("configure").action(async () => {});
|
program.command("configure").action(async () => {});
|
||||||
program.command("onboard").action(async () => {});
|
program.command("onboard").action(async () => {});
|
||||||
program
|
program
|
||||||
@ -145,6 +146,15 @@ describe("registerPreActionHooks", () => {
|
|||||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1);
|
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads plugin registry for agents command", async () => {
|
||||||
|
await runCommand({
|
||||||
|
parseArgv: ["agents"],
|
||||||
|
processArgv: ["node", "openclaw", "agents"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips config guard for doctor and completion commands", async () => {
|
it("skips config guard for doctor and completion commands", async () => {
|
||||||
await runCommand({
|
await runCommand({
|
||||||
parseArgv: ["doctor"],
|
parseArgv: ["doctor"],
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([
|
|||||||
"message",
|
"message",
|
||||||
"channels",
|
"channels",
|
||||||
"directory",
|
"directory",
|
||||||
|
"agents",
|
||||||
"configure",
|
"configure",
|
||||||
"onboard",
|
"onboard",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -3,9 +3,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const agentCliCommandMock = vi.fn();
|
const agentCliCommandMock = vi.fn();
|
||||||
const agentsAddCommandMock = vi.fn();
|
const agentsAddCommandMock = vi.fn();
|
||||||
|
const agentsBindingsCommandMock = vi.fn();
|
||||||
|
const agentsBindCommandMock = vi.fn();
|
||||||
const agentsDeleteCommandMock = vi.fn();
|
const agentsDeleteCommandMock = vi.fn();
|
||||||
const agentsListCommandMock = vi.fn();
|
const agentsListCommandMock = vi.fn();
|
||||||
const agentsSetIdentityCommandMock = vi.fn();
|
const agentsSetIdentityCommandMock = vi.fn();
|
||||||
|
const agentsUnbindCommandMock = vi.fn();
|
||||||
const setVerboseMock = vi.fn();
|
const setVerboseMock = vi.fn();
|
||||||
const createDefaultDepsMock = vi.fn(() => ({ deps: true }));
|
const createDefaultDepsMock = vi.fn(() => ({ deps: true }));
|
||||||
|
|
||||||
@ -21,9 +24,12 @@ vi.mock("../../commands/agent-via-gateway.js", () => ({
|
|||||||
|
|
||||||
vi.mock("../../commands/agents.js", () => ({
|
vi.mock("../../commands/agents.js", () => ({
|
||||||
agentsAddCommand: agentsAddCommandMock,
|
agentsAddCommand: agentsAddCommandMock,
|
||||||
|
agentsBindingsCommand: agentsBindingsCommandMock,
|
||||||
|
agentsBindCommand: agentsBindCommandMock,
|
||||||
agentsDeleteCommand: agentsDeleteCommandMock,
|
agentsDeleteCommand: agentsDeleteCommandMock,
|
||||||
agentsListCommand: agentsListCommandMock,
|
agentsListCommand: agentsListCommandMock,
|
||||||
agentsSetIdentityCommand: agentsSetIdentityCommandMock,
|
agentsSetIdentityCommand: agentsSetIdentityCommandMock,
|
||||||
|
agentsUnbindCommand: agentsUnbindCommandMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../globals.js", () => ({
|
vi.mock("../../globals.js", () => ({
|
||||||
@ -55,9 +61,12 @@ describe("registerAgentCommands", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
agentCliCommandMock.mockResolvedValue(undefined);
|
agentCliCommandMock.mockResolvedValue(undefined);
|
||||||
agentsAddCommandMock.mockResolvedValue(undefined);
|
agentsAddCommandMock.mockResolvedValue(undefined);
|
||||||
|
agentsBindingsCommandMock.mockResolvedValue(undefined);
|
||||||
|
agentsBindCommandMock.mockResolvedValue(undefined);
|
||||||
agentsDeleteCommandMock.mockResolvedValue(undefined);
|
agentsDeleteCommandMock.mockResolvedValue(undefined);
|
||||||
agentsListCommandMock.mockResolvedValue(undefined);
|
agentsListCommandMock.mockResolvedValue(undefined);
|
||||||
agentsSetIdentityCommandMock.mockResolvedValue(undefined);
|
agentsSetIdentityCommandMock.mockResolvedValue(undefined);
|
||||||
|
agentsUnbindCommandMock.mockResolvedValue(undefined);
|
||||||
createDefaultDepsMock.mockReturnValue({ deps: true });
|
createDefaultDepsMock.mockReturnValue({ deps: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -147,6 +156,61 @@ describe("registerAgentCommands", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards agents bindings options", async () => {
|
||||||
|
await runCli(["agents", "bindings", "--agent", "ops", "--json"]);
|
||||||
|
expect(agentsBindingsCommandMock).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
agent: "ops",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards agents bind options", async () => {
|
||||||
|
await runCli([
|
||||||
|
"agents",
|
||||||
|
"bind",
|
||||||
|
"--agent",
|
||||||
|
"ops",
|
||||||
|
"--bind",
|
||||||
|
"matrix-js:ops",
|
||||||
|
"--bind",
|
||||||
|
"telegram",
|
||||||
|
"--json",
|
||||||
|
]);
|
||||||
|
expect(agentsBindCommandMock).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
agent: "ops",
|
||||||
|
bind: ["matrix-js:ops", "telegram"],
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documents bind accountId resolution behavior in help text", () => {
|
||||||
|
const program = new Command();
|
||||||
|
registerAgentCommands(program, { agentChannelOptions: "last|telegram|discord" });
|
||||||
|
const agents = program.commands.find((command) => command.name() === "agents");
|
||||||
|
const bind = agents?.commands.find((command) => command.name() === "bind");
|
||||||
|
const help = bind?.helpInformation() ?? "";
|
||||||
|
expect(help).toContain("accountId is resolved by channel defaults/hooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards agents unbind options", async () => {
|
||||||
|
await runCli(["agents", "unbind", "--agent", "ops", "--all", "--json"]);
|
||||||
|
expect(agentsUnbindCommandMock).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
agent: "ops",
|
||||||
|
bind: [],
|
||||||
|
all: true,
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards agents delete options", async () => {
|
it("forwards agents delete options", async () => {
|
||||||
await runCli(["agents", "delete", "worker-a", "--force", "--json"]);
|
await runCli(["agents", "delete", "worker-a", "--force", "--json"]);
|
||||||
expect(agentsDeleteCommandMock).toHaveBeenCalledWith(
|
expect(agentsDeleteCommandMock).toHaveBeenCalledWith(
|
||||||
|
|||||||
@ -2,9 +2,12 @@ import type { Command } from "commander";
|
|||||||
import { agentCliCommand } from "../../commands/agent-via-gateway.js";
|
import { agentCliCommand } from "../../commands/agent-via-gateway.js";
|
||||||
import {
|
import {
|
||||||
agentsAddCommand,
|
agentsAddCommand,
|
||||||
|
agentsBindingsCommand,
|
||||||
|
agentsBindCommand,
|
||||||
agentsDeleteCommand,
|
agentsDeleteCommand,
|
||||||
agentsListCommand,
|
agentsListCommand,
|
||||||
agentsSetIdentityCommand,
|
agentsSetIdentityCommand,
|
||||||
|
agentsUnbindCommand,
|
||||||
} from "../../commands/agents.js";
|
} from "../../commands/agents.js";
|
||||||
import { setVerbose } from "../../globals.js";
|
import { setVerbose } from "../../globals.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
@ -102,6 +105,68 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
agents
|
||||||
|
.command("bindings")
|
||||||
|
.description("List routing bindings")
|
||||||
|
.option("--agent <id>", "Filter by agent id")
|
||||||
|
.option("--json", "Output JSON instead of text", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||||
|
await agentsBindingsCommand(
|
||||||
|
{
|
||||||
|
agent: opts.agent as string | undefined,
|
||||||
|
json: Boolean(opts.json),
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
agents
|
||||||
|
.command("bind")
|
||||||
|
.description("Add routing bindings for an agent")
|
||||||
|
.option("--agent <id>", "Agent id (defaults to current default agent)")
|
||||||
|
.option(
|
||||||
|
"--bind <channel[:accountId]>",
|
||||||
|
"Binding to add (repeatable). If omitted, accountId is resolved by channel defaults/hooks.",
|
||||||
|
collectOption,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.option("--json", "Output JSON summary", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||||
|
await agentsBindCommand(
|
||||||
|
{
|
||||||
|
agent: opts.agent as string | undefined,
|
||||||
|
bind: Array.isArray(opts.bind) ? (opts.bind as string[]) : undefined,
|
||||||
|
json: Boolean(opts.json),
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
agents
|
||||||
|
.command("unbind")
|
||||||
|
.description("Remove routing bindings for an agent")
|
||||||
|
.option("--agent <id>", "Agent id (defaults to current default agent)")
|
||||||
|
.option("--bind <channel[:accountId]>", "Binding to remove (repeatable)", collectOption, [])
|
||||||
|
.option("--all", "Remove all bindings for this agent", false)
|
||||||
|
.option("--json", "Output JSON summary", false)
|
||||||
|
.action(async (opts) => {
|
||||||
|
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||||
|
await agentsUnbindCommand(
|
||||||
|
{
|
||||||
|
agent: opts.agent as string | undefined,
|
||||||
|
bind: Array.isArray(opts.bind) ? (opts.bind as string[]) : undefined,
|
||||||
|
all: Boolean(opts.all),
|
||||||
|
json: Boolean(opts.json),
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
agents
|
agents
|
||||||
.command("add [name]")
|
.command("add [name]")
|
||||||
.description("Add a new isolated agent")
|
.description("Add a new isolated agent")
|
||||||
|
|||||||
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"]) {
|
function bindingMatchKey(match: AgentBinding["match"]) {
|
||||||
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
|
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
|
||||||
|
const identityKey = bindingMatchIdentityKey(match);
|
||||||
|
return [identityKey, accountId].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindingMatchIdentityKey(match: AgentBinding["match"]) {
|
||||||
|
const roles = Array.isArray(match.roles)
|
||||||
|
? Array.from(
|
||||||
|
new Set(
|
||||||
|
match.roles
|
||||||
|
.map((role) => role.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.toSorted(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
return [
|
return [
|
||||||
match.channel,
|
match.channel,
|
||||||
accountId,
|
|
||||||
match.peer?.kind ?? "",
|
match.peer?.kind ?? "",
|
||||||
match.peer?.id ?? "",
|
match.peer?.id ?? "",
|
||||||
match.guildId ?? "",
|
match.guildId ?? "",
|
||||||
match.teamId ?? "",
|
match.teamId ?? "",
|
||||||
|
roles.join(","),
|
||||||
].join("|");
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUpgradeBindingAccountScope(params: {
|
||||||
|
existing: AgentBinding;
|
||||||
|
incoming: AgentBinding;
|
||||||
|
normalizedIncomingAgentId: string;
|
||||||
|
}): boolean {
|
||||||
|
if (!params.incoming.match.accountId?.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (params.existing.match.accountId?.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizeAgentId(params.existing.agentId) !== params.normalizedIncomingAgentId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
bindingMatchIdentityKey(params.existing.match) ===
|
||||||
|
bindingMatchIdentityKey(params.incoming.match)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function describeBinding(binding: AgentBinding) {
|
export function describeBinding(binding: AgentBinding) {
|
||||||
const match = binding.match;
|
const match = binding.match;
|
||||||
const parts = [match.channel];
|
const parts = [match.channel];
|
||||||
@ -42,10 +77,11 @@ export function applyAgentBindings(
|
|||||||
): {
|
): {
|
||||||
config: OpenClawConfig;
|
config: OpenClawConfig;
|
||||||
added: AgentBinding[];
|
added: AgentBinding[];
|
||||||
|
updated: AgentBinding[];
|
||||||
skipped: AgentBinding[];
|
skipped: AgentBinding[];
|
||||||
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
|
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
|
||||||
} {
|
} {
|
||||||
const existing = cfg.bindings ?? [];
|
const existing = [...(cfg.bindings ?? [])];
|
||||||
const existingMatchMap = new Map<string, string>();
|
const existingMatchMap = new Map<string, string>();
|
||||||
for (const binding of existing) {
|
for (const binding of existing) {
|
||||||
const key = bindingMatchKey(binding.match);
|
const key = bindingMatchKey(binding.match);
|
||||||
@ -55,6 +91,7 @@ export function applyAgentBindings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const added: AgentBinding[] = [];
|
const added: AgentBinding[] = [];
|
||||||
|
const updated: AgentBinding[] = [];
|
||||||
const skipped: AgentBinding[] = [];
|
const skipped: AgentBinding[] = [];
|
||||||
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
|
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
|
||||||
|
|
||||||
@ -70,12 +107,41 @@ export function applyAgentBindings(
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upgradeIndex = existing.findIndex((candidate) =>
|
||||||
|
canUpgradeBindingAccountScope({
|
||||||
|
existing: candidate,
|
||||||
|
incoming: binding,
|
||||||
|
normalizedIncomingAgentId: agentId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (upgradeIndex >= 0) {
|
||||||
|
const current = existing[upgradeIndex];
|
||||||
|
if (!current) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const previousKey = bindingMatchKey(current.match);
|
||||||
|
const upgradedBinding: AgentBinding = {
|
||||||
|
...current,
|
||||||
|
agentId,
|
||||||
|
match: {
|
||||||
|
...current.match,
|
||||||
|
accountId: binding.match.accountId?.trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
existing[upgradeIndex] = upgradedBinding;
|
||||||
|
existingMatchMap.delete(previousKey);
|
||||||
|
existingMatchMap.set(bindingMatchKey(upgradedBinding.match), agentId);
|
||||||
|
updated.push(upgradedBinding);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
existingMatchMap.set(key, agentId);
|
existingMatchMap.set(key, agentId);
|
||||||
added.push({ ...binding, agentId });
|
added.push({ ...binding, agentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (added.length === 0) {
|
if (added.length === 0 && updated.length === 0) {
|
||||||
return { config: cfg, added, skipped, conflicts };
|
return { config: cfg, added, updated, skipped, conflicts };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -84,11 +150,78 @@ export function applyAgentBindings(
|
|||||||
bindings: [...existing, ...added],
|
bindings: [...existing, ...added],
|
||||||
},
|
},
|
||||||
added,
|
added,
|
||||||
|
updated,
|
||||||
skipped,
|
skipped,
|
||||||
conflicts,
|
conflicts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removeAgentBindings(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
bindings: AgentBinding[],
|
||||||
|
): {
|
||||||
|
config: OpenClawConfig;
|
||||||
|
removed: AgentBinding[];
|
||||||
|
missing: AgentBinding[];
|
||||||
|
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
|
||||||
|
} {
|
||||||
|
const existing = cfg.bindings ?? [];
|
||||||
|
const removeIndexes = new Set<number>();
|
||||||
|
const removed: AgentBinding[] = [];
|
||||||
|
const missing: AgentBinding[] = [];
|
||||||
|
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const desiredAgentId = normalizeAgentId(binding.agentId);
|
||||||
|
const key = bindingMatchKey(binding.match);
|
||||||
|
let matchedIndex = -1;
|
||||||
|
let conflictingAgentId: string | null = null;
|
||||||
|
for (let i = 0; i < existing.length; i += 1) {
|
||||||
|
if (removeIndexes.has(i)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const current = existing[i];
|
||||||
|
if (!current || bindingMatchKey(current.match) !== key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const currentAgentId = normalizeAgentId(current.agentId);
|
||||||
|
if (currentAgentId === desiredAgentId) {
|
||||||
|
matchedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
conflictingAgentId = currentAgentId;
|
||||||
|
}
|
||||||
|
if (matchedIndex >= 0) {
|
||||||
|
const matched = existing[matchedIndex];
|
||||||
|
if (matched) {
|
||||||
|
removeIndexes.add(matchedIndex);
|
||||||
|
removed.push(matched);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (conflictingAgentId) {
|
||||||
|
conflicts.push({ binding, existingAgentId: conflictingAgentId });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
missing.push(binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeIndexes.size === 0) {
|
||||||
|
return { config: cfg, removed, missing, conflicts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextBindings = existing.filter((_, index) => !removeIndexes.has(index));
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
...cfg,
|
||||||
|
bindings: nextBindings.length > 0 ? nextBindings : undefined,
|
||||||
|
},
|
||||||
|
removed,
|
||||||
|
missing,
|
||||||
|
conflicts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string {
|
function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): string {
|
||||||
const plugin = getChannelPlugin(provider);
|
const plugin = getChannelPlugin(provider);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
@ -97,6 +230,33 @@ function resolveDefaultAccountId(cfg: OpenClawConfig, provider: ChannelId): stri
|
|||||||
return resolveChannelDefaultAccountId({ plugin, cfg });
|
return resolveChannelDefaultAccountId({ plugin, cfg });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBindingAccountId(params: {
|
||||||
|
channel: ChannelId;
|
||||||
|
config: OpenClawConfig;
|
||||||
|
agentId: string;
|
||||||
|
explicitAccountId?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
const explicitAccountId = params.explicitAccountId?.trim();
|
||||||
|
if (explicitAccountId) {
|
||||||
|
return explicitAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = getChannelPlugin(params.channel);
|
||||||
|
const pluginAccountId = plugin?.setup?.resolveBindingAccountId?.({
|
||||||
|
cfg: params.config,
|
||||||
|
agentId: params.agentId,
|
||||||
|
});
|
||||||
|
if (pluginAccountId?.trim()) {
|
||||||
|
return pluginAccountId.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin?.meta.forceAccountBinding) {
|
||||||
|
return resolveDefaultAccountId(params.config, params.channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildChannelBindings(params: {
|
export function buildChannelBindings(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
selection: ChannelChoice[];
|
selection: ChannelChoice[];
|
||||||
@ -107,14 +267,14 @@ export function buildChannelBindings(params: {
|
|||||||
const agentId = normalizeAgentId(params.agentId);
|
const agentId = normalizeAgentId(params.agentId);
|
||||||
for (const channel of params.selection) {
|
for (const channel of params.selection) {
|
||||||
const match: AgentBinding["match"] = { channel };
|
const match: AgentBinding["match"] = { channel };
|
||||||
const accountId = params.accountIds?.[channel]?.trim();
|
const accountId = resolveBindingAccountId({
|
||||||
|
channel,
|
||||||
|
config: params.config,
|
||||||
|
agentId,
|
||||||
|
explicitAccountId: params.accountIds?.[channel],
|
||||||
|
});
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
match.accountId = accountId;
|
match.accountId = accountId;
|
||||||
} else {
|
|
||||||
const plugin = getChannelPlugin(channel);
|
|
||||||
if (plugin?.meta.forceAccountBinding) {
|
|
||||||
match.accountId = resolveDefaultAccountId(params.config, channel);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
bindings.push({ agentId, match });
|
bindings.push({ agentId, match });
|
||||||
}
|
}
|
||||||
@ -141,17 +301,17 @@ export function parseBindingSpecs(params: {
|
|||||||
errors.push(`Unknown channel "${channelRaw}".`);
|
errors.push(`Unknown channel "${channelRaw}".`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let accountId = accountRaw?.trim();
|
let accountId: string | undefined = accountRaw?.trim();
|
||||||
if (accountRaw !== undefined && !accountId) {
|
if (accountRaw !== undefined && !accountId) {
|
||||||
errors.push(`Invalid binding "${trimmed}" (empty account id).`);
|
errors.push(`Invalid binding "${trimmed}" (empty account id).`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!accountId) {
|
accountId = resolveBindingAccountId({
|
||||||
const plugin = getChannelPlugin(channel);
|
channel,
|
||||||
if (plugin?.meta.forceAccountBinding) {
|
config: params.config,
|
||||||
accountId = resolveDefaultAccountId(params.config, channel);
|
agentId,
|
||||||
}
|
explicitAccountId: accountId,
|
||||||
}
|
});
|
||||||
const match: AgentBinding["match"] = { channel };
|
const match: AgentBinding["match"] = { channel };
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
match.accountId = accountId;
|
match.accountId = accountId;
|
||||||
|
|||||||
@ -125,7 +125,7 @@ export async function agentsAddCommand(
|
|||||||
const bindingResult =
|
const bindingResult =
|
||||||
bindingParse.bindings.length > 0
|
bindingParse.bindings.length > 0
|
||||||
? applyAgentBindings(nextConfig, bindingParse.bindings)
|
? applyAgentBindings(nextConfig, bindingParse.bindings)
|
||||||
: { config: nextConfig, added: [], skipped: [], conflicts: [] };
|
: { config: nextConfig, added: [], updated: [], skipped: [], conflicts: [] };
|
||||||
|
|
||||||
await writeConfigFile(bindingResult.config);
|
await writeConfigFile(bindingResult.config);
|
||||||
if (!opts.json) {
|
if (!opts.json) {
|
||||||
@ -145,6 +145,7 @@ export async function agentsAddCommand(
|
|||||||
model,
|
model,
|
||||||
bindings: {
|
bindings: {
|
||||||
added: bindingResult.added.map(describeBinding),
|
added: bindingResult.added.map(describeBinding),
|
||||||
|
updated: bindingResult.updated.map(describeBinding),
|
||||||
skipped: bindingResult.skipped.map(describeBinding),
|
skipped: bindingResult.skipped.map(describeBinding),
|
||||||
conflicts: bindingResult.conflicts.map(
|
conflicts: bindingResult.conflicts.map(
|
||||||
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
|
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
|
||||||
|
|||||||
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,
|
applyAgentConfig,
|
||||||
buildAgentSummaries,
|
buildAgentSummaries,
|
||||||
pruneAgentConfig,
|
pruneAgentConfig,
|
||||||
|
removeAgentBindings,
|
||||||
} from "./agents.js";
|
} from "./agents.js";
|
||||||
|
|
||||||
describe("agents helpers", () => {
|
describe("agents helpers", () => {
|
||||||
@ -111,6 +112,114 @@ describe("agents helpers", () => {
|
|||||||
expect(result.config.bindings).toHaveLength(2);
|
expect(result.config.bindings).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applyAgentBindings upgrades channel-only binding to account-specific binding for same agent", () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
agentId: "main",
|
||||||
|
match: { channel: "telegram" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyAgentBindings(cfg, [
|
||||||
|
{
|
||||||
|
agentId: "main",
|
||||||
|
match: { channel: "telegram", accountId: "work" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.added).toHaveLength(0);
|
||||||
|
expect(result.updated).toHaveLength(1);
|
||||||
|
expect(result.conflicts).toHaveLength(0);
|
||||||
|
expect(result.config.bindings).toEqual([
|
||||||
|
{
|
||||||
|
agentId: "main",
|
||||||
|
match: { channel: "telegram", accountId: "work" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applyAgentBindings treats role-based bindings as distinct routes", () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
agentId: "main",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "guild-a",
|
||||||
|
guildId: "123",
|
||||||
|
roles: ["111", "222"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyAgentBindings(cfg, [
|
||||||
|
{
|
||||||
|
agentId: "work",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "guild-a",
|
||||||
|
guildId: "123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.added).toHaveLength(1);
|
||||||
|
expect(result.conflicts).toHaveLength(0);
|
||||||
|
expect(result.config.bindings).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeAgentBindings does not remove role-based bindings when removing channel-level routes", () => {
|
||||||
|
const cfg: OpenClawConfig = {
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
agentId: "main",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "guild-a",
|
||||||
|
guildId: "123",
|
||||||
|
roles: ["111", "222"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "main",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "guild-a",
|
||||||
|
guildId: "123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = removeAgentBindings(cfg, [
|
||||||
|
{
|
||||||
|
agentId: "main",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "guild-a",
|
||||||
|
guildId: "123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.removed).toHaveLength(1);
|
||||||
|
expect(result.conflicts).toHaveLength(0);
|
||||||
|
expect(result.config.bindings).toEqual([
|
||||||
|
{
|
||||||
|
agentId: "main",
|
||||||
|
match: {
|
||||||
|
channel: "discord",
|
||||||
|
accountId: "guild-a",
|
||||||
|
guildId: "123",
|
||||||
|
roles: ["111", "222"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => {
|
it("pruneAgentConfig removes agent, bindings, and allowlist entries", () => {
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export * from "./agents.bindings.js";
|
export * from "./agents.bindings.js";
|
||||||
|
export * from "./agents.commands.bind.js";
|
||||||
export * from "./agents.commands.add.js";
|
export * from "./agents.commands.add.js";
|
||||||
export * from "./agents.commands.delete.js";
|
export * from "./agents.commands.delete.js";
|
||||||
export * from "./agents.commands.identity.js";
|
export * from "./agents.commands.identity.js";
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
|||||||
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||||
import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js";
|
import { deleteTelegramUpdateOffset } from "../../telegram/update-offset-store.js";
|
||||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||||
|
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
|
||||||
|
import { buildAgentSummaries } from "../agents.config.js";
|
||||||
import { setupChannels } from "../onboard-channels.js";
|
import { setupChannels } from "../onboard-channels.js";
|
||||||
import type { ChannelChoice } from "../onboard-types.js";
|
import type { ChannelChoice } from "../onboard-types.js";
|
||||||
import {
|
import {
|
||||||
@ -111,6 +113,68 @@ export async function channelsAddCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bindTargets = selection
|
||||||
|
.map((channel) => ({
|
||||||
|
channel,
|
||||||
|
accountId: accountIds[channel]?.trim(),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
value,
|
||||||
|
): value is {
|
||||||
|
channel: ChannelChoice;
|
||||||
|
accountId: string;
|
||||||
|
} => Boolean(value.accountId),
|
||||||
|
);
|
||||||
|
if (bindTargets.length > 0) {
|
||||||
|
const bindNow = await prompter.confirm({
|
||||||
|
message: "Bind configured channel accounts to agents now?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (bindNow) {
|
||||||
|
const agentSummaries = buildAgentSummaries(nextConfig);
|
||||||
|
const defaultAgentId = resolveDefaultAgentId(nextConfig);
|
||||||
|
for (const target of bindTargets) {
|
||||||
|
const targetAgentId = await prompter.select({
|
||||||
|
message: `Route ${target.channel} account "${target.accountId}" to agent`,
|
||||||
|
options: agentSummaries.map((agent) => ({
|
||||||
|
value: agent.id,
|
||||||
|
label: agent.isDefault ? `${agent.id} (default)` : agent.id,
|
||||||
|
})),
|
||||||
|
initialValue: defaultAgentId,
|
||||||
|
});
|
||||||
|
const bindingResult = applyAgentBindings(nextConfig, [
|
||||||
|
{
|
||||||
|
agentId: targetAgentId,
|
||||||
|
match: { channel: target.channel, accountId: target.accountId },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
nextConfig = bindingResult.config;
|
||||||
|
if (bindingResult.added.length > 0 || bindingResult.updated.length > 0) {
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
...bindingResult.added.map((binding) => `Added: ${describeBinding(binding)}`),
|
||||||
|
...bindingResult.updated.map((binding) => `Updated: ${describeBinding(binding)}`),
|
||||||
|
].join("\n"),
|
||||||
|
"Routing bindings",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (bindingResult.conflicts.length > 0) {
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"Skipped bindings already claimed by another agent:",
|
||||||
|
...bindingResult.conflicts.map(
|
||||||
|
(conflict) =>
|
||||||
|
`- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
|
||||||
|
),
|
||||||
|
].join("\n"),
|
||||||
|
"Routing bindings",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await writeConfigFile(nextConfig);
|
await writeConfigFile(nextConfig);
|
||||||
await prompter.outro("Channels updated.");
|
await prompter.outro("Channels updated.");
|
||||||
return;
|
return;
|
||||||
@ -153,9 +217,6 @@ export async function channelsAddCommand(
|
|||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accountId =
|
|
||||||
plugin.setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account }) ??
|
|
||||||
normalizeAccountId(opts.account);
|
|
||||||
const useEnv = opts.useEnv === true;
|
const useEnv = opts.useEnv === true;
|
||||||
const initialSyncLimit =
|
const initialSyncLimit =
|
||||||
typeof opts.initialSyncLimit === "number"
|
typeof opts.initialSyncLimit === "number"
|
||||||
@ -199,6 +260,12 @@ export async function channelsAddCommand(
|
|||||||
dmAllowlist,
|
dmAllowlist,
|
||||||
autoDiscoverChannels: opts.autoDiscoverChannels,
|
autoDiscoverChannels: opts.autoDiscoverChannels,
|
||||||
};
|
};
|
||||||
|
const accountId =
|
||||||
|
plugin.setup.resolveAccountId?.({
|
||||||
|
cfg: nextConfig,
|
||||||
|
accountId: opts.account,
|
||||||
|
input,
|
||||||
|
}) ?? normalizeAccountId(opts.account);
|
||||||
|
|
||||||
const validationError = plugin.setup.validateInput?.({
|
const validationError = plugin.setup.validateInput?.({
|
||||||
cfg: nextConfig,
|
cfg: nextConfig,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user