diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 2843a795018..e1923cae0d2 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -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, diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index d2d0d2f6307..8431d1ecac1 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -1083,6 +1083,7 @@ export async function preflightDiscordMessage( messageChannelId, author, sender, + memberRoleIds, channelInfo, channelName, isGuildMessage, diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts index 575d8ee165b..10550bbb079 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.types.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts @@ -44,6 +44,7 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields messageChannelId: string; author: User; sender: DiscordSenderIdentity; + memberRoleIds: string[]; channelInfo: DiscordChannelInfo | null; channelName?: string; diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index e3c1c28b0c9..8960aedd2aa 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -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, diff --git a/extensions/discord/src/monitor/native-command-context.test.ts b/extensions/discord/src/monitor/native-command-context.test.ts index c17dbb1c879..ed587a2cc37 100644 --- a/extensions/discord/src/monitor/native-command-context.test.ts +++ b/extensions/discord/src/monitor/native-command-context.test.ts @@ -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"); diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index 81083ee6152..80cd91b93ee 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -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, diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 69ed16921f7..c9bc0c19ade 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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, diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 1e58733c31e..536e6109eb9 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -108,6 +108,8 @@ export type SpawnAcpContext = { 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; }; @@ -764,6 +766,7 @@ function resolveAcpSpawnRequesterState(params: { requesterTo: params.ctx.agentTo, requesterThreadId: params.ctx.agentThreadId, requesterGroupSpace: params.ctx.agentGroupSpace, + requesterMemberRoleIds: params.ctx.agentMemberRoleIds, }), }; } diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index a651ba3fead..ea4aba0c4b8 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -592,6 +592,41 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { ).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:), while the binding diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index cd547a19d8b..e511f5404fb 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index d06932eac28..24e05cc1fd2 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index efb00becb53..ea465367a33 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 5de331c48c6..997f23af153 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -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. */ diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index e10849cdda2..ae74cc50e37 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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, diff --git a/src/agents/spawned-context.ts b/src/agents/spawned-context.ts index 7d40382a0d9..2add9abefd3 100644 --- a/src/agents/spawned-context.ts +++ b/src/agents/spawned-context.ts @@ -15,6 +15,7 @@ export type SpawnedToolContext = { agentGroupId?: string | null; agentGroupChannel?: string | null; agentGroupSpace?: string | null; + agentMemberRoleIds?: string[]; workspaceDir?: string; }; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 5191f941bf3..f577f29c336 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -114,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; @@ -468,6 +469,7 @@ export async function spawnSubagentDirect( requesterTo: ctx.agentTo, requesterThreadId: ctx.agentThreadId, requesterGroupSpace: ctx.agentGroupSpace, + requesterMemberRoleIds: ctx.agentMemberRoleIds, }); 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 2f1d90dadd8..fa05597c721 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -244,6 +244,7 @@ export function createSessionsSpawnTool( agentThreadId: opts?.agentThreadId, agentGroupId: opts?.agentGroupId ?? undefined, agentGroupSpace: opts?.agentGroupSpace, + agentMemberRoleIds: opts?.agentMemberRoleIds, sandboxed: opts?.sandboxed, }, ); @@ -338,6 +339,7 @@ export function createSessionsSpawnTool( agentGroupId: opts?.agentGroupId, agentGroupChannel: opts?.agentGroupChannel, agentGroupSpace: opts?.agentGroupSpace, + agentMemberRoleIds: opts?.agentMemberRoleIds, requesterAgentIdOverride: opts?.requesterAgentIdOverride, workspaceDir: opts?.workspaceDir, }, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 3838a27a551..92b76d711d9 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -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", diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index fc13ac6db6a..94c03031247 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -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), diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 4ff36ff7387..d8fc62273d8 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -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. */ diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index 963686c9c63..fff5b8d8abf 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -265,6 +265,26 @@ describe("resolveDeliveryTarget", () => { 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",