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/README.en.md +12 -5
- package/README.md +11 -5
- package/dist/index.d.mts +31 -3
- package/dist/index.mjs +1363 -170
- package/docs/e2e-checklist.md +9 -2
- package/lib/client.js +47 -0
- package/package.json +4 -3
- package/src/AGENTS.md +860 -0
- package/src/connection.ts +114 -10
- package/src/deviceStore.ts +204 -5
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +129 -8
- package/src/index.ts +190 -8
- package/src/pairing.ts +504 -112
- package/src/rpc/jsonRpcClient.ts +19 -1
- package/src/subscriptions.ts +10 -2
- package/src/text.ts +158 -0
- package/src/tools.ts +803 -56
- package/static/settings-devices.jpg +0 -0
- package/static/settings-pairing-scope.jpg +0 -0
- package/ui/AGENTS.md +23 -0
- package/ui/src/DeviceCard.tsx +35 -0
- package/ui/src/i18n.ts +10 -0
- package/ui/src/types.ts +9 -1
- package/src/urlHost.ts +0 -15
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
|
+
|