fix(gateway): suppress announce/reply skip chat leakage (#51739)

Merged via squash.

Prepared head SHA: 2f53f3b0b7
Co-authored-by: Pinghuachiu <9033138+Pinghuachiu@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Pinghuachiu
2026-04-09 06:18:57 +08:00
committed by GitHub
parent 8190cc4d21
commit 68630a9e6d
6 changed files with 194 additions and 53 deletions

View File

@@ -46,7 +46,8 @@ Docs: https://docs.openclaw.ai
- Security/dependency audit: bump Hono to `4.12.12` and `@hono/node-server` to `1.19.13` in production resolution paths.
- Slack/partial streaming: keep the final fallback reply path active when preview finalization fails so stale preview text cannot suppress the actual final answer. (#62859) Thanks @gumadeiras.
- Plugins/contracts: keep test-only helpers out of production contract barrels, load shared contract harnesses through bundled test surfaces, and harden guardrails so indirect re-exports and canonical `*.test.ts` files stay blocked. (#63311) Thanks @altaywtf.
- Sessions/routing: preserve established external routes on inter-session announce traffic so `sessions_send` follow-ups do not steal delivery from Telegram, Discord, or other external channels. (#58013) thanks @duqaXxX.
- Sessions/routing: preserve established external routes on inter-session announce traffic so `sessions_send` follow-ups do not steal delivery from Telegram, Discord, or other external channels. (#58013) Thanks @duqaXxX.
- Gateway/chat: suppress exact and streamed `ANNOUNCE_SKIP` / `REPLY_SKIP` control replies across live chat updates and history sanitization so internal agent-to-agent control tokens no longer leak into user-facing gateway chat surfaces. (#51739) Thanks @Pinghuachiu.
## 2026.4.8
@@ -1481,7 +1482,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/edit tool: accept common path/text alias spellings, show current file contents on exact-match failures, and avoid false edit failures after successful writes. (#52516) thanks @mbelinky.
## 2026.3.13
### Changes

View File

@@ -0,0 +1,62 @@
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
const SUPPRESSED_CONTROL_REPLY_TOKENS = [
SILENT_REPLY_TOKEN,
"ANNOUNCE_SKIP",
"REPLY_SKIP",
] as const;
const MIN_BARE_PREFIX_LENGTH_BY_TOKEN: Readonly<
Record<(typeof SUPPRESSED_CONTROL_REPLY_TOKENS)[number], number>
> = {
[SILENT_REPLY_TOKEN]: 2,
ANNOUNCE_SKIP: 3,
REPLY_SKIP: 3,
};
function normalizeSuppressedControlReplyFragment(text: string): string {
const trimmed = text.trim();
if (!trimmed) {
return "";
}
const normalized = trimmed.toUpperCase();
if (/[^A-Z_]/.test(normalized)) {
return "";
}
return normalized;
}
/**
* Return true when a chat-visible reply is exactly an internal control token.
*/
export function isSuppressedControlReplyText(text: string): boolean {
const normalized = text.trim();
return SUPPRESSED_CONTROL_REPLY_TOKENS.some((token) => isSilentReplyText(normalized, token));
}
/**
* Return true when streamed assistant text looks like the leading fragment of a control token.
*/
export function isSuppressedControlReplyLeadFragment(text: string): boolean {
const trimmed = text.trim();
const normalized = normalizeSuppressedControlReplyFragment(text);
if (!normalized) {
return false;
}
return SUPPRESSED_CONTROL_REPLY_TOKENS.some((token) => {
const tokenUpper = token.toUpperCase();
if (normalized === tokenUpper) {
return false;
}
if (!tokenUpper.startsWith(normalized)) {
return false;
}
if (normalized.includes("_")) {
return true;
}
if (token !== SILENT_REPLY_TOKEN && trimmed !== trimmed.toUpperCase()) {
return false;
}
return normalized.length >= MIN_BARE_PREFIX_LENGTH_BY_TOKEN[token];
});
}

View File

@@ -215,36 +215,42 @@ describe("agent event handler", () => {
nowSpy?.mockRestore();
});
it("does not emit chat delta for NO_REPLY streaming text", () => {
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
createHarness({ now: 1_000 }),
" NO_REPLY ",
);
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
nowSpy?.mockRestore();
});
it.each([" NO_REPLY ", " ANNOUNCE_SKIP ", " REPLY_SKIP "])(
"does not emit chat delta for suppressed control text %s",
(replyText) => {
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
createHarness({ now: 1_000 }),
replyText,
);
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
nowSpy?.mockRestore();
},
);
it("does not include NO_REPLY text in chat final message", () => {
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
now: 2_000,
});
chatRunState.registry.add("run-2", { sessionKey: "session-2", clientRunId: "client-2" });
it.each(["NO_REPLY", "ANNOUNCE_SKIP", "REPLY_SKIP"])(
"does not include %s text in chat final message",
(replyText) => {
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
now: 2_000,
});
chatRunState.registry.add("run-2", { sessionKey: "session-2", clientRunId: "client-2" });
handler({
runId: "run-2",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: "NO_REPLY" },
});
emitLifecycleEnd(handler, "run-2");
handler({
runId: "run-2",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: replyText },
});
emitLifecycleEnd(handler, "run-2");
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
expect(payload.message).toBeUndefined();
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore();
});
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
expect(payload.message).toBeUndefined();
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore();
},
);
it("suppresses NO_REPLY lead fragments and does not leak NO in final chat message", () => {
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
@@ -269,6 +275,38 @@ describe("agent event handler", () => {
nowSpy?.mockRestore();
});
it.each([
["ANNOUNCE_SKIP", ["ANN", "ANNOUNCE_", "ANNOUNCE_SKIP"]],
["REPLY_SKIP", ["REP", "REPLY_", "REPLY_SKIP"]],
] as const)(
"suppresses %s lead fragments and does not leak the streamed prefix in the final chat message",
(_replyText, fragments) => {
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
now: 2_150,
});
chatRunState.registry.add("run-control", {
sessionKey: "session-control",
clientRunId: "client-control",
});
for (const text of fragments) {
handler({
runId: "run-control",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text },
});
}
emitLifecycleEnd(handler, "run-control");
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
expect(payload.message).toBeUndefined();
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore();
},
);
it("keeps final short replies like 'No' even when lead-fragment deltas are suppressed", () => {
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
now: 2_200,

View File

@@ -10,6 +10,10 @@ 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 {
isSuppressedControlReplyLeadFragment,
isSuppressedControlReplyText,
} from "./control-reply-text.js";
import { loadGatewaySessionRow } from "./server-chat.load-gateway-session-row.runtime.js";
import { persistGatewaySessionLifecycleEvent } from "./server-chat.persist-session-lifecycle.runtime.js";
import { deriveGatewaySessionLifecycleSnapshot } from "./session-lifecycle-state.js";
@@ -83,20 +87,6 @@ function normalizeHeartbeatChatFinalText(params: {
return { suppress: false, text: stripped.text };
}
function isSilentReplyLeadFragment(text: string): boolean {
const normalized = text.trim().toUpperCase();
if (!normalized) {
return false;
}
if (!/^[A-Z_]+$/.test(normalized)) {
return false;
}
if (normalized === SILENT_REPLY_TOKEN) {
return false;
}
return SILENT_REPLY_TOKEN.startsWith(normalized);
}
function appendUniqueSuffix(base: string, suffix: string): string {
if (!suffix) {
return base;
@@ -692,11 +682,11 @@ export function createAgentEventHandler({
return;
}
chatRunState.rawBuffers.set(clientRunId, mergedRawText);
if (isSilentReplyText(mergedRawText, SILENT_REPLY_TOKEN)) {
if (isSuppressedControlReplyText(mergedRawText)) {
chatRunState.buffers.set(clientRunId, "");
return;
}
if (isSilentReplyLeadFragment(mergedRawText)) {
if (isSuppressedControlReplyLeadFragment(mergedRawText)) {
chatRunState.buffers.set(clientRunId, mergedRawText);
return;
}
@@ -704,6 +694,12 @@ export function createAgentEventHandler({
? stripLeadingSilentToken(mergedRawText, SILENT_REPLY_TOKEN)
: mergedRawText;
chatRunState.buffers.set(clientRunId, mergedText);
if (isSuppressedControlReplyText(mergedText)) {
return;
}
if (isSuppressedControlReplyLeadFragment(mergedText)) {
return;
}
if (shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
return;
}
@@ -740,7 +736,7 @@ export function createAgentEventHandler({
});
const text = normalizedHeartbeatText.text.trim();
const shouldSuppressSilent =
normalizedHeartbeatText.suppress || isSilentReplyText(text, SILENT_REPLY_TOKEN);
normalizedHeartbeatText.suppress || isSuppressedControlReplyText(text);
return { text, shouldSuppressSilent };
};
@@ -751,7 +747,7 @@ export function createAgentEventHandler({
seq: number,
) => {
const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId);
const shouldSuppressSilentLeadFragment = isSilentReplyLeadFragment(text);
const shouldSuppressSilentLeadFragment = isSuppressedControlReplyLeadFragment(text);
const shouldSuppressHeartbeatStreaming = shouldHideHeartbeatChatOutput(
clientRunId,
sourceRunId,

View File

@@ -12,8 +12,8 @@ import {
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import type { MsgContext } from "../../auto-reply/templating.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import { resolveSessionFilePath } from "../../config/sessions.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { jsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
@@ -54,6 +54,7 @@ import {
} from "../chat-attachments.js";
import { stripEnvelopeFromMessage, stripEnvelopeFromMessages } from "../chat-sanitize.js";
import { augmentChatHistoryWithCliSessionImports } from "../cli-session-history.js";
import { isSuppressedControlReplyText } from "../control-reply-text.js";
import { ADMIN_SCOPE } from "../method-scopes.js";
import {
GATEWAY_CLIENT_CAPS,
@@ -732,7 +733,7 @@ function shouldDropAssistantHistoryMessage(message: unknown): boolean {
return true;
}
const text = extractAssistantTextForSilentCheck(message);
if (text === undefined || !isSilentReplyText(text, SILENT_REPLY_TOKEN)) {
if (text === undefined || !isSuppressedControlReplyText(text)) {
return false;
}
return !hasAssistantNonTextContent(message);
@@ -745,14 +746,14 @@ export function sanitizeChatHistoryMessages(messages: unknown[], maxChars: numbe
let changed = false;
const next: unknown[] = [];
for (const message of messages) {
// Drop assistant commentary-only entries and NO_REPLY-only entries, but
const res = sanitizeChatHistoryMessage(message, maxChars);
changed ||= res.changed;
// Drop assistant commentary-only entries and exact control replies, but
// keep mixed assistant entries that still carry non-text content.
if (shouldDropAssistantHistoryMessage(message)) {
if (shouldDropAssistantHistoryMessage(res.message)) {
changed = true;
continue;
}
const res = sanitizeChatHistoryMessage(message, maxChars);
changed ||= res.changed;
next.push(res.message);
}
return changed ? next : messages;

View File

@@ -565,6 +565,50 @@ describe("gateway server chat", () => {
expect(collectHistoryTextValues(historyMessages)).toEqual(["hello", "real reply"]);
});
test("chat.history hides assistant announce/reply skip-only entries", async () => {
const historyMessages = await loadChatHistoryWithMessages([
{
role: "assistant",
content: [{ type: "text", text: "ANNOUNCE_SKIP" }],
timestamp: 1,
},
{
role: "assistant",
content: [{ type: "text", text: "REPLY_SKIP" }],
timestamp: 2,
},
{
role: "assistant",
text: "real text field reply",
content: "ANNOUNCE_SKIP",
timestamp: 3,
},
{
role: "assistant",
content: [{ type: "text", text: "real reply" }],
timestamp: 4,
},
]);
const roleAndText = historyMessages
.map((message) => {
const role =
message &&
typeof message === "object" &&
typeof (message as { role?: unknown }).role === "string"
? (message as { role: string }).role
: "unknown";
const text =
message &&
typeof message === "object" &&
typeof (message as { text?: unknown }).text === "string"
? (message as { text: string }).text
: (extractFirstTextBlock(message) ?? "");
return `${role}:${text}`;
})
.filter((entry) => entry !== "unknown:");
expect(roleAndText).toEqual(["assistant:real text field reply", "assistant:real reply"]);
});
test("routes chat.send slash commands without agent runs", async () => {
await withMainSessionStore(async () => {
const spy = vi.mocked(agentCommand);