fix(routing): match bound peers by kind before applying wildcard precedence

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) <noreply@anthropic.com>
This commit is contained in:
Luke Boyett
2026-04-16 06:12:32 -04:00
committed by Gustavo Madeira Santana
parent 46708707f6
commit 6faff0c343
2 changed files with 84 additions and 0 deletions

View File

@@ -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");
});
});

View File

@@ -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;