fix(agents): honor binding scope in child origins

This commit is contained in:
Gustavo Madeira Santana
2026-04-17 21:11:48 -04:00
parent 33fc14e3fe
commit 8a59771adb
9 changed files with 258 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -243,6 +243,7 @@ export function createSessionsSpawnTool(
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
agentGroupId: opts?.agentGroupId ?? undefined,
agentGroupSpace: opts?.agentGroupSpace,
sandboxed: opts?.sandboxed,
},
);

View File

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

View File

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

View File

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

View File

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