Skip to content

Commit 58ef98e

Browse files
hugenshenNIO
andauthored
fix(agents): keep scp stderr tail UTF-16 safe end-to-end (#104148)
Co-authored-by: NIO <[email protected]>
1 parent a498fc5 commit 58ef98e

3 files changed

Lines changed: 40 additions & 2 deletions

File tree

src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ describe("stageSandboxMedia scp remote paths", () => {
9898
expect(stderr).not.toContain("start-");
9999
});
100100

101+
it("keeps scp stderr tail UTF-16 safe when the boundary bisects an emoji", () => {
102+
const stderr = appendScpStderrTail("prefix", "🤖tail", 5);
103+
104+
expect(stderr).toBe("tail");
105+
expect(
106+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(stderr),
107+
).toBe(false);
108+
});
109+
101110
it("rejects remote attachment filenames with shell metacharacters before spawning scp", async () => {
102111
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
103112
const { cfg, workspaceDir, sessionKey, remoteCacheDir } = createRemoteStageParams(home);

src/auto-reply/reply/stage-sandbox-media.scp.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { ChildProcess } from "node:child_process";
22
import { EventEmitter } from "node:events";
33
import { beforeEach, describe, expect, it, vi } from "vitest";
4-
import { testing } from "./stage-sandbox-media.js";
4+
import { SCP_STDERR_TAIL_CHARS, testing } from "./stage-sandbox-media.js";
5+
6+
const hasUnpairedUtf16Surrogate = (text: string): boolean =>
7+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(text);
58

69
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
710

@@ -48,6 +51,31 @@ describe("scpFile", () => {
4851
await expect(resultPromise).rejects.toThrow("scp failed (1): stderr EPIPE");
4952
});
5053

54+
it("surfaces UTF-16 safe scp stderr when transfer fails with emoji at tail boundary", async () => {
55+
const { child, stderr } = createChild();
56+
// Place the retained tail window on the emoji's low surrogate so raw slicing
57+
// would keep a lone surrogate half before the thrown error is built.
58+
const lowSurrogateTailStart = 100;
59+
const padding = "n".repeat(lowSurrogateTailStart - 1);
60+
const recent = "🤖" + "n".repeat(SCP_STDERR_TAIL_CHARS - 5) + "fail";
61+
62+
const resultPromise = testing.scpFile("host", "/remote/path", "/local/path");
63+
stderr.emit("data", padding);
64+
stderr.emit("data", recent);
65+
child.emit("close", 1);
66+
67+
let message = "";
68+
try {
69+
await resultPromise;
70+
} catch (error) {
71+
message = error instanceof Error ? error.message : String(error);
72+
}
73+
expect(message).toMatch(/^scp failed \(1\):/);
74+
expect(message).toContain("fail");
75+
expect(message).not.toContain("🤖");
76+
expect(hasUnpairedUtf16Surrogate(message)).toBe(false);
77+
});
78+
5179
it("does not terminate scp again when spawning fails", async () => {
5280
const { child, kill } = createChild();
5381

src/auto-reply/reply/stage-sandbox-media.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from "node:path";
66
import { fileURLToPath } from "node:url";
77
import { isInboundPathAllowed } from "@openclaw/media-core/inbound-path-policy";
88
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
9+
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
910
import { assertSandboxPath } from "../../agents/sandbox-paths.js";
1011
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
1112
import { slugifySessionKey } from "../../agents/sandbox/shared.js";
@@ -427,7 +428,7 @@ export function appendScpStderrTail(
427428
if (combined.length <= maxChars) {
428429
return combined;
429430
}
430-
return combined.slice(-maxChars);
431+
return sliceUtf16Safe(combined, Math.max(0, combined.length - maxChars));
431432
}
432433

433434
export const testing = { scpFile } as const;

0 commit comments

Comments
 (0)