mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 01:07:27 +00:00
fix(gateway): strip inline directive tags from displayed text
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
25
src/utils/directive-tags.test.ts
Normal file
25
src/utils/directive-tags.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 = {},
|
||||
|
||||
Reference in New Issue
Block a user