From 9cfb6e1d8526a3e9473a5382731e461b7298ff41 Mon Sep 17 00:00:00 2001 From: Luke Boyett Date: Thu, 16 Apr 2026 07:05:44 -0400 Subject: [PATCH] fix(agents): normalize requester peer id before bound-account lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivery targets on Matrix (and other channels that namespace the `to` field) arrive in `kind:` 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:`. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...subagents.sessions-spawn.lifecycle.test.ts | 45 +++++++++++++++++++ src/agents/subagent-spawn.ts | 4 ++ 2 files changed, 49 insertions(+) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index ab1a002e36e..e9e9b6fab2e 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -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:), 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"; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 210e159b8f2..6e9edb60a54 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -287,6 +287,10 @@ function summarizeError(err: unknown): string { return "error"; } +// Delivery targets carry a channel-side prefix (e.g. Matrix uses `room:`; +// LINE uses `line:group:`), but route bindings store raw peer ids on +// `match.peer.id`. Peel the `:` namespace first, then loop over generic +// target-kind prefixes so the raw peer id surfaces. const KIND_PREFIX_TO_CHAT_TYPE: Readonly> = { "room:": "channel", "channel:": "channel",