fix(gateway): strip inline directive tags from displayed text

This commit is contained in:
Peter Steinberger
2026-02-21 20:08:13 +01:00
parent 4540790cb6
commit f9108120c2
9 changed files with 199 additions and 19 deletions

View File

@@ -61,6 +61,11 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- 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.
- 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.
- 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)
- 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`).
- 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.
- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files.

View File

@@ -114,6 +114,21 @@ describe("agent event handler", () => {
nowSpy?.mockRestore();
});
it("strips inline directives from assistant chat events", () => {
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
createHarness({ now: 1_000 }),
"Hello [[reply_to_current]] world [[audio_as_voice]]",
);
const chatCalls = chatBroadcastCalls(broadcast);
expect(chatCalls).toHaveLength(1);
const payload = chatCalls[0]?.[1] as {
message?: { content?: Array<{ text?: string }> };
};
expect(payload.message?.content?.[0]?.text).toBe("Hello world ");
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore();
});
it("does not emit chat delta for NO_REPLY streaming text", () => {
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
createHarness({ now: 1_000 }),

View File

@@ -4,6 +4,7 @@ import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { loadConfig } from "../config/config.js";
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
@@ -283,10 +284,14 @@ export function createAgentEventHandler({
seq: number,
text: string,
) => {
if (isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
const cleaned = stripInlineDirectiveTagsForDisplay(text).text;
if (!cleaned) {
return;
}
chatRunState.buffers.set(clientRunId, text);
if (isSilentReplyText(cleaned, SILENT_REPLY_TOKEN)) {
return;
}
chatRunState.buffers.set(clientRunId, cleaned);
if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
return;
}
@@ -303,7 +308,7 @@ export function createAgentEventHandler({
state: "delta" as const,
message: {
role: "assistant",
content: [{ type: "text", text }],
content: [{ type: "text", text: cleaned }],
timestamp: now,
},
};
@@ -319,7 +324,9 @@ export function createAgentEventHandler({
jobState: "done" | "error",
error?: unknown,
) => {
const bufferedText = chatRunState.buffers.get(clientRunId)?.trim() ?? "";
const bufferedText = stripInlineDirectiveTagsForDisplay(
chatRunState.buffers.get(clientRunId) ?? "",
).text.trim();
const normalizedHeartbeatText = normalizeHeartbeatChatFinalText({
runId: clientRunId,
sourceRunId,

View File

@@ -10,6 +10,7 @@ import type { MsgContext } from "../../auto-reply/templating.js";
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import { resolveSessionFilePath } from "../../config/sessions.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import {
abortChatRunById,
@@ -103,9 +104,10 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan
const entry = { ...(block as Record<string, unknown>) };
let changed = false;
if (typeof entry.text === "string") {
const res = truncateChatHistoryText(entry.text);
const stripped = stripInlineDirectiveTagsForDisplay(entry.text);
const res = truncateChatHistoryText(stripped.text);
entry.text = res.text;
changed ||= res.truncated;
changed ||= stripped.changed || res.truncated;
}
if (typeof entry.partialJson === "string") {
const res = truncateChatHistoryText(entry.partialJson);
@@ -158,9 +160,10 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
}
if (typeof entry.content === "string") {
const res = truncateChatHistoryText(entry.content);
const stripped = stripInlineDirectiveTagsForDisplay(entry.content);
const res = truncateChatHistoryText(stripped.text);
entry.content = res.text;
changed ||= res.truncated;
changed ||= stripped.changed || res.truncated;
} else if (Array.isArray(entry.content)) {
const updated = entry.content.map((block) => sanitizeChatHistoryContentBlock(block));
if (updated.some((item) => item.changed)) {
@@ -170,9 +173,10 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang
}
if (typeof entry.text === "string") {
const res = truncateChatHistoryText(entry.text);
const stripped = stripInlineDirectiveTagsForDisplay(entry.text);
const res = truncateChatHistoryText(stripped.text);
entry.text = res.text;
changed ||= res.truncated;
changed ||= stripped.changed || res.truncated;
}
return { message: changed ? entry : message, changed };

View File

@@ -287,6 +287,75 @@ describe("gateway server chat", () => {
});
});
test("chat.history strips inline directives from displayed message text", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);
const sessionDir = await createSessionDir();
await writeMainSessionStore();
const lines = [
JSON.stringify({
message: {
role: "assistant",
content: [
{ type: "text", text: "Hello [[reply_to_current]] world [[audio_as_voice]]" },
],
timestamp: Date.now(),
},
}),
JSON.stringify({
message: {
role: "assistant",
content: "A [[reply_to:abc-123]] B",
timestamp: Date.now() + 1,
},
}),
JSON.stringify({
message: {
role: "assistant",
text: "[[ reply_to : 456 ]] C",
timestamp: Date.now() + 2,
},
}),
JSON.stringify({
message: {
role: "assistant",
content: [{ type: "text", text: " keep padded " }],
timestamp: Date.now() + 3,
},
}),
];
await fs.writeFile(
path.join(sessionDir, "sess-main.jsonl"),
`${lines.join("\n")}\n`,
"utf-8",
);
const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(historyRes.ok).toBe(true);
const messages = historyRes.payload?.messages ?? [];
expect(messages.length).toBe(4);
const serialized = JSON.stringify(messages);
expect(serialized.includes("[[reply_to")).toBe(false);
expect(serialized.includes("[[audio_as_voice]]")).toBe(false);
const first = messages[0] as { content?: Array<{ text?: string }> };
const second = messages[1] as { content?: string };
const third = messages[2] as { text?: string };
const fourth = messages[3] as { content?: Array<{ text?: string }> };
expect(first.content?.[0]?.text?.replace(/\s+/g, " ").trim()).toBe("Hello world");
expect(second.content?.replace(/\s+/g, " ").trim()).toBe("A B");
expect(third.text?.replace(/\s+/g, " ").trim()).toBe("C");
expect(fourth.content?.[0]?.text).toBe(" keep padded ");
});
});
test("smoke: supports abort and idempotent completion", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
const spy = getReplyFromConfig;

View File

@@ -375,6 +375,23 @@ describe("readLastMessagePreviewFromTranscript", () => {
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
});
test("strips inline directives from last preview text", () => {
const sessionId = "test-last-strip-inline-directives";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({
message: {
role: "assistant",
content: "Hello [[reply_to_current]] world [[audio_as_voice]]",
},
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Hello world");
});
});
describe("readSessionTitleFieldsFromTranscript cache", () => {
@@ -606,6 +623,23 @@ describe("readSessionPreviewItemsFromTranscript", () => {
expect(result[0]?.text.length).toBe(24);
expect(result[0]?.text.endsWith("...")).toBe(true);
});
test("strips inline directives from preview items", () => {
const sessionId = "preview-strip-inline-directives";
const lines = [
JSON.stringify({
message: {
role: "assistant",
content: "A [[reply_to:abc-123]] B [[audio_as_voice]]",
},
}),
];
writeTranscriptLines(sessionId, lines);
const result = readPreview(sessionId, 1, 120);
expect(result).toHaveLength(1);
expect(result[0]?.text).toBe("A B");
});
});
describe("resolveSessionTranscriptCandidates", () => {

View File

@@ -8,6 +8,7 @@ import {
} from "../config/sessions.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js";
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js";
import { stripEnvelope } from "./chat-sanitize.js";
import type { SessionPreviewItem } from "./session-utils.types.js";
@@ -366,7 +367,8 @@ export function readSessionTitleFieldsFromTranscript(
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
if (typeof content === "string") {
return content.trim() || null;
const normalized = stripInlineDirectiveTagsForDisplay(content).text.trim();
return normalized || null;
}
if (!Array.isArray(content)) {
return null;
@@ -376,9 +378,9 @@ function extractTextFromContent(content: TranscriptMessage["content"]): string |
continue;
}
if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
const trimmed = part.text.trim();
if (trimmed) {
return trimmed;
const normalized = stripInlineDirectiveTagsForDisplay(part.text).text.trim();
if (normalized) {
return normalized;
}
}
}
@@ -572,20 +574,22 @@ function truncatePreviewText(text: string, maxChars: number): string {
function extractPreviewText(message: TranscriptPreviewMessage): string | null {
if (typeof message.content === "string") {
const trimmed = message.content.trim();
return trimmed ? trimmed : null;
const normalized = stripInlineDirectiveTagsForDisplay(message.content).text.trim();
return normalized ? normalized : null;
}
if (Array.isArray(message.content)) {
const parts = message.content
.map((entry) => (typeof entry?.text === "string" ? entry.text : ""))
.map((entry) =>
typeof entry?.text === "string" ? stripInlineDirectiveTagsForDisplay(entry.text).text : "",
)
.filter((text) => text.trim().length > 0);
if (parts.length > 0) {
return parts.join("\n").trim();
}
}
if (typeof message.text === "string") {
const trimmed = message.text.trim();
return trimmed ? trimmed : null;
const normalized = stripInlineDirectiveTagsForDisplay(message.text).text.trim();
return normalized ? normalized : null;
}
return null;
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import { stripInlineDirectiveTagsForDisplay } from "./directive-tags.js";
describe("stripInlineDirectiveTagsForDisplay", () => {
test("removes reply and audio directives", () => {
const input = "hello [[reply_to_current]] world [[reply_to:abc-123]] [[audio_as_voice]]";
const result = stripInlineDirectiveTagsForDisplay(input);
expect(result.changed).toBe(true);
expect(result.text).toBe("hello world ");
});
test("supports whitespace variants", () => {
const input = "[[ reply_to : 123 ]]ok[[ audio_as_voice ]]";
const result = stripInlineDirectiveTagsForDisplay(input);
expect(result.changed).toBe(true);
expect(result.text).toBe("ok");
});
test("does not mutate plain text", () => {
const input = " keep leading and trailing whitespace ";
const result = stripInlineDirectiveTagsForDisplay(input);
expect(result.changed).toBe(false);
expect(result.text).toBe(input);
});
});

View File

@@ -24,6 +24,23 @@ function normalizeDirectiveWhitespace(text: string): string {
.trim();
}
type StripInlineDirectiveTagsResult = {
text: string;
changed: boolean;
};
export function stripInlineDirectiveTagsForDisplay(text: string): StripInlineDirectiveTagsResult {
if (!text) {
return { text, changed: false };
}
const withoutAudio = text.replace(AUDIO_TAG_RE, "");
const stripped = withoutAudio.replace(REPLY_TAG_RE, "");
return {
text: stripped,
changed: stripped !== text,
};
}
export function parseInlineDirectives(
text?: string,
options: InlineDirectiveParseOptions = {},