diff --git a/extensions/nostr/src/channel.inbound.test.ts b/extensions/nostr/src/channel.inbound.test.ts
index 47d0d5379126..1f4843df19a3 100644
--- a/extensions/nostr/src/channel.inbound.test.ts
+++ b/extensions/nostr/src/channel.inbound.test.ts
@@ -52,11 +52,12 @@ function createRuntimeHarness() {
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "**Table:** [docs](https://example.com)" });
});
+ const convertMarkdownTables = vi.fn((text: string) => text);
const runtime = {
channel: {
text: {
resolveMarkdownTableMode: vi.fn(() => "off"),
- convertMarkdownTables: vi.fn((text: string) => text),
+ convertMarkdownTables,
},
commands: {
shouldComputeCommandAuthorized: vi.fn(() => true),
@@ -91,6 +92,7 @@ function createRuntimeHarness() {
runtime,
recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher,
+ convertMarkdownTables,
};
}
@@ -227,4 +229,88 @@ describe("nostr inbound gateway path", () => {
await cleanup.stop();
});
+
+ it.each([
+ {
+ name: "strips an internal tool-failure banner",
+ text: "Done.\nâ ď¸ đ ď¸ `search repos (agent)` failed",
+ expected: "Done.",
+ },
+ {
+ name: "strips internal tool-call XML",
+ text: '{"name":"read","arguments":{"path":"private"}}Done.',
+ expected: "Done.",
+ },
+ {
+ name: "strips multiline tool-response scaffolding",
+ text: [
+ "Before",
+ "",
+ "private output",
+ "",
+ "After",
+ ].join("\n"),
+ expected: "Before\n\nAfter",
+ },
+ {
+ name: "does not send an internal-trace-only reply",
+ text: "â ď¸ đ ď¸ `search repos (agent)` failed",
+ expected: null,
+ },
+ {
+ name: "preserves ordinary visible prose",
+ text: "The relay has two active subscriptions.",
+ expected: "The relay has two active subscriptions.",
+ },
+ ])("$name before sending an inbound Nostr DM reply", async ({ text, expected }) => {
+ mocks.dispatchInboundDirectDm.mockImplementationOnce(
+ async (params: Parameters[0]) => {
+ await params.deliver({ text });
+ },
+ );
+ const { harness, cleanup } = await startGatewayHarness({
+ account: buildResolvedNostrAccount({
+ publicKey: "bot-pubkey",
+ config: { dmPolicy: "allowlist", allowFrom: ["nostr:sender-pubkey"] },
+ }),
+ cfg: {},
+ });
+ const options = mockCallArg(mocks.startNostrBus) as {
+ onMessage: (
+ senderPubkey: string,
+ text: string,
+ reply: (text: string) => Promise,
+ meta: { eventId: string; createdAt: number },
+ lifecycle: NostrIngressLifecycle,
+ ) => Promise;
+ };
+ const sendReply = vi.fn(async (_text: string) => {});
+ const lifecycle: NostrIngressLifecycle = {
+ abortSignal: new AbortController().signal,
+ onAdopted: vi.fn(async () => {}),
+ onDeferred: vi.fn(),
+ onAdoptionFinalizing: vi.fn(),
+ onAbandoned: vi.fn(async () => {}),
+ };
+
+ try {
+ await options.onMessage(
+ "sender-pubkey",
+ "hello from nostr",
+ sendReply,
+ { eventId: "event-123", createdAt: 1_710_000_000 },
+ lifecycle,
+ );
+
+ if (expected === null) {
+ expect(harness.convertMarkdownTables).not.toHaveBeenCalled();
+ expect(sendReply).not.toHaveBeenCalled();
+ } else {
+ expect(harness.convertMarkdownTables).toHaveBeenCalledWith(expected, "off");
+ expect(sendReply).toHaveBeenCalledWith(expected);
+ }
+ } finally {
+ await cleanup.stop();
+ }
+ });
});
diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts
index db873d6b98e4..12fb56045258 100644
--- a/extensions/nostr/src/channel.outbound.test.ts
+++ b/extensions/nostr/src/channel.outbound.test.ts
@@ -85,6 +85,47 @@ describe("nostr outbound cfg threading", () => {
mocks.startNostrBus.mockReset();
});
+ it.each([
+ {
+ name: "strips an internal tool-failure banner",
+ text: "Done.\nâ ď¸ đ ď¸ `search repos (agent)` failed",
+ expected: "Done.",
+ },
+ {
+ name: "strips internal tool-call XML",
+ text: '{"name":"read","arguments":{"path":"private"}}Done.',
+ expected: "Done.",
+ },
+ {
+ name: "strips multiline tool-response scaffolding",
+ text: [
+ "Before",
+ "",
+ "private output",
+ "",
+ "After",
+ ].join("\n"),
+ expected: "Before\n\nAfter",
+ },
+ {
+ name: "suppresses an internal-trace-only reply",
+ text: "â ď¸ đ ď¸ `search repos (agent)` failed",
+ expected: "",
+ },
+ {
+ name: "preserves ordinary visible prose",
+ text: "The relay has two active subscriptions.",
+ expected: "The relay has two active subscriptions.",
+ },
+ ])("$name through the Nostr outbound sanitizer", ({ text, expected }) => {
+ const sanitizeText = nostrPlugin.outbound?.sanitizeText;
+ expect(sanitizeText).toBeTypeOf("function");
+ if (!sanitizeText) {
+ throw new Error("Expected Nostr outbound assistant-visible text sanitizer");
+ }
+ expect(sanitizeText({ text, payload: { text } })).toBe(expected);
+ });
+
it("converts tables before projecting markdown to Nostr plain text", async () => {
const { resolveMarkdownTableMode, convertMarkdownTables } = installOutboundRuntime(
vi.fn((text: string) => (text === "***" ? text : "**Table:** [docs](https://example.com)")),
diff --git a/extensions/nostr/src/gateway.ts b/extensions/nostr/src/gateway.ts
index 08fc4015e3dd..4cdb37d5597a 100644
--- a/extensions/nostr/src/gateway.ts
+++ b/extensions/nostr/src/gateway.ts
@@ -10,7 +10,7 @@ import {
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
-import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
+import { sanitizeAssistantVisibleText, stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
import type { ChannelOutboundAdapter, ChannelPlugin } from "./channel-api.js";
import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
@@ -26,6 +26,7 @@ type NostrOutboundAdapter = Pick<
"deliveryCapabilities" | "deliveryMode" | "textChunkLimit" | "sendText"
> & {
sendText: NonNullable;
+ sanitizeText: NonNullable;
};
const activeBuses = new Map();
@@ -194,7 +195,10 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
payload && typeof payload === "object" && "text" in payload
? ((payload as { text?: string }).text ?? "")
: "";
- if (!outboundText.trim()) {
+ // Inbound DM replies bypass the outbound adapter; sanitize before
+ // Markdown conversion so private tool traces cannot reach a relay.
+ const sanitizedText = sanitizeAssistantVisibleText(outboundText);
+ if (!sanitizedText) {
return;
}
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
@@ -203,7 +207,7 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
accountId: account.accountId,
});
const message = stripMarkdown(
- runtime.channel.text.convertMarkdownTables(outboundText, tableMode),
+ runtime.channel.text.convertMarkdownTables(sanitizedText, tableMode),
);
if (message) {
await reply(message);
@@ -312,6 +316,7 @@ export const nostrPairingTextAdapter = {
export const nostrOutboundAdapter: NostrOutboundAdapter = {
deliveryMode: "direct",
textChunkLimit: 4000,
+ sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),
deliveryCapabilities: {
durableFinal: {
text: true,