dsh-cherry-bridge 0.1.6 → 0.1.8
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 +30 -16
- package/README.md +19 -11
- package/dist/index.mjs +253 -45
- package/package.json +1 -1
- package/src/defaults.ts +21 -0
- package/src/tools.ts +423 -78
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'
|
|
@@ -390,9 +391,15 @@ export interface CherryReadSnapshot {
|
|
|
390
391
|
*
|
|
391
392
|
* Present exactly when `truncated` is true and a stop point can be named
|
|
392
393
|
* honestly; because the walk runs newest-first, it marks OLDER messages as
|
|
393
|
-
* still unread — the only direction a truncation leaves behind.
|
|
394
|
-
*
|
|
395
|
-
*
|
|
394
|
+
* still unread — the only direction a truncation leaves behind.
|
|
395
|
+
*
|
|
396
|
+
* IT IS ABSENT IN TWO OPPOSITE CASES, and `truncated` is what tells them
|
|
397
|
+
* apart: a COMPLETE walk (`truncated: false` — nothing older left), and a
|
|
398
|
+
* TRUNCATED walk whose stop point could not be named (`truncated: true` plus
|
|
399
|
+
* `truncationReason` — older messages still unread). The absence therefore
|
|
400
|
+
* never means "nothing older left" on its own. WHICH stops can be named is
|
|
401
|
+
* decided in ONE place ({@link messageResumeToken}); the refusal cases are not
|
|
402
|
+
* restated here.
|
|
396
403
|
*
|
|
397
404
|
* WHAT REACHES THE UNREAD MESSAGES DEPENDS ON THE REASON. A `'limit'` stop was
|
|
398
405
|
* the caller's own ceiling: raise `limit`, or resume with this token to walk
|
|
@@ -402,6 +409,12 @@ export interface CherryReadSnapshot {
|
|
|
402
409
|
* messages are reached. From the resume point `limit` bounds how many messages
|
|
403
410
|
* ONE answer carries, so a long history is walked in successive answers, each
|
|
404
411
|
* carrying the next stop point.
|
|
412
|
+
*
|
|
413
|
+
* WITH NO TOKEN NAMED, the reason is all the caller has: a `'limit'` stop is
|
|
414
|
+
* still reachable by widening `limit`; a `'safety-bound'` stop is the internal
|
|
415
|
+
* circuit breaker, which no wider `limit` moves; and an `'aggregate-bytes'`
|
|
416
|
+
* stop has no remedy in that answer at all — the budget, not the ceiling, is
|
|
417
|
+
* what stopped it. The loss is stated rather than repaired.
|
|
405
418
|
*/
|
|
406
419
|
messagesResumeCursor?: string
|
|
407
420
|
}
|
|
@@ -446,10 +459,19 @@ export interface CherryMessageWalkVerdict {
|
|
|
446
459
|
* `cursor:<peer position>` when the whole collected window was returned;
|
|
447
460
|
* `after:<last returned message id>` when the byte stop happened INSIDE that
|
|
448
461
|
* window, because the peer's position sits past messages this walk trimmed
|
|
449
|
-
* and `AgentMessage` carries no `createdAt` to synthesise a closer one.
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
462
|
+
* and `AgentMessage` carries no `createdAt` to synthesise a closer one.
|
|
463
|
+
*
|
|
464
|
+
* IT IS ABSENT IN TWO OPPOSITE CASES, and `messagesTruncated` is what tells
|
|
465
|
+
* them apart: a COMPLETE walk (`messagesTruncated` absent or false — nothing
|
|
466
|
+
* older left), and a TRUNCATED walk whose stop point could not be named
|
|
467
|
+
* (`messagesTruncated: true` plus `messagesTruncationReason` — older history
|
|
468
|
+
* still unread). The absence never means "nothing older left" on its own.
|
|
469
|
+
* WHICH stops can be named is decided in ONE place
|
|
470
|
+
* ({@link messageResumeToken}) and is the same rule as
|
|
471
|
+
* {@link CherryReadSnapshot.messagesResumeCursor}. THE REMEDY IS NOT THE SAME
|
|
472
|
+
* ON THIS FACE: a `'limit'` stop here is the walk's own fixed message
|
|
473
|
+
* allowance — these tools take no `limit` argument — so the way further is
|
|
474
|
+
* `cherry_read`, which also carries no deadline.
|
|
453
475
|
*/
|
|
454
476
|
messagesResumeCursor?: string
|
|
455
477
|
}
|
|
@@ -2097,13 +2119,33 @@ class CherryToolEngine {
|
|
|
2097
2119
|
* #65). What a caller can act on is a token, so that is what is emitted.
|
|
2098
2120
|
*/
|
|
2099
2121
|
#listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
|
|
2122
|
+
// A position this face would REFUSE is not a position it may name. The
|
|
2123
|
+
// round-trip symmetry (a token a tool emits is a token that tool accepts) is
|
|
2124
|
+
// the whole point of the token, so a peer answering `nextCursor: ''`, one
|
|
2125
|
+
// carrying whitespace/control characters, or one shaped like our retired v1
|
|
2126
|
+
// token gets NO token. The absence has TWO opposite cases — a COMPLETE walk,
|
|
2127
|
+
// and a TRUNCATED one whose stop could not be named — and only `truncated`
|
|
2128
|
+
// tells them apart. The remedy follows the REASON: a `'limit'` stop answers
|
|
2129
|
+
// to a wider `limit`; a `'safety-bound'` stop does not, because it is an
|
|
2130
|
+
// internal circuit breaker a caller cannot move. The rule is NOT restated
|
|
2131
|
+
// here or in the message face:
|
|
2132
|
+
// `nameablePeerPosition` is the one place it lives, so the emitters and
|
|
2133
|
+
// their parsers cannot drift (issue #85).
|
|
2134
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor))
|
|
2135
|
+
// The same refusal one dimension further: an encoded token over
|
|
2136
|
+
// {@link MAX_RESUME_TOKEN_CHARS} is one this face would refuse on replay, so
|
|
2137
|
+
// it is not named ({@link withinResumeTokenCeiling}, issue #76). Judged only
|
|
2138
|
+
// on the arm that NAMES it — a complete walk has nothing to name, so it must
|
|
2139
|
+
// not pay for the encoding.
|
|
2140
|
+
const token =
|
|
2141
|
+
!page.truncated || position === undefined
|
|
2142
|
+
? undefined
|
|
2143
|
+
: withinResumeTokenCeiling(encodeListResumeCursor(binding, position))
|
|
2100
2144
|
return {
|
|
2101
2145
|
items: page.items,
|
|
2102
2146
|
truncated: page.truncated,
|
|
2103
2147
|
...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
|
|
2104
|
-
...(
|
|
2105
|
-
? { resumeCursor: encodeListResumeCursor(binding, page.nextCursor) }
|
|
2106
|
-
: {}),
|
|
2148
|
+
...(token === undefined ? {} : { resumeCursor: token }),
|
|
2107
2149
|
}
|
|
2108
2150
|
}
|
|
2109
2151
|
|
|
@@ -2206,9 +2248,10 @@ class CherryToolEngine {
|
|
|
2206
2248
|
'List the Cherry Studio agents available on a device. Paginated: `limit` bounds ' +
|
|
2207
2249
|
'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
|
|
2208
2250
|
'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
|
|
2209
|
-
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
2210
|
-
'
|
|
2211
|
-
'filters to continue from that stop point (`limit` then bounds the next
|
|
2251
|
+
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
|
|
2252
|
+
'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
|
|
2253
|
+
'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
|
|
2254
|
+
'answer).',
|
|
2212
2255
|
parameters: {
|
|
2213
2256
|
type: 'object',
|
|
2214
2257
|
properties: {
|
|
@@ -2249,8 +2292,9 @@ class CherryToolEngine {
|
|
|
2249
2292
|
'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
|
|
2250
2293
|
'The system workspace has no enumerable id: it is not in `items`, and its identity ' +
|
|
2251
2294
|
'is only observable by creating a session in it and reading it back. A truncated ' +
|
|
2252
|
-
'answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
2253
|
-
'`resumeCursor
|
|
2295
|
+
'answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop ' +
|
|
2296
|
+
'point can be named it carries `resumeCursor` — replay it as `cursor` with the same ' +
|
|
2297
|
+
'filters to continue.',
|
|
2254
2298
|
parameters: {
|
|
2255
2299
|
type: 'object',
|
|
2256
2300
|
properties: {
|
|
@@ -2301,9 +2345,10 @@ class CherryToolEngine {
|
|
|
2301
2345
|
'`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
|
|
2302
2346
|
'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
|
|
2303
2347
|
'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
|
|
2304
|
-
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
2305
|
-
'
|
|
2306
|
-
'filters to continue from that stop point (`limit` then bounds the next
|
|
2348
|
+
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
|
|
2349
|
+
'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
|
|
2350
|
+
'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
|
|
2351
|
+
'answer).',
|
|
2307
2352
|
parameters: {
|
|
2308
2353
|
type: 'object',
|
|
2309
2354
|
properties: {
|
|
@@ -2373,8 +2418,9 @@ class CherryToolEngine {
|
|
|
2373
2418
|
'"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
|
|
2374
2419
|
'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
|
|
2375
2420
|
'(it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE ' +
|
|
2376
|
-
'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor`
|
|
2377
|
-
'stop point can be named
|
|
2421
|
+
'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the ' +
|
|
2422
|
+
'stop point can be named — absent when it cannot, e.g. an empty or malformed position, a ' +
|
|
2423
|
+
'token past the length ceiling, or no anchor id — and ' +
|
|
2378
2424
|
'passing it back as `messagesResumeCursor` continues the walk from there (`limit` then ' +
|
|
2379
2425
|
'bounds how many messages are returned FROM that point). The two token forms are ' +
|
|
2380
2426
|
'`"cursor:<peer position>"` (the walk consumed its whole page window) and ' +
|
|
@@ -2396,15 +2442,18 @@ class CherryToolEngine {
|
|
|
2396
2442
|
description:
|
|
2397
2443
|
'Maximum number of messages to return in total across pages (default 100). Distinct from ' +
|
|
2398
2444
|
'the output budget: the answer may stop earlier with `truncationReason: "aggregate-bytes"`, ' +
|
|
2399
|
-
'which a larger `limit` does not extend — the resume token is
|
|
2445
|
+
'which a larger `limit` does not extend — the resume token, when one is returned, is how ' +
|
|
2446
|
+
'older messages are reached.',
|
|
2400
2447
|
},
|
|
2401
2448
|
messagesResumeCursor: {
|
|
2402
2449
|
type: 'string',
|
|
2450
|
+
maxLength: MAX_RESUME_TOKEN_CHARS,
|
|
2403
2451
|
description:
|
|
2404
2452
|
'A `messagesResumeCursor` a previous truncated read (this tool, or cherry_send/cherry_wait) ' +
|
|
2405
2453
|
'returned: `"cursor:<peer position>"` or `"after:<messageId>"`. Continues the walk at that ' +
|
|
2406
2454
|
'stop point instead of at the newest message; `limit` bounds what comes back from there. ' +
|
|
2407
|
-
'Omit it to read from the newest message.'
|
|
2455
|
+
'Omit it to read from the newest message. A token this tool did not emit is refused locally, ' +
|
|
2456
|
+
`and ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling.`,
|
|
2408
2457
|
},
|
|
2409
2458
|
},
|
|
2410
2459
|
required: ['device', 'sessionId'],
|
|
@@ -2575,11 +2624,30 @@ class CherryToolEngine {
|
|
|
2575
2624
|
...(truncationReason === undefined ? {} : { truncationReason }),
|
|
2576
2625
|
// Only a TRUNCATED walk has unread history, and the token then marks the
|
|
2577
2626
|
// stop point (OLDER messages remain unread) —
|
|
2578
|
-
// {@link CherryReadSnapshot.messagesResumeCursor}.
|
|
2579
|
-
//
|
|
2580
|
-
//
|
|
2581
|
-
//
|
|
2582
|
-
//
|
|
2627
|
+
// {@link CherryReadSnapshot.messagesResumeCursor}. Whether that stop can
|
|
2628
|
+
// be NAMED is not the walk's verdict: it depends on the peer's payload,
|
|
2629
|
+
// and {@link messageResumeToken} is the ONE place that judges it — content
|
|
2630
|
+
// through `nameableRemainder`, length through `withinResumeTokenCeiling`,
|
|
2631
|
+
// and, for a POSITION only, the retired-v1 check
|
|
2632
|
+
// ({@link nameablePeerPosition}; the anchor arm deliberately skips it,
|
|
2633
|
+
// exactly as `parseMessageResume`'s `after:` branch does). The refusal
|
|
2634
|
+
// cases are therefore not enumerated again here.
|
|
2635
|
+
//
|
|
2636
|
+
// The key is OMITTED rather than emitted `undefined`, and its absence only
|
|
2637
|
+
// means something once read together with `truncated`: the two cases have
|
|
2638
|
+
// opposite meanings —
|
|
2639
|
+
//
|
|
2640
|
+
// - a COMPLETE walk (nothing older left): `truncated: false`, no token —
|
|
2641
|
+
// the absence really does say "nothing older left";
|
|
2642
|
+
// - a TRUNCATED walk whose stop point cannot be named: `truncated: true`
|
|
2643
|
+
// plus a `truncationReason`, no token — OLDER MESSAGES REMAIN UNREAD,
|
|
2644
|
+
// and the absence never means they ran out. The remedy follows the
|
|
2645
|
+
// REASON: only a `limit` stop answers to a wider `limit`; a
|
|
2646
|
+
// `safety-bound` stop is the internal circuit breaker, and an
|
|
2647
|
+
// `aggregate-bytes` stop is this answer's own output budget, which a
|
|
2648
|
+
// wider `limit` does not move (more round trips, same budget). A
|
|
2649
|
+
// `deadline` stop is a WAIT face's reason — `cherry_read` carries no
|
|
2650
|
+
// deadline — so it is never this literal's.
|
|
2583
2651
|
...(truncated && resumeToken !== undefined ? { messagesResumeCursor: resumeToken } : {}),
|
|
2584
2652
|
}
|
|
2585
2653
|
}
|
|
@@ -3742,8 +3810,8 @@ class CherryToolEngine {
|
|
|
3742
3810
|
'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
|
|
3743
3811
|
'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
|
|
3744
3812
|
'`decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the ' +
|
|
3745
|
-
'peer ran out
|
|
3746
|
-
'continue.',
|
|
3813
|
+
'peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as ' +
|
|
3814
|
+
'`cursor` from the same `sessionId` to continue.',
|
|
3747
3815
|
parameters: {
|
|
3748
3816
|
type: 'object',
|
|
3749
3817
|
properties: {
|
|
@@ -5536,9 +5604,11 @@ class CherryToolEngine {
|
|
|
5536
5604
|
|
|
5537
5605
|
/**
|
|
5538
5606
|
* The timeout result: NOT a failure. Partial content read so far +, whenever a
|
|
5539
|
-
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
5540
|
-
* the unread history begins
|
|
5541
|
-
* or
|
|
5607
|
+
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
5608
|
+
* where the unread history begins. The token is missing in TWO OPPOSITE cases
|
|
5609
|
+
* — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
|
|
5610
|
+
* and `messagesTruncated` is what tells them apart; see
|
|
5611
|
+
* {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
5542
5612
|
*
|
|
5543
5613
|
* The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
|
|
5544
5614
|
* 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
|
|
@@ -5813,9 +5883,10 @@ class CherryToolEngine {
|
|
|
5813
5883
|
* `messagesResumeCursor`: the read scans from the newest end to the anchor
|
|
5814
5884
|
* and continues strictly after it.
|
|
5815
5885
|
*
|
|
5816
|
-
* The token is
|
|
5817
|
-
*
|
|
5818
|
-
*
|
|
5886
|
+
* The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
|
|
5887
|
+
* TRUNCATED one whose stop point could not be named — and `messagesTruncated`
|
|
5888
|
+
* is what tells them apart; absence never means "nothing older left" on its
|
|
5889
|
+
* own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
5819
5890
|
*/
|
|
5820
5891
|
async #readTerminalMessages(
|
|
5821
5892
|
connection: DeviceConnection,
|
|
@@ -6085,28 +6156,111 @@ function parseQuestionInput(input: unknown): string[] | undefined {
|
|
|
6085
6156
|
* invented: a wrong anchor is worse than no anchor, and `truncated` still says
|
|
6086
6157
|
* the walk was cut. Otherwise the whole collected window was consumed, and the
|
|
6087
6158
|
* peer's page position is exactly where the unread history begins
|
|
6088
|
-
* (`cursor:<pos>`)
|
|
6089
|
-
*
|
|
6159
|
+
* (`cursor:<pos>`).
|
|
6160
|
+
*
|
|
6161
|
+
* BOTH arms name a remainder only when the PARSER would accept it
|
|
6162
|
+
* ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
|
|
6163
|
+
* leading/trailing whitespace), or carrying a control character anywhere is
|
|
6164
|
+
* refused HERE exactly as `parseMessageResume` refuses it on replay. The
|
|
6165
|
+
* emitter must never mint what the parser refuses — a token this tool will not
|
|
6166
|
+
* take back is worse than none, because the refusal then surfaces at the RESUME
|
|
6167
|
+
* call instead of at the answer that produced it. This is the CONTENT
|
|
6168
|
+
* dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
|
|
6169
|
+
* `after:` anchor is covered by the same rule, not just the `cursor:` position:
|
|
6170
|
+
* a message id is an `opaqueId` too, and padding is schema-valid, so a padded
|
|
6171
|
+
* id really can reach the anchor.
|
|
6172
|
+
*
|
|
6173
|
+
* EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
|
|
6174
|
+
* driven through a tool: an anchor is a message ID, and projecting that message
|
|
6175
|
+
* means asking the peer for `agent.parts.list` with it, whose `messageId` is an
|
|
6176
|
+
* `opaqueId` (256 characters). A peer item carrying an id above the token
|
|
6177
|
+
* ceiling therefore fails its parts walk and is never RETURNED, so no walk
|
|
6178
|
+
* reaches the point of naming it. The length guard on that arm is still
|
|
6179
|
+
* required — the emitter must never mint what the parser refuses, whatever the
|
|
6180
|
+
* peer's cap becomes — and this export is how a unit test pins it
|
|
6181
|
+
* (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
|
|
6182
|
+
* reachable, because `opaqueId` allows padding: `tests/integration/
|
|
6183
|
+
* toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
|
|
6090
6184
|
*/
|
|
6091
|
-
function messageResumeToken(
|
|
6185
|
+
export function messageResumeToken(
|
|
6092
6186
|
page: CherryPage<unknown>,
|
|
6093
6187
|
returned: number,
|
|
6094
6188
|
lastReturnedId: string | undefined,
|
|
6095
6189
|
): string | undefined {
|
|
6096
|
-
const anchor = optionalString(lastReturnedId)
|
|
6190
|
+
const anchor = nameableRemainder(optionalString(lastReturnedId))
|
|
6097
6191
|
// TWO forms of "the walk stopped inside what it was given": it returned fewer
|
|
6098
6192
|
// items than it collected, or the PEER overshot the requested page and items
|
|
6099
6193
|
// were left in it ({@link CherryPage.pageOverflow}). Either way the page's own
|
|
6100
6194
|
// position sits past messages this walk never returned, so only the anchor is
|
|
6101
6195
|
// exact.
|
|
6102
6196
|
if (returned < page.items.length || page.pageOverflow === true) {
|
|
6103
|
-
|
|
6197
|
+
// The ANCHOR takes the content predicate only — NOT the v1 detector, and
|
|
6198
|
+
// that is not an oversight: `parseMessageResume`'s `after:` branch runs
|
|
6199
|
+
// `mangledRemainder` and nothing else, so an id-shaped `after:` remainder is
|
|
6200
|
+
// a token it ACCEPTS back. Applying the detector here would be the mirror
|
|
6201
|
+
// image of this issue — refusing to name a token the parser takes.
|
|
6202
|
+
return anchor === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`)
|
|
6104
6203
|
}
|
|
6105
|
-
//
|
|
6106
|
-
//
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6204
|
+
// A POSITION takes both: the content predicate, and the retired-format
|
|
6205
|
+
// detector its own parser applies before anything else (issue #85).
|
|
6206
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor))
|
|
6207
|
+
return position === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`)
|
|
6208
|
+
}
|
|
6209
|
+
|
|
6210
|
+
/**
|
|
6211
|
+
* The CONTENT dimension of "a remainder this bridge may name", or `undefined`
|
|
6212
|
+
* when it must not name one.
|
|
6213
|
+
*
|
|
6214
|
+
* The parsers run `mangledRemainder` on every remainder they read, so one that
|
|
6215
|
+
* fails it is a remainder this tool would refuse on replay: naming it hands the
|
|
6216
|
+
* caller a stop point that can never be used, and the refusal then surfaces at
|
|
6217
|
+
* the RESUME call instead of at the answer that produced it (issues #70, #80).
|
|
6218
|
+
* What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
|
|
6219
|
+
* trailing whitespace), or a control character ANYWHERE — an interior plain space
|
|
6220
|
+
* is legal and round-trips.
|
|
6221
|
+
*
|
|
6222
|
+
* This is the parser's own predicate, not a copy of it, so the two sides cannot
|
|
6223
|
+
* drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
|
|
6224
|
+
* on the FINAL token ({@link withinResumeTokenCeiling}).
|
|
6225
|
+
*/
|
|
6226
|
+
function nameableRemainder(value: string | undefined): string | undefined {
|
|
6227
|
+
return value === undefined || mangledRemainder(value) ? undefined : value
|
|
6228
|
+
}
|
|
6229
|
+
|
|
6230
|
+
/**
|
|
6231
|
+
* A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
|
|
6232
|
+
* plus the one refusal that applies to positions and NOT to anchors.
|
|
6233
|
+
*
|
|
6234
|
+
* `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
|
|
6235
|
+
* `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
|
|
6236
|
+
* position, so both parsers NAME and refuse it rather than forwarding it to a
|
|
6237
|
+
* desktop that would silently serve its FIRST page. An emitter that wrapped such
|
|
6238
|
+
* a position would mint `cursor:<v1 shape>` and then have its own parser answer
|
|
6239
|
+
* "issued by an older plugin version" about a token this tool had just printed —
|
|
6240
|
+
* the same broken round trip as #76/#80, one detector further out (issue #85).
|
|
6241
|
+
*
|
|
6242
|
+
* The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
|
|
6243
|
+
* `after:` branch never consults the detector, so an `after:` remainder of that
|
|
6244
|
+
* shape is a token it accepts.
|
|
6245
|
+
*/
|
|
6246
|
+
function nameablePeerPosition(value: string | undefined): string | undefined {
|
|
6247
|
+
const remainder = nameableRemainder(value)
|
|
6248
|
+
return remainder === undefined || looksLikeV1ListToken(remainder) ? undefined : remainder
|
|
6249
|
+
}
|
|
6250
|
+
|
|
6251
|
+
/**
|
|
6252
|
+
* The token ceiling, enforced on the way OUT as well as in. A token this tool
|
|
6253
|
+
* refuses to replay is worse than no token at all: `truncated` already says the
|
|
6254
|
+
* walk was cut, and the remedy then follows the reason — a `'limit'` stop
|
|
6255
|
+
* answers to a wider `limit` only where the caller owns that ceiling — so a
|
|
6256
|
+
* stop point whose token would push past
|
|
6257
|
+
* {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
|
|
6258
|
+
* string, never on a reconstruction of its length, so a format change cannot
|
|
6259
|
+
* leave the guard behind. Exactly at the ceiling is accepted — it is a length
|
|
6260
|
+
* the parser takes back.
|
|
6261
|
+
*/
|
|
6262
|
+
function withinResumeTokenCeiling(token: string): string | undefined {
|
|
6263
|
+
return token.length <= MAX_RESUME_TOKEN_CHARS ? token : undefined
|
|
6110
6264
|
}
|
|
6111
6265
|
|
|
6112
6266
|
/**
|
|
@@ -6213,8 +6367,14 @@ function messageIdOf(raw: Record<string, unknown>): string {
|
|
|
6213
6367
|
* `undefined`/`null` means "from the newest message", which is the ordinary
|
|
6214
6368
|
* read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
|
|
6215
6369
|
* foreign token: an empty anchor could never match a message.
|
|
6370
|
+
*
|
|
6371
|
+
* EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
|
|
6372
|
+
* is the invariant this whole file is built around: every token the emitters
|
|
6373
|
+
* NAME must be one this function ACCEPTS. Pinning that with a probe table is
|
|
6374
|
+
* what turns "we mirrored every refusal" from a promise into a check
|
|
6375
|
+
* (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
|
|
6216
6376
|
*/
|
|
6217
|
-
function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
6377
|
+
export function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
6218
6378
|
if (value === undefined || value === null) return undefined
|
|
6219
6379
|
const refuse = (): never => {
|
|
6220
6380
|
throw new Error(
|
|
@@ -6224,9 +6384,36 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
|
6224
6384
|
}
|
|
6225
6385
|
if (typeof value !== 'string') refuse()
|
|
6226
6386
|
const token = value as string
|
|
6387
|
+
// Issue #74: the ceiling is judged on the WHOLE token, before either form's
|
|
6388
|
+
// prefix — so `cursor:` and `after:` get the same verdict, and no decoding,
|
|
6389
|
+
// scanning or dial happens for a string that cannot be one of ours. It is the
|
|
6390
|
+
// same bound the list face enforces ({@link MAX_RESUME_TOKEN_CHARS}).
|
|
6391
|
+
if (token.length > MAX_RESUME_TOKEN_CHARS) {
|
|
6392
|
+
throw new Error(
|
|
6393
|
+
`cherry_read: messagesResumeCursor is ${token.length} characters long, over the ` +
|
|
6394
|
+
`${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit the argument to read from the ` +
|
|
6395
|
+
'newest message.',
|
|
6396
|
+
)
|
|
6397
|
+
}
|
|
6227
6398
|
if (token.startsWith(RESUME_CURSOR_PREFIX)) {
|
|
6228
6399
|
const cursor = token.slice(RESUME_CURSOR_PREFIX.length)
|
|
6229
6400
|
if (mangledRemainder(cursor)) refuse()
|
|
6401
|
+
// Issue #72, the RELEASED half: a v1 list token (0.1.6 — `cursor:` +
|
|
6402
|
+
// base64url of `{v,m,d,h,p}`) lives under this prefix and its payload is
|
|
6403
|
+
// colon-free base64url, so it arrives here as a perfectly ordinary
|
|
6404
|
+
// position and used to be forwarded to the desktop, which answers a
|
|
6405
|
+
// position it cannot parse by serving its FIRST page. The detector fires
|
|
6406
|
+
// only for a colon-free remainder that base64-decodes to a `{v:1,…}`
|
|
6407
|
+
// object: every real peer position is `<ms>:<id>` (has a colon) or a
|
|
6408
|
+
// numeric offset, so none can be mistaken for one.
|
|
6409
|
+
if (looksLikeV1ListToken(cursor)) {
|
|
6410
|
+
throw new Error(
|
|
6411
|
+
'cherry_read: messagesResumeCursor is a v1 LIST `resumeCursor` (issued by an older plugin version, ' +
|
|
6412
|
+
'before list tokens were compact): it resumes a list walk, not a message history, and it can no ' +
|
|
6413
|
+
'longer be verified. Omit the argument to read from the newest message, or pass the token this ' +
|
|
6414
|
+
'tool returned.',
|
|
6415
|
+
)
|
|
6416
|
+
}
|
|
6230
6417
|
return { kind: 'cursor', cursor }
|
|
6231
6418
|
}
|
|
6232
6419
|
if (token.startsWith(RESUME_ANCHOR_PREFIX)) {
|
|
@@ -6234,6 +6421,21 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
|
6234
6421
|
if (mangledRemainder(messageId)) refuse()
|
|
6235
6422
|
return { kind: 'anchor', messageId }
|
|
6236
6423
|
}
|
|
6424
|
+
if (token.startsWith(LIST_RESUME_PREFIX)) {
|
|
6425
|
+
// Issue #72, the other direction: a token the four list tools minted. The
|
|
6426
|
+
// two faces are told apart by PREFIX, never by scanning a position for our
|
|
6427
|
+
// own marker — `cursor:list:20` above is still a message cursor. (The branch
|
|
6428
|
+
// above is the one exception, and it is not about the peer's format: it
|
|
6429
|
+
// DECODES our own retired v1 list token, whose payload happens to be
|
|
6430
|
+
// indistinguishable from a position.) Forwarding a list token would hand
|
|
6431
|
+
// the desktop a position it cannot parse, and it answers that by serving
|
|
6432
|
+
// its FIRST page as if it were the continuation.
|
|
6433
|
+
throw new Error(
|
|
6434
|
+
'cherry_read: messagesResumeCursor is a LIST `resumeCursor` (`list:…`) — it resumes a list walk ' +
|
|
6435
|
+
'(cherry_agents / cherry_workspaces / cherry_sessions / cherry_interactions), not a message history. ' +
|
|
6436
|
+
'Omit the argument to read from the newest message, or pass the token this tool returned.',
|
|
6437
|
+
)
|
|
6438
|
+
}
|
|
6237
6439
|
return refuse()
|
|
6238
6440
|
}
|
|
6239
6441
|
|
|
@@ -6247,16 +6449,77 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
|
6247
6449
|
* another query is a perfectly valid number that reads the WRONG slice with no
|
|
6248
6450
|
* error anywhere. So this token carries a fingerprint of the binding and a
|
|
6249
6451
|
* mismatched replay is refused HERE — before any RPC.
|
|
6452
|
+
*
|
|
6453
|
+
* Wire format (issue #70):
|
|
6454
|
+
* `list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>` — the
|
|
6455
|
+
* position verbatim and LAST. It replaced a base64 JSON blob (`{v,m,d,h,p}`)
|
|
6456
|
+
* that was ~2.3x longer and had no integrity check: measured over 20,000 random
|
|
6457
|
+
* single-character corruptions, 28.7% of the old tokens were ACCEPTED with a
|
|
6458
|
+
* different position, because the position is the one field a structural check
|
|
6459
|
+
* cannot validate. The compact form plus the checksum makes that class empty.
|
|
6460
|
+
*
|
|
6461
|
+
* The prefix is `list:` and NOT `cursor:` (issue #72): the message face already
|
|
6462
|
+
* owns `cursor:` / `after:`, and while the two faces shared a prefix a list
|
|
6463
|
+
* token was a legal `messagesResumeCursor` payload — it was forwarded to the
|
|
6464
|
+
* desktop, which answers an unparseable position by serving its FIRST page, so
|
|
6465
|
+
* "continue from here" silently re-read the newest page. Two faces, two
|
|
6466
|
+
* prefixes: the refusal is a PREFIX check on either side, never an inspection
|
|
6467
|
+
* of the peer's position (a position that merely starts with `list:` still
|
|
6468
|
+
* travels verbatim inside its own `cursor:` token).
|
|
6250
6469
|
*/
|
|
6251
|
-
const LIST_RESUME_PREFIX = '
|
|
6470
|
+
const LIST_RESUME_PREFIX = 'list:'
|
|
6252
6471
|
|
|
6253
|
-
/**
|
|
6472
|
+
/**
|
|
6473
|
+
* The token's format marker (issue #70). It is a FIELD, not part of the prefix,
|
|
6474
|
+
* so a token from the PREVIOUS format is recognised and refused by name
|
|
6475
|
+
* ({@link looksLikeV1ListToken}) instead of getting the generic shape error: a
|
|
6476
|
+
* resume token outlives a plugin upgrade (the caller is holding it), and "start
|
|
6477
|
+
* the walk over" is actionable where "not a token this tool emitted" invites
|
|
6478
|
+
* one more attempt with the same string.
|
|
6479
|
+
*
|
|
6480
|
+
* A format NEWER than this one has no detector and falls through to the
|
|
6481
|
+
* generic refusal, which is the honest verdict: this release cannot know that a
|
|
6482
|
+
* future format is a resume token at all.
|
|
6483
|
+
*/
|
|
6484
|
+
const LIST_TOKEN_VERSION = 'v2'
|
|
6485
|
+
|
|
6486
|
+
/**
|
|
6487
|
+
* One character per list tool, replacing the full method name (19 characters of
|
|
6488
|
+
* a 225-character token). Every method a list tool can issue MUST appear here:
|
|
6489
|
+
* {@link encodeListResumeCursor} throws on an unknown one rather than emit a
|
|
6490
|
+
* token the matching parser could never accept.
|
|
6491
|
+
*/
|
|
6492
|
+
const LIST_TOOL_CODES: Readonly<Record<string, string>> = {
|
|
6493
|
+
'agent.agents.list': 'a',
|
|
6494
|
+
'agent.workspaces.list': 'w',
|
|
6495
|
+
'agent.sessions.list': 's',
|
|
6496
|
+
'agent.interactions.list': 'i',
|
|
6497
|
+
}
|
|
6498
|
+
|
|
6499
|
+
/** The inverse of {@link LIST_TOOL_CODES}, for {@link parseListResumeCursor}. */
|
|
6500
|
+
const LIST_CODE_METHODS: Readonly<Record<string, string>> = Object.fromEntries(
|
|
6501
|
+
Object.entries(LIST_TOOL_CODES).map(([method, code]) => [code, method]),
|
|
6502
|
+
)
|
|
6503
|
+
|
|
6504
|
+
/**
|
|
6505
|
+
* The fields after the version marker, BEFORE the peer position: tool code,
|
|
6506
|
+
* device digest, filter fingerprint and checksum. The position is the rest of
|
|
6507
|
+
* the token — it is peer-supplied and may contain the delimiter itself (the
|
|
6508
|
+
* desktop's session cursor is `entity:<orderKey>:<uuid>`), so the split stops
|
|
6509
|
+
* at a fixed count and never consumes it.
|
|
6510
|
+
*/
|
|
6511
|
+
const LIST_TOKEN_FIELDS = 4
|
|
6512
|
+
|
|
6513
|
+
/** The `cursor` parameter every list tool shares (issues #65 and #70). */
|
|
6254
6514
|
const LIST_CURSOR_PARAMETER = {
|
|
6255
6515
|
type: 'string',
|
|
6516
|
+
maxLength: MAX_RESUME_TOKEN_CHARS,
|
|
6256
6517
|
description:
|
|
6257
6518
|
'A `resumeCursor` a previous TRUNCATED answer of this tool returned: replay it with the SAME ' +
|
|
6258
6519
|
'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
|
|
6520
|
+
'from another tool, device or filter set is refused locally, and so is a token this account ' +
|
|
6521
|
+
`cannot have received intact (the token carries a checksum; ${MAX_RESUME_TOKEN_CHARS} characters ` +
|
|
6522
|
+
'is the ceiling).',
|
|
6260
6523
|
} as const
|
|
6261
6524
|
|
|
6262
6525
|
/** What a list token is valid for. `params` are the CALLER's filters, not the wire page params. */
|
|
@@ -6272,18 +6535,58 @@ function listParamsFingerprint(params: Record<string, unknown>): string {
|
|
|
6272
6535
|
.sort()
|
|
6273
6536
|
.filter((key) => params[key] !== undefined)
|
|
6274
6537
|
.map((key) => [key, params[key]] as const)
|
|
6275
|
-
return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0,
|
|
6538
|
+
return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0, 8)
|
|
6539
|
+
}
|
|
6540
|
+
|
|
6541
|
+
/**
|
|
6542
|
+
* The device half of the binding, as a digest rather than the id itself: a
|
|
6543
|
+
* `deviceId` is a 36-character UUID, and the token only ever has to answer "was
|
|
6544
|
+
* this issued for the device I just resolved?", never "which device was it?".
|
|
6545
|
+
* 12 hex characters (48 bits) make an accidental collision between two paired
|
|
6546
|
+
* desktops a non-event, where the old field was a third of the token.
|
|
6547
|
+
*/
|
|
6548
|
+
function listDeviceFingerprint(deviceId: string): string {
|
|
6549
|
+
return createHash('sha256').update(deviceId).digest('hex').slice(0, 12)
|
|
6550
|
+
}
|
|
6551
|
+
|
|
6552
|
+
/**
|
|
6553
|
+
* The token's integrity check (issue #70). It covers the peer position, which
|
|
6554
|
+
* is the ONE field no structural check can validate: a substitution there used
|
|
6555
|
+
* to decode as a perfectly well-formed token and was forwarded, and the desktop
|
|
6556
|
+
* silently falls back to its FIRST page for a position it cannot parse — so the
|
|
6557
|
+
* caller's "continue from here" came back as the newest page presented as older
|
|
6558
|
+
* history. 8 hex characters (32 bits) turn every single-character corruption
|
|
6559
|
+
* into a local refusal; the token is not a security boundary, so a MAC would
|
|
6560
|
+
* only make it longer.
|
|
6561
|
+
*/
|
|
6562
|
+
function listTokenChecksum(body: string, peerPosition: string): string {
|
|
6563
|
+
return createHash('sha256')
|
|
6564
|
+
.update(`${body}\u0000${peerPosition}`)
|
|
6565
|
+
.digest('hex')
|
|
6566
|
+
.slice(0, 8)
|
|
6276
6567
|
}
|
|
6277
6568
|
|
|
6278
6569
|
function encodeListResumeCursor(binding: ListResumeBinding, peerPosition: string): string {
|
|
6279
|
-
const
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6570
|
+
const code = LIST_TOOL_CODES[binding.method]
|
|
6571
|
+
if (code === undefined) throw new Error(`${binding.method}: no list resume token code`)
|
|
6572
|
+
const body = `${LIST_TOKEN_VERSION}:${code}:${listDeviceFingerprint(binding.deviceId)}:${listParamsFingerprint(
|
|
6573
|
+
binding.params,
|
|
6574
|
+
)}`
|
|
6575
|
+
return `${LIST_RESUME_PREFIX}${body}:${listTokenChecksum(body, peerPosition)}:${peerPosition}`
|
|
6576
|
+
}
|
|
6577
|
+
|
|
6578
|
+
/**
|
|
6579
|
+
* Whether a `cursor:` remainder is a token from the base64-JSON era (issue #65,
|
|
6580
|
+
* `v: 1`). Only used to explain a refusal: the payload is never replayed.
|
|
6581
|
+
*/
|
|
6582
|
+
function looksLikeV1ListToken(raw: string): boolean {
|
|
6583
|
+
if (raw === '' || raw.includes(':')) return false
|
|
6584
|
+
try {
|
|
6585
|
+
const decoded: unknown = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
|
|
6586
|
+
return typeof decoded === 'object' && decoded !== null && (decoded as Record<string, unknown>)['v'] === 1
|
|
6587
|
+
} catch {
|
|
6588
|
+
return false
|
|
6285
6589
|
}
|
|
6286
|
-
return LIST_RESUME_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
|
|
6287
6590
|
}
|
|
6288
6591
|
|
|
6289
6592
|
/**
|
|
@@ -6302,31 +6605,73 @@ function parseListResumeCursor(value: unknown, binding: ListResumeBinding): stri
|
|
|
6302
6605
|
'device and filters.',
|
|
6303
6606
|
)
|
|
6304
6607
|
}
|
|
6305
|
-
if (typeof value !== 'string'
|
|
6306
|
-
|
|
6608
|
+
if (typeof value !== 'string') refuse('must be a `list:` token this tool emitted')
|
|
6609
|
+
const token = value as string
|
|
6610
|
+
// Length first: the bound is about CALLER INPUT and must not depend on which
|
|
6611
|
+
// face the string appears to belong to.
|
|
6612
|
+
if (token.length > MAX_RESUME_TOKEN_CHARS) {
|
|
6613
|
+
throw new Error(
|
|
6614
|
+
`cursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a ` +
|
|
6615
|
+
'resume token; omit `cursor` to read from the start.',
|
|
6616
|
+
)
|
|
6307
6617
|
}
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6618
|
+
if (token.startsWith(RESUME_CURSOR_PREFIX)) {
|
|
6619
|
+
// Issue #72. Two faces, two prefixes: a token starting with `cursor:` was
|
|
6620
|
+
// minted by `cherry_read`'s message face, and replaying it here would ask a
|
|
6621
|
+
// LIST method for a slice at an offset taken from a MESSAGE history — a
|
|
6622
|
+
// perfectly valid number that reads the wrong rows with no error anywhere.
|
|
6623
|
+
// Until #70 the list face shared this prefix, so an old token is named
|
|
6624
|
+
// FIRST: "start the walk over" is the only thing that works there, and it
|
|
6625
|
+
// is not what this branch's other verdict says.
|
|
6626
|
+
if (looksLikeV1ListToken(token.slice(RESUME_CURSOR_PREFIX.length))) {
|
|
6627
|
+
throw new Error(
|
|
6628
|
+
'cursor was issued by an older plugin version (a v1 list token, from before list tokens were ' +
|
|
6629
|
+
'compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.',
|
|
6630
|
+
)
|
|
6631
|
+
}
|
|
6632
|
+
refuse(
|
|
6633
|
+
'is a MESSAGE-face token (`cursor:<peer position>`, i.e. a `messagesResumeCursor`), which a list walk ' +
|
|
6634
|
+
'never issues — a list token starts with `list:`',
|
|
6635
|
+
)
|
|
6314
6636
|
}
|
|
6315
|
-
if (
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6637
|
+
if (!token.startsWith(LIST_RESUME_PREFIX)) {
|
|
6638
|
+
refuse('must be a `list:` token this tool emitted')
|
|
6639
|
+
}
|
|
6640
|
+
const raw = token.slice(LIST_RESUME_PREFIX.length)
|
|
6641
|
+
if (!raw.startsWith(`${LIST_TOKEN_VERSION}:`)) {
|
|
6642
|
+
if (looksLikeV1ListToken(raw)) {
|
|
6643
|
+
// A v1 token cannot be replayed at ALL: the format changed, so "the same
|
|
6644
|
+
// tool, device and filters" would fail again. The generic tail would
|
|
6645
|
+
// therefore tell the caller to do something that can never work — the one
|
|
6646
|
+
// thing this version marker exists to avoid.
|
|
6647
|
+
throw new Error(
|
|
6648
|
+
'cursor was issued by an older plugin version (a v1 token, from before list tokens were compact ' +
|
|
6649
|
+
'and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.',
|
|
6650
|
+
)
|
|
6651
|
+
}
|
|
6324
6652
|
refuse('is not a token this tool emitted')
|
|
6325
6653
|
}
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
6654
|
+
let rest = raw.slice(LIST_TOKEN_VERSION.length + 1)
|
|
6655
|
+
const fields: string[] = []
|
|
6656
|
+
for (let index = 0; index < LIST_TOKEN_FIELDS; index += 1) {
|
|
6657
|
+
const separator = rest.indexOf(':')
|
|
6658
|
+
if (separator < 0) refuse('is not a token this tool emitted')
|
|
6659
|
+
fields.push(rest.slice(0, separator))
|
|
6660
|
+
rest = rest.slice(separator + 1)
|
|
6661
|
+
}
|
|
6662
|
+
// What is left is the peer position, verbatim — colons and all.
|
|
6663
|
+
const [code, device, filters, checksum] = fields as [string, string, string, string]
|
|
6664
|
+
const peerPosition = rest
|
|
6665
|
+
const body = `${LIST_TOKEN_VERSION}:${code}:${device}:${filters}`
|
|
6666
|
+
// The checksum is judged BEFORE the binding: a corrupted token is not a
|
|
6667
|
+
// statement about the caller's filters, and reporting it as one would send
|
|
6668
|
+
// the caller looking for a filter they never changed.
|
|
6669
|
+
if (checksum !== listTokenChecksum(body, peerPosition)) {
|
|
6670
|
+
refuse('is not a token this tool emitted (its checksum does not match, so it was altered in transit)')
|
|
6671
|
+
}
|
|
6672
|
+
if (LIST_CODE_METHODS[code] !== binding.method) refuse('was issued for a different tool')
|
|
6673
|
+
if (device !== listDeviceFingerprint(binding.deviceId)) refuse('was issued for a different device')
|
|
6674
|
+
if (filters !== listParamsFingerprint(binding.params)) refuse('was issued for a different filter set')
|
|
6330
6675
|
if (mangledRemainder(peerPosition)) refuse('is not a token this tool emitted')
|
|
6331
6676
|
return peerPosition
|
|
6332
6677
|
}
|