Skip to content

Commit fe0f686

Browse files
Gate Matrix profile updates for non-owner message tool runs (#62662)
Merged via squash. Prepared head SHA: 602b16a Co-authored-by: eleqtrizit <31522568+eleqtrizit@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
1 parent 1c1fe8a commit fe0f686

38 files changed

Lines changed: 596 additions & 152 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,9 @@ Docs: https://docs.openclaw.ai
288288
- 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.
289289
- 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.
290290
- 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.
291+
- 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.
292+
- 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.
293+
- 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.
291294

292295
## 2026.4.5
293296

extensions/matrix/src/actions.account-propagation.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe("matrixMessageActions account propagation", () => {
9191
await matrixMessageActions.handleAction?.(
9292
createContext({
9393
action: profileAction,
94+
senderIsOwner: true,
9495
accountId: "ops",
9596
params: {
9697
displayName: "Ops Bot",
@@ -111,10 +112,50 @@ describe("matrixMessageActions account propagation", () => {
111112
);
112113
});
113114

115+
it("rejects self-profile updates for non-owner callers", async () => {
116+
await expect(
117+
matrixMessageActions.handleAction?.(
118+
createContext({
119+
action: profileAction,
120+
senderIsOwner: false,
121+
accountId: "ops",
122+
params: {
123+
displayName: "Ops Bot",
124+
},
125+
}),
126+
),
127+
).rejects.toMatchObject({
128+
name: "ToolAuthorizationError",
129+
message: "Matrix profile updates require owner access.",
130+
});
131+
132+
expect(mocks.handleMatrixAction).not.toHaveBeenCalled();
133+
});
134+
135+
it("rejects self-profile updates when owner status is unknown", async () => {
136+
await expect(
137+
matrixMessageActions.handleAction?.(
138+
createContext({
139+
action: profileAction,
140+
accountId: "ops",
141+
params: {
142+
displayName: "Ops Bot",
143+
},
144+
}),
145+
),
146+
).rejects.toMatchObject({
147+
name: "ToolAuthorizationError",
148+
message: "Matrix profile updates require owner access.",
149+
});
150+
151+
expect(mocks.handleMatrixAction).not.toHaveBeenCalled();
152+
});
153+
114154
it("forwards local avatar paths for self-profile updates", async () => {
115155
await matrixMessageActions.handleAction?.(
116156
createContext({
117157
action: profileAction,
158+
senderIsOwner: true,
118159
accountId: "ops",
119160
params: {
120161
path: "/tmp/avatar.jpg",

extensions/matrix/src/actions.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ describe("matrixMessageActions", () => {
7878

7979
const discovery = describeMessageTool({
8080
cfg: createConfiguredMatrixConfig(),
81+
senderIsOwner: true,
8182
} as never);
8283
if (!discovery) {
8384
throw new Error("describeMessageTool returned null");
@@ -96,6 +97,31 @@ describe("matrixMessageActions", () => {
9697
expect(properties.avatarPath).toBeDefined();
9798
});
9899

100+
it("hides self-profile updates for non-owner discovery", () => {
101+
const discovery = matrixMessageActions.describeMessageTool({
102+
cfg: createConfiguredMatrixConfig(),
103+
senderIsOwner: false,
104+
} as never);
105+
if (!discovery) {
106+
throw new Error("describeMessageTool returned null");
107+
}
108+
109+
expect(discovery.actions).not.toContain(profileAction);
110+
expect(discovery.schema).toBeNull();
111+
});
112+
113+
it("hides self-profile updates when owner status is unknown", () => {
114+
const discovery = matrixMessageActions.describeMessageTool({
115+
cfg: createConfiguredMatrixConfig(),
116+
} as never);
117+
if (!discovery) {
118+
throw new Error("describeMessageTool returned null");
119+
}
120+
121+
expect(discovery.actions).not.toContain(profileAction);
122+
expect(discovery.schema).toBeNull();
123+
});
124+
99125
it("hides gated actions when the default Matrix account disables them", () => {
100126
const discovery = matrixMessageActions.describeMessageTool({
101127
cfg: {

extensions/matrix/src/actions.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import {
77
createActionGate,
88
readNumberParam,
99
readStringParam,
10+
ToolAuthorizationError,
1011
type ChannelMessageActionAdapter,
1112
type ChannelMessageActionContext,
1213
type ChannelMessageActionName,
1314
type ChannelMessageToolDiscovery,
14-
type ChannelToolSend,
1515
} from "./runtime-api.js";
1616
import type { CoreConfig } from "./types.js";
1717

@@ -35,6 +35,7 @@ const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
3535
function createMatrixExposedActions(params: {
3636
gate: ReturnType<typeof createActionGate>;
3737
encryptionEnabled: boolean;
38+
senderIsOwner?: boolean;
3839
}) {
3940
const actions = new Set<ChannelMessageActionName>(["poll", "poll-vote"]);
4041
if (params.gate("messages")) {
@@ -52,7 +53,7 @@ function createMatrixExposedActions(params: {
5253
actions.add("unpin");
5354
actions.add("list-pins");
5455
}
55-
if (params.gate("profile")) {
56+
if (params.gate("profile") && params.senderIsOwner === true) {
5657
actions.add("set-profile");
5758
}
5859
if (params.gate("memberInfo")) {
@@ -109,7 +110,7 @@ function buildMatrixProfileToolSchema(): NonNullable<ChannelMessageToolDiscovery
109110
}
110111

111112
export const matrixMessageActions: ChannelMessageActionAdapter = {
112-
describeMessageTool: ({ cfg, accountId }) => {
113+
describeMessageTool: ({ cfg, accountId, senderIsOwner }) => {
113114
const resolvedCfg = cfg as CoreConfig;
114115
if (!accountId && requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
115116
return { actions: [], capabilities: [] };
@@ -125,6 +126,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
125126
const actions = createMatrixExposedActions({
126127
gate,
127128
encryptionEnabled: account.config.encryption === true,
129+
senderIsOwner,
128130
});
129131
const listedActions = Array.from(actions);
130132
return {
@@ -134,7 +136,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
134136
};
135137
},
136138
supportsAction: ({ action }) => MATRIX_PLUGIN_HANDLED_ACTIONS.has(action),
137-
extractToolSend: ({ args }): ChannelToolSend | null => {
139+
extractToolSend: ({ args }) => {
138140
return extractToolSend(args, "sendMessage");
139141
},
140142
handleAction: async (ctx: ChannelMessageActionContext) => {
@@ -259,6 +261,9 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
259261
}
260262

261263
if (action === "set-profile") {
264+
if (ctx.senderIsOwner !== true) {
265+
throw new ToolAuthorizationError("Matrix profile updates require owner access.");
266+
}
262267
const avatarPath =
263268
readStringParam(params, "avatarPath") ??
264269
readStringParam(params, "path") ??

extensions/matrix/src/runtime-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
readReactionParams,
1111
readStringArrayParam,
1212
readStringParam,
13+
ToolAuthorizationError,
1314
} from "openclaw/plugin-sdk/channel-actions";
1415
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
1516
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";

src/agents/channel-tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export function listChannelSupportedActions(params: {
4141
sessionId?: string | null;
4242
agentId?: string | null;
4343
requesterSenderId?: string | null;
44+
senderIsOwner?: boolean;
4445
}): ChannelMessageActionName[] {
4546
const channelId = resolveMessageActionDiscoveryChannelId(params.channel);
4647
if (!channelId) {
@@ -71,6 +72,7 @@ export function listAllChannelSupportedActions(params: {
7172
sessionId?: string | null;
7273
agentId?: string | null;
7374
requesterSenderId?: string | null;
75+
senderIsOwner?: boolean;
7476
}): ChannelMessageActionName[] {
7577
const actions = new Set<ChannelMessageActionName>();
7678
for (const plugin of listChannelPlugins()) {

src/agents/cli-runner.spawn.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import {
6+
clearActiveMcpLoopbackRuntime,
7+
setActiveMcpLoopbackRuntime,
8+
} from "../gateway/mcp-http.loopback-runtime.js";
59
import { onAgentEvent, resetAgentEventsForTest } from "../infra/agent-events.js";
610
import {
711
makeBootstrapWarn as realMakeBootstrapWarn,
812
resolveBootstrapContextForRun as realResolveBootstrapContextForRun,
913
} from "./bootstrap-files.js";
14+
import { runClaudeCliAgent } from "./cli-runner.js";
1015
import {
1116
createManagedRun,
1217
mockSuccessfulCliRun,
1318
restoreCliRunnerPrepareTestDeps,
19+
setupCliRunnerTestRegistry,
1420
supervisorSpawnMock,
1521
} from "./cli-runner.test-support.js";
1622
import { buildCliEnvAuthLog, executePreparedCliRun } from "./cli-runner/execute.js";
@@ -97,6 +103,19 @@ function buildPreparedCliRunContext(params: {
97103
};
98104
}
99105

106+
function createClaudeSuccessRun(sessionId: string) {
107+
return createManagedRun({
108+
reason: "exit",
109+
exitCode: 0,
110+
exitSignal: null,
111+
durationMs: 50,
112+
stdout: JSON.stringify({ message: "ok", session_id: sessionId }),
113+
stderr: "",
114+
timedOut: false,
115+
noOutputTimedOut: false,
116+
});
117+
}
118+
100119
describe("runCliAgent spawn path", () => {
101120
it("does not inject hardcoded 'Tools are disabled' text into CLI arguments", async () => {
102121
supervisorSpawnMock.mockResolvedValueOnce(
@@ -367,6 +386,55 @@ describe("runCliAgent spawn path", () => {
367386
}
368387
});
369388

389+
it("ignores legacy claudeSessionId on the compat wrapper", async () => {
390+
setupCliRunnerTestRegistry();
391+
supervisorSpawnMock.mockResolvedValueOnce(createClaudeSuccessRun("sid-wrapper"));
392+
393+
await runClaudeCliAgent({
394+
sessionId: "openclaw-session",
395+
sessionFile: "/tmp/session.jsonl",
396+
workspaceDir: "/tmp",
397+
prompt: "hi",
398+
model: "opus",
399+
timeoutMs: 1_000,
400+
runId: "run-claude-legacy-wrapper",
401+
claudeSessionId: "c9d7b831-1c31-4d22-80b9-1e50ca207d4b",
402+
});
403+
404+
const input = supervisorSpawnMock.mock.calls[0]?.[0] as { argv?: string[]; input?: string };
405+
expect(input.argv).not.toContain("--resume");
406+
expect(input.argv).not.toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
407+
expect(input.argv).toContain("--session-id");
408+
expect(input.input).toContain("hi");
409+
});
410+
411+
it("forwards senderIsOwner through the compat wrapper into bundle MCP env", async () => {
412+
setupCliRunnerTestRegistry();
413+
setActiveMcpLoopbackRuntime({ port: 23119, token: "loopback-token-123" });
414+
try {
415+
supervisorSpawnMock.mockResolvedValueOnce(createClaudeSuccessRun("sid-owner"));
416+
417+
await runClaudeCliAgent({
418+
sessionId: "openclaw-session",
419+
sessionKey: "agent:main:matrix:room:123",
420+
sessionFile: "/tmp/session.jsonl",
421+
workspaceDir: "/tmp",
422+
prompt: "hi",
423+
model: "opus",
424+
timeoutMs: 1_000,
425+
runId: "run-claude-owner-wrapper",
426+
senderIsOwner: false,
427+
});
428+
429+
const input = supervisorSpawnMock.mock.calls[0]?.[0] as {
430+
env?: Record<string, string | undefined>;
431+
};
432+
expect(input.env?.OPENCLAW_MCP_SENDER_IS_OWNER).toBe("false");
433+
} finally {
434+
clearActiveMcpLoopbackRuntime("loopback-token-123");
435+
}
436+
});
437+
370438
it("runs CLI through supervisor and returns payload", async () => {
371439
supervisorSpawnMock.mockResolvedValueOnce(
372440
createManagedRun({

src/agents/cli-runner.ts

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import type { ImageContent } from "@mariozechner/pi-ai";
2-
import type { ThinkLevel } from "../auto-reply/thinking.js";
3-
import type { OpenClawConfig } from "../config/config.js";
41
import { formatErrorMessage } from "../infra/errors.js";
52
import { executePreparedCliRun } from "./cli-runner/execute.js";
63
import { prepareCliRunContext } from "./cli-runner/prepare.js";
@@ -95,24 +92,14 @@ export async function runPreparedCliAgent(
9592
}
9693
}
9794

98-
export async function runClaudeCliAgent(params: {
99-
sessionId: string;
100-
sessionKey?: string;
101-
agentId?: string;
102-
sessionFile: string;
103-
workspaceDir: string;
104-
config?: OpenClawConfig;
105-
prompt: string;
95+
export type RunClaudeCliAgentParams = Omit<RunCliAgentParams, "provider" | "cliSessionId"> & {
10696
provider?: string;
107-
model?: string;
108-
thinkLevel?: ThinkLevel;
109-
timeoutMs: number;
110-
runId: string;
111-
extraSystemPrompt?: string;
112-
ownerNumbers?: string[];
11397
claudeSessionId?: string;
114-
images?: ImageContent[];
115-
}): Promise<EmbeddedPiRunResult> {
98+
};
99+
100+
export async function runClaudeCliAgent(
101+
params: RunClaudeCliAgentParams,
102+
): Promise<EmbeddedPiRunResult> {
116103
return runCliAgent({
117104
sessionId: params.sessionId,
118105
sessionKey: params.sessionKey,
@@ -128,7 +115,10 @@ export async function runClaudeCliAgent(params: {
128115
runId: params.runId,
129116
extraSystemPrompt: params.extraSystemPrompt,
130117
ownerNumbers: params.ownerNumbers,
131-
cliSessionId: params.claudeSessionId,
118+
// Legacy `claudeSessionId` callers predate the shared CLI session contract.
119+
// Ignore it here so the compatibility wrapper does not accidentally resume
120+
// an incompatible Claude session on the generic runner path.
132121
images: params.images,
122+
senderIsOwner: params.senderIsOwner,
133123
});
134124
}

src/agents/cli-runner/bundle-mcp.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,12 +213,14 @@ describe("prepareCliBundleMcpConfig", () => {
213213
env: {
214214
OPENCLAW_MCP_TOKEN: "loopback-token-123",
215215
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
216+
OPENCLAW_MCP_SENDER_IS_OWNER: "false",
216217
},
217218
});
218219

219220
expect(prepared.env).toEqual({
220221
OPENCLAW_MCP_TOKEN: "loopback-token-123",
221222
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
223+
OPENCLAW_MCP_SENDER_IS_OWNER: "false",
222224
});
223225

224226
await prepared.cleanup?.();
@@ -256,6 +258,7 @@ describe("prepareCliBundleMcpConfig", () => {
256258
headers: {
257259
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
258260
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
261+
"x-openclaw-sender-is-owner": "${OPENCLAW_MCP_SENDER_IS_OWNER}",
259262
},
260263
},
261264
},
@@ -266,14 +269,14 @@ describe("prepareCliBundleMcpConfig", () => {
266269
"exec",
267270
"--json",
268271
"-c",
269-
'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" } } }',
272+
'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" } } }',
270273
]);
271274
expect(prepared.backend.resumeArgs).toEqual([
272275
"exec",
273276
"resume",
274277
"{sessionId}",
275278
"-c",
276-
'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" } } }',
279+
'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" } } }',
277280
]);
278281
expect(prepared.cleanup).toBeUndefined();
279282
});

src/agents/cli-runner/prepare.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export async function prepareCliRunContext(
132132
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
133133
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
134134
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "",
135+
OPENCLAW_MCP_SENDER_IS_OWNER: params.senderIsOwner === true ? "true" : "false",
135136
}
136137
: undefined,
137138
warn: (message) => cliBackendLog.warn(message),

0 commit comments

Comments
 (0)