Skip to content

Commit 34ea45c

Browse files
anyechOpenClaw PR consolidation bot
authored andcommitted
fix: sanitize block reply previews
1 parent aa2066a commit 34ea45c

2 files changed

Lines changed: 84 additions & 8 deletions

File tree

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5804,6 +5804,68 @@ describe("dispatchReplyFromConfig", () => {
58045804
expect(blockReplySettled).toBe(true);
58055805
});
58065806

5807+
5808+
it("sanitizes internal runtime context before queued and dispatched block replies", async () => {
5809+
setNoAbort();
5810+
const dispatcher = createDispatcher();
5811+
const ctx = buildTestCtx({ Provider: "whatsapp" });
5812+
const queuedTexts: string[] = [];
5813+
const dispatchedTexts: string[] = [];
5814+
const internalBlock = [
5815+
"Visible intro.",
5816+
"<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>",
5817+
"OpenClaw runtime event.",
5818+
"This context is runtime-generated, not user-authored. Keep internal details private.",
5819+
"",
5820+
"[Internal task completion event]",
5821+
"source: subagent",
5822+
"task: LEAK_SENTINEL_STRICT_BOUNDARIES",
5823+
"## Active Subagents",
5824+
"task_json=should-not-appear",
5825+
"<<<END_OPENCLAW_INTERNAL_CONTEXT>>>",
5826+
"Visible outro.",
5827+
].join("\n");
5828+
const replyResolver = async (
5829+
_ctx: MsgContext,
5830+
opts?: GetReplyOptions,
5831+
): Promise<ReplyPayload | undefined> => {
5832+
await opts?.onBlockReply?.({ text: internalBlock });
5833+
return undefined;
5834+
};
5835+
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mockImplementation(
5836+
(payload: ReplyPayload) => {
5837+
if (payload.text) {
5838+
dispatchedTexts.push(payload.text);
5839+
}
5840+
return true;
5841+
},
5842+
);
5843+
5844+
await dispatchReplyFromConfig({
5845+
ctx,
5846+
cfg: emptyConfig,
5847+
dispatcher,
5848+
replyResolver,
5849+
replyOptions: {
5850+
onBlockReplyQueued: (payload) => {
5851+
if (payload.text) {
5852+
queuedTexts.push(payload.text);
5853+
}
5854+
},
5855+
},
5856+
});
5857+
5858+
expect(queuedTexts).toEqual(["Visible intro.\n\nVisible outro."]);
5859+
expect(dispatchedTexts).toEqual(["Visible intro.\n\nVisible outro."]);
5860+
for (const text of [...queuedTexts, ...dispatchedTexts]) {
5861+
expect(text).not.toContain("LEAK_SENTINEL_STRICT_BOUNDARIES");
5862+
expect(text).not.toContain("## Active Subagents");
5863+
expect(text).not.toContain("task_json=");
5864+
expect(text).not.toContain("Internal task completion event");
5865+
expect(text).not.toContain("source: subagent");
5866+
}
5867+
});
5868+
58075869
it("forwards payload metadata into onBlockReplyQueued context", async () => {
58085870
setNoAbort();
58095871
const dispatcher = createDispatcher();

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resolveSessionAgentId,
1111
} from "../../agents/agent-scope.js";
1212
import { selectAgentHarness } from "../../agents/harness/selection.js";
13+
import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers/sanitize-user-facing-text.js";
1314
import {
1415
buildModelAliasIndex,
1516
resolveDefaultModelForAgent,
@@ -1155,6 +1156,15 @@ export async function dispatchReplyFromConfig(
11551156
return await normalizeReplyMediaPayloadPaths(payload);
11561157
};
11571158

1159+
const sanitizeVisibleBlockPayload = (payload: ReplyPayload): ReplyPayload | null => {
1160+
if (!payload.text) {
1161+
return payload;
1162+
}
1163+
const text = sanitizeUserFacingText(payload.text, { errorContext: Boolean(payload.isError) });
1164+
const nextPayload = { ...payload, text: text.trim() ? text : undefined };
1165+
return hasOutboundReplyContent(nextPayload, { trimText: true }) ? nextPayload : null;
1166+
};
1167+
11581168
const routeReplyToOriginating = async (
11591169
payload: ReplyPayload,
11601170
options?: { abortSignal?: AbortSignal; mirror?: boolean },
@@ -2246,30 +2256,34 @@ export async function dispatchReplyFromConfig(
22462256
if (payload.isReasoning === true) {
22472257
return;
22482258
}
2259+
const sanitizedPayload = sanitizeVisibleBlockPayload(payload);
2260+
if (!sanitizedPayload) {
2261+
return;
2262+
}
22492263
// Accumulate block text for TTS generation after streaming.
22502264
// Exclude status notices — they are informational UI signals
22512265
// and must not be synthesised into the spoken reply.
2252-
const isStatusNotice = isReplyPayloadStatusNotice(payload);
2253-
if (payload.text && !isStatusNotice) {
2266+
const isStatusNotice = isReplyPayloadStatusNotice(sanitizedPayload);
2267+
if (sanitizedPayload.text && !isStatusNotice) {
22542268
const joinsBufferedTtsDirective =
22552269
cleanBlockTtsDirectiveText?.hasBufferedDirectiveText() === true;
22562270
if (accumulatedBlockText.length > 0) {
22572271
accumulatedBlockText += "\n";
22582272
}
2259-
accumulatedBlockText += payload.text;
2273+
accumulatedBlockText += sanitizedPayload.text;
22602274
if (accumulatedBlockTtsText.length > 0 && !joinsBufferedTtsDirective) {
22612275
accumulatedBlockTtsText += "\n";
22622276
}
2263-
accumulatedBlockTtsText += payload.text;
2277+
accumulatedBlockTtsText += sanitizedPayload.text;
22642278
blockCount++;
22652279
}
22662280
const visiblePayload =
2267-
payload.text && cleanBlockTtsDirectiveText && !isStatusNotice
2281+
sanitizedPayload.text && cleanBlockTtsDirectiveText && !isStatusNotice
22682282
? (() => {
2269-
const text = cleanBlockTtsDirectiveText.push(payload.text);
2270-
return { ...payload, text: text.trim() ? text : undefined };
2283+
const text = cleanBlockTtsDirectiveText.push(sanitizedPayload.text);
2284+
return { ...sanitizedPayload, text: text.trim() ? text : undefined };
22712285
})()
2272-
: payload;
2286+
: sanitizedPayload;
22732287
if (!hasOutboundReplyContent(visiblePayload, { trimText: true })) {
22742288
return;
22752289
}

0 commit comments

Comments
 (0)