mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1083,6 +1083,7 @@ export async function preflightDiscordMessage(
|
||||
messageChannelId,
|
||||
author,
|
||||
sender,
|
||||
memberRoleIds,
|
||||
channelInfo,
|
||||
channelName,
|
||||
isGuildMessage,
|
||||
|
||||
@@ -44,6 +44,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
|
||||
messageChannelId: string;
|
||||
author: User;
|
||||
sender: DiscordSenderIdentity;
|
||||
memberRoleIds: string[];
|
||||
|
||||
channelInfo: DiscordChannelInfo | null;
|
||||
channelName?: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
201
src/agents/spawn-requester-origin.test.ts
Normal file
201
src/agents/spawn-requester-origin.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
127
src/agents/spawn-requester-origin.ts
Normal file
127
src/agents/spawn-requester-origin.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type SpawnedToolContext = {
|
||||
agentGroupId?: string | null;
|
||||
agentGroupChannel?: string | null;
|
||||
agentGroupSpace?: string | null;
|
||||
agentMemberRoleIds?: string[];
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
78
src/routing/binding-scope.ts
Normal file
78
src/routing/binding-scope.ts
Normal 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);
|
||||
}
|
||||
485
src/routing/bound-account-read.test.ts
Normal file
485
src/routing/bound-account-read.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
11
src/routing/peer-kind-match.ts
Normal file
11
src/routing/peer-kind-match.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user