Gate Matrix profile updates for non-owner message tool runs (#62662)

Merged via squash.

Prepared head SHA: 602b16a676
Co-authored-by: eleqtrizit <31522568+eleqtrizit@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Agustin Rivera
2026-04-10 09:56:17 -07:00
committed by GitHub
parent 1c1fe8a405
commit fe0f686c92
38 changed files with 619 additions and 175 deletions

View File

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

View File

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

View File

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

View File

@@ -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<ChannelMessageActionName>([
function createMatrixExposedActions(params: {
gate: ReturnType<typeof createActionGate>;
encryptionEnabled: boolean;
senderIsOwner?: boolean;
}) {
const actions = new Set<ChannelMessageActionName>(["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<ChannelMessageToolDiscovery
}
export const matrixMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg, accountId }) => {
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") ??

View File

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

View File

@@ -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<ChannelMessageActionName>();
for (const plugin of listChannelPlugins()) {

View File

@@ -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<string, string | undefined>;
};
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({

View File

@@ -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<RunCliAgentParams, "provider" | "cliSessionId"> & {
provider?: string;
model?: string;
thinkLevel?: ThinkLevel;
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
ownerNumbers?: string[];
claudeSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
};
export async function runClaudeCliAgent(
params: RunClaudeCliAgentParams,
): Promise<EmbeddedPiRunResult> {
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,
});
}

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ export type RunCliAgentParams = {
skillsSnapshot?: SkillSnapshot;
messageProvider?: string;
agentAccountId?: string;
senderIsOwner?: boolean;
abortSignal?: AbortSignal;
replyOperation?: ReplyOperation;
};

View File

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

View File

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

View File

@@ -677,6 +677,7 @@ export async function compactEmbeddedPiSessionDirect(
sessionId: params.sessionId,
agentId: sessionAgentId,
senderId: params.senderId,
senderIsOwner: params.senderIsOwner,
}),
)
: undefined;

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ type AttemptSpawnWorkspaceHoisted = {
createAgentSessionMock: UnknownMock;
sessionManagerOpenMock: UnknownMock;
resolveSandboxContextMock: UnknownMock;
buildEmbeddedMessageActionDiscoveryInputMock: UnknownMock;
subscribeEmbeddedPiSessionMock: Mock<SubscribeEmbeddedPiSessionFn>;
acquireSessionWriteLockMock: Mock<AcquireSessionWriteLockFn>;
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());

View File

@@ -655,6 +655,7 @@ export async function runEmbeddedAttempt(
sessionId: params.sessionId,
agentId: sessionAgentId,
senderId: params.senderId,
senderIsOwner: params.senderIsOwner,
}),
)
: undefined;

View File

@@ -260,6 +260,7 @@ async function executeSend(params: {
params?: Record<string, unknown>;
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<string, unknown>[] = [];
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);
});
});

View File

@@ -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<string>(["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<string>(["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<ChannelMessageActionName>(["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<ChannelMessageActionName | "send">(["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<ChannelMessageActionName | "send">(["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,

View File

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

View File

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

View File

@@ -239,6 +239,7 @@ export async function handleInlineActions(params: {
workspaceDir,
config: cfg,
allowGatewaySubagentBinding: true,
senderIsOwner: command.senderIsOwner,
});
const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner);

View File

@@ -25,6 +25,7 @@ export type ChannelMessageActionDiscoveryInput = {
sessionId?: string | null;
agentId?: string | null;
requesterSenderId?: string | null;
senderIsOwner?: boolean;
};
type ChannelActions = NonNullable<NonNullable<ReturnType<typeof getChannelPlugin>>["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<string, TSchema> {
const properties: Record<string, TSchema> = {};
const currentChannel = resolveMessageActionDiscoveryChannelId(params.channel);

View File

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

View File

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

View File

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

View File

@@ -119,6 +119,7 @@ export function createCronPromptExecutor(params: {
skillsSnapshot: params.skillsSnapshot,
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature,
senderIsOwner: true,
});
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,

View File

@@ -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}",
},
},
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ export function resolveGatewayScopedTools(params: {
surface?: GatewayScopedToolSurface;
excludeToolNames?: Iterable<string>;
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([

View File

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

View File

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

View File

@@ -79,6 +79,7 @@ export type RunMessageActionParams = {
params: Record<string, unknown>;
defaultAccountId?: string;
requesterSenderId?: string | null;
senderIsOwner?: boolean;
sessionId?: string;
toolContext?: ChannelThreadingToolContext;
gateway?: MessageActionRunnerGateway;
@@ -702,6 +703,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
mediaReadFile: mediaAccess.readFile,
accountId: accountId ?? undefined,
requesterSenderId: input.requesterSenderId ?? undefined,
senderIsOwner: input.senderIsOwner,
sessionKey: input.sessionKey,
sessionId: input.sessionId,
agentId,