fix(agents): prefer target agent's bound Matrix account for subagent spawns (#67508)

Merged via squash.

Prepared head SHA: 9300111038
Co-authored-by: lukeboyett <46942646+lukeboyett@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
lukeboyett
2026-04-18 14:02:53 -04:00
committed by GitHub
parent 3f3bc97cd3
commit c39314c14a
38 changed files with 1781 additions and 74 deletions

View File

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

View File

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

View File

@@ -1083,6 +1083,7 @@ export async function preflightDiscordMessage(
messageChannelId,
author,
sender,
memberRoleIds,
channelInfo,
channelName,
isGuildMessage,

View File

@@ -44,6 +44,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
messageChannelId: string;
author: User;
sender: DiscordSenderIdentity;
memberRoleIds: string[];
channelInfo: DiscordChannelInfo | null;
channelName?: string;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown>;
}) =>
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",

View File

@@ -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) {

View File

@@ -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<typeof getSessionsSpawnTool>[0];
callId: string;
agentId?: string;
}): Promise<string | undefined> {
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:<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.
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 <channel>:<kind>:<id> targets", async () => {
const rawGroupId = "U123example";
// LINE emits its originatingTo as `line:group:<id>`. Without peeling the
// channel prefix first and looping, a naive strip would leave `group:<id>`
// (or `line:<id>`) 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:<id>`. 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({});

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

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

View File

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

View File

@@ -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:<id>` or
// LINE `line:group:<id>`), 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<Record<string, ChatType>> = {
"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 `<alpha-token>:` 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,
});
}

View File

@@ -15,6 +15,7 @@ export type SpawnedToolContext = {
agentGroupId?: string | null;
agentGroupChannel?: string | null;
agentGroupSpace?: string | null;
agentMemberRoleIds?: string[];
workspaceDir?: string;
};

View File

@@ -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 () => {

View File

@@ -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<string, unknown> } | 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",

View File

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

View File

@@ -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,
},

View File

@@ -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",

View File

@@ -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),

View File

@@ -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. */

View File

@@ -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",

View File

@@ -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<string> | 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<string>,
): memberRoleIds is Iterable<string> & { has(roleId: string): boolean } {
return typeof (memberRoleIds as { has?: unknown }).has === "function";
}
function hasAnyRouteBindingRole(
roles: readonly string[],
memberRoleIds: Iterable<string> | 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);
}

View File

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

View File

@@ -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<string> {
const exactPeerIds = new Set<string>();
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;
}

View File

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

View File

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