diff --git a/extensions/acpx/src/pi-session-catalog-plugin.ts b/extensions/acpx/src/pi-session-catalog-plugin.ts index 3f9d90c2340a..c00ec7e3b548 100644 --- a/extensions/acpx/src/pi-session-catalog-plugin.ts +++ b/extensions/acpx/src/pi-session-catalog-plugin.ts @@ -20,8 +20,8 @@ import type { } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { + isExactPiSessionCursor, listLocalPiSessionPage, - optionalPiString, readLocalPiTranscriptPage, type PiSessionPage, } from "./pi-session-catalog.js"; @@ -35,7 +35,6 @@ const CAPABILITY = "pi-sessions"; const LOCAL_HOST_ID = "gateway"; const MAX_PAGE_LIMIT = 100; const MAX_HOSTS = 100; -const MAX_CURSOR_LENGTH = 128; const NODE_TIMEOUT_MS = 20_000; const SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u; const TRANSCRIPT_ITEM_TYPES = new Set([ @@ -244,13 +243,17 @@ async function listPiNodeHost( }; } try { + const cursor = query.cursors?.[hostId]; + if (cursor !== undefined && !isExactPiSessionCursor(cursor)) { + throw new Error("cursor is invalid"); + } const raw = await runtime.nodes.invoke({ nodeId: node.nodeId, command: PI_SESSIONS_LIST_COMMAND, params: { ...(query.limitPerHost ? { limit: query.limitPerHost } : {}), ...(query.search ? { searchTerm: query.search } : {}), - ...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}), + ...(cursor !== undefined ? { cursor } : {}), }, timeoutMs: NODE_TIMEOUT_MS, scopes: ["operator.write"], @@ -283,11 +286,11 @@ function parseNodeSessionPage(value: unknown): PiSessionPage { throw new Error("Pi node returned an invalid session page"); } const sessions = value.sessions; - const nextCursor = optionalPiString(value.nextCursor, MAX_CURSOR_LENGTH); - if (value.nextCursor !== undefined && !nextCursor) { + const nextCursor = value.nextCursor; + if (nextCursor !== undefined && !isExactPiSessionCursor(nextCursor)) { throw new Error("Pi node returned an invalid cursor"); } - return { sessions, ...(nextCursor ? { nextCursor } : {}) }; + return { sessions, ...(nextCursor !== undefined ? { nextCursor } : {}) }; } function parseNodeTranscriptPage(value: unknown, threadId: string): SessionsCatalogReadResult { @@ -300,15 +303,15 @@ function parseNodeTranscriptPage(value: unknown, threadId: string): SessionsCata ) { throw new Error("Pi node returned an invalid transcript page"); } - const nextCursor = optionalPiString(value.nextCursor, MAX_CURSOR_LENGTH); - if (value.nextCursor !== undefined && !nextCursor) { + const nextCursor = value.nextCursor; + if (nextCursor !== undefined && !isExactPiSessionCursor(nextCursor)) { throw new Error("Pi node returned an invalid cursor"); } return { hostId: LOCAL_HOST_ID, threadId, items: value.items, - ...(nextCursor ? { nextCursor } : {}), + ...(nextCursor !== undefined ? { nextCursor } : {}), }; } @@ -457,11 +460,15 @@ async function readPiTranscript( runtime: PluginRuntime, request: Parameters[0], ): Promise { + const cursor = request.cursor; + if (cursor !== undefined && !isExactPiSessionCursor(cursor)) { + throw new Error("cursor is invalid"); + } if (request.hostId === LOCAL_HOST_ID) { return await readLocalPiTranscriptPage({ threadId: request.threadId, ...(request.limit ? { limit: request.limit } : {}), - ...(request.cursor ? { cursor: request.cursor } : {}), + ...(cursor !== undefined ? { cursor } : {}), }); } if (!request.hostId.startsWith("node:")) { @@ -483,7 +490,7 @@ async function readPiTranscript( params: { threadId: request.threadId, ...(request.limit ? { limit: request.limit } : {}), - ...(request.cursor ? { cursor: request.cursor } : {}), + ...(cursor !== undefined ? { cursor } : {}), }, timeoutMs: NODE_TIMEOUT_MS, scopes: ["operator.write"], diff --git a/extensions/acpx/src/pi-session-catalog.test.ts b/extensions/acpx/src/pi-session-catalog.test.ts index 91a74f4fe86b..92cbfce1acc6 100644 --- a/extensions/acpx/src/pi-session-catalog.test.ts +++ b/extensions/acpx/src/pi-session-catalog.test.ts @@ -261,6 +261,27 @@ describe("Pi session catalog", () => { cursor: latest.nextCursor, }); expect(older.items.map((item) => item.type)).toEqual(["reasoning", "agentMessage"]); + const nonEmitted = Buffer.from(JSON.stringify({ offset: 2, extra: true }), "utf8").toString( + "base64url", + ); + const unsafeOffset = Buffer.from( + JSON.stringify({ offset: Number.MAX_SAFE_INTEGER + 1 }), + "utf8", + ).toString("base64url"); + for (const cursor of [ + `${latest.nextCursor}$`, + `${latest.nextCursor}=`, + ` ${latest.nextCursor} `, + nonEmitted, + unsafeOffset, + ]) { + await expect( + readLocalPiTranscriptPage({ + threadId: "pi-session", + cursor, + }), + ).rejects.toThrow("cursor is invalid"); + } await expect(listLocalPiSessionPage({ cursor: " " })).rejects.toThrow("cursor is invalid"); await expect( readLocalPiTranscriptPage({ threadId: "pi-session", cursor: 123 }), @@ -913,5 +934,61 @@ describe("Pi session catalog", () => { await expect(catalog!.read({ hostId: "node:node-1", threadId: "pi-remote" })).rejects.toThrow( "invalid transcript page", ); + + invoke.mockClear(); + await expect( + catalog!.read({ hostId: "node:node-1", threadId: "pi-remote", cursor: "" }), + ).rejects.toThrow("cursor is invalid"); + await expect( + catalog!.list({ + hostIds: ["node:node-1"], + cursors: { "node:node-1": "" }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, + }), + ]); + expect(invoke).not.toHaveBeenCalled(); + + invoke.mockResolvedValueOnce({ + payloadJSON: JSON.stringify({ sessions: [], nextCursor: " wrapped " }), + }); + await expect(catalog!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([ + expect.objectContaining({ + error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, + }), + ]); + invoke.mockResolvedValueOnce({ + payloadJSON: JSON.stringify({ + threadId: "pi-remote", + items: [], + nextCursor: " wrapped ", + }), + }); + await expect(catalog!.read({ hostId: "node:node-1", threadId: "pi-remote" })).rejects.toThrow( + "invalid cursor", + ); + + const exactCursor = Buffer.from(JSON.stringify({ offset: 1 }), "utf8").toString("base64url"); + invoke.mockResolvedValueOnce({ payloadJSON: JSON.stringify({ sessions: [] }) }); + await catalog!.list({ + hostIds: ["node:node-1"], + cursors: { "node:node-1": exactCursor }, + }); + expect(invoke).toHaveBeenLastCalledWith( + expect.objectContaining({ params: { cursor: exactCursor } }), + ); + invoke.mockResolvedValueOnce({ + payloadJSON: JSON.stringify({ threadId: "pi-remote", items: [] }), + }); + await catalog!.read({ + hostId: "node:node-1", + threadId: "pi-remote", + cursor: exactCursor, + }); + expect(invoke).toHaveBeenLastCalledWith( + expect.objectContaining({ params: { threadId: "pi-remote", cursor: exactCursor } }), + ); }); }); diff --git a/extensions/acpx/src/pi-session-catalog.ts b/extensions/acpx/src/pi-session-catalog.ts index 3c54aff3d771..e8073759b41a 100644 --- a/extensions/acpx/src/pi-session-catalog.ts +++ b/extensions/acpx/src/pi-session-catalog.ts @@ -19,7 +19,7 @@ const SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u; export type PiSessionPage = { sessions: SessionCatalogSession[]; nextCursor?: string }; -export function optionalPiString(value: unknown, maxLength: number): string | undefined { +function optionalPiString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } @@ -41,25 +41,52 @@ function encodeCursor(offset: number): string { return Buffer.from(JSON.stringify({ offset }), "utf8").toString("base64url"); } -function decodeCursor(value: unknown): number { +function optionalRawCursor(value: unknown): string | undefined { if (value === undefined) { - return 0; + return undefined; } - const cursor = optionalPiString(value, MAX_CURSOR_LENGTH); - if (!cursor) { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_CURSOR_LENGTH) { throw new Error("cursor is invalid"); } + return value; +} + +function decodeCursor(value: unknown): number { + const cursor = optionalRawCursor(value); + if (cursor === undefined) { + return 0; + } try { - const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown; - if (!isRecord(parsed) || !Number.isInteger(parsed.offset) || Number(parsed.offset) < 0) { + const bytes = Buffer.from(cursor, "base64url"); + if (bytes.toString("base64url") !== cursor) { + throw new Error("non-canonical base64url"); + } + const parsed = JSON.parse(bytes.toString("utf8")) as unknown; + if (!isRecord(parsed) || !Number.isSafeInteger(parsed.offset) || Number(parsed.offset) < 0) { throw new Error("invalid offset"); } - return Number(parsed.offset); + const offset = Number(parsed.offset); + if (encodeCursor(offset) !== cursor) { + throw new Error("non-canonical cursor payload"); + } + return offset; } catch (error) { throw new Error("cursor is invalid", { cause: error }); } } +export function isExactPiSessionCursor(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + try { + decodeCursor(value); + return true; + } catch { + return false; + } +} + function truncateUtf8(text: string, maxBytes: number): string { if (Buffer.byteLength(text, "utf8") <= maxBytes) { return text; @@ -162,10 +189,7 @@ function parseListParams(value: unknown): { searchTerm?: string; limit: number; if (value.searchTerm !== undefined && !searchTerm) { throw new Error("searchTerm is invalid"); } - const cursor = optionalPiString(value.cursor, MAX_CURSOR_LENGTH); - if (value.cursor !== undefined && !cursor) { - throw new Error("cursor is invalid"); - } + const cursor = optionalRawCursor(value.cursor); return { limit: boundedLimit(value.limit), ...(searchTerm ? { searchTerm } : {}), @@ -185,10 +209,7 @@ function parseReadParams(value: unknown): { threadId: string; limit: number; cur if (!threadId || !SESSION_ID_PATTERN.test(threadId)) { throw new Error("threadId is invalid"); } - const cursor = optionalPiString(value.cursor, MAX_CURSOR_LENGTH); - if (value.cursor !== undefined && !cursor) { - throw new Error("cursor is invalid"); - } + const cursor = optionalRawCursor(value.cursor); return { threadId, limit: boundedLimit(value.limit), diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index cf2298ef9610..714c460ad952 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -31,14 +31,13 @@ import { openOpenCodeCatalogTerminal, } from "./session-catalog-terminal.js"; import { + isExactOpenCodeSessionCursor, listLocalOpenCodeSessionPage, - optionalOpenCodeString, readLocalOpenCodeTranscriptPage, type OpenCodeSessionPage, } from "./session-catalog.js"; const MAX_HOSTS = 100; -const MAX_CURSOR_LENGTH = 128; const TRANSCRIPT_ITEM_TYPES = new Set([ "userMessage", "agentMessage", @@ -231,13 +230,17 @@ async function listOpenCodeNodeHost( }; } try { + const cursor = query.cursors?.[hostId]; + if (cursor !== undefined && !isExactOpenCodeSessionCursor(cursor)) { + throw new Error("cursor is invalid"); + } const raw = await runtime.nodes.invoke({ nodeId: node.nodeId, command: OPENCODE_SESSIONS_LIST_COMMAND, params: { ...(query.limitPerHost ? { limit: query.limitPerHost } : {}), ...(query.search ? { searchTerm: query.search } : {}), - ...(query.cursors?.[hostId] ? { cursor: query.cursors[hostId] } : {}), + ...(cursor !== undefined ? { cursor } : {}), }, timeoutMs: NODE_TIMEOUT_MS, scopes: ["operator.write"], @@ -273,11 +276,11 @@ function parseNodeSessionPage(value: unknown): OpenCodeSessionPage { throw new Error("OpenCode node returned an invalid session page"); } const sessions = value.sessions; - const nextCursor = optionalOpenCodeString(value.nextCursor, MAX_CURSOR_LENGTH); - if (value.nextCursor !== undefined && !nextCursor) { + const nextCursor = value.nextCursor; + if (nextCursor !== undefined && !isExactOpenCodeSessionCursor(nextCursor)) { throw new Error("OpenCode node returned an invalid cursor"); } - return { sessions, ...(nextCursor ? { nextCursor } : {}) }; + return { sessions, ...(nextCursor !== undefined ? { nextCursor } : {}) }; } function parseNodeTranscriptPage(value: unknown, threadId: string): SessionsCatalogReadResult { @@ -290,15 +293,15 @@ function parseNodeTranscriptPage(value: unknown, threadId: string): SessionsCata ) { throw new Error("OpenCode node returned an invalid transcript page"); } - const nextCursor = optionalOpenCodeString(value.nextCursor, MAX_CURSOR_LENGTH); - if (value.nextCursor !== undefined && !nextCursor) { + const nextCursor = value.nextCursor; + if (nextCursor !== undefined && !isExactOpenCodeSessionCursor(nextCursor)) { throw new Error("OpenCode node returned an invalid cursor"); } return { hostId: LOCAL_HOST_ID, threadId, items: value.items, - ...(nextCursor ? { nextCursor } : {}), + ...(nextCursor !== undefined ? { nextCursor } : {}), }; } @@ -363,11 +366,15 @@ async function readOpenCodeTranscript( runtime: PluginRuntime, request: Parameters[0], ): Promise { + const cursor = request.cursor; + if (cursor !== undefined && !isExactOpenCodeSessionCursor(cursor)) { + throw new Error("cursor is invalid"); + } if (request.hostId === LOCAL_HOST_ID) { return await readLocalOpenCodeTranscriptPage({ threadId: request.threadId, ...(request.limit ? { limit: request.limit } : {}), - ...(request.cursor ? { cursor: request.cursor } : {}), + ...(cursor !== undefined ? { cursor } : {}), }); } if (!request.hostId.startsWith("node:")) { @@ -389,7 +396,7 @@ async function readOpenCodeTranscript( params: { threadId: request.threadId, ...(request.limit ? { limit: request.limit } : {}), - ...(request.cursor ? { cursor: request.cursor } : {}), + ...(cursor !== undefined ? { cursor } : {}), }, timeoutMs: NODE_TIMEOUT_MS, scopes: ["operator.write"], diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index bffa750ef465..26edf2066cea 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -257,6 +257,27 @@ describe("OpenCode session catalog", () => { cursor: latest.nextCursor, }); expect(older.items.map((item) => item.type)).toEqual(["reasoning", "agentMessage"]); + const nonEmitted = Buffer.from(JSON.stringify({ offset: 2, extra: true }), "utf8").toString( + "base64url", + ); + const unsafeOffset = Buffer.from( + JSON.stringify({ offset: Number.MAX_SAFE_INTEGER + 1 }), + "utf8", + ).toString("base64url"); + for (const cursor of [ + `${latest.nextCursor}$`, + `${latest.nextCursor}=`, + ` ${latest.nextCursor} `, + nonEmitted, + unsafeOffset, + ]) { + await expect( + readLocalOpenCodeTranscriptPage({ + threadId: "ses_test", + cursor, + }), + ).rejects.toThrow("cursor is invalid"); + } await expect(listLocalOpenCodeSessionPage({ cursor: " " })).rejects.toThrow( "cursor is invalid", ); @@ -632,6 +653,62 @@ describe("OpenCode session catalog", () => { await expect(catalog!.read({ hostId: "node:node-1", threadId: "ses_remote" })).rejects.toThrow( "invalid transcript page", ); + + invoke.mockClear(); + await expect( + catalog!.read({ hostId: "node:node-1", threadId: "ses_remote", cursor: "" }), + ).rejects.toThrow("cursor is invalid"); + await expect( + catalog!.list({ + hostIds: ["node:node-1"], + cursors: { "node:node-1": "" }, + }), + ).resolves.toEqual([ + expect.objectContaining({ + error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, + }), + ]); + expect(invoke).not.toHaveBeenCalled(); + + invoke.mockResolvedValueOnce({ + payloadJSON: JSON.stringify({ sessions: [], nextCursor: " wrapped " }), + }); + await expect(catalog!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([ + expect.objectContaining({ + error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, + }), + ]); + invoke.mockResolvedValueOnce({ + payloadJSON: JSON.stringify({ + threadId: "ses_remote", + items: [], + nextCursor: " wrapped ", + }), + }); + await expect(catalog!.read({ hostId: "node:node-1", threadId: "ses_remote" })).rejects.toThrow( + "invalid cursor", + ); + + const exactCursor = Buffer.from(JSON.stringify({ offset: 1 }), "utf8").toString("base64url"); + invoke.mockResolvedValueOnce({ payloadJSON: JSON.stringify({ sessions: [] }) }); + await catalog!.list({ + hostIds: ["node:node-1"], + cursors: { "node:node-1": exactCursor }, + }); + expect(invoke).toHaveBeenLastCalledWith( + expect.objectContaining({ params: { cursor: exactCursor } }), + ); + invoke.mockResolvedValueOnce({ + payloadJSON: JSON.stringify({ threadId: "ses_remote", items: [] }), + }); + await catalog!.read({ + hostId: "node:node-1", + threadId: "ses_remote", + cursor: exactCursor, + }); + expect(invoke).toHaveBeenLastCalledWith( + expect.objectContaining({ params: { threadId: "ses_remote", cursor: exactCursor } }), + ); }); it.each(["stdout", "stderr"] as const)( diff --git a/extensions/opencode/session-catalog.ts b/extensions/opencode/session-catalog.ts index c7342ef33e84..7cf7150d6ce7 100644 --- a/extensions/opencode/session-catalog.ts +++ b/extensions/opencode/session-catalog.ts @@ -63,7 +63,7 @@ type OpenCodeReadParams = { cursor?: string; }; -export function optionalOpenCodeString(value: unknown, maxLength: number): string | undefined { +function optionalOpenCodeString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } @@ -85,25 +85,52 @@ function encodeCursor(offset: number): string { return Buffer.from(JSON.stringify({ offset }), "utf8").toString("base64url"); } -function decodeCursor(value: unknown): number { +function optionalRawCursor(value: unknown): string | undefined { if (value === undefined) { - return 0; + return undefined; } - const cursor = optionalOpenCodeString(value, MAX_CURSOR_LENGTH); - if (!cursor) { + if (typeof value !== "string" || value.length === 0 || value.length > MAX_CURSOR_LENGTH) { throw new Error("cursor is invalid"); } + return value; +} + +function decodeCursor(value: unknown): number { + const cursor = optionalRawCursor(value); + if (cursor === undefined) { + return 0; + } try { - const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown; - if (!isRecord(parsed) || !Number.isInteger(parsed.offset) || Number(parsed.offset) < 0) { + const bytes = Buffer.from(cursor, "base64url"); + if (bytes.toString("base64url") !== cursor) { + throw new Error("non-canonical base64url"); + } + const parsed = JSON.parse(bytes.toString("utf8")) as unknown; + if (!isRecord(parsed) || !Number.isSafeInteger(parsed.offset) || Number(parsed.offset) < 0) { throw new Error("invalid offset"); } - return Number(parsed.offset); + const offset = Number(parsed.offset); + if (encodeCursor(offset) !== cursor) { + throw new Error("non-canonical cursor payload"); + } + return offset; } catch (error) { throw new Error("cursor is invalid", { cause: error }); } } +export function isExactOpenCodeSessionCursor(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + try { + decodeCursor(value); + return true; + } catch { + return false; + } +} + function truncateUtf8(text: string, maxBytes: number): string { if (Buffer.byteLength(text, "utf8") <= maxBytes) { return text; @@ -173,10 +200,7 @@ function parseListParams( if (value.searchTerm !== undefined && !searchTerm) { throw new Error("searchTerm is invalid"); } - const cursor = optionalOpenCodeString(value.cursor, MAX_CURSOR_LENGTH); - if (value.cursor !== undefined && !cursor) { - throw new Error("cursor is invalid"); - } + const cursor = optionalRawCursor(value.cursor); return { limit: boundedLimit(value.limit), ...(searchTerm ? { searchTerm } : {}), @@ -198,10 +222,7 @@ function parseReadParams( if (!threadId || !SESSION_ID_PATTERN.test(threadId)) { throw new Error("threadId is invalid"); } - const cursor = optionalOpenCodeString(value.cursor, MAX_CURSOR_LENGTH); - if (value.cursor !== undefined && !cursor) { - throw new Error("cursor is invalid"); - } + const cursor = optionalRawCursor(value.cursor); return { threadId, limit: boundedLimit(value.limit), diff --git a/src/acp/translator.lifecycle.test.ts b/src/acp/translator.lifecycle.test.ts index 7d687a3256bd..b157693b5050 100644 --- a/src/acp/translator.lifecycle.test.ts +++ b/src/acp/translator.lifecycle.test.ts @@ -206,6 +206,11 @@ describe("acp translator stable lifecycle handlers", () => { ]); expect(second.sessions.map((session) => session.cwd)).toEqual(["/work/a", "/work/a"]); expect(second.nextCursor).toBeNull(); + await expect( + agent.listSessions( + createListSessionsRequest({ cwd: "/work/a", cursor: ` ${first.nextCursor} ` }), + ), + ).rejects.toThrow("Invalid ACP session list cursor."); expect(request).toHaveBeenNthCalledWith(1, "sessions.list", { limit: 3, includeDerivedTitles: true, diff --git a/src/acp/translator.session-list.test.ts b/src/acp/translator.session-list.test.ts index f862eda39e29..572f19b8d5d0 100644 --- a/src/acp/translator.session-list.test.ts +++ b/src/acp/translator.session-list.test.ts @@ -29,6 +29,18 @@ describe("ACP translator session list helpers", () => { ).toThrow("Invalid ACP session list cursor offset."); }); + it("rejects altered and non-emitted cursor spellings", () => { + const canonical = encodeListSessionsCursor({ offset: 25, cwd: "/tmp/work" }); + const extraField = Buffer.from( + JSON.stringify({ v: 1, offset: 25, cwd: "/tmp/work", extra: true }), + "utf8", + ).toString("base64url"); + + for (const cursor of [`${canonical}$`, `${canonical}=`, ` ${canonical} `, extraField]) { + expect(() => decodeListSessionsCursor(cursor)).toThrow("Invalid ACP session list cursor."); + } + }); + it("clamps page size metadata to the bridge maximum", () => { expect(resolveListSessionsPageSize(null)).toBe(100); expect(resolveListSessionsPageSize({ limit: 2.9 })).toBe(2); diff --git a/src/acp/translator.session-list.ts b/src/acp/translator.session-list.ts index cff340c1e505..a81fc47fbde8 100644 --- a/src/acp/translator.session-list.ts +++ b/src/acp/translator.session-list.ts @@ -24,12 +24,16 @@ export function encodeListSessionsCursor(cursor: ListSessionsCursor): string { /** Decodes and validates an ACP session-list cursor, defaulting to the first page. */ export function decodeListSessionsCursor(value: string | null | undefined): ListSessionsCursor { - if (!value) { + if (value === null || value === undefined) { return { offset: 0 }; } let parsed: unknown; try { - parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")); + const bytes = Buffer.from(value, "base64url"); + if (bytes.toString("base64url") !== value) { + throw new Error("non-canonical base64url"); + } + parsed = JSON.parse(bytes.toString("utf8")); } catch { throw new Error("Invalid ACP session list cursor."); } @@ -42,17 +46,21 @@ export function decodeListSessionsCursor(value: string | null | undefined): List } if ( typeof record.offset !== "number" || - !Number.isInteger(record.offset) || + !Number.isSafeInteger(record.offset) || record.offset < 0 || record.offset > ACP_LIST_SESSIONS_MAX_CURSOR_OFFSET ) { throw new Error("Invalid ACP session list cursor offset."); } const cwd = normalizeOptionalString(record.cwd); - return { + const cursor = { offset: record.offset, ...(cwd ? { cwd } : {}), }; + if (encodeListSessionsCursor(cursor) !== value) { + throw new Error("Invalid ACP session list cursor."); + } + return cursor; } /** Throws when an ACP method receives a relative cwd filter/path. */ diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 07f89996952d..65606fac715d 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -471,7 +471,7 @@ export class AcpGatewayAgent implements Agent { assertAbsoluteCwd(requestedCwd, "session/list"); } const fallbackCwd = requestedCwd ?? process.cwd(); - const rawCursor = normalizeOptionalString(params.cursor); + const rawCursor = params.cursor; const cursor = decodeListSessionsCursor(rawCursor); if (rawCursor && cursor.cwd !== requestedCwd) { throw new Error("ACP session list cursor does not match the cwd filter.");