dsh-mobile-plugin 0.3.1 → 0.4.1
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/config/agent-presets/standard/agent.cordis.yml +7 -5
- package/dsh-mobile-credentials/lib/credentials.js +8 -8
- package/host.js +108 -39
- package/lib/client.js +29 -82
- package/lib/desktop-view.cjs +5 -10
- package/lib/git/read.js +6 -8
- package/lib/host-compat.cjs +120 -0
- package/lib/host-guard.cjs +9 -39
- package/lib/host-patches.cjs +5 -16
- package/lib/http/routing.js +7 -27
- package/lib/impl.js +6 -8
- package/lib/mirror/pusher.cjs +33 -85
- package/lib/mirror-snapshot.cjs +6 -24
- package/lib/mobile/agent-presets.js +6 -26
- package/lib/mobile/approvals.js +18 -44
- package/lib/mobile/archive-clean.js +10 -19
- package/lib/mobile/attachments.js +11 -36
- package/lib/mobile/channels.js +15 -41
- package/lib/mobile/credential-upgrade.js +7 -16
- package/lib/mobile/credentials.js +5 -21
- package/lib/mobile/directory-browse.js +8 -27
- package/lib/mobile/directory-listing.js +10 -23
- package/lib/mobile/drive-roots.js +59 -0
- package/lib/mobile/envelope.js +4 -15
- package/lib/mobile/events.js +35 -65
- package/lib/mobile/extensions.js +5 -8
- package/lib/mobile/git-repos.js +6 -15
- package/lib/mobile/git.js +11 -19
- package/lib/mobile/history-derive.js +23 -41
- package/lib/mobile/host-bridge/endpoints.js +4 -6
- package/lib/mobile/host-bridge/event-synthesis.js +21 -35
- package/lib/mobile/host-bridge/shims.js +12 -22
- package/lib/mobile/host-bridge.js +8 -20
- package/lib/mobile/host-settings.js +9 -19
- package/lib/mobile/inbox-clean.js +7 -24
- package/lib/mobile/local-images.js +11 -17
- package/lib/mobile/monitor.js +8 -17
- package/lib/mobile/pairing.js +7 -19
- package/lib/mobile/plugin-self.js +5 -22
- package/lib/mobile/plugin-update.js +8 -27
- package/lib/mobile/plugins.js +6 -24
- package/lib/mobile/questions.js +15 -40
- package/lib/mobile/references.js +11 -31
- package/lib/mobile/relay-control.js +6 -27
- package/lib/mobile/rpc.js +5 -13
- package/lib/mobile/schedule.js +8 -33
- package/lib/mobile/self-check.cjs +9 -36
- package/lib/mobile/session-cache.js +13 -19
- package/lib/mobile/session-file.js +8 -21
- package/lib/mobile/session-flags.js +7 -13
- package/lib/mobile/session-mirror.js +8 -15
- package/lib/mobile/session-models.js +3 -3
- package/lib/mobile/session-schedule-mark.js +8 -14
- package/lib/mobile/session-send.js +13 -21
- package/lib/mobile/session-snapshot.js +4 -6
- package/lib/mobile/sessions.js +7 -23
- package/lib/mobile/stats-collect.cjs +10 -24
- package/lib/mobile/stats-ledger.cjs +13 -20
- package/lib/mobile/stream.js +12 -23
- package/lib/mobile/subagents.js +5 -18
- package/lib/mobile/uploads.js +8 -17
- package/lib/mobile/workspaces.js +20 -8
- package/lib/panel/index.html +7 -7
- package/lib/read-receipt.cjs +10 -21
- package/lib/relay/lan-network.cjs +14 -28
- package/lib/relay/lan-proxy.js +14 -21
- package/lib/relay/pairing-policy.js +6 -9
- package/lib/relay/preview.js +8 -25
- package/lib/relay/protocol.js +6 -13
- package/lib/relay/status.js +5 -8
- package/lib/relay/tunnel.js +9 -14
- package/lib/routes/control-plane.js +28 -45
- package/lib/routes/mobile-api.js +19 -32
- package/lib/session-cleanup.cjs +16 -27
- package/lib/shared/credentials-entry.js +4 -11
- package/lib/shared/dshtoken.js +4 -4
- package/lib/shared/e2ee-request.js +1 -1
- package/lib/shared/e2ee.js +8 -14
- package/lib/shared/fresh-request.js +6 -22
- package/lib/shared/http-util.js +3 -4
- package/lib/shared/list-revision.js +5 -14
- package/lib/shared/session-signal.js +7 -16
- package/lib/shared/sse-write.js +14 -20
- package/lib/shared/workspace-guard.js +7 -15
- package/lib/shell/device-ota.js +4 -6
- package/lib/shell/manager.js +12 -27
- package/lib/shell/ota-package.js +7 -12
- package/lib/shell/runtime.js +8 -16
- package/lib/shell/self-check-after-swap.js +9 -23
- package/lib/shell/updater.js +13 -26
- package/lib/sidecar-facade-worker.cjs +20 -33
- package/lib/sidecar-facade.cjs +7 -7
- package/lib/sidecar-lifecycle.cjs +7 -9
- package/lib/update-install.cjs +14 -59
- package/lib/update-registry.cjs +6 -18
- package/package.json +8 -5
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 让「官方工具行」跟着运行时换代,而不是跟着本包发版那一刻。
|
|
5
|
+
*
|
|
6
|
+
* 提问工具、交付文件工具、目录浏览后端这三个包互相用精确版本咬死,而 `dsh-app-boot` 在
|
|
7
|
+
* 装载前拿每一行跟运行时对一遍——对不上就整行禁用,现象是手机上「模型说没有这个工具」。
|
|
8
|
+
* 所以启动时先问运行时那棵树要同代的那一份,拿到就把行的 `name` 换成它的入口绝对路径,
|
|
9
|
+
* 绕开按裸名解析;拿不到就保持原样,让官方检查去禁用并打印原因。
|
|
10
|
+
*
|
|
11
|
+
* 这里只放纯函数:`host.js` 一 import 就 `boot()` 整个宿主,测试碰不得。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs')
|
|
15
|
+
const path = require('node:path')
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 预设里那两行的 id 与包名。
|
|
19
|
+
*
|
|
20
|
+
* 两个都要:都是官方工具、都精确咬版本、都不在 `dsh-web-app` 的预设里,漏掉一个对应
|
|
21
|
+
* 能力就会同样安静地消失。
|
|
22
|
+
*/
|
|
23
|
+
const TOOL_ROWS = Object.freeze([
|
|
24
|
+
Object.freeze({ id: 'tool-ask-user', name: '@deepseek-ai/dsh-tool-ask-user' }),
|
|
25
|
+
Object.freeze({ id: 'tool-present', name: '@deepseek-ai/dsh-tool-present' }),
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
/** 无头宿主里被钉成 browse 的目录选择后端;同样精确咬版本。 */
|
|
29
|
+
const DIRECTORY_PICKER_ROW = Object.freeze({
|
|
30
|
+
id: 'directory-picker-browse',
|
|
31
|
+
name: '@deepseek-ai/dsh-host-directory-picker-browse',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const ROW_ID = /^(\s*)-\s+id:\s*(\S+)\s*$/
|
|
35
|
+
const ROW_NAME = /^(\s+)name:\s*'([^']+)'\s*$/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 把 `- id: <id>` 紧跟着的那个 `name:` 换成给定值。
|
|
39
|
+
*
|
|
40
|
+
* 逐行扫而不是解析 YAML 再序列化:这份预设带着大量解释性注释,解析再写回会把它们和
|
|
41
|
+
* 空行全洗掉。只认「上一行是目标 id」的 name,同一个包名出现在别处不会被误伤。
|
|
42
|
+
*
|
|
43
|
+
* @param {string} source 预设文件内容
|
|
44
|
+
* @param {Record<string, string>} targets 行 id → 新的 name(入口绝对路径)
|
|
45
|
+
* @returns {{ text: string, changed: string[] }}
|
|
46
|
+
*/
|
|
47
|
+
function rewriteToolNames(source, targets) {
|
|
48
|
+
const changed = []
|
|
49
|
+
let currentId = null
|
|
50
|
+
const text = source
|
|
51
|
+
.split('\n')
|
|
52
|
+
.map(line => {
|
|
53
|
+
const id = ROW_ID.exec(line)
|
|
54
|
+
if (id) {
|
|
55
|
+
currentId = id[2]
|
|
56
|
+
return line
|
|
57
|
+
}
|
|
58
|
+
if (currentId === null) return line
|
|
59
|
+
const name = ROW_NAME.exec(line)
|
|
60
|
+
if (!name || targets[currentId] === undefined) return line
|
|
61
|
+
if (name[2] === targets[currentId]) return line
|
|
62
|
+
changed.push(currentId)
|
|
63
|
+
// 单引号是 YAML 的转义形式:值里的 ' 要写成 ''。
|
|
64
|
+
return `${name[1]}name: '${targets[currentId].replace(/'/g, "''")}'`
|
|
65
|
+
})
|
|
66
|
+
.join('\n')
|
|
67
|
+
return { text, changed }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 把预设目录同步一份到 `outDir`,并把目标行换成本次运行时的入口。
|
|
72
|
+
*
|
|
73
|
+
* 每次启动都重写:两份 yml 加起来不到 8KB,而「只在变化时写」要多记一份状态,就多一处
|
|
74
|
+
* 能不一致的地方。
|
|
75
|
+
*
|
|
76
|
+
* @param {string} presetDir 包内 `config/agent-presets`
|
|
77
|
+
* @param {string} outDir 宿主可写的目标目录
|
|
78
|
+
* @param {Record<string, string>} targets 行 id → 入口绝对路径
|
|
79
|
+
* @returns {string} 真正该交给 `agent-presets` roots 的目录
|
|
80
|
+
*/
|
|
81
|
+
function syncPresetDir(presetDir, outDir, targets) {
|
|
82
|
+
fs.rmSync(outDir, { recursive: true, force: true })
|
|
83
|
+
fs.cpSync(presetDir, outDir, { recursive: true })
|
|
84
|
+
if (Object.keys(targets).length === 0) return outDir
|
|
85
|
+
for (const entry of fs.readdirSync(outDir, { recursive: true, withFileTypes: true })) {
|
|
86
|
+
if (!entry.isFile() || !entry.name.endsWith('.yml')) continue
|
|
87
|
+
const file = path.join(entry.parentPath ?? entry.path, entry.name)
|
|
88
|
+
const { text, changed } = rewriteToolNames(fs.readFileSync(file, 'utf8'), targets)
|
|
89
|
+
if (changed.length > 0) fs.writeFileSync(file, text)
|
|
90
|
+
}
|
|
91
|
+
return outDir
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 把 patch 层里命中目标的 `name` 换成入口绝对路径。
|
|
96
|
+
*
|
|
97
|
+
* 只改 `insert` 里的行:其它形态的 `name` 是断言,与原名相同才会应用,改它等于让整条
|
|
98
|
+
* patch 被静默跳过。
|
|
99
|
+
*
|
|
100
|
+
* @param {Array} patches 已加载的 patch 层
|
|
101
|
+
* @param {Record<string, string>} targets 包名 → 入口绝对路径
|
|
102
|
+
* @returns {Array} 新数组,不改原对象
|
|
103
|
+
*/
|
|
104
|
+
function pinInsertedRows(patches, targets) {
|
|
105
|
+
const swap = (row) => {
|
|
106
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return row
|
|
107
|
+
const next = targets[row.name] === undefined ? row : { ...row, name: targets[row.name] }
|
|
108
|
+
if (Array.isArray(next.insert)) return { ...next, insert: next.insert.map(swap) }
|
|
109
|
+
return next
|
|
110
|
+
}
|
|
111
|
+
return patches.map(swap)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
TOOL_ROWS,
|
|
116
|
+
DIRECTORY_PICKER_ROW,
|
|
117
|
+
rewriteToolNames,
|
|
118
|
+
syncPresetDir,
|
|
119
|
+
pinInsertedRows,
|
|
120
|
+
}
|
package/lib/host-guard.cjs
CHANGED
|
@@ -3,44 +3,15 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 宿主进程级错误边界(crash guard)。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* DSH 宿主是长驻进程(嵌入桌面版或由 systemd 以 `Restart=on-failure` 托管),任何逃出
|
|
7
|
+
* 事件循环的异常都会带走整个 DSH。路由层已有 `.catch`,进程层的缺口是两处:
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 带走整个 DSH——用户那边表现为「DSH 崩了/插件把 DSH 搞挂了」,而且桌面端连个
|
|
11
|
-
* 插件隔离层都没有,插件的 bug 直接就是宿主的 bug。
|
|
9
|
+
* 1. 游离的 Promise 拒绝——Node 15+ 默认 throw 并杀进程,插件里大量 fire-and-forget;
|
|
10
|
+
* 2. 第三方回调(事件流、WS、timer)里抛错,没有 try 包着就是 uncaughtException。
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* 1. **游离的 Promise 拒绝**。Node 15+ 起 `unhandledRejection` 默认行为是 *throw*,
|
|
18
|
-
* 也就是**杀进程**。插件里凡是 `void someAsync()`(fire-and-forget)的地方,
|
|
19
|
-
* 一个没 catch 的 reject 就等于一次 DSH 崩溃。这类点极多:自动归档轮询、
|
|
20
|
-
* 镜像推送、隧道重连、遥测上报……任意一个后台任务炸一次就够了。
|
|
21
|
-
* 2. **第三方回调里抛错**。宿主事件流、WS 回调、timer 回调抛出的异常没有 try 包裹,
|
|
22
|
-
* 直接升级为 `uncaughtException`。
|
|
23
|
-
*
|
|
24
|
-
* ## 策略:记录 + 保持存活,连续 5 次才退出
|
|
25
|
-
*
|
|
26
|
-
* 单个异常通常不致命(最常见的是"某个后台任务这一次没成")。一刀切退出等于把
|
|
27
|
-
* 偶发故障变成必然崩溃;一味吞掉又会掩盖真正的崩溃循环。所以取中间值:
|
|
28
|
-
*
|
|
29
|
-
* - **记录**:每一条都进内存账本,带时间/类型/消息/是否致命,供 `status()` 暴露
|
|
30
|
-
* (「及时阻止」的前提是能看见);
|
|
31
|
-
* - **保持存活**:`unhandledRejection` 与单次 `uncaughtException` 都只记录并继续跑,
|
|
32
|
-
* 不退出。这把「插件的偶发 bug」和「DSH 崩溃」解耦了;
|
|
33
|
-
* - **熔断**:只有 `uncaughtException` 计入致命计数,且**必须落在时间窗内**。
|
|
34
|
-
* 超过 `windowMs` 没再犯就重新计数——这样捕获的是「崩溃循环」(秒级连炸),
|
|
35
|
-
* 而不是「跑三个月偶发 5 次」的正常长驻。真正熔断时再主动退出,交给
|
|
36
|
-
* `Restart=on-failure` / 桌面端重启拉起,拿到一个干净的新进程。
|
|
37
|
-
*
|
|
38
|
-
* ## 为什么把状态机和进程接线拆开
|
|
39
|
-
*
|
|
40
|
-
* `createGuard()` 是纯状态机(时间/日志/退出都注入),测试可以完全确定性地驱动它,
|
|
41
|
-
* 不必真的往 `process` 上挂处理器、也不会在跑测试时把测试进程搞死;
|
|
42
|
-
* `installProcessGuard()` 才是那层薄薄的进程接线。这个拆分是本模块能被守卫测试
|
|
43
|
-
* 覆盖的前提。
|
|
12
|
+
* 策略:全部记入内存账本并保持存活;只有 uncaughtException 计入熔断,且必须落在时间窗
|
|
13
|
+
* 内连发到 `fatalCount` 才主动退出,交给 `Restart=on-failure` 拉起。`createGuard()` 是
|
|
14
|
+
* 纯状态机,`installProcessGuard()` 才是进程接线,拆分是为了能被守卫测试覆盖。
|
|
44
15
|
*/
|
|
45
16
|
|
|
46
17
|
const FATAL_FAULT_COUNT = 5;
|
|
@@ -187,9 +158,8 @@ function activeGuard() { return installed; }
|
|
|
187
158
|
/**
|
|
188
159
|
* 把一个可能抛/可能 reject 的调用包成「永不把异常带出去」的函数。
|
|
189
160
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* 也再也不会有一处忘了 catch 就把 DSH 带走。
|
|
161
|
+
* 插件里大量 fire-and-forget 的调用点可以套这一层,不必各自 try/catch,也就不会
|
|
162
|
+
* 因为漏了一处 catch 把 DSH 带走。
|
|
193
163
|
*
|
|
194
164
|
* @param {Function} fn 原始函数
|
|
195
165
|
* @param {string} [label] 出错时用于定位的来源名
|
package/lib/host-patches.cjs
CHANGED
|
@@ -8,23 +8,12 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* 摘掉「按裸包名挂载本插件」的 patch 行。
|
|
10
10
|
*
|
|
11
|
-
* `cordis.patch.yml`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* `cordis.patch.yml` 里那行 `insert: [{ id: dsh-mobile, name: 'dsh-mobile-plugin' }]` 是
|
|
12
|
+
* 给桌面端用的:插件管理器把包装进 profile 的 `node_modules`,Loader 从那里按包名解析。
|
|
13
|
+
* 无头宿主没有那次安装,解析不到包名时 loader 会让整棵插件树加载失败,而不是留一条警告。
|
|
14
|
+
* 宿主自己用绝对路径另插一行 `mobile-api`,所以这行在这里只有害处。
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
* `node_modules` —— 于是这一行解析不到。而 loader 对解析失败**不是**留一条警告,是整棵树
|
|
17
|
-
* 加载失败:
|
|
18
|
-
*
|
|
19
|
-
* dsh-mobile-plugin: boot failed: Error: dsh-mobile-plugin: plugin tree failed to load:
|
|
20
|
-
* failed to apply loader entry include (cordis:include): failed to import loader entry
|
|
21
|
-
* dsh-mobile (dsh-mobile-plugin): Cannot find package 'dsh-mobile-plugin'
|
|
22
|
-
* code: 'ERR_MODULE_NOT_FOUND'
|
|
23
|
-
*
|
|
24
|
-
* (实测:服务器宿主从引入这一行的提交起就再也起不来了。)宿主自己用绝对路径另插一行
|
|
25
|
-
* `mobile-api` 挂载本插件,所以这一行在这里只有害处。
|
|
26
|
-
*
|
|
27
|
-
* 不改 `cordis.patch.yml` 本身:桌面端还要靠它。只在无头宿主装载前从内存里摘掉。
|
|
16
|
+
* 不改 `cordis.patch.yml` 本身(桌面端还要靠它),只在无头宿主装载前从内存里摘掉。
|
|
28
17
|
*
|
|
29
18
|
* @param {Array} patches 已加载的 patch 层(loadOverlayPatches 的返回值)
|
|
30
19
|
* @param {string} binName 本插件的包名
|
package/lib/http/routing.js
CHANGED
|
@@ -3,14 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 插件 HTTP 路由基建:控制面与移动端两套守卫的共用地基。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 恰好是最该有测试的地方:它们挡的是「谁能调控制面」「谁能调移动端」,写错一行的
|
|
9
|
-
* 后果是鉴权被绕过而不是某个页面显示不出来。
|
|
10
|
-
*
|
|
11
|
-
* 抽出来单独放一个文件是为了让它们能被直接 require 并喂假 req/res 测试。抽出后
|
|
12
|
-
* 两套守卫并排放在一个文件里,肉眼就能对照出「控制面多挡了跨站与 method,移动端只
|
|
13
|
-
* 挡 token」这个刻意的不对称。
|
|
6
|
+
* 都是只依赖 req/res 的纯函数,抽到这里才能直接 require 并喂假 req/res 测试。它们挡的是
|
|
7
|
+
* 「谁能调控制面」「谁能调移动端」,写错一行的后果是鉴权被绕过,不是页面显示不出来。
|
|
14
8
|
*/
|
|
15
9
|
|
|
16
10
|
const net = require('node:net');
|
|
@@ -123,29 +117,15 @@ const localOnly = (method, handler) => async (req, res) => {
|
|
|
123
117
|
|
|
124
118
|
const readJson = (req) => readJsonBody(req, 256 * 1024).then(body => body || {})
|
|
125
119
|
|
|
126
|
-
/**
|
|
127
|
-
* 移动端路由守卫:token + 把 handler 抛出的异常统一收成 500 信封。
|
|
128
|
-
*
|
|
129
|
-
* 与 localOnly 的不对称是刻意的:移动端请求本来就要从公网进来,跨站不是它的风险;
|
|
130
|
-
* 它的风险是「没带 token 也能读别人的会话」,所以只校验 token。
|
|
131
|
-
*
|
|
132
|
-
* @param {object} ctx 插件 ctx,透传给 handler
|
|
133
|
-
* @param {(req: object) => boolean} authorized 移动 token 校验,由调用方注入
|
|
134
|
-
* @param {() => void} [note] 通过 token 校验之后叫一声:宿主用它记「手机来过」的时刻,
|
|
135
|
-
* 引导最后一页据此说「手机已经连上了」。**只在校验通过之后叫**,401 那条不算——
|
|
136
|
-
* 否则随便谁打一下也能把面板骗成「连上了」。
|
|
137
|
-
* @returns {(handler: (ctx: object, req: object, res: object) => Promise<unknown>) => (req: object, res: object) => Promise<unknown>}
|
|
138
|
-
*/
|
|
139
120
|
/**
|
|
140
121
|
* 移动端路由守卫:token + 协议 v2 信封 + 把 handler 抛出的异常统一收成 500 信封。
|
|
141
122
|
*
|
|
142
123
|
* 与 localOnly 的不对称是刻意的:移动端请求本来就要从公网进来,跨站不是它的风险;
|
|
143
124
|
* 它的风险是「没带 token 也能读别人的会话」,所以只校验 token。
|
|
144
125
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* 因为 App 必须先在解不开信封的情况下读懂它。
|
|
126
|
+
* 信封顺序定死:鉴权(token)→ 验签 → 重放 → 解封请求体 → 业务 handler → 封响应。
|
|
127
|
+
* 鉴权在最前,因为「这台设备是谁」决定拿哪把密钥验签;鉴权自身失败回明文 401,
|
|
128
|
+
* App 必须能在解不开信封时读懂它。
|
|
149
129
|
*
|
|
150
130
|
* @param {object} ctx 插件 ctx,透传给 handler
|
|
151
131
|
* @param {(req: object) => boolean} authorized 移动 token 校验,由调用方注入
|
|
@@ -251,8 +231,8 @@ const createGuarded = (ctx, authorized, note = null, extra = {}) => {
|
|
|
251
231
|
const sealedPayload = sealResponse(req, { statusCode: chosenStatus }, sealed.keys, sealed.req, parsed)
|
|
252
232
|
// **封完直接发,不再 gzip**:`content-encoding` 只能在头出去之前声明,而这个
|
|
253
233
|
// 钩子跑在 `sendJson` 的 `writeHead` 之后,`setHeader` 会被静默忽略 —— 结果
|
|
254
|
-
// 是「线上是 gzip、头却说 identity」,客户端解不开,报「请求失败 (HTTP 200)
|
|
255
|
-
//
|
|
234
|
+
// 是「线上是 gzip、头却说 identity」,客户端解不开,报「请求失败 (HTTP 200)」。
|
|
235
|
+
// 信封是 base64 密文,外层压缩本来也只省几个百分点;
|
|
256
236
|
// 要省带宽靠载荷裁剪,不靠这一层。
|
|
257
237
|
payload = JSON.stringify(sealedPayload)
|
|
258
238
|
}
|
package/lib/impl.js
CHANGED
|
@@ -217,16 +217,14 @@ function pairingPayload(state, lanPort) {
|
|
|
217
217
|
// 「手机上最近来过没有」同理,住在 `./mobile/activity.cjs`(守卫记、控制面状态读)。
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
* 运行时是否自带 `@deepseek-ai/dsh-host-apiproxy
|
|
220
|
+
* 运行时是否自带 `@deepseek-ai/dsh-host-apiproxy`(`api-gateway` 那个入口)。
|
|
221
221
|
*
|
|
222
|
-
* 从本模块所在位置解析,而不是读 DSH 版本号:桌面端插件装在
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* 无头宿主直接跑在插件包里,包内 `node_modules` 有整套 `@deepseek-ai/*` → 解析得到
|
|
226
|
-
* → 用宿主自己那份。解析失败只是「没有」,不该把插件带崩。
|
|
222
|
+
* 从本模块所在位置解析,而不是读 DSH 版本号:桌面端插件装在 profile 的 node_modules 里,
|
|
223
|
+
* 往上碰不到 DSH 的依赖树(在 app.asar 里),解析不到就用兼容桥;无头宿主跑在插件包里,
|
|
224
|
+
* 包内就有整套 `@deepseek-ai/*`,解析得到就用宿主自己那份。解析失败只是「没有」。
|
|
227
225
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
226
|
+
* 模块顶层就被 `export const inject` 用了一次,所以必须是函数声明(提升),不能写成 `const`
|
|
227
|
+
* 箭头函数 —— 那会撞上 TDZ。
|
|
230
228
|
*/
|
|
231
229
|
function runtimeProvidesApiProxy() {
|
|
232
230
|
try {
|
package/lib/mirror/pusher.cjs
CHANGED
|
@@ -3,25 +3,11 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* 镜像推送调度器:把宿主的会话状态推到中转,手机端才看得到列表与消息。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 三条不能丢的约束:隧道没连上就不构建快照(否则 503 刷屏,真故障被淹没);空清单/空快照
|
|
7
|
+
* 不许广播(「没取到」与「被删光」形状相同,放行会让手机侧整行消失且零报错);推送成功后
|
|
8
|
+
* 先 `sessionSignal.bump` 再 `announceSessionsRevision`,顺序反了手机会来取上一份镜像。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* 1. 隧道没连上就不构建快照——worker 的 `/tunnel/send` 在未连接时直接回 503,旧实现
|
|
12
|
-
* 照推不误,500ms 一个 tick 完整构建五份快照再撞一次 503,宿主日志被
|
|
13
|
-
* 「sidecar facade request failed: 503」刷屏(约 2 行/秒),真故障信号被淹没。
|
|
14
|
-
* 2. 空工作区/空会话快照不许广播——「没取到」和「真被删光」在单份快照上都是
|
|
15
|
-
* { ok: true, items: [] },直接放行会让手机侧工作区整行消失、会话全塌进「未分组」,
|
|
16
|
-
* 且中转只附一个 App 不看的 stale 标记,全程零报错。
|
|
17
|
-
*
|
|
18
|
-
* 注意扩展名是 .cjs:包根声明 type=module,而 lib/ 下只有 shared / mobile / relay /
|
|
19
|
-
* routes / http 这几个子目录放了自己的 {"type":"commonjs"},本目录(lib/mirror/)没有 ——
|
|
20
|
-
* 所以放这里的模块必须是 .cjs,才能被同为 CJS 的 mobile/* 与 shared/* 直接 require。
|
|
21
|
-
*
|
|
22
|
-
* 推送成功后要做两件广播(顺序不能反):先进 sessionSignal.bump(正文水位,驱动手机补拉
|
|
23
|
-
* 没打开的会话),再 announceSessionsRevision(清单 revision,驱动列表刷新)。镜像先落地、
|
|
24
|
-
* 广播后发出——反过来的话手机会立刻来取,而取的还是上一份镜像。
|
|
10
|
+
* 必须是 .cjs:包根声明 type=module,而 lib/mirror/ 没有自己的 `{"type":"commonjs"}`。
|
|
25
11
|
*/
|
|
26
12
|
|
|
27
13
|
const path = require('node:path');
|
|
@@ -43,12 +29,9 @@ const BG_TICK_INTERVAL_MS = 500;
|
|
|
43
29
|
/**
|
|
44
30
|
* 没人看的时候,后台 tick 降到这个节拍。
|
|
45
31
|
*
|
|
46
|
-
* 500ms
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* 上限由 sidecar 的快照 TTL 卡着:镜像快照 15s 过期,降频必须明显小于它,
|
|
51
|
-
* 取 5s(= TTL/3,`test/mirror-tick-budget.test.cjs` 钉着这条比例)。
|
|
32
|
+
* 500ms 是给「手机正在用」准备的:清单或工作区一变,半秒内就该看到。没人看时每 500ms
|
|
33
|
+
* 整轮重建并推一次纯属空转。上限由 sidecar 的 15s 快照 TTL 卡着,取 5s(= TTL/3,
|
|
34
|
+
* `test/mirror-tick-budget.test.cjs` 钉着这条比例)。
|
|
52
35
|
*/
|
|
53
36
|
const IDLE_TICK_INTERVAL_MS = 5_000;
|
|
54
37
|
/**
|
|
@@ -62,21 +45,13 @@ const AUDIENCE_IDLE_MS = 30_000;
|
|
|
62
45
|
/**
|
|
63
46
|
* 冷会话轮转的最小间隔(清单/工作区都没变时才生效)。
|
|
64
47
|
*
|
|
65
|
-
* 500ms 的 tick 不能省:清单与工作区的内容指纹就是这个节奏读宿主才发现的,
|
|
66
|
-
*
|
|
67
|
-
* lib/relay/lan-proxy.js 的 ?fresh=1、lib/mirror-snapshot.cjs 的 15s TTL)也押在它上面。
|
|
48
|
+
* 500ms 的 tick 不能省:清单与工作区的内容指纹就是这个节奏读宿主才发现的,App 侧
|
|
49
|
+
* 「镜像正常最多旧 500ms」的新鲜度契约也押在它上面。
|
|
68
50
|
*
|
|
69
|
-
* 但 tick
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* 按定义(isHighPriority 之外:没在跑、5 分钟没动过)正文不可能变,每 500ms 重做一遍
|
|
74
|
-
* 纯属白烧宿主 CPU/内存 —— 真机事故里宿主被内存压力拖慢时,这一路正是 504 的主要来源。
|
|
75
|
-
*
|
|
76
|
-
* 所以:清单或工作区指纹一变就照旧立刻整轮重建并推送(新鲜度分毫不动),两者都没变时
|
|
77
|
-
* 轮转退到 5s 一次,它只剩「把还没热身过的冷会话正文补进中转镜像」这一个作用
|
|
78
|
-
* (N 个冷会话最迟 N × 5s 补齐)。5s 必须明显小于 lib/mirror-snapshot.cjs 的
|
|
79
|
-
* MIRROR_SNAPSHOT_TTL_MS(15s),否则 sidecar 的镜像快照会被判过期。
|
|
51
|
+
* 但 tick 里最贵的是轮转冷会话:合成 summary 让每轮指纹都不同,session-mirror 的缓存
|
|
52
|
+
* 永不命中,每 500ms 真的去拉 200 条消息,而冷会话的正文按定义不会变。所以两个指纹都没变
|
|
53
|
+
* 时轮转退到 5s 一次,只留「把冷会话正文补进镜像」这一个作用。5s 必须明显小于
|
|
54
|
+
* `MIRROR_SNAPSHOT_TTL_MS`(15s),否则 sidecar 的镜像快照会被判过期。
|
|
80
55
|
*/
|
|
81
56
|
const BG_COLD_ROTATE_MIN_INTERVAL_MS = 5000;
|
|
82
57
|
const MAX_CONCURRENT_SESSION_BUILDS = 2;
|
|
@@ -233,19 +208,10 @@ function createMirrorPusher(deps) {
|
|
|
233
208
|
/**
|
|
234
209
|
* 「这些会话的正文又长了」的唯一广播点。
|
|
235
210
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* - 隧道断开(`!tunnelConnected()`)或纯局域网模式下 pushMirrorPayload 会短路,旧实现
|
|
241
|
-
* 把广播放在成功分支里,于是**一帧水位都不发** —— 手机侧非打开会话的「有新内容」
|
|
242
|
-
* 提示只能靠 60s 兜底轮询或用户点开,正好是「点开对话要等一会儿才看到内容」的来源。
|
|
243
|
-
* - 内容未变(指纹相同)时同样不推,但快照里的水位可能已经涨过(上一帧失败过、或
|
|
244
|
-
* 这一帧由 pushMirror 用更新的事件重建)—— 这里补一次广播是幂等的。
|
|
245
|
-
*
|
|
246
|
-
* 幂等由 sessionSignal 保证(同会话水位不变或更低时不广播),所以即使在每 500ms 的
|
|
247
|
-
* tick 上调用也不会变成刷新风暴。顺序上「先落地后广播」在局域网下天然成立:正文读的
|
|
248
|
-
* 就是本进程同一份会话历史。
|
|
211
|
+
* 广播的不是「镜像里有新正文」,而是「本机这份会话的正文到了这个水位」——手机拿水位和
|
|
212
|
+
* 自己的缓存一比就知道该不该增量拉。所以它与「镜像有没有推成功」是两件事:隧道断开或
|
|
213
|
+
* 纯局域网模式下推送会短路,广播绑在成功分支里就一帧水位都发不出去,非打开会话只能靠
|
|
214
|
+
* 60s 兜底轮询。内容没变时补一次广播是幂等的,sessionSignal 保证水位不变或更低时不发。
|
|
249
215
|
*/
|
|
250
216
|
const announceSessionActivity = (messages) => {
|
|
251
217
|
if (!messages || typeof messages !== 'object') return;
|
|
@@ -331,11 +297,8 @@ function createMirrorPusher(deps) {
|
|
|
331
297
|
/**
|
|
332
298
|
* 镜像清单本身的规模(自检台账用):会话总数、其中活跃的、其中子会话。
|
|
333
299
|
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
* 在重建,顺手记三个计数不额外花销。
|
|
337
|
-
*
|
|
338
|
-
* 只在真拿到清单时更新:拿不到时保留上一次的读数(连带 `at`),与「清单突然变成 0」区分开。
|
|
300
|
+
* 这是插件侧能诚实报出的那部分规模:宿主内存里挂着多少会话、正文占多少,插件看不到。
|
|
301
|
+
* 只在真拿到清单时更新,拿不到时保留上一次读数,与「清单突然变成 0」区分开。
|
|
339
302
|
*/
|
|
340
303
|
let mirrorListSessions = 0;
|
|
341
304
|
let mirrorListActiveSessions = 0;
|
|
@@ -402,20 +365,12 @@ function createMirrorPusher(deps) {
|
|
|
402
365
|
/**
|
|
403
366
|
* 构建一个会话的镜像数据(正文快照 + 统计投影 + 模型列表)。
|
|
404
367
|
*
|
|
405
|
-
* `summary`
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
* (isHighPriority)用的就是这个字段,不能在这里改口径。
|
|
368
|
+
* `summary` 必须是清单里的那一行(或它的 Promise):`session-mirror.js` 的后台缓存指纹
|
|
369
|
+
* 就是 `updatedAt:running|idle:limit`,硬塞 `Date.now()` 会让 2 分钟 TTL 永不命中。
|
|
370
|
+
* `running` 如实取清单值,高优先判定用的就是它。传 Promise 是为了与清单构建并行。
|
|
409
371
|
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
* `reuseCache` 默认 true,但**事件驱动路径必须传 false**:那个缓存只认「宿主抬了
|
|
414
|
-
* updatedAt」这一种内容变化。一条新消息落盘而清单摘要还没变(或宿主不按消息抬
|
|
415
|
-
* updatedAt)时,缓存会把上一份正文当成这一份发出去 —— device-session-activity 的守卫
|
|
416
|
-
* 钉的正是这个场景(新消息必须广播新水位,见 test/device-session-activity.test.cjs)。
|
|
417
|
-
* 实现方式是 session-mirror 的 `noCache`(既不读也不写缓存),不是伪造 updatedAt。
|
|
418
|
-
* 收益落在「没人动的冷会话轮转」:那里每 5s 重建 200 条正文才是成本大头。
|
|
372
|
+
* `reuseCache` 默认 true,但事件驱动路径必须传 false:缓存只认「updatedAt 抬了」这一种
|
|
373
|
+
* 变化,新消息落盘而摘要未变时会把上一份正文当成这一份发出去(`noCache` 既不读也不写缓存)。
|
|
419
374
|
*/
|
|
420
375
|
const buildSessionDataUnlocked = async (sessionId, summaryOrPromise, { reuseCache = true } = {}) => {
|
|
421
376
|
const summary = summaryOrPromise && typeof summaryOrPromise.then === 'function'
|
|
@@ -425,19 +380,13 @@ function createMirrorPusher(deps) {
|
|
|
425
380
|
// 缓存指纹,不影响正文内容本身。
|
|
426
381
|
const updatedAt = (summary && summary.updatedAt) || Date.now();
|
|
427
382
|
/**
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
* (`session/agent-busy: subagent Sessions require their durable parent address`),
|
|
432
|
-
* 而镜像这份正文手机本来就用不到:手机打开子代理读的是子代理那条专用通道(带父会话
|
|
433
|
-
* 地址与 mode,见 lib/mobile/subagents.js 的文件头)。以前每次都照读、每次都被拒,
|
|
434
|
-
* 于是 5 秒一次的冷轮转里固定夹着一次注定失败的读取,`skippedBodies` 一路涨
|
|
435
|
-
* (真机实测 4 分钟 408 → 451),日志里那条 skip 也永远说不清到底哪坏了。
|
|
383
|
+
* 子会话的正文刻意不建:普通历史接口读子会话会被宿主按 durable parent address 拒掉
|
|
384
|
+
* (`agent-busy: subagent Sessions require their durable parent address`),而手机打开
|
|
385
|
+
* 子代理读的是专用通道(见 lib/mobile/subagents.js),这份正文本来就用不到。
|
|
436
386
|
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* 「正文缺席」那条路径完全一致。
|
|
387
|
+
* 提前认出清单行上的 `origin === 'subagent'` 就不发那次请求,且 skipReason 留空:它不是
|
|
388
|
+
* 失败,不该进 `skippedBodies`。清单行、stats、models 照旧构建,载荷形状与「正文缺席」
|
|
389
|
+
* 那条路径一致。
|
|
441
390
|
*/
|
|
442
391
|
if (summary && summary.origin === 'subagent') {
|
|
443
392
|
const [stats, models] = await Promise.all([
|
|
@@ -683,10 +632,9 @@ function createMirrorPusher(deps) {
|
|
|
683
632
|
// 清单会一直挂到 15s TTL 过期,手机上已删的会话最长还能看到十几秒;而且这条路径
|
|
684
633
|
// 完全不记账,运维看不出镜像为什么不动。
|
|
685
634
|
//
|
|
686
|
-
// 跨包契约:中转侧对「空覆盖非空」有同口径保护,只认 emptyConfirmed === true
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
// 原样 body,天然带这个字段。
|
|
635
|
+
// 跨包契约:中转侧对「空覆盖非空」有同口径保护,只认 emptyConfirmed === true 的空
|
|
636
|
+
// 清单(中转的镜像状态里同一条谓词)。这里推的是 buildSessionsResponse 的原样 body,
|
|
637
|
+
// 天然带这个字段。
|
|
690
638
|
const workspacesBodyEmpty = await workspaces.buildWorkspacesBody(ctx).then(observeWorkspaces);
|
|
691
639
|
const wsBlockedEmpty = guardWorkspaces(workspacesBodyEmpty);
|
|
692
640
|
if (wsBlockedEmpty) {
|
package/lib/mirror-snapshot.cjs
CHANGED
|
@@ -1,33 +1,15 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// (与 reconnect-scheduler.cjs 同样的做法),而不是把判定散在 sidecar 的读取函数里。
|
|
3
|
+
// 桌面推送的「移动端镜像快照」新鲜度判定。独立成模块是为了能被 test/ 直接驱动。
|
|
5
4
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// - 它依赖的 sidecar `/tunnel/send` 自己也要求 `tunnel && tunnel.connected`,否则 503
|
|
10
|
-
// (sidecar-facade-worker.cjs:186-190)。
|
|
11
|
-
// 于是「中转断掉、局域网仍然健康」时快照被永久冻结在最后一个版本,而 snapshotRead 依旧把
|
|
12
|
-
// 它当权威读:新建的会话/工作区在 App 里**永远不出现**。实测证据(Mac 宿主,kill 掉中转后
|
|
13
|
-
// 走局域网新建并发送):
|
|
14
|
-
// POST /session/new → session-1c9b048a-…,POST /session/send → {"ok":true,"accepted":true}
|
|
15
|
-
// GET /sessions → count 9(与 kill 之前一模一样),新会话 absent,+70s 仍 absent
|
|
16
|
-
// GET /app → 设备侧同样看不到
|
|
17
|
-
// GET /session/messages?sessionId=<新的> → 能读到 user 'SNAPSHOT-STALE-1'
|
|
18
|
-
// 最后一条说明回源主进程的通道是好的(快照里没有这个 sessionId 就自然回源),坏的只是
|
|
19
|
-
// 「列表类读被冻结的镜像顶掉」。
|
|
20
|
-
//
|
|
21
|
-
// 修法:把镜像当**短时代读缓存**,不是权威存储——超过 TTL 就当作缺失,让调用方回源主进程。
|
|
22
|
-
// 隧道连着时快照每几百毫秒就被重推一次(processBgQueue 的 500ms tick),所以 15s 足够长,
|
|
23
|
-
// 快路径照常生效;一旦中转断开,最多 15s 后所有读都回源,数据重新变准。
|
|
5
|
+
// 镜像只是**短时代读缓存**,不是权威存储:快照只能靠隧道推来,中转断开而局域网仍健康时
|
|
6
|
+
// 它会冻在最后一个版本,列表读于是把新建的会话/工作区一直藏住。超过 TTL 就当作缺失,
|
|
7
|
+
// 让调用方回源主进程。隧道连着时快照每几百毫秒重推一次,15s 足够长,快路径照常生效。
|
|
24
8
|
|
|
25
9
|
const MIRROR_SNAPSHOT_TTL_MS = 15_000
|
|
26
10
|
|
|
27
|
-
// 只有带可信时间戳 `at` 且在 TTL
|
|
28
|
-
//
|
|
29
|
-
// `at: Date.now()`,没有 `at` 说明这不是预期的镜像载荷,宁可回源也不要拿它当权威。
|
|
30
|
-
// 时间戳来自同一台机器,不存在跨机时钟偏移,所以未来时间戳按不可信处理。
|
|
11
|
+
// 只有带可信时间戳 `at` 且在 TTL 内的快照才可用。缺 `at` 的载荷按陈旧处理:预期的镜像载荷
|
|
12
|
+
// 一定写了 `at`,没有就说明来路不对,宁可回源。时间戳来自同一台机器,未来时间戳按不可信处理。
|
|
31
13
|
function isMirrorSnapshotFresh(snapshot, now = Date.now()) {
|
|
32
14
|
if (!snapshot || typeof snapshot !== 'object') return false
|
|
33
15
|
const at = snapshot.at
|
|
@@ -1,34 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* 移动端「Agent 预设管理」API
|
|
4
|
+
* 移动端「Agent 预设管理」API:把桌面端设置页的预设管理分区补齐到手机上。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* AgentPresetChip(list + select),现已整颗删除 —— 手机端一律吃宿主默认档,
|
|
9
|
-
* `POST /session/select-agent-preset` 路由也一并删除(消费端没了就是死路由)。
|
|
10
|
-
* 2. 会话标题旁的只读标签 → 手机:随 1 一起下线
|
|
11
|
-
* 3. 名单本身 → 手机:GET /agent-presets
|
|
12
|
-
* 4. **管理分区**:名单卡片 + 只读查看器 + 复制对话框 + 删除确认
|
|
13
|
-
* —— 这才是现在手机上唯一与预设有关的界面(app 的 PresetManager)。
|
|
6
|
+
* 手机端只保留管理分区(app 的 PresetManager):桌面那边设置里的一行与新建会话芯片已下线,
|
|
7
|
+
* 手机一律吃宿主默认档。三条路由都只转发宿主的 agentPreset 域:read / copy / remove。
|
|
14
8
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* - POST /agent-presets/copy { from, agentPreset, name? } → agentPreset.copy
|
|
19
|
-
* - POST /agent-presets/remove { agentPreset } → agentPreset.remove
|
|
20
|
-
*
|
|
21
|
-
* 语义按宿主原文透传,不在这里自作主张:
|
|
22
|
-
* - copy 是「整份复制」,标识符会成为目录名且事后无法更改;宿主自己会重校验 id
|
|
23
|
-
* 规则与重名(PresetExistsError → agent-preset-invalid),这里只做便利检查。
|
|
24
|
-
* - remove 只对 user 预设有效:随附预设宿主回 agent-preset-read-only,删不掉。
|
|
25
|
-
* - openDocument 不做。它打开的是**宿主机器**上的目录(与 host.openPath 同性质),
|
|
26
|
-
* 手机点了也看不见;桌面端在没有打开器时(hasDocument:false)本来就是把目录
|
|
27
|
-
* 当文本显示,手机端照此办理即可,不需要这条 RPC。
|
|
28
|
-
*
|
|
29
|
-
* 宿主的失败对象是「code + details」:not-found 带 available[]、read-only 带 reason、
|
|
30
|
-
* invalid 带 reason(可能是 id 非法,也可能是重名),都不是给用户看的文案;
|
|
31
|
-
* 按 code 说中文,同时把原文留在 details.hostMessage 里备查。
|
|
9
|
+
* 语义按宿主原文透传:copy 是整份复制,标识符成为目录名且事后无法更改;remove 只对 user
|
|
10
|
+
* 预设有效(随附预设回 agent-preset-read-only)。宿主的失败对象是 code + details,按 code
|
|
11
|
+
* 说中文,原文留在 details.hostMessage。
|
|
32
12
|
*/
|
|
33
13
|
|
|
34
14
|
const { sendJson, readJsonBody } = require('../shared/http-util');
|