From f23d9049a79d275a08acdbab649d7813fe01086e Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 14:14:39 +0000 Subject: [PATCH 1/5] fix: bypass DM sessions without metadata and make tool globally visible 1. DM bypass: when neither senderId nor channelId can be extracted from the prompt (DM sessions lack untrusted conversation info), skip the no-reply gate and allow the message through with end-marker injection. 2. Tool visibility: change whispergateway_tools registration from optional=true to optional=false so all agents can see the tool without needing explicit tools.allow entries. --- plugin/index.ts | 2 +- plugin/rules.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugin/index.ts b/plugin/index.ts index 0561df2..540a538 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -355,7 +355,7 @@ export default { return { content: [{ type: "text", text: `unsupported action: ${action}` }], isError: true }; }, }, - { optional: true }, + { optional: false }, ); api.on("message_received", async (event, ctx) => { diff --git a/plugin/rules.ts b/plugin/rules.ts index 42b50d1..ccaacd8 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -73,6 +73,12 @@ export function evaluateDecision(params: { return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: false, reason: "non_discord" }; } + // DM bypass: if no conversation info was found in the prompt (no senderId AND no channelId), + // this is a DM session where untrusted metadata is not injected. Always allow through. + if (!params.senderId && !params.channelId) { + return { shouldUseNoReply: false, shouldInjectEndMarkerPrompt: true, reason: "dm_no_metadata_bypass" }; + } + const policy = resolvePolicy(config, params.channelId, params.channelPolicies); const mode = policy.listMode; From f74b3978e77fb30cb588958897d1ab7c450f7f2d Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 14:25:12 +0000 Subject: [PATCH 2/5] fix(installer): resolve plugin path relative to repo instead of hardcoded operator path PLUGIN_PATH defaulted to /root/.openclaw/workspace-operator/... regardless of which workspace the installer was run from. Now resolves relative to the script location (../dist/whispergate). --- scripts/install-whispergate-openclaw.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/install-whispergate-openclaw.mjs b/scripts/install-whispergate-openclaw.mjs index 73d7ad9..be64876 100755 --- a/scripts/install-whispergate-openclaw.mjs +++ b/scripts/install-whispergate-openclaw.mjs @@ -13,7 +13,8 @@ const mode = modeArg === "--install" ? "install" : "uninstall"; const env = process.env; const OPENCLAW_CONFIG_PATH = env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json"); -const PLUGIN_PATH = env.PLUGIN_PATH || "/root/.openclaw/workspace-operator/WhisperGate/dist/whispergate"; +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const PLUGIN_PATH = env.PLUGIN_PATH || path.resolve(__dirname, "..", "dist", "whispergate"); const NO_REPLY_PROVIDER_ID = env.NO_REPLY_PROVIDER_ID || "whisper-gateway"; const NO_REPLY_MODEL_ID = env.NO_REPLY_MODEL_ID || "no-reply"; const NO_REPLY_BASE_URL = env.NO_REPLY_BASE_URL || "http://127.0.0.1:8787/v1"; From 75d659787c9eda31635ac2aafd0a61f79d2f03c4 Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 14:37:59 +0000 Subject: [PATCH 3/5] fix(rules): strip trailing metadata blocks before checking end symbol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLastChar was checking the last character of the full event.prompt, which includes Conversation/Sender metadata blocks appended by OpenClaw after the actual message. This meant end symbols like 🔚 at the end of the message body were invisible — the last char was always backtick or whitespace from the metadata JSON block. Fix: strip trailing '(untrusted metadata)' blocks before extracting the last character. This only affects non-humanList senders (humanList senders bypass end symbol check via human_list_sender reason). --- plugin/rules.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/plugin/rules.ts b/plugin/rules.ts index ccaacd8..f886f56 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -25,8 +25,24 @@ export type Decision = { reason: string; }; +/** + * Strip trailing OpenClaw metadata blocks from the prompt to get the actual message content. + * The prompt format is: [prepend instruction]\n\n[message]\n\nConversation info (untrusted metadata):\n```json\n{...}\n```\n\nSender (untrusted metadata):\n```json\n{...}\n``` + */ +function stripTrailingMetadata(input: string): string { + // Remove all trailing "XXX (untrusted metadata):\n```json\n...\n```" blocks + let text = input; + // eslint-disable-next-line no-constant-condition + while (true) { + const m = text.match(/\n*[A-Z][^\n]*\(untrusted metadata\):\s*```json\s*[\s\S]*?```\s*$/); + if (!m) break; + text = text.slice(0, text.length - m[0].length); + } + return text; +} + function getLastChar(input: string): string { - const t = input.trim(); + const t = stripTrailingMetadata(input).trim(); return t.length ? t[t.length - 1] : ""; } From 3749de981f90fa80850cdeb57b430bcca5653f4c Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 15:16:06 +0000 Subject: [PATCH 4/5] fix: use configured endSymbols in injected prompt and exempt gateway keywords - buildEndMarkerInstruction() replaces hardcoded END_MARKER_INSTRUCTION, dynamically using the resolved policy's endSymbols - Instruction now explicitly exempts gateway keywords (NO_REPLY, HEARTBEAT_OK) from requiring end symbols - Export resolvePolicy from rules.ts for reuse in before_prompt_build hook --- plugin/index.ts | 15 ++++++++++++--- plugin/rules.ts | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/plugin/index.ts b/plugin/index.ts index 540a538..20ac75e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { evaluateDecision, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; +import { evaluateDecision, resolvePolicy, type ChannelPolicy, type Decision, type WhisperGateConfig } from "./rules.js"; type DiscordControlAction = "channel-private-create" | "channel-private-update" | "member-list"; @@ -24,7 +24,10 @@ type DebugConfig = { const sessionDecision = new Map(); const MAX_SESSION_DECISIONS = 2000; const DECISION_TTL_MS = 5 * 60 * 1000; -const END_MARKER_INSTRUCTION = "你的这次发言必须以🔚作为结尾。"; +function buildEndMarkerInstruction(endSymbols: string[]): string { + const symbols = endSymbols.length > 0 ? endSymbols.join("") : "🔚"; + return `你的这次发言必须以${symbols}作为结尾。除非你的回复是 gateway 关键词(如 NO_REPLY、HEARTBEAT_OK),这些关键词不要加${symbols}。`; +} const policyState: PolicyState = { filePath: "", @@ -505,8 +508,14 @@ export default { return; } + // Resolve end symbols from config/policy for dynamic instruction + const prompt = ((event as Record).prompt as string) || ""; + const derived = deriveDecisionInputFromPrompt(prompt, ctx.messageProvider); + const policy = resolvePolicy(live, derived.channelId, policyState.channelPolicies); + const instruction = buildEndMarkerInstruction(policy.endSymbols); + api.logger.info(`whispergate: prepend end marker instruction for session=${key}, reason=${rec.decision.reason}`); - return { prependContext: END_MARKER_INSTRUCTION }; + return { prependContext: instruction }; }); }, }; diff --git a/plugin/rules.ts b/plugin/rules.ts index f886f56..1a69b44 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -46,7 +46,7 @@ function getLastChar(input: string): string { return t.length ? t[t.length - 1] : ""; } -function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { +export function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) { const globalMode = config.listMode || "human-list"; const globalHuman = config.humanList || config.bypassUserIds || []; const globalAgent = config.agentList || []; From 75f358001b28418626af86b098f8ad43e3b3db8a Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 27 Feb 2026 15:20:05 +0000 Subject: [PATCH 5/5] fix(rules): handle multi-byte emoji in getLastChar via Array.from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getLastChar used t[t.length-1] which only gets the trailing surrogate of emoji like 🔚 (U+1F51A, a surrogate pair in UTF-16). This meant end symbol matching ALWAYS failed for emoji symbols, causing every non-humanList message to hit rule_match_no_end_symbol -> no-reply. Fix: use Array.from(t) to correctly split by Unicode code points. --- plugin/rules.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugin/rules.ts b/plugin/rules.ts index 1a69b44..ca00593 100644 --- a/plugin/rules.ts +++ b/plugin/rules.ts @@ -43,7 +43,10 @@ function stripTrailingMetadata(input: string): string { function getLastChar(input: string): string { const t = stripTrailingMetadata(input).trim(); - return t.length ? t[t.length - 1] : ""; + if (!t.length) return ""; + // Use Array.from to handle multi-byte characters (emoji, surrogate pairs) + const chars = Array.from(t); + return chars[chars.length - 1] || ""; } export function resolvePolicy(config: WhisperGateConfig, channelId?: string, channelPolicies?: Record) {