dsh-cherry-bridge 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -38,13 +38,13 @@ flowchart TB
38
38
 
39
39
  ```bash
40
40
  # From npm (recommended, pinned)
41
- npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.6
41
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.7
42
42
 
43
43
  # Install the latest npm release
44
44
  npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
45
45
 
46
46
  # From GitHub source (same commit as the npm release)
47
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.6
47
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.7
48
48
  ```
49
49
 
50
50
  The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
package/README.md CHANGED
@@ -36,13 +36,13 @@ flowchart TB
36
36
 
37
37
  ```bash
38
38
  # 从 npm 安装(推荐,钉住版本)
39
- npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.6
39
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.7
40
40
 
41
41
  # 安装 npm 最新发布版
42
42
  npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
43
43
 
44
44
  # 从 GitHub 源码安装(与 npm 发布同一提交)
45
- npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.6
45
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.7
46
46
  ```
47
47
 
48
48
  本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
package/dist/index.mjs CHANGED
@@ -2261,6 +2261,21 @@ const RECORD_BUDGET_BYTES = 63488;
2261
2261
  const PARTS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 16896);
2262
2262
  /** Interactions per interactions.list page: inline input + inline summary. */
2263
2263
  const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
2264
+ /**
2265
+ * Hard ceiling on ONE resume token (issues #70 and #74), in characters.
2266
+ *
2267
+ * BOTH faces take a token back from the caller — `cursor` on the four list
2268
+ * tools, `messagesResumeCursor` on `cherry_read` — and both tail it with a
2269
+ * peer-supplied position whose length we do not control: the desktop's session
2270
+ * cursor is `entity:<orderKey>:<uuid>` (~57 characters), a message cursor
2271
+ * `<ms>:<uuid>`, the numeric `pageOf` faces a handful of digits, and a future
2272
+ * desktop may use something longer — but nothing legitimate comes near this.
2273
+ * The bound exists so a hand-built or model-mangled string is refused by a
2274
+ * BOUND instead of being split, hashed and carried to the peer, and so both
2275
+ * parameters can declare `maxLength` honestly. ONE number for both faces: it
2276
+ * bounds caller INPUT, not what a token means.
2277
+ */
2278
+ const MAX_RESUME_TOKEN_CHARS = 4096;
2264
2279
  //#endregion
2265
2280
  //#region src/urlHost.ts
2266
2281
  /**
@@ -5462,11 +5477,12 @@ var CherryToolEngine = class {
5462
5477
  * #65). What a caller can act on is a token, so that is what is emitted.
5463
5478
  */
5464
5479
  #listPage(page, binding) {
5480
+ const position = optionalString(page.nextCursor);
5465
5481
  return {
5466
5482
  items: page.items,
5467
5483
  truncated: page.truncated,
5468
5484
  ...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
5469
- ...page.truncated && page.nextCursor !== void 0 ? { resumeCursor: encodeListResumeCursor(binding, page.nextCursor) } : {}
5485
+ ...page.truncated && position !== void 0 && !mangledRemainder(position) ? { resumeCursor: encodeListResumeCursor(binding, position) } : {}
5470
5486
  };
5471
5487
  }
5472
5488
  #devicesTool() {
@@ -5720,7 +5736,8 @@ var CherryToolEngine = class {
5720
5736
  },
5721
5737
  messagesResumeCursor: {
5722
5738
  type: "string",
5723
- description: "A `messagesResumeCursor` a previous truncated read (this tool, or cherry_send/cherry_wait) returned: `\"cursor:<peer position>\"` or `\"after:<messageId>\"`. Continues the walk at that stop point instead of at the newest message; `limit` bounds what comes back from there. Omit it to read from the newest message."
5739
+ maxLength: MAX_RESUME_TOKEN_CHARS,
5740
+ description: `A \`messagesResumeCursor\` a previous truncated read (this tool, or cherry_send/cherry_wait) returned: \`"cursor:<peer position>"\` or \`"after:<messageId>"\`. Continues the walk at that stop point instead of at the newest message; \`limit\` bounds what comes back from there. Omit it to read from the newest message. A token this tool did not emit is refused locally, and ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling.`
5724
5741
  }
5725
5742
  },
5726
5743
  required: ["device", "sessionId"],
@@ -8231,9 +8248,11 @@ function parseMessageResume(value) {
8231
8248
  };
8232
8249
  if (typeof value !== "string") refuse();
8233
8250
  const token = value;
8251
+ if (token.length > 4096) throw new Error(`cherry_read: messagesResumeCursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit the argument to read from the newest message.`);
8234
8252
  if (token.startsWith(RESUME_CURSOR_PREFIX)) {
8235
8253
  const cursor = token.slice(7);
8236
8254
  if (mangledRemainder(cursor)) refuse();
8255
+ if (looksLikeV1ListToken(cursor)) throw new Error("cherry_read: messagesResumeCursor is a v1 LIST `resumeCursor` (issued by an older plugin version, before list tokens were compact): it resumes a list walk, not a message history, and it can no longer be verified. Omit the argument to read from the newest message, or pass the token this tool returned.");
8237
8256
  return {
8238
8257
  kind: "cursor",
8239
8258
  cursor
@@ -8247,6 +8266,7 @@ function parseMessageResume(value) {
8247
8266
  messageId
8248
8267
  };
8249
8268
  }
8269
+ if (token.startsWith(LIST_RESUME_PREFIX)) throw new Error("cherry_read: messagesResumeCursor is a LIST `resumeCursor` (`list:…`) — it resumes a list walk (cherry_agents / cherry_workspaces / cherry_sessions / cherry_interactions), not a message history. Omit the argument to read from the newest message, or pass the token this tool returned.");
8250
8270
  return refuse();
8251
8271
  }
8252
8272
  /**
@@ -8259,27 +8279,112 @@ function parseMessageResume(value) {
8259
8279
  * another query is a perfectly valid number that reads the WRONG slice with no
8260
8280
  * error anywhere. So this token carries a fingerprint of the binding and a
8261
8281
  * mismatched replay is refused HERE — before any RPC.
8282
+ *
8283
+ * Wire format (issue #70):
8284
+ * `list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>` — the
8285
+ * position verbatim and LAST. It replaced a base64 JSON blob (`{v,m,d,h,p}`)
8286
+ * that was ~2.3x longer and had no integrity check: measured over 20,000 random
8287
+ * single-character corruptions, 28.7% of the old tokens were ACCEPTED with a
8288
+ * different position, because the position is the one field a structural check
8289
+ * cannot validate. The compact form plus the checksum makes that class empty.
8290
+ *
8291
+ * The prefix is `list:` and NOT `cursor:` (issue #72): the message face already
8292
+ * owns `cursor:` / `after:`, and while the two faces shared a prefix a list
8293
+ * token was a legal `messagesResumeCursor` payload — it was forwarded to the
8294
+ * desktop, which answers an unparseable position by serving its FIRST page, so
8295
+ * "continue from here" silently re-read the newest page. Two faces, two
8296
+ * prefixes: the refusal is a PREFIX check on either side, never an inspection
8297
+ * of the peer's position (a position that merely starts with `list:` still
8298
+ * travels verbatim inside its own `cursor:` token).
8299
+ */
8300
+ const LIST_RESUME_PREFIX = "list:";
8301
+ /**
8302
+ * The token's format marker (issue #70). It is a FIELD, not part of the prefix,
8303
+ * so a token from the PREVIOUS format is recognised and refused by name
8304
+ * ({@link looksLikeV1ListToken}) instead of getting the generic shape error: a
8305
+ * resume token outlives a plugin upgrade (the caller is holding it), and "start
8306
+ * the walk over" is actionable where "not a token this tool emitted" invites
8307
+ * one more attempt with the same string.
8308
+ *
8309
+ * A format NEWER than this one has no detector and falls through to the
8310
+ * generic refusal, which is the honest verdict: this release cannot know that a
8311
+ * future format is a resume token at all.
8312
+ */
8313
+ const LIST_TOKEN_VERSION = "v2";
8314
+ /**
8315
+ * One character per list tool, replacing the full method name (19 characters of
8316
+ * a 225-character token). Every method a list tool can issue MUST appear here:
8317
+ * {@link encodeListResumeCursor} throws on an unknown one rather than emit a
8318
+ * token the matching parser could never accept.
8319
+ */
8320
+ const LIST_TOOL_CODES = {
8321
+ "agent.agents.list": "a",
8322
+ "agent.workspaces.list": "w",
8323
+ "agent.sessions.list": "s",
8324
+ "agent.interactions.list": "i"
8325
+ };
8326
+ /** The inverse of {@link LIST_TOOL_CODES}, for {@link parseListResumeCursor}. */
8327
+ const LIST_CODE_METHODS = Object.fromEntries(Object.entries(LIST_TOOL_CODES).map(([method, code]) => [code, method]));
8328
+ /**
8329
+ * The fields after the version marker, BEFORE the peer position: tool code,
8330
+ * device digest, filter fingerprint and checksum. The position is the rest of
8331
+ * the token — it is peer-supplied and may contain the delimiter itself (the
8332
+ * desktop's session cursor is `entity:<orderKey>:<uuid>`), so the split stops
8333
+ * at a fixed count and never consumes it.
8262
8334
  */
8263
- const LIST_RESUME_PREFIX = "cursor:";
8264
- /** The `cursor` parameter every list tool shares (issue #65). */
8335
+ const LIST_TOKEN_FIELDS = 4;
8336
+ /** The `cursor` parameter every list tool shares (issues #65 and #70). */
8265
8337
  const LIST_CURSOR_PARAMETER = {
8266
8338
  type: "string",
8267
- description: "A `resumeCursor` a previous TRUNCATED answer of this tool returned: replay it with the SAME device and filters to continue from that stop point (`limit` then bounds this answer). A token from another tool, device or filter set is refused locally."
8339
+ maxLength: MAX_RESUME_TOKEN_CHARS,
8340
+ description: `A \`resumeCursor\` a previous TRUNCATED answer of this tool returned: replay it with the SAME device and filters to continue from that stop point (\`limit\` then bounds this answer). A token from another tool, device or filter set is refused locally, and so is a token this account cannot have received intact (the token carries a checksum; ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling).`
8268
8341
  };
8269
8342
  /** Canonical, key-sorted rendering of the caller's filters (undefined keys dropped). */
8270
8343
  function listParamsFingerprint(params) {
8271
8344
  const entries = Object.keys(params).sort().filter((key) => params[key] !== void 0).map((key) => [key, params[key]]);
8272
- return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 16);
8345
+ return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 8);
8346
+ }
8347
+ /**
8348
+ * The device half of the binding, as a digest rather than the id itself: a
8349
+ * `deviceId` is a 36-character UUID, and the token only ever has to answer "was
8350
+ * this issued for the device I just resolved?", never "which device was it?".
8351
+ * 12 hex characters (48 bits) make an accidental collision between two paired
8352
+ * desktops a non-event, where the old field was a third of the token.
8353
+ */
8354
+ function listDeviceFingerprint(deviceId) {
8355
+ return createHash("sha256").update(deviceId).digest("hex").slice(0, 12);
8356
+ }
8357
+ /**
8358
+ * The token's integrity check (issue #70). It covers the peer position, which
8359
+ * is the ONE field no structural check can validate: a substitution there used
8360
+ * to decode as a perfectly well-formed token and was forwarded, and the desktop
8361
+ * silently falls back to its FIRST page for a position it cannot parse — so the
8362
+ * caller's "continue from here" came back as the newest page presented as older
8363
+ * history. 8 hex characters (32 bits) turn every single-character corruption
8364
+ * into a local refusal; the token is not a security boundary, so a MAC would
8365
+ * only make it longer.
8366
+ */
8367
+ function listTokenChecksum(body, peerPosition) {
8368
+ return createHash("sha256").update(`${body}\u0000${peerPosition}`).digest("hex").slice(0, 8);
8273
8369
  }
8274
8370
  function encodeListResumeCursor(binding, peerPosition) {
8275
- const payload = {
8276
- v: 1,
8277
- m: binding.method,
8278
- d: binding.deviceId,
8279
- h: listParamsFingerprint(binding.params),
8280
- p: peerPosition
8281
- };
8282
- return LIST_RESUME_PREFIX + Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
8371
+ const code = LIST_TOOL_CODES[binding.method];
8372
+ if (code === void 0) throw new Error(`${binding.method}: no list resume token code`);
8373
+ const body = `${LIST_TOKEN_VERSION}:${code}:${listDeviceFingerprint(binding.deviceId)}:${listParamsFingerprint(binding.params)}`;
8374
+ return `${LIST_RESUME_PREFIX}${body}:${listTokenChecksum(body, peerPosition)}:${peerPosition}`;
8375
+ }
8376
+ /**
8377
+ * Whether a `cursor:` remainder is a token from the base64-JSON era (issue #65,
8378
+ * `v: 1`). Only used to explain a refusal: the payload is never replayed.
8379
+ */
8380
+ function looksLikeV1ListToken(raw) {
8381
+ if (raw === "" || raw.includes(":")) return false;
8382
+ try {
8383
+ const decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
8384
+ return typeof decoded === "object" && decoded !== null && decoded["v"] === 1;
8385
+ } catch {
8386
+ return false;
8387
+ }
8283
8388
  }
8284
8389
  /**
8285
8390
  * Decode a list token, refusing anything that is not one this tool emitted for
@@ -8294,21 +8399,33 @@ function parseListResumeCursor(value, binding) {
8294
8399
  const refuse = (why) => {
8295
8400
  throw new Error(`cursor ${why}; omit \`cursor\` to read from the start, or replay the token with the same tool, device and filters.`);
8296
8401
  };
8297
- if (typeof value !== "string" || !value.startsWith(LIST_RESUME_PREFIX)) refuse("must be a `cursor:` token this tool emitted");
8298
- const raw = value.slice(7);
8299
- let decoded;
8300
- try {
8301
- decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
8302
- } catch {
8402
+ if (typeof value !== "string") refuse("must be a `list:` token this tool emitted");
8403
+ const token = value;
8404
+ if (token.length > 4096) throw new Error(`cursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit \`cursor\` to read from the start.`);
8405
+ if (token.startsWith(RESUME_CURSOR_PREFIX)) {
8406
+ if (looksLikeV1ListToken(token.slice(7))) throw new Error("cursor was issued by an older plugin version (a v1 list token, from before list tokens were compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.");
8407
+ refuse("is a MESSAGE-face token (`cursor:<peer position>`, i.e. a `messagesResumeCursor`), which a list walk never issues — a list token starts with `list:`");
8408
+ }
8409
+ if (!token.startsWith(LIST_RESUME_PREFIX)) refuse("must be a `list:` token this tool emitted");
8410
+ const raw = token.slice(5);
8411
+ if (!raw.startsWith(`${LIST_TOKEN_VERSION}:`)) {
8412
+ if (looksLikeV1ListToken(raw)) throw new Error("cursor was issued by an older plugin version (a v1 token, from before list tokens were compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.");
8303
8413
  refuse("is not a token this tool emitted");
8304
8414
  }
8305
- if (typeof decoded !== "object" || decoded === null) refuse("is not a token this tool emitted");
8306
- const payload = decoded;
8307
- if (payload["v"] !== 1 || typeof payload["m"] !== "string" || typeof payload["d"] !== "string" || typeof payload["h"] !== "string" || typeof payload["p"] !== "string") refuse("is not a token this tool emitted");
8308
- if (payload["m"] !== binding.method) refuse("was issued for a different tool");
8309
- if (payload["d"] !== binding.deviceId) refuse("was issued for a different device");
8310
- if (payload["h"] !== listParamsFingerprint(binding.params)) refuse("was issued for a different filter set");
8311
- const peerPosition = payload["p"];
8415
+ let rest = raw.slice(3);
8416
+ const fields = [];
8417
+ for (let index = 0; index < LIST_TOKEN_FIELDS; index += 1) {
8418
+ const separator = rest.indexOf(":");
8419
+ if (separator < 0) refuse("is not a token this tool emitted");
8420
+ fields.push(rest.slice(0, separator));
8421
+ rest = rest.slice(separator + 1);
8422
+ }
8423
+ const [code, device, filters, checksum] = fields;
8424
+ const peerPosition = rest;
8425
+ if (checksum !== listTokenChecksum(`${LIST_TOKEN_VERSION}:${code}:${device}:${filters}`, peerPosition)) refuse("is not a token this tool emitted (its checksum does not match, so it was altered in transit)");
8426
+ if (LIST_CODE_METHODS[code] !== binding.method) refuse("was issued for a different tool");
8427
+ if (device !== listDeviceFingerprint(binding.deviceId)) refuse("was issued for a different device");
8428
+ if (filters !== listParamsFingerprint(binding.params)) refuse("was issued for a different filter set");
8312
8429
  if (mangledRemainder(peerPosition)) refuse("is not a token this tool emitted");
8313
8430
  return peerPosition;
8314
8431
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",
package/src/defaults.ts CHANGED
@@ -137,3 +137,19 @@ export const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(
137
137
 
138
138
  /** Items per every other aggregated list page (agents/sessions/messages). */
139
139
  export const LIST_PAGE_CLAMP = 16
140
+
141
+ /**
142
+ * Hard ceiling on ONE resume token (issues #70 and #74), in characters.
143
+ *
144
+ * BOTH faces take a token back from the caller — `cursor` on the four list
145
+ * tools, `messagesResumeCursor` on `cherry_read` — and both tail it with a
146
+ * peer-supplied position whose length we do not control: the desktop's session
147
+ * cursor is `entity:<orderKey>:<uuid>` (~57 characters), a message cursor
148
+ * `<ms>:<uuid>`, the numeric `pageOf` faces a handful of digits, and a future
149
+ * desktop may use something longer — but nothing legitimate comes near this.
150
+ * The bound exists so a hand-built or model-mangled string is refused by a
151
+ * BOUND instead of being split, hashed and carried to the peer, and so both
152
+ * parameters can declare `maxLength` honestly. ONE number for both faces: it
153
+ * bounds caller INPUT, not what a token means.
154
+ */
155
+ export const MAX_RESUME_TOKEN_CHARS = 4_096
package/src/tools.ts CHANGED
@@ -6,6 +6,7 @@ import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
6
6
  import {
7
7
  INTERACTIONS_LIST_PAGE_CLAMP,
8
8
  LIST_PAGE_CLAMP,
9
+ MAX_RESUME_TOKEN_CHARS,
9
10
  PARTS_LIST_PAGE_CLAMP,
10
11
  } from './defaults.js'
11
12
  import { JsonRpcRequestError, RequestTimeoutError } from './rpc/jsonRpcClient.js'
@@ -2097,12 +2098,20 @@ class CherryToolEngine {
2097
2098
  * #65). What a caller can act on is a token, so that is what is emitted.
2098
2099
  */
2099
2100
  #listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
2101
+ // A position this face would REFUSE is not a position it may name. The
2102
+ // round-trip symmetry (a token a tool emits is a token that tool accepts) is
2103
+ // the whole point of the token, so a peer answering `nextCursor: ''` — or a
2104
+ // position carrying whitespace/control characters, which `mangledRemainder`
2105
+ // rejects on the way back — gets NO token; `truncated` still says the walk
2106
+ // was cut and a wider `limit` is the remedy. `messageResumeToken` guards the
2107
+ // same hole for the message face.
2108
+ const position = optionalString(page.nextCursor)
2100
2109
  return {
2101
2110
  items: page.items,
2102
2111
  truncated: page.truncated,
2103
2112
  ...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
2104
- ...(page.truncated && page.nextCursor !== undefined
2105
- ? { resumeCursor: encodeListResumeCursor(binding, page.nextCursor) }
2113
+ ...(page.truncated && position !== undefined && !mangledRemainder(position)
2114
+ ? { resumeCursor: encodeListResumeCursor(binding, position) }
2106
2115
  : {}),
2107
2116
  }
2108
2117
  }
@@ -2400,11 +2409,13 @@ class CherryToolEngine {
2400
2409
  },
2401
2410
  messagesResumeCursor: {
2402
2411
  type: 'string',
2412
+ maxLength: MAX_RESUME_TOKEN_CHARS,
2403
2413
  description:
2404
2414
  'A `messagesResumeCursor` a previous truncated read (this tool, or cherry_send/cherry_wait) ' +
2405
2415
  'returned: `"cursor:<peer position>"` or `"after:<messageId>"`. Continues the walk at that ' +
2406
2416
  'stop point instead of at the newest message; `limit` bounds what comes back from there. ' +
2407
- 'Omit it to read from the newest message.',
2417
+ 'Omit it to read from the newest message. A token this tool did not emit is refused locally, ' +
2418
+ `and ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling.`,
2408
2419
  },
2409
2420
  },
2410
2421
  required: ['device', 'sessionId'],
@@ -6224,9 +6235,36 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6224
6235
  }
6225
6236
  if (typeof value !== 'string') refuse()
6226
6237
  const token = value as string
6238
+ // Issue #74: the ceiling is judged on the WHOLE token, before either form's
6239
+ // prefix — so `cursor:` and `after:` get the same verdict, and no decoding,
6240
+ // scanning or dial happens for a string that cannot be one of ours. It is the
6241
+ // same bound the list face enforces ({@link MAX_RESUME_TOKEN_CHARS}).
6242
+ if (token.length > MAX_RESUME_TOKEN_CHARS) {
6243
+ throw new Error(
6244
+ `cherry_read: messagesResumeCursor is ${token.length} characters long, over the ` +
6245
+ `${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit the argument to read from the ` +
6246
+ 'newest message.',
6247
+ )
6248
+ }
6227
6249
  if (token.startsWith(RESUME_CURSOR_PREFIX)) {
6228
6250
  const cursor = token.slice(RESUME_CURSOR_PREFIX.length)
6229
6251
  if (mangledRemainder(cursor)) refuse()
6252
+ // Issue #72, the RELEASED half: a v1 list token (0.1.6 — `cursor:` +
6253
+ // base64url of `{v,m,d,h,p}`) lives under this prefix and its payload is
6254
+ // colon-free base64url, so it arrives here as a perfectly ordinary
6255
+ // position and used to be forwarded to the desktop, which answers a
6256
+ // position it cannot parse by serving its FIRST page. The detector fires
6257
+ // only for a colon-free remainder that base64-decodes to a `{v:1,…}`
6258
+ // object: every real peer position is `<ms>:<id>` (has a colon) or a
6259
+ // numeric offset, so none can be mistaken for one.
6260
+ if (looksLikeV1ListToken(cursor)) {
6261
+ throw new Error(
6262
+ 'cherry_read: messagesResumeCursor is a v1 LIST `resumeCursor` (issued by an older plugin version, ' +
6263
+ 'before list tokens were compact): it resumes a list walk, not a message history, and it can no ' +
6264
+ 'longer be verified. Omit the argument to read from the newest message, or pass the token this ' +
6265
+ 'tool returned.',
6266
+ )
6267
+ }
6230
6268
  return { kind: 'cursor', cursor }
6231
6269
  }
6232
6270
  if (token.startsWith(RESUME_ANCHOR_PREFIX)) {
@@ -6234,6 +6272,21 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6234
6272
  if (mangledRemainder(messageId)) refuse()
6235
6273
  return { kind: 'anchor', messageId }
6236
6274
  }
6275
+ if (token.startsWith(LIST_RESUME_PREFIX)) {
6276
+ // Issue #72, the other direction: a token the four list tools minted. The
6277
+ // two faces are told apart by PREFIX, never by scanning a position for our
6278
+ // own marker — `cursor:list:20` above is still a message cursor. (The branch
6279
+ // above is the one exception, and it is not about the peer's format: it
6280
+ // DECODES our own retired v1 list token, whose payload happens to be
6281
+ // indistinguishable from a position.) Forwarding a list token would hand
6282
+ // the desktop a position it cannot parse, and it answers that by serving
6283
+ // its FIRST page as if it were the continuation.
6284
+ throw new Error(
6285
+ 'cherry_read: messagesResumeCursor is a LIST `resumeCursor` (`list:…`) — it resumes a list walk ' +
6286
+ '(cherry_agents / cherry_workspaces / cherry_sessions / cherry_interactions), not a message history. ' +
6287
+ 'Omit the argument to read from the newest message, or pass the token this tool returned.',
6288
+ )
6289
+ }
6237
6290
  return refuse()
6238
6291
  }
6239
6292
 
@@ -6247,16 +6300,77 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
6247
6300
  * another query is a perfectly valid number that reads the WRONG slice with no
6248
6301
  * error anywhere. So this token carries a fingerprint of the binding and a
6249
6302
  * mismatched replay is refused HERE — before any RPC.
6303
+ *
6304
+ * Wire format (issue #70):
6305
+ * `list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>` — the
6306
+ * position verbatim and LAST. It replaced a base64 JSON blob (`{v,m,d,h,p}`)
6307
+ * that was ~2.3x longer and had no integrity check: measured over 20,000 random
6308
+ * single-character corruptions, 28.7% of the old tokens were ACCEPTED with a
6309
+ * different position, because the position is the one field a structural check
6310
+ * cannot validate. The compact form plus the checksum makes that class empty.
6311
+ *
6312
+ * The prefix is `list:` and NOT `cursor:` (issue #72): the message face already
6313
+ * owns `cursor:` / `after:`, and while the two faces shared a prefix a list
6314
+ * token was a legal `messagesResumeCursor` payload — it was forwarded to the
6315
+ * desktop, which answers an unparseable position by serving its FIRST page, so
6316
+ * "continue from here" silently re-read the newest page. Two faces, two
6317
+ * prefixes: the refusal is a PREFIX check on either side, never an inspection
6318
+ * of the peer's position (a position that merely starts with `list:` still
6319
+ * travels verbatim inside its own `cursor:` token).
6250
6320
  */
6251
- const LIST_RESUME_PREFIX = 'cursor:'
6321
+ const LIST_RESUME_PREFIX = 'list:'
6252
6322
 
6253
- /** The `cursor` parameter every list tool shares (issue #65). */
6323
+ /**
6324
+ * The token's format marker (issue #70). It is a FIELD, not part of the prefix,
6325
+ * so a token from the PREVIOUS format is recognised and refused by name
6326
+ * ({@link looksLikeV1ListToken}) instead of getting the generic shape error: a
6327
+ * resume token outlives a plugin upgrade (the caller is holding it), and "start
6328
+ * the walk over" is actionable where "not a token this tool emitted" invites
6329
+ * one more attempt with the same string.
6330
+ *
6331
+ * A format NEWER than this one has no detector and falls through to the
6332
+ * generic refusal, which is the honest verdict: this release cannot know that a
6333
+ * future format is a resume token at all.
6334
+ */
6335
+ const LIST_TOKEN_VERSION = 'v2'
6336
+
6337
+ /**
6338
+ * One character per list tool, replacing the full method name (19 characters of
6339
+ * a 225-character token). Every method a list tool can issue MUST appear here:
6340
+ * {@link encodeListResumeCursor} throws on an unknown one rather than emit a
6341
+ * token the matching parser could never accept.
6342
+ */
6343
+ const LIST_TOOL_CODES: Readonly<Record<string, string>> = {
6344
+ 'agent.agents.list': 'a',
6345
+ 'agent.workspaces.list': 'w',
6346
+ 'agent.sessions.list': 's',
6347
+ 'agent.interactions.list': 'i',
6348
+ }
6349
+
6350
+ /** The inverse of {@link LIST_TOOL_CODES}, for {@link parseListResumeCursor}. */
6351
+ const LIST_CODE_METHODS: Readonly<Record<string, string>> = Object.fromEntries(
6352
+ Object.entries(LIST_TOOL_CODES).map(([method, code]) => [code, method]),
6353
+ )
6354
+
6355
+ /**
6356
+ * The fields after the version marker, BEFORE the peer position: tool code,
6357
+ * device digest, filter fingerprint and checksum. The position is the rest of
6358
+ * the token — it is peer-supplied and may contain the delimiter itself (the
6359
+ * desktop's session cursor is `entity:<orderKey>:<uuid>`), so the split stops
6360
+ * at a fixed count and never consumes it.
6361
+ */
6362
+ const LIST_TOKEN_FIELDS = 4
6363
+
6364
+ /** The `cursor` parameter every list tool shares (issues #65 and #70). */
6254
6365
  const LIST_CURSOR_PARAMETER = {
6255
6366
  type: 'string',
6367
+ maxLength: MAX_RESUME_TOKEN_CHARS,
6256
6368
  description:
6257
6369
  'A `resumeCursor` a previous TRUNCATED answer of this tool returned: replay it with the SAME ' +
6258
6370
  'device and filters to continue from that stop point (`limit` then bounds this answer). A token ' +
6259
- 'from another tool, device or filter set is refused locally.',
6371
+ 'from another tool, device or filter set is refused locally, and so is a token this account ' +
6372
+ `cannot have received intact (the token carries a checksum; ${MAX_RESUME_TOKEN_CHARS} characters ` +
6373
+ 'is the ceiling).',
6260
6374
  } as const
6261
6375
 
6262
6376
  /** What a list token is valid for. `params` are the CALLER's filters, not the wire page params. */
@@ -6272,18 +6386,58 @@ function listParamsFingerprint(params: Record<string, unknown>): string {
6272
6386
  .sort()
6273
6387
  .filter((key) => params[key] !== undefined)
6274
6388
  .map((key) => [key, params[key]] as const)
6275
- return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0, 16)
6389
+ return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0, 8)
6390
+ }
6391
+
6392
+ /**
6393
+ * The device half of the binding, as a digest rather than the id itself: a
6394
+ * `deviceId` is a 36-character UUID, and the token only ever has to answer "was
6395
+ * this issued for the device I just resolved?", never "which device was it?".
6396
+ * 12 hex characters (48 bits) make an accidental collision between two paired
6397
+ * desktops a non-event, where the old field was a third of the token.
6398
+ */
6399
+ function listDeviceFingerprint(deviceId: string): string {
6400
+ return createHash('sha256').update(deviceId).digest('hex').slice(0, 12)
6401
+ }
6402
+
6403
+ /**
6404
+ * The token's integrity check (issue #70). It covers the peer position, which
6405
+ * is the ONE field no structural check can validate: a substitution there used
6406
+ * to decode as a perfectly well-formed token and was forwarded, and the desktop
6407
+ * silently falls back to its FIRST page for a position it cannot parse — so the
6408
+ * caller's "continue from here" came back as the newest page presented as older
6409
+ * history. 8 hex characters (32 bits) turn every single-character corruption
6410
+ * into a local refusal; the token is not a security boundary, so a MAC would
6411
+ * only make it longer.
6412
+ */
6413
+ function listTokenChecksum(body: string, peerPosition: string): string {
6414
+ return createHash('sha256')
6415
+ .update(`${body}\u0000${peerPosition}`)
6416
+ .digest('hex')
6417
+ .slice(0, 8)
6276
6418
  }
6277
6419
 
6278
6420
  function encodeListResumeCursor(binding: ListResumeBinding, peerPosition: string): string {
6279
- const payload = {
6280
- v: 1,
6281
- m: binding.method,
6282
- d: binding.deviceId,
6283
- h: listParamsFingerprint(binding.params),
6284
- p: peerPosition,
6421
+ const code = LIST_TOOL_CODES[binding.method]
6422
+ if (code === undefined) throw new Error(`${binding.method}: no list resume token code`)
6423
+ const body = `${LIST_TOKEN_VERSION}:${code}:${listDeviceFingerprint(binding.deviceId)}:${listParamsFingerprint(
6424
+ binding.params,
6425
+ )}`
6426
+ return `${LIST_RESUME_PREFIX}${body}:${listTokenChecksum(body, peerPosition)}:${peerPosition}`
6427
+ }
6428
+
6429
+ /**
6430
+ * Whether a `cursor:` remainder is a token from the base64-JSON era (issue #65,
6431
+ * `v: 1`). Only used to explain a refusal: the payload is never replayed.
6432
+ */
6433
+ function looksLikeV1ListToken(raw: string): boolean {
6434
+ if (raw === '' || raw.includes(':')) return false
6435
+ try {
6436
+ const decoded: unknown = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
6437
+ return typeof decoded === 'object' && decoded !== null && (decoded as Record<string, unknown>)['v'] === 1
6438
+ } catch {
6439
+ return false
6285
6440
  }
6286
- return LIST_RESUME_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
6287
6441
  }
6288
6442
 
6289
6443
  /**
@@ -6302,31 +6456,73 @@ function parseListResumeCursor(value: unknown, binding: ListResumeBinding): stri
6302
6456
  'device and filters.',
6303
6457
  )
6304
6458
  }
6305
- if (typeof value !== 'string' || !value.startsWith(LIST_RESUME_PREFIX)) {
6306
- refuse('must be a `cursor:` token this tool emitted')
6459
+ if (typeof value !== 'string') refuse('must be a `list:` token this tool emitted')
6460
+ const token = value as string
6461
+ // Length first: the bound is about CALLER INPUT and must not depend on which
6462
+ // face the string appears to belong to.
6463
+ if (token.length > MAX_RESUME_TOKEN_CHARS) {
6464
+ throw new Error(
6465
+ `cursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a ` +
6466
+ 'resume token; omit `cursor` to read from the start.',
6467
+ )
6307
6468
  }
6308
- const raw = (value as string).slice(LIST_RESUME_PREFIX.length)
6309
- let decoded: unknown
6310
- try {
6311
- decoded = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
6312
- } catch {
6313
- refuse('is not a token this tool emitted')
6469
+ if (token.startsWith(RESUME_CURSOR_PREFIX)) {
6470
+ // Issue #72. Two faces, two prefixes: a token starting with `cursor:` was
6471
+ // minted by `cherry_read`'s message face, and replaying it here would ask a
6472
+ // LIST method for a slice at an offset taken from a MESSAGE history — a
6473
+ // perfectly valid number that reads the wrong rows with no error anywhere.
6474
+ // Until #70 the list face shared this prefix, so an old token is named
6475
+ // FIRST: "start the walk over" is the only thing that works there, and it
6476
+ // is not what this branch's other verdict says.
6477
+ if (looksLikeV1ListToken(token.slice(RESUME_CURSOR_PREFIX.length))) {
6478
+ throw new Error(
6479
+ 'cursor was issued by an older plugin version (a v1 list token, from before list tokens were ' +
6480
+ 'compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.',
6481
+ )
6482
+ }
6483
+ refuse(
6484
+ 'is a MESSAGE-face token (`cursor:<peer position>`, i.e. a `messagesResumeCursor`), which a list walk ' +
6485
+ 'never issues — a list token starts with `list:`',
6486
+ )
6314
6487
  }
6315
- if (typeof decoded !== 'object' || decoded === null) refuse('is not a token this tool emitted')
6316
- const payload = decoded as Record<string, unknown>
6317
- if (
6318
- payload['v'] !== 1 ||
6319
- typeof payload['m'] !== 'string' ||
6320
- typeof payload['d'] !== 'string' ||
6321
- typeof payload['h'] !== 'string' ||
6322
- typeof payload['p'] !== 'string'
6323
- ) {
6488
+ if (!token.startsWith(LIST_RESUME_PREFIX)) {
6489
+ refuse('must be a `list:` token this tool emitted')
6490
+ }
6491
+ const raw = token.slice(LIST_RESUME_PREFIX.length)
6492
+ if (!raw.startsWith(`${LIST_TOKEN_VERSION}:`)) {
6493
+ if (looksLikeV1ListToken(raw)) {
6494
+ // A v1 token cannot be replayed at ALL: the format changed, so "the same
6495
+ // tool, device and filters" would fail again. The generic tail would
6496
+ // therefore tell the caller to do something that can never work — the one
6497
+ // thing this version marker exists to avoid.
6498
+ throw new Error(
6499
+ 'cursor was issued by an older plugin version (a v1 token, from before list tokens were compact ' +
6500
+ 'and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.',
6501
+ )
6502
+ }
6324
6503
  refuse('is not a token this tool emitted')
6325
6504
  }
6326
- if (payload['m'] !== binding.method) refuse('was issued for a different tool')
6327
- if (payload['d'] !== binding.deviceId) refuse('was issued for a different device')
6328
- if (payload['h'] !== listParamsFingerprint(binding.params)) refuse('was issued for a different filter set')
6329
- const peerPosition = payload['p'] as string
6505
+ let rest = raw.slice(LIST_TOKEN_VERSION.length + 1)
6506
+ const fields: string[] = []
6507
+ for (let index = 0; index < LIST_TOKEN_FIELDS; index += 1) {
6508
+ const separator = rest.indexOf(':')
6509
+ if (separator < 0) refuse('is not a token this tool emitted')
6510
+ fields.push(rest.slice(0, separator))
6511
+ rest = rest.slice(separator + 1)
6512
+ }
6513
+ // What is left is the peer position, verbatim — colons and all.
6514
+ const [code, device, filters, checksum] = fields as [string, string, string, string]
6515
+ const peerPosition = rest
6516
+ const body = `${LIST_TOKEN_VERSION}:${code}:${device}:${filters}`
6517
+ // The checksum is judged BEFORE the binding: a corrupted token is not a
6518
+ // statement about the caller's filters, and reporting it as one would send
6519
+ // the caller looking for a filter they never changed.
6520
+ if (checksum !== listTokenChecksum(body, peerPosition)) {
6521
+ refuse('is not a token this tool emitted (its checksum does not match, so it was altered in transit)')
6522
+ }
6523
+ if (LIST_CODE_METHODS[code] !== binding.method) refuse('was issued for a different tool')
6524
+ if (device !== listDeviceFingerprint(binding.deviceId)) refuse('was issued for a different device')
6525
+ if (filters !== listParamsFingerprint(binding.params)) refuse('was issued for a different filter set')
6330
6526
  if (mangledRemainder(peerPosition)) refuse('is not a token this tool emitted')
6331
6527
  return peerPosition
6332
6528
  }