Skip to content

Commit f910812

Browse files
committed
fix(gateway): strip inline directive tags from displayed text
1 parent 4540790 commit f910812

9 files changed

Lines changed: 199 additions & 19 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ Docs: https://docs.openclaw.ai
6161

6262
### Fixes
6363

64+
- Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:<id>]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces.
65+
- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
66+
- Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting.
67+
- Doctor/State integrity: only require/create the OAuth credentials directory when WhatsApp or pairing-backed channels are configured, and downgrade fresh-install missing-dir noise to an informational warning.
68+
- Agents/Sanitization: stop rewriting billing-shaped assistant text outside explicit error context so normal replies about billing/credits/payment are preserved across messaging channels. (#17834, fixes #11359)
6469
- Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`).
6570
- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops.
6671
- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files.

src/gateway/server-chat.agent-events.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,21 @@ describe("agent event handler", () => {
114114
nowSpy?.mockRestore();
115115
});
116116

117+
it("strips inline directives from assistant chat events", () => {
118+
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
119+
createHarness({ now: 1_000 }),
120+
"Hello [[reply_to_current]] world [[audio_as_voice]]",
121+
);
122+
const chatCalls = chatBroadcastCalls(broadcast);
123+
expect(chatCalls).toHaveLength(1);
124+
const payload = chatCalls[0]?.[1] as {
125+
message?: { content?: Array<{ text?: string }> };
126+
};
127+
expect(payload.message?.content?.[0]?.text).toBe("Hello world ");
128+
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
129+
nowSpy?.mockRestore();
130+
});
131+
117132
it("does not emit chat delta for NO_REPLY streaming text", () => {
118133
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
119134
createHarness({ now: 1_000 }),

src/gateway/server-chat.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
44
import { loadConfig } from "../config/config.js";
55
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
66
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
7+
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
78
import { loadSessionEntry } from "./session-utils.js";
89
import { formatForLog } from "./ws-log.js";
910

@@ -283,10 +284,14 @@ export function createAgentEventHandler({
283284
seq: number,
284285
text: string,
285286
) => {
286-
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
287+
const cleaned = stripInlineDirectiveTagsForDisplay(text).text;
288+
if (!cleaned) {
287289
return;
288290
}
289-
chatRunState.buffers.set(clientRunId, text);
291+
if (isSilentReplyText(cleaned, SILENT_REPLY_TOKEN)) {
292+
return;
293+
}
294+
chatRunState.buffers.set(clientRunId, cleaned);
290295
if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
291296
return;
292297
}
@@ -303,7 +308,7 @@ export function createAgentEventHandler({
303308
state: "delta" as const,
304309
message: {
305310
role: "assistant",
306-
content: [{ type: "text", text }],
311+
content: [{ type: "text", text: cleaned }],
307312
timestamp: now,
308313
},
309314
};
@@ -319,7 +324,9 @@ export function createAgentEventHandler({
319324
jobState: "done" | "error",
320325
error?: unknown,
321326
) => {
322-
const bufferedText = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
327+
const bufferedText = stripInlineDirectiveTagsForDisplay(
328+
chatRunState.buffers.get(clientRunId) ?? "",
329+
).text.trim();
323330
const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({
324331
runId: clientRunId,
325332
sourceRunId,

src/gateway/server-methods/chat.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { MsgContext } from "../../auto-reply/templating.js";
1010
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
1111
import { resolveSessionFilePath } from "../../config/sessions.js";
1212
import { resolveSendPolicy } from "../../sessions/send-policy.js";
13+
import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js";
1314
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
1415
import {
1516
abortChatRunById,
@@ -103,9 +104,10 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan
103104
const entry = { ...(block as Record<string, unknown>) };
104105
let changed = false;
105106
if (typeof entry.text === "string") {
106-
const res = truncateChatHistoryText(entry.text);
107+
const stripped = stripInlineDirectiveTagsForDisplay(entry.text);
108+
const res = truncateChatHistoryText(stripped.text);
107109
entry.text = res.text;
108-
changed ||= res.truncated;
110+
changed ||= stripped.changed || res.truncated;
109111
}
110112
if (typeof entry.partialJson === "string") {
111113
const res = truncateChatHistoryText(entry.partialJson);
@@ -158,9 +160,10 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
158160
}
159161

160162
if (typeof entry.content === "string") {
161-
const res = truncateChatHistoryText(entry.content);
163+
const stripped = stripInlineDirectiveTagsForDisplay(entry.content);
164+
const res = truncateChatHistoryText(stripped.text);
162165
entry.content = res.text;
163-
changed ||= res.truncated;
166+
changed ||= stripped.changed || res.truncated;
164167
} else if (Array.isArray(entry.content)) {
165168
const updated = entry.content.map((block) => sanitizeChatHistoryContentBlock(block));
166169
if (updated.some((item) => item.changed)) {
@@ -170,9 +173,10 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
170173
}
171174

172175
if (typeof entry.text === "string") {
173-
const res = truncateChatHistoryText(entry.text);
176+
const stripped = stripInlineDirectiveTagsForDisplay(entry.text);
177+
const res = truncateChatHistoryText(stripped.text);
174178
entry.text = res.text;
175-
changed ||= res.truncated;
179+
changed ||= stripped.changed || res.truncated;
176180
}
177181

178182
return { message: changed ? entry : message, changed };

src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,75 @@ describe("gateway server chat", () => {
287287
});
288288
});
289289

290+
test("chat.history strips inline directives from displayed message text", async () => {
291+
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
292+
await connectOk(ws);
293+
294+
const sessionDir = await createSessionDir();
295+
await writeMainSessionStore();
296+
297+
const lines = [
298+
JSON.stringify({
299+
message: {
300+
role: "assistant",
301+
content: [
302+
{ type: "text", text: "Hello [[reply_to_current]] world [[audio_as_voice]]" },
303+
],
304+
timestamp: Date.now(),
305+
},
306+
}),
307+
JSON.stringify({
308+
message: {
309+
role: "assistant",
310+
content: "A [[reply_to:abc-123]] B",
311+
timestamp: Date.now() + 1,
312+
},
313+
}),
314+
JSON.stringify({
315+
message: {
316+
role: "assistant",
317+
text: "[[ reply_to : 456 ]] C",
318+
timestamp: Date.now() + 2,
319+
},
320+
}),
321+
JSON.stringify({
322+
message: {
323+
role: "assistant",
324+
content: [{ type: "text", text: " keep padded " }],
325+
timestamp: Date.now() + 3,
326+
},
327+
}),
328+
];
329+
await fs.writeFile(
330+
path.join(sessionDir, "sess-main.jsonl"),
331+
`${lines.join("\n")}\n`,
332+
"utf-8",
333+
);
334+
335+
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
336+
sessionKey: "main",
337+
limit: 1000,
338+
});
339+
expect(historyRes.ok).toBe(true);
340+
const messages = historyRes.payload?.messages ?? [];
341+
expect(messages.length).toBe(4);
342+
343+
const serialized = JSON.stringify(messages);
344+
expect(serialized.includes("[[reply_to")).toBe(false);
345+
expect(serialized.includes("[[audio_as_voice]]")).toBe(false);
346+
347+
const first = messages[0] as { content?: Array<{ text?: string }> };
348+
const second = messages[1] as { content?: string };
349+
const third = messages[2] as { text?: string };
350+
const fourth = messages[3] as { content?: Array<{ text?: string }> };
351+
352+
expect(first.content?.[0]?.text?.replace(/\s+/g, " ").trim()).toBe("Hello world");
353+
expect(second.content?.replace(/\s+/g, " ").trim()).toBe("A B");
354+
expect(third.text?.replace(/\s+/g, " ").trim()).toBe("C");
355+
expect(fourth.content?.[0]?.text).toBe(" keep padded ");
356+
});
357+
});
358+
290359
test("smoke: supports abort and idempotent completion", async () => {
291360
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
292361
const spy = getReplyFromConfig;

src/gateway/session-utils.fs.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,23 @@ describe("readLastMessagePreviewFromTranscript", () => {
375375
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
376376
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
377377
});
378+
379+
test("strips inline directives from last preview text", () => {
380+
const sessionId = "test-last-strip-inline-directives";
381+
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
382+
const lines = [
383+
JSON.stringify({
384+
message: {
385+
role: "assistant",
386+
content: "Hello [[reply_to_current]] world [[audio_as_voice]]",
387+
},
388+
}),
389+
];
390+
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
391+
392+
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
393+
expect(result).toBe("Hello world");
394+
});
378395
});
379396

380397
describe("readSessionTitleFieldsFromTranscript cache", () => {
@@ -606,6 +623,23 @@ describe("readSessionPreviewItemsFromTranscript", () => {
606623
expect(result[0]?.text.length).toBe(24);
607624
expect(result[0]?.text.endsWith("...")).toBe(true);
608625
});
626+
627+
test("strips inline directives from preview items", () => {
628+
const sessionId = "preview-strip-inline-directives";
629+
const lines = [
630+
JSON.stringify({
631+
message: {
632+
role: "assistant",
633+
content: "A [[reply_to:abc-123]] B [[audio_as_voice]]",
634+
},
635+
}),
636+
];
637+
writeTranscriptLines(sessionId, lines);
638+
const result = readPreview(sessionId, 1, 120);
639+
640+
expect(result).toHaveLength(1);
641+
expect(result[0]?.text).toBe("A B");
642+
});
609643
});
610644

611645
describe("resolveSessionTranscriptCandidates", () => {

src/gateway/session-utils.fs.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "../config/sessions.js";
99
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
1010
import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js";
11+
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
1112
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
1213
import { stripEnvelope } from "./chat-sanitize.js";
1314
import type { SessionPreviewItem } from "./session-utils.types.js";
@@ -366,7 +367,8 @@ export function readSessionTitleFieldsFromTranscript(
366367

367368
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
368369
if (typeof content === "string") {
369-
return content.trim() || null;
370+
const normalized = stripInlineDirectiveTagsForDisplay(content).text.trim();
371+
return normalized || null;
370372
}
371373
if (!Array.isArray(content)) {
372374
return null;
@@ -376,9 +378,9 @@ function extractTextFromContent(content: TranscriptMessage["content"]): string |
376378
continue;
377379
}
378380
if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
379-
const trimmed = part.text.trim();
380-
if (trimmed) {
381-
return trimmed;
381+
const normalized = stripInlineDirectiveTagsForDisplay(part.text).text.trim();
382+
if (normalized) {
383+
return normalized;
382384
}
383385
}
384386
}
@@ -572,20 +574,22 @@ function truncatePreviewText(text: string, maxChars: number): string {
572574

573575
function extractPreviewText(message: TranscriptPreviewMessage): string | null {
574576
if (typeof message.content === "string") {
575-
const trimmed = message.content.trim();
576-
return trimmed ? trimmed : null;
577+
const normalized = stripInlineDirectiveTagsForDisplay(message.content).text.trim();
578+
return normalized ? normalized : null;
577579
}
578580
if (Array.isArray(message.content)) {
579581
const parts = message.content
580-
.map((entry) => (typeof entry?.text === "string" ? entry.text : ""))
582+
.map((entry) =>
583+
typeof entry?.text === "string" ? stripInlineDirectiveTagsForDisplay(entry.text).text : "",
584+
)
581585
.filter((text) => text.trim().length > 0);
582586
if (parts.length > 0) {
583587
return parts.join("\n").trim();
584588
}
585589
}
586590
if (typeof message.text === "string") {
587-
const trimmed = message.text.trim();
588-
return trimmed ? trimmed : null;
591+
const normalized = stripInlineDirectiveTagsForDisplay(message.text).text.trim();
592+
return normalized ? normalized : null;
589593
}
590594
return null;
591595
}

src/utils/directive-tags.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, test } from "vitest";
2+
import { stripInlineDirectiveTagsForDisplay } from "./directive-tags.js";
3+
4+
describe("stripInlineDirectiveTagsForDisplay", () => {
5+
test("removes reply and audio directives", () => {
6+
const input = "hello [[reply_to_current]] world [[reply_to:abc-123]] [[audio_as_voice]]";
7+
const result = stripInlineDirectiveTagsForDisplay(input);
8+
expect(result.changed).toBe(true);
9+
expect(result.text).toBe("hello world ");
10+
});
11+
12+
test("supports whitespace variants", () => {
13+
const input = "[[ reply_to : 123 ]]ok[[ audio_as_voice ]]";
14+
const result = stripInlineDirectiveTagsForDisplay(input);
15+
expect(result.changed).toBe(true);
16+
expect(result.text).toBe("ok");
17+
});
18+
19+
test("does not mutate plain text", () => {
20+
const input = " keep leading and trailing whitespace ";
21+
const result = stripInlineDirectiveTagsForDisplay(input);
22+
expect(result.changed).toBe(false);
23+
expect(result.text).toBe(input);
24+
});
25+
});

src/utils/directive-tags.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ function normalizeDirectiveWhitespace(text: string): string {
2424
.trim();
2525
}
2626

27+
type StripInlineDirectiveTagsResult = {
28+
text: string;
29+
changed: boolean;
30+
};
31+
32+
export function stripInlineDirectiveTagsForDisplay(text: string): StripInlineDirectiveTagsResult {
33+
if (!text) {
34+
return { text, changed: false };
35+
}
36+
const withoutAudio = text.replace(AUDIO_TAG_RE, "");
37+
const stripped = withoutAudio.replace(REPLY_TAG_RE, "");
38+
return {
39+
text: stripped,
40+
changed: stripped !== text,
41+
};
42+
}
43+
2744
export function parseInlineDirectives(
2845
text?: string,
2946
options: InlineDirectiveParseOptions = {},

0 commit comments

Comments
 (0)