mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 03:20:43 +00:00
fix(agents): honor binding scope in child origins
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -243,6 +243,7 @@ export function createSessionsSpawnTool(
|
||||
agentTo: opts?.agentTo,
|
||||
agentThreadId: opts?.agentThreadId,
|
||||
agentGroupId: opts?.agentGroupId ?? undefined,
|
||||
agentGroupSpace: opts?.agentGroupSpace,
|
||||
sandboxed: opts?.sandboxed,
|
||||
},
|
||||
);
|
||||
|
||||
59
src/routing/binding-scope.ts
Normal file
59
src/routing/binding-scope.ts
Normal 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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user