mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
* improve: reuse live placement authority for worker checks * fix(gateway): narrow placement authority read facts * test(state): retain real cache lifecycle exports in reader fixture * test(browser): retain relay fixture port through cleanup * test(gateway): revoke the actual claim before transcript writes
1535 lines
58 KiB
TypeScript
1535 lines
58 KiB
TypeScript
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { discordPlugin } from "../extensions/discord/api.js";
|
|
import { slackPlugin } from "../extensions/slack/api.js";
|
|
import {
|
|
createOperationalRunInstanceRef,
|
|
prepareAgentRunAdmission,
|
|
} from "../src/agents/admitted-run-context.js";
|
|
import { buildPreparedCliRunContext } from "../src/agents/cli-runner.test-helpers.js";
|
|
import { runPlugin, SUCCESS_RESULT } from "../src/agents/cli-runner/execute-plugin.test-support.js";
|
|
import { createCliToolTracking } from "../src/agents/cli-runner/execute-tool-tracking.js";
|
|
import { buildCliMcpGrantContext } from "../src/agents/cli-runner/mcp-grant-context.js";
|
|
import type { RunCliAgentParams } from "../src/agents/cli-runner/types.js";
|
|
import type { ScheduledToolPolicyContext } from "../src/agents/scheduled-tool-policy.js";
|
|
import type { ChannelMessageActionAdapter } from "../src/channels/plugins/types.core.js";
|
|
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../src/config/config.js";
|
|
import type { OpenClawConfig } from "../src/config/types.openclaw.js";
|
|
import { createAgentRuntimeApprovalAuthorityValidator } from "../src/gateway/agent-runtime-approval-authority.js";
|
|
import type { CronAuthenticatedChannelRequester } from "../src/gateway/cron-creator-authority-grant.types.js";
|
|
import {
|
|
activateMcpLoopbackClientGrantCapture,
|
|
deactivateMcpLoopbackClientGrantCapture,
|
|
mintMcpLoopbackClientGrant,
|
|
resolveMcpLoopbackClientGrant,
|
|
revokeMcpLoopbackClientGrant,
|
|
} from "../src/gateway/mcp-grant-store.js";
|
|
import { closeMcpLoopbackServer, ensureMcpLoopbackServer } from "../src/gateway/mcp-http.js";
|
|
import {
|
|
beginMcpLoopbackToolCallCapture,
|
|
clearMcpLoopbackToolCallCapture,
|
|
getActiveMcpLoopbackRuntime,
|
|
} from "../src/gateway/mcp-http.loopback-runtime.js";
|
|
import {
|
|
mintMessageActionTurnCapability,
|
|
revokeMessageActionTurnCapability,
|
|
} from "../src/gateway/message-action-turn-capability.js";
|
|
import { createRequestGatewayMethodRegistry } from "../src/gateway/server-methods.js";
|
|
import type { GatewayRequestContext } from "../src/gateway/server-methods/types.js";
|
|
import { createPluginRegistry } from "../src/plugins/registry.js";
|
|
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../src/plugins/runtime.js";
|
|
import {
|
|
bindGatewayContextResolver,
|
|
clearGatewayContextResolver,
|
|
} from "../src/plugins/runtime/gateway-request-scope.js";
|
|
import type { PluginRuntime } from "../src/plugins/runtime/types.js";
|
|
import { createPluginRecord } from "../src/plugins/status.test-fixtures.js";
|
|
import { trackAsyncWork } from "../src/shared/async-work-scope.js";
|
|
import type { Deferred } from "../src/shared/deferred.js";
|
|
import { closeOpenClawStateDatabaseForTest } from "../src/state/openclaw-state-db.js";
|
|
import { createDeferred, withTestTimeout } from "./helpers/promise.js";
|
|
|
|
// Device inventory is unrelated to the real grant, message tool, and provider boundary.
|
|
vi.mock("../src/agents/node-exec-availability.js", () => ({
|
|
loadNodeExecAvailability: async () => ({ cacheKey: "no-nodes", isAvailable: () => false }),
|
|
}));
|
|
|
|
const channels = {
|
|
discord: { current: "100000000000000003", sender: "100000000000000009" },
|
|
slack: { current: "C0123456789", sender: "U0123456789" },
|
|
};
|
|
const discordSibling = "100000000000000004";
|
|
const discordDm = "100000000000000005";
|
|
const discordGuild = "100000000000000001";
|
|
const discordManagementRole = "100000000000000006";
|
|
const discordMessage = "100000000000000020";
|
|
const discordWorkToken = "synthetic-scheduled-work-token";
|
|
const discordChannelPath = `/api/v10/channels/${channels.discord.current}`;
|
|
const discordMessagePath = `${discordChannelPath}/messages/${discordMessage}`;
|
|
const discordPinPath = `${discordChannelPath}/pins/${discordMessage}`;
|
|
const discordGuildsPath = "/api/v10/users/@me/guilds";
|
|
const discordGuildChannelsPath = `/api/v10/guilds/${discordGuild}/channels`;
|
|
const react = { action: "react", channel: "discord", messageId: discordMessage, emoji: "✅" };
|
|
const channelEdit = {
|
|
action: "channel-edit",
|
|
channel: "discord",
|
|
target: `channel:${channels.discord.current}`,
|
|
topic: "A permitted channel topic",
|
|
};
|
|
const trustedScheduledPolicy: ScheduledToolPolicyContext = { version: 1, mode: "trusted" };
|
|
const accountScheduledPolicy: ScheduledToolPolicyContext = {
|
|
version: 1,
|
|
mode: "account",
|
|
ownerSessionKey: `agent:main:discord:channel:${channels.discord.current}`,
|
|
ownerAccountId: "default",
|
|
ownerOrigin: { kind: "external", channel: "discord" },
|
|
};
|
|
const channelRequester: CronAuthenticatedChannelRequester = {
|
|
version: 1,
|
|
channel: "discord",
|
|
accountId: "default",
|
|
senderId: channels.discord.sender,
|
|
};
|
|
type McpResponse = {
|
|
result?: {
|
|
tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>;
|
|
content?: Array<{ type: string; text?: string }>;
|
|
isError?: boolean;
|
|
};
|
|
error?: unknown;
|
|
};
|
|
|
|
function expectSuccess(response: McpResponse) {
|
|
expect(response.error).toBeUndefined();
|
|
expect(response.result?.isError, JSON.stringify(response)).not.toBe(true);
|
|
const text = response.result?.content?.find((item) => item.type === "text")?.text;
|
|
expect(JSON.parse(text ?? "null")).toMatchObject({ ok: true });
|
|
}
|
|
|
|
function expectDenied(response: McpResponse, reason: RegExp) {
|
|
expect(response.error).toBeUndefined();
|
|
expect(response.result?.isError).toBe(true);
|
|
expect(response.result?.content?.map((item) => item.text).join("\n")).toMatch(reason);
|
|
}
|
|
|
|
describe("CLI message authority integration", () => {
|
|
const requests: Array<{
|
|
method: string;
|
|
path: string;
|
|
fields: Record<string, string>;
|
|
body: string;
|
|
usesWorkCredential: boolean;
|
|
}> = [];
|
|
const cleanupTurns: Array<() => void> = [];
|
|
const providerWork = new Set<Promise<void>>();
|
|
const providerErrors: unknown[] = [];
|
|
let provider: Server;
|
|
let providerOrigin: string;
|
|
let mcpOrigin: string;
|
|
let cfg: OpenClawConfig;
|
|
let initialConfig: OpenClawConfig;
|
|
let registeredDiscordActions: ChannelMessageActionAdapter | undefined;
|
|
let workspaceDir: string;
|
|
let sequence = 0;
|
|
let realFetch: typeof fetch;
|
|
let directoryChannelName: string;
|
|
let gatewaySend = false;
|
|
let acceptedChannelEdits = 0;
|
|
let nextChannelEditStatus: 403 | 429 | undefined;
|
|
let acceptedMessageWrites = 0;
|
|
let nextMessageWriteStatus: 403 | undefined;
|
|
let heldRequest:
|
|
| {
|
|
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
|
path: string;
|
|
entered: Deferred;
|
|
release: Deferred;
|
|
}
|
|
| undefined;
|
|
|
|
async function handleProviderRequest(req: IncomingMessage, res: ServerResponse) {
|
|
const url = new URL(req.url ?? "/", "http://fixture.invalid");
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) {
|
|
chunks.push(Buffer.from(chunk));
|
|
}
|
|
const rawBody = Buffer.concat(chunks).toString();
|
|
const fields = Object.fromEntries(new URLSearchParams(rawBody));
|
|
requests.push({
|
|
method: req.method ?? "",
|
|
path: url.pathname,
|
|
fields,
|
|
body: rawBody,
|
|
usesWorkCredential: req.headers.authorization === `Bot ${discordWorkToken}`,
|
|
});
|
|
const isChannelEdit = req.method === "PATCH" && url.pathname === discordChannelPath;
|
|
const messageWriteTarget = /^\/api\/v10\/channels\/(\d+)\/(messages|pins)\/(\d+)$/.exec(
|
|
url.pathname,
|
|
);
|
|
const isMessageWrite =
|
|
(messageWriteTarget?.[2] === "messages" &&
|
|
(req.method === "PATCH" || req.method === "DELETE")) ||
|
|
(messageWriteTarget?.[2] === "pins" && (req.method === "PUT" || req.method === "DELETE"));
|
|
const rejectedWriteStatus = isChannelEdit
|
|
? nextChannelEditStatus
|
|
: isMessageWrite
|
|
? nextMessageWriteStatus
|
|
: undefined;
|
|
if (isChannelEdit) {
|
|
nextChannelEditStatus = undefined;
|
|
}
|
|
if (isMessageWrite) {
|
|
nextMessageWriteStatus = undefined;
|
|
}
|
|
if (isChannelEdit || isMessageWrite) {
|
|
if (rejectedWriteStatus) {
|
|
res.writeHead(rejectedWriteStatus, {
|
|
"content-type": "application/json",
|
|
...(rejectedWriteStatus === 429 ? { "retry-after": "0.001" } : {}),
|
|
});
|
|
res.flushHeaders();
|
|
} else if (isChannelEdit) {
|
|
// The fixture accepts the mutation before an optional response barrier.
|
|
acceptedChannelEdits += 1;
|
|
} else {
|
|
acceptedMessageWrites += 1;
|
|
}
|
|
}
|
|
const gate = heldRequest;
|
|
if (gate && req.method === gate.method && url.pathname === gate.path) {
|
|
gate.entered.resolve();
|
|
await gate.release.promise;
|
|
if (heldRequest === gate) {
|
|
heldRequest = undefined;
|
|
}
|
|
}
|
|
if (rejectedWriteStatus) {
|
|
res.end(
|
|
JSON.stringify(
|
|
rejectedWriteStatus === 429
|
|
? { message: "Rate limited", retry_after: 0.001, global: false }
|
|
: { message: "Missing Permissions", code: 50013 },
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
let body: unknown;
|
|
if (req.method === "GET" && url.pathname === discordGuildsPath) {
|
|
body = [{ id: discordGuild, name: "Scheduled administration" }];
|
|
} else if (req.method === "GET" && url.pathname === discordGuildChannelsPath) {
|
|
body = [
|
|
{
|
|
id: channels.discord.current,
|
|
type: 0,
|
|
guild_id: discordGuild,
|
|
name: directoryChannelName,
|
|
},
|
|
];
|
|
} else if (req.method === "GET" && /^\/api\/v10\/channels\/\d+$/.test(url.pathname)) {
|
|
const channelId = url.pathname.split("/").at(-1);
|
|
body =
|
|
channelId === discordDm
|
|
? { id: discordDm, type: 1, recipients: [{ id: channels.discord.sender }] }
|
|
: { id: channelId, type: 0, guild_id: discordGuild, name: "allowed" };
|
|
} else if (req.method === "GET" && /\/channels\/\d+\/messages$/.test(url.pathname)) {
|
|
body = [];
|
|
} else if (req.method === "POST" && url.pathname === "/api/v10/users/@me/channels") {
|
|
expect(JSON.parse(rawBody)).toEqual({ recipient_id: channels.discord.sender });
|
|
body = { id: discordDm, type: 1, recipients: [{ id: channels.discord.sender }] };
|
|
} else if (req.method === "POST" && url.pathname === `${discordChannelPath}/messages`) {
|
|
body = {
|
|
id: discordMessage,
|
|
channel_id: channels.discord.current,
|
|
content: "accepted message",
|
|
};
|
|
} else if (req.method === "PUT" && /\/reactions\/[^/]+\/@me$/.test(url.pathname)) {
|
|
res.writeHead(204).end();
|
|
return;
|
|
} else if (isMessageWrite) {
|
|
if (req.method === "PATCH") {
|
|
body = {
|
|
id: messageWriteTarget?.[3],
|
|
channel_id: messageWriteTarget?.[1],
|
|
content: "edited message",
|
|
};
|
|
} else {
|
|
res.writeHead(204).end();
|
|
return;
|
|
}
|
|
} else if (req.method === "GET" && url.pathname === `/api/v10/guilds/${discordGuild}`) {
|
|
body = {
|
|
id: discordGuild,
|
|
owner_id: "100000000000000008",
|
|
roles: [
|
|
{ id: discordGuild, permissions: "0" },
|
|
{ id: discordManagementRole, permissions: "16" }, // Discord MANAGE_CHANNELS.
|
|
],
|
|
};
|
|
} else if (
|
|
req.method === "GET" &&
|
|
url.pathname === `/api/v10/guilds/${discordGuild}/members/${channels.discord.sender}`
|
|
) {
|
|
body = { user: { id: channels.discord.sender }, roles: [discordManagementRole] };
|
|
} else if (req.method === "PATCH" && url.pathname === discordChannelPath) {
|
|
body = {
|
|
id: channels.discord.current,
|
|
type: 0,
|
|
guild_id: discordGuild,
|
|
name: "allowed",
|
|
topic: "A permitted channel topic",
|
|
};
|
|
} else if (url.pathname === "/api/conversations.info") {
|
|
body = { ok: true, channel: { id: fields.channel, is_channel: true, name: "allowed" } };
|
|
} else if (url.pathname === "/api/conversations.history") {
|
|
body = { ok: true, messages: [], has_more: false };
|
|
} else {
|
|
throw new Error(`Unexpected provider request: ${req.method} ${url.pathname}`);
|
|
}
|
|
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const isolatedHome = process.env.OPENCLAW_TEST_HOME;
|
|
if (!isolatedHome) {
|
|
throw new Error("CLI message integration requires the shared isolated test HOME");
|
|
}
|
|
workspaceDir = path.join(isolatedHome, "workspace");
|
|
provider = createServer((req, res) => {
|
|
const work = handleProviderRequest(req, res).catch((error: unknown) => {
|
|
providerErrors.push(error);
|
|
res.destroy();
|
|
});
|
|
providerWork.add(work);
|
|
void work.finally(() => providerWork.delete(work));
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
provider.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const address = provider.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("Expected provider TCP address");
|
|
}
|
|
providerOrigin = `http://127.0.0.1:${address.port}`;
|
|
realFetch = globalThis.fetch.bind(globalThis);
|
|
cfg = {
|
|
agents: { defaults: { workspace: workspaceDir } },
|
|
tools: { allow: ["message"] },
|
|
channels: {
|
|
discord: {
|
|
enabled: true,
|
|
token: "synthetic-message-provider-token",
|
|
groupPolicy: "allowlist",
|
|
guilds: { [discordGuild]: { channels: { "*": { enabled: true } } } },
|
|
},
|
|
slack: {
|
|
enabled: true,
|
|
botToken: "synthetic-message-provider-token",
|
|
groupPolicy: "open",
|
|
dm: { groupEnabled: true },
|
|
},
|
|
},
|
|
};
|
|
initialConfig = cfg;
|
|
setRuntimeConfigSnapshot(cfg, cfg);
|
|
await ensureMcpLoopbackServer(0);
|
|
const runtime = expectDefined(getActiveMcpLoopbackRuntime(), "task-owned MCP runtime");
|
|
mcpOrigin = `http://127.0.0.1:${runtime.port}`;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
cfg = structuredClone(initialConfig);
|
|
directoryChannelName = `scheduled-edit-${++sequence}`;
|
|
registeredDiscordActions = undefined;
|
|
gatewaySend = false;
|
|
acceptedChannelEdits = 0;
|
|
nextChannelEditStatus = undefined;
|
|
acceptedMessageWrites = 0;
|
|
nextMessageWriteStatus = undefined;
|
|
requests.length = 0;
|
|
providerErrors.length = 0;
|
|
vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
if (url.origin === "https://discord.com" && url.pathname.startsWith("/api/v10/")) {
|
|
return realFetch(new URL(`${url.pathname}${url.search}`, providerOrigin), init);
|
|
}
|
|
if (url.origin !== providerOrigin && url.origin !== mcpOrigin) {
|
|
throw new Error(`Unexpected fixture network destination: ${url.origin}`);
|
|
}
|
|
return realFetch(input, init);
|
|
});
|
|
vi.stubEnv("SLACK_API_URL", `${providerOrigin}/api/`);
|
|
for (const key of [
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"ALL_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"all_proxy",
|
|
]) {
|
|
vi.stubEnv(key, undefined);
|
|
}
|
|
registerChannelPlugins();
|
|
setRuntimeConfigSnapshot(cfg, cfg);
|
|
});
|
|
|
|
function registerChannelPlugins(options: { discordOrigin?: "global" | "bundled" } = {}) {
|
|
const owner = createPluginRegistry({
|
|
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
runtime: {} as PluginRuntime,
|
|
activateGlobalSideEffects: false,
|
|
});
|
|
// Installer provenance has its own coverage; the official adapters and gates are real.
|
|
for (const plugin of [discordPlugin, slackPlugin]) {
|
|
const origin = plugin.id === "discord" ? (options.discordOrigin ?? "global") : "global";
|
|
const record = createPluginRecord({
|
|
id: plugin.id,
|
|
origin,
|
|
trustedOfficialInstall: origin === "global",
|
|
});
|
|
owner.registry.plugins.push(record);
|
|
const actions: ChannelMessageActionAdapter | undefined = plugin.actions
|
|
? {
|
|
...plugin.actions,
|
|
// Only the accepted-send case selects the supported Gateway-owned dispatch branch.
|
|
resolveExecutionMode: (params) =>
|
|
gatewaySend && params.action === "send"
|
|
? "gateway"
|
|
: (plugin.actions?.resolveExecutionMode?.(params) ?? "local"),
|
|
}
|
|
: undefined;
|
|
if (plugin.id === "discord") {
|
|
registeredDiscordActions = actions;
|
|
}
|
|
owner.createApi(record, { config: cfg, registrationMode: "full" }).registerChannel({
|
|
plugin: {
|
|
...plugin,
|
|
status: undefined,
|
|
...(actions ? { actions } : {}),
|
|
},
|
|
});
|
|
}
|
|
setActivePluginRegistry(owner.registry);
|
|
}
|
|
|
|
afterEach(async () => {
|
|
heldRequest?.release.resolve();
|
|
heldRequest = undefined;
|
|
for (const cleanup of cleanupTurns.splice(0)) {
|
|
cleanup();
|
|
}
|
|
await Promise.all(providerWork);
|
|
if (providerErrors.length) {
|
|
throw new AggregateError(providerErrors, "Provider fixture failed");
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await closeMcpLoopbackServer();
|
|
provider?.closeAllConnections();
|
|
if (provider?.listening) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
provider.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
closeOpenClawStateDatabaseForTest();
|
|
resetPluginRuntimeStateForTest();
|
|
clearRuntimeConfigSnapshot();
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
function holdProviderRequest(
|
|
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
|
|
requestPath = `${discordChannelPath}${method === "POST" ? "/messages" : ""}`,
|
|
) {
|
|
const gate = {
|
|
method,
|
|
path: requestPath,
|
|
entered: createDeferred(),
|
|
release: createDeferred(),
|
|
};
|
|
heldRequest = gate;
|
|
return {
|
|
entered: (pending?: Promise<McpResponse>) =>
|
|
withTestTimeout(
|
|
pending
|
|
? Promise.race([
|
|
gate.entered.promise,
|
|
pending.then((response) => {
|
|
throw new Error(
|
|
`MCP completed before the provider request: ${JSON.stringify(response)}`,
|
|
);
|
|
}),
|
|
])
|
|
: gate.entered.promise,
|
|
10_000,
|
|
"Expected provider request was not reached",
|
|
),
|
|
release: () => gate.release.resolve(),
|
|
};
|
|
}
|
|
|
|
async function createTurn(
|
|
channel: keyof typeof channels,
|
|
options: {
|
|
splitSession?: boolean;
|
|
bindCapability?: boolean;
|
|
gatewaySend?: boolean;
|
|
discordDm?: boolean;
|
|
scheduledPolicy?: ScheduledToolPolicyContext;
|
|
channelRequester?: CronAuthenticatedChannelRequester;
|
|
agentAccountId?: string;
|
|
} = {},
|
|
) {
|
|
gatewaySend = options.gatewaySend === true;
|
|
const runtime = expectDefined(getActiveMcpLoopbackRuntime(), "active MCP runtime");
|
|
const source = new AbortController();
|
|
const scheduledSource = new AbortController();
|
|
const scheduledPolicy = options.scheduledPolicy;
|
|
const runId = `cli-message-${channel}-${++sequence}`;
|
|
const direct = !scheduledPolicy && channel === "discord" && options.discordDm === true;
|
|
const senderId = channels[channel].sender;
|
|
const currentChannelId = direct ? `user:${channels.discord.sender}` : channels[channel].current;
|
|
const policySessionKey = scheduledPolicy
|
|
? `agent:main:cron:${runId}:run:fixture`
|
|
: direct
|
|
? `agent:main:discord:default:direct:${channels.discord.sender}`
|
|
: `agent:main:${channel}:channel:${channels[channel].current}`;
|
|
const splitSession = !scheduledPolicy && (options.splitSession || direct);
|
|
const run = {
|
|
sessionId: `session-${runId}`,
|
|
sessionKey: splitSession ? "agent:main:main" : policySessionKey,
|
|
sessionFile: path.join(workspaceDir, `${runId}.jsonl`),
|
|
runId,
|
|
workspaceDir,
|
|
...(splitSession ? { runtimePolicySessionKey: policySessionKey } : {}),
|
|
provider: "claude-cli" as const,
|
|
model: "test-model",
|
|
prompt: "Inspect the current conversation.",
|
|
timeoutMs: 60_000,
|
|
...(scheduledPolicy
|
|
? {
|
|
scheduledToolPolicy: scheduledPolicy,
|
|
trigger: "cron" as const,
|
|
agentAccountId: options.agentAccountId,
|
|
}
|
|
: {
|
|
messageProvider: channel,
|
|
messageChannel: channel,
|
|
// Discord CLI ingress receives the user target; the capability keeps the native DM id.
|
|
currentChannelId,
|
|
...(direct ? { chatType: "direct" as const, currentMessageId: discordMessage } : {}),
|
|
agentAccountId: "default",
|
|
senderId,
|
|
}),
|
|
senderIsOwner: false,
|
|
cliToolAvailability: { native: [], openClaw: ["message"] },
|
|
} satisfies RunCliAgentParams;
|
|
const admission = prepareAgentRunAdmission({
|
|
cfg,
|
|
facts: {
|
|
runId,
|
|
agentId: "main",
|
|
ingress: {
|
|
kind: scheduledPolicy ? "schedule" : "system",
|
|
boundary: "cli-message-test",
|
|
state: "present",
|
|
},
|
|
},
|
|
operationalRunInstance: createOperationalRunInstanceRef(runId),
|
|
});
|
|
const admitted = await admission.admit("gateway", runId);
|
|
const directToolContext = direct
|
|
? expectDefined(
|
|
discordPlugin.threading?.buildToolContext?.({
|
|
cfg,
|
|
accountId: "default",
|
|
context: {
|
|
Channel: "discord",
|
|
From: `discord:${senderId}`,
|
|
To: currentChannelId,
|
|
ChatType: "direct",
|
|
NativeChannelId: discordDm,
|
|
CurrentMessageId: discordMessage,
|
|
},
|
|
hasRepliedRef: undefined,
|
|
}),
|
|
"Discord DM threading context",
|
|
)
|
|
: undefined;
|
|
const capability = mintMessageActionTurnCapability({
|
|
agentId: "main",
|
|
runId,
|
|
sessionKey: policySessionKey,
|
|
sourceReplySessionKey: run.sessionKey,
|
|
sessionId: run.sessionId,
|
|
...(scheduledPolicy
|
|
? {
|
|
// This fixture owns the source lifetime; the cron producer is covered separately.
|
|
scheduled: {
|
|
policy: scheduledPolicy,
|
|
assertCurrent: () => scheduledSource.signal.throwIfAborted(),
|
|
...(options.channelRequester ? { channelRequester: options.channelRequester } : {}),
|
|
},
|
|
}
|
|
: {
|
|
requesterAccountId: "default",
|
|
requesterSenderId: senderId,
|
|
toolContext: {
|
|
currentChannelProvider: channel,
|
|
...(directToolContext ?? {
|
|
currentChannelId,
|
|
currentChatType: "channel" as const,
|
|
}),
|
|
},
|
|
}),
|
|
});
|
|
const grant = mintMcpLoopbackClientGrant({
|
|
context: buildCliMcpGrantContext({
|
|
run,
|
|
config: cfg,
|
|
requireExplicitMessageTarget: Boolean(scheduledPolicy),
|
|
agentId: "main",
|
|
modelProvider: "anthropic",
|
|
modelId: "test-model",
|
|
toolsAllow: ["message"],
|
|
}),
|
|
runtimeOwnerToken: runtime.ownerToken,
|
|
admittedRunContext: admitted,
|
|
messageActionTurnCapability: options.bindCapability === false ? undefined : capability,
|
|
abortSignal: source.signal,
|
|
});
|
|
const capture = {
|
|
token: grant.token,
|
|
runtimeOwnerToken: runtime.ownerToken,
|
|
captureKey: `capture-${runId}`,
|
|
};
|
|
const gatewayContext = {
|
|
trackExecution: trackAsyncWork,
|
|
getRuntimeConfig: () => cfg,
|
|
dedupe: new Map(),
|
|
getGatewayMethodRegistry: () => createRequestGatewayMethodRegistry(),
|
|
validateAgentRuntimeApprovalAuthority: createAgentRuntimeApprovalAuthorityValidator(),
|
|
} as GatewayRequestContext;
|
|
bindGatewayContextResolver(admitted, () => gatewayContext);
|
|
cleanupTurns.push(() => {
|
|
source.abort();
|
|
scheduledSource.abort();
|
|
revokeMcpLoopbackClientGrant(grant.token);
|
|
revokeMessageActionTurnCapability(capability);
|
|
clearMcpLoopbackToolCallCapture(capture.captureKey);
|
|
clearGatewayContextResolver(admitted);
|
|
admission.close();
|
|
});
|
|
expect(activateMcpLoopbackClientGrantCapture(capture)).not.toBe(false);
|
|
beginMcpLoopbackToolCallCapture({ captureKey: capture.captureKey, onToolCallResult() {} });
|
|
const rpc = async (
|
|
method: string,
|
|
params?: Record<string, unknown>,
|
|
headers?: Record<string, string>,
|
|
): Promise<McpResponse> => {
|
|
const response = await realFetch(`${mcpOrigin}/mcp`, {
|
|
method: "POST",
|
|
headers: {
|
|
...headers,
|
|
authorization: `Bearer ${grant.token}`,
|
|
"content-type": "application/json",
|
|
"x-openclaw-cli-capture-key": capture.captureKey,
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: ++sequence,
|
|
method,
|
|
...(params ? { params } : {}),
|
|
}),
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
const payload = (await response.json()) as McpResponse;
|
|
expect(response.status, JSON.stringify(payload)).toBe(200);
|
|
return payload;
|
|
};
|
|
// Warm discovery so later calls also exercise the grant-owned tool cache.
|
|
const advertisedMessage = expectDefined(
|
|
(await rpc("tools/list")).result?.tools?.find((tool) => tool.name === "message"),
|
|
"advertised message tool",
|
|
);
|
|
return {
|
|
advertisedMessage,
|
|
capability,
|
|
source,
|
|
capture,
|
|
revokeScheduledPermission: () =>
|
|
scheduledSource.abort(new Error("Scheduled source permission revoked.")),
|
|
runParams: {
|
|
...run,
|
|
agentId: "main",
|
|
config: cfg,
|
|
admittedRunContext: admitted,
|
|
abortSignal: source.signal,
|
|
messageActionTurnCapability: options.bindCapability === false ? undefined : capability,
|
|
},
|
|
isCurrent: () => resolveMcpLoopbackClientGrant(capture)?.isCurrent() === true,
|
|
call: (args: Record<string, unknown>, headers?: Record<string, string>) =>
|
|
rpc("tools/call", { name: "message", arguments: args }, headers),
|
|
};
|
|
}
|
|
|
|
it.each(["discord", "slack"] as const)(
|
|
"reads through an official %s CLI grant",
|
|
async (channel) => {
|
|
const turn = await createTurn(channel, { splitSession: channel === "discord" });
|
|
const target = channel === "discord" ? discordSibling : channels.slack.current;
|
|
if (channel === "discord") {
|
|
expect(turn.runParams.sessionKey).not.toBe(turn.runParams.runtimePolicySessionKey);
|
|
}
|
|
expectSuccess(
|
|
await turn.call({ action: "read", channel, target: `channel:${target}`, limit: 1 }),
|
|
);
|
|
expect(requests).toContainEqual(
|
|
expect.objectContaining(
|
|
channel === "discord"
|
|
? { method: "GET", path: `/api/v10/channels/${target}/messages` }
|
|
: {
|
|
method: "POST",
|
|
path: "/api/conversations.history",
|
|
fields: expect.objectContaining({ channel: target }),
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("reacts with bare, prefixed, and default channel targets while rejecting another conversation", async () => {
|
|
const turn = await createTurn("discord");
|
|
for (const target of [
|
|
`channel:${channels.discord.current}`,
|
|
channels.discord.current,
|
|
undefined,
|
|
]) {
|
|
expectSuccess(await turn.call({ ...react, ...(target ? { target } : {}) }));
|
|
}
|
|
expect(requests.filter((request) => request.method === "PUT")).toEqual([
|
|
expect.objectContaining({
|
|
path: `${discordChannelPath}/messages/${discordMessage}/reactions/%E2%9C%85/@me`,
|
|
}),
|
|
expect.objectContaining({
|
|
path: `${discordChannelPath}/messages/${discordMessage}/reactions/%E2%9C%85/@me`,
|
|
}),
|
|
expect.objectContaining({
|
|
path: `${discordChannelPath}/messages/${discordMessage}/reactions/%E2%9C%85/@me`,
|
|
}),
|
|
]);
|
|
const before = requests.length;
|
|
expectDenied(
|
|
await turn.call(
|
|
{ ...react, target: `channel:${discordSibling}` },
|
|
{ "x-openclaw-current-channel-id": discordSibling },
|
|
),
|
|
/exact current conversation and account/,
|
|
);
|
|
expect(requests).toHaveLength(before);
|
|
});
|
|
|
|
it.each([
|
|
{ selection: "a prefixed native target", target: `channel:${discordDm}` },
|
|
{ selection: "a bare native target", target: discordDm },
|
|
{ selection: "an implicit target", target: undefined },
|
|
])("reacts in a Discord DM with $selection and an implicit account", async ({ target }) => {
|
|
const turn = await createTurn("discord", { discordDm: true });
|
|
expectSuccess(await turn.call({ ...react, ...(target ? { target } : {}) }));
|
|
expect(requests.filter((request) => request.method === "PUT")).toEqual([
|
|
expect.objectContaining({
|
|
path: `/api/v10/channels/${discordDm}/messages/${discordMessage}/reactions/%E2%9C%85/@me`,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("pins the current Discord message without an explicit target", async () => {
|
|
const turn = await createTurn("discord");
|
|
expectSuccess(
|
|
await turn.call({ action: "pin", channel: "discord", messageId: discordMessage }),
|
|
);
|
|
expect(requests.filter((request) => request.method === "PUT")).toEqual([
|
|
expect.objectContaining({ path: `${discordChannelPath}/pins/${discordMessage}` }),
|
|
]);
|
|
});
|
|
|
|
it("deletes an explicit message in the current Discord channel", async () => {
|
|
const turn = await createTurn("discord");
|
|
expectSuccess(
|
|
await turn.call({
|
|
action: "delete",
|
|
channel: "discord",
|
|
target: `channel:${channels.discord.current}`,
|
|
messageId: discordMessage,
|
|
}),
|
|
);
|
|
expect(requests.filter((request) => request.method === "DELETE")).toEqual([
|
|
expect.objectContaining({ path: `${discordChannelPath}/messages/${discordMessage}` }),
|
|
]);
|
|
});
|
|
|
|
it("edits the current Discord channel with the admitted sender's permission", async () => {
|
|
const turn = await createTurn("discord");
|
|
expectSuccess(
|
|
await turn.call({
|
|
action: "channel-edit",
|
|
channel: "discord",
|
|
target: `channel:${channels.discord.current}`,
|
|
topic: "A permitted channel topic",
|
|
}),
|
|
);
|
|
expect(requests).toContainEqual(
|
|
expect.objectContaining({
|
|
method: "GET",
|
|
path: `/api/v10/guilds/${discordGuild}/members/${channels.discord.sender}`,
|
|
}),
|
|
);
|
|
const edits = requests.filter((request) => request.method === "PATCH");
|
|
expect(edits).toHaveLength(1);
|
|
expect(edits[0]?.path).toBe(discordChannelPath);
|
|
expect(JSON.parse(edits[0]?.body ?? "null")).toEqual({ topic: "A permitted channel topic" });
|
|
});
|
|
|
|
describe("scheduled channel-edit consumer", () => {
|
|
it("resolves a channel name through the Discord directory before the scheduled edit", async () => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: trustedScheduledPolicy });
|
|
|
|
expectSuccess(await turn.call({ ...channelEdit, target: `#${directoryChannelName}` }));
|
|
|
|
expect(
|
|
requests.filter(
|
|
(request) =>
|
|
request.path === discordGuildsPath || request.path === discordGuildChannelsPath,
|
|
),
|
|
).toEqual([
|
|
expect.objectContaining({ method: "GET", path: discordGuildsPath }),
|
|
expect.objectContaining({ method: "GET", path: discordGuildChannelsPath }),
|
|
]);
|
|
const edits = requests.filter((request) => request.method === "PATCH");
|
|
expect(edits).toHaveLength(1);
|
|
expect(edits[0]?.path).toBe(discordChannelPath);
|
|
expect(JSON.parse(edits[0]?.body ?? "null")).toEqual({ topic: channelEdit.topic });
|
|
expect(acceptedChannelEdits).toBe(1);
|
|
});
|
|
|
|
it.each([
|
|
{ retirement: "job permission is revoked", retire: "source" },
|
|
{
|
|
retirement: "the selected plugin registration is replaced",
|
|
retire: "registration",
|
|
},
|
|
] as const)("stops channel-name lookup after $retirement", async ({ retire }) => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: trustedScheduledPolicy });
|
|
const firstLookup = holdProviderRequest("GET", discordGuildsPath);
|
|
const pending = turn.call({ ...channelEdit, target: `#${directoryChannelName}` });
|
|
try {
|
|
await firstLookup.entered(pending);
|
|
if (retire === "source") {
|
|
turn.revokeScheduledPermission();
|
|
} else {
|
|
registerChannelPlugins();
|
|
}
|
|
firstLookup.release();
|
|
|
|
expectDenied(await pending, /no longer active|permission revoked/i);
|
|
expect(requests).toEqual([
|
|
expect.objectContaining({ method: "GET", path: discordGuildsPath }),
|
|
]);
|
|
expect(acceptedChannelEdits).toBe(0);
|
|
} finally {
|
|
firstLookup.release();
|
|
await pending.catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ name: "an ordinary account job", identity: {}, namedTarget: false },
|
|
{ name: "an account job with a channel-name target", identity: {}, namedTarget: true },
|
|
{
|
|
name: "an account job with forged owner and sender arguments",
|
|
namedTarget: false,
|
|
identity: {
|
|
senderIsOwner: true,
|
|
requesterSenderId: channels.discord.sender,
|
|
senderUserId: channels.discord.sender,
|
|
conversationReadOrigin: "direct-operator",
|
|
},
|
|
},
|
|
])("does not promote $name to administration", async ({ identity, namedTarget }) => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: accountScheduledPolicy });
|
|
|
|
expectDenied(
|
|
await turn.call({
|
|
...channelEdit,
|
|
...identity,
|
|
target: namedTarget ? `#${directoryChannelName}` : channelEdit.target,
|
|
}),
|
|
/fresh Discord requester authorization/,
|
|
);
|
|
if (namedTarget) {
|
|
expect(requests).toEqual([]);
|
|
}
|
|
expect(requests.filter((request) => request.method === "PATCH")).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
{ origin: "unknown", ownerOrigin: { kind: "unknown" } },
|
|
{ origin: "external Slack", ownerOrigin: { kind: "external", channel: "slack" } },
|
|
] as const)(
|
|
"discovers and invokes the native editor with an $origin read origin",
|
|
async ({ ownerOrigin }) => {
|
|
const discord = expectDefined(cfg.channels?.discord, "configured Discord accounts");
|
|
cfg = {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
discord: {
|
|
...discord,
|
|
defaultAccount: "default",
|
|
accounts: {
|
|
default: { actions: { channels: false } },
|
|
work: { token: discordWorkToken, actions: { channels: true } },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
setRuntimeConfigSnapshot(cfg, cfg);
|
|
const turn = await createTurn("discord", {
|
|
scheduledPolicy: {
|
|
...accountScheduledPolicy,
|
|
ownerAccountId: "work",
|
|
ownerOrigin,
|
|
},
|
|
channelRequester: { ...channelRequester, accountId: "work" },
|
|
agentAccountId: "default",
|
|
});
|
|
expect(turn.advertisedMessage.inputSchema).toMatchObject({
|
|
properties: { action: { enum: expect.arrayContaining(["channel-edit"]) } },
|
|
});
|
|
expect(turn.advertisedMessage.description).toContain("channel-edit");
|
|
expect(turn.runParams.agentAccountId).toBe("default");
|
|
expect(turn.runParams.senderIsOwner).toBe(false);
|
|
expect(turn.runParams).not.toHaveProperty("senderId");
|
|
expect(turn.runParams).not.toHaveProperty("currentChannelId");
|
|
expectSuccess(
|
|
await turn.call({
|
|
...channelEdit,
|
|
target: `#${directoryChannelName}`,
|
|
senderIsOwner: true,
|
|
senderUserId: "100000000000000008",
|
|
}),
|
|
);
|
|
expect(requests).toContainEqual(
|
|
expect.objectContaining({
|
|
method: "GET",
|
|
path: `/api/v10/guilds/${discordGuild}/members/${channelRequester.senderId}`,
|
|
}),
|
|
);
|
|
expect(requests.every((request) => request.usesWorkCredential)).toBe(true);
|
|
expect(acceptedChannelEdits).toBe(1);
|
|
const beforeRead = requests.length;
|
|
expectDenied(
|
|
await turn.call({ action: "read", channel: "discord", target: channelEdit.target }),
|
|
/matching recorded creator origin/,
|
|
);
|
|
expect(requests).toHaveLength(beforeRead);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ ...channelRequester, channel: "slack" },
|
|
{ ...channelRequester, accountId: "another-account" },
|
|
])("does not use a native requester from another channel/account (%j)", async (requester) => {
|
|
const turn = await createTurn("discord", {
|
|
scheduledPolicy: accountScheduledPolicy,
|
|
channelRequester: requester,
|
|
});
|
|
expectDenied(await turn.call(channelEdit), /authenticated requester account and channel/);
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
it("stops the next native permission request when the job grant is revoked", async () => {
|
|
const turn = await createTurn("discord", {
|
|
scheduledPolicy: accountScheduledPolicy,
|
|
channelRequester,
|
|
});
|
|
const permissionRead = holdProviderRequest(
|
|
"GET",
|
|
`/api/v10/guilds/${discordGuild}/members/${channelRequester.senderId}`,
|
|
);
|
|
const pending = turn.call(channelEdit);
|
|
try {
|
|
await permissionRead.entered(pending);
|
|
turn.revokeScheduledPermission();
|
|
permissionRead.release();
|
|
expectDenied(await pending, /no longer active|permission revoked/i);
|
|
expect(acceptedChannelEdits).toBe(0);
|
|
expect(requests.filter((request) => request.method === "PATCH")).toEqual([]);
|
|
} finally {
|
|
permissionRead.release();
|
|
await pending.catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("does not accept scheduled administration authority from tool arguments or headers", async () => {
|
|
const turn = await createTurn("discord", {
|
|
scheduledPolicy: trustedScheduledPolicy,
|
|
bindCapability: false,
|
|
});
|
|
|
|
expectDenied(
|
|
await turn.call(
|
|
{
|
|
...channelEdit,
|
|
senderIsOwner: true,
|
|
requesterSenderId: channels.discord.sender,
|
|
senderUserId: channels.discord.sender,
|
|
messageActionTurnCapability: turn.capability,
|
|
scheduledToolPolicy: trustedScheduledPolicy,
|
|
},
|
|
{ "x-openclaw-message-action-turn-capability": turn.capability },
|
|
),
|
|
/trusted.*sender|operator-authorized/i,
|
|
);
|
|
expect(requests.filter((request) => request.method === "PATCH")).toEqual([]);
|
|
});
|
|
|
|
it("requires the registered adapter's write declaration before editing", async () => {
|
|
const actions = expectDefined(registeredDiscordActions, "registered Discord actions");
|
|
delete actions.writeAuthorityActions;
|
|
const turn = await createTurn("discord", { scheduledPolicy: trustedScheduledPolicy });
|
|
|
|
expectDenied(
|
|
await turn.call({ ...channelEdit, target: `#${directoryChannelName}` }),
|
|
/write authorization support/,
|
|
);
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
it("keeps channel deletion behind the existing trusted sender guard", async () => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: trustedScheduledPolicy });
|
|
|
|
expectDenied(
|
|
await turn.call({
|
|
action: "channel-delete",
|
|
channel: "discord",
|
|
target: channelEdit.target,
|
|
}),
|
|
/trusted.*sender/i,
|
|
);
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
{ change: "unchanged authority", revoke: undefined },
|
|
{ change: "immediate job revocation", revoke: "source" },
|
|
{ change: "prospective action configuration", revoke: "action" },
|
|
{ change: "prospective account configuration", revoke: "account" },
|
|
] as const)("preserves $change across a Discord 429 retry", async ({ revoke }) => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: trustedScheduledPolicy });
|
|
nextChannelEditStatus = 429;
|
|
const rateLimited = holdProviderRequest("PATCH");
|
|
const pending = turn.call(channelEdit);
|
|
try {
|
|
await rateLimited.entered(pending);
|
|
expect(acceptedChannelEdits).toBe(0);
|
|
if (revoke === "source") {
|
|
turn.revokeScheduledPermission();
|
|
} else if (revoke) {
|
|
const discord = expectDefined(cfg.channels?.discord, "configured Discord account");
|
|
cfg = {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
discord: {
|
|
...discord,
|
|
...(revoke === "action"
|
|
? { actions: { ...discord.actions, channels: false } }
|
|
: {
|
|
accounts: {
|
|
...discord.accounts,
|
|
default: { ...discord.accounts?.default, enabled: false },
|
|
},
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
setRuntimeConfigSnapshot(cfg, cfg);
|
|
}
|
|
rateLimited.release();
|
|
|
|
const result = await pending;
|
|
if (revoke === "source") {
|
|
expectDenied(result, /agent runtime authority is no longer active/);
|
|
} else {
|
|
expectSuccess(result);
|
|
}
|
|
expect(requests.filter((request) => request.method === "PATCH")).toHaveLength(
|
|
revoke === "source" ? 1 : 2,
|
|
);
|
|
expect(acceptedChannelEdits).toBe(revoke === "source" ? 0 : 1);
|
|
if (revoke === "action" || revoke === "account") {
|
|
const requestCount = requests.length;
|
|
expectDenied(
|
|
await turn.call({ ...channelEdit, target: `#${directoryChannelName}` }),
|
|
/disabled/,
|
|
);
|
|
expect(requests).toHaveLength(requestCount);
|
|
expect(requests.filter((request) => request.method === "PATCH")).toHaveLength(2);
|
|
expect(acceptedChannelEdits).toBe(1);
|
|
}
|
|
} finally {
|
|
rateLimited.release();
|
|
await pending.catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it.each(["operator", "native-account"] as const)(
|
|
"settles an accepted %s edit after scheduled permission is revoked without replay",
|
|
async (kind) => {
|
|
const turn = await createTurn(
|
|
"discord",
|
|
kind === "native-account"
|
|
? { scheduledPolicy: accountScheduledPolicy, channelRequester }
|
|
: { scheduledPolicy: trustedScheduledPolicy },
|
|
);
|
|
const accepted = holdProviderRequest("PATCH");
|
|
const pending = turn.call(channelEdit);
|
|
try {
|
|
await accepted.entered(pending);
|
|
expect(acceptedChannelEdits).toBe(1);
|
|
turn.revokeScheduledPermission();
|
|
accepted.release();
|
|
|
|
expectSuccess(await pending);
|
|
expectDenied(await turn.call(channelEdit), /Scheduled source permission revoked/);
|
|
expect(requests.filter((request) => request.method === "PATCH")).toHaveLength(1);
|
|
expect(acceptedChannelEdits).toBe(1);
|
|
} finally {
|
|
accepted.release();
|
|
await pending.catch(() => undefined);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("reports provider permission denial without retrying the edit", async () => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: trustedScheduledPolicy });
|
|
nextChannelEditStatus = 403;
|
|
|
|
expectDenied(await turn.call(channelEdit), /Missing Permissions/);
|
|
expect(requests.filter((request) => request.method === "PATCH")).toHaveLength(1);
|
|
expect(acceptedChannelEdits).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("does not accept a private capability supplied through child arguments or headers", async () => {
|
|
const turn = await createTurn("discord", { bindCapability: false });
|
|
expect(turn.isCurrent()).toBe(true);
|
|
expectDenied(
|
|
await turn.call(
|
|
{
|
|
action: "read",
|
|
channel: "discord",
|
|
target: `channel:${channels.discord.current}`,
|
|
messageActionTurnCapability: turn.capability,
|
|
conversationReadOrigin: "direct-operator",
|
|
},
|
|
{ "x-openclaw-message-action-turn-capability": turn.capability },
|
|
),
|
|
/current provider and account context/,
|
|
);
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
it("rejects a revoked capability after warming the MCP tool cache", async () => {
|
|
const turn = await createTurn("discord");
|
|
revokeMessageActionTurnCapability(turn.capability);
|
|
expect(turn.isCurrent()).toBe(true);
|
|
expectDenied(await turn.call(react), /turn capability.*no longer active/);
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
it("cancels a provider lookup before a reaction can be written", async () => {
|
|
const turn = await createTurn("discord");
|
|
const lookup = holdProviderRequest("GET");
|
|
const response = turn.call({ ...react, target: `channel:${channels.discord.current}` });
|
|
try {
|
|
await lookup.entered();
|
|
turn.source.abort();
|
|
} finally {
|
|
lookup.release();
|
|
}
|
|
expectDenied(await response, /abort|cancel|no longer active/i);
|
|
expect(requests).toContainEqual(
|
|
expect.objectContaining({ method: "GET", path: discordChannelPath }),
|
|
);
|
|
expect(requests.every((request) => request.method === "GET")).toBe(true);
|
|
});
|
|
|
|
it("retains an accepted Gateway send result after normal CLI completion", async () => {
|
|
const turn = await createTurn("discord", { gatewaySend: true, bindCapability: false });
|
|
const heldSend = holdProviderRequest("POST");
|
|
const context = buildPreparedCliRunContext({
|
|
...turn.runParams,
|
|
backend: { command: "/bin/sh", args: [] },
|
|
});
|
|
context.params = turn.runParams;
|
|
context.preparedBackend.mcpClientGrantCapture = {
|
|
transportToken: turn.capture.token,
|
|
adoptProcessToken: vi.fn(),
|
|
revokeProcessToken: vi.fn(),
|
|
activate: (captureKey, assertCurrent) => {
|
|
activateMcpLoopbackClientGrantCapture({ ...turn.capture, captureKey, assertCurrent });
|
|
},
|
|
deactivate: (captureKey) => {
|
|
deactivateMcpLoopbackClientGrantCapture({ ...turn.capture, captureKey });
|
|
},
|
|
};
|
|
const tracking = createCliToolTracking(context);
|
|
const toolCallId = "accepted-gateway-send";
|
|
const args = {
|
|
action: "send",
|
|
channel: "discord",
|
|
target: `channel:${channels.discord.current}`,
|
|
message: "Preserve the accepted message.",
|
|
};
|
|
let pendingSend: ReturnType<typeof turn.call> | undefined;
|
|
let nativeSignal: AbortSignal | undefined;
|
|
let drain: Promise<void> | undefined;
|
|
const recordRunError = vi.fn();
|
|
try {
|
|
await expect(
|
|
runPlugin(
|
|
context,
|
|
async function* (execution) {
|
|
nativeSignal = execution.abortSignal;
|
|
tracking.handleCliToolUseStart({
|
|
toolCallId,
|
|
name: "mcp__openclaw__message",
|
|
kind: "mcp_tool_use",
|
|
args,
|
|
});
|
|
pendingSend = turn.call(args);
|
|
void pendingSend.catch(() => undefined);
|
|
await heldSend.entered();
|
|
yield { ...SUCCESS_RESULT, session_id: turn.runParams.sessionId };
|
|
},
|
|
{
|
|
sessionId: turn.runParams.sessionId,
|
|
activeToolCount: () => (pendingSend ? 1 : 0),
|
|
mcpCapture: {
|
|
captureKey: turn.capture.captureKey,
|
|
beginCapture: tracking.beginGatewayCapture,
|
|
},
|
|
},
|
|
),
|
|
).resolves.toMatchObject({ reason: "exit", exitCode: 0 });
|
|
expect(nativeSignal?.aborted).toBe(true);
|
|
drain = tracking.finishDeliveryTracking({
|
|
useManagedClaudeLiveSession: false,
|
|
recordRunError,
|
|
});
|
|
heldSend.release();
|
|
const response = await expectDefined(pendingSend, "accepted MCP message request");
|
|
await drain;
|
|
expect(response).toMatchObject({ result: { isError: false } });
|
|
expect(JSON.stringify(response)).toContain(discordMessage);
|
|
expect(tracking.resolveCliLoopbackTerminalOutcome(toolCallId)).toEqual({
|
|
outcome: "completed",
|
|
});
|
|
expect(tracking.withExecutionEvidence({ text: "completed" }).didSendViaMessagingTool).toBe(
|
|
true,
|
|
);
|
|
expect(recordRunError).not.toHaveBeenCalled();
|
|
expect(requests.filter((request) => request.method === "POST")).toHaveLength(1);
|
|
} finally {
|
|
heldSend.release();
|
|
await pendingSend?.catch(() => undefined);
|
|
await drain;
|
|
tracking.finalizeCapture(() => {});
|
|
}
|
|
expect(turn.isCurrent()).toBe(false);
|
|
});
|
|
|
|
describe("scheduled message management", () => {
|
|
const messageTarget = {
|
|
channel: "discord",
|
|
target: `channel:${channels.discord.current}`,
|
|
messageId: discordMessage,
|
|
};
|
|
const editedContent = "A permitted scheduled message edit";
|
|
const writes = [
|
|
{ action: "edit", method: "PATCH", path: discordMessagePath },
|
|
{ action: "delete", method: "DELETE", path: discordMessagePath },
|
|
{ action: "pin", method: "PUT", path: discordPinPath },
|
|
{ action: "unpin", method: "DELETE", path: discordPinPath },
|
|
] as const;
|
|
|
|
it.each(
|
|
[
|
|
{ principal: "trusted", scheduledPolicy: trustedScheduledPolicy },
|
|
{ principal: "account", scheduledPolicy: accountScheduledPolicy },
|
|
].flatMap(({ principal, scheduledPolicy }) =>
|
|
writes.map(({ action, method, path: requestPath }) => ({
|
|
principal,
|
|
scheduledPolicy,
|
|
action,
|
|
method,
|
|
path: requestPath,
|
|
})),
|
|
),
|
|
)(
|
|
"dispatches $action through the provider route for a $principal scheduled job",
|
|
async ({ scheduledPolicy, action, method, path: requestPath }) => {
|
|
const turn = await createTurn("discord", { scheduledPolicy });
|
|
|
|
expectSuccess(
|
|
await turn.call({
|
|
...messageTarget,
|
|
action,
|
|
...(action === "edit" ? { message: editedContent } : {}),
|
|
}),
|
|
);
|
|
|
|
const mutations = requests.filter((request) => request.method !== "GET");
|
|
expect(mutations).toEqual([expect.objectContaining({ method, path: requestPath })]);
|
|
if (action === "edit") {
|
|
expect(JSON.parse(mutations[0]?.body ?? "null")).toEqual({ content: editedContent });
|
|
} else {
|
|
expect(mutations[0]?.body).toBe("");
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ action: "edit", gate: "messages" },
|
|
{ action: "pin", gate: "pins" },
|
|
] as const)("retains the $gate action gate for scheduled $action", async ({ action, gate }) => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: accountScheduledPolicy });
|
|
const discord = expectDefined(cfg.channels?.discord, "configured Discord account");
|
|
cfg = {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
discord: { ...discord, actions: { ...discord.actions, [gate]: false } },
|
|
},
|
|
};
|
|
setRuntimeConfigSnapshot(cfg, cfg);
|
|
|
|
expectDenied(
|
|
await turn.call({
|
|
...messageTarget,
|
|
action,
|
|
...(action === "edit" ? { message: editedContent } : {}),
|
|
}),
|
|
/message edits are disabled|pins are disabled|is disabled for this account/,
|
|
);
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
boundary: "another configured account",
|
|
scheduledPolicy: accountScheduledPolicy,
|
|
args: { accountId: "other" },
|
|
reason: /cannot use another creator account/,
|
|
},
|
|
{
|
|
boundary: "a forbidden target for an account job",
|
|
scheduledPolicy: accountScheduledPolicy,
|
|
args: { target: `channel:${discordSibling}` },
|
|
reason: /Discord read target channel is not allowed/,
|
|
},
|
|
{
|
|
boundary: "a forbidden target for a trusted job",
|
|
scheduledPolicy: trustedScheduledPolicy,
|
|
args: { target: `channel:${discordSibling}` },
|
|
reason: /Discord read target channel is not allowed/,
|
|
},
|
|
])("denies $boundary before message mutation", async ({ scheduledPolicy, args, reason }) => {
|
|
const discord = expectDefined(cfg.channels?.discord, "configured Discord account");
|
|
cfg = {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
discord: {
|
|
...discord,
|
|
accounts: {
|
|
...discord.accounts,
|
|
other: { token: "synthetic-other-message-provider-token" },
|
|
},
|
|
guilds: {
|
|
[discordGuild]: {
|
|
channels: {
|
|
[channels.discord.current]: { enabled: true },
|
|
[discordSibling]: { enabled: false },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
setRuntimeConfigSnapshot(cfg, cfg);
|
|
const turn = await createTurn("discord", { scheduledPolicy });
|
|
|
|
expectDenied(await turn.call({ ...messageTarget, action: "delete", ...args }), reason);
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([]);
|
|
});
|
|
|
|
it("allows an account job to pin in another conversation permitted by Discord policy", async () => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: accountScheduledPolicy });
|
|
|
|
expectSuccess(
|
|
await turn.call({ ...messageTarget, action: "pin", target: `channel:${discordSibling}` }),
|
|
);
|
|
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([
|
|
expect.objectContaining({
|
|
method: "PUT",
|
|
path: `/api/v10/channels/${discordSibling}/pins/${discordMessage}`,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("resolves a channel-name target for an account-bound pin", async () => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: accountScheduledPolicy });
|
|
|
|
expectSuccess(
|
|
await turn.call({
|
|
...messageTarget,
|
|
action: "pin",
|
|
target: `#${directoryChannelName}`,
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
requests.filter(
|
|
(request) =>
|
|
request.path === discordGuildsPath || request.path === discordGuildChannelsPath,
|
|
),
|
|
).toEqual([
|
|
expect.objectContaining({ method: "GET", path: discordGuildsPath }),
|
|
expect.objectContaining({ method: "GET", path: discordGuildChannelsPath }),
|
|
]);
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([
|
|
expect.objectContaining({ method: "PUT", path: discordPinPath }),
|
|
]);
|
|
});
|
|
|
|
it("settles an accepted delete after job permission revocation and blocks the next call", async () => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: accountScheduledPolicy });
|
|
const accepted = holdProviderRequest("DELETE", discordMessagePath);
|
|
const args = { ...messageTarget, action: "delete" };
|
|
const pending = turn.call(args);
|
|
try {
|
|
await accepted.entered(pending);
|
|
expect(acceptedMessageWrites).toBe(1);
|
|
turn.revokeScheduledPermission();
|
|
accepted.release();
|
|
|
|
expectSuccess(await pending);
|
|
const requestCount = requests.length;
|
|
expectDenied(await turn.call(args), /Scheduled source permission revoked/);
|
|
expect(requests).toHaveLength(requestCount);
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([
|
|
expect.objectContaining({ method: "DELETE", path: discordMessagePath }),
|
|
]);
|
|
expect(acceptedMessageWrites).toBe(1);
|
|
} finally {
|
|
accepted.release();
|
|
await pending.catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it("reports a provider-denied unpin without another mutation attempt", async () => {
|
|
const turn = await createTurn("discord", { scheduledPolicy: accountScheduledPolicy });
|
|
nextMessageWriteStatus = 403;
|
|
|
|
expectDenied(await turn.call({ ...messageTarget, action: "unpin" }), /Missing Permissions/);
|
|
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([
|
|
expect.objectContaining({ method: "DELETE", path: discordPinPath }),
|
|
]);
|
|
});
|
|
|
|
it("preserves installed interactive pin admission without a write declaration", async () => {
|
|
const actions = expectDefined(registeredDiscordActions, "registered Discord actions");
|
|
delete actions.writeAuthorityActions;
|
|
const turn = await createTurn("discord");
|
|
|
|
expectSuccess(await turn.call({ ...messageTarget, action: "pin" }));
|
|
const requestCount = requests.length;
|
|
expectDenied(
|
|
await turn.call({ ...messageTarget, action: "pin", target: `channel:${discordSibling}` }),
|
|
/exact current conversation and account/,
|
|
);
|
|
const scheduledTurn = await createTurn("discord", {
|
|
scheduledPolicy: accountScheduledPolicy,
|
|
});
|
|
expectDenied(
|
|
await scheduledTurn.call({ ...messageTarget, action: "pin" }),
|
|
/write authorization support/,
|
|
);
|
|
|
|
expect(requests).toHaveLength(requestCount);
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([
|
|
expect.objectContaining({ method: "PUT", path: discordPinPath }),
|
|
]);
|
|
});
|
|
|
|
it("keeps bundled interactive pins but denies undeclared scheduled writes", async () => {
|
|
registerChannelPlugins({ discordOrigin: "bundled" });
|
|
const actions = expectDefined(registeredDiscordActions, "bundled Discord actions");
|
|
delete actions.writeAuthorityActions;
|
|
|
|
const interactiveTurn = await createTurn("discord");
|
|
expectSuccess(
|
|
await interactiveTurn.call({
|
|
...messageTarget,
|
|
action: "pin",
|
|
target: `channel:${discordSibling}`,
|
|
}),
|
|
);
|
|
const requestCount = requests.length;
|
|
const mismatchedOriginPolicy: ScheduledToolPolicyContext = {
|
|
...accountScheduledPolicy,
|
|
ownerSessionKey: `agent:main:slack:channel:${channels.slack.current}`,
|
|
ownerOrigin: { kind: "external", channel: "slack" },
|
|
};
|
|
|
|
const mismatchedOriginTurn = await createTurn("discord", {
|
|
scheduledPolicy: mismatchedOriginPolicy,
|
|
});
|
|
expectDenied(
|
|
await mismatchedOriginTurn.call({
|
|
...messageTarget,
|
|
action: "pin",
|
|
target: `channel:${discordSibling}`,
|
|
}),
|
|
/matching recorded creator origin/,
|
|
);
|
|
const undeclaredTurn = await createTurn("discord", {
|
|
scheduledPolicy: accountScheduledPolicy,
|
|
});
|
|
expectDenied(
|
|
await undeclaredTurn.call({
|
|
...messageTarget,
|
|
action: "pin",
|
|
target: `channel:${discordSibling}`,
|
|
}),
|
|
/write authorization support/,
|
|
);
|
|
expect(requests).toHaveLength(requestCount);
|
|
expect(requests.filter((request) => request.method !== "GET")).toEqual([
|
|
expect.objectContaining({
|
|
method: "PUT",
|
|
path: `/api/v10/channels/${discordSibling}/pins/${discordMessage}`,
|
|
}),
|
|
]);
|
|
});
|
|
});
|
|
});
|