fix(acp): reject malformed session list cursors (#107895)

* fix(acp): reject malformed session list cursors

* fix(session-catalog): validate pagination cursors exactly

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

* refactor(session-catalog): align search normalization owner

* refactor(session-catalog): keep parsing helpers private

Co-authored-by: 杨浩宇0668001029 <yang.haoyu@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
mushuiyu886
2026-07-19 07:38:02 +08:00
committed by GitHub
parent 828bcd8231
commit d411559dfb
10 changed files with 294 additions and 59 deletions
@@ -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<SessionCatalogProvider["read"]>[0],
): Promise<SessionsCatalogReadResult> {
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"],
@@ -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 } }),
);
});
});
+37 -16
View File
@@ -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),
+18 -11
View File
@@ -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<SessionCatalogProvider["read"]>[0],
): Promise<SessionsCatalogReadResult> {
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"],
@@ -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)(
+37 -16
View File
@@ -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),
+5
View File
@@ -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,
+12
View File
@@ -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);
+12 -4
View File
@@ -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. */
+1 -1
View File
@@ -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.");