dsh-mobile-plugin 0.0.0-stage → 0.1.57

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.
Files changed (87) hide show
  1. package/README.md +128 -2
  2. package/config/agent-presets/standard/agent.cordis.yml +170 -0
  3. package/config/agent-presets/standard/preset.yml +3 -0
  4. package/cordis.mobile-host.yml +48 -0
  5. package/cordis.patch.yml +21 -0
  6. package/dsh-mobile-credentials/lib/credentials.js +135 -0
  7. package/dsh-mobile-credentials/package.json +13 -0
  8. package/host.js +128 -0
  9. package/lib/auto-archive.cjs +98 -0
  10. package/lib/client.js +690 -0
  11. package/lib/desktop-half-stats.cjs +101 -0
  12. package/lib/desktop-view.cjs +109 -0
  13. package/lib/host-guard.cjs +230 -0
  14. package/lib/host-patches.cjs +46 -0
  15. package/lib/http/package.json +3 -0
  16. package/lib/http/routing.js +84 -0
  17. package/lib/impl.js +406 -0
  18. package/lib/mirror/pusher.cjs +706 -0
  19. package/lib/mirror-snapshot.cjs +39 -0
  20. package/lib/mobile/agent-presets.js +182 -0
  21. package/lib/mobile/attachments.js +116 -0
  22. package/lib/mobile/auth.js +78 -0
  23. package/lib/mobile/completion.js +114 -0
  24. package/lib/mobile/credentials.js +150 -0
  25. package/lib/mobile/directory-browse.js +295 -0
  26. package/lib/mobile/envelope.js +51 -0
  27. package/lib/mobile/events.js +293 -0
  28. package/lib/mobile/history-derive.js +608 -0
  29. package/lib/mobile/host-bridge/endpoints.js +189 -0
  30. package/lib/mobile/host-bridge/event-synthesis.js +232 -0
  31. package/lib/mobile/host-bridge/shims.js +218 -0
  32. package/lib/mobile/host-bridge/transport.js +168 -0
  33. package/lib/mobile/host-bridge.js +71 -0
  34. package/lib/mobile/host-settings.js +282 -0
  35. package/lib/mobile/idempotency.js +78 -0
  36. package/lib/mobile/jobs.js +97 -0
  37. package/lib/mobile/local-images.js +153 -0
  38. package/lib/mobile/package.json +3 -0
  39. package/lib/mobile/plugins.js +149 -0
  40. package/lib/mobile/questions.js +71 -0
  41. package/lib/mobile/references.js +209 -0
  42. package/lib/mobile/rpc.js +76 -0
  43. package/lib/mobile/session-cache.js +263 -0
  44. package/lib/mobile/session-goal.js +44 -0
  45. package/lib/mobile/session-log.js +94 -0
  46. package/lib/mobile/session-mirror.js +177 -0
  47. package/lib/mobile/session-models.js +153 -0
  48. package/lib/mobile/session-presets.js +52 -0
  49. package/lib/mobile/session-search.js +65 -0
  50. package/lib/mobile/session-send.js +278 -0
  51. package/lib/mobile/session-snapshot.js +142 -0
  52. package/lib/mobile/sessions.js +462 -0
  53. package/lib/mobile/skills.js +80 -0
  54. package/lib/mobile/stream.js +268 -0
  55. package/lib/mobile/subagents.js +265 -0
  56. package/lib/mobile/workspaces.js +214 -0
  57. package/lib/panel/index.html +33 -0
  58. package/lib/read-receipt.cjs +124 -0
  59. package/lib/reconnect-scheduler.cjs +37 -0
  60. package/lib/relay/core.js +77 -0
  61. package/lib/relay/lan-network.cjs +85 -0
  62. package/lib/relay/lan-proxy.js +593 -0
  63. package/lib/relay/package.json +3 -0
  64. package/lib/relay/pairing-policy.js +45 -0
  65. package/lib/relay/port-allocator.cjs +60 -0
  66. package/lib/relay/preview.js +374 -0
  67. package/lib/relay/protocol.js +177 -0
  68. package/lib/relay/status.js +145 -0
  69. package/lib/relay/tunnel.js +721 -0
  70. package/lib/routes/control-plane.js +311 -0
  71. package/lib/routes/mobile-api.js +208 -0
  72. package/lib/routes/package.json +3 -0
  73. package/lib/session-cleanup.cjs +153 -0
  74. package/lib/shared/credentials-entry.js +48 -0
  75. package/lib/shared/dshtoken.js +62 -0
  76. package/lib/shared/fresh-request.js +35 -0
  77. package/lib/shared/http-util.js +71 -0
  78. package/lib/shared/list-revision.js +51 -0
  79. package/lib/shared/list-signal.js +87 -0
  80. package/lib/shared/package.json +3 -0
  81. package/lib/shared/paths.js +27 -0
  82. package/lib/shared/session-signal.js +110 -0
  83. package/lib/shared/workspace-guard.js +127 -0
  84. package/lib/sidecar-facade-worker.cjs +353 -0
  85. package/lib/sidecar-facade.cjs +141 -0
  86. package/lib/sidecar-lifecycle.cjs +246 -0
  87. package/package.json +74 -4
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 控制面路由(`/api/relay/*`):桌面端设置页与宿主本机运维动作走这里。
5
+ *
6
+ * 与移动端路由表(lib/routes/mobile-api.js)是刻意的两套:控制面只认本机来源
7
+ * (localOnly),移动端只认 token(guarded)。这条不对称是有原因的——控制面会读
8
+ * 进程级错误账本(含堆栈)并且能改配置、能吊销全部授权,暴露给中转等于把桌面端的
9
+ * 后门开到公网;反过来,移动端请求可能来自中转,根本没有本机 socket 可言。
10
+ *
11
+ * 抽出来的原因与移动端路由表一样:它们原本是 apply() 闭包里连着 130 多行的注册语句,
12
+ * 混在 sidecar 生命周期与镜像推送之间,改一条路由要在 1000 行文件里找。依赖以
13
+ * 取值器/函数注入,不捕获对象——闭包里那些 `let`(sidecarSnapshot、lanPortAdjusted、
14
+ * sidecarState…)会被整体替换或重新赋值。`getSidecar` 尤其重要:sidecar 会被自重启换掉,
15
+ * 拿到实例本身就等于拿到一份指向旧 facade 的快照。
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const QRCode = require('qrcode');
21
+ const { randomBytes } = require('node:crypto');
22
+
23
+ const core = require('../relay/core.js');
24
+ const pairingApproval = require('../relay/pairing-policy.js');
25
+ const desktopView = require('../desktop-view.cjs');
26
+ const readReceipt = require('../read-receipt.cjs');
27
+ // 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
28
+ // 「那半边根本没在跑」和「跑了但没清掉」(详见 lib/desktop-half-stats.cjs 的文件头)。
29
+ const desktopHalf = require('../desktop-half-stats.cjs');
30
+ const { clearCompletionForAllDevices, invalidateSessionList } = require('../mobile/session-cache.js');
31
+
32
+ /** 一次 desktop-read 上报最多带多少个会话 id(防灌爆;客户端半边一次最多也就几个)。 */
33
+ const MAX_DESKTOP_READ_IDS = 200;
34
+
35
+ /**
36
+ * 面板页(`React/` 那份 React 源码构建成的单文件 HTML)在包里的位置。
37
+ *
38
+ * 用 `__dirname` 而不是包根:控制面路由住在「实现代」里(`lib/.live/<releaseId>/…`),
39
+ * 所以这一份 HTML 跟着实现代一起换代 —— 改版式走 `/update/apply` 就生效,不用重装、
40
+ * 不用重启 DSH。这正是把设置界面从客户端半边(`lib/client.js`,不可热替换)搬过来的原因。
41
+ */
42
+ const PANEL_FILE = path.join(__dirname, '..', 'panel', 'index.html');
43
+
44
+ /** 按 mtime 缓存:换代之后路径变了、内容变了,缓存自然失效,不必手工清。 */
45
+ let panelCache = { file: '', mtime: -1, html: '' };
46
+
47
+ function panelHtml() {
48
+ try {
49
+ const stat = fs.statSync(PANEL_FILE);
50
+ if (panelCache.file === PANEL_FILE && panelCache.mtime === stat.mtimeMs) return panelCache.html;
51
+ const html = fs.readFileSync(PANEL_FILE, 'utf8');
52
+ panelCache = { file: PANEL_FILE, mtime: stat.mtimeMs, html };
53
+ return html;
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 面板页的绝对地址,给客户端半边那个「打开配置页面」的链接用。
61
+ *
62
+ * **必须是绝对 http(s) 地址**:桌面版把前端挂在 `dsh-app://app/` 下,页面里写相对
63
+ * 地址会被解析成那个自定义协议,而 Electron 的 `setWindowOpenHandler` 只把 http(s)
64
+ * 交给系统浏览器,其它协议一律静默丢弃(devmap 的面板入口踩过这个坑)。
65
+ * 拿不到 Host 头就回空串,让客户端半边退成「还没就绪」而不是给一个点不开的链接。
66
+ */
67
+ function panelUrlOf(req) {
68
+ const host = typeof req?.headers?.host === 'string' ? req.headers.host : '';
69
+ if (!host) return '';
70
+ const forwarded = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim();
71
+ return `${forwarded || 'http'}://${host}/api/relay/panel`;
72
+ }
73
+
74
+ /**
75
+ * @param {object} deps 见 index.js 里 registerControlPlaneRoutes({...}) 的调用点
76
+ */
77
+ function registerControlPlaneRoutes(deps) {
78
+ const {
79
+ registerRoute,
80
+ localOnly,
81
+ readJson,
82
+ sendJson,
83
+ sendHtml,
84
+ state,
85
+ sidecarToken,
86
+ status,
87
+ settings,
88
+ syncSidecar,
89
+ getSidecar,
90
+ credentials,
91
+ activeGuard,
92
+ resolveLanPort,
93
+ lanPortInUse,
94
+ getLanPortAdjusted,
95
+ pairingPayload,
96
+ restartSidecar,
97
+ // 插件 ctx。只给「桌面端说它读过了」那条路由用:它要清掉宿主侧的完成提醒。
98
+ // 缺省 null 时那条路由降级为「只回 200、什么都不清」。
99
+ ctx = null,
100
+ } = deps;
101
+
102
+ // Pairing approval. The desktop shell used to answer this with an interactive
103
+ // prompt; a headless host has no surface for one, so `pairingPolicy` decides.
104
+ // Both transports funnel through here — the relay forwards `pairRequest` and
105
+ // the LAN proxy calls the worker's onApprove — and the sidecar treats anything
106
+ // other than an explicit `true` as a rejection, so 'deny' reproduces the
107
+ // previous fail-closed behaviour exactly.
108
+ //
109
+ // 这条不走 localOnly:它由中转与局域网代理代转,没有本机 socket 可言。身份靠
110
+ // sidecarToken(中转与 worker 之间的共享密钥)。
111
+ registerRoute({ kind: 'exact', path: '/api/relay/internal/approval', handler: async (req, res) => {
112
+ if (req.headers.authorization !== `Bearer ${sidecarToken}`) return sendJson(res, 401, { error: 'unauthorized' })
113
+ let body = {}
114
+ try { body = (await readJson(req)) || {} } catch { body = {} }
115
+ const decision = pairingApproval.evaluate(state.pairingPolicy, body.device)
116
+ // Audit trail: the removed prompt showed the device before letting it in.
117
+ process.stderr.write(`[dsh-mobile-plugin] pairing ${decision.approved ? 'approved' : 'denied'} policy=${decision.policy} device=${decision.device}${decision.ip ? ` ip=${decision.ip}` : ''}\n`)
118
+ return sendJson(res, 200, { data: { approved: decision.approved, reason: decision.reason } })
119
+ } })
120
+ // 控制面状态里额外带上面板页的绝对地址:客户端半边(lib/client.js)那一项现在只是
121
+ // 一个「打开配置页面」的入口,链接必须是绝对的(原因见 panelUrlOf)。地址只在这里
122
+ // 补,不动 createStatusView —— 那份 JSON 手机端也在读,127.0.0.1 的地址对它没意义。
123
+ registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req) })) })
124
+ // 面板页本体:设置界面从客户端半边搬到这里,浏览器(或桌面端里的「打开配置页面」)
125
+ // 直接打开它。三条地址吐的是同一份 HTML,分流在 React 侧(见 React/src/Root.tsx):
126
+ // /api/relay/panel 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
127
+ // /api/relay/panel/setup 强制走引导(想再看一遍流程时用)
128
+ // /api/relay/panel/settings 强制进主页(演示 / 排查时跳过引导)
129
+ // 整页 HTML 不需要 token,但这几条仍然走 localOnly —— 它读的是本机运维界面,
130
+ // 而局域网与中转那两条链路本来就不该看到它(它们带 dsh_pair/dsh_lan 凭据,会被挡掉)。
131
+ const servePanel = (res) => {
132
+ const html = panelHtml();
133
+ if (!html) return sendJson(res, 500, { ok: false, error: { code: 'panel_missing', message: '面板页面还没构建:在 dsh-mobile-plugin/React 里跑一次 pnpm run build' } });
134
+ return sendHtml(res, 200, html);
135
+ };
136
+ registerRoute({ kind: 'exact', path: '/api/relay/panel', handler: localOnly('GET', async (_req, res) => servePanel(res)) })
137
+ registerRoute({ kind: 'exact', path: '/api/relay/panel/setup', handler: localOnly('GET', async (_req, res) => servePanel(res)) })
138
+ registerRoute({ kind: 'exact', path: '/api/relay/panel/settings', handler: localOnly('GET', async (_req, res) => servePanel(res)) })
139
+ // 桌面端工作区视图(侧栏展开态)的回传/读取。桌面端只把展开态记在浏览器
140
+ // localStorage 里,宿主进程读不到,只能由页面内的客户端半边(lib/client.js)
141
+ // 每 2s 变了就 POST 到这里;App 再经 /api/mobile/v1/desktop-view 取走。
142
+ registerRoute({ kind: 'exact', path: '/api/relay/desktop-view', handler: localOnly(['GET', 'POST'], async (req, res) => {
143
+ if (String(req.method || 'GET').toUpperCase() === 'GET') {
144
+ return sendJson(res, 200, { ok: true, view: desktopView.snapshot() })
145
+ }
146
+ let body = {}
147
+ try { body = (await readJson(req)) || {} } catch { body = {} }
148
+ try {
149
+ desktopHalf.noteDesktopViewPost()
150
+ return sendJson(res, 200, { ok: true, view: desktopView.update(body) })
151
+ } catch (error) {
152
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: String((error && error.message) || error) } })
153
+ }
154
+ }) })
155
+ // 手机端「已读回执」的下发口:客户端半边每 2s GET 一次,拿 sessionId → readAt 水位,
156
+ // 对水位前进过的会话在桌面页面里就地清掉 completionUnread(见 lib/client.js)。
157
+ // 台账由 /api/mobile/v1/session/read 写(lib/mobile/session-send.js)。
158
+ registerRoute({ kind: 'exact', path: '/api/relay/read-receipt', handler: localOnly('GET', async (_req, res) => {
159
+ desktopHalf.noteReadReceiptGet()
160
+ return sendJson(res, 200, { ok: true, receipt: readReceipt.snapshot() })
161
+ }) })
162
+ // 客户端半边的自检上报口。它每 10s 把自己「能不能看见那两个服务」报一次:
163
+ // 宿主只能数出「它没来敲门」,数不出「它为什么没来」,而这条通道是唯一的问法。
164
+ // 只收白名单里的布尔/短字符串/整数(见 lib/desktop-half-stats.cjs 的 noteClientHalf)。
165
+ registerRoute({ kind: 'exact', path: '/api/relay/client-half', handler: localOnly('POST', async (req, res) => {
166
+ let body = {}
167
+ try { body = (await readJson(req)) || {} } catch { body = {} }
168
+ return sendJson(res, 200, { ok: true, client: desktopHalf.noteClientHalf(body) })
169
+ }) })
170
+ // 反方向:桌面端页面里的客户端半边发现某个会话的未读绿点掉了(用户刚在桌面点开),
171
+ // 把它报回宿主,宿主清掉**所有设备**维度上的完成提醒,手机端侧栏随之灭灯。
172
+ // 只清「刚刚掉出去」的那些 id:首帧的全量未读集合不上报,否则会把还没读的也灭掉。
173
+ registerRoute({ kind: 'exact', path: '/api/relay/desktop-read', handler: localOnly('POST', async (req, res) => {
174
+ let body = {}
175
+ try { body = (await readJson(req)) || {} } catch { body = {} }
176
+ const raw = Array.isArray(body.sessionIds) ? body.sessionIds : []
177
+ const ids = []
178
+ for (const item of raw) {
179
+ if (typeof item !== 'string') continue
180
+ const id = item.trim()
181
+ if (!id || id.length > 200 || ids.includes(id)) continue
182
+ ids.push(id)
183
+ if (ids.length >= MAX_DESKTOP_READ_IDS) break
184
+ }
185
+ if (!ids.length) return sendJson(res, 200, { ok: true, cleared: 0 })
186
+ if (!ctx) return sendJson(res, 200, { ok: true, cleared: 0 })
187
+ let cleared = 0
188
+ for (const id of ids) cleared += clearCompletionForAllDevices(ctx, id)
189
+ desktopHalf.noteDesktopRead({ ids: ids.length, cleared })
190
+ // 有东西真被清掉才作废列表缓存:invalidateSessionList 会抬 generation,
191
+ // 让镜像推送器重建清单并广播 device/list-changed(见 A3-14)。
192
+ if (cleared > 0) invalidateSessionList(ctx)
193
+ return sendJson(res, 200, { ok: true, cleared })
194
+ }) })
195
+ // The process-level error ledger (see lib/host-guard.cjs). "Stop an unknown
196
+ // error before it kills DSH" is only actionable if the faults are visible, and
197
+ // they cannot ride on /api/relay/status: that one is answered by the sidecar
198
+ // worker, a different process that never sees this one's uncaught errors.
199
+ // Local-only, same as status — the ledger carries stack traces.
200
+ registerRoute({ kind: 'exact', path: '/api/relay/faults', handler: localOnly('GET', async (_req, res) => {
201
+ const guard = activeGuard()
202
+ if (!guard) return sendJson(res, 200, { ok: true, installed: false, faults: [], stats: null })
203
+ return sendJson(res, 200, { ok: true, installed: true, faults: guard.faults(), stats: guard.stats() })
204
+ }) })
205
+ registerRoute({ kind: 'exact', path: '/api/relay/pairing', handler: localOnly('GET', async (_req, res) => {
206
+ if (!state.enabled) {
207
+ sendJson(res, 409, { ok: false, error: '请先启用远程控制并保存配置' })
208
+ return
209
+ }
210
+ await resolveLanPort()
211
+ const { payload, text } = pairingPayload(state, lanPortInUse())
212
+ if (!payload.relayUrl && payload.lanUrls.length === 0) {
213
+ sendJson(res, 409, { ok: false, error: '没有可用的局域网或中转连接地址' })
214
+ return
215
+ }
216
+ try {
217
+ const svg = await QRCode.toString(text, {
218
+ type: 'svg',
219
+ errorCorrectionLevel: 'M',
220
+ margin: 2,
221
+ width: 280,
222
+ })
223
+ sendJson(res, 200, {
224
+ ok: true,
225
+ name: payload.name,
226
+ relayUrl: payload.relayUrl,
227
+ lanUrls: payload.lanUrls,
228
+ pairingCode: payload.code,
229
+ // 二维码里带的局域网地址用的是这个端口:首选端口被占时它与 relay.json 里
230
+ // 的 `lanPort` 不同,不说清楚会让人对着 18787 排查一个根本没在监听的端口。
231
+ lanPort: lanPortInUse(),
232
+ lanPortAdjusted: getLanPortAdjusted(),
233
+ svg,
234
+ })
235
+ } catch (error) {
236
+ sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
237
+ }
238
+ }) })
239
+ registerRoute({ kind: 'exact', path: '/api/relay/toggle', handler: localOnly('POST', async (req, res) => {
240
+ const body = await readJson(req)
241
+ await settings.update({ enabled: Boolean(body && body.enabled) })
242
+ // 等同步真正落地再回状态,否则返回的是重启前的旧快照。
243
+ await syncSidecar().catch(() => undefined)
244
+ sendJson(res, 200, status())
245
+ }) })
246
+ registerRoute({ kind: 'exact', path: '/api/relay/regenerate', handler: localOnly('POST', async (_req, res) => {
247
+ state.pairingCode = core.genPairingCode()
248
+ core.saveState(state)
249
+ await syncSidecar().catch(() => undefined)
250
+ sendJson(res, 200, status())
251
+ }) })
252
+ // 「首次接入引导已经走完」的标记。落盘(saveState 把整个 state 写进 relay.json),
253
+ // 所以换浏览器、换端口、重启宿主都还在;面板据此决定打开时先走引导还是直接进主页。
254
+ // 刻意不碰 sidecar、也不进 RemoteControlSettingsSchema:它不是一项远程控制配置,
255
+ // 改它不该触发一次重配(那条链上每加一个字段都要同步改三处,见 core.js 的注释)。
256
+ registerRoute({ kind: 'exact', path: '/api/relay/onboarded', handler: localOnly('POST', async (_req, res) => {
257
+ state.onboarded = true
258
+ core.saveState(state)
259
+ sendJson(res, 200, status())
260
+ }) })
261
+ registerRoute({ kind: 'exact', path: '/api/relay/revoke-all', handler: localOnly('POST', async (_req, res) => {
262
+ // 清除所有授权 = 中转清空本实例全部设备授权 + 本机吊销局域网设备凭据 +
263
+ // 轮换 lanSecret/配对码。三处都要做:只清中转的话,同网设备仍能用旧的
264
+ // `dshc_` 凭据直连宿主,而移动端的「撤销全部授权」正是在要求它们全部失效。
265
+ let revoked = 0
266
+ try {
267
+ const result = await getSidecar().request('/revoke-all', 'POST', undefined, 20_000)
268
+ revoked = Number(result && result.revoked) || 0
269
+ } catch { /* 中转不可达时仍继续吊销本机凭据与密钥 */ }
270
+ const localRevoked = credentials.revokeAllByInstance(state.instanceId)
271
+ state.lanSecret = randomBytes(32).toString('hex')
272
+ state.pairingCode = core.genPairingCode()
273
+ core.saveState(state)
274
+ await syncSidecar().catch(() => undefined)
275
+ sendJson(res, 200, { ok: true, revoked: revoked + localRevoked, revokedRelay: revoked, revokedLocal: localRevoked, ...status() })
276
+ }) })
277
+ registerRoute({ kind: 'exact', path: '/api/relay/config', handler: localOnly('POST', async (req, res) => {
278
+ const body = await readJson(req)
279
+ const patch = {}
280
+ if (typeof body?.relayUrl === 'string') patch.relayUrl = body.relayUrl.trim()
281
+ if (typeof body?.lanEnabled === 'boolean') patch.lanEnabled = body.lanEnabled
282
+ if (Number.isInteger(body?.lanPort) && body.lanPort >= 1024 && body.lanPort <= 65535) patch.lanPort = body.lanPort
283
+ if (typeof body?.name === 'string' && body.name.trim()) patch.name = body.name.trim()
284
+ if (body?.pairingPolicy === 'code' || body?.pairingPolicy === 'deny') patch.pairingPolicy = body.pairingPolicy
285
+ // 注册密钥:只接受字符串,过长拒绝;写空串等于关掉(回到"中转开了校验就连不上")。
286
+ if (typeof body?.registerSecret === 'string' && body.registerSecret.length <= 512) patch.registerSecret = body.registerSecret
287
+ await settings.update(patch)
288
+ await syncSidecar().catch(() => undefined)
289
+ sendJson(res, 200, status())
290
+ }) })
291
+ registerRoute({ kind: 'exact', path: '/api/relay/reconnect', handler: localOnly('POST', async (_req, res) => {
292
+ await syncSidecar()
293
+ sendJson(res, 200, status())
294
+ }) })
295
+ // ── 插件更新 ──
296
+ //
297
+ // 这里**没有**自更新路由了(2026-10 拆掉):插件从 npm 分发,更新由 DSH 的插件市场做
298
+ // (网页版那张卡片直接调市场的公共更新接口 `/dsh-market/api/v1/*`,见 React/)。
299
+ // 原来那四条(`/update/{status,check,apply,reload}`)走的是中转的插件 OTA 与 `.live`
300
+ // 代机制,整套已经删掉 —— 插件只剩一条更新路径:市场装新版本 + 重启 DSH。
301
+
302
+
303
+ // sidecar 自重启:换掉 worker 子进程 + facade 模块,宿主本身不重启。
304
+ // 走 localOnly——和其余控制面一样,这是本机运维动作。
305
+ registerRoute({ kind: 'exact', path: '/api/relay/restart-sidecar', handler: localOnly('POST', async (_req, res) => {
306
+ const result = await restartSidecar()
307
+ sendJson(res, result.ok ? 200 : 500, { ...result, status: status() })
308
+ }) })
309
+ }
310
+
311
+ module.exports = { registerControlPlaneRoutes };
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 移动端 API 路由表(`/api/mobile/v1/*`)。
5
+ *
6
+ * 这块此前是 lib/index.js 里连续 120 多行的注册语句,与控制面路由、sidecar 生命周期、
7
+ * 镜像推送调度器混在同一个 apply() 闭包里。它本身没有任何逻辑——绝大部分是「路径 → 已
8
+ * 写好的 handler」的纯映射,只有 status / connection-info / desktop-view 三条带一点内联
9
+ * 逻辑——但混在装配代码里有两个代价:
10
+ *
11
+ * 1. 想确认「App 打的某个地址到底注册没有」必须人工通读 apply()。
12
+ * 历史上 `workspace/rename` 与 `workspace/archive-session` 就是 App 一直在打、
13
+ * 插件从来没注册,手机端必然 404。
14
+ * 2. 改一条路径要在 1000 行文件里找。
15
+ *
16
+ * 现在它是一张可以一眼读完、可以对着 App 侧 apiPaths.ts 核对的表。
17
+ *
18
+ * 依赖注入只留三样:注册器、移动端守卫、以及两个会变的值。handler 们直接 require,
19
+ * 因为它们本来就只是被这张表挂上去的。
20
+ */
21
+
22
+ const { sendJson } = require('../http/routing.js');
23
+
24
+ const sessions = require('../mobile/sessions.js');
25
+ const agentPresets = require('../mobile/agent-presets.js');
26
+ const stream = require('../mobile/stream.js');
27
+ const events = require('../mobile/events.js');
28
+ const questions = require('../mobile/questions.js');
29
+ const workspaces = require('../mobile/workspaces.js');
30
+ const sessionLog = require('../mobile/session-log.js');
31
+ const hostSettings = require('../mobile/host-settings.js');
32
+ const skills = require('../mobile/skills.js');
33
+ const mobileCredentials = require('../mobile/credentials.js');
34
+ const subagents = require('../mobile/subagents.js');
35
+ const references = require('../mobile/references.js');
36
+ const plugins = require('../mobile/plugins.js');
37
+ const desktopView = require('../desktop-view.cjs');
38
+
39
+ const API_VERSION = '1';
40
+
41
+ /**
42
+ * @param {object} deps
43
+ * @param {(route: object) => void} deps.registerRoute 交给 ctx.webServer 的注册器
44
+ * @param {(handler: Function) => Function} deps.guarded 移动端守卫(token + 500 信封)
45
+ * @param {object} deps.state 内存里的中转状态(本进程唯一真值)
46
+ * @param {() => object} deps.hostMetadata 宿主元信息(隧道准入状态等)
47
+ * @param {() => object} deps.getSidecarSnapshot 取快照的**取值器**。
48
+ * 这里必须传函数而不是对象:sidecarSnapshot 是会被 applySidecarConfig 整体替换的
49
+ * let 变量,闭包捕获对象会让 handler 永远读到启动那一刻的旧快照(lan.updatedAt 停在
50
+ * 进程启动时间,手机端看连接信息像失联了一样)。
51
+ * @param {(port: number) => Array<object>} deps.safeLanCandidates 局域网候选地址
52
+ * @param {() => number} deps.lanPortInUse 手机该拨的局域网端口。**必须用取值器**:
53
+ * sidecar 自己 bind 到的端口可能与本进程解析出的不同(两条端口自适应),只认
54
+ * `state.lanPort` 会把这个接口变成「报一个没人监听的端口」——真机实测过 50933
55
+ * 死地址把手机逼回公网中转(见 impl.js 的 advertisedLanPort)。
56
+ * @param {(route: { path: string, handler: Function }) => void} deps.registerUpgradeRoute
57
+ * 交给 ctx.webServer 的**升级**路由注册器(`registerUpgrade`)。与 registerRoute 是
58
+ * 两套名字空间:同一个 path 可以既有一条普通路由又有一条升级路由,但升级路由重复注册
59
+ * 会抛错。
60
+ * @param {object} deps.preview 预览字节管道服务(lib/relay/preview.js)。它自带鉴权与
61
+ * 端口边界,所以**不能**包在 guarded 里——guarded 回的是 JSON,而升级请求需要的是
62
+ * 一次拒绝或一次握手。
63
+ */
64
+ function registerMobileRoutes(deps) {
65
+ const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, lanPortInUse, preview } = deps;
66
+ const route = (path, handler) => registerRoute({ kind: 'exact', path, handler: guarded(handler) })
67
+
68
+ registerRoute({ kind: 'exact', path: '/api/mobile/v1/status', handler: guarded(async (_ctx, _req, res) => {
69
+ // 内存 `state` 是本进程唯一的真值。旧实现这里 `loadRelayState()` 重新读盘,
70
+ // 于是同一份状态出现两个来源:控制面改完内存立刻返回,而移动端读到的还是
71
+ // 上一次落盘的内容(多实例/慢盘时甚至永久不一致)。
72
+ sendJson(res, 200, {
73
+ ok: true,
74
+ apiVersion: API_VERSION,
75
+ instanceId: state.instanceId || null,
76
+ name: state.name || null,
77
+ remoteEnabled: Boolean(state.enabled),
78
+ ...hostMetadata(),
79
+ })
80
+ }) })
81
+ // 局域网/中转连接信息。公网配对时由中转在 `/api/mobile/v1/connection-info`
82
+ // 代答宿主上报的镜像数据;但纯局域网配对(App 只扫到局域网二维码、完全不经
83
+ // 中转)需要宿主自己回答同一个地址,否则 App 拿不到 lanCandidates,换网卡后
84
+ // 就再也找不到这台机器。字段与中转侧保持一致以便 App 用同一套解析。
85
+ route('/api/mobile/v1/connection-info', async (_ctx, _req, res) => {
86
+ // 端口取「手机该拨的那个」(sidecar 真 bind 成的),不是配置里的首选端口:
87
+ // 两者不一致时这里报出去的候选是死地址,手机只能退回公网中转。
88
+ const port = typeof lanPortInUse === 'function' ? lanPortInUse() : state.lanPort
89
+ const candidates = state.lanEnabled ? safeLanCandidates(port) : []
90
+ const lanUpdatedAt = getSidecarSnapshot()?.lan?.updatedAt || Date.now()
91
+ sendJson(res, 200, {
92
+ ok: true,
93
+ instanceId: state.instanceId || null,
94
+ name: state.name || null,
95
+ relayUrl: state.enabled ? (state.relayUrl || null) : null,
96
+ lanUrls: candidates,
97
+ lanCandidates: candidates,
98
+ updatedAt: lanUpdatedAt,
99
+ lastSeen: lanUpdatedAt,
100
+ })
101
+ })
102
+
103
+ route('/api/mobile/v1/sessions', sessions.handleSessions)
104
+ route('/api/mobile/v1/session', sessions.handleSession)
105
+ route('/api/mobile/v1/session/messages', sessions.handleMessages)
106
+ route('/api/mobile/v1/session/send', sessions.handleSend)
107
+ route('/api/mobile/v1/commands', sessions.handleCommands)
108
+ route('/api/mobile/v1/session/read', sessions.handleSessionRead)
109
+ route('/api/mobile/v1/session/stop', sessions.handleStop)
110
+ route('/api/mobile/v1/session/rename', sessions.handleRename)
111
+ route('/api/mobile/v1/session/fork', sessions.handleFork)
112
+ route('/api/mobile/v1/session/models', sessions.handleModels)
113
+ route('/api/mobile/v1/session/select-model', sessions.handleSelectModel)
114
+ route('/api/mobile/v1/session/stats', sessions.handleStats)
115
+ // 正文检索(宿主 session.search 的移动端代理):只回 { sessionId, snippet },
116
+ // 标题 / 工作区由 App 用自己轮询到的会话列表拼接,见 mobile/sessions.js。
117
+ route('/api/mobile/v1/session/search', sessions.handleSessionSearch)
118
+ // Agent 预设(宿主 agentPreset.list / agentPreset.select 的移动端代理)。桌面端
119
+ // 会话头标签、设置行、hero 芯片都读这份名单;宿主未配置 roster 时 list 回空数组。
120
+ route('/api/mobile/v1/agent-presets', sessions.handleAgentPresets)
121
+ // Agent 预设管理(宿主 agentPreset.read / copy / remove 的移动端代理):桌面端设置页
122
+ // 那个「管理分区」——只读查看器、复制(桌面端唯一的创建入口)、删除。此前手机只能
123
+ // 看到名单与当前档,既读不到预设正文也造不出自己的预设。
124
+ route('/api/mobile/v1/agent-presets/read', agentPresets.handleAgentPresetRead)
125
+ route('/api/mobile/v1/agent-presets/copy', agentPresets.handleAgentPresetCopy)
126
+ route('/api/mobile/v1/agent-presets/remove', agentPresets.handleAgentPresetRemove)
127
+ route('/api/mobile/v1/session/new', sessions.handleNew)
128
+ route('/api/mobile/v1/session/stream', stream.handleStream)
129
+ route('/api/mobile/v1/events/stream', events.handleDeviceStream)
130
+ route('/api/mobile/v1/question/respond', questions.handleQuestionRespond)
131
+ route('/api/mobile/v1/approval/respond', questions.handleApprovalRespond)
132
+ route('/api/mobile/v1/session/goal', sessions.handleGoalAction)
133
+ route('/api/mobile/v1/session/queue/update', sessions.handleQueueUpdate)
134
+ route('/api/mobile/v1/workspaces', workspaces.handleWorkspaces)
135
+ // 桌面端工作区展开态镜像(由页面内的客户端半边经 /api/relay/desktop-view 回传)。
136
+ // 桌面端只把这份状态记在浏览器 localStorage 里,宿主读不到,所以这里给 App 的只是
137
+ // 「宿主最近收到的快照」;从未收到过时返回 view: null,App 按桌面的折叠缺省处理。
138
+ route('/api/mobile/v1/desktop-view', async (_ctx, _req, res) => sendJson(res, 200, { ok: true, view: desktopView.snapshot() }))
139
+ route('/api/mobile/v1/directory/list', workspaces.handleListDirectory)
140
+ route('/api/mobile/v1/directory/create', workspaces.handleCreateDirectory)
141
+ route('/api/mobile/v1/workspace/create', workspaces.handleCreateWorkspace)
142
+ route('/api/mobile/v1/workspace/delete', workspaces.handleDeleteWorkspace)
143
+ route('/api/mobile/v1/workspace/move-session', workspaces.handleMoveSession)
144
+ // App 的 `renameWorkspace()` / `archiveSession()` 早就在打这两个地址
145
+ // (`app/src/services/apiPaths.ts` 的 workspaceRename / workspaceArchiveSession),
146
+ // 但插件从来没有注册 → 移动端「重命名工作区」和「归档会话」必然 404。
147
+ route('/api/mobile/v1/workspace/rename', workspaces.handleRenameWorkspace)
148
+ route('/api/mobile/v1/workspace/archive-session', workspaces.handleArchiveSession)
149
+ // 桌面端 `/export` 其实是两步:命令本身只留一行 transcript 标记,真正的下载由
150
+ // @deepseek-ai/dsh-session-log-export 在浏览器侧监听 command/executed 后拉
151
+ // `GET /api/session.export` 完成。手机端没有浏览器下载管理器,而那个端点绑在宿主
152
+ // 的 127.0.0.1 上手机直连不到 —— 所以由插件代取(mobile/session-log.js),App 侧
153
+ // 再落盘。
154
+ route('/api/mobile/v1/session-log', sessionLog.handleSessionLog)
155
+ // 宿主设置与模型提供方(宿主 settings.describe / llm.providers 的移动端只读代理)。
156
+ // 桌面端「设置」里有默认模型、提供方配置、凭据是否已设;手机端此前完全没有这一块,
157
+ // 只能看到 App 自己的偏好项。两个宿主接口本身已脱敏(secrets 只给 path 与 set),
158
+ // 插件再丢掉对手机没用的 schema 树,见 mobile/host-settings.js。
159
+ route('/api/mobile/v1/host-settings', hostSettings.handleHostSettings)
160
+ // 改设置:与桌面端同一个 settings.mutate(按路径寻址的 op + expectedRevision 乐观并发)。
161
+ route('/api/mobile/v1/host-settings/mutate', hostSettings.handleHostSettingsMutate)
162
+ route('/api/mobile/v1/llm/providers', hostSettings.handleLlmProviders)
163
+ // 技能目录(宿主 skill.list 的移动端只读代理)。桌面端敲「/」时命令候选菜单里
164
+ // 混着技能(@deepseek-ai/dsh-client-ui-skill 注册的 inputTriggers source),
165
+ // 手机端此前只有 commands.list 的命令目录,技能一个都看不到,见 mobile/skills.js。
166
+ route('/api/mobile/v1/skills', skills.handleSkills)
167
+ // 宿主凭据(宿主 credentials.{describe,set,unset} 的移动端代理)。桌面端设置面板
168
+ // 能往宿主写 API Key,手机端此前只能看到「1 项密钥未设置」却没有入口,见
169
+ // mobile/credentials.js。密钥值只在这条路上出现一次,宿主从不回传值。
170
+ route('/api/mobile/v1/credentials', mobileCredentials.handleCredentialsDescribe)
171
+ route('/api/mobile/v1/credentials/set', mobileCredentials.handleCredentialsSet)
172
+ route('/api/mobile/v1/credentials/unset', mobileCredentials.handleCredentialsUnset)
173
+ // 子代理目录(宿主 subagent.list 的移动端只读代理)。桌面端会话标题栏挂着
174
+ // 「N 个子代理」下拉,点进去能读子会话的完整记录;手机端把 origin === 'subagent'
175
+ // 的行在侧栏里 continue 掉之后就再没有任何入口,见 mobile/subagents.js。
176
+ route('/api/mobile/v1/subagents', subagents.handleSubagents)
177
+ // 子代理续话与打断(宿主 subagent.prompt / subagent.interrupt 的移动端代理)。
178
+ // 手机端的输入框只会发 session.send,而宿主对子会话一律回 agent-busy,所以给
179
+ // 「可继续」子代理发消息必须换这条路由;一次性子代理宿主根本不收续话。
180
+ route('/api/mobile/v1/subagents/prompt', subagents.handleSubagentPrompt)
181
+ route('/api/mobile/v1/subagents/interrupt', subagents.handleSubagentInterrupt)
182
+ // 引用发现(宿主 fileReferences.list / sessionReferenceResolver.candidates 的移动端
183
+ // 代理)。桌面端输入 @ 会同时查这两个域,文件排在会话前面;手机端此前完全没有补全。
184
+ // 和插件清单一样,这两个宿主服务不在 apiProxy 上,走 typertGateway,
185
+ // 见 mobile/references.js。两个域各自独立降级,一个不可用不影响另一个。
186
+ route('/api/mobile/v1/file-references', references.handleFileReferences)
187
+ route('/api/mobile/v1/session-references', references.handleSessionReferences)
188
+
189
+ // 桌面端 设置 → 插件 的只读部署快照,由 dsh-host-plugin-inventory 拥有,
190
+ // 经 typertGateway 命名空间 pluginInventory 取,见 mobile/plugins.js。
191
+ route('/api/mobile/v1/plugins', plugins.handlePlugins)
192
+
193
+ // 内置浏览器预览(App 侧「点 localhost 链接在内置浏览器里打开」的宿主端一半)。
194
+ //
195
+ // 这是一条 **WebSocket 升级**路由,不是普通 HTTP 路由:手机原生模块按端口在环回监听、
196
+ // 把 WebView 写下的原始字节塞进 WS 二进制帧;宿主这边 lib/relay/preview.js 完成握手后
197
+ // 与 `127.0.0.1:<port>` 双向对搬。中转与局域网代理都**不用改**——中转的升级分发只看
198
+ // 鉴权不看路径,局域网代理更是把任意升级路径原样转给宿主 webServer。
199
+ //
200
+ // 端口用查询串带(`?port=3001`):宿主的升级匹配按 pathname 精确匹配、**忽略查询串**,
201
+ // 所以下面这个不带查询串的字面量就是那条路由本身。
202
+ //
203
+ // 路径字面量必须与 App 侧 apiPaths.ts 的 preview 一致:test/mobile-routes.test.cjs
204
+ // 就是拿这张表反向核对 App 常量表的。
205
+ registerUpgradeRoute({ path: '/api/mobile/v1/preview', handler: preview.handleUpgrade })
206
+ }
207
+
208
+ module.exports = { registerMobileRoutes };
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }