From 6faff0c343fb2645c6a5d869e4ef190bd1f3ba93 Mon Sep 17 00:00:00 2001 From: Luke Boyett Date: Thu, 16 Apr 2026 06:12:32 -0400 Subject: [PATCH] fix(routing): match bound peers by kind before applying wildcard precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent with bindings that differed only by peer kind (for example direct/* and channel/*, or the same peer id across kinds) could pick the wrong sender account in resolveFirstBoundAccountId because the lookup compared peer.id only and dropped peer.kind. Combined with the peerId now always being forwarded from subagent spawns, an unrelated binding could win purely by config order and route child messages from the wrong identity. - src/routing/bound-account-read.ts: preserve peer.kind in the normalized match and accept an optional peerKind on resolveFirstBoundAccountId. When both caller and binding declare a kind, they must match or the binding is skipped. If either side omits the kind, kind is not used as a filter (preserves prior behavior for callers that do not know the kind, such as cron delivery resolution). - src/agents/subagent-spawn.ts: derive peerKind for the lookup via the active channel plugin's inferTargetChatType helper and pass it through. Same-agent spawns still short-circuit the lookup entirely. Regression coverage in src/routing/bound-account-read.test.ts: - Filters bindings by peer kind when caller supplies peerKind — direct/* and channel/* wildcards resolve to distinct accounts. - Skips peer-specific bindings whose kind does not match the caller's peerKind, falling through to the channel-only binding. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routing/bound-account-read.test.ts | 71 ++++++++++++++++++++++++++ src/routing/bound-account-read.ts | 13 +++++ 2 files changed, 84 insertions(+) diff --git a/src/routing/bound-account-read.test.ts b/src/routing/bound-account-read.test.ts index 5e768cfef23..ce1a0cc19ac 100644 --- a/src/routing/bound-account-read.test.ts +++ b/src/routing/bound-account-read.test.ts @@ -147,4 +147,75 @@ describe("resolveFirstBoundAccountId", () => { }), ).toBeUndefined(); }); + + it("filters bindings by peer kind when caller supplies peerKind", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "direct", id: "*" }, + accountId: "bot-alpha-dm", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-room", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!room:example.org", + peerKind: "channel", + }), + ).toBe("bot-alpha-room"); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "@user:example.org", + peerKind: "direct", + }), + ).toBe("bot-alpha-dm"); + }); + + it("skips peer-specific bindings whose kind does not match the caller's peerKind", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "direct", id: "!room:example.org" }, + accountId: "bot-alpha-wrong-kind", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + ]); + // Caller peerKind=channel: the direct-kind binding is ineligible even though + // its peerId would match — falls through to the channel-only binding. + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!room:example.org", + peerKind: "channel", + }), + ).toBe("bot-alpha-default"); + }); }); diff --git a/src/routing/bound-account-read.ts b/src/routing/bound-account-read.ts index 08347352958..8d493be35e0 100644 --- a/src/routing/bound-account-read.ts +++ b/src/routing/bound-account-read.ts @@ -1,3 +1,4 @@ +import { normalizeChatType, type ChatType } from "../channels/chat-type.js"; import { normalizeChatChannelId } from "../channels/ids.js"; import { listRouteBindings } from "../config/bindings.js"; import type { AgentRouteBinding } from "../config/types.agents.js"; @@ -19,6 +20,7 @@ function resolveNormalizedBindingMatch(binding: AgentRouteBinding): { accountId: string; channelId: string; peerId?: string; + peerKind?: ChatType; } | null { if (!binding || typeof binding !== "object") { return null; @@ -36,11 +38,13 @@ function resolveNormalizedBindingMatch(binding: AgentRouteBinding): { return null; } const peerId = match.peer && typeof match.peer.id === "string" ? match.peer.id.trim() : undefined; + const peerKind = match.peer ? normalizeChatType(match.peer.kind) : undefined; return { agentId: normalizeAgentId(binding.agentId), accountId: normalizeAccountId(accountId), channelId, peerId: peerId || undefined, + peerKind: peerKind ?? undefined, }; } @@ -49,6 +53,7 @@ export function resolveFirstBoundAccountId(params: { channelId: string; agentId: string; peerId?: string; + peerKind?: ChatType; }): string | undefined { const normalizedChannel = normalizeBindingChannelId(params.channelId); if (!normalizedChannel) { @@ -56,6 +61,7 @@ export function resolveFirstBoundAccountId(params: { } const normalizedAgentId = normalizeAgentId(params.agentId); const normalizedPeerId = params.peerId?.trim() || undefined; + const normalizedPeerKind = normalizeChatType(params.peerKind) ?? undefined; let wildcardPeerMatch: string | undefined; let channelOnlyFallback: string | undefined; let peerlessPeerSpecificFallback: string | undefined; @@ -68,6 +74,13 @@ export function resolveFirstBoundAccountId(params: { ) { continue; } + // When the caller knows the peer kind and the binding declares a peer kind, + // they must match — a direct/* binding must not win for a channel caller, + // and vice versa. If either side omits the kind, we do not filter on it + // (preserves backward-compat for peerless cron callers). + if (resolved.peerKind && normalizedPeerKind && resolved.peerKind !== normalizedPeerKind) { + continue; + } if (resolved.peerId === "*") { if (normalizedPeerId) { wildcardPeerMatch ??= resolved.accountId;