mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(nostr): keep private tool traces out of encrypted messages (#115769)
Sanitize both Nostr delivery paths with the shared assistant-visible text contract. Preserve ordinary replies and suppress internal-only messages before Markdown conversion. Related: #90684 Co-authored-by: liyuanbin <li.yuanbin1@xydigit.com>
This commit is contained in:
committed by
GitHub
parent
ed17774f3f
commit
dbba7e5c4b
@@ -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: '<tool_call>{"name":"read","arguments":{"path":"private"}}</tool_call>Done.',
|
||||
expected: "Done.",
|
||||
},
|
||||
{
|
||||
name: "strips multiline tool-response scaffolding",
|
||||
text: [
|
||||
"Before",
|
||||
"<function_response>",
|
||||
"private output",
|
||||
"</function_response>",
|
||||
"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<typeof DispatchInboundDirectDm>[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<void>,
|
||||
meta: { eventId: string; createdAt: number },
|
||||
lifecycle: NostrIngressLifecycle,
|
||||
) => Promise<void>;
|
||||
};
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '<tool_call>{"name":"read","arguments":{"path":"private"}}</tool_call>Done.',
|
||||
expected: "Done.",
|
||||
},
|
||||
{
|
||||
name: "strips multiline tool-response scaffolding",
|
||||
text: [
|
||||
"Before",
|
||||
"<function_response>",
|
||||
"private output",
|
||||
"</function_response>",
|
||||
"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)")),
|
||||
|
||||
@@ -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<ChannelOutboundAdapter["sendText"]>;
|
||||
sanitizeText: NonNullable<ChannelOutboundAdapter["sanitizeText"]>;
|
||||
};
|
||||
|
||||
const activeBuses = new Map<string, NostrBusHandle>();
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user