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 +2 -2
- package/README.md +2 -2
- package/dist/index.mjs +144 -27
- package/package.json +1 -1
- package/src/defaults.ts +16 -0
- package/src/tools.ts +231 -35
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
8264
|
-
/** The `cursor` parameter every list tool shares (
|
|
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
|
-
|
|
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,
|
|
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
|
|
8276
|
-
|
|
8277
|
-
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
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"
|
|
8298
|
-
const
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
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
|
-
|
|
8306
|
-
const
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
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
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 &&
|
|
2105
|
-
? { resumeCursor: encodeListResumeCursor(binding,
|
|
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 = '
|
|
6321
|
+
const LIST_RESUME_PREFIX = 'list:'
|
|
6252
6322
|
|
|
6253
|
-
/**
|
|
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,
|
|
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
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
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'
|
|
6306
|
-
|
|
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
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
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 (
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
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
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
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
|
}
|