mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-16 03:31:10 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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") ??
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -35,6 +35,7 @@ export type RunCliAgentParams = {
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
senderIsOwner?: boolean;
|
||||
abortSignal?: AbortSignal;
|
||||
replyOperation?: ReplyOperation;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -677,6 +677,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
sessionId: params.sessionId,
|
||||
agentId: sessionAgentId,
|
||||
senderId: params.senderId,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -655,6 +655,7 @@ export async function runEmbeddedAttempt(
|
||||
sessionId: params.sessionId,
|
||||
agentId: sessionAgentId,
|
||||
senderId: params.senderId,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +239,7 @@ export async function handleInlineActions(params: {
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
allowGatewaySubagentBinding: true,
|
||||
senderIsOwner: command.senderIsOwner,
|
||||
});
|
||||
const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -119,6 +119,7 @@ export function createCronPromptExecutor(params: {
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
bootstrapPromptWarningSignaturesSeen,
|
||||
bootstrapPromptWarningSignature,
|
||||
senderIsOwner: true,
|
||||
});
|
||||
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
|
||||
result.meta?.systemPromptReport,
|
||||
|
||||
@@ -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}",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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}",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user