fix(session-catalog): keep searches UTF-16 safe (#107719)

This commit is contained in:
Leon-SK668
2026-07-15 16:16:01 +08:00
committed by GitHub
parent 9e6cce6358
commit 42d42703e6
4 changed files with 111 additions and 10 deletions
@@ -19,6 +19,7 @@ import type {
SessionsCatalogReadResult,
} from "openclaw/plugin-sdk/session-catalog";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
listLocalPiSessionPage,
optionalPiString,
@@ -241,7 +242,7 @@ async function listPiNodeHost(
params: {
...(query.limitPerHost ? { limit: query.limitPerHost } : {}),
...(query.search?.trim()
? { searchTerm: query.search.trim().slice(0, MAX_SEARCH_LENGTH) }
? { searchTerm: truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) }
: {}),
...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}),
},
@@ -310,7 +311,9 @@ async function listPiHosts(
query: Parameters<SessionCatalogProvider["list"]>[0],
): Promise<SessionCatalogHost[]> {
const requested = query.hostIds ? new Set(query.hostIds) : undefined;
const searchTerm = query.search?.trim().slice(0, MAX_SEARCH_LENGTH) || undefined;
const searchTerm = query.search
? truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) || undefined
: undefined;
const hosts: SessionCatalogHost[] = [];
if ((!requested || requested.has(LOCAL_HOST_ID)) && piSessionStoreAvailable(process.env)) {
try {
+49 -3
View File
@@ -32,6 +32,10 @@ import { piSessionStore } from "./pi-session-paths.js";
const PI_SESSIONS_LIST_COMMAND = "acpx.pi.sessions.list.v1";
const PI_SESSION_READ_COMMAND = "acpx.pi.sessions.read.v1";
const PI_TERMINAL_RESUME_COMMAND = "acpx.pi.terminal.resume.v1";
const UTF16_SEARCH_PREFIX = "x".repeat(499);
const UTF16_BOUNDARY_SEARCH = `${UTF16_SEARCH_PREFIX}😀`;
const LONE_SURROGATE_PATTERN =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
const temporaryDirectories: string[] = [];
const originalSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR;
@@ -40,7 +44,10 @@ const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalPath = process.env.PATH;
async function createPiStore(assistantText = "hi"): Promise<string> {
async function createPiStore(
assistantText = "hi",
sessionName = "Pi catalog session",
): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-catalog-"));
temporaryDirectories.push(directory);
process.env.PI_CODING_AGENT_SESSION_DIR = directory;
@@ -94,7 +101,7 @@ async function createPiStore(assistantText = "hi"): Promise<string> {
id: "info-1",
parentId: "tool-1",
timestamp: "2026-07-13T10:00:04.000Z",
name: "Pi catalog session",
name: sessionName,
},
];
await fs.writeFile(
@@ -276,6 +283,32 @@ describe("Pi session catalog", () => {
]);
});
it("keeps local search needles UTF-16 safe at the length limit", async () => {
await createPiStore("hi", UTF16_SEARCH_PREFIX);
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
registerPiSessionCatalog({
pluginConfig: {},
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
provider = value;
},
registerNodeHostCommand: vi.fn(),
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(
provider!.list({ hostIds: ["gateway"], search: UTF16_BOUNDARY_SEARCH }),
).resolves.toEqual([
expect.objectContaining({
hostId: "gateway",
sessions: [expect.objectContaining({ threadId: "pi-session", name: UTF16_SEARCH_PREFIX })],
}),
]);
await expect(
provider!.list({ hostIds: ["gateway"], search: `${"y".repeat(499)}😀` }),
).resolves.toEqual([expect.objectContaining({ hostId: "gateway", sessions: [] })]);
});
it("summarizes and pages a large session within transport limits", async () => {
await createPiStore("x".repeat(600 * 1024));
const listed = await listLocalPiSessionPage({ limit: 20 });
@@ -735,11 +768,24 @@ describe("Pi session catalog", () => {
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(provider!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([
await expect(
provider!.list({ hostIds: ["node:node-1"], search: UTF16_BOUNDARY_SEARCH }),
).resolves.toEqual([
expect.objectContaining({
sessions: [expect.objectContaining({ threadId: "pi-remote", canOpenTerminal: true })],
}),
]);
expect(invoke).toHaveBeenNthCalledWith(1, {
nodeId: "node-1",
command: PI_SESSIONS_LIST_COMMAND,
params: { searchTerm: UTF16_SEARCH_PREFIX },
timeoutMs: 20_000,
scopes: ["operator.write"],
});
const firstRequest = invoke.mock.calls[0]?.[0] as
| { params?: { searchTerm?: string } }
| undefined;
expect(firstRequest?.params?.searchTerm).not.toMatch(LONE_SURROGATE_PATTERN);
await expect(
provider!.openTerminal!({ hostId: "node:node-1", threadId: "pi-remote" }),
).resolves.toEqual({
@@ -16,6 +16,7 @@ import type {
SessionsCatalogReadResult,
} from "openclaw/plugin-sdk/session-catalog";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
OPENCODE_LOCAL_SESSION_HOST_ID as LOCAL_HOST_ID,
OPENCODE_NODE_INVOKE_TIMEOUT_MS as NODE_TIMEOUT_MS,
@@ -244,7 +245,7 @@ async function listOpenCodeNodeHost(
params: {
...(query.limitPerHost ? { limit: query.limitPerHost } : {}),
...(query.search?.trim()
? { searchTerm: query.search.trim().slice(0, MAX_SEARCH_LENGTH) }
? { searchTerm: truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) }
: {}),
...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}),
},
@@ -316,7 +317,9 @@ async function listOpenCodeHosts(
query: Parameters<SessionCatalogProvider["list"]>[0],
): Promise<SessionCatalogHost[]> {
const requested = query.hostIds ? new Set(query.hostIds) : undefined;
const searchTerm = query.search?.trim().slice(0, MAX_SEARCH_LENGTH) || undefined;
const searchTerm = query.search
? truncateUtf16Safe(query.search.trim(), MAX_SEARCH_LENGTH) || undefined
: undefined;
const hosts: SessionCatalogHost[] = [];
if (
(!requested || requested.has(LOCAL_HOST_ID)) &&
+52 -3
View File
@@ -39,17 +39,24 @@ import {
readLocalOpenCodeTranscriptPage,
} from "./session-catalog.js";
const UTF16_SEARCH_PREFIX = "x".repeat(499);
const UTF16_BOUNDARY_SEARCH = `${UTF16_SEARCH_PREFIX}😀`;
const LONE_SURROGATE_PATTERN =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u;
const temporaryDirectories: string[] = [];
const originalPath = process.env.PATH;
const originalUnrelatedEnv = process.env.CATALOG_UNRELATED_ENV;
async function installFakeOpenCode(assistantText = "hi"): Promise<string> {
async function installFakeOpenCode(
assistantText = "hi",
sessionTitle = "Catalog session",
): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-opencode-catalog-"));
temporaryDirectories.push(directory);
const executable = path.join(directory, "opencode");
const session = {
id: "ses_test",
title: "Catalog session",
title: sessionTitle,
created: 1_700_000_000_000,
updated: 1_700_000_001_000,
projectId: "project",
@@ -196,6 +203,35 @@ describe("OpenCode session catalog", () => {
},
);
it.runIf(process.platform !== "win32")(
"keeps local search needles UTF-16 safe at the length limit",
async () => {
await installFakeOpenCode("hi", UTF16_SEARCH_PREFIX);
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
registerOpenCodeSessionCatalog({
pluginConfig: {},
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
provider = value;
},
registerNodeHostCommand: vi.fn(),
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(
provider!.list({ hostIds: ["gateway"], search: UTF16_BOUNDARY_SEARCH }),
).resolves.toEqual([
expect.objectContaining({
hostId: "gateway",
sessions: [expect.objectContaining({ threadId: "ses_test", name: UTF16_SEARCH_PREFIX })],
}),
]);
await expect(
provider!.list({ hostIds: ["gateway"], search: `${"y".repeat(499)}😀` }),
).resolves.toEqual([expect.objectContaining({ hostId: "gateway", sessions: [] })]);
},
);
it.runIf(process.platform !== "win32")(
"keeps oversized transcript items below the node payload budget",
async () => {
@@ -368,11 +404,24 @@ describe("OpenCode session catalog", () => {
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(provider!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([
await expect(
provider!.list({ hostIds: ["node:node-1"], search: UTF16_BOUNDARY_SEARCH }),
).resolves.toEqual([
expect.objectContaining({
sessions: [expect.objectContaining({ threadId: "ses_remote", canOpenTerminal: true })],
}),
]);
expect(invoke).toHaveBeenNthCalledWith(1, {
nodeId: "node-1",
command: OPENCODE_SESSIONS_LIST_COMMAND,
params: { searchTerm: UTF16_SEARCH_PREFIX },
timeoutMs: 35_000,
scopes: ["operator.write"],
});
const firstRequest = invoke.mock.calls[0]?.[0] as
| { params?: { searchTerm?: string } }
| undefined;
expect(firstRequest?.params?.searchTerm).not.toMatch(LONE_SURROGATE_PATTERN);
await expect(
provider!.openTerminal!({ hostId: "node:node-1", threadId: "ses_remote" }),
).resolves.toEqual({