diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 4fc829b2473..1e58733c31e 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -106,6 +106,8 @@ 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; sandboxed?: boolean; }; @@ -761,6 +763,7 @@ function resolveAcpSpawnRequesterState(params: { requesterAccountId: params.ctx.agentAccountId, requesterTo: params.ctx.agentTo, requesterThreadId: params.ctx.agentThreadId, + requesterGroupSpace: params.ctx.agentGroupSpace, }), }; } diff --git a/src/agents/spawn-requester-origin.test.ts b/src/agents/spawn-requester-origin.test.ts index 646ea75895a..4811ec01178 100644 --- a/src/agents/spawn-requester-origin.test.ts +++ b/src/agents/spawn-requester-origin.test.ts @@ -114,6 +114,56 @@ describe("resolveRequesterOriginForChild", () => { }); }); + 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 = { diff --git a/src/agents/spawn-requester-origin.ts b/src/agents/spawn-requester-origin.ts index 06e9bb66472..0fb5629682b 100644 --- a/src/agents/spawn-requester-origin.ts +++ b/src/agents/spawn-requester-origin.ts @@ -83,6 +83,8 @@ export function resolveRequesterOriginForChild(params: { requesterAccountId?: string; requesterTo?: string; requesterThreadId?: string | number; + requesterGroupSpace?: string | null; + requesterMemberRoleIds?: string[]; }) { const { peerId: normalizedPeerId, peerKind: inferredPeerKind } = extractRequesterPeer( params.requesterChannel, @@ -102,6 +104,8 @@ export function resolveRequesterOriginForChild(params: { exactPeerIdAliases: rawPeerIdAlias && rawPeerIdAlias !== normalizedPeerId ? [rawPeerIdAlias] : undefined, peerKind: inferredPeerKind, + groupSpace: params.requesterGroupSpace, + memberRoleIds: params.requesterMemberRoleIds, }) : undefined; return normalizeDeliveryContext({ diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index b4e147c3496..5191f941bf3 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -467,6 +467,7 @@ export async function spawnSubagentDirect( requesterAccountId: ctx.agentAccountId, requesterTo: ctx.agentTo, requesterThreadId: ctx.agentThreadId, + requesterGroupSpace: ctx.agentGroupSpace, }); let childSessionOrigin = requesterOrigin; if (targetAgentId !== requesterAgentId) { diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index f9b77692dc6..2f1d90dadd8 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -243,6 +243,7 @@ export function createSessionsSpawnTool( agentTo: opts?.agentTo, agentThreadId: opts?.agentThreadId, agentGroupId: opts?.agentGroupId ?? undefined, + agentGroupSpace: opts?.agentGroupSpace, sandboxed: opts?.sandboxed, }, ); diff --git a/src/routing/binding-scope.ts b/src/routing/binding-scope.ts new file mode 100644 index 00000000000..1b0d952fa14 --- /dev/null +++ b/src/routing/binding-scope.ts @@ -0,0 +1,59 @@ +export type RouteBindingScopeConstraint = { + guildId?: string | null; + teamId?: string | null; + roles?: string[] | null; +}; + +export type RouteBindingScope = { + guildId?: string | null; + teamId?: string | null; + groupSpace?: string | null; + memberRoleIds?: Iterable | null; +}; + +export function normalizeRouteBindingId(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "bigint") { + return String(value).trim(); + } + return ""; +} + +export function normalizeRouteBindingRoles(value: string[] | null | undefined): string[] | null { + return Array.isArray(value) && value.length > 0 ? value : null; +} + +function scopeIdMatches(params: { + constraint: string | null | undefined; + exact: string; + groupSpace: string; +}): boolean { + if (!params.constraint) { + return true; + } + return params.constraint === params.exact || params.constraint === params.groupSpace; +} + +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; + } + const memberRoleIds = new Set(scope.memberRoleIds ?? []); + return roles.some((role) => memberRoleIds.has(role)); +} diff --git a/src/routing/bound-account-read.test.ts b/src/routing/bound-account-read.test.ts index ee02c50d3d9..fdd32cb0787 100644 --- a/src/routing/bound-account-read.test.ts +++ b/src/routing/bound-account-read.test.ts @@ -375,4 +375,111 @@ describe("resolveFirstBoundAccountId", () => { }), ).toBe("bot-alpha-default"); }); + + it("skips scoped bindings when the caller has no matching group space", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-other", + accountId: "bot-alpha-other-guild", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { channel: "discord", accountId: "bot-alpha-default" }, + }, + ]); + + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "discord", + agentId: "bot-alpha", + groupSpace: "guild-current", + }), + ).toBe("bot-alpha-default"); + }); + + it("matches scoped guild and team bindings against caller group space", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-current", + accountId: "bot-alpha-guild", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "slack", + teamId: "team-current", + accountId: "bot-alpha-team", + }, + }, + ]); + + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "discord", + agentId: "bot-alpha", + groupSpace: "guild-current", + }), + ).toBe("bot-alpha-guild"); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "slack", + agentId: "bot-alpha", + groupSpace: "team-current", + }), + ).toBe("bot-alpha-team"); + }); + + it("requires caller roles before selecting role-scoped bindings", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "discord", + guildId: "guild-current", + roles: ["admin"], + accountId: "bot-alpha-admin", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { channel: "discord", accountId: "bot-alpha-default" }, + }, + ]); + + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "discord", + agentId: "bot-alpha", + groupSpace: "guild-current", + memberRoleIds: ["member"], + }), + ).toBe("bot-alpha-default"); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "discord", + agentId: "bot-alpha", + groupSpace: "guild-current", + memberRoleIds: ["admin"], + }), + ).toBe("bot-alpha-admin"); + }); }); diff --git a/src/routing/bound-account-read.ts b/src/routing/bound-account-read.ts index 8a919a5d70e..10b7d2884d5 100644 --- a/src/routing/bound-account-read.ts +++ b/src/routing/bound-account-read.ts @@ -4,6 +4,11 @@ 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"; @@ -22,6 +27,9 @@ function resolveNormalizedBindingMatch(binding: AgentRouteBinding): { channelId: string; peerId?: string; peerKind?: ChatType; + guildId?: string | null; + teamId?: string | null; + roles?: string[] | null; } | null { if (!binding || typeof binding !== "object") { return null; @@ -46,6 +54,9 @@ function resolveNormalizedBindingMatch(binding: AgentRouteBinding): { channelId, peerId: peerId || undefined, peerKind: peerKind ?? undefined, + guildId: normalizeRouteBindingId(match.guildId) || null, + teamId: normalizeRouteBindingId(match.teamId) || null, + roles: normalizeRouteBindingRoles(match.roles), }; } @@ -74,6 +85,10 @@ export function resolveFirstBoundAccountId(params: { peerId?: string; exactPeerIdAliases?: string[]; peerKind?: ChatType; + guildId?: string | null; + teamId?: string | null; + groupSpace?: string | null; + memberRoleIds?: string[]; }): string | undefined { const normalizedChannel = normalizeBindingChannelId(params.channelId); if (!normalizedChannel) { @@ -98,6 +113,16 @@ export function resolveFirstBoundAccountId(params: { ) { continue; } + if ( + !routeBindingScopeMatches(resolved, { + guildId: params.guildId, + teamId: params.teamId, + 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 diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 5f0cfedba42..1ee0ba82ee8 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -5,6 +5,11 @@ 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 { @@ -82,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: { @@ -515,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), }; } @@ -605,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 {