diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e6de66b28..5c62c8d9cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin. - Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets. - Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn. - Slack/Subagent completion delivery: stop forcing bound conversation IDs into `threadId` so Slack completion announces do not send invalid `thread_ts` for DMs/top-level channels. Landed from contributor PR #31105 by @stakeswky. Thanks @stakeswky. diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index c92bfe2ba17..a685baa5bc7 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -547,6 +547,74 @@ describe("backward compatibility: peer.kind dm → direct", () => { }); }); +describe("backward compatibility: peer.kind group ↔ channel", () => { + test("config group binding matches runtime channel scope", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "slack-group-agent", + match: { + channel: "slack", + peer: { kind: "group", id: "C123456" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "slack", + accountId: null, + peer: { kind: "channel", id: "C123456" }, + }); + expect(route.agentId).toBe("slack-group-agent"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("config channel binding matches runtime group scope", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "slack-channel-agent", + match: { + channel: "slack", + peer: { kind: "channel", id: "C123456" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "slack", + accountId: null, + peer: { kind: "group", id: "C123456" }, + }); + expect(route.agentId).toBe("slack-channel-agent"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("group/channel compatibility does not match direct peer kind", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "group-only-agent", + match: { + channel: "slack", + peer: { kind: "group", id: "C123456" }, + }, + }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "slack", + accountId: null, + peer: { kind: "direct", id: "C123456" }, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + }); +}); + describe("role-based agent routing", () => { type DiscordBinding = NonNullable[number]; diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 74f1b3831b4..736727e2e75 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -262,12 +262,24 @@ function hasRolesConstraint(match: NormalizedBindingMatch): boolean { return Boolean(match.roles); } +function peerKindMatches(bindingKind: ChatType, scopeKind: ChatType): boolean { + if (bindingKind === scopeKind) { + return true; + } + const both = new Set([bindingKind, scopeKind]); + return both.has("group") && both.has("channel"); +} + function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope): boolean { if (match.peer.state === "invalid") { return false; } if (match.peer.state === "valid") { - if (!scope.peer || scope.peer.kind !== match.peer.kind || scope.peer.id !== match.peer.id) { + if ( + !scope.peer || + !peerKindMatches(match.peer.kind, scope.peer.kind) || + scope.peer.id !== match.peer.id + ) { return false; } }