From c39314c14aaab6dacee6167e974a16ee058b985e Mon Sep 17 00:00:00 2001 From: lukeboyett <46942646+lukeboyett@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:02:53 -0400 Subject: [PATCH] fix(agents): prefer target agent's bound Matrix account for subagent spawns (#67508) Merged via squash. Prepared head SHA: 9300111038b242f6ea870fab8f5c323dbf0d456b Co-authored-by: lukeboyett <46942646+lukeboyett@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 8 + .../discord/src/monitor/agent-components.ts | 1 + .../src/monitor/message-handler.preflight.ts | 1 + .../message-handler.preflight.types.ts | 1 + .../src/monitor/message-handler.process.ts | 2 + .../monitor/native-command-context.test.ts | 3 + .../src/monitor/native-command-context.ts | 6 + .../discord/src/monitor/native-command.ts | 2 + .../message-handler.authz.test.ts | 1 + .../src/monitor-handler/message-handler.ts | 1 + .../monitor/message-handler/prepare.test.ts | 1 + .../src/monitor/message-handler/prepare.ts | 1 + extensions/slack/src/monitor/slash.test.ts | 3 +- extensions/slack/src/monitor/slash.ts | 1 + src/agents/acp-spawn.test.ts | 110 ++++ src/agents/acp-spawn.ts | 53 +- ...subagents.sessions-spawn.lifecycle.test.ts | 393 ++++++++++++++ src/agents/openclaw-tools.ts | 1 + src/agents/pi-embedded-runner/run.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 1 + src/agents/pi-embedded-runner/run/params.ts | 2 + src/agents/pi-tools.ts | 3 + src/agents/spawn-requester-origin.test.ts | 201 ++++++++ src/agents/spawn-requester-origin.ts | 127 +++++ src/agents/spawned-context.ts | 1 + src/agents/subagent-spawn.test.ts | 17 +- .../subagent-spawn.thread-binding.test.ts | 111 ++++ src/agents/subagent-spawn.ts | 21 +- src/agents/tools/sessions-spawn-tool.ts | 3 + .../reply/agent-runner-utils.test.ts | 2 + src/auto-reply/reply/agent-runner-utils.ts | 10 + src/auto-reply/templating.ts | 2 + .../isolated-agent/delivery-target.test.ts | 44 ++ src/routing/binding-scope.ts | 78 +++ src/routing/bound-account-read.test.ts | 485 ++++++++++++++++++ src/routing/bound-account-read.ts | 106 +++- src/routing/peer-kind-match.ts | 11 + src/routing/resolve-route.ts | 40 +- 38 files changed, 1781 insertions(+), 74 deletions(-) create mode 100644 src/agents/spawn-requester-origin.test.ts create mode 100644 src/agents/spawn-requester-origin.ts create mode 100644 src/routing/binding-scope.ts create mode 100644 src/routing/bound-account-read.test.ts create mode 100644 src/routing/peer-kind-match.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7f26e341a..a0c317f0edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Changes + +### Fixes + +- Agents/channels: route cross-agent subagent spawns through the target agent's bound channel account while preserving peer and workspace/role-scoped bindings, so child sessions no longer inherit the caller's account in shared rooms, workspaces, or multi-account setups. (#67508) Thanks @lukeboyett and @gumadeiras. + ## 2026.4.18 ### Changes diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 2843a795018..e1923cae0d2 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -464,6 +464,7 @@ async function dispatchDiscordComponentEvent(params: { SenderTag: senderTag, GroupSubject: groupSubject, GroupChannel: groupChannel, + MemberRoleIds: interactionCtx.memberRoleIds, GroupSystemPrompt: interactionCtx.isDirectMessage ? undefined : groupSystemPrompt, GroupSpace: guildInfo?.id ?? guildInfo?.slug ?? interactionCtx.rawGuildId ?? undefined, OwnerAllowFrom: ownerAllowFrom, diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index d2d0d2f6307..8431d1ecac1 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -1083,6 +1083,7 @@ export async function preflightDiscordMessage( messageChannelId, author, sender, + memberRoleIds, channelInfo, channelName, isGuildMessage, diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index 575d8ee165b..10550bbb079 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -44,6 +44,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields messageChannelId: string; author: User; sender: DiscordSenderIdentity; + memberRoleIds: string[]; channelInfo: DiscordChannelInfo | null; channelName?: string; diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index e3c1c28b0c9..8960aedd2aa 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -144,6 +144,7 @@ export async function processDiscordMessage( displayChannelSlug, guildInfo, guildSlug, + memberRoleIds, channelConfig, baseSessionKey, boundSessionKey, @@ -481,6 +482,7 @@ export async function processDiscordMessage( SenderTag: senderTag, GroupSubject: groupSubject, GroupChannel: groupChannel, + MemberRoleIds: memberRoleIds, UntrustedContext: untrustedContext, GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined, GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, diff --git a/extensions/discord/src/monitor/native-command-context.test.ts b/extensions/discord/src/monitor/native-command-context.test.ts index c17dbb1c879..ed587a2cc37 100644 --- a/extensions/discord/src/monitor/native-command-context.test.ts +++ b/extensions/discord/src/monitor/native-command-context.test.ts @@ -50,6 +50,7 @@ describe("buildDiscordNativeCommandContext", () => { interactionId: "interaction-1", channelId: "chan-1", threadParentId: "parent-1", + memberRoleIds: ["admin"], guildName: "Ops", channelTopic: "Production alerts only", channelConfig: { @@ -82,6 +83,8 @@ describe("buildDiscordNativeCommandContext", () => { expect(ctx.ChatType).toBe("channel"); expect(ctx.ConversationLabel).toBe("chan-1"); expect(ctx.GroupSubject).toBe("Ops"); + expect(ctx.GroupSpace).toBe("guild-1"); + expect(ctx.MemberRoleIds).toEqual(["admin"]); expect(ctx.GroupSystemPrompt).toBe("Use the runbook."); expect(ctx.OwnerAllowFrom).toEqual(["user-1"]); expect(ctx.MessageThreadId).toBe("chan-1"); diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index 81083ee6152..80cd91b93ee 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -13,6 +13,8 @@ export type BuildDiscordNativeCommandContextParams = { interactionId: string; channelId: string; threadParentId?: string; + memberRoleIds?: string[]; + guildId?: string; guildName?: string; channelTopic?: string; channelConfig?: DiscordChannelConfigResolved | null; @@ -67,6 +69,10 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma ChatType: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel", ConversationLabel: conversationLabel, GroupSubject: params.isGuild ? params.guildName : undefined, + GroupSpace: params.isGuild + ? (params.guildInfo?.id ?? params.guildInfo?.slug ?? params.guildId) + : undefined, + MemberRoleIds: params.memberRoleIds, GroupSystemPrompt: groupSystemPrompt, UntrustedContext: untrustedContext, OwnerAllowFrom: ownerAllowFrom, diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 69ed16921f7..c9bc0c19ade 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -1183,6 +1183,8 @@ async function dispatchDiscordCommandInteraction(params: { interactionId, channelId, threadParentId, + memberRoleIds, + guildId: interaction.guild?.id, guildName: interaction.guild?.name, channelTopic: channel && "topic" in channel ? (channel.topic ?? undefined) : undefined, channelConfig, diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index e54cb355468..fdc8fb0e9d3 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -651,6 +651,7 @@ describe("msteams monitor handler authz", () => { expect(dispatched?.ctxPayload).toMatchObject({ BodyForAgent: "[Thread history]\nAlice: Allowed context\n[/Thread history]\n\nCurrent message", + GroupSpace: "team123", }); expect( String((dispatched?.ctxPayload as { BodyForAgent?: string }).BodyForAgent), diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index b50ad9ae076..e304059314e 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -773,6 +773,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group", ConversationLabel: envelopeFrom, GroupSubject: !isDirectMessage ? conversationType : undefined, + GroupSpace: teamId, SenderName: senderName, SenderId: senderId, Provider: "msteams" as const, diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index e03a92fba2f..56f60f38060 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -196,6 +196,7 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared).toBeTruthy(); expectInboundContextContract(prepared!.ctxPayload as any); + expect(prepared!.ctxPayload.GroupSpace).toBe("T1"); }); it("does not enable Slack status reactions when the message timestamp is missing", async () => { diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 43dfaf44464..d753dd2dde8 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -736,6 +736,7 @@ export async function prepareSlackMessage(params: { ChatType: isDirectMessage ? "direct" : "channel", ConversationLabel: envelopeFrom, GroupSubject: isRoomish ? roomLabel : undefined, + GroupSpace: ctx.teamId || undefined, GroupSystemPrompt: groupSystemPrompt, UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined, SenderName: senderName, diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index ce70282f791..b4f8eb77ee0 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -983,9 +983,10 @@ describe("slack slash command session metadata", () => { expect(recordSessionMetaFromInboundMock).toHaveBeenCalledTimes(1); const call = recordSessionMetaFromInboundMock.mock.calls[0]?.[0] as { sessionKey?: string; - ctx?: { OriginatingChannel?: string }; + ctx?: { GroupSpace?: string; OriginatingChannel?: string }; }; expect(call.ctx?.OriginatingChannel).toBe("slack"); + expect(call.ctx?.GroupSpace).toBe("T1"); expect(call.sessionKey).toBeDefined(); }); diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index b6f5e2966b6..e29689f6e8b 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -577,6 +577,7 @@ export async function registerSlackMonitorSlashCommands(params: { : `slack:group:${command.channel_id}`, }) ?? (isDirectMessage ? senderName : roomLabel), GroupSubject: isRoomish ? roomLabel : undefined, + GroupSpace: ctx.teamId || undefined, GroupSystemPrompt: groupSystemPrompt, UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined, SenderName: senderName, diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 9e090397170..6bdb7c00ee9 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -1006,6 +1006,116 @@ describe("spawnAcpDirect", () => { expect(findAgentGatewayCall()?.params?.accountId).toBe("work"); }); + it("uses the target agent's bound account for cross-agent ACP thread spawns", async () => { + const boundRoom = "!room:example.org"; + replaceSpawnConfig({ + ...hoisted.state.cfg, + acp: { + ...hoisted.state.cfg.acp, + allowedAgents: ["codex", "bot-alpha"], + }, + channels: { + ...hoisted.state.cfg.channels, + matrix: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + accounts: { + "bot-alpha": { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + }, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { + kind: "channel", + id: boundRoom, + }, + accountId: "bot-alpha", + }, + }, + ], + }); + registerSessionBindingAdapter({ + channel: "matrix", + accountId: "bot-alpha", + capabilities: createSessionBindingCapabilities(), + bind: async (input) => await hoisted.sessionBindingBindMock(input), + listBySession: (targetSessionKey) => + hoisted.sessionBindingListBySessionMock(targetSessionKey), + resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), + unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), + }); + hoisted.sessionBindingBindMock.mockImplementationOnce( + async (input: { + targetSessionKey: string; + conversation: { + accountId: string; + conversationId: string; + parentConversationId?: string; + }; + metadata?: Record; + }) => + createSessionBinding({ + targetSessionKey: input.targetSessionKey, + conversation: { + channel: "matrix", + accountId: input.conversation.accountId, + conversationId: input.conversation.conversationId, + parentConversationId: input.conversation.parentConversationId, + }, + metadata: { + boundBy: + typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", + agentId: "bot-alpha", + }, + }), + ); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "bot-alpha", + mode: "session", + thread: true, + }, + { + agentSessionKey: "agent:main:matrix:room:requester", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: `room:${boundRoom}`, + }, + ); + + expect(result.status).toBe("accepted"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "child", + conversation: expect.objectContaining({ + channel: "matrix", + accountId: "bot-alpha", + conversationId: boundRoom, + }), + }), + ); + expect(findAgentGatewayCall()?.params).toMatchObject({ + deliver: true, + channel: "matrix", + accountId: "bot-alpha", + to: `room:${boundRoom}`, + }); + }); + it.each([ { name: "canonical line target", diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 4368139d0c1..536e6109eb9 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -73,6 +73,7 @@ import { } from "./acp-spawn-parent-stream.js"; import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; +import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js"; import { resolveSpawnedWorkspaceInheritance } from "./spawned-context.js"; import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; @@ -105,6 +106,10 @@ export type SpawnAcpContext = { agentThreadId?: string | number; /** Group chat ID for channels that distinguish group vs. topic (e.g. Telegram). */ agentGroupId?: string; + /** Group space label (guild/team id) from the originating channel context. */ + agentGroupSpace?: string | null; + /** Trusted provider role ids for the requester in this group turn. */ + agentMemberRoleIds?: string[]; sandboxed?: boolean; }; @@ -716,6 +721,7 @@ function prepareAcpThreadBinding(params: { function resolveAcpSpawnRequesterState(params: { cfg: OpenClawConfig; parentSessionKey?: string; + targetAgentId: string; ctx: SpawnAcpContext; }): AcpSpawnRequesterState { const bindingService = getSessionBindingService(); @@ -751,11 +757,16 @@ function resolveAcpSpawnRequesterState(params: { requesterAgentId, }) : false, - origin: normalizeDeliveryContext({ - channel: params.ctx.agentChannel, - accountId: params.ctx.agentAccountId, - to: params.ctx.agentTo, - threadId: params.ctx.agentThreadId, + origin: resolveRequesterOriginForChild({ + cfg: params.cfg, + targetAgentId: params.targetAgentId, + requesterAgentId: normalizeAgentId(requesterAgentId), + requesterChannel: params.ctx.agentChannel, + requesterAccountId: params.ctx.agentAccountId, + requesterTo: params.ctx.agentTo, + requesterThreadId: params.ctx.agentThreadId, + requesterGroupSpace: params.ctx.agentGroupSpace, + requesterMemberRoleIds: params.ctx.agentMemberRoleIds, }), }; } @@ -1040,18 +1051,6 @@ export async function spawnAcpDirect( }); } - const requesterState = resolveAcpSpawnRequesterState({ - cfg, - parentSessionKey, - ctx, - }); - const { effectiveStreamToParent } = resolveAcpSpawnStreamPlan({ - spawnMode, - requestThreadBinding, - streamToParentRequested, - requester: requesterState, - }); - const targetAgentResult = resolveTargetAcpAgentId({ requestedAgentId: params.agentId, cfg, @@ -1072,6 +1071,18 @@ export async function spawnAcpDirect( error: agentPolicyError.message, }); } + const requesterState = resolveAcpSpawnRequesterState({ + cfg, + parentSessionKey, + targetAgentId, + ctx, + }); + const { effectiveStreamToParent } = resolveAcpSpawnStreamPlan({ + spawnMode, + requestThreadBinding, + streamToParentRequested, + requester: requesterState, + }); const sessionKey = `agent:${targetAgentId}:acp:${crypto.randomUUID()}`; const runtimeMode = resolveAcpSessionMode(spawnMode); @@ -1099,10 +1110,10 @@ export async function spawnAcpDirect( if (requestThreadBinding) { const prepared = prepareAcpThreadBinding({ cfg, - channel: ctx.agentChannel, - accountId: ctx.agentAccountId, - to: ctx.agentTo, - threadId: ctx.agentThreadId, + channel: requesterState.origin?.channel, + accountId: requesterState.origin?.accountId, + to: requesterState.origin?.to, + threadId: requesterState.origin?.threadId, groupId: ctx.agentGroupId, }); if (!prepared.ok) { 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 b28ccadac31..ea4aba0c4b8 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -1,4 +1,5 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentRouteBinding } from "../config/types.agents.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import "./test-helpers/fast-core-tools.js"; import { @@ -97,6 +98,36 @@ async function executeSpawnAndExpectAccepted(params: { return result; } +async function executeBoundAccountSpawn(params: { + bindings: AgentRouteBinding[]; + context: Parameters[0]; + callId: string; + agentId?: string; +}): Promise { + let spawnAccountId: string | undefined; + setSessionsSpawnConfigOverride({ + session: { mainKey: "main", scope: "per-sender" }, + messages: { queue: { debounceMs: 0 } }, + agents: { defaults: { subagents: { allowAgents: ["bot-alpha"] } } }, + bindings: params.bindings, + }); + setupSessionsSpawnGatewayMock({ + onAgentSubagentSpawn: (hookParams) => { + const rec = hookParams as { accountId?: string } | undefined; + spawnAccountId = rec?.accountId; + }, + }); + + const tool = await getSessionsSpawnTool(params.context); + const result = await tool.execute(params.callId, { + task: "do thing", + ...(params.agentId ? { agentId: params.agentId } : {}), + cleanup: "keep", + }); + expect(result.details).toMatchObject({ status: "accepted", runId: expect.any(String) }); + return spawnAccountId; +} + async function emitLifecycleEndAndFlush(params: { runId: string; startedAt: number; @@ -399,6 +430,368 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { expect(getLatestSubagentRunByChildSessionKey(childSessionKey)?.outcome?.status).toBe("timeout"); }); + it("sessions_spawn uses the target agent's bound account for a Matrix room-bound route", async () => { + const boundRoom = "!exampleRoomId:example.org"; + expect( + await executeBoundAccountSpawn({ + callId: "call-bound-account", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: boundRoom, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { + kind: "channel", + id: boundRoom, + }, + accountId: "bot-alpha", + }, + }, + ], + }), + ).toBe("bot-alpha"); + }); + + it("sessions_spawn prefers peer-specific binding over channel-only binding", async () => { + const targetRoom = "!roomA:example.org"; + expect( + await executeBoundAccountSpawn({ + callId: "call-peer-specific", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: targetRoom, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: targetRoom }, + accountId: "bot-alpha-room-a", + }, + }, + ], + }), + ).toBe("bot-alpha-room-a"); + }); + + it("sessions_spawn falls back to channel-only binding when peer does not match", async () => { + const otherRoom = "!roomB:example.org"; + expect( + await executeBoundAccountSpawn({ + callId: "call-fallback", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: otherRoom, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "!roomA:example.org" }, + accountId: "bot-alpha-room-a", + }, + }, + ], + }), + ).toBe("bot-alpha-default"); + }); + + it("sessions_spawn treats a wildcard peer binding as match-any and beats channel-only", async () => { + const callerRoom = "!anyRoom:example.org"; + expect( + await executeBoundAccountSpawn({ + callId: "call-wildcard-peer", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: callerRoom, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-wildcard", + }, + }, + ], + }), + ).toBe("bot-alpha-wildcard"); + }); + + it("sessions_spawn prefers exact peer binding over wildcard peer binding", async () => { + const exactRoom = "!roomA:example.org"; + expect( + await executeBoundAccountSpawn({ + callId: "call-exact-over-wildcard", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: exactRoom, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-wildcard", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: exactRoom }, + accountId: "bot-alpha-room-a", + }, + }, + ], + }), + ).toBe("bot-alpha-room-a"); + }); + + it("sessions_spawn uses requester roles for role-scoped target-agent accounts", async () => { + expect( + await executeBoundAccountSpawn({ + callId: "call-role-scoped-account", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "discord", + agentAccountId: "bot-beta", + agentTo: "channel:ops", + agentGroupSpace: "guild-current", + agentMemberRoleIds: ["admin"], + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { channel: "discord", accountId: "bot-alpha-default" }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-current", + roles: ["admin"], + peer: { kind: "channel", id: "channel:ops" }, + accountId: "bot-alpha-admin", + }, + }, + ], + }), + ).toBe("bot-alpha-admin"); + }); + + it("sessions_spawn strips channel-side prefixes from agentTo before bound-account lookup", async () => { + const rawRoomId = "!exampleRoomId:example.org"; + // 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. + expect( + await executeBoundAccountSpawn({ + callId: "call-prefixed-to", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: `room:${rawRoomId}`, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: rawRoomId }, + accountId: "bot-alpha", + }, + }, + ], + }), + ).toBe("bot-alpha"); + }); + + it("sessions_spawn peels channel prefix then kind prefix for :: targets", async () => { + const rawGroupId = "U123example"; + // LINE emits its originatingTo as `line:group:`. Without peeling the + // channel prefix first and looping, a naive strip would leave `group:` + // (or `line:`) and the exact peer-id binding would not match. + expect( + await executeBoundAccountSpawn({ + callId: "call-line-nested-prefix", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "line", + agentAccountId: "bot-beta", + agentTo: `line:group:${rawGroupId}`, + }, + bindings: [ + // Wildcard peer binding with a conflicting kind (direct) must be + // skipped because the inferred kind is `group`. + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "line", + peer: { kind: "direct", id: "*" }, + accountId: "bot-alpha-line-dm", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "line", + peer: { kind: "group", id: rawGroupId }, + accountId: "bot-alpha-line", + }, + }, + ], + }), + ).toBe("bot-alpha-line"); + }); + + it("sessions_spawn classifies Matrix room:@user targets as direct, not channel", async () => { + const rawUserId = "@other-user:example.org"; + // Matrix thread delivery encodes per-user DM targets as `room:@user:server`. + // The `room:` prefix must not override the embedded `@` direct-peer marker. + expect( + await executeBoundAccountSpawn({ + callId: "call-room-at-user", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: `room:${rawUserId}`, + }, + bindings: [ + // A conflicting channel-kinded binding on the same peer id must not + // match because the embedded `@` marker identifies a direct peer. + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: rawUserId }, + accountId: "bot-alpha-wrong-kind", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "direct", id: rawUserId }, + accountId: "bot-alpha-dm", + }, + }, + ], + }), + ).toBe("bot-alpha-dm"); + }); + + it("sessions_spawn strips only the Teams conversation: wrapper", async () => { + const rawConversationId = "a:1:example-conversation@thread.v2"; + // Teams inbound context sets OriginatingTo to `conversation:`. The + // Teams id itself may start with another token-colon segment, so extraction + // must stop after the known wrapper instead of peeling arbitrary prefixes. + expect( + await executeBoundAccountSpawn({ + callId: "call-teams-conversation", + agentId: "bot-alpha", + context: { + agentSessionKey: "main", + agentChannel: "msteams", + agentAccountId: "bot-beta", + agentTo: `conversation:${rawConversationId}`, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "msteams", + peer: { kind: "channel", id: rawConversationId }, + accountId: "bot-alpha-teams", + }, + }, + ], + }), + ).toBe("bot-alpha-teams"); + }); + + it("sessions_spawn preserves the caller's account for same-agent subagent spawns", async () => { + const room = "!someRoom:example.org"; + // Spawn a child of the same agent (no explicit agentId), so the caller's + // active account must win over any configured binding for that same agent. + expect( + await executeBoundAccountSpawn({ + callId: "call-same-agent", + context: { + agentSessionKey: "agent:bot-alpha:session:main", + agentChannel: "matrix", + agentAccountId: "bot-alpha-adhoc", + agentTo: room, + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + ], + }), + ).toBe("bot-alpha-adhoc"); + }); + it("sessions_spawn announces with requester accountId", async () => { const ctx = setupSessionsSpawnGatewayMock({}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index cd547a19d8b..e511f5404fb 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -288,6 +288,7 @@ export function createOpenClawTools( agentGroupId: options?.agentGroupId, agentGroupChannel: options?.agentGroupChannel, agentGroupSpace: options?.agentGroupSpace, + agentMemberRoleIds: options?.agentMemberRoleIds, sandboxed: options?.sandboxed, requesterAgentIdOverride: options?.requesterAgentIdOverride, workspaceDir: spawnWorkspaceDir, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index d06932eac28..24e05cc1fd2 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -687,6 +687,7 @@ export async function runEmbeddedPiAgent( groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, + memberRoleIds: params.memberRoleIds, spawnedBy: params.spawnedBy, isCanonicalWorkspace, senderId: params.senderId, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index efb00becb53..ea465367a33 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -487,6 +487,7 @@ export async function runEmbeddedAttempt( groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, + memberRoleIds: params.memberRoleIds, spawnedBy: params.spawnedBy, senderId: params.senderId, senderName: params.senderName, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 5de331c48c6..997f23af153 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -40,6 +40,8 @@ export type RunEmbeddedPiAgentParams = { groupChannel?: string | null; /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ groupSpace?: string | null; + /** Trusted provider role ids for the requester in this group turn. */ + memberRoleIds?: string[]; /** Parent session key for subagent policy inheritance. */ spawnedBy?: string | null; /** Whether workspaceDir points at the canonical agent workspace for bootstrap purposes. */ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index e10849cdda2..ae74cc50e37 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -310,6 +310,8 @@ export function createOpenClawCodingTools(options?: { groupChannel?: string | null; /** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */ groupSpace?: string | null; + /** Trusted provider role ids for the requester in this group turn. */ + memberRoleIds?: string[]; /** Parent session key for subagent group policy inheritance. */ spawnedBy?: string | null; senderId?: string | null; @@ -570,6 +572,7 @@ export function createOpenClawCodingTools(options?: { agentGroupId: options?.groupId ?? null, agentGroupChannel: options?.groupChannel ?? null, agentGroupSpace: options?.groupSpace ?? null, + agentMemberRoleIds: options?.memberRoleIds, agentDir: options?.agentDir, sandboxRoot, sandboxContainerWorkdir: sandbox?.containerWorkdir, diff --git a/src/agents/spawn-requester-origin.test.ts b/src/agents/spawn-requester-origin.test.ts new file mode 100644 index 00000000000..4811ec01178 --- /dev/null +++ b/src/agents/spawn-requester-origin.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js"; + +describe("resolveRequesterOriginForChild", () => { + it.each([ + ["channel:conversation-a", "channel:conversation-a", "channel"], + ["dm:conversation-a", "dm:conversation-a", "direct"], + ["thread:conversation-a/thread-a", "thread:conversation-a/thread-a", "channel"], + ] as const)( + "keeps canonical prefixed peer id %s eligible for exact binding lookup", + (to, peerId, peerKind) => { + const cfg = { + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "qa-channel", + peer: { + kind: peerKind, + id: peerId, + }, + accountId: "bot-alpha-qa", + }, + }, + ], + } as OpenClawConfig; + + expect( + resolveRequesterOriginForChild({ + cfg, + targetAgentId: "bot-alpha", + requesterAgentId: "main", + requesterChannel: "qa-channel", + requesterAccountId: "bot-beta", + requesterTo: to, + }), + ).toMatchObject({ + channel: "qa-channel", + accountId: "bot-alpha-qa", + to, + }); + }, + ); + + it("preserves canonical peer ids that start with token-colon after a known wrapper", () => { + const to = "conversation:a:1:team-thread"; + const cfg = { + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "msteams", + peer: { + kind: "channel", + id: "a:1:team-thread", + }, + accountId: "bot-alpha-teams", + }, + }, + ], + } as OpenClawConfig; + + expect( + resolveRequesterOriginForChild({ + cfg, + targetAgentId: "bot-alpha", + requesterAgentId: "main", + requesterChannel: "msteams", + requesterAccountId: "bot-beta", + requesterTo: to, + }), + ).toMatchObject({ + channel: "msteams", + accountId: "bot-alpha-teams", + to, + }); + }); + + it("keeps plugin-inferred channel kind for ids that start with direct marker characters", () => { + const to = "channel:@ops"; + const cfg = { + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "qa-channel", + peer: { + kind: "channel", + id: to, + }, + accountId: "bot-alpha-qa", + }, + }, + ], + } as OpenClawConfig; + + expect( + resolveRequesterOriginForChild({ + cfg, + targetAgentId: "bot-alpha", + requesterAgentId: "main", + requesterChannel: "qa-channel", + requesterAccountId: "bot-beta", + requesterTo: to, + }), + ).toMatchObject({ + channel: "qa-channel", + accountId: "bot-alpha-qa", + to, + }); + }); + + it("uses requester group space before selecting a scoped target-agent account", () => { + const to = "channel:ops"; + const cfg = { + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-other", + peer: { + kind: "channel", + id: to, + }, + accountId: "bot-alpha-other-guild", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-current", + peer: { + kind: "channel", + id: to, + }, + accountId: "bot-alpha-current-guild", + }, + }, + ], + } as OpenClawConfig; + + expect( + resolveRequesterOriginForChild({ + cfg, + targetAgentId: "bot-alpha", + requesterAgentId: "main", + requesterChannel: "discord", + requesterAccountId: "main-current-guild", + requesterTo: to, + requesterGroupSpace: "guild-current", + }), + ).toMatchObject({ + channel: "discord", + accountId: "bot-alpha-current-guild", + to, + }); + }); + + it("still peels channel id plus kind wrappers before peer lookup", () => { + const to = "line:group:U123example"; + const cfg = { + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "line", + peer: { + kind: "group", + id: "U123example", + }, + accountId: "bot-alpha-line", + }, + }, + ], + } as OpenClawConfig; + + expect( + resolveRequesterOriginForChild({ + cfg, + targetAgentId: "bot-alpha", + requesterAgentId: "main", + requesterChannel: "line", + requesterAccountId: "bot-beta", + requesterTo: to, + }), + ).toMatchObject({ + channel: "line", + accountId: "bot-alpha-line", + to, + }); + }); +}); diff --git a/src/agents/spawn-requester-origin.ts b/src/agents/spawn-requester-origin.ts new file mode 100644 index 00000000000..83340586e52 --- /dev/null +++ b/src/agents/spawn-requester-origin.ts @@ -0,0 +1,127 @@ +import type { ChatType } from "../channels/chat-type.js"; +import { getChannelPlugin } from "../channels/plugins/registry.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveFirstBoundAccountId } from "../routing/bound-account-read.js"; +import { normalizeDeliveryContext } from "../utils/delivery-context.js"; + +// Delivery targets often carry a transport wrapper (e.g. Matrix `room:` or +// LINE `line:group:`), while route bindings commonly store raw peer ids on +// `match.peer.id`. Peel wrappers for those lookups, and separately pass the +// original target as an exact-match alias for channels whose canonical peer ids +// intentionally include prefixes such as `channel:` or `thread:`. +const KIND_PREFIX_TO_CHAT_TYPE: Readonly> = { + "room:": "channel", + "channel:": "channel", + "conversation:": "channel", + "chat:": "channel", + "thread:": "channel", + "topic:": "channel", + "group:": "group", + "team:": "group", + "user:": "direct", + "dm:": "direct", + "pm:": "direct", +}; + +// Matches one leading `:` wrapper at a time. +const GENERIC_PREFIX_PATTERN = /^[a-z][a-z0-9_-]*:/i; + +function getKindForRequesterPrefix(prefix: string): ChatType | undefined { + return Object.hasOwn(KIND_PREFIX_TO_CHAT_TYPE, prefix) + ? KIND_PREFIX_TO_CHAT_TYPE[prefix] + : undefined; +} + +function normalizeChannelPrefix(channelId: string | undefined): string | undefined { + const normalized = channelId?.trim().toLowerCase(); + return normalized ? `${normalized}:` : undefined; +} + +function shouldPeelRequesterPrefix(prefix: string, channelPrefix: string | undefined): boolean { + return Boolean(getKindForRequesterPrefix(prefix) || prefix === channelPrefix); +} + +export function extractRequesterPeer( + channelId: string | undefined, + requesterTo: string | undefined, +): { peerId?: string; peerKind?: ChatType } { + if (!requesterTo) { + return {}; + } + const raw = requesterTo.trim(); + if (!raw) { + return {}; + } + const pluginInferredKind = channelId + ? (getChannelPlugin(channelId)?.messaging?.inferTargetChatType?.({ to: raw }) ?? undefined) + : undefined; + const channelPrefix = normalizeChannelPrefix(channelId); + let inferredKind: ChatType | undefined = pluginInferredKind; + let value = raw; + while (true) { + const match = GENERIC_PREFIX_PATTERN.exec(value); + if (!match) { + break; + } + const prefix = match[0].toLowerCase(); + if (!shouldPeelRequesterPrefix(prefix, channelPrefix)) { + break; + } + const kindFromPrefix = getKindForRequesterPrefix(prefix); + if (kindFromPrefix) { + inferredKind ??= kindFromPrefix; + } + value = value.slice(prefix.length).trim(); + } + if (value && !pluginInferredKind) { + // Id-embedded kind markers (Matrix `!`/`@`, IRC `#`) are a fallback when + // the channel plugin cannot identify its own target grammar. + if (value.startsWith("@")) { + inferredKind = "direct"; + } else if (value.startsWith("!") || value.startsWith("#")) { + inferredKind = "channel"; + } + } + return { peerId: value || undefined, peerKind: inferredKind }; +} + +export function resolveRequesterOriginForChild(params: { + cfg: OpenClawConfig; + targetAgentId: string; + requesterAgentId: string; + requesterChannel?: string; + requesterAccountId?: string; + requesterTo?: string; + requesterThreadId?: string | number; + requesterGroupSpace?: string | null; + requesterMemberRoleIds?: string[]; +}) { + const { peerId: normalizedPeerId, peerKind: inferredPeerKind } = extractRequesterPeer( + params.requesterChannel, + params.requesterTo, + ); + const rawPeerIdAlias = params.requesterTo?.trim(); + // Same-agent spawns must keep the caller's active inbound account, not + // re-resolve via bindings that may select a different account for the same + // agent/channel. + const boundAccountId = + params.requesterChannel && params.targetAgentId !== params.requesterAgentId + ? resolveFirstBoundAccountId({ + cfg: params.cfg, + channelId: params.requesterChannel, + agentId: params.targetAgentId, + peerId: normalizedPeerId, + exactPeerIdAliases: + rawPeerIdAlias && rawPeerIdAlias !== normalizedPeerId ? [rawPeerIdAlias] : undefined, + peerKind: inferredPeerKind, + groupSpace: params.requesterGroupSpace, + memberRoleIds: params.requesterMemberRoleIds, + }) + : undefined; + return normalizeDeliveryContext({ + channel: params.requesterChannel, + accountId: boundAccountId ?? params.requesterAccountId, + to: params.requesterTo, + threadId: params.requesterThreadId, + }); +} diff --git a/src/agents/spawned-context.ts b/src/agents/spawned-context.ts index 7d40382a0d9..2add9abefd3 100644 --- a/src/agents/spawned-context.ts +++ b/src/agents/spawned-context.ts @@ -15,6 +15,7 @@ export type SpawnedToolContext = { agentGroupId?: string | null; agentGroupChannel?: string | null; agentGroupSpace?: string | null; + agentMemberRoleIds?: string[]; workspaceDir?: string; }; diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 7b960131849..d64ca11b7c5 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -189,16 +189,13 @@ describe("spawnSubagentDirect seam flow", () => { ); expect(result.status).toBe("accepted"); - expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith( - expect.objectContaining({ - requesterOrigin: expect.objectContaining({ - channel: "discord", - accountId: "acct-1", - to: "user-1", - threadId: undefined, - }), - }), - ); + const registerInput = hoisted.registerSubagentRunMock.mock.calls[0]?.[0]; + expect(registerInput?.requesterOrigin).toMatchObject({ + channel: "discord", + accountId: "acct-1", + to: "user-1", + }); + expect(registerInput?.requesterOrigin).not.toHaveProperty("threadId"); }); it("pins admin-only methods to operator.admin and preserves least-privilege for others (#59428)", async () => { diff --git a/src/agents/subagent-spawn.thread-binding.test.ts b/src/agents/subagent-spawn.thread-binding.test.ts index 21c07c7db31..f03274e78f2 100644 --- a/src/agents/subagent-spawn.thread-binding.test.ts +++ b/src/agents/subagent-spawn.thread-binding.test.ts @@ -31,6 +31,117 @@ describe("spawnSubagentDirect thread binding delivery", () => { installSessionStoreCaptureMock(hoisted.updateSessionStoreMock); }); + it("passes the target agent's bound account to thread binding hooks", async () => { + const boundRoom = "!room:example.org"; + let hookRequester: + | { channel?: string; accountId?: string; to?: string; threadId?: string | number } + | undefined; + hoisted.hookRunner.hasHooks.mockImplementation( + (hookName?: string) => hookName === "subagent_spawning", + ); + hoisted.hookRunner.runSubagentSpawning.mockImplementation(async (event: unknown) => { + hookRequester = ( + event as { + requester?: { + channel?: string; + accountId?: string; + to?: string; + threadId?: string | number; + }; + } + ).requester; + return { + status: "ok", + threadBindingReady: true, + deliveryOrigin: { + channel: "matrix", + to: `room:${boundRoom}`, + threadId: "$thread-root", + }, + }; + }); + const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({ + callGatewayMock: hoisted.callGatewayMock, + loadConfig: () => + createSubagentSpawnTestConfig(os.tmpdir(), { + agents: { + defaults: { + workspace: os.tmpdir(), + subagents: { + allowAgents: ["bot-alpha"], + }, + }, + list: [ + { id: "main", workspace: "/tmp/workspace-main" }, + { id: "bot-alpha", workspace: "/tmp/workspace-bot-alpha" }, + ], + }, + bindings: [ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { + kind: "channel", + id: boundRoom, + }, + accountId: "bot-alpha", + }, + }, + ], + }), + updateSessionStoreMock: hoisted.updateSessionStoreMock, + registerSubagentRunMock: hoisted.registerSubagentRunMock, + emitSessionLifecycleEventMock: hoisted.emitSessionLifecycleEventMock, + hookRunner: hoisted.hookRunner, + resolveSubagentSpawnModelSelection: () => "openai-codex/gpt-5.4", + resolveSandboxRuntimeStatus: () => ({ sandboxed: false }), + }); + + const result = await spawnSubagentDirect( + { + task: "reply with a marker", + agentId: "bot-alpha", + thread: true, + mode: "session", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "matrix", + agentAccountId: "bot-beta", + agentTo: `room:${boundRoom}`, + }, + ); + + expect(result.status).toBe("accepted"); + expect(hookRequester).toMatchObject({ + channel: "matrix", + accountId: "bot-alpha", + to: `room:${boundRoom}`, + }); + const agentCall = hoisted.callGatewayMock.mock.calls.find( + ([call]) => (call as { method?: string }).method === "agent", + )?.[0] as { params?: Record } | undefined; + expect(agentCall?.params).toMatchObject({ + channel: "matrix", + accountId: "bot-alpha", + to: `room:${boundRoom}`, + threadId: "$thread-root", + deliver: true, + }); + expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith( + expect.objectContaining({ + requesterOrigin: { + channel: "matrix", + accountId: "bot-alpha", + to: `room:${boundRoom}`, + threadId: "$thread-root", + }, + }), + ); + }); + it("seeds a thread-bound child session from the binding created during spawn", async () => { hoisted.hookRunner.hasHooks.mockImplementation( (hookName?: string) => hookName === "subagent_spawning", diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 1c8b55bf673..f577f29c336 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -27,6 +27,7 @@ export { SUBAGENT_SPAWN_ACCEPTED_NOTE, SUBAGENT_SPAWN_SESSION_ACCEPTED_NOTE, } from "./subagent-spawn-accepted-note.js"; +import { resolveRequesterOriginForChild } from "./spawn-requester-origin.js"; import { resolveConfiguredSubagentRunTimeoutSeconds, resolveSubagentModelAndThinkingPlan, @@ -113,6 +114,7 @@ export type SpawnSubagentContext = { agentGroupId?: string | null; agentGroupChannel?: string | null; agentGroupSpace?: string | null; + agentMemberRoleIds?: string[]; requesterAgentIdOverride?: string; /** Explicit workspace directory for subagent to inherit (optional). */ workspaceDir?: string; @@ -396,13 +398,6 @@ export async function spawnSubagentDirect( ? params.cleanup : "keep"; const expectsCompletionMessage = params.expectsCompletionMessage !== false; - const requesterOrigin = normalizeDeliveryContext({ - channel: ctx.agentChannel, - accountId: ctx.agentAccountId, - to: ctx.agentTo, - threadId: ctx.agentThreadId, - }); - let childSessionOrigin = requesterOrigin; const hookRunner = subagentSpawnDeps.getGlobalHookRunner(); const cfg = loadSubagentConfig(); @@ -465,6 +460,18 @@ export async function spawnSubagentDirect( }; } const targetAgentId = requestedAgentId ? normalizeAgentId(requestedAgentId) : requesterAgentId; + const requesterOrigin = resolveRequesterOriginForChild({ + cfg, + targetAgentId, + requesterAgentId, + requesterChannel: ctx.agentChannel, + requesterAccountId: ctx.agentAccountId, + requesterTo: ctx.agentTo, + requesterThreadId: ctx.agentThreadId, + requesterGroupSpace: ctx.agentGroupSpace, + requesterMemberRoleIds: ctx.agentMemberRoleIds, + }); + let childSessionOrigin = requesterOrigin; if (targetAgentId !== requesterAgentId) { const allowAgents = resolveAgentConfig(cfg, requesterAgentId)?.subagents?.allowAgents ?? diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index f9b77692dc6..fa05597c721 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -243,6 +243,8 @@ export function createSessionsSpawnTool( agentTo: opts?.agentTo, agentThreadId: opts?.agentThreadId, agentGroupId: opts?.agentGroupId ?? undefined, + agentGroupSpace: opts?.agentGroupSpace, + agentMemberRoleIds: opts?.agentMemberRoleIds, sandboxed: opts?.sandboxed, }, ); @@ -337,6 +339,7 @@ export function createSessionsSpawnTool( agentGroupId: opts?.agentGroupId, agentGroupChannel: opts?.agentGroupChannel, agentGroupSpace: opts?.agentGroupSpace, + agentMemberRoleIds: opts?.agentMemberRoleIds, requesterAgentIdOverride: opts?.requesterAgentIdOverride, workspaceDir: opts?.workspaceDir, }, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 3838a27a551..92b76d711d9 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -158,6 +158,7 @@ describe("agent-runner-utils", () => { Provider: "OpenAI", To: "channel-1", SenderId: "sender-1", + MemberRoleIds: ["admin", " ", "operator"], }, hasRepliedRef: undefined, provider: "anthropic", @@ -173,6 +174,7 @@ describe("agent-runner-utils", () => { agentId: run.agentId, messageProvider: "openai", messageTo: "channel-1", + memberRoleIds: ["admin", "operator"], }); expect(resolved.senderContext).toEqual({ senderId: "sender-1", diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index fc13ac6db6a..94c03031247 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -238,6 +238,7 @@ export function buildEmbeddedContextFromTemplate(params: { to: params.sessionCtx.To, }), messageThreadId: params.sessionCtx.MessageThreadId ?? undefined, + memberRoleIds: normalizeMemberRoleIds(params.sessionCtx.MemberRoleIds), // Provider threading context for tool auto-injection ...buildThreadingToolContext({ sessionCtx: params.sessionCtx, @@ -247,6 +248,15 @@ export function buildEmbeddedContextFromTemplate(params: { }; } +function normalizeMemberRoleIds(value: TemplateContext["MemberRoleIds"]): string[] | undefined { + const roles = Array.isArray(value) + ? value + .map((roleId) => normalizeOptionalString(roleId)) + .filter((roleId): roleId is string => Boolean(roleId)) + : []; + return roles.length > 0 ? roles : undefined; +} + export function buildTemplateSenderContext(sessionCtx: TemplateContext) { return { senderId: normalizeOptionalString(sessionCtx.SenderId), diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 4ff36ff7387..d8fc62273d8 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -126,6 +126,8 @@ export type MsgContext = { /** Human label for channel-like group conversations (e.g. #general, #support). */ GroupChannel?: string; GroupSpace?: string; + /** Trusted provider role ids for the sender in this group turn. */ + MemberRoleIds?: string[]; GroupMembers?: string; GroupSystemPrompt?: string; /** Untrusted metadata that must not be treated as system instructions. */ diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index d60eadcc487..fff5b8d8abf 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -241,6 +241,50 @@ describe("resolveDeliveryTarget", () => { expect(result.accountId).toBe("account-b"); }); + it("preserves binding order when peerless delivery falls back to a bound accountId", async () => { + setMainSessionEntry(undefined); + const cfg = makeCfg({ + bindings: [ + { + agentId: AGENT_ID, + match: { + channel: "telegram", + peer: { kind: "channel", id: "123456" }, + accountId: "peer-first", + }, + }, + { + agentId: AGENT_ID, + match: { channel: "telegram", accountId: "channel-second" }, + }, + ], + }); + + const result = await resolveForAgent({ cfg }); + + expect(result.accountId).toBe("peer-first"); + }); + + it("does not infer scoped bound accountId for peerless cron delivery", async () => { + setMainSessionEntry(undefined); + const cfg = makeCfg({ + bindings: [ + { + agentId: AGENT_ID, + match: { + channel: "telegram", + guildId: "guild-1", + accountId: "tenant-account", + }, + }, + ], + }); + + const result = await resolveForAgent({ cfg }); + + expect(result.accountId).toBeUndefined(); + }); + it("preserves session lastAccountId when present", async () => { setMainSessionEntry({ sessionId: "sess-1", diff --git a/src/routing/binding-scope.ts b/src/routing/binding-scope.ts new file mode 100644 index 00000000000..40e1daa14f0 --- /dev/null +++ b/src/routing/binding-scope.ts @@ -0,0 +1,78 @@ +export type RouteBindingScopeConstraint = { + guildId?: string | null; + teamId?: string | null; + roles?: string[] | null; +}; + +export type RouteBindingScope = { + guildId?: string | null; + teamId?: string | null; + groupSpace?: string | null; + memberRoleIds?: Iterable | null; +}; + +export function normalizeRouteBindingId(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "bigint") { + return String(value).trim(); + } + return ""; +} + +export function normalizeRouteBindingRoles(value: string[] | null | undefined): string[] | null { + return Array.isArray(value) && value.length > 0 ? value : null; +} + +function scopeIdMatches(params: { + constraint: string | null | undefined; + exact: string; + groupSpace: string; +}): boolean { + if (!params.constraint) { + return true; + } + return params.constraint === params.exact || params.constraint === params.groupSpace; +} + +function hasRoleLookup( + memberRoleIds: Iterable, +): memberRoleIds is Iterable & { has(roleId: string): boolean } { + return typeof (memberRoleIds as { has?: unknown }).has === "function"; +} + +function hasAnyRouteBindingRole( + roles: readonly string[], + memberRoleIds: Iterable | null | undefined, +): boolean { + if (!memberRoleIds) { + return false; + } + if (hasRoleLookup(memberRoleIds)) { + return roles.some((role) => memberRoleIds.has(role)); + } + const memberRoleIdSet = new Set(memberRoleIds); + return roles.some((role) => memberRoleIdSet.has(role)); +} + +export function routeBindingScopeMatches( + constraint: RouteBindingScopeConstraint, + scope: RouteBindingScope, +): boolean { + const guildId = normalizeRouteBindingId(scope.guildId); + const teamId = normalizeRouteBindingId(scope.teamId); + const groupSpace = normalizeRouteBindingId(scope.groupSpace); + if (!scopeIdMatches({ constraint: constraint.guildId, exact: guildId, groupSpace })) { + return false; + } + if (!scopeIdMatches({ constraint: constraint.teamId, exact: teamId, groupSpace })) { + return false; + } + + const roles = normalizeRouteBindingRoles(constraint.roles); + if (!roles) { + return true; + } + return hasAnyRouteBindingRole(roles, scope.memberRoleIds); +} diff --git a/src/routing/bound-account-read.test.ts b/src/routing/bound-account-read.test.ts new file mode 100644 index 00000000000..fdd32cb0787 --- /dev/null +++ b/src/routing/bound-account-read.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it } from "vitest"; +import type { AgentRouteBinding } from "../config/types.agents.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveFirstBoundAccountId } from "./bound-account-read.js"; + +function cfgWithBindings(bindings: AgentRouteBinding[]): OpenClawConfig { + return { bindings } as unknown as OpenClawConfig; +} + +describe("resolveFirstBoundAccountId", () => { + it("returns exact peer match when caller supplies a matching peerId", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "!roomA:example.org" }, + accountId: "bot-alpha-room-a", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!roomA:example.org", + }), + ).toBe("bot-alpha-room-a"); + }); + + it("prefers wildcard peer binding over channel-only when caller peerKind matches", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-wildcard", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!anyRoom:example.org", + peerKind: "channel", + }), + ).toBe("bot-alpha-wildcard"); + }); + + it("preserves first-match binding order for peerless callers", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-wildcard", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + }), + ).toBe("bot-alpha-wildcard"); + }); + + it("falls back to peer-specific binding for peerless callers when no channel-only or wildcard binding exists", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "!specificRoom:example.org" }, + accountId: "bot-alpha-specific", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + }), + ).toBe("bot-alpha-specific"); + }); + + it("skips non-matching peer-specific bindings when caller supplies a different peerId", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "!otherRoom:example.org" }, + accountId: "bot-alpha-other", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!differentRoom:example.org", + }), + ).toBeUndefined(); + }); + + it("returns undefined when the agent has no binding on the channel", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { channel: "whatsapp", accountId: "bot-alpha-whatsapp" }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + }), + ).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("treats group and channel peer kinds as equivalent (matches resolve-route semantics)", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "line", + peer: { kind: "group", id: "*" }, + accountId: "bot-alpha-group", + }, + }, + ]); + // Caller inferred as `channel` (e.g. Matrix room, Mattermost channel) + // should still match a `group` wildcard binding because group/channel are + // compatible kinds in the routing model. + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "line", + agentId: "bot-alpha", + peerId: "!roomA:example.org", + peerKind: "channel", + }), + ).toBe("bot-alpha-group"); + // And vice versa: `channel` binding matches a `group` caller. + const cfg2 = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "line", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-channel", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg: cfg2, + channelId: "line", + agentId: "bot-alpha", + peerId: "groupA", + peerKind: "group", + }), + ).toBe("bot-alpha-channel"); + }); + + it("accepts a wildcard peer binding as fallback for peerless callers", () => { + // Cron-style peerless caller: we have no peer context to verify kind + // safety against, so a wildcard binding is the only available answer and + // must not silently regress to undefined. + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-wildcard", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + }), + ).toBe("bot-alpha-wildcard"); + }); + + it("skips wildcard peer bindings when the caller's peerKind is unknown", () => { + 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", accountId: "bot-alpha-default" }, + }, + ]); + // Without a peerKind on the caller, we cannot verify kind compatibility + // for the wildcard binding — it must be skipped in favor of the channel-only + // fallback rather than risk routing to the wrong identity. + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!room:example.org", + }), + ).toBe("bot-alpha-default"); + }); + + it("matches exact peer id even when the caller's peerKind is unknown", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "!room:example.org" }, + accountId: "bot-alpha-room", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!room:example.org", + }), + ).toBe("bot-alpha-room"); + }); + + it("matches exact canonical peer aliases before falling back to wildcard bindings", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "qa-channel", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-wildcard", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "qa-channel", + peer: { kind: "channel", id: "channel:conversation-a" }, + accountId: "bot-alpha-conversation", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "qa-channel", + agentId: "bot-alpha", + peerId: "conversation-a", + exactPeerIdAliases: ["channel:conversation-a"], + peerKind: "channel", + }), + ).toBe("bot-alpha-conversation"); + }); + + 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"); + }); + + it("skips scoped bindings when the caller has no matching group space", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-other", + accountId: "bot-alpha-other-guild", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { channel: "discord", accountId: "bot-alpha-default" }, + }, + ]); + + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "discord", + agentId: "bot-alpha", + groupSpace: "guild-current", + }), + ).toBe("bot-alpha-default"); + }); + + it("matches scoped guild and team bindings against caller group space", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-current", + accountId: "bot-alpha-guild", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "slack", + teamId: "team-current", + accountId: "bot-alpha-team", + }, + }, + ]); + + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "discord", + agentId: "bot-alpha", + groupSpace: "guild-current", + }), + ).toBe("bot-alpha-guild"); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "slack", + agentId: "bot-alpha", + groupSpace: "team-current", + }), + ).toBe("bot-alpha-team"); + }); + + it("requires caller roles before selecting role-scoped bindings", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-current", + roles: ["admin"], + accountId: "bot-alpha-admin", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { channel: "discord", accountId: "bot-alpha-default" }, + }, + ]); + + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "discord", + agentId: "bot-alpha", + groupSpace: "guild-current", + memberRoleIds: ["member"], + }), + ).toBe("bot-alpha-default"); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "discord", + agentId: "bot-alpha", + groupSpace: "guild-current", + memberRoleIds: ["admin"], + }), + ).toBe("bot-alpha-admin"); + }); +}); diff --git a/src/routing/bound-account-read.ts b/src/routing/bound-account-read.ts index 93212b155e2..c98590d8de7 100644 --- a/src/routing/bound-account-read.ts +++ b/src/routing/bound-account-read.ts @@ -1,8 +1,15 @@ +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"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { + normalizeRouteBindingId, + normalizeRouteBindingRoles, + routeBindingScopeMatches, +} from "./binding-scope.js"; +import { peerKindMatches } from "./peer-kind-match.js"; import { normalizeAccountId, normalizeAgentId } from "./session-key.js"; function normalizeBindingChannelId(raw?: string | null): string | null { @@ -18,6 +25,11 @@ function resolveNormalizedBindingMatch(binding: AgentRouteBinding): { agentId: string; accountId: string; channelId: string; + peerId?: string; + peerKind?: ChatType; + guildId?: string | null; + teamId?: string | null; + roles?: string[] | null; } | null { if (!binding || typeof binding !== "object") { return null; @@ -34,32 +46,118 @@ function resolveNormalizedBindingMatch(binding: AgentRouteBinding): { if (!accountId || accountId === "*") { 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, + guildId: normalizeRouteBindingId(match.guildId) || null, + teamId: normalizeRouteBindingId(match.teamId) || null, + roles: normalizeRouteBindingRoles(match.roles), }; } +function buildExactPeerIdSet(params: { + peerId?: string; + exactPeerIdAliases?: string[]; +}): Set { + const exactPeerIds = new Set(); + const peerId = params.peerId?.trim(); + if (peerId) { + exactPeerIds.add(peerId); + } + for (const alias of params.exactPeerIdAliases ?? []) { + const trimmed = alias.trim(); + if (trimmed) { + exactPeerIds.add(trimmed); + } + } + return exactPeerIds; +} + export function resolveFirstBoundAccountId(params: { cfg: OpenClawConfig; channelId: string; agentId: string; + peerId?: string; + exactPeerIdAliases?: string[]; + peerKind?: ChatType; + groupSpace?: string | null; + memberRoleIds?: string[]; }): string | undefined { const normalizedChannel = normalizeBindingChannelId(params.channelId); if (!normalizedChannel) { return undefined; } const normalizedAgentId = normalizeAgentId(params.agentId); + const normalizedPeerId = params.peerId?.trim() || undefined; + const exactPeerIds = buildExactPeerIdSet({ + peerId: normalizedPeerId, + exactPeerIdAliases: params.exactPeerIdAliases, + }); + const hasPeerContext = exactPeerIds.size > 0; + const normalizedPeerKind = normalizeChatType(params.peerKind) ?? undefined; + let wildcardPeerMatch: string | undefined; + let channelOnlyFallback: string | undefined; for (const binding of listRouteBindings(params.cfg)) { const resolved = resolveNormalizedBindingMatch(binding); if ( - resolved && - resolved.channelId === normalizedChannel && - resolved.agentId === normalizedAgentId + !resolved || + resolved.channelId !== normalizedChannel || + resolved.agentId !== normalizedAgentId ) { + continue; + } + if ( + !routeBindingScopeMatches(resolved, { + groupSpace: params.groupSpace, + memberRoleIds: params.memberRoleIds, + }) + ) { + continue; + } + if (!hasPeerContext) { + // Cron and other peerless callers historically used the first matching + // agent/channel binding. Keep that fallback order unless the caller has + // enough peer context for the stricter exact/wildcard routing below. return resolved.accountId; } + if (resolved.peerId === "*") { + // Caller has a peer. Wildcard bindings are only safe when both sides + // declare a peer kind AND the kinds agree — a direct/* binding must + // never win for a channel caller (or vice versa), and we'd rather fall + // through to channel-only or the caller account than actively route to + // the wrong identity. + if ( + !resolved.peerKind || + !normalizedPeerKind || + !peerKindMatches(resolved.peerKind, normalizedPeerKind) + ) { + continue; + } + wildcardPeerMatch ??= resolved.accountId; + } else if (resolved.peerId) { + // Exact peer id match: peer ids are channel-unique so id alone is + // sufficient, but when both sides declare a kind they must still agree + // (avoids a direct-kind binding matching a channel caller that happens + // to share an id, which can occur on channels where ids are reused + // across kinds). + if ( + resolved.peerKind && + normalizedPeerKind && + !peerKindMatches(resolved.peerKind, normalizedPeerKind) + ) { + continue; + } + if (exactPeerIds.has(resolved.peerId)) { + return resolved.accountId; + } + } else { + channelOnlyFallback ??= resolved.accountId; + } } - return undefined; + return wildcardPeerMatch ?? channelOnlyFallback; } diff --git a/src/routing/peer-kind-match.ts b/src/routing/peer-kind-match.ts new file mode 100644 index 00000000000..7fdeb03fd08 --- /dev/null +++ b/src/routing/peer-kind-match.ts @@ -0,0 +1,11 @@ +import type { ChatType } from "../channels/chat-type.js"; + +export function peerKindMatches(bindingKind: ChatType, scopeKind: ChatType): boolean { + if (bindingKind === scopeKind) { + return true; + } + return ( + (bindingKind === "group" && scopeKind === "channel") || + (bindingKind === "channel" && scopeKind === "group") + ); +} diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 7bceb71453c..1ee0ba82ee8 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -5,7 +5,13 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { shouldLogVerbose } from "../globals.js"; import { logDebug } from "../logger.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { + normalizeRouteBindingId, + normalizeRouteBindingRoles, + routeBindingScopeMatches, +} from "./binding-scope.js"; import { listBindings } from "./bindings.js"; +import { peerKindMatches } from "./peer-kind-match.js"; import { buildAgentMainSessionKey, buildAgentPeerSessionKey, @@ -81,13 +87,7 @@ function normalizeToken(value: string | undefined | null): string { } function normalizeId(value: unknown): string { - if (typeof value === "string") { - return value.trim(); - } - if (typeof value === "number" || typeof value === "bigint") { - return String(value).trim(); - } - return ""; + return normalizeRouteBindingId(value); } export function buildAgentSessionKey(params: { @@ -514,7 +514,7 @@ function normalizeBindingMatch( peer: normalizePeerConstraint(match?.peer), guildId: normalizeId(match?.guildId) || null, teamId: normalizeId(match?.teamId) || null, - roles: Array.isArray(rawRoles) && rawRoles.length > 0 ? rawRoles : null, + roles: normalizeRouteBindingRoles(rawRoles), }; } @@ -586,14 +586,6 @@ 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; @@ -612,21 +604,7 @@ function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope) return false; } } - if (match.guildId && match.guildId !== scope.guildId) { - return false; - } - if (match.teamId && match.teamId !== scope.teamId) { - return false; - } - if (match.roles) { - for (const role of match.roles) { - if (scope.memberRoleIds.has(role)) { - return true; - } - } - return false; - } - return true; + return routeBindingScopeMatches(match, scope); } export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute {