Download test/cli-message-authority.integration.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 29.3 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/test/cli-message-authority.integration.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/test/cli-message-authority.integration.test.ts
-
curl -L -o cli-message-authority.integration.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/test/cli-message-authority.integration.test.ts
29.3 kB
| 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 { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../src/config/config.js"; | |
| import type { OpenClawConfig } from "../src/config/types.openclaw.js"; | |
| import { createAgentRuntimeApprovalAuthorityValidator } from "../src/gateway/agent-runtime-identity-token.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 discordChannelPath = `/api/v10/channels/${channels.discord.current}`; | |
| const react = { action: "react", channel: "discord", messageId: discordMessage, emoji: "✅" }; | |
| type McpResponse = { | |
| result?: { | |
| tools?: Array<{ name: string }>; | |
| 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; | |
| }> = []; | |
| 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 workspaceDir: string; | |
| let sequence = 0; | |
| let realFetch: typeof fetch; | |
| let gatewaySend = false; | |
| let heldRequest: { method: "GET" | "POST"; 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 }); | |
| const gate = heldRequest; | |
| if ( | |
| gate && | |
| req.method === gate.method && | |
| url.pathname === `${discordChannelPath}${gate.method === "POST" ? "/messages" : ""}` | |
| ) { | |
| gate.entered.resolve(); | |
| await gate.release.promise; | |
| if (heldRequest === gate) { | |
| heldRequest = undefined; | |
| } | |
| } | |
| let body: unknown; | |
| 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 ( | |
| (req.method === "PUT" && url.pathname === `${discordChannelPath}/pins/${discordMessage}`) || | |
| (req.method === "DELETE" && | |
| url.pathname === `${discordChannelPath}/messages/${discordMessage}`) | |
| ) { | |
| 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 }, | |
| }, | |
| }, | |
| }; | |
| setRuntimeConfigSnapshot(cfg, cfg); | |
| await ensureMcpLoopbackServer(0); | |
| const runtime = expectDefined(getActiveMcpLoopbackRuntime(), "task-owned MCP runtime"); | |
| mcpOrigin = `http://127.0.0.1:${runtime.port}`; | |
| }); | |
| beforeEach(() => { | |
| gatewaySend = false; | |
| 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); | |
| } | |
| 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 record = createPluginRecord({ | |
| id: plugin.id, | |
| origin: "global", | |
| trustedOfficialInstall: true, | |
| }); | |
| owner.registry.plugins.push(record); | |
| owner.createApi(record, { config: cfg, registrationMode: "full" }).registerChannel({ | |
| plugin: { | |
| ...plugin, | |
| status: undefined, | |
| ...(plugin.actions | |
| ? { | |
| 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"), | |
| }, | |
| } | |
| : {}), | |
| }, | |
| }); | |
| } | |
| setActivePluginRegistry(owner.registry); | |
| setRuntimeConfigSnapshot(cfg, cfg); | |
| }); | |
| 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") { | |
| const gate = { method, entered: createDeferred(), release: createDeferred() }; | |
| heldRequest = gate; | |
| return { | |
| entered: () => | |
| withTestTimeout(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; | |
| } = {}, | |
| ) { | |
| gatewaySend = options.gatewaySend === true; | |
| const runtime = expectDefined(getActiveMcpLoopbackRuntime(), "active MCP runtime"); | |
| const source = new AbortController(); | |
| const runId = `cli-message-${channel}-${++sequence}`; | |
| const direct = channel === "discord" && options.discordDm === true; | |
| const policySessionKey = direct | |
| ? `agent:main:discord:default:direct:${channels.discord.sender}` | |
| : `agent:main:${channel}:channel:${channels[channel].current}`; | |
| const splitSession = 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, | |
| messageProvider: channel, | |
| messageChannel: channel, | |
| // Discord CLI ingress receives the user target; the capability keeps the native DM id. | |
| currentChannelId: direct ? `user:${channels.discord.sender}` : channels[channel].current, | |
| ...(direct ? { chatType: "direct" as const, currentMessageId: discordMessage } : {}), | |
| agentAccountId: "default", | |
| senderId: channels[channel].sender, | |
| senderIsOwner: false, | |
| cliToolAvailability: { native: [], openClaw: ["message"] }, | |
| } satisfies RunCliAgentParams; | |
| const admission = prepareAgentRunAdmission({ | |
| cfg, | |
| facts: { | |
| runId, | |
| agentId: "main", | |
| ingress: { kind: "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:${run.senderId}`, | |
| To: run.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, | |
| requesterAccountId: "default", | |
| requesterSenderId: run.senderId, | |
| toolContext: { | |
| currentChannelProvider: channel, | |
| ...(directToolContext ?? { | |
| currentChannelId: run.currentChannelId, | |
| currentChatType: "channel" as const, | |
| }), | |
| }, | |
| }); | |
| const grant = mintMcpLoopbackClientGrant({ | |
| context: buildCliMcpGrantContext({ | |
| run, | |
| config: cfg, | |
| requireExplicitMessageTarget: false, | |
| 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(); | |
| 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. | |
| expect((await rpc("tools/list")).result?.tools?.some((tool) => tool.name === "message")).toBe( | |
| true, | |
| ); | |
| return { | |
| capability, | |
| source, | |
| capture, | |
| 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" }); | |
| }); | |
| 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); | |
| }); | |
| }); | |