fix(routing): exclude peer-specific bindings from guild-wide matching (#15274)
* fix(routing): exclude peer-specific bindings from guild-wide matching (#14752) * fix(routing): enforce binding scope AND semantics + regressions * fix(routing): document strict binding-scope behavior (#15274) (thanks @lailoo) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
|
||||
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
|
||||
- Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo.
|
||||
- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.
|
||||
- Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.
|
||||
- Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.
|
||||
|
||||
|
||||
@@ -44,11 +44,15 @@ Examples:
|
||||
Routing picks **one agent** for each inbound message:
|
||||
|
||||
1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`).
|
||||
2. **Guild match** (Discord) via `guildId`.
|
||||
3. **Team match** (Slack) via `teamId`.
|
||||
4. **Account match** (`accountId` on the channel).
|
||||
5. **Channel match** (any account on that channel).
|
||||
6. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`).
|
||||
2. **Parent peer match** (thread inheritance).
|
||||
3. **Guild + roles match** (Discord) via `guildId` + `roles`.
|
||||
4. **Guild match** (Discord) via `guildId`.
|
||||
5. **Team match** (Slack) via `teamId`.
|
||||
6. **Account match** (`accountId` on the channel).
|
||||
7. **Channel match** (any account on that channel, `accountId: "*"`).
|
||||
8. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`).
|
||||
|
||||
When a binding includes multiple match fields (`peer`, `guildId`, `teamId`, `roles`), **all provided fields must match** for that binding to apply.
|
||||
|
||||
The matched agent determines which workspace and session store are used.
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ Token resolution is account-aware. Config token values win over env fallback. `D
|
||||
|
||||
### Role-based agent routing
|
||||
|
||||
Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings.
|
||||
Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. If a binding also sets other match fields (for example `peer` + `guildId` + `roles`), all configured fields must match.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -125,11 +125,15 @@ Notes:
|
||||
Bindings are **deterministic** and **most-specific wins**:
|
||||
|
||||
1. `peer` match (exact DM/group/channel id)
|
||||
2. `guildId` (Discord)
|
||||
3. `teamId` (Slack)
|
||||
4. `accountId` match for a channel
|
||||
5. channel-level match (`accountId: "*"`)
|
||||
6. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`)
|
||||
2. `parentPeer` match (thread inheritance)
|
||||
3. `guildId + roles` (Discord role routing)
|
||||
4. `guildId` (Discord)
|
||||
5. `teamId` (Slack)
|
||||
6. `accountId` match for a channel
|
||||
7. channel-level match (`accountId: "*"`)
|
||||
8. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`)
|
||||
|
||||
If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics).
|
||||
|
||||
## Multiple accounts / phone numbers
|
||||
|
||||
|
||||
@@ -169,6 +169,126 @@ describe("resolveAgentRoute", () => {
|
||||
expect(route.matchedBy).toBe("binding.guild");
|
||||
});
|
||||
|
||||
test("peer+guild binding does not act as guild-wide fallback when peer mismatches (#14752)", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "olga",
|
||||
match: {
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "CHANNEL_A" },
|
||||
guildId: "GUILD_1",
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
match: {
|
||||
channel: "discord",
|
||||
guildId: "GUILD_1",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "CHANNEL_B" },
|
||||
guildId: "GUILD_1",
|
||||
});
|
||||
expect(route.agentId).toBe("main");
|
||||
expect(route.matchedBy).toBe("binding.guild");
|
||||
});
|
||||
|
||||
test("peer+guild binding requires guild match even when peer matches", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "wrongguild",
|
||||
match: {
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
guildId: "g1",
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "rightguild",
|
||||
match: {
|
||||
channel: "discord",
|
||||
guildId: "g2",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
guildId: "g2",
|
||||
});
|
||||
expect(route.agentId).toBe("rightguild");
|
||||
expect(route.matchedBy).toBe("binding.guild");
|
||||
});
|
||||
|
||||
test("peer+team binding does not act as team-wide fallback when peer mismatches", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "roomonly",
|
||||
match: {
|
||||
channel: "slack",
|
||||
peer: { kind: "channel", id: "C_A" },
|
||||
teamId: "T1",
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "teamwide",
|
||||
match: {
|
||||
channel: "slack",
|
||||
teamId: "T1",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C_B" },
|
||||
});
|
||||
expect(route.agentId).toBe("teamwide");
|
||||
expect(route.matchedBy).toBe("binding.team");
|
||||
});
|
||||
|
||||
test("peer+team binding requires team match even when peer matches", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "wrongteam",
|
||||
match: {
|
||||
channel: "slack",
|
||||
peer: { kind: "channel", id: "C1" },
|
||||
teamId: "T1",
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "rightteam",
|
||||
match: {
|
||||
channel: "slack",
|
||||
teamId: "T2",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
teamId: "T2",
|
||||
peer: { kind: "channel", id: "C1" },
|
||||
});
|
||||
expect(route.agentId).toBe("rightteam");
|
||||
expect(route.matchedBy).toBe("binding.team");
|
||||
});
|
||||
|
||||
test("missing accountId in binding matches default account only", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [{ agentId: "defaultAcct", match: { channel: "whatsapp" } }],
|
||||
@@ -592,4 +712,37 @@ describe("role-based agent routing", () => {
|
||||
expect(route.agentId).toBe("main");
|
||||
expect(route.matchedBy).toBe("default");
|
||||
});
|
||||
|
||||
test("peer+guild+roles binding does not act as guild+roles fallback when peer mismatches", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [
|
||||
{
|
||||
agentId: "peer-roles",
|
||||
match: {
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "c-target" },
|
||||
guildId: "g1",
|
||||
roles: ["r1"],
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "guild-roles",
|
||||
match: {
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
roles: ["r1"],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
memberRoleIds: ["r1"],
|
||||
peer: { kind: "channel", id: "c-other" },
|
||||
});
|
||||
expect(route.agentId).toBe("guild-roles");
|
||||
expect(route.matchedBy).toBe("binding.guild+roles");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,25 +152,6 @@ function matchesPeer(
|
||||
return kind === peer.kind && id === peer.id;
|
||||
}
|
||||
|
||||
function matchesGuild(
|
||||
match: { guildId?: string | undefined } | undefined,
|
||||
guildId: string,
|
||||
): boolean {
|
||||
const id = normalizeId(match?.guildId);
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
return id === guildId;
|
||||
}
|
||||
|
||||
function matchesTeam(match: { teamId?: string | undefined } | undefined, teamId: string): boolean {
|
||||
const id = normalizeId(match?.teamId);
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
return id === teamId;
|
||||
}
|
||||
|
||||
function matchesRoles(
|
||||
match: { roles?: string[] | undefined } | undefined,
|
||||
memberRoleIds: string[],
|
||||
@@ -182,6 +163,91 @@ function matchesRoles(
|
||||
return roles.some((role) => memberRoleIds.includes(role));
|
||||
}
|
||||
|
||||
function hasGuildConstraint(match: { guildId?: string | undefined } | undefined): boolean {
|
||||
return Boolean(normalizeId(match?.guildId));
|
||||
}
|
||||
|
||||
function hasTeamConstraint(match: { teamId?: string | undefined } | undefined): boolean {
|
||||
return Boolean(normalizeId(match?.teamId));
|
||||
}
|
||||
|
||||
function hasRolesConstraint(match: { roles?: string[] | undefined } | undefined): boolean {
|
||||
return Array.isArray(match?.roles) && match.roles.length > 0;
|
||||
}
|
||||
|
||||
function matchesOptionalPeer(
|
||||
match: { peer?: { kind?: string; id?: string } | undefined } | undefined,
|
||||
peer: RoutePeer | null,
|
||||
): boolean {
|
||||
if (!match?.peer) {
|
||||
return true;
|
||||
}
|
||||
if (!peer) {
|
||||
return false;
|
||||
}
|
||||
return matchesPeer(match, peer);
|
||||
}
|
||||
|
||||
function matchesOptionalGuild(
|
||||
match: { guildId?: string | undefined } | undefined,
|
||||
guildId: string,
|
||||
): boolean {
|
||||
const requiredGuildId = normalizeId(match?.guildId);
|
||||
if (!requiredGuildId) {
|
||||
return true;
|
||||
}
|
||||
if (!guildId) {
|
||||
return false;
|
||||
}
|
||||
return requiredGuildId === guildId;
|
||||
}
|
||||
|
||||
function matchesOptionalTeam(
|
||||
match: { teamId?: string | undefined } | undefined,
|
||||
teamId: string,
|
||||
): boolean {
|
||||
const requiredTeamId = normalizeId(match?.teamId);
|
||||
if (!requiredTeamId) {
|
||||
return true;
|
||||
}
|
||||
if (!teamId) {
|
||||
return false;
|
||||
}
|
||||
return requiredTeamId === teamId;
|
||||
}
|
||||
|
||||
function matchesOptionalRoles(
|
||||
match: { roles?: string[] | undefined } | undefined,
|
||||
memberRoleIds: string[],
|
||||
): boolean {
|
||||
if (!hasRolesConstraint(match)) {
|
||||
return true;
|
||||
}
|
||||
return matchesRoles(match, memberRoleIds);
|
||||
}
|
||||
|
||||
function matchesBindingScope(params: {
|
||||
match:
|
||||
| {
|
||||
peer?: { kind?: string; id?: string } | undefined;
|
||||
guildId?: string | undefined;
|
||||
teamId?: string | undefined;
|
||||
roles?: string[] | undefined;
|
||||
}
|
||||
| undefined;
|
||||
peer: RoutePeer | null;
|
||||
guildId: string;
|
||||
teamId: string;
|
||||
memberRoleIds: string[];
|
||||
}): boolean {
|
||||
return (
|
||||
matchesOptionalPeer(params.match, params.peer) &&
|
||||
matchesOptionalGuild(params.match, params.guildId) &&
|
||||
matchesOptionalTeam(params.match, params.teamId) &&
|
||||
matchesOptionalRoles(params.match, params.memberRoleIds)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute {
|
||||
const channel = normalizeToken(input.channel);
|
||||
const accountId = normalizeAccountId(input.accountId);
|
||||
@@ -228,7 +294,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
};
|
||||
|
||||
if (peer) {
|
||||
const peerMatch = bindings.find((b) => matchesPeer(b.match, peer));
|
||||
const peerMatch = bindings.find(
|
||||
(b) =>
|
||||
Boolean(b.match?.peer) &&
|
||||
matchesBindingScope({
|
||||
match: b.match,
|
||||
peer,
|
||||
guildId,
|
||||
teamId,
|
||||
memberRoleIds,
|
||||
}),
|
||||
);
|
||||
if (peerMatch) {
|
||||
return choose(peerMatch.agentId, "binding.peer");
|
||||
}
|
||||
@@ -239,7 +315,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
? { kind: input.parentPeer.kind, id: normalizeId(input.parentPeer.id) }
|
||||
: null;
|
||||
if (parentPeer && parentPeer.id) {
|
||||
const parentPeerMatch = bindings.find((b) => matchesPeer(b.match, parentPeer));
|
||||
const parentPeerMatch = bindings.find(
|
||||
(b) =>
|
||||
Boolean(b.match?.peer) &&
|
||||
matchesBindingScope({
|
||||
match: b.match,
|
||||
peer: parentPeer,
|
||||
guildId,
|
||||
teamId,
|
||||
memberRoleIds,
|
||||
}),
|
||||
);
|
||||
if (parentPeerMatch) {
|
||||
return choose(parentPeerMatch.agentId, "binding.peer.parent");
|
||||
}
|
||||
@@ -247,7 +333,16 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
|
||||
if (guildId && memberRoleIds.length > 0) {
|
||||
const guildRolesMatch = bindings.find(
|
||||
(b) => matchesGuild(b.match, guildId) && matchesRoles(b.match, memberRoleIds),
|
||||
(b) =>
|
||||
hasGuildConstraint(b.match) &&
|
||||
hasRolesConstraint(b.match) &&
|
||||
matchesBindingScope({
|
||||
match: b.match,
|
||||
peer,
|
||||
guildId,
|
||||
teamId,
|
||||
memberRoleIds,
|
||||
}),
|
||||
);
|
||||
if (guildRolesMatch) {
|
||||
return choose(guildRolesMatch.agentId, "binding.guild+roles");
|
||||
@@ -257,8 +352,15 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
if (guildId) {
|
||||
const guildMatch = bindings.find(
|
||||
(b) =>
|
||||
matchesGuild(b.match, guildId) &&
|
||||
(!Array.isArray(b.match?.roles) || b.match.roles.length === 0),
|
||||
hasGuildConstraint(b.match) &&
|
||||
!hasRolesConstraint(b.match) &&
|
||||
matchesBindingScope({
|
||||
match: b.match,
|
||||
peer,
|
||||
guildId,
|
||||
teamId,
|
||||
memberRoleIds,
|
||||
}),
|
||||
);
|
||||
if (guildMatch) {
|
||||
return choose(guildMatch.agentId, "binding.guild");
|
||||
@@ -266,7 +368,17 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
const teamMatch = bindings.find((b) => matchesTeam(b.match, teamId));
|
||||
const teamMatch = bindings.find(
|
||||
(b) =>
|
||||
hasTeamConstraint(b.match) &&
|
||||
matchesBindingScope({
|
||||
match: b.match,
|
||||
peer,
|
||||
guildId,
|
||||
teamId,
|
||||
memberRoleIds,
|
||||
}),
|
||||
);
|
||||
if (teamMatch) {
|
||||
return choose(teamMatch.agentId, "binding.team");
|
||||
}
|
||||
@@ -274,7 +386,14 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
|
||||
const accountMatch = bindings.find(
|
||||
(b) =>
|
||||
b.match?.accountId?.trim() !== "*" && !b.match?.peer && !b.match?.guildId && !b.match?.teamId,
|
||||
b.match?.accountId?.trim() !== "*" &&
|
||||
matchesBindingScope({
|
||||
match: b.match,
|
||||
peer,
|
||||
guildId,
|
||||
teamId,
|
||||
memberRoleIds,
|
||||
}),
|
||||
);
|
||||
if (accountMatch) {
|
||||
return choose(accountMatch.agentId, "binding.account");
|
||||
@@ -282,7 +401,14 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
|
||||
|
||||
const anyAccountMatch = bindings.find(
|
||||
(b) =>
|
||||
b.match?.accountId?.trim() === "*" && !b.match?.peer && !b.match?.guildId && !b.match?.teamId,
|
||||
b.match?.accountId?.trim() === "*" &&
|
||||
matchesBindingScope({
|
||||
match: b.match,
|
||||
peer,
|
||||
guildId,
|
||||
teamId,
|
||||
memberRoleIds,
|
||||
}),
|
||||
);
|
||||
if (anyAccountMatch) {
|
||||
return choose(anyAccountMatch.agentId, "binding.channel");
|
||||
|
||||
Reference in New Issue
Block a user