mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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 } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user