Agents: upgrade channel binding to account-scoped binding

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 18:54:32 -05:00
parent 3df4df7387
commit c1ec034f49
5 changed files with 119 additions and 7 deletions

View File

@@ -58,6 +58,25 @@ describe("agents bind/unbind commands", () => {
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,

View File

@@ -8,6 +8,11 @@ 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(
@@ -20,7 +25,6 @@ function bindingMatchKey(match: AgentBinding["match"]) {
: [];
return [
match.channel,
accountId,
match.peer?.kind ?? "",
match.peer?.id ?? "",
match.guildId ?? "",
@@ -29,6 +33,26 @@ function bindingMatchKey(match: AgentBinding["match"]) {
].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];
@@ -53,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);
@@ -66,6 +91,7 @@ export function applyAgentBindings(
}
const added: AgentBinding[] = [];
const updated: AgentBinding[] = [];
const skipped: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
@@ -81,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 {
@@ -95,6 +150,7 @@ export function applyAgentBindings(
bindings: [...existing, ...added],
},
added,
updated,
skipped,
conflicts,
};

View File

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

View File

@@ -150,7 +150,7 @@ export async function agentsBindCommand(
}
const result = applyAgentBindings(cfg, parsed.bindings);
if (result.added.length > 0) {
if (result.added.length > 0 || result.updated.length > 0) {
await writeConfigFile(result.config);
if (!opts.json) {
logConfigUpdated(runtime);
@@ -160,6 +160,7 @@ export async function agentsBindCommand(
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})`,
@@ -178,10 +179,17 @@ export async function agentsBindCommand(
for (const binding of result.added) {
runtime.log(`- ${describeBinding(binding)}`);
}
} else {
} 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) {

View File

@@ -112,6 +112,34 @@ 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: [