dsh-cherry-bridge 0.1.10 → 0.2.0

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/src/AGENTS.md ADDED
@@ -0,0 +1,860 @@
1
+ # AGENTS.md — `src/`
2
+
3
+ The server half of the bridge: the connection pool, the JSON-RPC client, pairing,
4
+ the fourteen tools, the HTTP face and the settings bundle's API.
5
+
6
+ Loaded automatically when a file tool touches `src/**` (a `bash`-only turn does
7
+ not load it). It holds the CONTRACTS — the protocol basis, the invariants each
8
+ one defends, and the upstream facts they rest on. The flow, the build facts and
9
+ the release steps are in the root [`AGENTS.md`](../AGENTS.md).
10
+
11
+ ---
12
+
13
+ ## Protocol basis
14
+
15
+ Pinned at **exact versions**, no `^`, with contract assertions on the pins:
16
+
17
+ | Package | Version |
18
+ | --- | --- |
19
+ | `@cherrystudio/remote-protocol` | `0.7.0` |
20
+ | `@cherrystudio/remote-transport` | `0.2.5` |
21
+ | `ws` | `8.22.0` |
22
+ | `zod` | `4.3.4` |
23
+
24
+ These are the versions Cherry Studio **desktop 2.1.8** ships (verified at
25
+ tag `v2.1.8`). Upgrading either protocol package means re-running
26
+ `tests/contract/contract.test.ts` against schema drift — the suite models the
27
+ **six behavioral differences** between our loopback fake peer and a real
28
+ desktop, each mapped to a manual step
29
+ in [`docs/e2e-checklist.md`](../docs/e2e-checklist.md).
30
+
31
+ **Wire budget (from `remoteLimits` in the protocol package):**
32
+
33
+ | Limit | Value | Consequence in this plugin |
34
+ | --- | --- | --- |
35
+ | `recordBytes` | 65,536 (64 KiB) | one JSON-RPC record above it **aborts the connection** — the transport answers by killing it, so every in-flight request fails |
36
+ | `contentBytes` | 24,576 (24 KiB) | the slice size for one `agent.content.read` |
37
+ | `pageItems` | 100 | the protocol's single-page hard cap; `hello` may negotiate smaller |
38
+
39
+ Through 2.1.7 the desktop's `pageOf` pages by **count alone** (2.1.8's byte
40
+ budget aside, still count-paged): `pageItems: 100` fat parts produced a record
41
+ the transport killed — reading a complex session reset the connection. The
42
+ byte-safe clamps (`defaults.ts`: a 4-bytes-per-char worst case over the peer's
43
+ 4,096-char `INLINE_TEXT_LIMIT`, +2 KiB margin) fix it; the effective page size
44
+ is `min(negotiated pageItems, clamp)`.
45
+
46
+ **Upstream context (#21854, shipped in 2.1.8).** A `PAGE_ITEM_BYTES` byte budget
47
+ (62,279) was added to every paged `agent.*.list` face (`pageOf` truncates,
48
+ `queryPage` re-queries a keyset page at a halved limit); an item that cannot fit
49
+ raises `RESOURCE_EXHAUSTED`. No release through 2.1.7 carries it, so deployed
50
+ peers mostly page by count alone and the clamps stay.
51
+
52
+ Follow-up [`#21951`](https://github.com/CherryHQ/cherry-studio/issues/21951):
53
+ an item above the budget **dead-ends the cursor** — it and everything behind it
54
+ become unreachable via a schema-valid record (`partIds` has no bound) —
55
+ `RESOURCE_EXHAUSTED` presents that permanent condition as retryable, and
56
+ `queryPage`'s halving wastes up to half the budget. A peer may therefore
57
+ come back with a page **shorter** than requested while still promising more,
58
+ which our walk already follows (`toolsListWalkCompat.test.ts` T2); it must
59
+ never come back **longer** (`CherryPage.pageOverflow`).
60
+
61
+ **Paged summaries (#105).** The hello `agentMessageSummariesVersion` bit is
62
+ stored beside `limits` per hello (absent when unadvertised — no reconnect
63
+ inherits it); ONE helper (`messageListRequest`) serves both message walks and
64
+ sends `partsMode: 'paged'` only at exactly `1`. Parts keep their clamp; only
65
+ the message page widens (to the negotiated `pageItems`); a
66
+ `RESOURCE_EXHAUSTED` with `details.retryable: false` says retrying cannot help.
67
+
68
+ **Pacing: two tiers against the desktop's allowances.**
69
+
70
+ | Tier | Methods | Desktop allowance | Default |
71
+ | --- | --- | --- | --- |
72
+ | `shared` | every business RPC but content reads | 64 req/s (≤2.1.6), **256 req/s** (≥2.1.7) | `rateBudgetPerSecond` = **32** |
73
+ | `content` | `agent.content.read` alone | 512 req/s (both) | `DEFAULT_CONTENT_READ_BUDGET_PER_SECOND` = **256** |
74
+
75
+ - **An over-budget request's verdict is generation-dependent**: through 2.1.6
76
+ the desktop closes the whole session (`1011`); from 2.1.7 (PR #21687) it
77
+ refuses that one request (`RATE_LIMITED` + `retryAfterMs`) and keeps the
78
+ session. The generations cannot be told apart before one answers, so the
79
+ default is sized for the harsher: 32/s is 2× under 64, 8× under 256; the
80
+ content tier is 2× under 512.
81
+ - The tier is chosen by `classifyRateTier`, an **exact match**, not a prefix:
82
+ widening it gives a shared-tier method the content budget silently.
83
+ - The content tier is **not a config key** — it derives from the one injected
84
+ shared budget at 8:1, so lowering `rateBudgetPerSecond` lowers both, and is
85
+ **capped at the peer's 512/s** (both generations), so a shared budget above 64
86
+ stops scaling it.
87
+ - Since 2.1.6 the desktop externalizes every part over **4,096 characters**,
88
+ making multi-slice content walks the common case — the reason the content
89
+ tier exists.
90
+
91
+ ## Design decisions and invariants
92
+
93
+ These are load-bearing; the code comments are the long version.
94
+
95
+ ### Transport loss: reads replay once, commands never
96
+
97
+ - A **read** method that loses its link mid-flight is replayed **once**, after a
98
+ reconnect and through a **fresh** rate-limit admission (no replay storm). A
99
+ second loss, or a torn-down connection lifecycle, yields a structured lost
100
+ error (`TransportLostError`, retryable) instead of an endless retry. The
101
+ replay waits at most `transportRetryWaitMs` (default 5 s).
102
+ - A **command** (`cherry_send` / `cherry_create` / `cherry_respond` /
103
+ `cherry_cancel`) is **never replayed**. A lost reply is not a verdict — the
104
+ command stays uncertain and is recovered by **receipt**: the caller retries
105
+ with the **original `commandId`**, and the desktop's command ledger replays
106
+ the original receipt. Only the same id with a *different* body is an
107
+ idempotency conflict.
108
+
109
+ ### The `connecting` window: waited out, then judged
110
+
111
+ - The pool builds a `DeviceConnection` on **first touch** and dials it at once
112
+ (`connectionFor`), so a tool called in the same tick — or during a reconnect —
113
+ observes `connecting`: **an attempt in flight, not a verdict**. It used to be
114
+ answered `device-unavailable` about a connection that came up a moment later.
115
+ - `#resolve` (the eleven device tools; `cherry_devices` and `cherry_unpair` take
116
+ their own paths) and `cherry_devices`' rows wait for that
117
+ attempt, bounded by `DEVICE_SETTLE_WAIT_MS` = 5 s: the wait ends on the first
118
+ transition out of `connecting`, or on the bound with the state it finds, so the
119
+ error face is the same — just later, after the attempt had its chance (before
120
+ any wait answer's `AssemblyDeadline`).
121
+ - **Only `connecting` is waited on.** `offline` (parked in backoff), `disabled`
122
+ and `identity-mismatch` answer at once: no wait can change them, and a
123
+ powered-off desktop would pay the bound on every call. Nothing here starts a
124
+ dial — the pool already did — so tools still never dial implicitly; kicking a
125
+ device out of backoff is separate work.
126
+
127
+ ### Memory: 32 MiB per ref, ~3× assembly, 4 refs at once
128
+
129
+ - `MAX_EXTERNALIZED_BYTES` = 32 MiB caps **one `{ref}`**, the same ceiling the
130
+ official client applies. It is a refusal to start a walk that could never
131
+ finish (a peer declaring 4 GiB would mean ~170,000 slices), not a proof about
132
+ the content. A part above it is `unavailable`.
133
+ - Assembling one ref's content holds roughly **3× its size** at peak: the
134
+ decoded slices, the single concatenation the digest and decoder need, and
135
+ the decoded string. That is the price of never hashing or decoding a buffer a
136
+ caller could mutate.
137
+ - An aggregate reads refs **sequentially**, so its own peak is one ref's 3×.
138
+ Across calls, ONE process-wide gate (`ContentReadGate`, issue #39) bounds how
139
+ many refs are being assembled at once: `DEFAULT_CONTENT_READ_CONCURRENCY` = 4,
140
+ the official client's read-concurrency policy. The whole engine — every tool
141
+ call, every device — shares it, so the ASSEMBLY peak is
142
+ **4 × 3 × 32 MiB = 384 MiB worst case**, and in ordinary traffic
143
+ `4 × 3 × (the declared size)`. It bounds the COUNT, not bytes: a byte budget
144
+ must reserve each ref's declared size (optimistic) or its cap when unusable
145
+ (serializing all behind one slot) — deliberately not what landed.
146
+ It bounds what is being ASSEMBLED, not everything resident: finished content
147
+ is retained by its caller OUTSIDE this product (up to one whole message per
148
+ concurrent read, because `AGGREGATE_BYTES` is judged only between messages),
149
+ plus the parts cache's own budgeted bytes (32 MiB per device).
150
+ - **The gate's boundary with the deadline (issue #21).** While a caller is
151
+ QUEUED it honours its budget: at the deadline it leaves the queue and fails
152
+ (`ContentGateTimeoutError`, which the projection degrades to one part's
153
+ `unavailable`). A slot that frees after the deadline but before that timer
154
+ runs is RE-CHECKED and passed on, never spent. Once a walk is RUNNING the gate
155
+ does not interrupt it — its per-slice requests carry the deadline to the
156
+ transport, which is where the I/O stops. Interrupting in both places would
157
+ make "cancelled but still reading" an unexplainable state.
158
+
159
+ ### Read cache: the parts-list RESULT, fenced by the peer's own revision
160
+
161
+ `cherry_read` and the terminal/timeout/idle aggregates share ONE projection
162
+ (`#projectMessage`), which runs in two steps: obtain the parts RECORDS (one
163
+ `agent.parts.list` walk), then project them (one `agent.content.read` walk per
164
+ externalized `{ref}`, digest-verified). The cache sits BETWEEN the steps —
165
+ lookup before the list, store after it — and holds the LIST, never the
166
+ projection. An unchanged message costs no repeat `agent.parts.list`
167
+ RPC on send → wait → read rounds, while every read still re-fetches and
168
+ sha256-verifies every `{ref}`'s bytes (a corrupted ref behind an unchanged
169
+ revision fails with `ContentIntegrityError`; `toolsReadCache.test.ts` T1b pins
170
+ this beside the cache). A hit is still not a zero-RPC read: 2.1.6+ externalizes
171
+ every part over 4,096 characters.
172
+
173
+ - **Entry & key**: the `agent.parts.list` result — `{revision, page}` — keyed
174
+ `<deviceId>\0<messageId>` (device-scoped: a message id is the peer's; two
175
+ desktops can issue the same one). The revision is the FENCE, not part of the
176
+ key, and it covers parts RECORDS only: the cache inherits the peer's own
177
+ `REVISION_EXPIRED` staleness class and adds none — and no more strength: the
178
+ revision is a millisecond wall clock, so two writes inside one millisecond
179
+ can keep it equal while the parts differ — a hit would then serve the older
180
+ list for that revision's lifetime, and reproducing it needs a read between
181
+ the two same-millisecond writes (a shared property of the scheme, not one
182
+ the cache invented).
183
+ - **Never cached**: a row with an EMPTY revision (no fence — defensive only,
184
+ `messageRevision` is a strict decimal, so no test drives it), a TRUNCATED
185
+ parts page (a short list
186
+ from a transient fault is retried, never remembered; the refusal is
187
+ reason-blind so a future reason cannot reintroduce the bug), and a page above
188
+ `MESSAGE_CACHE_ENTRY_BYTES` (1 MiB; walked fresh rather than allowed to crowd
189
+ out a device's working set).
190
+ - **A failed part does NOT forget the list**: degradation happens in
191
+ projection; the held records stay the peer's complete answer and every part
192
+ is re-attempted next read.
193
+ - **Sizing**: `JSON.stringify(page.items).length` (the `AGGREGATE_BYTES`
194
+ proxy). It excludes `{ref}` text (the multi-megabyte half) but INCLUDES
195
+ inline text — up to the peer's 4,096-char `INLINE_TEXT_LIMIT` per part, so a
196
+ few hundred fat inline parts can legitimately reach the 1 MiB entry cap.
197
+ `MESSAGE_CACHE_BYTES_PER_DEVICE` = 32 MiB. Two bounds judged INDEPENDENTLY:
198
+ an entry exceeding the entry cap **or** the device budget is refused — an
199
+ entry the device can never fit must not evict it wholesale to store itself.
200
+ - **Eviction is FIFO** — `Map` order IS the policy (the same choice `#remember`
201
+ documents). A **refresh keeps the key's insertion position** (a rewrite, not
202
+ a use; delete-then-set would silently make it LRU). Room is made BEFORE a
203
+ write for the write's **growth** (`bytes - previous`, zero when it shrinks),
204
+ so a device never passes budget on either path — the refresh-that-grew case
205
+ is the one that used to slip through. `#dropCachedMessage` is the ONE delete
206
+ path (totals cannot drift from the map); the loop skips the key being written
207
+ (self-eviction would reorder it). A drop warns once per insert that dropped
208
+ anything: device and count only, never a message id.
209
+ - **A hit hands the PAGE out BY REFERENCE** — safe while projection only reads
210
+ it; a future mutator must copy first.
211
+ - **Invalidation** rides `#forgetDevice` with the three session caches;
212
+ `#watchConnection` routes `offline` / `identity-mismatch` there. An ordinary
213
+ link loss also publishes `offline`, so a reconnect drops the cache — one
214
+ re-walk per session, the documented cost of the seam.
215
+
216
+ ### Walks: what is tolerated, what is refused
217
+
218
+ - **Empty pages.** The desktop's pinned-list walk legally answers
219
+ `{items: [], nextCursor: 'entity:'}` once per walk (the pin→entity section
220
+ transition). Tolerance is **2 consecutive** advancing empty pages; the **3rd**
221
+ refuses. Any non-empty page resets the count.
222
+ - **A page whose `items` is not an array** (wrong type, or absent) is a
223
+ PROTOCOL violation, refused on EVERY page, terminal or not: `pageOf` types the
224
+ field `array(item)`, so a page with no rows says `items: []`. Read as an empty
225
+ page it loses a whole page silently — on a terminal page even as
226
+ `truncated: false`. The throw names the shape it got (`missing`, `null`,
227
+ `string`, …) and never the payload, and it sits **before** the dropped-item
228
+ bookkeeping (no array, no drop count to report).
229
+ - **Cursors.** A cursor that does not advance is refused, and so is a cursor
230
+ that **loops back** to one already served (a `seen` set per walk) — never spun
231
+ on. `MAX_AGGREGATE_PAGES` (10,000) is the backstop for every other
232
+ non-terminating shape.
233
+ - **Ordering of guards.** The stuck-cursor check runs **before** the empty-page
234
+ tolerance: tolerating empty pages must not blunt the no-progress guard.
235
+ - A truncated walk is **always surfaced** (a `truncationReason` plus a
236
+ stop-point token), never a silent short result. Hitting a safety bound fails
237
+ or reports; it never returns a partial as if it were complete.
238
+ - **An overshooting page is absorbed, not refused.** `limit` is a MAXIMUM on a
239
+ list method, so a page carrying more items than were asked for is a peer fault
240
+ — but nothing is lost yet, and throwing would make the list unreadable (the
241
+ worse failure). The walk takes what its ceiling allows, stops with the
242
+ caller's own attribution, and **re-uses the position it SENT** for that page:
243
+ the page's own `nextCursor` sits past items the walk never collected, so a
244
+ resume from it would skip them, and a terminal overshooting page would
245
+ otherwise be reported complete while dropping its tail. `CherryPage.pageOverflow`
246
+ records the case and `messageResumeToken` then prefers the exact `after:`
247
+ anchor.
248
+ - A **resume** (an `after:` anchor scan) obeys every guard above unchanged —
249
+ including the stuck-cursor and cycle checks, which are pinned during a scan by
250
+ their own tests. Its two failures are its own: an anchor that is not on any
251
+ page up to the peer's last one, and a scan longer than
252
+ `MAX_ANCHOR_SCAN_PAGES`. Both throw. A scan asks for a **full page**
253
+ regardless of the caller's `limit`: its items are discarded either way, and
254
+ paging at `limit: 1` would spend the page budget on a history a full page
255
+ reaches in dozens.
256
+
257
+ ### Wait answers: one absolute deadline
258
+
259
+ The aggregate behind `cherry_send(wait: true)` / `cherry_wait` (the terminal,
260
+ timeout and idle faces) runs AFTER the caller's wait window, so the window
261
+ cannot bound it. It once had no time bound at all: since #12 a read whose link
262
+ dies waits up to `transportRetryWaitMs` (5 s), so under a flapping link each of
263
+ ~30 requests could absorb that wait — a 1 s window answering ~150 s later, the
264
+ shape #3 fixed with a message ceiling and a byte budget (issue #21).
265
+
266
+ - **One instant, derived once.** `AssemblyDeadline` = `startedAt + window +
267
+ clamp(window, WAIT_ANSWER_TAIL_FLOOR_MS, WAIT_ANSWER_TAIL_MS)` — one more
268
+ window, floored at 1 s (the tool's window has a 1 ms minimum, and a 1 ms
269
+ budget would return the stub receipt for a reply one round trip away) and
270
+ capped at 15 s (the largest single item in the walk is one 32 MiB `{ref}` at
271
+ ~1,365 sequential slices, single-digit seconds on a LAN; the rest of the walk
272
+ is bounded to 20 messages). It is built on `performance.now()`, the same
273
+ monotonic clock as the waiter's own deadline.
274
+ - **Every wait is charged to it, where the wait happens.** The rpc layer clamps
275
+ a request's own timeout to `min(timeoutMs, remaining)` (so a budget farther
276
+ away never lengthens one, and one stuck request cannot eat a whole answer);
277
+ the connection refuses a request — and a replay — once it has expired
278
+ (`RpcDeadlineExceededError`), caps the reconnect wait by the remaining budget,
279
+ and judges the PACING ADMISSION before taking it (a spent budget buys no
280
+ slot) as well as after (taking one can consume the last of the budget). The
281
+ walk re-judges the deadline BEFORE every request so a spent budget never
282
+ spends an admission and a round trip, and between messages so one message's
283
+ parts walk cannot start with no time left. One bound is left over: a request
284
+ ALREADY queued for its pacing slot is released by the limiter, so it can be
285
+ refused up to one `#minGapMs` late (36 ms shared, 5 ms content). Refusing it
286
+ earlier needs deadline-aware pacing — a separate piece of work.
287
+ - **A spent budget is a STOP, not a failure.** The message walk and the parts
288
+ walk stop where they are and report `'deadline'` — the third
289
+ `truncationReason`, beside `'limit'` and `'aggregate-bytes'` — and the content
290
+ already read is returned (spec §3.6: a timeout answer exists to hand back
291
+ partial content). A message whose own parts were cut carries
292
+ `partsTruncated: 'deadline'` + `incomplete`. A CONTENT slice has no verdict of
293
+ its own to report: it is one part inside a message, so the part is marked
294
+ `unavailable` (the degradable class — the bytes could not be verified) and the
295
+ message's ordinary projection flags it `incomplete`. **Only** the two
296
+ time-exhaustion classes are converted: a peer refusal, an offline device or an
297
+ integrity failure after the deadline still surfaces as itself, because
298
+ reporting it as `'deadline'` would send the caller looking at their own clock.
299
+ - **`clampedByDeadline` is a flag, not a clock test.** A timer may fire a
300
+ millisecond early, so "has the deadline passed by the time I handle the
301
+ rejection" is a race; `RequestTimeoutError` records whether its timeout was
302
+ DERIVED from the deadline, which is a fact about how the error was produced.
303
+ A plain client timeout (the 60 s default) is not the budget and is not
304
+ converted.
305
+ - **The snapshot is inside the budget and inside the degrade arm — on the
306
+ terminal and timeout faces.** Both start with `agent.sessions.get` (it carries
307
+ `historyRevision`); it rides the same deadline, and its failure degrades to
308
+ the stub receipt (`aggregateError` + `guidance`) exactly like a failed walk —
309
+ the #3 invariant: a run's verdict and its identifiers survive a content
310
+ failure. Before #21 a dead link there made `cherry_send` reject.
311
+ The IDLE face is different, and it is no longer a gap (issue #51): its
312
+ `agent.sessions.get` is the §6 PRECONDITION that decides whether the session
313
+ is idle at all, so there is nothing to degrade to — but it now rides an
314
+ `AssemblyDeadline` of its own, anchored at TOOL ENTRY with the caller's window.
315
+ A spent budget FAILS the call with a guidance error: nothing was learned about
316
+ the run, so a retry is the recovery, and the caller is never handed a verdict
317
+ the snapshot never produced. Before this it was the one wait-answer request
318
+ with no absolute bound, and a flapping link could put 5–10 s of reconnect +
319
+ replay behind a 500 ms window. The idle answer's HISTORY walk keeps its own
320
+ anchor AFTER the snapshot, deliberately: a snapshot that was slow but
321
+ succeeded must not leave the walk with nothing, so the two phases are bounded
322
+ separately (a pathological call can spend both).
323
+
324
+ What this is NOT: a global `cherry_read`/list-tool budget — they take no
325
+ deadline; their bounds are `limit`, the safety breaker and the byte budgets.
326
+
327
+ ### Stop-point cursors (the teaching case)
328
+
329
+ `messagesResumeCursor` is a stop point **and** an input: `cherry_read` takes it
330
+ back as its own `messagesResumeCursor` argument (#26). The marker a walk emits
331
+ is the token a walk accepts — **round-trip symmetry** is the invariant, and both
332
+ message-walk faces (the read face and the terminal/timeout/idle aggregate)
333
+ derive it from the SAME helper, `messageResumeToken`, so they cannot drift into
334
+ two dialects.
335
+
336
+ Three `truncationReason` values mean different things:
337
+
338
+ - **`limit`** — the caller's own ceiling was reached with more available. Fix:
339
+ raise `limit`, or resume with the token and keep walking in another answer.
340
+ - **`aggregate-bytes`** — the answer reached its **output budget** with the walk
341
+ still having messages. This is **independent of `limit`**: raising `limit` only
342
+ costs more round trips and stops at the same budget. The **resume token** is
343
+ how the older messages are reached; from a resume point `limit` bounds how
344
+ many messages one answer carries, so a long history is walked in successive
345
+ answers, each carrying the next stop point.
346
+ - **`deadline`** — the WAIT ANSWER's own assembly budget ran out (see
347
+ [Wait answers](#wait-answers-one-absolute-deadline)). Neither the caller nor
348
+ the peer did anything wrong, so neither raising `limit` nor resuming helps by
349
+ itself: the content that was read is returned (with the token when one can be
350
+ named), and the rest is reached with `cherry_read`, which has no deadline.
351
+
352
+ Keep this distinction in the README. Collapsing `aggregate-bytes` into "raise
353
+ `limit`" was a field defect (a caller paid every round trip and still never saw
354
+ the cut-off history), and a marker with no consumer states a loss nothing can
355
+ act on.
356
+
357
+ **Two token forms, both prefixed** — the prefix is ours, the rest is opaque:
358
+
359
+ - `cursor:<peerPosition>` — the walk consumed its whole collected window, so the
360
+ peer's page position is exactly where the unread history begins. The resume
361
+ read sends it as the **first** request's `cursor`.
362
+ - `after:<messageId>` — the walk stopped **inside** its collected window (a byte
363
+ stop), so the peer's position sits past messages the walk already collected
364
+ and would skip them. `AgentMessage` carries no `createdAt`, so no peer cursor
365
+ can be synthesised for that boundary either. The anchor is the **last returned
366
+ message**; the resume read starts cursorless, scans from the newest end until
367
+ it has met the anchor (discarding pages whole), and returns what follows it.
368
+
369
+ **The peer's cursor format is never parsed or validated.** The real desktop's is
370
+ an opaque keyset token, the loopback peer's is a numeric offset, and a future
371
+ desktop may change it. Every token we emit is
372
+ therefore self-describing, and every token we accept must carry one of our two
373
+ prefixes; anything else is refused **before any device work**. This is not
374
+ pedantry: a malformed cursor handed to the real desktop is **silently swallowed**
375
+ (it falls back to the newest page), so a forwarded foreign string would come
376
+ back as the newest page presented as "continued history".
377
+
378
+ **An anchor the walk cannot find is loud.** If the peer's terminal page arrives
379
+ with the anchor still unfound, the read throws naming the message id (it is
380
+ probably gone: deleted, or the session replaced), rather than returning an empty
381
+ page that reads as "no older history" — the opposite of what happened. A scan
382
+ past `MAX_ANCHOR_SCAN_PAGES` (500 pages) throws for the same reason: a session
383
+ that deep is not one this tool should chase.
384
+
385
+ **Every existing guard stays in force during a scan** — the stuck-cursor check,
386
+ the cursor-cycle `seen` set, the consecutive-empty-page run and
387
+ `MAX_AGGREGATE_PAGES`. The empty-page run counts the **raw** page the peer
388
+ served, never what the scan kept: a non-empty page nobody wanted is still
389
+ progress. The collected-item ceiling counts **returned** items, so a scan's
390
+ discarded pages do not spend the caller's `limit`.
391
+
392
+ **The LIST face (issue #65) speaks the same vocabulary with one addition.** A
393
+ list walk is scoped by (method, device, filters), and the peer's page position is
394
+ opaque: replaying one from a different query is a perfectly valid number that
395
+ reads the wrong slice with no error anywhere. So a truncated list answers
396
+ `resumeCursor: "list:<opaque>"` — the position PLUS a fingerprint of that
397
+ binding — and the token is refused locally (no dial, no page) when replayed
398
+ against a different tool, device or filter set. The bare peer position never
399
+ leaves the tool: `nextCursor` is internal now. The `after:` anchor form stays
400
+ message-only (a list walk never trims a page, so a stop it can name always has a
401
+ peer position). From a resume point `limit` bounds THAT answer; a page the peer
402
+ overshoots is re-read from the position the walk sent (duplicates, never a gap),
403
+ and `cherry_interactions` counts pre-filter rows against `limit`.
404
+
405
+ **The token itself is compact and checksummed (issue #70):**
406
+
407
+ list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>
408
+
409
+ - One character per tool (`a`/`w`/`s`/`i`) instead of the method name, a 12-hex
410
+ **digest** of the deviceId instead of the 36-character UUID, an 8-hex filter
411
+ fingerprint, and an 8-hex checksum. The peer position goes **last and
412
+ verbatim** (it may contain the delimiter — the desktop's session cursor is
413
+ `entity:<orderKey>:<uuid>`), so the split stops at a fixed field count. The
414
+ three binding verdicts keep their own wording; `MAX_RESUME_TOKEN_CHARS` (4,096,
415
+ shared with `cherry_read`'s `messagesResumeCursor`) is enforced by the parser and
416
+ declared as the parameter's `maxLength`.
417
+ - **The checksum is the load-bearing half of that change, not the length.** The
418
+ position is the one field no structural check can validate: measured over
419
+ 20,000 random single-character corruptions of the old base64-JSON token,
420
+ **28.7% were accepted with a different position** — and the desktop's cursor
421
+ decoders (`decodePinnedListCursor`, `decodeListCursor`) warn and fall back to
422
+ the FIRST page for a position they cannot parse, so "continue from here"
423
+ silently became the newest page presented as older history. Every
424
+ single-character corruption is now refused locally (a test walks every
425
+ position of a real token), and the token is 225 → ~100 characters on the
426
+ sessions worst case, ~150 → ~44 on the numeric faces.
427
+ - **Two faces, two prefixes (issue #72).** The message face owns `cursor:` /
428
+ `after:`; the list face owns `list:`. They must stay disjoint: while they
429
+ shared one prefix, a list token replayed as `messagesResumeCursor` was a legal
430
+ message cursor and was forwarded to the desktop, which answers an unparseable
431
+ position by serving its FIRST page — the silent "newest page as older history"
432
+ again. Each face now refuses the other's prefix with a message naming the
433
+ mistake where the prefix identifies the other face (a bare `after:` on a list
434
+ tool gets the generic "must be a `list:` token" verdict).
435
+ The two exceptions are about OUR OWN retired formats, never about the peer's:
436
+ a v1 list token is recognised by DECODING it (it lived under `cursor:` and its
437
+ payload is colon-free base64url, so the message face would otherwise forward
438
+ it), and it is named as an older format FIRST. A peer position is never
439
+ inspected for a marker: `cursor:list:20` is still a message cursor whose
440
+ `list:20` position travels verbatim, because the peer's format is opaque.
441
+ - A v1 token (base64 JSON, `{v,m,d,h,p}`) is refused by name — a plugin upgrade
442
+ invalidates a held token, and "start the walk over" is the actionable answer,
443
+ so that refusal does NOT reuse the generic "…or replay the token" tail: a v1
444
+ token can never be replayed, whoever holds it.
445
+ - **Both faces name only a remainder their own parser would accept, and the rule
446
+ lives in ONE pair of helpers.** `nameableRemainder` is the content dimension —
447
+ `mangledRemainder` itself, the predicate `parseMessageResume` and
448
+ `parseListResumeCursor` apply on replay — so the two sides cannot drift into
449
+ two dialects. It refuses an empty remainder, one that is not equal to its own
450
+ `.trim()` (leading/trailing whitespace), and one carrying a control character
451
+ ANYWHERE; an interior plain space is legal and round-trips (`a b`).
452
+ `nameablePeerPosition` adds the one refusal that applies to a POSITION and not
453
+ to an anchor: `looksLikeV1ListToken`, because our retired 0.1.6 token
454
+ (colon-free base64url of `{v,m,d,h,p}`) is indistinguishable from a peer
455
+ position and both parsers name it. The `after:` arm deliberately does NOT take
456
+ that second check — `parseMessageResume`'s `after:` branch never consults the
457
+ detector, so applying it there would be the same bug mirrored: refusing to name
458
+ a token the parser takes.
459
+ **The round trip itself is a test, not a promise.** `parseMessageResume` is
460
+ exported for one property: every token these emitters NAME is one it ACCEPTS.
461
+ It is asserted over a varied probe table (numeric, keyset, unicode, interior
462
+ space, base64url-shaped, the retired v1 payload) rather than per-clause, so a
463
+ refusal added to the parser WITHOUT mirroring it here turns red for any probe
464
+ that reaches it — the compensations are that a clause no probe reaches still
465
+ slips through, and that over-refusal (naming less than the parser would take)
466
+ is allowed by design, since it costs resumability rather than correctness.
467
+ The failure mode all of this prevents is the emitter minting a stop point the
468
+ same tool then refuses: the caller holds a token that can never be used, and
469
+ the refusal surfaces at the resume call instead of at the answer that produced
470
+ it. A refused remainder yields no token at all — `truncated` already says the
471
+ walk was cut, and the remedy then follows the REASON rather than the absence:
472
+ a `'limit'` stop answers to a wider `limit` only where the caller owns that
473
+ ceiling (`cherry_read` and the four list tools — a WAIT answer's `'limit'` is
474
+ its own fixed message allowance, so `cherry_read` is the way further), an
475
+ `'aggregate-bytes'` stop is that answer's own output budget (more round trips,
476
+ same budget), a `'safety-bound'` stop is an internal circuit breaker a caller
477
+ cannot move, and a `'deadline'` stop — a wait answer's reason — is continued
478
+ with `cherry_read`, which carries no deadline. The message face applies the
479
+ content rule to the `after:` anchor as well as the `cursor:` position: a
480
+ message id is an `opaqueId` too, so a padded id is schema-valid and really does
481
+ reach the anchor arm. The LENGTH ceiling is the sibling guard in the next
482
+ bullet.
483
+ - **The ceiling is enforced on the way OUT too — the same refusal in one more
484
+ dimension.** `MAX_RESUME_TOKEN_CHARS` is judged by both parsers, so a token
485
+ over it is one this tool would refuse on replay, and emitting it would hand
486
+ the caller a stop point that can never be used. Both emitters run the FINAL
487
+ token string through `withinResumeTokenCeiling` — the message face's
488
+ `cursor:` and `after:` arms alike, and the list face's encoded token — and
489
+ name nothing when it is over the bound: `truncated` still says the walk was
490
+ cut, and the remedy follows the reason as set out above. The comparison is
491
+ `<=`, so exactly 4,096 characters is still emitted. The guard measures the
492
+ string it is about to hand out, never a reconstruction of its length, so a
493
+ format change cannot leave it behind. This is about what the tool ACCEPTS; a
494
+ well-behaved peer is far below it (the protocol's own `opaqueId` caps a cursor
495
+ at 256 characters).
496
+ - **The `after:` arm's LENGTH case is pinned on the function; its CONTENT case
497
+ runs through the tool.** An anchor is a message ID, and projecting that
498
+ message sends the same id to `agent.parts.list`, whose `messageId` is an
499
+ `opaqueId`. A peer item carrying an id above the token ceiling therefore
500
+ fails its parts walk and is never RETURNED, so the over-long state is
501
+ unreachable through the protocol, not merely untested; that arm is pinned by
502
+ driving `messageResumeToken` directly (`tests/unit/resumeTokenEmit.test.ts`,
503
+ both arms, both sides of the boundary), and the guard stays regardless,
504
+ because the emitter must never mint what the parser refuses, whatever the
505
+ peer's cap becomes. The CONTENT refusal IS reachable: `opaqueId` allows
506
+ padding, so the parts walk for `' m-0 '` succeeds and that message becomes
507
+ the anchor — `tests/integration/toolsReadResume.test.ts` ⑭d/⑭e/⑭f drive both
508
+ arms through the tools. The `cursor:` arm and the list face keep their
509
+ end-to-end tests as well (`toolsReadResume.test.ts` ⑭a/⑭b,
510
+ `toolsRead.test.ts` ⑦h), which is why that helper is exported.
511
+ - **The tool DESCRIPTION is part of the contract, and it is pinned.** The guards
512
+ above bound what a tool ACCEPTS and NAMES, so a description promising a token
513
+ unconditionally is a claim the tool contradicts — and it is the claim a model
514
+ reads first, more consequentially than the READMEs. Every TOP-LEVEL description
515
+ that mentions a resume token says "when the stop point can be named", and none
516
+ names the wrong absent case ("reached the peer's last page" was the stale one:
517
+ a TRUNCATED walk by definition never reaches it). `cherry_read` states the
518
+ exceptions, and it states them as EXAMPLES, deliberately: an exhaustive list is
519
+ a promise that the next guard will falsify.
520
+ `tests/unit/toolSchemas.test.ts` asserts the clause on every description that
521
+ mentions the token (naming the tools would let a future one slip in) and the
522
+ retired wording across all fourteen, so reverting one to the old text is a red
523
+ test rather than a review catch.
524
+
525
+ ### `AGGREGATE_BYTES`: one budget, two faces
526
+
527
+ `AGGREGATE_BYTES` = 256 KiB bounds a **projected message aggregate** (measured
528
+ with `JSON.stringify(...).length` as a cheap approximation of wire cost). Two
529
+ faces share it and one vocabulary:
530
+
531
+ - the terminal/timeout/idle aggregate returned by `cherry_send(wait: true)` /
532
+ `cherry_wait` (message ceiling 20, `TERMINAL_AGGREGATE_MESSAGES` — ten runs'
533
+ slack, newest-first), and
534
+ - `cherry_read`.
535
+
536
+ The constraint that failed in the field is the **MCP output boundary**: it does
537
+ not care which tool produced an oversized answer ("value is not lossless JSON"
538
+ loses the verdict *along with* the content). One budget, one reason word; what
539
+ differs between the faces is the **message ceiling**, not the cost of a message.
540
+ A second byte value would drift silently.
541
+
542
+ The budget is checked only **between** messages and the first message is always
543
+ taken before any check: the **newest message is always returned whole**, and a
544
+ stop only ever drops **older** messages.
545
+
546
+ ### Truncation has two carriers; never conflate them
547
+
548
+ - **Message-walk truncation** (`truncated` + `truncationReason` +
549
+ `messagesResumeCursor`) drops the **older** messages. It never cuts inside a
550
+ returned message's parts.
551
+ - **Parts-walk truncation** (`partsTruncated` + `partsResumeCursor`) drops that
552
+ one message's **later** parts; parts are read oldest-first. The reason is
553
+ `'safety-bound'` for the local circuit breaker at `DEFAULT_PARTS_SAFETY_BOUND`
554
+ = 10,000 (the protocol caps nothing there) and `'deadline'` when a wait
555
+ answer's budget cut the walk (see
556
+ [Wait answers](#wait-answers-one-absolute-deadline)); the parts walk has no
557
+ byte budget of its own.
558
+
559
+ A returned message missing its own content carries `incomplete: true`, plus
560
+ `unavailableParts` when parts were unreadable — a content verdict on the
561
+ message, unrelated to either walk. **`unavailableParts` absent ≠ 0**: it is
562
+ absent when nothing was counted as unavailable (e.g. a pure parts-walk
563
+ circuit-break with no unreadable part).
564
+
565
+ ### `byteLength` tolerance is a deliberate divergence
566
+
567
+ The official client refuses to read a ref whose `byteLength` is not a safe
568
+ integer ≤ 32 MiB (`RESOURCE_UNAVAILABLE`). We are **more tolerant**: a missing or
569
+ unusable `byteLength` falls back to a slice bound (`DEFAULT_CONTENT_SLICE_BOUND`
570
+ = 4,096 slices), and the **cumulative bytes-read arm** still caps the walk, so a
571
+ peer that serves more than it declared is stopped by bytes actually read.
572
+ It is commented and covered by a test (a peer serving more than it declared);
573
+ do not "align" it without a reason.
574
+
575
+ ### Integrity: what degrades, what is fatal
576
+
577
+ Content the peer **would not or could not** serve (a `NOT_FOUND` pin, a
578
+ transient RPC failure, a non-advancing cursor) **degrades**: the part is marked
579
+ `unavailable` and the rest of the snapshot is still exactly what the peer said.
580
+
581
+ Anything that certifies **wrong or unverifiable** bytes does not degrade:
582
+
583
+ - A **digest mismatch** (reassembled bytes ≠ the ref's advertised sha256) raises
584
+ `ContentIntegrityError` and fails the **whole read**. Returning it as a
585
+ partial part would look like a complete snapshot containing content the peer
586
+ never certified — worse than an error, because the caller cannot tell.
587
+ - **Two sources, one digest.** The ref's declared sha256 and the slices' own
588
+ sha256 must **agree** when both are present; neither can certify the other. A
589
+ disagreement means no single certified value exists (degradable: the part
590
+ loses its text). Every slice is also compared against the ref as it arrives —
591
+ a final honest digest must not launder earlier dishonest ones.
592
+ - Bytes must be reassembled **before** decoding: slicing at 24 KiB splits
593
+ multi-byte characters, and per-slice decoding turns both halves into U+FFFD
594
+ while the byte digest still matches. The walk accumulates `Buffer`s, verifies
595
+ once, then decodes **once**.
596
+ - The decode is **fatal UTF-8**: a digest certifies bytes, not their legality.
597
+ Bytes that hash correctly but are not legal UTF-8 mark the part `unavailable`
598
+ rather than handing the model substitution characters as if they were text.
599
+ - A walk that never reaches `eof`, or that is stopped by any arm of the 32 MiB
600
+ cap, is a failure — never a "partial" value.
601
+
602
+ ### `cherry_read` facts worth keeping in mind
603
+
604
+ - Two-level baseline: `agent.sessions.get` for the history revision,
605
+ `agent.messages.list` for messages, and **each message's own revision** for
606
+ its parts.
607
+ - Messages are **newest-first**; `limit` drops the **oldest**.
608
+ - A failed message may carry `failure: {reasonCode, retryable, message}`
609
+ (peer classification, retry-worthiness, bounded summary); only its
610
+ `source`/`context` are withheld.
611
+ - Attachments — `kind: 'file'`, and legacy `kind: 'data'` named `file` whose
612
+ `{mediaType, filename}` is JSON in `content.text` — report `name`/`mediaType`
613
+ (`byteLength` from a ref only), no `unavailable` (bytes not fetched), and
614
+ `content` **empty** unless the record has a key we skip (then raw text,
615
+ lossless).
616
+ - A terminal aggregate that cannot assemble is a **content** problem, not a
617
+ verdict — the status stands; guidance points at `cherry_read`.
618
+
619
+ ### Device archive: scheme+port per host
620
+
621
+ `DeviceRecord` (v4) stores host-keyed `security` (`'ws'|'wss'`) and `ports`
622
+ (`number`) maps — one per candidate host. A stored v4 record
623
+ carries every entry (missing/unknown hosts, junk values rejected); the DIAL's
624
+ `?.` fallback (`ws`/`port`) serves only an in-memory pre-validation record.
625
+ `port` is the winner's at pairing, the operator's after a PATCH, and the
626
+ fallback. The steady-state dial uses `directEndpointUrl` + both maps (`??`):
627
+ `wss`→`wss` (#126), non-winner host→own port (#135); pairing dials the QR
628
+ candidate's own host/port/scheme, never the record. v2→v3 backfills `ws`,
629
+ v3→v4 `ports` from `port`, atomic on read. Scheme loss breaks a TLS proxy;
630
+ port loss dials the right scheme at the wrong port. Re-typing keeps scheme
631
+ (`securityFor`)/port (`portsFor`); whole-replace `hosts` defaults new hosts to
632
+ `ws`/port; a CHANGED `port` re-points EVERY final host, the current value a map
633
+ no-op (the settings UI sends both on save). `GET /devices` reports both maps
634
+ (addresses, not credentials); tools/diagnostics show only `port`. The settings
635
+ page shows the per-host ports only when they differ from the record-level one.
636
+ TLS trust is operator config (`wsTls`: FLAT `tls.connect` `rejectUnauthorized`/
637
+ `ca` only), forwarded to both dial sites (pool, pairing). `resolveBridgeConfig`
638
+ validates it: unknown key or unusable value warns by FIELD NAME, WHOLE object
639
+ ignored (half-applied trust reaches some peers, not others). Never persisted,
640
+ logged or reported; `ca` is PEM text.
641
+
642
+ ### Pairing: one dial budget across candidates
643
+
644
+ `desktopIdentity` is decoded at PARSE time with the transport's own
645
+ `peerIdFromString`: a pin that does not DECODE is `invalid-payload` before any
646
+ socket; a decodable non-Ed25519 pin still fails post-open (`channel:`).
647
+ ONE aggregate `dialBudgetMs` deadline (30 s) bounds the walk; unreached
648
+ candidates are named. Claim/poll re-anchors a FRESH 120 s race guard at the
649
+ claim (not the invitation's birth); peer `expired` is the real bound.
650
+ A failed PRE-OPEN dial is prefixed `timeout:` or `websocket:` (the `ws` code is kept); a POST-OPEN channel failure IN THE DIAL is `channel:` (a CLAIM failure rides an established channel and is not tagged).
651
+ Each segment is capped: `FAILURE_LABEL_MAX` = 128 clips an over-long host
652
+ (keeping its port) and `FAILURE_SEGMENT_MAX` = 240 caps the whole
653
+ `<label>: <detail>` (marker `… (+N chars)` included), so 32 candidates join to
654
+ ≤ 7,742 characters.
655
+ The progress line is bounded (first host always, 160 chars of host names,
656
+ `(+K more)`); a stage-failure verdict is ONE error, not a join, capped at
657
+ `FAILURE_SEGMENT_MAX`.
658
+
659
+ ### Device selector
660
+
661
+ `DeviceStore.resolve` = exact `deviceId` → the **union** of `deviceId` prefixes
662
+ and display-name prefixes (one filter, store order, deduplicated by `deviceId`)
663
+ → exactly one hit, `{notFound: true}` (+ `known`), or an ambiguity (+
664
+ `candidates`). `cherry_devices` returns either verdict as a structured result;
665
+ every other tool **throws** when nothing matches. A quarantined archive
666
+ (`archive-quarantined`) outranks all of this. `tests/unit/toolDeviceDocSync.test.ts`
667
+ pins the selector clause in every tool schema — 13 tools take a `device`
668
+ selector, `cherry_pair` does not.
669
+
670
+ ### Scope: hiding, not refusing
671
+
672
+ - The 14 tools are decided **per workspace**; the default is `selected` + an
673
+ empty list, so **nobody** has them (fail-closed: a corrupt/invalid/unreadable
674
+ document lands on the stricter side; no "unreadable → global" escape).
675
+ - A save re-installs or releases the restriction layer for **every live agent**
676
+ and takes effect on the **next turn** — no restart, no new session.
677
+ - Scope governs **tools only**; the settings page and `/cherry-remote/api` are
678
+ never scope-gated, so an excluded workspace can rescue itself.
679
+ - Capability missing is never papered over: `pending` is a neutral wait,
680
+ `unsupported` is a read-only warning with the real cause and save disabled;
681
+ under both the tools stay **unregistered**.
682
+ - `visibility-only` degradation means the execution-side backstop (`tools.guard`)
683
+ is gone but `restrict` still removes the tools from the table — a visibility
684
+ guarantee, not an execution boundary. The panel only ever claims "this plugin
685
+ does not shadow them"; upstream `allow`-shaped filtering can still hide them.
686
+ - The scope document lives at `<dataDir>/scope.json` (0600, atomic, monotonic
687
+ `revision`), **not** in profile YAML. The one exception: a document that fails
688
+ to parse or has invalid fields is rebuilt on the stricter side with `revision`
689
+ reset to 0, and stays writable. `PUT` is read-modify-write with a required
690
+ `expectedRevision`; a `PUT` prune is the only path that clears stale ids.
691
+
692
+ ### HTTP face: endpoints, scope errors and the diagnostics body
693
+
694
+ Mounted under the host web server's `/cherry-remote/api` prefix — **no extra
695
+ listener, no LAN exposure, no separate credentials**:
696
+
697
+ | Method & path | Purpose |
698
+ | --- | --- |
699
+ | `GET /devices` | Device list. |
700
+ | `PATCH /devices/:id` | Update display name / enabled / addresses / port (`hosts` is **whole-replace**). |
701
+ | `DELETE /devices/:id` | Remove the device and tear down its connection. |
702
+ | `GET /scope` | The whole scope state in one body (see below). |
703
+ | `PUT /scope` | Save the scope — read-modify-write, `expectedRevision` required. |
704
+ | `POST /pair` | Start a pairing run; returns `202 {claimId}` at once, progress over SSE. |
705
+ | `GET /events` | SSE: `device-state` / `pairing-progress`. |
706
+ | `GET /diagnostics` | Operations report (below). |
707
+
708
+ > The minimal `pairing-progress` frame shape is `{stage, detail?}`; this plugin
709
+ > **additionally** attaches the optional `claimId` (a cross-run isolation
710
+ > extension, not in the minimal set) — consumers must tolerate its absence.
711
+ > The heartbeat is a payload-free comment line.
712
+
713
+ `GET /scope` returns ONE body: `scope` (the document's `mode`/`workspaceIds`/
714
+ `revision` — `version`/`updatedAt` stay behind), `workspaces` (the registry
715
+ projected to `id`/`title`/`path` only), `staleWorkspaceIds` (ticked ids the
716
+ registry no longer has — a `PUT` prune is the only path that clears them),
717
+ `pathUnmatched` (`agentId` + `cwd` for sessions whose cwd belongs to no
718
+ registered workspace — treated as workspace-less, **not** a degradation), and
719
+ `host` (`state` / `capable` / `reason` / `degraded`). `staleWorkspaceIds` and
720
+ `pathUnmatched` are two separate channels and neither derives from the other.
721
+ `degraded` is an array of `{source, reason}`, one latest entry per source, over
722
+ exactly five closed sources — `install`, `workspace-list`, `visibility-only`,
723
+ `scope-version`, `path-normalization` (an empty array is the honest "no
724
+ degradation"). `host` and `pathUnmatched` come from the same scope read the
725
+ diagnostics report uses, so the two faces cannot drift.
726
+
727
+ `PUT /scope` failures return `{error, message?}` with a stable code:
728
+
729
+ | HTTP | `error` | Meaning |
730
+ | --- | --- | --- |
731
+ | 400 | `invalid-scope` | `mode` is not `global`/`selected`, or `workspaceIds` is not a string array. |
732
+ | 400 | `missing-expected-revision` | The baseline is absent **or not a finite number** (one guard judges both). |
733
+ | 409 | `scope-revision-conflict` | The baseline `revision` is stale; `message` carries the current one. |
734
+ | 503 | `workspace-list-unavailable` | The registry is unreadable: write refused, stored ticks and `mode` move not one bit. |
735
+ | 503 | `scope-version-unsupported` | The document's `version` disagrees with the supported one (either direction): read-only, writes locked. |
736
+
737
+ A body that cannot be read at all (invalid JSON) is the generic `400
738
+ invalid-request` — not a scope-field verdict. Success is `200 {scope, pruned}`;
739
+ the returned `revision` is the next write's baseline.
740
+
741
+ ### Diagnostics redaction is a whitelist
742
+
743
+ `GET /cherry-remote/api/diagnostics` aggregates device states, connection
744
+ statistics and a recent-error digest (`ERROR_MESSAGE_MAX` = 200 characters on
745
+ the error message).
746
+
747
+ - **May be emitted** (`sanitizeDevice`): `deviceId`, `displayName`, `state`,
748
+ `enabled`, `hosts`, `port`, a **12-char digest** of `desktopIdentity`, the
749
+ hello-negotiated `limits` (`heartbeatMs`/`subscriptions`/`pageItems`/
750
+ `inFlightRequests` — **only with a live connection**; the endpoint never
751
+ dials and never wakes a device to fetch fallbacks), `messageSummaries` (the
752
+ hello capability bit, absent when not advertised), `sessionId` and session
753
+ cursors (unsubscribed sessions have none), the `stats` counters
754
+ (`connections`/`sockets`/`connectionsBuilt`/`subscriptions`), the two resolved
755
+ `config` keys, and error `reason`/`message`.
756
+ - **Never emitted**: `accessToken`, `tokenExpiresAt`, `invitationSecret`, the
757
+ raw pinned `desktopIdentity`, message text and part text.
758
+ - The report is built field by field, so a later credential is excluded **by
759
+ construction**, not by a deny-list. `limits` is re-projected from its four
760
+ keys: a whitelist inside the whitelist.
761
+ - `stats.connectionsBuilt` counts connections built since the last `apply`, not
762
+ RPC requests — those belong to the transport; a same-named metric would be
763
+ misread. Counters and the error ring are zeroed on every `apply`: the
764
+ statistics describe **only** the current assembly.
765
+
766
+ ### Logging: levels, identifiers, and what never appears
767
+
768
+ Levels: **error** = connection / authentication / protocol failure; **warn** =
769
+ reconnect backoff / cache eviction / subscription LRU eviction; **info** =
770
+ pairing progress / subscription establish & recovery / device-state
771
+ transitions. Info logs the **stage name and identifiers only** (`deviceId`,
772
+ `sessionId`, pairing stage) — never payloads. Sensitive fields (tokens,
773
+ `invitationSecret`, `accessToken`, message and part text) **never enter any
774
+ log**; `deviceId`, the `desktopIdentity` digest and `sessionId` may. A
775
+ contract test drives pair → connect → send → interact and asserts sentinel
776
+ strings never reach a console channel (`contract.test.ts` — “leaks no secret
777
+ to the log”). **Peer-controlled text in a log line or a pairing verdict is
778
+ clipped where it is RENDERED**: `clipText` (one detail, `… (+N chars)`) or
779
+ `boundedJoin` (a list, `(+K more)`) in `src/text.ts`, per-site ceilings;
780
+ `deviceId` is exempt, and `bounded()` is the frame-drop path's older
781
+ count-less form.
782
+
783
+ **A CAUGHT failure renders as ONE bounded string** — `failureText` (the message,
784
+ `LOG_TEXT_MAX`) — in every catch that PEER traffic reaches: the notification
785
+ router, a background re-ensure, an ack, a best-effort close, the connect
786
+ rejection. Never hand the error OBJECT to `console.*`: it prints the name, the
787
+ message and the whole stack (own properties too, once a devtools inspect
788
+ expands it), so peer text arrives unbounded. A purely LOCAL catch keeps the
789
+ object print on purpose — no peer text can reach it and the stack is the useful
790
+ part: an fs quarantine, the host registry, our own listener/settle fan-out, a
791
+ REST route's synchronous construction. `failureText` cannot throw, because it
792
+ runs inside those catches. A TOOL RESULT is deliberately NOT bounded: it is the
793
+ model's signal, and the peer is the user's own Noise-authenticated desktop.
794
+
795
+ ### Trust fence
796
+
797
+ Every `/cherry-remote/api` route re-checks same-origin before acting, in order:
798
+ (1) non-loopback undeclared `Host` → refuse; (2) `sec-fetch-site: cross-site` →
799
+ refuse; (3) `Origin` present and not same-origin with `Host` → refuse;
800
+ (4) `Origin` absent → **allow** (curl, not a cross-site page).
801
+
802
+ - **A folded bind is normalized to an AUTHORITY.** `webStartup.host` is a bare
803
+ hostname, but every `trustedHosts` entry is parsed with `new URL('http://' +
804
+ entry)`, where an IPv6 literal MUST be bracketed. So an IPv6 `--host` is
805
+ bracketed when folded (`fe80::1` → `[fe80::1]`) and an already-bracketed value
806
+ is left alone (`[[fe80::2]]` parses to nothing). Without this an IPv6 LAN bind
807
+ contributed no usable entry and only loopback stayed reachable — fail-closed,
808
+ but an availability gap (issue #9). Zone-scoped literals (`fe80::1%eth0`, with
809
+ or without `%25`) still parse to nothing — this runtime rejects zone ids in an
810
+ IPv6 literal — and a browser sends no zone id. A bind with a PORT
811
+ (`example.com:8080`) is out of contract for `webStartup.host` and is dropped
812
+ rather than trusted, the fail-closed direction.
813
+
814
+ ## Upstream references
815
+
816
+ Neither reference is published with this repository.
817
+
818
+ - **`.scratch-csa`** (gitignored) — the official Cherry Studio **app**
819
+ (`CherryHQ/cherry-studio-app` @ v0.5.0, read 2026-10-27), the **same role we
820
+ play** (protocol *client* against the desktop's server). Its remote layer is
821
+ `src/backend/services/remoteAgent/`; "what the official client does" comes
822
+ from there. v0.5.0 moved onto our pins (0.7.0 / 0.2.5), whose one behavioural
823
+ change is the versioned pairing QR.
824
+ - **`_research/cherry-studio`** — the **desktop** source at v2.1.6 (`90b234e`),
825
+ read alongside v2.1.7, the protocol **server** of record. The faces we depend
826
+ on are `src/main/services/remoteAccess/` and
827
+ `packages/remote-{protocol,transport}`; `RemoteAccessService.ts` carries both
828
+ rate lines. Gitignored; treated as read-only research.
829
+ - `node_modules/@cherrystudio/remote-protocol` / `remote-transport` are the
830
+ protocol schema source of truth for what we send and decode.
831
+
832
+ **Verified desktop range: 2.1.4 → 2.1.8.** mDNS, protocol versions (`[1]`) and
833
+ Noise saw **zero changes**; pairing only ADDED the v3 QR arm (below). Notable
834
+ faces:
835
+
836
+ | Upstream change | Impact |
837
+ | --- | --- |
838
+ | 2.1.5 `connection.hello` adds `agentUploadsVersion` / `agentUploadChecksum` / `agentAttachmentSelections` and 5 `agentUpload*` limits | Additive: hello has no strict validation, limits are read by name |
839
+ | 2.1.5 record framing adds a binary marker (first byte `1`) for upload chunks | Additive: JSON record encoding is unchanged; we neither send nor receive binary |
840
+ | 2.1.5 attachments project as `{kind: 'file', name, ref}` | Metadata only — bytes are never fetched |
841
+ | 2.1.5 `agent.messages.send` requires non-empty trimmed `text` | `cherry_send` rejects blank text locally as `text-empty` |
842
+ | `agent.content.read` relaxed to 512/s | Our 32/s shared budget is unchanged; the content tier exists for throughput, not for the ceiling |
843
+ | 2.1.5 keychain-less Linux stores the identity as plaintext 0600; identity is never regenerated | Upgrades need **no re-pairing** |
844
+ | 2.1.7 raises the shared allowance to 256/s and refuses an over-budget request (`RATE_LIMITED` + `retryAfterMs`) instead of closing 1011 | No schema change; our 32/s default already sits far below it |
845
+ | 2.1.8: v3 pairing QR (`bc570d2b9`, PR #21387) — `pairingQrSchema` v2|v3 with custom host/port/security `endpoints`, tightened IPv6; paged message summaries (`54736dc9`/`8bf2fe28d`, PR #21992) advertised by the `agentMessageSummariesVersion` hello capability; server `PAGE_ITEM_BYTES = 62_279` per-item budget | Pairing consumes the published union (#124); summaries are consumed behind the hello capability (#105); a byte-budgeted page may come back SHORTER, which our walk already follows |
846
+ | `5db8ab7` (agent runtime DSH → Pi, deleting `packages/dsh-bridge`) | The **opposite** bridge (Cherry driving DSH); unrelated to the remote-access face |
847
+
848
+ **Traps in the official app (a cautionary list, not a spec).**
849
+
850
+ - Its `RemoteAgentError` marks those four retryable, but its scope layer DOES
851
+ honour `details.retryable: false`; it has no adaptive page size, so a
852
+ byte-budget `RESOURCE_EXHAUSTED` still does not converge at a fixed limit. Our
853
+ answer is the byte-safe page **clamp**: never ask for a page that cannot fit.
854
+ - Its read path refuses an unusable `byteLength`; we fall back to a slice bound
855
+ plus the cumulative arm.
856
+ - Its read path never replays a read; we replay reads **once** (deliberate
857
+ enhancement) while both sides keep commands receipt-recovered.
858
+ - Its command journal backs command recovery, which is why our commands are
859
+ never replayed.
860
+