diff --git a/CHANGELOG.md b/CHANGELOG.md index d43e008a72e..99fde826347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -288,6 +288,9 @@ Docs: https://docs.openclaw.ai - CLI/tasks: `openclaw tasks cancel` now records operator cancellation for CLI runtime tasks instead of returning "Task runtime does not support cancellation yet", so stuck `running` CLI tasks can be cleared. (#62419) Thanks @neeravmakwana. - Sessions/context: resolve context window limits using the active provider plus model (not bare model id alone) when persisting session usage, applying inline directives, and sizing memory-flush / preflight compaction thresholds, so duplicate model ids across providers no longer leak the wrong `contextTokens` into the session store or `/status`. (#62472) Thanks @neeravmakwana. - Channels/setup: exclude workspace shadow entries from channel setup catalog lookups and align trust checks with auto-enable so workspace-scoped overrides no longer bypass the trusted catalog. (`GHSA-82qx-6vj7-p8m2`) Thanks @zsxsoft. +- Reply execution: prefer the active runtime snapshot over stale queued reply config during embedded reply and follow-up execution so SecretRef-backed reply turns stop crashing after secrets have already resolved. (#62693) Thanks @mbelinky. +- Android/manual connect: allow blank port input only for TLS manual gateway endpoints so standard HTTPS Tailscale hosts default to `443` without silently changing cleartext manual connects. (#63134) Thanks @Tyler-RNG. +- Matrix/agents: hide owner-only `set-profile` from embedded agent channel-action discovery so non-owner runs stop advertising profile updates they cannot execute. (#62662) Thanks @eleqtrizit. ## 2026.4.5 diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts index 4843e1cdf59..681689343a1 100644 --- a/extensions/matrix/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -91,6 +91,7 @@ describe("matrixMessageActions account propagation", () => { await matrixMessageActions.handleAction?.( createContext({ action: profileAction, + senderIsOwner: true, accountId: "ops", params: { displayName: "Ops Bot", @@ -111,10 +112,50 @@ describe("matrixMessageActions account propagation", () => { ); }); + it("rejects self-profile updates for non-owner callers", async () => { + await expect( + matrixMessageActions.handleAction?.( + createContext({ + action: profileAction, + senderIsOwner: false, + accountId: "ops", + params: { + displayName: "Ops Bot", + }, + }), + ), + ).rejects.toMatchObject({ + name: "ToolAuthorizationError", + message: "Matrix profile updates require owner access.", + }); + + expect(mocks.handleMatrixAction).not.toHaveBeenCalled(); + }); + + it("rejects self-profile updates when owner status is unknown", async () => { + await expect( + matrixMessageActions.handleAction?.( + createContext({ + action: profileAction, + accountId: "ops", + params: { + displayName: "Ops Bot", + }, + }), + ), + ).rejects.toMatchObject({ + name: "ToolAuthorizationError", + message: "Matrix profile updates require owner access.", + }); + + expect(mocks.handleMatrixAction).not.toHaveBeenCalled(); + }); + it("forwards local avatar paths for self-profile updates", async () => { await matrixMessageActions.handleAction?.( createContext({ action: profileAction, + senderIsOwner: true, accountId: "ops", params: { path: "/tmp/avatar.jpg", diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index f407133e0d2..cf360467526 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -78,6 +78,7 @@ describe("matrixMessageActions", () => { const discovery = describeMessageTool({ cfg: createConfiguredMatrixConfig(), + senderIsOwner: true, } as never); if (!discovery) { throw new Error("describeMessageTool returned null"); @@ -96,6 +97,31 @@ describe("matrixMessageActions", () => { expect(properties.avatarPath).toBeDefined(); }); + it("hides self-profile updates for non-owner discovery", () => { + const discovery = matrixMessageActions.describeMessageTool({ + cfg: createConfiguredMatrixConfig(), + senderIsOwner: false, + } as never); + if (!discovery) { + throw new Error("describeMessageTool returned null"); + } + + expect(discovery.actions).not.toContain(profileAction); + expect(discovery.schema).toBeNull(); + }); + + it("hides self-profile updates when owner status is unknown", () => { + const discovery = matrixMessageActions.describeMessageTool({ + cfg: createConfiguredMatrixConfig(), + } as never); + if (!discovery) { + throw new Error("describeMessageTool returned null"); + } + + expect(discovery.actions).not.toContain(profileAction); + expect(discovery.schema).toBeNull(); + }); + it("hides gated actions when the default Matrix account disables them", () => { const discovery = matrixMessageActions.describeMessageTool({ cfg: { diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index e46ad269bde..607be5dcfd0 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -7,11 +7,11 @@ import { createActionGate, readNumberParam, readStringParam, + ToolAuthorizationError, type ChannelMessageActionAdapter, type ChannelMessageActionContext, type ChannelMessageActionName, type ChannelMessageToolDiscovery, - type ChannelToolSend, } from "./runtime-api.js"; import type { CoreConfig } from "./types.js"; @@ -35,6 +35,7 @@ const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set([ function createMatrixExposedActions(params: { gate: ReturnType; encryptionEnabled: boolean; + senderIsOwner?: boolean; }) { const actions = new Set(["poll", "poll-vote"]); if (params.gate("messages")) { @@ -52,7 +53,7 @@ function createMatrixExposedActions(params: { actions.add("unpin"); actions.add("list-pins"); } - if (params.gate("profile")) { + if (params.gate("profile") && params.senderIsOwner === true) { actions.add("set-profile"); } if (params.gate("memberInfo")) { @@ -109,7 +110,7 @@ function buildMatrixProfileToolSchema(): NonNullable { + describeMessageTool: ({ cfg, accountId, senderIsOwner }) => { const resolvedCfg = cfg as CoreConfig; if (!accountId && requiresExplicitMatrixDefaultAccount(resolvedCfg)) { return { actions: [], capabilities: [] }; @@ -125,6 +126,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { const actions = createMatrixExposedActions({ gate, encryptionEnabled: account.config.encryption === true, + senderIsOwner, }); const listedActions = Array.from(actions); return { @@ -134,7 +136,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { }; }, supportsAction: ({ action }) => MATRIX_PLUGIN_HANDLED_ACTIONS.has(action), - extractToolSend: ({ args }): ChannelToolSend | null => { + extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, handleAction: async (ctx: ChannelMessageActionContext) => { @@ -259,6 +261,9 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { } if (action === "set-profile") { + if (ctx.senderIsOwner !== true) { + throw new ToolAuthorizationError("Matrix profile updates require owner access."); + } const avatarPath = readStringParam(params, "avatarPath") ?? readStringParam(params, "path") ?? diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 55e12329cd8..60757706f8a 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -10,6 +10,7 @@ export { readReactionParams, readStringArrayParam, readStringParam, + ToolAuthorizationError, } from "openclaw/plugin-sdk/channel-actions"; export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives"; export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts index 3738f0a4e32..ea984ec82eb 100644 --- a/src/agents/channel-tools.ts +++ b/src/agents/channel-tools.ts @@ -41,6 +41,7 @@ export function listChannelSupportedActions(params: { sessionId?: string | null; agentId?: string | null; requesterSenderId?: string | null; + senderIsOwner?: boolean; }): ChannelMessageActionName[] { const channelId = resolveMessageActionDiscoveryChannelId(params.channel); if (!channelId) { @@ -71,6 +72,7 @@ export function listAllChannelSupportedActions(params: { sessionId?: string | null; agentId?: string | null; requesterSenderId?: string | null; + senderIsOwner?: boolean; }): ChannelMessageActionName[] { const actions = new Set(); for (const plugin of listChannelPlugins()) { diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 7b0f726cddb..bea84e15fa0 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -2,15 +2,21 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearActiveMcpLoopbackRuntime, + setActiveMcpLoopbackRuntime, +} from "../gateway/mcp-http.loopback-runtime.js"; import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js"; import { makeBootstrapWarn as realMakeBootstrapWarn, resolveBootstrapContextForRun as realResolveBootstrapContextForRun, } from "./bootstrap-files.js"; +import { runClaudeCliAgent } from "./cli-runner.js"; import { createManagedRun, mockSuccessfulCliRun, restoreCliRunnerPrepareTestDeps, + setupCliRunnerTestRegistry, supervisorSpawnMock, } from "./cli-runner.test-support.js"; import { buildCliEnvAuthLog, executePreparedCliRun } from "./cli-runner/execute.js"; @@ -97,6 +103,19 @@ function buildPreparedCliRunContext(params: { }; } +function createClaudeSuccessRun(sessionId: string) { + return createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: JSON.stringify({ message: "ok", session_id: sessionId }), + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); +} + describe("runCliAgent spawn path", () => { it("does not inject hardcoded 'Tools are disabled' text into CLI arguments", async () => { supervisorSpawnMock.mockResolvedValueOnce( @@ -367,6 +386,55 @@ describe("runCliAgent spawn path", () => { } }); + it("ignores legacy claudeSessionId on the compat wrapper", async () => { + setupCliRunnerTestRegistry(); + supervisorSpawnMock.mockResolvedValueOnce(createClaudeSuccessRun("sid-wrapper")); + + await runClaudeCliAgent({ + sessionId: "openclaw-session", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + model: "opus", + timeoutMs: 1_000, + runId: "run-claude-legacy-wrapper", + claudeSessionId: "c9d7b831-1c31-4d22-80b9-1e50ca207d4b", + }); + + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; input?: string }; + expect(input.argv).not.toContain("--resume"); + expect(input.argv).not.toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b"); + expect(input.argv).toContain("--session-id"); + expect(input.input).toContain("hi"); + }); + + it("forwards senderIsOwner through the compat wrapper into bundle MCP env", async () => { + setupCliRunnerTestRegistry(); + setActiveMcpLoopbackRuntime({ port: 23119, token: "loopback-token-123" }); + try { + supervisorSpawnMock.mockResolvedValueOnce(createClaudeSuccessRun("sid-owner")); + + await runClaudeCliAgent({ + sessionId: "openclaw-session", + sessionKey: "agent:main:matrix:room:123", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + prompt: "hi", + model: "opus", + timeoutMs: 1_000, + runId: "run-claude-owner-wrapper", + senderIsOwner: false, + }); + + const input = supervisorSpawnMock.mock.calls[0]?.[0] as { + env?: Record; + }; + expect(input.env?.OPENCLAW_MCP_SENDER_IS_OWNER).toBe("false"); + } finally { + clearActiveMcpLoopbackRuntime("loopback-token-123"); + } + }); + it("runs CLI through supervisor and returns payload", async () => { supervisorSpawnMock.mockResolvedValueOnce( createManagedRun({ diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 7fad364adcd..11686cdd2f9 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -1,6 +1,3 @@ -import type { ImageContent } from "@mariozechner/pi-ai"; -import type { ThinkLevel } from "../auto-reply/thinking.js"; -import type { OpenClawConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; import { executePreparedCliRun } from "./cli-runner/execute.js"; import { prepareCliRunContext } from "./cli-runner/prepare.js"; @@ -95,24 +92,14 @@ export async function runPreparedCliAgent( } } -export async function runClaudeCliAgent(params: { - sessionId: string; - sessionKey?: string; - agentId?: string; - sessionFile: string; - workspaceDir: string; - config?: OpenClawConfig; - prompt: string; +export type RunClaudeCliAgentParams = Omit & { provider?: string; - model?: string; - thinkLevel?: ThinkLevel; - timeoutMs: number; - runId: string; - extraSystemPrompt?: string; - ownerNumbers?: string[]; claudeSessionId?: string; - images?: ImageContent[]; -}): Promise { +}; + +export async function runClaudeCliAgent( + params: RunClaudeCliAgentParams, +): Promise { return runCliAgent({ sessionId: params.sessionId, sessionKey: params.sessionKey, @@ -128,7 +115,10 @@ export async function runClaudeCliAgent(params: { runId: params.runId, extraSystemPrompt: params.extraSystemPrompt, ownerNumbers: params.ownerNumbers, - cliSessionId: params.claudeSessionId, + // Legacy `claudeSessionId` callers predate the shared CLI session contract. + // Ignore it here so the compatibility wrapper does not accidentally resume + // an incompatible Claude session on the generic runner path. images: params.images, + senderIsOwner: params.senderIsOwner, }); } diff --git a/src/agents/cli-runner/bundle-mcp.test.ts b/src/agents/cli-runner/bundle-mcp.test.ts index 9a9af654920..d43a0aae8af 100644 --- a/src/agents/cli-runner/bundle-mcp.test.ts +++ b/src/agents/cli-runner/bundle-mcp.test.ts @@ -213,12 +213,14 @@ describe("prepareCliBundleMcpConfig", () => { env: { OPENCLAW_MCP_TOKEN: "loopback-token-123", OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123", + OPENCLAW_MCP_SENDER_IS_OWNER: "false", }, }); expect(prepared.env).toEqual({ OPENCLAW_MCP_TOKEN: "loopback-token-123", OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123", + OPENCLAW_MCP_SENDER_IS_OWNER: "false", }); await prepared.cleanup?.(); @@ -256,6 +258,7 @@ describe("prepareCliBundleMcpConfig", () => { headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", "x-session-key": "${OPENCLAW_MCP_SESSION_KEY}", + "x-openclaw-sender-is-owner": "${OPENCLAW_MCP_SENDER_IS_OWNER}", }, }, }, @@ -266,14 +269,14 @@ describe("prepareCliBundleMcpConfig", () => { "exec", "--json", "-c", - 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', + 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY", x-openclaw-sender-is-owner = "OPENCLAW_MCP_SENDER_IS_OWNER" } } }', ]); expect(prepared.backend.resumeArgs).toEqual([ "exec", "resume", "{sessionId}", "-c", - 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', + 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY", x-openclaw-sender-is-owner = "OPENCLAW_MCP_SENDER_IS_OWNER" } } }', ]); expect(prepared.cleanup).toBeUndefined(); }); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index cec3a8b3fef..01984f261a2 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -132,6 +132,7 @@ export async function prepareCliRunContext( OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "", OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "", OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "", + OPENCLAW_MCP_SENDER_IS_OWNER: params.senderIsOwner === true ? "true" : "false", } : undefined, warn: (message) => cliBackendLog.warn(message), diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 5ca3a94314a..74003ee1087 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -35,6 +35,7 @@ export type RunCliAgentParams = { skillsSnapshot?: SkillSnapshot; messageProvider?: string; agentAccountId?: string; + senderIsOwner?: boolean; abortSignal?: AbortSignal; replyOperation?: ReplyOperation; }; diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index b2bcd0b64a6..e84daaf153d 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -395,6 +395,7 @@ export function runAgentAttempt(params: { streamParams: params.opts.streamParams, messageProvider: params.messageChannel, agentAccountId: params.runContext.accountId, + senderIsOwner: params.opts.senderIsOwner, }); return runCliWithSession(cliSessionBinding?.sessionId).catch(async (err) => { if ( diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index d058de6a946..7e91d36c909 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -206,6 +206,7 @@ export function createOpenClawTools( sandboxRoot: options?.sandboxRoot, requireExplicitTarget: options?.requireExplicitMessageTarget, requesterSenderId: options?.requesterSenderId ?? undefined, + senderIsOwner: options?.senderIsOwner, }); const nodesToolBase = createNodesTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index b89eb0e255d..d3ff4959b68 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -677,6 +677,7 @@ export async function compactEmbeddedPiSessionDirect( sessionId: params.sessionId, agentId: sessionAgentId, senderId: params.senderId, + senderIsOwner: params.senderIsOwner, }), ) : undefined; diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts b/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts index 7b2acd199c0..1641144af47 100644 --- a/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts +++ b/src/agents/pi-embedded-runner/message-action-discovery-input.test.ts @@ -14,6 +14,7 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => { sessionId: "session-1", agentId: "main", senderId: "user-123", + senderIsOwner: false, }), ).toEqual({ cfg: undefined, @@ -26,6 +27,7 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => { sessionId: "session-1", agentId: "main", requesterSenderId: "user-123", + senderIsOwner: false, }); }); @@ -41,6 +43,7 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => { sessionId: null, agentId: null, senderId: null, + senderIsOwner: false, }), ).toEqual({ cfg: undefined, @@ -53,6 +56,28 @@ describe("buildEmbeddedMessageActionDiscoveryInput", () => { sessionId: undefined, agentId: undefined, requesterSenderId: undefined, + senderIsOwner: false, + }); + }); + + it("preserves owner authorization for downstream channel action gating", () => { + expect( + buildEmbeddedMessageActionDiscoveryInput({ + channel: "matrix", + senderIsOwner: true, + }), + ).toEqual({ + cfg: undefined, + channel: "matrix", + currentChannelId: undefined, + currentThreadTs: undefined, + currentMessageId: undefined, + accountId: undefined, + sessionKey: undefined, + sessionId: undefined, + agentId: undefined, + requesterSenderId: undefined, + senderIsOwner: true, }); }); }); diff --git a/src/agents/pi-embedded-runner/message-action-discovery-input.ts b/src/agents/pi-embedded-runner/message-action-discovery-input.ts index 3002e90d357..07c25d885e7 100644 --- a/src/agents/pi-embedded-runner/message-action-discovery-input.ts +++ b/src/agents/pi-embedded-runner/message-action-discovery-input.ts @@ -11,6 +11,7 @@ export function buildEmbeddedMessageActionDiscoveryInput(params: { sessionId?: string | null; agentId?: string | null; senderId?: string | null; + senderIsOwner?: boolean; }) { return { cfg: params.cfg, @@ -23,5 +24,6 @@ export function buildEmbeddedMessageActionDiscoveryInput(params: { sessionId: params.sessionId ?? undefined, agentId: params.agentId ?? undefined, requesterSenderId: params.senderId ?? undefined, + senderIsOwner: params.senderIsOwner, }; } diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts index 6916872a75a..1e8bfd0760c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js"; import { HEARTBEAT_PROMPT } from "../../../auto-reply/heartbeat.js"; import { limitHistoryTurns } from "../history.js"; @@ -8,6 +8,15 @@ import { type AttemptContextEngine, resolveAttemptBootstrapContext, } from "./attempt.context-engine-helpers.js"; +import { + cleanupTempPaths, + createContextEngineAttemptRunner, + getHoisted, + resetEmbeddedAttemptHarness, +} from "./attempt.spawn-workspace.test-support.js"; + +const hoisted = getHoisted(); +const tempPaths: string[] = []; async function resolveBootstrapContext(params: { contextInjectionMode?: "always" | "continuation-skip"; @@ -37,6 +46,14 @@ async function resolveBootstrapContext(params: { } describe("embedded attempt context injection", () => { + beforeEach(() => { + resetEmbeddedAttemptHarness(); + }); + + afterEach(async () => { + await cleanupTempPaths(tempPaths); + }); + it("skips bootstrap reinjection on safe continuation turns when configured", async () => { const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } = await resolveBootstrapContext({ @@ -69,6 +86,28 @@ describe("embedded attempt context injection", () => { expect(resolver).toHaveBeenCalledTimes(1); }); + it("forwards senderIsOwner into embedded message-action discovery", async () => { + await createContextEngineAttemptRunner({ + contextEngine: { + assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), + }, + attemptOverrides: { + messageChannel: "matrix", + messageProvider: "matrix", + senderIsOwner: false, + }, + sessionKey: "agent:main", + tempPaths, + }); + + expect(hoisted.buildEmbeddedMessageActionDiscoveryInputMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "matrix", + senderIsOwner: false, + }), + ); + }); + it("never skips heartbeat bootstrap filtering", async () => { const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } = await resolveBootstrapContext({ diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index a57abe219b7..801e2dd3b5c 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -47,6 +47,7 @@ type AttemptSpawnWorkspaceHoisted = { createAgentSessionMock: UnknownMock; sessionManagerOpenMock: UnknownMock; resolveSandboxContextMock: UnknownMock; + buildEmbeddedMessageActionDiscoveryInputMock: UnknownMock; subscribeEmbeddedPiSessionMock: Mock; acquireSessionWriteLockMock: Mock; installToolResultContextGuardMock: UnknownMock; @@ -70,6 +71,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { const createAgentSessionMock = vi.fn(); const sessionManagerOpenMock = vi.fn(); const resolveSandboxContextMock = vi.fn(); + const buildEmbeddedMessageActionDiscoveryInputMock = vi.fn((params: unknown) => params); const installToolResultContextGuardMock = vi.fn(() => () => {}); const flushPendingToolResultsAfterIdleMock = vi.fn(async () => {}); const releaseWsSessionMock = vi.fn(() => {}); @@ -128,6 +130,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { createAgentSessionMock, sessionManagerOpenMock, resolveSandboxContextMock, + buildEmbeddedMessageActionDiscoveryInputMock, subscribeEmbeddedPiSessionMock, acquireSessionWriteLockMock, installToolResultContextGuardMock, @@ -527,7 +530,8 @@ vi.mock("../logger.js", () => ({ })); vi.mock("../message-action-discovery-input.js", () => ({ - buildEmbeddedMessageActionDiscoveryInput: () => undefined, + buildEmbeddedMessageActionDiscoveryInput: (...args: unknown[]) => + hoisted.buildEmbeddedMessageActionDiscoveryInputMock(...args), })); vi.mock("../model.js", () => ({ @@ -669,6 +673,9 @@ export function resetEmbeddedAttemptHarness( hoisted.createAgentSessionMock.mockReset(); hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager); hoisted.resolveSandboxContextMock.mockReset(); + hoisted.buildEmbeddedMessageActionDiscoveryInputMock + .mockReset() + .mockImplementation((params) => params); hoisted.subscribeEmbeddedPiSessionMock .mockReset() .mockImplementation(() => createSubscriptionMock()); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 47927f0a036..eb442a1ec92 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -655,6 +655,7 @@ export async function runEmbeddedAttempt( sessionId: params.sessionId, agentId: sessionAgentId, senderId: params.senderId, + senderIsOwner: params.senderIsOwner, }), ) : undefined; diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index e1c3caa3446..6e406fc8b40 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -260,6 +260,7 @@ async function executeSend(params: { params?: Record; sandboxRoot?: string; requesterSenderId?: string; + senderIsOwner?: boolean; } | undefined; } @@ -800,6 +801,52 @@ describe("message tool schema scoping", () => { }), ); }); + + it("forwards senderIsOwner into plugin action discovery", () => { + const seenContexts: Record[] = []; + const ownerAwarePlugin = createChannelPlugin({ + id: "matrix", + label: "Matrix", + docsPath: "/channels/matrix", + blurb: "Matrix owner-aware plugin.", + describeMessageTool: (ctx) => { + seenContexts.push(ctx); + return { + actions: ctx.senderIsOwner === false ? ["send"] : ["send", "set-profile"], + }; + }, + }); + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "matrix", source: "test", plugin: ownerAwarePlugin }]), + ); + + const ownerTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "matrix", + senderIsOwner: true, + }); + const nonOwnerTool = createMessageTool({ + config: {} as never, + currentChannelProvider: "matrix", + senderIsOwner: false, + }); + + expect(getActionEnum(getToolProperties(ownerTool))).toContain("set-profile"); + expect(getActionEnum(getToolProperties(nonOwnerTool))).not.toContain("set-profile"); + expect(seenContexts).toContainEqual(expect.objectContaining({ senderIsOwner: true })); + expect(seenContexts).toContainEqual(expect.objectContaining({ senderIsOwner: false })); + }); + + it("keeps core send and broadcast actions in unscoped schemas", () => { + const tool = createMessageTool({ + config: {} as never, + }); + + expect(getActionEnum(getToolProperties(tool))).toEqual( + expect.arrayContaining(["send", "broadcast"]), + ); + }); }); describe("message tool description", () => { @@ -1003,6 +1050,14 @@ describe("message tool description", () => { expect(tool.description).toContain("Supports actions:"); expect(tool.description).toContain('Use action="read" with threadId'); }); + + it("includes broadcast in the generic fallback description", () => { + const tool = createMessageTool({ + config: {} as never, + }); + + expect(tool.description).toContain("Supports actions: send, broadcast."); + }); }); describe("message tool reasoning tag sanitization", () => { @@ -1082,4 +1137,18 @@ describe("message tool sandbox passthrough", () => { expect(call?.requesterSenderId).toBe("1234567890"); }); + + it("forwards senderIsOwner to runMessageAction", async () => { + mockSendResult({ to: "discord:123" }); + + const call = await executeSend({ + toolOptions: { senderIsOwner: false }, + action: { + target: "discord:123", + message: "hi", + }, + }); + + expect(call?.senderIsOwner).toBe(false); + }); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index f98f5490e30..b8d080e5bb5 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -3,7 +3,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js"; import { channelSupportsMessageCapability, channelSupportsMessageCapabilityForChannel, - listChannelMessageActions, + type ChannelMessageActionDiscoveryInput, resolveChannelMessageToolSchemaProperties, } from "../../channels/plugins/message-action-discovery.js"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; @@ -24,7 +24,7 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { resolveSessionAgentId } from "../agent-scope.js"; -import { listChannelSupportedActions } from "../channel-tools.js"; +import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js"; import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; @@ -411,9 +411,10 @@ type MessageToolOptions = { sandboxRoot?: string; requireExplicitTarget?: boolean; requesterSenderId?: string; + senderIsOwner?: boolean; }; -function resolveMessageToolSchemaActions(params: { +type MessageToolDiscoveryParams = { cfg: OpenClawConfig; currentChannelProvider?: string; currentChannelId?: string; @@ -424,117 +425,16 @@ function resolveMessageToolSchemaActions(params: { sessionId?: string; agentId?: string; requesterSenderId?: string; -}): string[] { - const currentChannel = normalizeMessageChannel(params.currentChannelProvider); - if (currentChannel) { - const scopedActions = listChannelSupportedActions({ - cfg: params.cfg, - channel: currentChannel, - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.currentAccountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }); - const allActions = new Set(["send", ...scopedActions]); - // Include actions from other configured channels so isolated/cron agents - // can invoke cross-channel actions without validation errors. - for (const plugin of listChannelPlugins()) { - if (plugin.id === currentChannel) { - continue; - } - for (const action of listChannelSupportedActions({ - cfg: params.cfg, - channel: plugin.id, - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.currentAccountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - })) { - allActions.add(action); - } - } - return Array.from(allActions); - } - const actions = listChannelMessageActions(params.cfg); - return actions.length > 0 ? actions : ["send"]; -} + senderIsOwner?: boolean; +}; -function resolveIncludeCapability( - params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; - currentChannelId?: string; - currentThreadTs?: string; - currentMessageId?: string | number; - currentAccountId?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - requesterSenderId?: string; - }, - capability: ChannelMessageCapability, -): boolean { - const currentChannel = normalizeMessageChannel(params.currentChannelProvider); - if (currentChannel) { - return channelSupportsMessageCapabilityForChannel( - { - cfg: params.cfg, - channel: currentChannel, - currentChannelId: params.currentChannelId, - currentThreadTs: params.currentThreadTs, - currentMessageId: params.currentMessageId, - accountId: params.currentAccountId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - requesterSenderId: params.requesterSenderId, - }, - capability, - ); - } - return channelSupportsMessageCapability(params.cfg, capability); -} - -function resolveIncludeInteractive(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; - currentChannelId?: string; - currentThreadTs?: string; - currentMessageId?: string | number; - currentAccountId?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - requesterSenderId?: string; -}): boolean { - return resolveIncludeCapability(params, "interactive"); -} - -function buildMessageToolSchema(params: { - cfg: OpenClawConfig; - currentChannelProvider?: string; - currentChannelId?: string; - currentThreadTs?: string; - currentMessageId?: string | number; - currentAccountId?: string; - sessionKey?: string; - sessionId?: string; - agentId?: string; - requesterSenderId?: string; -}) { - const actions = resolveMessageToolSchemaActions(params); - const includeInteractive = resolveIncludeInteractive(params); - const extraProperties = resolveChannelMessageToolSchemaProperties({ +function buildMessageActionDiscoveryInput( + params: MessageToolDiscoveryParams, + channel?: string, +): ChannelMessageActionDiscoveryInput { + return { cfg: params.cfg, - channel: normalizeMessageChannel(params.currentChannelProvider), + ...(channel ? { channel } : {}), currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, currentMessageId: params.currentMessageId, @@ -543,7 +443,66 @@ function buildMessageToolSchema(params: { sessionId: params.sessionId, agentId: params.agentId, requesterSenderId: params.requesterSenderId, - }); + senderIsOwner: params.senderIsOwner, + }; +} + +function resolveMessageToolSchemaActions(params: MessageToolDiscoveryParams): string[] { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + if (currentChannel) { + const scopedActions = listChannelSupportedActions( + buildMessageActionDiscoveryInput(params, currentChannel), + ); + const allActions = new Set(["send", ...scopedActions]); + // Include actions from other configured channels so isolated/cron agents + // can invoke cross-channel actions without validation errors. + for (const plugin of listChannelPlugins()) { + if (plugin.id === currentChannel) { + continue; + } + for (const action of listChannelSupportedActions( + buildMessageActionDiscoveryInput(params, plugin.id), + )) { + allActions.add(action); + } + } + return Array.from(allActions); + } + return listAllMessageToolActions(params); +} + +function listAllMessageToolActions(params: MessageToolDiscoveryParams): ChannelMessageActionName[] { + const pluginActions = listAllChannelSupportedActions(buildMessageActionDiscoveryInput(params)); + return Array.from(new Set(["send", "broadcast", ...pluginActions])); +} + +function resolveIncludeCapability( + params: MessageToolDiscoveryParams, + capability: ChannelMessageCapability, +): boolean { + const currentChannel = normalizeMessageChannel(params.currentChannelProvider); + if (currentChannel) { + return channelSupportsMessageCapabilityForChannel( + buildMessageActionDiscoveryInput(params, currentChannel), + capability, + ); + } + return channelSupportsMessageCapability(params.cfg, capability); +} + +function resolveIncludeInteractive(params: MessageToolDiscoveryParams): boolean { + return resolveIncludeCapability(params, "interactive"); +} + +function buildMessageToolSchema(params: MessageToolDiscoveryParams) { + const actions = resolveMessageToolSchemaActions(params); + const includeInteractive = resolveIncludeInteractive(params); + const extraProperties = resolveChannelMessageToolSchemaProperties( + buildMessageActionDiscoveryInput( + params, + normalizeMessageChannel(params.currentChannelProvider), + ), + ); return buildMessageToolSchemaFromActions(actions.length > 0 ? actions : ["send"], { includeInteractive, extraProperties, @@ -569,25 +528,32 @@ function buildMessageToolDescription(options?: { sessionId?: string; agentId?: string; requesterSenderId?: string; + senderIsOwner?: boolean; }): string { const baseDescription = "Send, delete, and manage messages via channel plugins."; const resolvedOptions = options ?? {}; const currentChannel = normalizeMessageChannel(resolvedOptions.currentChannel); + const messageToolDiscoveryParams = resolvedOptions.config + ? { + cfg: resolvedOptions.config, + currentChannelProvider: resolvedOptions.currentChannel, + currentChannelId: resolvedOptions.currentChannelId, + currentThreadTs: resolvedOptions.currentThreadTs, + currentMessageId: resolvedOptions.currentMessageId, + currentAccountId: resolvedOptions.currentAccountId, + sessionKey: resolvedOptions.sessionKey, + sessionId: resolvedOptions.sessionId, + agentId: resolvedOptions.agentId, + requesterSenderId: resolvedOptions.requesterSenderId, + senderIsOwner: resolvedOptions.senderIsOwner, + } + : undefined; // If we have a current channel, show its actions and list other configured channels - if (currentChannel) { - const channelActions = listChannelSupportedActions({ - cfg: resolvedOptions.config, - channel: currentChannel, - currentChannelId: resolvedOptions.currentChannelId, - currentThreadTs: resolvedOptions.currentThreadTs, - currentMessageId: resolvedOptions.currentMessageId, - accountId: resolvedOptions.currentAccountId, - sessionKey: resolvedOptions.sessionKey, - sessionId: resolvedOptions.sessionId, - agentId: resolvedOptions.agentId, - requesterSenderId: resolvedOptions.requesterSenderId, - }); + if (currentChannel && messageToolDiscoveryParams) { + const channelActions = listChannelSupportedActions( + buildMessageActionDiscoveryInput(messageToolDiscoveryParams, currentChannel), + ); if (channelActions.length > 0) { // Always include "send" as a base action const allActions = new Set(["send", ...channelActions]); @@ -600,18 +566,9 @@ function buildMessageToolDescription(options?: { if (plugin.id === currentChannel) { continue; } - const actions = listChannelSupportedActions({ - cfg: resolvedOptions.config, - channel: plugin.id, - currentChannelId: resolvedOptions.currentChannelId, - currentThreadTs: resolvedOptions.currentThreadTs, - currentMessageId: resolvedOptions.currentMessageId, - accountId: resolvedOptions.currentAccountId, - sessionKey: resolvedOptions.sessionKey, - sessionId: resolvedOptions.sessionId, - agentId: resolvedOptions.agentId, - requesterSenderId: resolvedOptions.requesterSenderId, - }); + const actions = listChannelSupportedActions( + buildMessageActionDiscoveryInput(messageToolDiscoveryParams, plugin.id), + ); if (actions.length > 0) { const all = new Set(["send", ...actions]); otherChannels.push(`${plugin.id} (${Array.from(all).toSorted().join(", ")})`); @@ -629,8 +586,8 @@ function buildMessageToolDescription(options?: { } // Fallback to generic description with all configured actions - if (resolvedOptions.config) { - const actions = listChannelMessageActions(resolvedOptions.config); + if (messageToolDiscoveryParams) { + const actions = listAllMessageToolActions(messageToolDiscoveryParams); if (actions.length > 0) { return appendMessageToolReadHint( `${baseDescription} Supports actions: ${actions.join(", ")}.`, @@ -678,6 +635,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { sessionId: options.sessionId, agentId: resolvedAgentId, requesterSenderId: options.requesterSenderId, + senderIsOwner: options.senderIsOwner, }) : MessageToolSchema; const description = buildMessageToolDescription({ @@ -691,6 +649,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { sessionId: options?.sessionId, agentId: resolvedAgentId, requesterSenderId: options?.requesterSenderId, + senderIsOwner: options?.senderIsOwner, }); return { @@ -810,6 +769,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { params, defaultAccountId: accountId ?? undefined, requesterSenderId: options?.requesterSenderId, + senderIsOwner: options?.senderIsOwner, gateway, toolContext, sessionKey: options?.agentSessionKey, diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 6cd56491fa2..6436698d67e 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -848,6 +848,7 @@ export async function runAgentTurnWithFallback(params: { skillsSnapshot: params.followupRun.run.skillsSnapshot, messageProvider: params.followupRun.run.messageProvider, agentAccountId: params.followupRun.run.agentAccountId, + senderIsOwner: params.followupRun.run.senderIsOwner, abortSignal: params.replyOperation?.abortSignal ?? params.opts?.abortSignal, replyOperation: params.replyOperation, }); diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index 1d5b6b47ee9..4eeebfb94c4 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -436,4 +436,62 @@ describe("handleInlineActions", () => { ); expect(toolExecute).toHaveBeenCalled(); }); + + it("passes senderIsOwner into inline tool runtimes before owner-only filtering", async () => { + const typing = createTypingController(); + const toolExecute = vi.fn(async () => ({ text: "updated" })); + createOpenClawToolsMock.mockReturnValue([ + { + name: "message", + execute: toolExecute, + }, + ]); + + const ctx = buildTestCtx({ + Body: "/set_profile display name", + CommandBody: "/set_profile display name", + }); + const skillCommands: SkillCommandSpec[] = [ + { + name: "set_profile", + skillName: "matrix-profile", + description: "Set Matrix profile", + dispatch: { + kind: "tool", + toolName: "message", + argMode: "raw", + }, + sourceFilePath: "/tmp/plugin/commands/set-profile.md", + }, + ]; + + const result = await handleInlineActions( + createHandleInlineActionsInput({ + ctx, + typing, + cleanedBody: "/set_profile display name", + command: { + isAuthorizedSender: true, + senderId: "sender-1", + senderIsOwner: true, + abortKey: "sender-1", + rawBodyNormalized: "/set_profile display name", + commandBodyNormalized: "/set_profile display name", + }, + overrides: { + cfg: { commands: { text: true } }, + allowTextCommands: true, + skillCommands, + }, + }), + ); + + expect(result).toEqual({ kind: "reply", reply: { text: "✅ Done." } }); + expect(createOpenClawToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: true, + }), + ); + expect(toolExecute).toHaveBeenCalled(); + }); }); diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 66ef3755e8d..1d1034a6310 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -239,6 +239,7 @@ export async function handleInlineActions(params: { workspaceDir, config: cfg, allowGatewaySubagentBinding: true, + senderIsOwner: command.senderIsOwner, }); const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner); diff --git a/src/channels/plugins/message-action-discovery.ts b/src/channels/plugins/message-action-discovery.ts index 2d10a4930bb..2657bdfd68c 100644 --- a/src/channels/plugins/message-action-discovery.ts +++ b/src/channels/plugins/message-action-discovery.ts @@ -25,6 +25,7 @@ export type ChannelMessageActionDiscoveryInput = { sessionId?: string | null; agentId?: string | null; requesterSenderId?: string | null; + senderIsOwner?: boolean; }; type ChannelActions = NonNullable>["actions"]>; @@ -52,6 +53,7 @@ export function createMessageActionDiscoveryContext( sessionId: params.sessionId, agentId: params.agentId, requesterSenderId: params.requesterSenderId, + senderIsOwner: params.senderIsOwner, }; } @@ -184,6 +186,7 @@ export function listChannelMessageCapabilitiesForChannel(params: { sessionId?: string | null; agentId?: string | null; requesterSenderId?: string | null; + senderIsOwner?: boolean; }): ChannelMessageCapability[] { const channelId = resolveMessageActionDiscoveryChannelId(params.channel); if (!channelId) { @@ -227,6 +230,7 @@ export function resolveChannelMessageToolSchemaProperties(params: { sessionId?: string | null; agentId?: string | null; requesterSenderId?: string | null; + senderIsOwner?: boolean; }): Record { const properties: Record = {}; const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 1ed6ff6b9b6..60926a0a71c 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -46,6 +46,7 @@ export type ChannelMessageActionDiscoveryContext = { sessionId?: string | null; agentId?: string | null; requesterSenderId?: string | null; + senderIsOwner?: boolean; }; /** @@ -600,6 +601,7 @@ export type ChannelMessageActionContext = { * never be sourced from tool/model-controlled params. */ requesterSenderId?: string | null; + senderIsOwner?: boolean; sessionKey?: string | null; sessionId?: string | null; agentId?: string | null; diff --git a/src/commands/message.default-agent.test.ts b/src/commands/message.default-agent.test.ts index 1bf950c8ea1..97f2f9d94a4 100644 --- a/src/commands/message.default-agent.test.ts +++ b/src/commands/message.default-agent.test.ts @@ -140,4 +140,41 @@ describe("messageCommand agent routing", () => { }), ); }); + + it.each([ + { + name: "defaults senderIsOwner to true for local message runs", + opts: {}, + expected: true, + }, + { + name: "honors explicit senderIsOwner override", + opts: { senderIsOwner: false }, + expected: false, + }, + ])("$name", async ({ opts, expected }) => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + await messageCommand( + { + action: "send", + channel: "telegram", + target: "123456", + message: "hi", + json: true, + ...opts, + }, + {} as CliDeps, + runtime, + ); + + expect(runMessageAction).toHaveBeenCalledWith( + expect.objectContaining({ + senderIsOwner: expected, + }), + ); + }); }); diff --git a/src/commands/message.ts b/src/commands/message.ts index 4e5198a1e4e..5a642fb2026 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -56,6 +56,7 @@ export async function messageCommand( const action = actionMatch as ChannelMessageActionName; const outboundDeps: OutboundSendDeps = createOutboundSendDeps(deps); + const senderIsOwner = typeof opts.senderIsOwner === "boolean" ? opts.senderIsOwner : true; const run = async () => await runMessageAction({ @@ -64,6 +65,7 @@ export async function messageCommand( params: opts, deps: outboundDeps, agentId: resolveDefaultAgentId(cfg), + senderIsOwner, gateway: { clientName: GATEWAY_CLIENT_NAMES.CLI, mode: GATEWAY_CLIENT_MODES.CLI, diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index f5e73a4dd69..f1bfaab5c4a 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -119,6 +119,7 @@ export function createCronPromptExecutor(params: { skillsSnapshot: params.skillsSnapshot, bootstrapPromptWarningSignaturesSeen, bootstrapPromptWarningSignature, + senderIsOwner: true, }); bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( result.meta?.systemPromptReport, diff --git a/src/gateway/mcp-http.loopback-runtime.ts b/src/gateway/mcp-http.loopback-runtime.ts index 21a97105559..3d274a6a073 100644 --- a/src/gateway/mcp-http.loopback-runtime.ts +++ b/src/gateway/mcp-http.loopback-runtime.ts @@ -31,6 +31,7 @@ export function createMcpLoopbackServerConfig(port: number) { "x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}", "x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}", "x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}", + "x-openclaw-sender-is-owner": "${OPENCLAW_MCP_SENDER_IS_OWNER}", }, }, }, diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 868e5611272..4e292b754a1 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -1,7 +1,10 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { loadConfig } from "../config/config.js"; import { resolveMainSessionKey } from "../config/sessions.js"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { getHeader } from "./http-utils.js"; @@ -11,6 +14,7 @@ export type McpRequestContext = { sessionKey: string; messageProvider: string | undefined; accountId: string | undefined; + senderIsOwner: boolean | undefined; }; function resolveScopedSessionKey( @@ -92,10 +96,15 @@ export function resolveMcpRequestContext( req: IncomingMessage, cfg: ReturnType, ): McpRequestContext { + const senderIsOwnerRaw = normalizeOptionalLowercaseString( + getHeader(req, "x-openclaw-sender-is-owner"), + ); return { sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")), messageProvider: normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined, accountId: normalizeOptionalString(getHeader(req, "x-openclaw-account-id")), + senderIsOwner: + senderIsOwnerRaw === "true" ? true : senderIsOwnerRaw === "false" ? false : undefined, }; } diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index 725d6afbe4f..23a6390c979 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -30,10 +30,14 @@ export class McpLoopbackToolCache { sessionKey: string; messageProvider: string | undefined; accountId: string | undefined; + senderIsOwner: boolean | undefined; }): CachedScopedTools { - const cacheKey = [params.sessionKey, params.messageProvider ?? "", params.accountId ?? ""].join( - "\u0000", - ); + const cacheKey = [ + params.sessionKey, + params.messageProvider ?? "", + params.accountId ?? "", + params.senderIsOwner === true ? "owner" : params.senderIsOwner === false ? "non-owner" : "", + ].join("\u0000"); const now = Date.now(); const cached = this.#entries.get(cacheKey); if (cached && cached.configRef === params.cfg && now - cached.time < TOOL_CACHE_TTL_MS) { @@ -45,6 +49,7 @@ export class McpLoopbackToolCache { sessionKey: params.sessionKey, messageProvider: params.messageProvider, accountId: params.accountId, + senderIsOwner: params.senderIsOwner, surface: "loopback", excludeToolNames: NATIVE_TOOL_EXCLUDE, }); diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 231b34385ff..27c2a3e6694 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -103,6 +103,48 @@ describe("mcp loopback server", () => { sessionKey: "agent:main:telegram:group:chat123", accountId: "work", messageProvider: "telegram", + senderIsOwner: undefined, + surface: "loopback", + }), + ); + }); + + it("threads senderIsOwner through loopback request context and cache separation", async () => { + server = await startMcpLoopbackServer(0); + const runtime = getActiveMcpLoopbackRuntime(); + + const sendToolsList = async (senderIsOwner: "true" | "false") => + await sendRaw({ + port: server.port, + token: runtime?.token, + headers: { + "content-type": "application/json", + "x-session-key": "agent:main:matrix:dm:test", + "x-openclaw-message-channel": "matrix", + "x-openclaw-sender-is-owner": senderIsOwner, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + + expect((await sendToolsList("true")).status).toBe(200); + expect((await sendToolsList("false")).status).toBe(200); + + expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(2); + expect(resolveGatewayScopedToolsMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sessionKey: "agent:main:matrix:dm:test", + messageProvider: "matrix", + senderIsOwner: true, + surface: "loopback", + }), + ); + expect(resolveGatewayScopedToolsMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sessionKey: "agent:main:matrix:dm:test", + messageProvider: "matrix", + senderIsOwner: false, surface: "loopback", }), ); @@ -154,5 +196,8 @@ describe("createMcpLoopbackServerConfig", () => { expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe( "${OPENCLAW_MCP_MESSAGE_CHANNEL}", ); + expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-sender-is-owner"]).toBe( + "${OPENCLAW_MCP_SENDER_IS_OWNER}", + ); }); }); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 7c0a498f446..759660f3eb7 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -46,6 +46,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ sessionKey: requestContext.sessionKey, messageProvider: requestContext.messageProvider, accountId: requestContext.accountId, + senderIsOwner: requestContext.senderIsOwner, }); const messages = Array.isArray(parsed) ? parsed : [parsed]; diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 27b27034c3a..9dce73a8902 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -35,6 +35,7 @@ export function resolveGatewayScopedTools(params: { surface?: GatewayScopedToolSurface; excludeToolNames?: Iterable; disablePluginTools?: boolean; + senderIsOwner?: boolean; }) { const { agentId, @@ -77,6 +78,7 @@ export function resolveGatewayScopedTools(params: { allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, allowMediaInvokeCommands: params.allowMediaInvokeCommands, disablePluginTools: params.disablePluginTools, + senderIsOwner: params.senderIsOwner, config: params.cfg, workspaceDir, pluginToolAllowlist: collectExplicitAllowlist([ diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index a26958d6cf1..6896f1c9237 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -476,6 +476,28 @@ describe("POST /tools/invoke", () => { expect(body.result).toEqual({ ok: true, result: [] }); }); + it("threads senderIsOwner into tool creation before owner-only filtering", async () => { + setMainAllowedTools({ allow: ["session_status", "owner_only_test"] }); + + const writeRes = await invokeTool({ + port: sharedPort, + headers: gatewayAuthHeaders(), + tool: "session_status", + sessionKey: "main", + }); + expect(writeRes.status).toBe(200); + expect(lastCreateOpenClawToolsContext?.senderIsOwner).toBe(false); + + const adminRes = await invokeTool({ + port: sharedPort, + headers: gatewayAdminHeaders(), + tool: "session_status", + sessionKey: "main", + }); + expect(adminRes.status).toBe(200); + expect(lastCreateOpenClawToolsContext?.senderIsOwner).toBe(true); + }); + it("uses before_tool_call adjusted params for HTTP tool execution", async () => { setMainAllowedTools({ allow: ["tools_invoke_test"] }); hookMocks.runBeforeToolCallHook.mockImplementationOnce(async () => ({ diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 136c928aa7c..d4b4715b487 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -231,6 +231,12 @@ export async function handleToolsInvokeHttpRequest( const accountId = normalizeOptionalString(getHeader(req, "x-openclaw-account-id")); const agentTo = normalizeOptionalString(getHeader(req, "x-openclaw-message-to")); const agentThreadId = normalizeOptionalString(getHeader(req, "x-openclaw-thread-id")); + // Owner semantics intentionally follow the same shared-secret HTTP contract + // on this direct tool surface; SECURITY.md documents this as designed-as-is. + // Computed before resolveGatewayScopedTools so the message tool is created + // with the correct owner context and channel-action gates (e.g. Matrix set-profile) + // work correctly for both owner and non-owner callers. + const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth); const { agentId, tools } = resolveGatewayScopedTools({ cfg, sessionKey, @@ -242,10 +248,8 @@ export async function handleToolsInvokeHttpRequest( allowMediaInvokeCommands: true, surface: "http", disablePluginTools: isKnownCoreToolId(toolName), + senderIsOwner, }); - // Owner semantics intentionally follow the same shared-secret HTTP contract - // on this direct tool surface; SECURITY.md documents this as designed-as-is. - const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth); const gatewayFiltered = applyOwnerOnlyToolPolicy(tools, senderIsOwner); const tool = gatewayFiltered.find((t) => t.name === toolName); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 0f462a92bd3..b0826f5a43a 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -79,6 +79,7 @@ export type RunMessageActionParams = { params: Record; defaultAccountId?: string; requesterSenderId?: string | null; + senderIsOwner?: boolean; sessionId?: string; toolContext?: ChannelThreadingToolContext; gateway?: MessageActionRunnerGateway; @@ -702,6 +703,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise