dsh-zen-remote 2.2.0 → 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.
- package/README.md +19 -5
- package/README.zh-CN.md +19 -5
- package/docs/interface.md +1 -1
- package/docs/plugin-bridge.md +1 -1
- package/docs/remote-access.en.md +13 -1
- package/docs/remote-access.md +14 -2
- package/lib/activity.js +25 -0
- package/lib/activity.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/index.js +25 -13
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/methods.js +79 -33
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
- package/lib/index.js +27 -1
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +21 -1
- package/lib/intercept.js.map +1 -1
- package/lib/invoke-binary.js +126 -0
- package/lib/invoke-binary.js.map +1 -0
- package/lib/relay-server.js +8 -1
- package/lib/relay-server.js.map +1 -1
- package/lib/types/activity.d.ts +22 -0
- package/lib/types/activity.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +16 -11
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts +23 -7
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +2 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/invoke-binary.d.ts +41 -0
- package/lib/types/invoke-binary.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts.map +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/activity.ts +32 -0
- package/src/adapters/dsh-better-sidebar/index.ts +25 -13
- package/src/adapters/dsh-better-sidebar/methods.ts +81 -29
- package/src/index.ts +27 -1
- package/src/intercept.ts +22 -2
- package/src/invoke-binary.ts +134 -0
- package/src/relay-server.ts +9 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-server.d.ts","sourceRoot":"","sources":["../../src/relay-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAOH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAWlD,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAE3E,OAAO,EAA0C,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACpG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"relay-server.d.ts","sourceRoot":"","sources":["../../src/relay-server.ts"],"names":[],"mappings":"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"}
|
package/llms.txt
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
- Trust model: a paired desktop client is a TRUSTED DEVICE — server-user terminal without sandbox/approval gates, file previews not contained to the session dir, session creation in any workspace. "Shared only" constrains session data (lists, history, events are refused/filtered server-side; @ candidates list only shared sessions; references in messages, queue edits and slash commands naming an unshared session refuse the call), not the machine.
|
|
17
17
|
- Env: `LAN_GATE_PORT` (3088), `LAN_GATE_HOST`, `LAN_GATE_TARGET_PORT` (auto-discovered), `LAN_GATE_RATE_LIMIT` (120), `LAN_GATE_TRUSTED_PROXIES`, `LAN_GATE_VAPID_SUBJECT`, `LAN_GATE_LANG`, `LAN_GATE_RELAY_SECRET` (auto), `DSH_PUSH_*`, `DSH_PUSH_APPROVAL_GRACE_MS`. Same keys (lowerCamelCase) in `lan-gate.config.json`.
|
|
18
18
|
- Security: 8-char one-time role-bound pairing code (10 min TTL), 5-wrong-code lockout, revocable per-device tokens (kills connections + push subscription), 120 req/min per client IP for unpaired requests.
|
|
19
|
-
- Known limits: remote sessions disable export (menu item hidden, backend 403 for virtual ids); changes summary/diff work remotely but the "open on the server machine" entries (open / reveal in Finder / open in app) are hidden there; with dsh-better-sidebar installed,
|
|
19
|
+
- Known limits: remote sessions disable export (menu item hidden, backend 403 for virtual ids); changes summary/diff work remotely but the "open on the server machine" entries (open / reveal in Finder / open in app) are hidden there; with dsh-better-sidebar installed, UNBRIDGED versions have their file surfaces (file browser, editor, git, tasks, bottom workbench, floating windows) hidden in remote sessions because they act on the CLIENT machine's own files — 0.25.0 and 0.26.0 are bridged by the built-in adapter and keep working against the server, so only an UNBRIDGED remote session has no sidebar file browser (it takes over the host's "Files" tab) and unbridged file links in chat or deliverables open an empty right column; @ candidates are truncated to 50 by the host BEFORE share filtering (a shared session can be missing from the list on a session-heavy server); third-party plugins' non-standard endpoints are not forwarded — their tools run on the server and are unaffected, what breaks is UI calling the plugin's own backend with a session id (better-sidebar, handled as above); both ends need the same zen-remote version; server switch-back or workspace-id reuse needs a page reload; several DSH windows at once (including the desktop backend opened in a browser) can misjudge which session is currently open — closed-remote hiding can fire at the wrong moment and worst case dismiss an open session to the home page. Dev only (see AGENTS.md hard constraints): same-machine testing must reach the gateway over a LAN IP (loopback = local-direct, skips token checks) — normal two-machine use is unaffected.
|
|
20
20
|
|
|
21
21
|
## Docs
|
|
22
22
|
- README.md (English, install + config + migration + security model), README.zh-CN.md (same in Chinese), AGENTS.md (repo guide), LICENSE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-zen-remote",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
4
4
|
"description": "一个插件、两种角色:主服务端把 DeepSeek Harness 变成手机能安全访问的 PWA(移动端界面、配对码网关、Web Push),并把选定会话共享给已配对的桌面端;子客户端在另一台 DSH 桌面端的侧边栏里远程使用这些会话。One DSH plugin, two roles: the host turns DeepSeek Harness into a phone-reachable PWA (mobile UI, pairing-code gateway behind your own reverse proxy, Web Push) and shares chosen sessions to paired desktops; the client uses those sessions remotely from another DSH desktop app's sidebar.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/activity.ts
CHANGED
|
@@ -160,6 +160,38 @@ export function createParentIndex(): ParentIndex {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/** One persisted-session snapshot as `SessionPersistence.list()` hands it
|
|
164
|
+
* back: the detached header is all the parent index needs (`id`,
|
|
165
|
+
* `origin: 'subagent'`, `parentSession` — dsh-session's SessionHeader).
|
|
166
|
+
* Structural on purpose: the service's full snapshot carries revision and
|
|
167
|
+
* sizes we do not care about. */
|
|
168
|
+
export interface PersistedSessionSnapshot {
|
|
169
|
+
header?: { id?: unknown; origin?: unknown; parentSession?: unknown } | undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Seed a parent index from persisted-session snapshots (2026-10-13 fix):
|
|
174
|
+
* the index otherwise rebuilds only from LIVE `session/event` traffic, so
|
|
175
|
+
* after a host restart every already-stored sidechat / subagent child lost
|
|
176
|
+
* its parent link and the relay answered `not-shared` for sessions whose
|
|
177
|
+
* share it used to inherit. Feeding `SessionPersistence.list()`'s snapshots
|
|
178
|
+
* through `observe` restores the chain; it reads headers only, never event
|
|
179
|
+
* logs. Returns how many parent links the pass recorded.
|
|
180
|
+
*/
|
|
181
|
+
export function seedParentIndexFromSnapshots(index: ParentIndex, snapshots: readonly PersistedSessionSnapshot[]): number {
|
|
182
|
+
let recorded = 0
|
|
183
|
+
for (const snapshot of snapshots) {
|
|
184
|
+
const header = snapshot?.header
|
|
185
|
+
if (header?.origin !== 'subagent') continue
|
|
186
|
+
const id = header.id
|
|
187
|
+
if (typeof id !== 'string') continue
|
|
188
|
+
const isNew = index.parentOf(id) === undefined
|
|
189
|
+
index.observe({ id, header })
|
|
190
|
+
if (isNew && index.parentOf(id) !== undefined) recorded += 1
|
|
191
|
+
}
|
|
192
|
+
return recorded
|
|
193
|
+
}
|
|
194
|
+
|
|
163
195
|
/** Duck-typed timer halves: Node's setInterval/clearInterval at runtime,
|
|
164
196
|
* fakes in tests. Loose on purpose — the real NodeJS.Timeout type would
|
|
165
197
|
* fight the contravariant `clearInterval` parameter under strictFunctionTypes. */
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The dsh-better-sidebar adapter (PB-12), written against EXACTLY
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 (
|
|
20
|
-
* 6075-6099
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
23
|
-
*
|
|
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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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);子代理与工作流面板带
|
|
@@ -25,6 +34,13 @@
|
|
|
25
34
|
* (lib/index.js:3479-3485)——都不带会话 id。这四类不改写。
|
|
26
35
|
* `sidechat.start` 返回 `{childId}`(lib/index.js:5050)——PB-13 起
|
|
27
36
|
* 虚拟化(子客户端拿它发后续调用)。
|
|
37
|
+
*
|
|
38
|
+
* 信封:`/sidebar/api` 的成功响应**全部**是 `{ok:true, value:<handler
|
|
39
|
+
* 返回值>}`(lib/index.js:889-896 的 `writeOk`),上面引用的行号都是
|
|
40
|
+
* handler 层、不是线上形状——响应改写器一律经 `rewriteEnveloped` 先剥
|
|
41
|
+
* 信封再改写再包回去(2026-10-13 修复:sidechat.start 的 childId 曾在
|
|
42
|
+
* 顶层找不到而漏虚拟化,浏览器拿真实 id 发后续调用被判本地、落到子
|
|
43
|
+
* 客户端本机报 session not found)。
|
|
28
44
|
*/
|
|
29
45
|
|
|
30
46
|
/** 响应改写器的上下文(框架的 AdapterResponseContext 的结构性子集)。 */
|
|
@@ -33,6 +49,21 @@ export interface SidebarRewriteContext {
|
|
|
33
49
|
isVirtual(value: unknown): boolean
|
|
34
50
|
}
|
|
35
51
|
|
|
52
|
+
/** 改写信封内的 value(信封外层的 ok 等字段原样保留)。 */
|
|
53
|
+
type SidebarInnerRewrite = (value: unknown, ctx: SidebarRewriteContext) => unknown
|
|
54
|
+
|
|
55
|
+
/** 剥 `{ok:true, value}` 信封 → 改写 value → 包回去;无信封原样改写。
|
|
56
|
+
* `writeOk`(lib/index.js:889)给每个 /sidebar/api 成功响应都包了这层。 */
|
|
57
|
+
function rewriteEnveloped(body: unknown, inner: SidebarInnerRewrite, ctx: SidebarRewriteContext): unknown {
|
|
58
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
59
|
+
const record = body as Record<string, unknown>
|
|
60
|
+
if (record.ok === true && record.value !== null && typeof record.value === 'object' && !Array.isArray(record.value)) {
|
|
61
|
+
return { ...record, value: inner(record.value, ctx) }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return inner(body, ctx)
|
|
65
|
+
}
|
|
66
|
+
|
|
36
67
|
/** 一个 `/sidebar/api` 方法的处置。 */
|
|
37
68
|
export interface SidebarMethodSpec {
|
|
38
69
|
/** 该方法在 JSON 体顶层的会话字段名(settings.* 无会话字段,缺省)。 */
|
|
@@ -48,7 +79,7 @@ export interface SidebarMethodSpec {
|
|
|
48
79
|
/** 无改写的普通转发方法的简写工厂。 */
|
|
49
80
|
const forward = (kind: 'read' | 'write', sessionField: 'sessionId' | 'rootSessionId' | 'childId' = 'sessionId'): SidebarMethodSpec => ({ kind, sessionField })
|
|
50
81
|
|
|
51
|
-
/** 0.25.0
|
|
82
|
+
/** 两版(0.25.0 + 0.26.0)方法名的并集,各条目的处置见上。 */
|
|
52
83
|
export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
|
|
53
84
|
// ---- 会话(lib/index.js:6319-6333)----
|
|
54
85
|
'session.phase': forward('read'),
|
|
@@ -56,7 +87,7 @@ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
|
|
|
56
87
|
kind: 'read',
|
|
57
88
|
sessionField: 'sessionId',
|
|
58
89
|
// 请求的 sessionId 原样回显(lib/index.js:6327)。
|
|
59
|
-
rewrite: (body, ctx) =>
|
|
90
|
+
rewrite: (body, ctx) => rewriteEnveloped(body, (v) => rewriteTopLevel(v, 'sessionId', ctx), ctx),
|
|
60
91
|
},
|
|
61
92
|
|
|
62
93
|
// ---- 文件(lib/index.js:6334-6442)----
|
|
@@ -64,6 +95,11 @@ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
|
|
|
64
95
|
'fs.trees': forward('read'),
|
|
65
96
|
'fs.search': forward('read'),
|
|
66
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 的客户端不再派发它。
|
|
67
103
|
'fs.write': forward('write'),
|
|
68
104
|
'fs.rename': forward('write'),
|
|
69
105
|
'fs.mkdir': forward('write'),
|
|
@@ -104,30 +140,13 @@ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
|
|
|
104
140
|
kind: 'read',
|
|
105
141
|
sessionField: 'rootSessionId',
|
|
106
142
|
// 响应的 live 映射以会话 id 为键(lib/index.js:4513)。
|
|
107
|
-
rewrite: (body, ctx) =>
|
|
108
|
-
if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
|
|
109
|
-
const live = (body as Record<string, unknown>).live
|
|
110
|
-
if (live === null || typeof live !== 'object' || Array.isArray(live)) return body
|
|
111
|
-
const rewritten: Record<string, unknown> = {}
|
|
112
|
-
for (const [id, view] of Object.entries(live as Record<string, unknown>)) {
|
|
113
|
-
rewritten[ctx.isVirtual(id) ? id : ctx.virtualize(id)] = view
|
|
114
|
-
}
|
|
115
|
-
return { ...(body as Record<string, unknown>), live: rewritten }
|
|
116
|
-
},
|
|
143
|
+
rewrite: (body, ctx) => rewriteEnveloped(body, rewriteLiveKeys, ctx),
|
|
117
144
|
},
|
|
118
145
|
'workflows.list': {
|
|
119
146
|
kind: 'read',
|
|
120
147
|
sessionField: 'rootSessionId',
|
|
121
148
|
// 每行的 originSessionId(4666)与 members[].childId(4686)。
|
|
122
|
-
rewrite: (body, ctx) =>
|
|
123
|
-
if (body === null || typeof body !== 'object' || Array.isArray(body)) return body
|
|
124
|
-
const runs = (body as Record<string, unknown>).runs
|
|
125
|
-
if (!Array.isArray(runs)) return body
|
|
126
|
-
return {
|
|
127
|
-
...(body as Record<string, unknown>),
|
|
128
|
-
runs: runs.map((run) => rewriteWorkflowRun(run, ctx)),
|
|
129
|
-
}
|
|
130
|
-
},
|
|
149
|
+
rewrite: (body, ctx) => rewriteEnveloped(body, rewriteWorkflowsBody, ctx),
|
|
131
150
|
},
|
|
132
151
|
// 响应是 {ok, value:<任务记录>},不带会话 id —— 不改写。
|
|
133
152
|
'teams.taskCreate': forward('write', 'rootSessionId'),
|
|
@@ -157,14 +176,24 @@ export const SIDEBAR_METHODS: Readonly<Record<string, SidebarMethodSpec>> = {
|
|
|
157
176
|
'sidechat.start': {
|
|
158
177
|
kind: 'write',
|
|
159
178
|
sessionField: 'sessionId',
|
|
160
|
-
// 响应 `{ childId }`(lib/index.js:5050
|
|
161
|
-
|
|
179
|
+
// 响应 `{ childId }`(lib/index.js:5050,外面还有 writeOk 信封)——
|
|
180
|
+
// 服务端真实 id 换虚拟。
|
|
181
|
+
rewrite: (body, ctx) => rewriteEnveloped(body, (v) => rewriteTopLevel(v, 'childId', ctx), ctx),
|
|
162
182
|
},
|
|
163
183
|
'sidechat.prompt': forward('write', 'childId'),
|
|
164
184
|
'sidechat.cancel': forward('write', 'childId'),
|
|
165
185
|
'sidechat.dispose': forward('write', 'childId'),
|
|
166
186
|
'sidechat.info': forward('read', 'childId'),
|
|
167
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'),
|
|
168
197
|
}
|
|
169
198
|
|
|
170
199
|
/** `/sidebar/api` 的路径前缀(方法名跟在后面,lib/index.js:6888)。 */
|
|
@@ -187,6 +216,29 @@ function rewriteTopLevel(body: unknown, field: string, ctx: SidebarRewriteContex
|
|
|
187
216
|
return { ...record, [field]: ctx.virtualize(value) }
|
|
188
217
|
}
|
|
189
218
|
|
|
219
|
+
/** subagents.live 的 value(信封内):live 映射的键是会话 id。 */
|
|
220
|
+
function rewriteLiveKeys(value: unknown, ctx: SidebarRewriteContext): unknown {
|
|
221
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value
|
|
222
|
+
const live = (value as Record<string, unknown>).live
|
|
223
|
+
if (live === null || typeof live !== 'object' || Array.isArray(live)) return value
|
|
224
|
+
const rewritten: Record<string, unknown> = {}
|
|
225
|
+
for (const [id, view] of Object.entries(live as Record<string, unknown>)) {
|
|
226
|
+
rewritten[ctx.isVirtual(id) ? id : ctx.virtualize(id)] = view
|
|
227
|
+
}
|
|
228
|
+
return { ...(value as Record<string, unknown>), live: rewritten }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** workflows.list 的 value(信封内):runs[].originSessionId 等。 */
|
|
232
|
+
function rewriteWorkflowsBody(value: unknown, ctx: SidebarRewriteContext): unknown {
|
|
233
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value
|
|
234
|
+
const runs = (value as Record<string, unknown>).runs
|
|
235
|
+
if (!Array.isArray(runs)) return value
|
|
236
|
+
return {
|
|
237
|
+
...(value as Record<string, unknown>),
|
|
238
|
+
runs: runs.map((run) => rewriteWorkflowRun(run, ctx)),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
190
242
|
/** 一行工作流运行的改写(originSessionId + phases[].members[].childId)。 */
|
|
191
243
|
function rewriteWorkflowRun(run: unknown, ctx: SidebarRewriteContext): unknown {
|
|
192
244
|
if (run === null || typeof run !== 'object' || Array.isArray(run)) return run
|
package/src/index.ts
CHANGED
|
@@ -38,7 +38,7 @@ declare module '@deepseek-ai/cordis' {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
import { readFileConfig, resolveConfig, unwrapVolatile } from './config.js'
|
|
41
|
-
import { createActivityTracker, createParentIndex, startSweeper } from './activity.js'
|
|
41
|
+
import { createActivityTracker, createParentIndex, seedParentIndexFromSnapshots, startSweeper, type PersistedSessionSnapshot } from './activity.js'
|
|
42
42
|
import { ADMIN_ROUTE_PREFIX, createAdminHandler } from './admin-routes.js'
|
|
43
43
|
import type { SessionQueryLike } from './admin-routes.js'
|
|
44
44
|
import { normalizedRowServerUrl } from './client-pairing.js'
|
|
@@ -657,6 +657,32 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
657
657
|
const agents = (agentsCtx as Context & { agents: { list(): readonly AgentStatusLike[] } }).agents
|
|
658
658
|
restoreBusy(store, () => agents.list())
|
|
659
659
|
})
|
|
660
|
+
// Restart repair (2026-10-13): the parent index rebuilds only from LIVE
|
|
661
|
+
// session/event traffic, so after a host restart every already-stored
|
|
662
|
+
// sidechat / subagent child lost its parent link and the relay answered
|
|
663
|
+
// `not-shared` for a session whose share it used to inherit through the
|
|
664
|
+
// ancestor chain. Seed the index once from the persistence service's
|
|
665
|
+
// snapshot list — headers only, event logs are never read.
|
|
666
|
+
try {
|
|
667
|
+
ctx.inject(['sessionPersistence'], (persistenceCtx) => {
|
|
668
|
+
const persistence = (persistenceCtx as Context & {
|
|
669
|
+
sessionPersistence?: { list(): Promise<readonly PersistedSessionSnapshot[]> } | undefined
|
|
670
|
+
}).sessionPersistence
|
|
671
|
+
if (persistence === undefined) {
|
|
672
|
+
ctx.logger.warn('dsh-zen-remote: no sessionPersistence service — pre-restart subagent sessions stay not-shared until they emit an event')
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
void persistence
|
|
676
|
+
.list()
|
|
677
|
+
.then((snapshots) => {
|
|
678
|
+
const recorded = seedParentIndexFromSnapshots(parentIndex, snapshots)
|
|
679
|
+
if (recorded > 0) ctx.logger.info?.('dsh-zen-remote: restored %d persisted parent links', recorded)
|
|
680
|
+
})
|
|
681
|
+
.catch((error: unknown) => ctx.logger.warn('dsh-zen-remote cannot seed parent links from persisted sessions: %s', message(error)))
|
|
682
|
+
})
|
|
683
|
+
} catch (error) {
|
|
684
|
+
ctx.logger.warn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error))
|
|
685
|
+
}
|
|
660
686
|
// Settings-surface admin routes (T14). The browser cannot call the
|
|
661
687
|
// gateway's loopback-only admin API from DSH's origin, so the host
|
|
662
688
|
// process re-exposes it same-origin and the handler calls the gateway AS
|
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
|
-
|
|
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
|
+
}
|
package/src/relay-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|