fix(logging): keep bounded log text UTF-16 safe (#102560)

* fix(logging): use truncateUtf16Safe in diagnostic log text clamp

Replace naive .slice(0, maxChars) with truncateUtf16Safe() in
clampDiagnosticLogText() — the core helper used by all diagnostic
log output (sanitizeDiagnosticLogText, formatDiagnosticAttributes,
etc.). This prevents surrogate pair splitting across the entire
diagnostic logging subsystem.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(logging): preserve UTF-16 in bounded log text

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
lsr911 2026-07-09 17:11:27 +08:00 committed by GitHub
parent f7cc6ebe1e
commit 0de5d37f92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 32 additions and 2 deletions

View file

@ -143,6 +143,23 @@ describe("diagnostic log events", () => {
expect(Object.hasOwn(event, "argsJson")).toBe(false);
});
it("keeps bounded diagnostic messages UTF-16 safe", async () => {
const received: Array<Extract<DiagnosticEventPayload, { type: "log.record" }>> = [];
const unsubscribe = onInternalDiagnosticEvent((event) => {
if (event.type === "log.record") {
received.push(event);
}
});
const prefix = "x".repeat(4_095);
getChildLogger({ subsystem: "diagnostic" }).info(`${prefix}😀tail`);
await flushDiagnosticEvents();
unsubscribe();
// The post-redaction bound keeps the first dot from the initial truncation marker.
expect(received.at(-1)?.message).toBe(`${prefix}....(truncated)`);
});
it("drops sensitive, blocked, and excess log attribute keys without copying large objects", async () => {
const received: Array<Extract<DiagnosticEventPayload, { type: "log.record" }>> = [];
const unsubscribe = onInternalDiagnosticEvent((evt) => {

View file

@ -192,6 +192,18 @@ describe("file log redaction", () => {
expect(record.message).toBe("request completed");
});
it("keeps bounded file-log messages UTF-16 safe", () => {
const logPath = logPathTracker.nextPath();
setLoggerOverride({ level: "info", file: logPath });
const prefix = "x".repeat(4_095);
getLogger().info(`${prefix}😀tail`);
const [line] = fs.readFileSync(logPath, "utf8").trim().split("\n");
const record = JSON.parse(line ?? "{}") as Record<string, unknown>;
expect(record.message).toBe(`${prefix}...(truncated)`);
});
it("retries hostname resolution after an empty value and caches the first real value", () => {
const logPath = logPathTracker.nextPath();
setLoggerOverride({ level: "info", file: logPath });

View file

@ -2,6 +2,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { Logger as TsLogger } from "tslog";
import type { OpenClawConfig } from "../config/types.js";
import {
@ -90,7 +91,7 @@ let cachedHostname: string | null = null;
type DiagnosticLogAttributes = Record<string, string | number | boolean>;
function clampDiagnosticLogText(value: string, maxChars: number): string {
return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value;
return value.length > maxChars ? `${truncateUtf16Safe(value, maxChars)}...(truncated)` : value;
}
function sanitizeDiagnosticLogText(value: string, maxChars: number): string {
@ -217,7 +218,7 @@ function getSortedNumericLogArgs(logObj: TsLogRecord): unknown[] {
}
function clampFileLogText(value: string, maxChars: number): string {
return value.length > maxChars ? `${value.slice(0, maxChars)}...(truncated)` : value;
return value.length > maxChars ? `${truncateUtf16Safe(value, maxChars)}...(truncated)` : value;
}
function normalizeFileLogContextValue(value: unknown): string | undefined {