dsh-app-updater 1.3.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [1.3.1] — 2026-11-01
6
+
7
+ ### 安全
8
+
9
+ - **「仅凭回环地址放行」改为默认拒绝**(插件市场 dshopen.org 对 1.3.0 的复核意见):
10
+ 1.3.0 接入 `connection` 准入后仍留了一条退回路径 —— 宿主没有 `connection` 服务,或请求
11
+ 不带任何连接凭据且来自回环地址时,直接按「本机信任」放行。在没有准入服务的旧宿主上,
12
+ 任何本机进程(以及能向回环发起请求的页面)都能借此调用 `/update`、`/settings` 等安装接口,
13
+ 等于绕过认证。1.3.1 起这条路径**默认关闭**:
14
+ - 宿主有 `connection`:一律以 `admit()` 的结果为准,`401` / `403` / `409` 原样返回
15
+ (响应头 `X-DSH-Admission: denied`)。只有「401 + 回环 + 无凭据 + 用户显式开启兼容模式」
16
+ 这一种例外,且必须在下面第 2 条的前提下才成立。
17
+ - 宿主没有 `connection`:**默认 403**,不再自动相信回环地址。
18
+ - **兼容模式必须由用户显式开启**(保留给确实跑在旧宿主上的用户):插件配置
19
+ `allowLegacyLoopback: true`(`cordis.patch.yml` 覆盖同名条目)或环境变量
20
+ `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`,两者默认都没有。开启后:
21
+ - 放行的请求带 `X-DSH-Admission: loopback-legacy-optin` 与 `X-DSH-Admission-Warning`;
22
+ - `state.json` / `health.json` 新增 `admission` 字段(`mode` / `service` /
23
+ `allowLegacyLoopback` / `warning`),复核时不必读代码就能确认当前路径;
24
+ - 设置页顶部显示红色警示条,日志写一条 `warn`。
25
+ - **准入拒绝统一返回 JSON**:`401`/`403`/`409` 带 `{ ok:false, error, admission:"denied" }`,
26
+ 不再用纯文本,排障时能直接看出是被准入挡掉的。
27
+
28
+ ### 测试
29
+
30
+ - `test/unit.mjs`:准入用例从 6 项扩到 25 项,覆盖兼容模式开关(配置 / 环境变量 / 缺省关闭)、
31
+ 无 `connection` 服务默认拒绝、显式开启后放行、非回环仍拒绝、`409`、带凭据未认证不放行等。
32
+ - 新增 `test/deny.mjs`:起真实 HTTP 服务,验证无 `connection` 且未开兼容模式时**每一条路由**
33
+ 都是 `403 + X-DSH-Admission: denied`;再验证显式开启后放行并标注 opt-in 响应头。
34
+ - `test/render.mjs`:新增兼容模式警示条 4 项(开启显示、样式、文案、默认不显示)。
35
+ - 自测命令更新为 `node test/unit.mjs && node test/render.mjs && node test/deny.mjs`。
36
+
5
37
  ## [1.3.0] — 2026-10-31
6
38
 
7
39
  ### 修复
package/CONTRACT.md CHANGED
@@ -41,10 +41,14 @@ dsh-app-updater/
41
41
 
42
42
  全部 `kind: "exact"`,前缀 `/dsh-app-updater/`。每个请求先过 **connection 准入**:
43
43
  `ctx.connection.admit(req)`(DSH 的 Host/Origin 信任校验 + 浏览器会话 / API 认证,
44
- 宿主自己的 `/api` 路由用的同一套),被拒即返回 `401`(未认证)/ `403`(不可信来源);
45
- 写操作再要求 `confirm: true`。
46
- 宿主没有 `connection` 服务、或请求不带任何连接凭据且来自回环地址时,退回旧的「回环」检查,
47
- 并在响应头标注 `X-DSH-Admission: loopback-legacy`(`lib/index.js` 的 `admissionGate`)。
44
+ 宿主自己的 `/api` 路由用的同一套),被拒即返回 `401`(未认证)/ `403`(不可信来源)/
45
+ `409`(连接被占用),响应头带 `X-DSH-Admission: denied`;写操作再要求 `confirm: true`。
46
+
47
+ **默认拒绝**(1.3.1 起):宿主没有 `connection` 服务时不再「仅凭回环地址放行」,而是 `403`。
48
+ 只有用户显式开启兼容模式(插件配置 `allowLegacyLoopback: true` 或环境变量
49
+ `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`)时,回环请求才放行,并带
50
+ `X-DSH-Admission: loopback-legacy-optin` 与 `X-DSH-Admission-Warning`(见 `lib/index.js` 的
51
+ `admissionGate` / `resolveLegacyLoopbackOptIn`)。
48
52
 
49
53
  ### GET /dsh-app-updater/state.json
50
54
 
@@ -62,7 +66,9 @@ dsh-app-updater/
62
66
  "update": { "running": false, "total": 0, "done": 0, "current": null, "error": null },
63
67
  "apps": { "scannedAt": 0, "stats": { "total": 0, "upgradable": 0, "unsupported": 0 }, "items": [], "engine": "winget" },
64
68
  "log": [ { "at": 0, "level": "info|warn|error|success", "text": "..." } ],
65
- "settings": { "includeStoreApps": true, "includeRegistryOnly": true, "downloadDir": "", "keepInstallers": true, "hashFallbackUpgrade": true, "dryRun": false, "matchSystemArch": true, "autoElevate": true }
69
+ "settings": { "includeStoreApps": true, "includeRegistryOnly": true, "downloadDir": "", "keepInstallers": true, "hashFallbackUpgrade": true, "dryRun": false, "matchSystemArch": true, "autoElevate": true },
70
+ // 准入现状,供界面与复核确认当前走哪条路径(默认 "deny" 不会出现在能读到响应的地方)
71
+ "admission": { "mode": "connection|connection+loopback-legacy|loopback-legacy-optin|deny", "service": true, "allowLegacyLoopback": false, "warning": null }
66
72
  }
67
73
  ```
68
74
 
package/README.en.md CHANGED
@@ -110,8 +110,25 @@ curl -X POST -H "Content-Type: application/json" \
110
110
 
111
111
  ## Safety boundaries
112
112
 
113
- - **Every write requires loopback + explicit confirmation**: `scan` / `update` / `cancel` /
114
- `settings` / `open-downloads` accept only requests from loopback with `confirm:true`.
113
+ - **Every route is deny-by-default**: each request first goes through `ctx.connection.admit(request)`
114
+ — DSH's Host/Origin fence plus browser-session / API authentication, the same gate the host uses for
115
+ its own `/api` routes. Rejected requests get `401` / `403` / `409` with `X-DSH-Admission: denied`.
116
+ - **Legacy loopback mode is off by default** (since 1.3.1): when the host has no `connection`
117
+ service, requests are no longer trusted just because they come from loopback — they get `403`.
118
+ If you really need it on an old host, opt in explicitly, either via plugin config:
119
+
120
+ ```yaml
121
+ - id: dsh-app-updater
122
+ config:
123
+ allowLegacyLoopback: true
124
+ ```
125
+
126
+ or by exporting `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1` before starting DSH. Once enabled, allowed
127
+ requests carry `X-DSH-Admission: loopback-legacy-optin` and `X-DSH-Admission-Warning`, the
128
+ `admission` field in `state.json` / `health.json` reports `mode: "loopback-legacy-optin"`, the
129
+ settings page shows a red banner, and a `warn` line is written to the log.
130
+ - **Every write still requires explicit confirmation**: `scan` / `update` / `cancel` /
131
+ `settings` / `open-downloads` require `confirm:true`.
115
132
  - **No guessed silent switches**: the publisher's declared `Silent` switches are preferred;
116
133
  generic defaults are used only when a manifest is unavailable.
117
134
  - **Elevation only for installs, and on demand**: install commands that need administrator rights
package/README.md CHANGED
@@ -113,14 +113,32 @@ curl -X POST -H "Content-Type: application/json" \
113
113
  `downloadDir` 会被校验:必须留空(用默认目录)或是绝对路径(`D:\Apps`)/UNC 路径(`\\server\share`),
114
114
  否则返回 `400` 并在面板里显示红字提示,不会把安装包写到意料之外的位置。
115
115
 
116
+ > 这些路由**默认拒绝**:请求必须通过宿主的 `connection` 准入(浏览器里就是带上 DSH 页面的会话
117
+ > Cookie),或在**没有 `connection` 服务的旧宿主**上由你显式开启兼容模式(见「安全边界」)。
118
+ > 仅凭「来自 127.0.0.1」不再放行 —— 1.3.1 已把这条路径改成默认拒绝。
119
+
116
120
  ---
117
121
 
118
122
  ## 安全边界
119
123
 
120
- - **接入宿主 connection 准入**:每个请求先过 `ctx.connection.admit(request)` —— DSH 用来做
124
+ - **路由默认拒绝**:每个请求先过 `ctx.connection.admit(request)` —— DSH 用来做
121
125
  Host/Origin 信任校验与浏览器会话 / API 认证的那套边界(宿主自己的 `/api` 路由用的就是它),
122
- 未通过的请求直接得到 `401`/`403`。宿主没有 `connection` 服务、或请求不带任何连接凭据且来自
123
- 回环地址时,退回旧的「回环」规则,并在响应头标注 `X-DSH-Admission: loopback-legacy`。
126
+ 未通过的请求直接得到 `401` / `403` / `409`,响应头带 `X-DSH-Admission: denied`。
127
+ - **兼容模式默认关闭**(1.3.1 起):宿主没有 `connection` 服务时**不再**「仅凭回环地址放行」,
128
+ 一律 `403`。旧宿主上确实需要时,必须由你**显式**开启,二选一:
129
+ - 插件配置(`cordis.patch.yml` 里覆盖同名条目):
130
+
131
+ ```yaml
132
+ - id: dsh-app-updater
133
+ config:
134
+ allowLegacyLoopback: true
135
+ ```
136
+
137
+ - 或启动 DSH 前设置环境变量 `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`。
138
+
139
+ 开启后:放行的回环请求带 `X-DSH-Admission: loopback-legacy-optin` 与
140
+ `X-DSH-Admission-Warning`,`state.json` / `health.json` 的 `admission` 字段会标明
141
+ `mode: "loopback-legacy-optin"`,设置页顶部也会出现红色提示条;日志里记一条 `warn`。
124
142
  - **写操作还要显式确认**:`scan` / `update` / `cancel` / `settings` / `open-downloads` 必须带 `confirm:true`。
125
143
  - **不猜测静默参数**:优先用发布方在清单里声明的 `Silent` 参数;清单缺失时才按安装包类型用保守的默认开关。
126
144
  - **需要管理员时自动提权**:机器级安装包(MSI、写入 `Program Files`)交给
package/lib/client.js CHANGED
@@ -200,6 +200,8 @@ window.__ModuleLoader__.load({
200
200
 
201
201
  /* 演练模式警示条 */
202
202
  ".appupd-banner{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:8px;border:1px solid rgba(251,191,36,.5);background:rgba(251,191,36,.12);color:#fbbf24;font-size:12px;}",
203
+ /* 兼容模式(仅回环放行)警示条:默认关闭,开启时用红色提醒 */
204
+ ".appupd-banner-warn{border-color:rgba(239,68,68,.55);background:rgba(239,68,68,.12);color:#f87171;}",
203
205
 
204
206
  /* 插件设置面板 */
205
207
  ".appupd-panel{border:1px solid var(--dsw-alias-border-l1,#4b5563);border-radius:8px;overflow:hidden;}",
@@ -927,6 +929,21 @@ window.__ModuleLoader__.load({
927
929
  )
928
930
  : null;
929
931
 
932
+ /* 兼容模式:只有用户显式开启(插件配置 / 环境变量)才会出现,默认不显示 */
933
+ const admission = (state && state.admission) || null;
934
+ const legacyOn = Boolean(admission && admission.allowLegacyLoopback);
935
+ const legacyBanner = legacyOn
936
+ ? h(
937
+ "div",
938
+ { className: "appupd-banner appupd-banner-warn", role: "alert" },
939
+ h(
940
+ "span",
941
+ null,
942
+ "兼容模式已开启:没有连接凭据的本机回环请求会被放行(旧宿主没有 connection 准入时才会用到)。非必要请关闭,见 README 的「安全」一节。"
943
+ )
944
+ )
945
+ : null;
946
+
930
947
  const switchEl = (key) => {
931
948
  const on = Boolean(settings[key]);
932
949
  return h("button", {
@@ -1195,6 +1212,9 @@ window.__ModuleLoader__.load({
1195
1212
  /* 3. 演练模式提示 */
1196
1213
  dryRunBanner,
1197
1214
 
1215
+ /* 3b. 兼容模式(仅回环放行)提示:默认不出现 */
1216
+ legacyBanner,
1217
+
1198
1218
  /* 4. 进度 */
1199
1219
  progress,
1200
1220
 
package/lib/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * dsh-app-updater 宿主半身。
3
3
  *
4
4
  * 职责:暴露 HTTP 路由给设置页 UI,串行调度「扫描」与「更新」两条后台任务,
5
- * 把进度写进持久化状态。所有写操作都要求 loopback 来源 + confirm:true。
5
+ * 把进度写进持久化状态。所有路由默认拒绝:先过宿主 connection 准入;写操作另需
6
+ * confirm:true。仅在用户显式开启兼容模式时,才按「回环地址」放行(默认关闭)。
6
7
  *
7
8
  * 设计上不依赖宿主内部服务(不用 llm / tools / agent),只依赖 webServer,
8
9
  * 因此在没有 Web 的 composition 里它会安静地不挂载任何路由。
@@ -86,46 +87,110 @@ export function hasConnectionCredentials(req) {
86
87
  }
87
88
 
88
89
  /**
89
- * connection 准入(宿主信任边界)。
90
+ * 兼容模式开关:只允许在用户**显式开启**时按「回环地址」放行。
91
+ *
92
+ * 1.3.0 在 `connection` 不可用(或没有凭据可核验)时会直接按回环放行,旧宿主上
93
+ * 等于绕过了认证就能调用安装接口 —— 市场复核要求这条路径改为默认拒绝。
94
+ * 因此 1.3.1 起默认关闭,只有下面两种显式动作之一才恢复(并会在响应头、状态接口
95
+ * 与日志里标注,方便复核):
96
+ * - 插件配置 `allowLegacyLoopback: true`(cordis patch 的 `config`)
97
+ * - 环境变量 `DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK=1`
98
+ */
99
+ export const LEGACY_LOOPBACK_ENV = "DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK";
100
+
101
+ function truthyFlag(value) {
102
+ if (value === true || value === 1) return true;
103
+ if (typeof value !== "string") return false;
104
+ return /^(1|true|yes|on)$/i.test(value.trim());
105
+ }
106
+
107
+ /** 解析兼容模式开关:插件配置优先,其次环境变量;默认(两者都没有)为 false。 */
108
+ export function resolveLegacyLoopbackOptIn(config, env = process.env) {
109
+ if (config && truthyFlag(config.allowLegacyLoopback)) return true;
110
+ return truthyFlag((env || {})[LEGACY_LOOPBACK_ENV]);
111
+ }
112
+
113
+ /** 兼容模式放行时打标记,便于复核与排障:正常准入不会出现这两个头。 */
114
+ function markLegacyAdmission(res) {
115
+ if (typeof res.setHeader !== "function") return;
116
+ res.setHeader("X-DSH-Admission", "loopback-legacy-optin");
117
+ res.setHeader("X-DSH-Admission-Warning", "legacy-loopback-compatibility-enabled");
118
+ }
119
+
120
+ /** 统一写出拒绝响应,并标明是「被拒绝」而不是「未经过闸门」。 */
121
+ function denyAdmission(res, status, error) {
122
+ if (typeof res.setHeader === "function") res.setHeader("X-DSH-Admission", "denied");
123
+ json(res, status, { ok: false, error, admission: "denied" });
124
+ }
125
+
126
+ /**
127
+ * connection 准入(宿主信任边界)。**默认拒绝**。
90
128
  *
91
129
  * DSH 的 `connection` 服务负责插件 HTTP 路由的信任校验:`admit(request)` 先做
92
130
  * Host/Origin 校验,再做浏览器会话 / API 认证,返回本次调用对应的 Peer,或用
93
- * `401`(未认证)/`403`(不可信来源)拒绝 —— 宿主自己的 `/api` 路由用的就是它。
131
+ * `401`(未认证)/`403`(不可信来源)/`409`(连接已被占用)拒绝 —— 宿主自己的
132
+ * `/api` 路由用的就是它。
94
133
  *
95
- * 退回策略(只有一条,且完全不涉及凭据):宿主没有 `connection` 服务,
96
- * 或者请求**没有携带任何连接凭据**、来自回环地址时,按旧的「回环」规则放行,
97
- * 并在响应头标注 `X-DSH-Admission: loopback-legacy`。
98
- * 带凭据的请求一律以 admission 的结果为准;非回环请求一律拒绝。
134
+ * 规则(按顺序):
135
+ * 1. 宿主有 `connection`:一律以 `admit()` 为准,通过就放行;
136
+ * 只有「401 + 回环 + 没有任何凭据 + 用户显式开启兼容模式」这一种情况例外,
137
+ * 否则按 `admit()` 给出的状态码拒绝。
138
+ * 2. 宿主没有 `connection`:**默认拒绝**(403)。只有用户显式开启兼容模式、
139
+ * 且请求来自回环地址时才放行。
99
140
  *
100
141
  * @returns {boolean} 已放行 true;已写出拒绝响应 false
101
142
  */
102
- export function admissionGate(req, res, connection) {
143
+ export function admissionGate(req, res, connection, options = {}) {
103
144
  const loopback = isLoopback(req);
145
+ const optIn = options.allowLegacyLoopback === true;
104
146
  if (connection && typeof connection.admit === "function") {
105
147
  let admission;
106
148
  try {
107
149
  admission = connection.admit(req);
108
150
  } catch (err) {
109
- json(res, 500, { ok: false, error: `连接准入失败:${String((err && err.message) || err)}` });
151
+ denyAdmission(res, 500, `连接准入失败:${String((err && err.message) || err)}`);
152
+ return false;
153
+ }
154
+ if (!admission || typeof admission !== "object") {
155
+ // 宿主实现异常时宁可拒绝,也不要把「看不懂的结果」当成放行
156
+ denyAdmission(res, 500, "连接准入返回了无法识别的结果,按默认拒绝处理");
110
157
  return false;
111
158
  }
112
- if (!admission || typeof admission.rejection !== "number") return true;
113
- if (admission.rejection === 401 && loopback && !hasConnectionCredentials(req)) {
114
- // 宿主自己的载体(Electron 经 IPC 桥接的 fetch)与本机脚本都不带 Cookie,
115
- // 没有可核验的凭据,只能按回环信任放行 —— 与接入准入之前的行为一致。
116
- if (typeof res.setHeader === "function") res.setHeader("X-DSH-Admission", "loopback-legacy");
159
+ if (!("rejection" in admission)) return true;
160
+ if (typeof admission.rejection !== "number") {
161
+ denyAdmission(res, 403, "连接准入返回了无法识别的拒绝码,按默认拒绝处理");
162
+ return false;
163
+ }
164
+ if (admission.rejection === 401 && loopback && !hasConnectionCredentials(req) && optIn) {
165
+ // 例外路径:宿主自己的载体(Electron 经 IPC 桥接的 fetch)与本机脚本都不带
166
+ // Cookie,没有可核验的凭据。只有用户显式开启兼容模式才退回回环信任。
167
+ markLegacyAdmission(res);
117
168
  return true;
118
169
  }
119
- res.writeHead(admission.rejection, { "Content-Type": "text/plain; charset=utf-8" });
120
- res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
170
+ const status = admission.rejection === 403 ? 403 : admission.rejection === 409 ? 409 : 401;
171
+ denyAdmission(
172
+ res,
173
+ status,
174
+ status === 401
175
+ ? "请求未通过 DSH connection 认证:缺少有效的会话 Cookie / API 凭据"
176
+ : status === 409
177
+ ? "请求对应的连接已被占用"
178
+ : "请求来源不受信任:Host/Origin 校验未通过"
179
+ );
121
180
  return false;
122
181
  }
123
- if (!loopback) {
124
- json(res, 403, { ok: false, error: "只接受本机请求(loopback):当前宿主没有可用的 connection 准入服务" });
125
- return false;
182
+ if (optIn && loopback) {
183
+ markLegacyAdmission(res);
184
+ return true;
126
185
  }
127
- if (typeof res.setHeader === "function") res.setHeader("X-DSH-Admission", "loopback-legacy");
128
- return true;
186
+ denyAdmission(
187
+ res,
188
+ 403,
189
+ optIn
190
+ ? "只接受本机(loopback)请求:当前宿主没有可用的 connection 准入服务"
191
+ : "拒绝访问:当前宿主没有 connection 准入服务,兼容模式(仅回环放行)未开启,按默认拒绝处理"
192
+ );
193
+ return false;
129
194
  }
130
195
 
131
196
  /**
@@ -343,16 +408,30 @@ function openFolder(path) {
343
408
 
344
409
  export default {
345
410
  name: "dsh-app-updater",
346
- apply(ctx) {
411
+ apply(ctx, config) {
412
+ // 兼容模式:默认关闭。只有用户显式写进插件配置或环境变量才开启(见 LEGACY_LOOPBACK_ENV)。
413
+ const allowLegacyLoopback = resolveLegacyLoopbackOptIn(config);
414
+
347
415
  ctx.effect(() => {
348
- store.load().catch(() => {});
416
+ store
417
+ .load()
418
+ .then(() => {
419
+ if (allowLegacyLoopback) {
420
+ store.log(
421
+ "warn",
422
+ "兼容模式(allowLegacyLoopback)已开启:没有连接凭据的回环请求会按本机信任放行;" +
423
+ "建议只在旧宿主(没有 connection 服务)上临时使用"
424
+ );
425
+ }
426
+ })
427
+ .catch(() => {});
349
428
  }, "dsh-app-updater: load state");
350
429
 
351
430
  ctx.inject(["webServer"], (webCtx) => {
352
431
  const webServer = webCtx.webServer;
353
432
 
354
433
  // connection 准入:宿主提供的信任边界(Host/Origin 校验 + 浏览器/API 认证)。
355
- // 它在宿主没有 Web 载体时不存在,此时 admissionGate 退回旧的「回环」检查。
434
+ // 它在宿主没有 Web 载体时不存在,此时 admissionGate 默认拒绝,除非兼容模式显式开启。
356
435
  let connection = null;
357
436
  ctx.inject(["connection"], (connCtx) => {
358
437
  connection = connCtx.connection;
@@ -362,6 +441,22 @@ export default {
362
441
  });
363
442
  const routes = [];
364
443
 
444
+ /** 准入现状:写进状态接口,界面/复核者不必读代码就知道当前走的是哪条路径。 */
445
+ const admissionInfo = () => ({
446
+ mode: connection
447
+ ? allowLegacyLoopback
448
+ ? "connection+loopback-legacy"
449
+ : "connection"
450
+ : allowLegacyLoopback
451
+ ? "loopback-legacy-optin"
452
+ : "deny",
453
+ service: Boolean(connection),
454
+ allowLegacyLoopback,
455
+ warning: allowLegacyLoopback
456
+ ? "兼容模式已开启:没有连接凭据的本机回环请求会被放行,仅应在旧宿主上临时使用"
457
+ : null,
458
+ });
459
+
365
460
  /**
366
461
  * 注册一条路由:请求先过 connection 准入(见 admissionGate),再进业务处理。
367
462
  * 写接口另有 `confirm:true` 闸门(writeGate)。
@@ -372,7 +467,7 @@ export default {
372
467
  kind: "exact",
373
468
  path: `${PREFIX}${path}`,
374
469
  handler: (req, res) => {
375
- if (!admissionGate(req, res, connection)) return;
470
+ if (!admissionGate(req, res, connection, { allowLegacyLoopback })) return;
376
471
  return handler(req, res);
377
472
  },
378
473
  })
@@ -382,7 +477,7 @@ export default {
382
477
  register("/state.json", async (req, res) => {
383
478
  try {
384
479
  await store.load();
385
- json(res, 200, buildStatePayload());
480
+ json(res, 200, { ...buildStatePayload(), admission: admissionInfo() });
386
481
  } catch (err) {
387
482
  json(res, 500, { ok: false, error: String((err && err.message) || err) });
388
483
  }
@@ -479,6 +574,7 @@ export default {
479
574
  envSummary: describeEnvironment(env),
480
575
  engine,
481
576
  capabilities: capabilitiesFor(env, Boolean(engine.available)),
577
+ admission: admissionInfo(),
482
578
  routes: routes.length,
483
579
  dataDir: join(process.env.DSH_HOME || join(homedir(), ".dsh"), "dsh-app-updater"),
484
580
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-app-updater",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "description": "App Updater for DeepSeek Harness: detect every installed app that has a newer version and update it with one click by downloading the vendor's full installer from its official website and installing it silently — without launching the app. Windows (winget) and macOS (Homebrew), architecture-aware (ARM64 / x64). · 检测本机所有已安装应用是否有新版本,并从各自官网下载全量安装包一键静默更新(不需要打开被检测的软件);自动识别 Windows / macOS 与 ARM64 / x64 架构,按架构下载对应安装包。",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "dsh": ">=0.1.5-rc.1 <0.2.0-0 || >=0.2.0-rc.2 <0.3.0-0"
38
38
  },
39
39
  "scripts": {
40
- "test": "node test/unit.mjs && node test/render.mjs"
40
+ "test": "node test/unit.mjs && node test/render.mjs && node test/deny.mjs"
41
41
  },
42
42
  "dsh": {
43
43
  "bundle": {
package/test/deny.mjs ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * 默认拒绝的端到端自测:宿主没有 connection 服务、且用户没有显式开启兼容模式时,
3
+ * `/dsh-app-updater/*` 的每一条路由都必须 403(1.3.0 的「仅凭回环放行」已移除)。
4
+ * 反方向再验证一次:显式开启兼容模式后,回环请求才会被放行并带上 opt-in 响应头。
5
+ *
6
+ * 运行:node test/deny.mjs
7
+ */
8
+
9
+ import http from "node:http";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, join } from "node:path";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ process.env.DSH_HOME = process.env.DSH_HOME || join(here, "..", ".test-home");
15
+ delete process.env.DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK;
16
+
17
+ const plugin = (await import("../lib/index.js")).default;
18
+
19
+ let checks = 0;
20
+ let failures = 0;
21
+ function ok(cond, label, extra) {
22
+ checks += 1;
23
+ if (cond) console.log(` ✓ ${label}`);
24
+ else {
25
+ failures += 1;
26
+ console.log(` ✗ ${label}${extra === undefined ? "" : ` — ${JSON.stringify(extra)}`}`);
27
+ }
28
+ }
29
+
30
+ /** 起一个假宿主(没有 connection 服务),返回服务地址与路由表。 */
31
+ function mount(config) {
32
+ const routes = [];
33
+ const webServer = {
34
+ register(spec) {
35
+ routes.push(spec);
36
+ return () => {};
37
+ },
38
+ };
39
+ const ctx = {
40
+ effect(fn) {
41
+ const dispose = fn();
42
+ return () => {
43
+ if (typeof dispose === "function") dispose();
44
+ };
45
+ },
46
+ inject(deps, fn) {
47
+ // 只提供 webServer,不提供 connection —— 等价于旧宿主
48
+ fn({ webServer });
49
+ },
50
+ };
51
+ plugin.apply(ctx, config);
52
+
53
+ const server = http.createServer((req, res) => {
54
+ const url = new URL(req.url, "http://127.0.0.1");
55
+ const route = routes.find((r) => r.path === url.pathname);
56
+ if (!route) {
57
+ res.writeHead(404, { "Content-Type": "application/json" });
58
+ res.end(JSON.stringify({ ok: false, error: "no route" }));
59
+ return;
60
+ }
61
+ Promise.resolve(route.handler(req, res)).catch((err) => {
62
+ if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
63
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }));
64
+ });
65
+ });
66
+ return { routes, server };
67
+ }
68
+
69
+ async function serve(server) {
70
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
71
+ return `http://127.0.0.1:${server.address().port}`;
72
+ }
73
+
74
+ const ROUTES = [
75
+ "/dsh-app-updater/state.json",
76
+ "/dsh-app-updater/apps.json",
77
+ "/dsh-app-updater/scan",
78
+ "/dsh-app-updater/update",
79
+ "/dsh-app-updater/cancel",
80
+ "/dsh-app-updater/settings",
81
+ "/dsh-app-updater/open-downloads",
82
+ "/dsh-app-updater/health.json",
83
+ ];
84
+
85
+ /* ------------------------------------------------------------------ *
86
+ * 1. 默认拒绝:没有 connection + 没有兼容模式
87
+ * ------------------------------------------------------------------ */
88
+ console.log("\n默认拒绝(没有 connection 服务、未开启兼容模式)");
89
+ const denied = mount(undefined);
90
+ const deniedBase = await serve(denied.server);
91
+ for (const p of ROUTES) {
92
+ const isWrite = ["/scan", "/update", "/cancel", "/settings", "/open-downloads"].includes(
93
+ p.replace("/dsh-app-updater", "")
94
+ );
95
+ const res = await fetch(deniedBase + p, isWrite
96
+ ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ confirm: true }) }
97
+ : undefined);
98
+ let body = null;
99
+ try {
100
+ body = await res.json();
101
+ } catch {
102
+ body = null;
103
+ }
104
+ ok(
105
+ res.status === 403 && res.headers.get("x-dsh-admission") === "denied" && body && body.ok === false,
106
+ `${p} → 403 + X-DSH-Admission: denied`,
107
+ { status: res.status, admission: res.headers.get("x-dsh-admission"), body }
108
+ );
109
+ }
110
+ denied.server.close();
111
+
112
+ /* ------------------------------------------------------------------ *
113
+ * 2. 显式开启兼容模式:同一套路由才放行(且只在回环地址上)
114
+ * ------------------------------------------------------------------ */
115
+ console.log("\n显式开启兼容模式(配置 allowLegacyLoopback: true)");
116
+ const legacy = mount({ allowLegacyLoopback: true });
117
+ const legacyBase = await serve(legacy.server);
118
+
119
+ const stateRes = await fetch(legacyBase + "/dsh-app-updater/state.json");
120
+ const state = await stateRes.json();
121
+ ok(
122
+ stateRes.status === 200 && stateRes.headers.get("x-dsh-admission") === "loopback-legacy-optin",
123
+ "state.json → 200 + X-DSH-Admission: loopback-legacy-optin"
124
+ );
125
+ ok(
126
+ state && state.admission && state.admission.mode === "loopback-legacy-optin" && state.admission.allowLegacyLoopback === true,
127
+ "状态接口暴露 admission 现状(供复核)",
128
+ state && state.admission
129
+ );
130
+
131
+ const health = await (await fetch(legacyBase + "/dsh-app-updater/health.json")).json();
132
+ ok(
133
+ health && health.admission && health.admission.mode === "loopback-legacy-optin",
134
+ "health.json 同样标注兼容模式",
135
+ health && health.admission
136
+ );
137
+
138
+ const badWrite = await (await fetch(legacyBase + "/dsh-app-updater/scan", {
139
+ method: "POST",
140
+ headers: { "content-type": "application/json" },
141
+ body: JSON.stringify({}),
142
+ })).json();
143
+ ok(badWrite && badWrite.ok === false && String(badWrite.error).includes("confirm"), "兼容模式下写接口仍要求 confirm:true");
144
+
145
+ legacy.server.close();
146
+
147
+ /* ------------------------------------------------------------------ */
148
+ console.log(`\n${failures === 0 ? "全部通过" : "存在失败"}:${checks - failures}/${checks}`);
149
+ process.exit(failures === 0 ? 0 : 1);
package/test/harness.mjs CHANGED
@@ -11,6 +11,9 @@ import { dirname, join } from "node:path";
11
11
 
12
12
  const here = dirname(fileURLToPath(import.meta.url));
13
13
  process.env.DSH_HOME = process.env.DSH_HOME || join(here, "..", ".test-home");
14
+ // 这个假 ctx 没有 connection 服务,等价于旧宿主:默认拒绝下路由一律 403。
15
+ // 所以自测里显式开启兼容模式(与用户在旧宿主上手动开启走的是同一段代码)。
16
+ process.env.DSH_APP_UPDATER_ALLOW_LEGACY_LOOPBACK = "1";
14
17
 
15
18
  const plugin = (await import("../lib/index.js")).default;
16
19
 
package/test/render.mjs CHANGED
@@ -394,6 +394,26 @@ await tick();
394
394
  tree = rt.render();
395
395
  ok(byClass(tree, "appupd-banner").length === 0, "关闭演练模式后警示条消失(宿主回读 state)");
396
396
 
397
+ group("兼容模式(仅回环放行)警示条");
398
+ statePayload = baseState({ admission: { mode: "loopback-legacy-optin", service: false, allowLegacyLoopback: true, warning: null } });
399
+ rt.reset();
400
+ await tick();
401
+ tree = rt.render();
402
+ await tick();
403
+ tree = rt.render();
404
+ ok(byClass(tree, "appupd-banner").length === 1, "兼容模式开启时显示警示条", byClass(tree, "appupd-banner").length);
405
+ ok(byClass(tree, "appupd-banner-warn").length === 1, "兼容模式警示条用警示样式");
406
+ ok(textOf(byClass(tree, "appupd-banner")[0]).includes("兼容模式已开启"), "警示条文案包含「兼容模式已开启」");
407
+
408
+ statePayload = baseState({ admission: { mode: "deny", service: false, allowLegacyLoopback: false, warning: null } });
409
+ rt.reset();
410
+ await tick();
411
+ tree = rt.render();
412
+ await tick();
413
+ tree = rt.render();
414
+ ok(byClass(tree, "appupd-banner").length === 0, "默认(未开启兼容模式)不显示任何警示条");
415
+ statePayload = baseState();
416
+
397
417
  group("下载目录校验");
398
418
  calls.length = 0;
399
419
  statePayload = baseState();
package/test/unit.mjs CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  import { compareVersions, isNewer, looksLikeVersion, splitVersion } from "../lib/version.js";
21
21
  import { parseInstallerManifest, splitSwitchArgs, defaultSilentArgs, resolveInstallArgs, parseBrewCaskVersions, parseBrewOutdated, findBrew, isArchNotFoundError, explainInstallerExitCode, evaluateUpdateResult, findEdgeUpdate, updateHintFor } from "../lib/engine.js";
22
22
  import { psQuote, ELEVATION_CANCELLED } from "../lib/elevate.js";
23
- import { admissionGate, hasConnectionCredentials } from "../lib/index.js";
23
+ import { admissionGate, hasConnectionCredentials, resolveLegacyLoopbackOptIn, LEGACY_LOOPBACK_ENV } from "../lib/index.js";
24
24
  import {
25
25
  detectEnvironment,
26
26
  describeEnvironment,
@@ -300,25 +300,77 @@ function fakeReq({ loopback = true, headers = {} } = {}) {
300
300
  return { socket: { remoteAddress: loopback ? "127.0.0.1" : "10.0.0.5" }, headers };
301
301
  }
302
302
 
303
- group("connection 准入(市场审核要求)");
303
+ group("connection 准入(市场审核要求:默认拒绝)");
304
304
  ok(hasConnectionCredentials({ headers: { cookie: "dsh=x" } }) === true, "识别浏览器会话 Cookie");
305
305
  ok(hasConnectionCredentials({ headers: { authorization: "Bearer x" } }) === true, "识别 API Cookie/Authorization");
306
306
  ok(hasConnectionCredentials({ headers: {} }) === false, "没有凭据时返回 false");
307
- ok(admissionGate(fakeReq(), fakeRes(), null) === true, "宿主没有 connection 服务时,回环请求放行");
307
+
308
+ /* --- 兼容模式开关:默认关闭,必须是显式动作 --- */
309
+ ok(resolveLegacyLoopbackOptIn(undefined, {}) === false, "没有任何显式开关 → 兼容模式关闭");
310
+ ok(resolveLegacyLoopbackOptIn(null, {}) === false, "配置为 null → 兼容模式关闭");
311
+ ok(resolveLegacyLoopbackOptIn({}, {}) === false, "配置里没有该字段 → 兼容模式关闭");
312
+ ok(resolveLegacyLoopbackOptIn({ allowLegacyLoopback: false }, {}) === false, "配置显式 false → 关闭");
313
+ ok(resolveLegacyLoopbackOptIn({ allowLegacyLoopback: true }, {}) === true, "插件配置 allowLegacyLoopback:true → 开启");
314
+ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "1" }) === true, "环境变量 =1 → 开启");
315
+ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "true" }) === true, "环境变量 =true → 开启");
316
+ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "0" }) === false, "环境变量 =0 → 关闭");
317
+ ok(resolveLegacyLoopbackOptIn(undefined, { [LEGACY_LOOPBACK_ENV]: "off" }) === false, "环境变量 =off → 关闭");
318
+
319
+ /* --- 宿主没有 connection 服务:默认拒绝,只有显式开启兼容模式才放行 --- */
320
+ {
321
+ const res = fakeRes();
322
+ ok(
323
+ admissionGate(fakeReq(), res, null) === false && res.statusCode === 403 && res.headers["X-DSH-Admission"] === "denied",
324
+ "没有 connection 服务 + 回环 + 未开兼容模式 → 403(默认拒绝)"
325
+ );
326
+ }
308
327
  {
309
328
  const res = fakeRes();
310
329
  ok(admissionGate(fakeReq({ loopback: false }), res, null) === false && res.statusCode === 403, "没有 connection 服务时,非回环请求 403");
311
330
  }
331
+ {
332
+ const res = fakeRes();
333
+ ok(
334
+ admissionGate(fakeReq(), res, null, { allowLegacyLoopback: true }) === true &&
335
+ res.headers["X-DSH-Admission"] === "loopback-legacy-optin" &&
336
+ res.headers["X-DSH-Admission-Warning"] === "legacy-loopback-compatibility-enabled",
337
+ "显式开启兼容模式 + 回环 → 放行并标注 opt-in 响应头"
338
+ );
339
+ }
340
+ {
341
+ const res = fakeRes();
342
+ ok(
343
+ admissionGate(fakeReq({ loopback: false }), res, null, { allowLegacyLoopback: true }) === false && res.statusCode === 403,
344
+ "即使开启兼容模式,非回环请求仍然 403"
345
+ );
346
+ }
347
+
348
+ /* --- 宿主有 connection 服务:一律以 admit() 为准 --- */
312
349
  {
313
350
  const res = fakeRes();
314
351
  const connection = { admit: () => ({ peer: { id: "peer-1" } }) };
315
352
  ok(admissionGate(fakeReq(), res, connection) === true && res.statusCode === 0, "准入通过 → 放行且不写响应");
316
353
  }
354
+ {
355
+ const res = fakeRes();
356
+ const connection = { admit: () => undefined };
357
+ ok(admissionGate(fakeReq(), res, connection) === false && res.statusCode === 500, "准入返回空值 → 500(不把看不懂的结果放行)");
358
+ }
359
+ {
360
+ const res = fakeRes();
361
+ const connection = { admit: () => ({ rejection: "401" }) };
362
+ ok(admissionGate(fakeReq(), res, connection) === false && res.statusCode === 403, "准入返回非数字拒绝码 → 403(fail-closed)");
363
+ }
317
364
  {
318
365
  const res = fakeRes();
319
366
  const connection = { admit: () => ({ rejection: 403 }) };
320
367
  ok(admissionGate(fakeReq({ loopback: false }), res, connection) === false && res.statusCode === 403, "准入拒绝 403(不可信来源)→ 403");
321
368
  }
369
+ {
370
+ const res = fakeRes();
371
+ const connection = { admit: () => ({ rejection: 409 }) };
372
+ ok(admissionGate(fakeReq(), res, connection) === false && res.statusCode === 409, "准入拒绝 409(连接被占用)→ 409");
373
+ }
322
374
  {
323
375
  const res = fakeRes();
324
376
  const connection = { admit: () => ({ rejection: 401 }) };
@@ -328,8 +380,34 @@ ok(admissionGate(fakeReq(), fakeRes(), null) === true, "宿主没有 connection
328
380
  const res = fakeRes();
329
381
  const connection = { admit: () => ({ rejection: 401 }) };
330
382
  ok(
331
- admissionGate(fakeReq(), res, connection) === true && res.headers["X-DSH-Admission"] === "loopback-legacy",
332
- "无凭据的回环请求退回旧规则并标注响应头"
383
+ admissionGate(fakeReq({ headers: { cookie: "dsh=x" } }), res, connection, { allowLegacyLoopback: true }) === false &&
384
+ res.statusCode === 401,
385
+ "带凭据未认证时,兼容模式也不放行"
386
+ );
387
+ }
388
+ {
389
+ const res = fakeRes();
390
+ const connection = { admit: () => ({ rejection: 401 }) };
391
+ ok(
392
+ admissionGate(fakeReq(), res, connection) === false && res.statusCode === 401 && res.headers["X-DSH-Admission"] === "denied",
393
+ "无凭据的回环请求默认拒绝(不再退回回环信任)"
394
+ );
395
+ }
396
+ {
397
+ const res = fakeRes();
398
+ const connection = { admit: () => ({ rejection: 401 }) };
399
+ ok(
400
+ admissionGate(fakeReq(), res, connection, { allowLegacyLoopback: true }) === true &&
401
+ res.headers["X-DSH-Admission"] === "loopback-legacy-optin",
402
+ "显式开启兼容模式后,无凭据的回环请求才按旧规则放行"
403
+ );
404
+ }
405
+ {
406
+ const res = fakeRes();
407
+ const connection = { admit: () => ({ rejection: 401 }) };
408
+ ok(
409
+ admissionGate(fakeReq({ loopback: false }), res, connection, { allowLegacyLoopback: true }) === false && res.statusCode === 401,
410
+ "兼容模式的例外只覆盖回环地址,非回环仍然 401"
333
411
  );
334
412
  }
335
413
  {