dsh-zen-remote 2.2.1 → 2.2.2

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.
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The dsh-better-sidebar adapter (PB-12), written against EXACTLY
3
- * version 0.25.0. Everything it knows about the plugin came off
4
- * `/tmp/bs-versions/0.25.0/lib/index.js` (the installed copy at
5
- * `~/.dsh/profiles/desktop/node_modules/dsh-better-sidebar` is the same
6
- * build); the method table lives in `methods.ts` beside this file, and the
7
- * test suite re-extracts the plugin's real method-name set to catch a
3
+ * versions 0.25.0 and 0.26.0 (PB-15 widened the list; 0.26.0 is a JSON-api
4
+ * superset of 0.25.0 — it grows fs.readPage and the sidechat dequeue/model/
5
+ * models methods, and moves fs.write's content onto the new raw-text
6
+ * /sidebar/write route). Everything the adapter knows about the plugin came
7
+ * off `/tmp/bs-versions/{0.25.0,0.26.0}/lib/index.js`; the method table
8
+ * lives in `methods.ts` beside this file, and the test suite re-extracts
9
+ * the plugin's real method-name set (the two-version UNION) to catch a
8
10
  * silently drifted version.
9
11
  *
10
12
  * Product decisions (2026-10-10): reads AND writes both forward — a
@@ -12,15 +14,18 @@
12
14
  * model spend (commit-message generation), documented as such; NO path
13
15
  * fencing; `open.external` refuses for a remote session; `settings.*` and
14
16
  * `/sidebar/bundle` stay local; the adapter registers as whole-machine
15
- * data (consent per plugin, default off).
17
+ * data (consent per plugin, default off). PB-15: the 0.26.0 WebSocket
18
+ * `/sidebar/ws/sidechat` is deliberately NOT declared (not bridged).
16
19
  *
17
20
  * The `cwd` handling is the one subtle part: better-sidebar resolves a
18
21
  * request's working directory as the session's own `header.cwd` first and
19
- * only falls back to a `cwd` field the CLIENT supplied (lib/index.js:6295,
20
- * 6075-6099). A remote panel must not steer the server by path, so the
21
- * server hook strips `cwd` — from the JSON body (`/sidebar/api`) and from
22
- * the query (`/sidebar/file`, `/sidebar/upload`) — and lets the backend
23
- * resolve from the (validated, real) session id.
22
+ * only falls back to a `cwd` field the CLIENT supplied (0.25.0
23
+ * lib/index.js:6295, 6075-6099; 0.26.0 resolves /sidebar/write's cwd the
24
+ * same way, lib/index.js:7902). A remote panel must not steer the server
25
+ * by path, so the server hook strips `cwd` — from the JSON body
26
+ * (`/sidebar/api`) and from the query (`/sidebar/file`, `/sidebar/upload`,
27
+ * `/sidebar/write`) — and lets the backend resolve from the (validated,
28
+ * real) session id.
24
29
  */
25
30
 
26
31
  import type { BridgeAdapter } from '../types.js'
@@ -44,7 +49,7 @@ const apiMethodOf = (pathname: string): string => sidebarMethodNameOf(pathname)
44
49
 
45
50
  export const dshBetterSidebarAdapter: BridgeAdapter = {
46
51
  plugin: 'dsh-better-sidebar',
47
- versions: ['0.25.0'],
52
+ versions: ['0.25.0', '0.26.0'],
48
53
  routes: [
49
54
  {
50
55
  // The JSON API: the method name rides the path tail, the session
@@ -82,6 +87,13 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
82
87
  // The raw upload (lib/index.js:6905-6948): binary bytes, capped by the
83
88
  // relay's own upload budget.
84
89
  { path: '/sidebar/upload', match: 'exact', methods: ['POST'], maxBody: RELAY_UPLOAD_CAP_BYTES, locator: { in: 'query', name: 'sessionId' } },
90
+ // The raw text write (0.26.0, lib/index.js:7875-7919): the document IS
91
+ // the POST body (text/plain — no JSON escaping layer), the session id,
92
+ // path and a client cwd ride the QUERY. The plugin caps the body at
93
+ // WRITE_BODY_GUARD_BYTES = 8 MiB (lib/index.js:6854); the adapter takes
94
+ // 10 MiB — headroom over the plugin's own guard, under the relay's
95
+ // 100 MiB upload budget.
96
+ { path: '/sidebar/write', match: 'exact', methods: ['POST'], maxBody: 10 * 1024 * 1024, locator: { in: 'query', name: 'sessionId' } },
85
97
  // The HTML preview (lib/index.js:7026+): the session id is the FIRST
86
98
  // path segment after the prefix; the rest of the segments are the
87
99
  // preview file's relative path. Same-prefix relative resources ride
@@ -125,7 +137,7 @@ export const dshBetterSidebarAdapter: BridgeAdapter = {
125
137
  // directory from the (already validated) session id's own header,
126
138
  // falling back to the persisted record — a remote panel never
127
139
  // steers the server by path.
128
- if (ctx.pathname === '/sidebar/file' || ctx.pathname === '/sidebar/upload') {
140
+ if (ctx.pathname === '/sidebar/file' || ctx.pathname === '/sidebar/upload' || ctx.pathname === '/sidebar/write') {
129
141
  if (ctx.query.get('cwd') !== null) {
130
142
  const stripped = new URLSearchParams(ctx.query)
131
143
  stripped.delete('cwd')
@@ -1,10 +1,19 @@
1
1
  /**
2
- * better-sidebar 0.25.0 的 `/sidebar/api` 方法表(PB-12)——适配器唯一
3
- * 认识的接口清单,逐条对着 `/tmp/bs-versions/0.25.0/lib/index.js` 复核
4
- * (行号见各条注释,以 0.25.0 的构建产物为准)。表里没有的方法在远程
5
- * 会话里一律拒绝(reason `adapter-method-unknown`):宁可少转发,不可
6
- * 误转发——`test/adapter-better-sidebar.test.cjs` 会把源码里的实际方法
7
- * 名集合提取出来与这张表逐一比对,防止插件升级后漏记。
2
+ * better-sidebar 0.25.0 + 0.26.0 的 `/sidebar/api` 方法表(PB-12/PB-15)
3
+ * ——适配器唯一认识的接口清单,逐条对着 `/tmp/bs-versions/{0.25.0,
4
+ * 0.26.0}/lib/index.js` 复核(行号见各条注释,未标版本的一律以 0.25.0
5
+ * 的构建产物为准;0.26.0 是 0.25.0 的 JSON-api 超集,除下述三处外形状
6
+ * 不变)。表里没有的方法在远程会话里一律拒绝(reason
7
+ * `adapter-method-unknown`):宁可少转发,不可误转发——
8
+ * `test/adapter-better-sidebar.test.cjs` 会把两版源码里的实际方法名集合
9
+ * 提取出来求并集,与这张表逐一比对,防止插件升级后漏记。
10
+ *
11
+ * 0.26.0 相对 0.25.0 的差异(本表如何覆盖两版):
12
+ * - 新增 `fs.readPage` 与 sidechat 的 `dequeue`/`model`/`models` 三个;
13
+ * - `fs.write` 从 JSON api 移除,改走新的 `/sidebar/write` HTTP 路由
14
+ * (内容作纯文本 POST 体,见 index.ts 的路由条目)。表里保留
15
+ * `fs.write`:0.25.0 的宿主仍从 JSON api 派发它;0.26.0 的客户端
16
+ * 不再调它,登记无害。
8
17
  *
9
18
  * 会话字段:绝大多数方法在 JSON 体顶层带 `sessionId`(client.js 的
10
19
  * scopePayload,lib/client.js:3866);子代理与工作流面板带
@@ -70,7 +79,7 @@ export interface SidebarMethodSpec {
70
79
  /** 无改写的普通转发方法的简写工厂。 */
71
80
  const forward = (kind: 'read' | 'write', sessionField: 'sessionId' | 'rootSessionId' | 'childId' = 'sessionId'): SidebarMethodSpec => ({ kind, sessionField })
72
81
 
73
- /** 0.25.0 的全部方法。 */
82
+ /** 两版(0.25.0 + 0.26.0)方法名的并集,各条目的处置见上。 */
74
83
  export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
75
84
  // ---- 会话(lib/index.js:6319-6333)----
76
85
  'session.phase': forward('read'),
@@ -86,6 +95,11 @@ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
86
95
  'fs.trees': forward('read'),
87
96
  'fs.search': forward('read'),
88
97
  'fs.read': forward('read'),
98
+ // 分页读(0.26.0 lib/index.js:7332-7338):payload 形状同 fs.read 加
99
+ // 顶层 `offset` 数字;响应是文件文本,不带会话 id —— 不改写。
100
+ 'fs.readPage': forward('read'),
101
+ // 0.26.0 起从 JSON api 移除(写内容改走 /sidebar/write 路由,见
102
+ // index.ts);保留给 0.25.0 的宿主——0.26.0 的客户端不再派发它。
89
103
  'fs.write': forward('write'),
90
104
  'fs.rename': forward('write'),
91
105
  'fs.mkdir': forward('write'),
@@ -171,6 +185,15 @@ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
171
185
  'sidechat.dispose': forward('write', 'childId'),
172
186
  'sidechat.info': forward('read', 'childId'),
173
187
  'sidechat.events': forward('read', 'childId'),
188
+ // 撤回排队消息(0.26.0 lib/index.js:6206-6226):childId 定位,可选
189
+ // `messageId`;响应 {accepted, removed} 不带会话 id —— 不改写。
190
+ 'sidechat.dequeue': forward('write', 'childId'),
191
+ // 模型目录(0.26.0 lib/index.js:6228):handler 不收 payload、无会话
192
+ // 字段 → local(settings.* 先例),登记只为「未知方法」比对。
193
+ 'sidechat.models': { kind: 'read', local: true },
194
+ // 给线程切模型(0.26.0 lib/index.js:6229-6245):childId 定位;响应
195
+ // {accepted, selection} 不带会话 id —— 不改写。
196
+ 'sidechat.model': forward('write', 'childId'),
174
197
  }
175
198
 
176
199
  /** `/sidebar/api` 的路径前缀(方法名跟在后面,lib/index.js:6888)。 */
package/src/intercept.ts CHANGED
@@ -84,6 +84,7 @@ import type { RelayClient, RelayClock, RelayState } from './relay-client.js'
84
84
  import { checkGatewayShape } from './intercept-shape.js'
85
85
  import type { GatewayShapeCheck } from './intercept-shape.js'
86
86
  import { fromVirtual, isVirtual, toVirtual } from './virtual-id.js'
87
+ import { INVOKE_BINARY_RESULT_METHODS, extractResultAttachments, restoreInvokeBinary, type ResultAttachment } from './invoke-binary.js'
87
88
  import {
88
89
  createControlMerger,
89
90
  createWorkspaceMerger,
@@ -240,7 +241,7 @@ export const CLIENT_METHOD_FIELDS: Readonly<Record<string, readonly SessionField
240
241
  * envelope WE construct carries `details: {}` (the host rpcFailure's
241
242
  * fallback). */
242
243
  export type DispatchEnvelope =
243
- | { ok: true; value: unknown }
244
+ | { ok: true; value: unknown; attachments?: ResultAttachment[] }
244
245
  | { ok: false; error: { code: string; message?: string; details: Record<string, unknown> } }
245
246
 
246
247
  /**
@@ -2699,7 +2700,26 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
2699
2700
  // The session answered: any closed-session entry for it is stale
2700
2701
  // (re-shared and served again) — the banner may go.
2701
2702
  clearClosed(virtuals)
2702
- return { ok: true, value: rewriteResult(endpoint, value, serverId) }
2703
+ // 2026-10-14: a binary-result method's answer arrives marker-encoded
2704
+ // (invoke-binary.ts) — the relay's JSON channel cannot carry a typed
2705
+ // array, so the server marked it and the caller's own zod validation
2706
+ // needs the real Uint8Array back.
2707
+ const restored = INVOKE_BINARY_RESULT_METHODS.has(endpoint) ? restoreInvokeBinary(value) : value
2708
+ // 2026-10-14 (root cause, live-probed): dispatchRpc's envelope contract
2709
+ // carries Uint8Arrays in the ATTACHMENTS side channel (dsh-api-gateway
2710
+ // encodeRpcResult) — value positions become null and the renderer
2711
+ // re-attaches by path. A raw typed array in the value dies on the
2712
+ // background→browser bridge (JSON), which is exactly the remote image
2713
+ // preview's "expected Uint8Array" zod failure. Restore, then re-extract.
2714
+ if (INVOKE_BINARY_RESULT_METHODS.has(endpoint)) {
2715
+ const extracted = extractResultAttachments(restored)
2716
+ return {
2717
+ ok: true,
2718
+ value: rewriteResult(endpoint, extracted.value, serverId),
2719
+ ...(extracted.attachments.length > 0 ? { attachments: extracted.attachments } : {}),
2720
+ }
2721
+ }
2722
+ return { ok: true, value: rewriteResult(endpoint, restored, serverId) }
2703
2723
  } catch (error) {
2704
2724
  const code = error instanceof RelayError ? error.code : 'internal'
2705
2725
  recordFailure(endpoint, code)
@@ -0,0 +1,134 @@
1
+ /**
2
+ * invoke 二进制载荷的线上标记(2026-10-14 修复):`v1/invoke` 是纯 JSON
3
+ * 通道,而 `workspaceFiles/readBytes` 的结果带 `data: Uint8Array`——直接
4
+ * `JSON.stringify` 会把 typed array 打成普通对象,子客户端侧的 zod 校验
5
+ * (expected "Uint8Array")当场拒绝,远程会话的图片预览报「读取失败」。
6
+ *
7
+ * 两端共用同一份规则:服务端在应答前对已知二进制方法的结果做深度标记
8
+ * (typed array → `{ $zr-u8: <base64> }`),子客户端在 forwardInvoke 拿回
9
+ * 值后还原成真 `Uint8Array`。两端都按同一个 `INVOKE_BINARY_RESULT_METHODS`
10
+ * 常量开关——单一来源,不可能漂移(这是登记表同步硬约束之外的第三张
11
+ * 「小表」,但它只此一处定义)。
12
+ *
13
+ * 纯逻辑:不 import DSH、不碰 I/O。
14
+ */
15
+
16
+ /** 标记键。线上只会出现服务端写入的标记;客户端只认这个键。 */
17
+ export const BINARY_MARKER = '$zr-u8'
18
+
19
+ /** 结果里带二进制的方法(`namespace/method`)。请求侧参数不带二进制
20
+ * (readBytes 的参数是 scope/paths),故只有结果方向需要标记。 */
21
+ export const INVOKE_BINARY_RESULT_METHODS: ReadonlySet<string> = new Set(['workspaceFiles/readBytes'])
22
+
23
+ /** 深度上限:与其余 walker(share-store 祖先链、会话引用扫描)一致。 */
24
+ const MAX_DEPTH = 16
25
+
26
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
27
+ value !== null && typeof value === 'object' && !Array.isArray(value) && !isTypedArray(value)
28
+
29
+ const isTypedArray = (value: unknown): value is ArrayBufferView =>
30
+ ArrayBuffer.isView(value) === true && !(value instanceof DataView)
31
+
32
+ /** 深度遍历,把 typed array(含 Buffer)换成 `{ $zr-u8: base64 }` 标记。 */
33
+ export function markInvokeBinary(value: unknown, depth = 0): unknown {
34
+ if (depth >= MAX_DEPTH) return value
35
+ if (isTypedArray(value)) return { [BINARY_MARKER]: Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64') }
36
+ if (Array.isArray(value)) {
37
+ let changed = false
38
+ const out: unknown[] = []
39
+ for (const item of value) {
40
+ const marked = markInvokeBinary(item, depth + 1)
41
+ if (marked !== item) changed = true
42
+ out.push(marked)
43
+ }
44
+ return changed ? out : value
45
+ }
46
+ if (isPlainObject(value)) {
47
+ let changed = false
48
+ const out: Record<string, unknown> = {}
49
+ for (const [key, item] of Object.entries(value)) {
50
+ const marked = markInvokeBinary(item, depth + 1)
51
+ if (marked !== item) changed = true
52
+ out[key] = marked
53
+ }
54
+ return changed ? out : value
55
+ }
56
+ return value
57
+ }
58
+
59
+ /** 深度遍历,把 `{ $zr-u8: base64 }` 标记还原成 `Uint8Array`。
60
+ * 标记只会是本模块的 `markInvokeBinary` 写下的(键名即凭据),base64
61
+ * 恒有效;万一混入同键的怪值,`Buffer.from(..., 'base64')` 的宽容解码
62
+ * 也不会抛。非标记对象原样返回(同一引用,不惊动浅比较的调用方)。 */
63
+ export function restoreInvokeBinary(value: unknown, depth = 0): unknown {
64
+ if (depth >= MAX_DEPTH) return value
65
+ if (Array.isArray(value)) {
66
+ let changed = false
67
+ const out: unknown[] = value.map((item) => {
68
+ const restored = restoreInvokeBinary(item, depth + 1)
69
+ if (restored !== item) changed = true
70
+ return restored
71
+ })
72
+ return changed ? out : value
73
+ }
74
+ if (isPlainObject(value)) {
75
+ const marked = value[BINARY_MARKER]
76
+ // 空串也是合法标记(base64 空串 = 0 字节,mark 自己写下的);
77
+ // 只排除非字符串与带兄弟键的诱饵形状。
78
+ if (typeof marked === 'string' && Object.keys(value).length === 1) {
79
+ return new Uint8Array(Buffer.from(marked, 'base64'))
80
+ }
81
+ let changed = false
82
+ const out: Record<string, unknown> = {}
83
+ for (const [key, item] of Object.entries(value)) {
84
+ const restored = restoreInvokeBinary(item, depth + 1)
85
+ if (restored !== item) changed = true
86
+ out[key] = restored
87
+ }
88
+ return changed ? out : value
89
+ }
90
+ return value
91
+ }
92
+
93
+ /** dispatchRpc 信封的附件旁路形状(dsh-api-gateway 的 encodeRpcResult):
94
+ * Uint8Array 不进 JSON 值,抽到 `attachments: [{path, bytes}]`,原位置
95
+ * 置 null,渲染层按 path 回填。远程路径必须产出同样的契约,否则
96
+ * typed array 在后台→浏览器的桥上被 JSON 压扁(2026-10-14 实机定位)。 */
97
+ export interface ResultAttachment {
98
+ path: (string | number)[]
99
+ bytes: Uint8Array
100
+ }
101
+
102
+ /** 把(已还原的)结果值里的 Uint8Array 抽成附件;值内原位置置 null。
103
+ * 没有 typed array 时 value 原引用返回、attachments 为空数组。 */
104
+ export function extractResultAttachments(value: unknown): { value: unknown; attachments: ResultAttachment[] } {
105
+ const attachments: ResultAttachment[] = []
106
+ const walk = (input: unknown, path: (string | number)[], depth: number): unknown => {
107
+ if (depth >= MAX_DEPTH) return input
108
+ if (isTypedArray(input)) {
109
+ attachments.push({ path, bytes: input as Uint8Array })
110
+ return null
111
+ }
112
+ if (Array.isArray(input)) {
113
+ let changed = false
114
+ const out: unknown[] = input.map((item, index) => {
115
+ const extracted = walk(item, [...path, index], depth + 1)
116
+ if (extracted !== item) changed = true
117
+ return extracted
118
+ })
119
+ return changed ? out : input
120
+ }
121
+ if (isPlainObject(input)) {
122
+ let changed = false
123
+ const out: Record<string, unknown> = {}
124
+ for (const [key, item] of Object.entries(input)) {
125
+ const extracted = walk(item, [...path, key], depth + 1)
126
+ if (extracted !== item) changed = true
127
+ out[key] = extracted
128
+ }
129
+ return changed ? out : input
130
+ }
131
+ return input
132
+ }
133
+ return { value: walk(value, [], 0), attachments }
134
+ }
@@ -48,6 +48,7 @@ import { routeMethodsOf, routeMaxBodyOf } from './plugin-bridge.js'
48
48
  import { createPluginTunnel, validatePluginCall, type PluginTunnelRoutes } from './plugin-tunnel.js'
49
49
  import type { PluginLoopback } from './plugin-loopback.js'
50
50
  import { isLoopbackAuthError } from './plugin-loopback.js'
51
+ import { INVOKE_BINARY_RESULT_METHODS, markInvokeBinary } from './invoke-binary.js'
51
52
  import type { NormalizedAdapter, AdapterVerdict } from './plugin-adapter-core.js'
52
53
  import { matchAdapterRoute, locateAdapterIds, adapterOwnsRegistryEntry, AdapterMultiValueQueryError } from './plugin-adapter-core.js'
53
54
 
@@ -1363,7 +1364,14 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
1363
1364
  : decision.filter === 'model-catalog'
1364
1365
  ? filterModelCatalogResult(value)
1365
1366
  : value
1366
- responseJson(res, 200, { ok: true, value: travels })
1367
+ // 2026-10-14: a binary-result method (workspaceFiles/readBytes) cannot
1368
+ // ride JSON.stringify — typed arrays collapse into plain objects and
1369
+ // the sub-client's own zod validation rejects the answer (remote image
1370
+ // preview: "expected Uint8Array"). The marker round-trip
1371
+ // (invoke-binary.ts) is restored on the client half (intercept.ts).
1372
+ const wire =
1373
+ INVOKE_BINARY_RESULT_METHODS.has(`${namespace}/${method}`) ? markInvokeBinary(travels) : travels
1374
+ responseJson(res, 200, { ok: true, value: wire })
1367
1375
  } catch (error) {
1368
1376
  // The gateway's own failures (unknown namespace, absent service) are
1369
1377
  // business answers, not transport errors: they ride a 200 envelope