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.
@@ -5,6 +5,7 @@ import {
5
5
  remoteFailureSchema,
6
6
  } from '@cherrystudio/remote-protocol'
7
7
  import type { SecureChannel } from '@cherrystudio/remote-transport'
8
+ import { failureText } from '../text.js'
8
9
 
9
10
  /** Default cap on business requests in flight on a single connection. */
10
11
  const DEFAULT_MAX_IN_FLIGHT = 8
@@ -558,7 +559,24 @@ export class JsonRpcClient {
558
559
  try {
559
560
  handler(method, params)
560
561
  } catch (error) {
561
- console.warn(`[cherry-remote] notification handler for "${method}" threw`, error)
562
+ // BOUNDED, and ONE STRING (issue #155; rendered by the shared
563
+ // `failureText`, issue #161). The old form passed the error OBJECT as
564
+ // a second argument, so the console chose the rendering: the stack
565
+ // plus every own property, with nothing bounding it. A handler
566
+ // failure is not a payload, so the line carries what an operator
567
+ // needs and nothing else — the METHOD and the MESSAGE under
568
+ // LOG_TEXT_MAX. The method is already bounded twice over, without
569
+ // clipText: the notification schema caps `method` at 128 characters
570
+ // and `bounded()` (the frame-drop path's older, count-less form,
571
+ // deliberately kept: folding it into clipText would change its bare
572
+ // `…` marker and the caller's asserted text) clips to 120 + an
573
+ // ellipsis.
574
+ // Ceiling: `[cherry-remote] notification handler for "` (42) +
575
+ // bounded method (≤ 121: FRAME_FIELD_LIMIT 120 + the ellipsis) +
576
+ // `" threw: ` (9) + LOG_TEXT_MAX (240) = 412.
577
+ console.warn(
578
+ `[cherry-remote] notification handler for "${bounded(method)}" threw: ${failureText(error)}`,
579
+ )
562
580
  }
563
581
  }
564
582
  return
@@ -1,6 +1,7 @@
1
1
  import { JsonRpcRequestError } from './rpc/jsonRpcClient.js'
2
2
  import type { JsonRpcClient } from './rpc/jsonRpcClient.js'
3
3
  import { DEFAULT_PER_SESSION_CACHE_BYTES } from './defaults.js'
4
+ import { failureText } from './text.js'
4
5
  import type { AgentCursor, AgentEvent } from '@cherrystudio/remote-protocol/agent'
5
6
 
6
7
  /**
@@ -760,8 +761,15 @@ export class SessionSubscriptions {
760
761
  await this.#rpc.request('agent.subscriptions.close', { subscriptionId })
761
762
  } catch (error) {
762
763
  // Closing is best-effort: the peer may already have reset it, and the
763
- // caller is about to establish a replacement either way.
764
- console.warn(`[cherry-remote] closing subscription ${subscriptionId} failed`, error)
764
+ // caller is about to establish a replacement either way. ONE bounded
765
+ // STRING (issue #161): the refusal is the PEER's JSON-RPC
766
+ // `error.message` verbatim, so the old object print let the console
767
+ // render peer text with no local bound. Ceiling:
768
+ // `[cherry-remote] closing subscription ` (37) + subscriptionId +
769
+ // ` failed: ` (9) + LOG_TEXT_MAX (240).
770
+ console.warn(
771
+ `[cherry-remote] closing subscription ${subscriptionId} failed: ${failureText(error)}`,
772
+ )
765
773
  }
766
774
  }
767
775
 
package/src/text.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Bounded rendering for peer- and payload-controlled text (issue #155).
3
+ *
4
+ * The ONE helper this audit STANDARDISED ON — not the first attempt at the
5
+ * idea: `src/diagnostics.ts`'s `truncateErrorMessage` (the diagnostics error
6
+ * digest) and `src/rpc/jsonRpcClient.ts`'s `bounded()` (the frame-drop
7
+ * warning's classification fields) predate it and stay where they are, for
8
+ * their own callers. #155 is where the rendering rule was stated once and
9
+ * applied to the SIX sites the audit found — four files: pairing's unknown-key
10
+ * warning and stage verdict, the link-recovery line, the two connection error
11
+ * lines, and the JSON-RPC notification-handler line. Each was unbounded for a
12
+ * different "why not" (a 10 KiB payload cap, a hello-advertised field, the
13
+ * 64 KiB record budget), which is exactly how the class of defect recurs: each
14
+ * site answered the question for itself.
15
+ *
16
+ * The rule these helpers establish is stated once, here: bound the text where
17
+ * it is RENDERED (never where it is carried — an error message is a value
18
+ * until a log line or a verdict makes it text), name the amount removed with
19
+ * the `… (+N chars)` marker, and never drop a list ENTRY silently (the
20
+ * `(+K more)` count is explicit).
21
+ *
22
+ * Issue #161 swept the SAME SHAPE at the sites #155 did not name — a catch
23
+ * handing an error OBJECT to `console.warn`'s second argument — on the paths
24
+ * PEER traffic reaches. One renderer states the answer there, {@link
25
+ * failureText}: the message alone, bounded. A purely LOCAL catch keeps the
26
+ * object print, deliberately: its stack is the useful part and no peer text
27
+ * can reach it (fs quarantine, the host registry, our own listener / settle
28
+ * fan-out, a REST route's synchronous construction). The two earlier forms it
29
+ * did NOT absorb — `truncateErrorMessage` (the diagnostics digest) and
30
+ * `bounded()` (the frame-drop path) — are left alone: each emits a different
31
+ * marker (`…[truncated]`, a bare `…`) that callers and docs depend on, so
32
+ * folding would change output rather than converge on the rule.
33
+ */
34
+
35
+ /**
36
+ * Clips `text` into `max` characters, the `… (+N chars)` marker included in
37
+ * the count. `kept` is a fixed point — the number of removed characters sets
38
+ * the marker's own digits — and it is the LARGEST prefix that fits, so a
39
+ * caller's ceiling is used without ever being exceeded.
40
+ *
41
+ * The final guard never ends the kept prefix between a UTF-16 surrogate pair: a
42
+ * clip there would emit a lone surrogate, which is not valid unicode text (the
43
+ * peer's `unicodeText` params face refuses it) and would travel in a
44
+ * user-visible verdict. Dropping one char pays for a marker that can grow by
45
+ * one digit at a 9→10 boundary, and the marker is re-derived from the new
46
+ * dropped count. Pinned by `tests/unit/text.test.ts` — the branch used to be a
47
+ * declared coverage gap while the clip point was private to pairing.
48
+ */
49
+ export function clipText(text: string, max: number): string {
50
+ if (text.length <= max) return text
51
+ let kept = max
52
+ for (;;) {
53
+ const next = max - `… (+${text.length - kept} chars)`.length
54
+ if (next === kept) break
55
+ // A room too small for the marker cannot carry both; admit the loss with
56
+ // the bare ellipsis rather than overrun the caller's bound.
57
+ if (next < 0) return '…'.slice(0, max)
58
+ kept = next
59
+ }
60
+ const before = text.charCodeAt(kept - 1)
61
+ const after = text.charCodeAt(kept)
62
+ if (kept > 0 && before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff) {
63
+ kept -= 1
64
+ }
65
+ return `${text.slice(0, kept)}… (+${text.length - kept} chars)`
66
+ }
67
+
68
+ /**
69
+ * Renders `items` as a comma-joined list under `limits.max`, each item clipped
70
+ * to `limits.itemMax` FIRST. When anything is left out the list says so:
71
+ * ` (+K more)` where K is the number of items NOT named.
72
+ *
73
+ * Caller contract: `itemMax <= max`, so the first item always fits and a list
74
+ * carrying a single over-long entry never renders an empty one. The marker
75
+ * rides ON TOP of `limits.max` — it is a count, not an item, so the named text
76
+ * never has to absorb its length. `[]` renders as `''`.
77
+ *
78
+ * Pairing's progress line (`stageHosts`) deliberately does NOT use this helper:
79
+ * it always shows the FIRST host in full even above its own budget, because
80
+ * that host is the run's identity. That is not an oversight, and it is not a
81
+ * reason to weaken the contract here.
82
+ */
83
+ export function boundedJoin(
84
+ items: readonly string[],
85
+ limits: { max: number; itemMax: number },
86
+ ): string {
87
+ const rendered = items.map((item) => clipText(item, limits.itemMax))
88
+ const first = rendered[0]
89
+ if (first === undefined) return ''
90
+ let text = first
91
+ let shown = 1
92
+ for (; shown < rendered.length; shown += 1) {
93
+ // The separator is charged to the same budget as the item: a list that
94
+ // stops two characters early on paper would still refuse an entry it could
95
+ // have named.
96
+ if (text.length + 2 + rendered[shown]!.length > limits.max) break
97
+ text += `, ${rendered[shown]}`
98
+ }
99
+ const omitted = items.length - shown
100
+ return omitted > 0 ? `${text} (+${omitted} more)` : text
101
+ }
102
+
103
+ /**
104
+ * The ceiling for ONE peer- or payload-controlled detail interpolated into a
105
+ * log line: 240 characters, the `… (+N chars)` marker included.
106
+ *
107
+ * It is deliberately NOT the same budget as pairing's `FAILURE_SEGMENT_MAX`,
108
+ * although both read 240: that one bounds the WHOLE `<label>: <detail>`, so
109
+ * the label eats from it and the detail there is at most 218 (a
110
+ * `pairing hello failed` label — 20 characters — plus its `': '`) and often
111
+ * less. Same number, different quantity: this one is the detail alone.
112
+ *
113
+ * Call sites. Two shapes are rendered with it:
114
+ *
115
+ * - peer-controlled DETAIL through {@link clipText}: `src/connection.ts`'s
116
+ * protocol-version console.error (the cause is the peer's own hello-echo
117
+ * text) and transient-failure console.warn (the transport's own message).
118
+ * - a caught FAILURE through {@link failureText} (issue #161), on the catches
119
+ * peer traffic reaches: `src/index.ts`'s notification routing and
120
+ * subscription recovery, `src/tools.ts`'s ack and both background re-ensure
121
+ * arms plus its `#assemblyFailed` log line, `src/subscriptions.ts`'s
122
+ * subscription-close catch, `src/httpApi.ts`'s `warnConnect` rejection, and
123
+ * the JSON-RPC notification-handler catch in `src/rpc/jsonRpcClient.ts`
124
+ * (#155). None of them passes the error object.
125
+ *
126
+ * Other bounded faces state their own value beside the line they bound
127
+ * (`UNKNOWN_FIELDS_MAX` / `RECOVERY_SESSIONS_MAX`), because what they bound is
128
+ * a rendered LIST or a joined field, not one detail.
129
+ */
130
+ export const LOG_TEXT_MAX = 240
131
+
132
+ /**
133
+ * Renders ONE caught failure for a log line: the message alone, clipped to
134
+ * {@link LOG_TEXT_MAX}.
135
+ *
136
+ * This is the ONE form a caught failure takes in a PEER-TRIGGERED catch (issue
137
+ * #161): a catch reached from peer traffic or a peer-triggered callback. The
138
+ * old shape — `console.warn(prefix, error)` — handed the console the OBJECT.
139
+ * On Node the console prints the error's name, message and stack (own
140
+ * properties only when inspected), so the peer-controlled message and the
141
+ * stack were both unbounded; for a defensive catch the stack is the noise.
142
+ *
143
+ * A caught failure from a purely LOCAL path (an fs copy, the host registry, our
144
+ * own observers) keeps the object print: its stack is the useful part and no
145
+ * peer text can reach it.
146
+ *
147
+ * TOTAL by construction: it runs INSIDE catch blocks, so a captured value whose
148
+ * `toString` (or an Error subclass whose `message` getter) throws must not
149
+ * escape the catch. The extraction is guarded and falls back to the fixed token
150
+ * `<unrenderable failure>`.
151
+ */
152
+ export function failureText(error: unknown): string {
153
+ try {
154
+ return clipText(error instanceof Error ? error.message : String(error), LOG_TEXT_MAX)
155
+ } catch {
156
+ return '<unrenderable failure>'
157
+ }
158
+ }