fix(agents): normalize requester peer id before bound-account lookup

Delivery targets on Matrix (and other channels that namespace the `to`
field) arrive in `kind:<id>` form — for example `room:!abc:example.org`
— while route bindings store the raw peer id on `match.peer.id`
(`!abc:example.org`). Passing `ctx.agentTo` directly to
`resolveFirstBoundAccountId` caused exact peer matches to silently fail
and the lookup to fall through to channel-only or caller-account
fallback, so cross-agent spawns could still post as the wrong identity
when only a peer-specific binding was configured.

- src/agents/subagent-spawn.ts: strip known delivery-target prefixes
  (`room:`, `channel:`, `chat:`, `user:`, `dm:`, `group:`, and the
  channel-namespaced `${channelId}:`) from `requesterTo` before handing
  it to `resolveFirstBoundAccountId`. The inferred `peerKind` still uses
  the original `requesterTo` so channel plugins can apply their own
  inference on the wire format.

Regression test in lifecycle suite:
- sessions_spawn strips channel-side prefixes from agentTo before the
  bound-account lookup — a binding on the raw room id resolves correctly
  even when the caller's `agentTo` is `room:<id>`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luke Boyett
2026-04-16 07:05:44 -04:00
committed by Gustavo Madeira Santana
parent 6faff0c343
commit 9cfb6e1d85
2 changed files with 49 additions and 0 deletions

View File

@@ -652,6 +652,51 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
expect(spawnAccountId).toBe("bot-alpha-room-a");
});
it("sessions_spawn strips channel-side prefixes from agentTo before bound-account lookup", async () => {
let spawnAccountId: string | undefined;
const rawRoomId = "!exampleRoomId:example.org";
setSessionsSpawnConfigOverride({
session: { mainKey: "main", scope: "per-sender" },
messages: { queue: { debounceMs: 0 } },
agents: { defaults: { subagents: { allowAgents: ["bot-alpha"] } } },
bindings: [
{
type: "route",
agentId: "bot-alpha",
match: {
channel: "matrix",
peer: { kind: "channel", id: rawRoomId },
accountId: "bot-alpha",
},
},
],
});
setupSessionsSpawnGatewayMock({
onAgentSubagentSpawn: (params) => {
const rec = params as { accountId?: string } | undefined;
spawnAccountId = rec?.accountId;
},
});
// agentTo arrives in delivery-target format (room:<id>), while the binding
// stores the raw id. Without prefix normalization the exact peer match
// would silently fail and the caller account would leak to the child.
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
agentChannel: "matrix",
agentAccountId: "bot-beta",
agentTo: `room:${rawRoomId}`,
});
const result = await tool.execute("call-prefixed-to", {
task: "do thing",
agentId: "bot-alpha",
cleanup: "keep",
});
expect(result.details).toMatchObject({ status: "accepted", runId: expect.any(String) });
expect(spawnAccountId).toBe("bot-alpha");
});
it("sessions_spawn preserves the caller's account for same-agent subagent spawns", async () => {
let spawnAccountId: string | undefined;
const room = "!someRoom:example.org";

View File

@@ -287,6 +287,10 @@ function summarizeError(err: unknown): string {
return "error";
}
// Delivery targets carry a channel-side prefix (e.g. Matrix uses `room:<roomId>`;
// LINE uses `line:group:<id>`), but route bindings store raw peer ids on
// `match.peer.id`. Peel the `<channel>:` namespace first, then loop over generic
// target-kind prefixes so the raw peer id surfaces.
const KIND_PREFIX_TO_CHAT_TYPE: Readonly<Record<string, ChatType>> = {
"room:": "channel",
"channel:": "channel",