dsh-remote-bridge 2.6.0 → 2.6.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/README.en.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <img alt="Version" src="https://img.shields.io/badge/version-v2.6.0-087f8c">
12
+ <img alt="Version" src="https://img.shields.io/badge/version-v2.6.1-087f8c">
13
13
  <img alt="DSH plugin" src="https://img.shields.io/badge/DeepSeek_Harness-plugin-1f2328">
14
14
  <img alt="Platforms" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-586069">
15
15
  <img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0-2da44e">
@@ -102,10 +102,10 @@ dsh plugin --profile web add dsh-remote-bridge
102
102
  dsh plugin --profile web add dsh-remote-bridge --registry https://registry.npmmirror.com
103
103
 
104
104
  # 2. tarball from the GitHub release
105
- dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.6.0/dsh-remote-bridge-v2.6.0.tgz
105
+ dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.6.1/dsh-remote-bridge-v2.6.1.tgz
106
106
 
107
107
  # 3. tarball from the Gitee release (faster from mainland China)
108
- dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.6.0/dsh-remote-bridge-v2.6.0.tgz
108
+ dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.6.1/dsh-remote-bridge-v2.6.1.tgz
109
109
 
110
110
  npx @deepseek-ai/dsh web
111
111
  ```
@@ -122,7 +122,7 @@ Restart DSH after installation, then open settings — The section is titled **
122
122
  To install a downloaded release archive:
123
123
 
124
124
  ```sh
125
- dsh plugin --profile web add ./dsh-remote-bridge-v2.6.0.tgz
125
+ dsh plugin --profile web add ./dsh-remote-bridge-v2.6.1.tgz
126
126
  ```
127
127
 
128
128
  ## First connection
@@ -219,10 +219,28 @@ The following controls remain active over both HTTP and HTTPS:
219
219
  - revocable scopes and per-root grants;
220
220
  - separate rate limits for pairing, authentication failures, and normal requests;
221
221
  - audit records that omit tokens, file bodies, and chat bodies;
222
- - no automatic access to roots added after a device was paired.
222
+ - no automatic access to roots added after a device was paired;
223
+ - provider `baseURL` changes are limited to public http(s) destinations, and moving a
224
+ provider that already stores a credential to a new host also requires a fresh API key
225
+ (the kernel sends that key to the address, so this is a credential boundary, not just SSRF).
223
226
 
224
227
  Only a loopback client may add roots, change device grants, create pairing codes, configure the listener, or permanently purge trash.
225
228
 
229
+ ### Local LLM endpoints (ollama / LM Studio)
230
+
231
+ Private, loopback, link-local and CGNAT provider addresses are **rejected by default**.
232
+ To use a local model server, list it explicitly in the plugin config:
233
+
234
+ ```yaml
235
+ allowPrivateProviderHosts:
236
+ - 127.0.0.1:11434 # recommended: pin the port
237
+ - 192.168.1.5 # bare host: allows every port on that host
238
+ ```
239
+
240
+ Exact `host` or `host:port` only — CIDR is not supported. Prefer the `host:port` form:
241
+ allowing `192.168.1.5` allows every service on it, while ollama only listens on 11434.
242
+ See `docs/project/SECURITY.md` for the reasoning and the audit trail fields.
243
+
226
244
  ## Path boundary
227
245
 
228
246
  External requests use `rootId + relativePath`, never an absolute server path.
@@ -297,6 +315,16 @@ A device also needs an explicit grant for every `rootId`. Local administrators m
297
315
 
298
316
  **New scopes are picked before pairing, in Remote access**: whatever you tick is what the generated pairing code carries, and those scopes take effect once the app enters the code. For a device that is already paired, open **Devices** and use its **Permissions** button.
299
317
 
318
+ The default selection is five scopes — `chat.read`, `chat.write`, `files.read`, `files.write`, `settings.read` — and **deliberately excludes** `files.delete` and `settings.write`. You can still tick them; the default just no longer hands out destructive permissions on its own.
319
+
320
+ ### Device names and models
321
+
322
+ At pairing time the bridge has no idea who will use the token, so a new device starts with a **platform-neutral** placeholder name (`Mobile device`). The first time the device connects, the bridge works out its platform and model from the request and replaces the name with `platform · model` (for example `HarmonyOS · ICL-AL10`).
323
+
324
+ Platform detection prefers the client-declared `X-Dsh-Device: <platform>/<model>` header and falls back to the `User-Agent`. **The model is only taken from what the client reports**: neither okhttp nor ArkWeb puts a model in its default UA, and the bridge will not guess — a wrong model is worse than no model.
325
+
326
+ Open **Devices** and use **Permissions** to rename a device. **A name you set yourself is never overwritten**; an automatically generated name still updates when the device reports a new identity. Device names are for display and troubleshooting only and take no part in authorization.
327
+
300
328
  **Revoking a device deletes it from the database** rather than leaving a marker row behind. Its grants go with it, while the audit trail (who revoked which device, and when) is kept.
301
329
 
302
330
  ## Upgrade and uninstall
@@ -354,19 +382,32 @@ pnpm pack
354
382
 
355
383
  `pnpm check` covers type-checking, documentation consistency, and tests. It currently passes **67 test files / 557 cases**, plus **8 browser-layer cases** (Playwright measuring real computed styles, including the 375px narrow-viewport contract).
356
384
 
357
- `npm pack` produces roughly a **0.61 MB** archive (v2.5.2 measured 635,325 bytes, SHA-256 `18d36904…c8095`): 2.2.0 dropped the standalone page and its editor dependencies and turned source maps off, down from 1.72 MB; it has since grown slightly with projection passthrough, the permission BFF, and the five-dimension hardening.
385
+ `npm pack` produces roughly a **0.64 MB** archive (v2.6.1 measured 669,434 bytes, SHA-256 `9bf0189b…2cd35`): 2.2.0 dropped the standalone page and its editor dependencies and turned source maps off, down from 1.72 MB; it has since grown slightly with projection passthrough, the permission BFF, and the five-dimension hardening.
358
386
 
359
387
  v2.5.0 was verified on DSH **0.2.1-rc.2** on real hardware: all 14/14 host services injected, the three jobs endpoints exercised, and the permission catalog returned the kernel's real three presets. The development baseline is upstream `dsh-v0.2.1-rc.2` (commit `687f282e8625c6b8d11ff58211a90524be2e4317`); see `engines.dsh` above for the supported range. CI covers Windows, Ubuntu, and macOS.
360
388
 
361
- **v2.6.0 verification status**: 70 test files / 593 cases green, plus 8 Playwright browser cases
389
+ **v2.6.1 verification status**: 74 test files / 682 cases green, plus 8 Playwright browser cases
362
390
  (including the 375px narrow-viewport contract); `typecheck`, `docs:check`, and the build all pass.
363
- All three rounds of real-hardware tunnel regressions were reproduced and verified on a
364
- React18 + Playwright click harness (one-click token issuance, the stop-tunnel escape hatch, and the
365
- locked state when re-entering settings), then confirmed by manual testing on the user's machine.
391
+ The SEC-H11 attack chain was confirmed **by measurement** (a fake LLM endpoint was stood up and the
392
+ kernel was observed sending the real `Authorization: Bearer` to it); each of the four gates now has a
393
+ regression assertion. The three device-identity specs (27 + 13 + 12 cases) cover the UA detection
394
+ order, "a `declared` name is never overwritten", and idempotent migration of the old table shape.
366
395
 
367
396
  ## Project
368
397
 
369
- - Version: `v2.6.0`
398
+ - Version: `v2.6.1`
399
+ - **v2.6.1 is the security-fix + device-identity release**: it closes a **high-severity** hole —
400
+ the three provider `baseURL` entry points (create / update / model discovery) performed no
401
+ validation at all, so a device holding `settings.write` could point the baseURL at its own server
402
+ and the kernel would request `/models` there with **that provider's real API key**, leaking the
403
+ credential outright; the persistent variant was worse, since after changing the baseURL every
404
+ subsequent chat would send the key there. There are now three gates (protocol allow-list,
405
+ private networks denied by default with an explicit `allowPrivateProviderHosts` opt-in, and a
406
+ new key required when repointing a provider that already holds a credential), and the pairing
407
+ default was narrowed from seven scopes to five (no more `settings.write` / `files.delete` by default).
408
+ It also fixes "the device name is always 'Android device'": the bridge now derives the platform and
409
+ model from the `X-Dsh-Device` header (User-Agent as fallback), renames the placeholder to
410
+ "platform · model", and offers a rename channel (a manually set name is never overwritten).
370
411
  - **v2.6.0 is the plugin-settings + IM-entry release**: it adds the per-plugin settings namespaces (`GET /api/v1/settings/namespaces`, `PATCH .../{ns}`) so the app can build the form from the kernel schema, and two entry points for "I am out and the desktop is still locked" — the host command `/tunnel` and the tool `open_tunnel_config`; plus one-tap pruning of unused devices, the audit page removed, and a guard so a failing optional registration cannot tear down the whole plugin context.
371
412
  - **v2.5.2 is a comprehensive hardening release**: it lands the five-dimension audit
372
413
  (security, features, performance, UI, button feedback) — including the unauthenticated
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <img alt="Version" src="https://img.shields.io/badge/version-v2.6.0-087f8c">
12
+ <img alt="Version" src="https://img.shields.io/badge/version-v2.6.1-087f8c">
13
13
  <img alt="DSH plugin" src="https://img.shields.io/badge/DeepSeek_Harness-plugin-1f2328">
14
14
  <img alt="Platforms" src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-586069">
15
15
  <img alt="License" src="https://img.shields.io/badge/license-AGPL--3.0-2da44e">
@@ -93,10 +93,10 @@ dsh plugin --profile web add dsh-remote-bridge
93
93
  dsh plugin --profile web add dsh-remote-bridge --registry https://registry.npmmirror.com
94
94
 
95
95
  # ② GitHub Release 的 tarball
96
- dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.6.0/dsh-remote-bridge-v2.6.0.tgz
96
+ dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.6.1/dsh-remote-bridge-v2.6.1.tgz
97
97
 
98
98
  # ③ Gitee Release 的 tarball(国内访问更快)
99
- dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.6.0/dsh-remote-bridge-v2.6.0.tgz
99
+ dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.6.1/dsh-remote-bridge-v2.6.1.tgz
100
100
 
101
101
  npx @deepseek-ai/dsh web
102
102
  ```
@@ -112,7 +112,7 @@ npx @deepseek-ai/dsh web
112
112
  从 GitHub Release 下载离线安装包时,可直接安装 tarball:
113
113
 
114
114
  ```sh
115
- dsh plugin --profile web add ./dsh-remote-bridge-v2.6.0.tgz
115
+ dsh plugin --profile web add ./dsh-remote-bridge-v2.6.1.tgz
116
116
  ```
117
117
 
118
118
  ## 第一次使用
@@ -251,9 +251,27 @@ DSH1:eyJ2IjoxLCJlbmRwb2ludHMiOlt7ImxhYmVsIjoi…
251
251
  - 配对、认证和普通请求分别限流。
252
252
  - 审计记录操作类型和对象,但不记录令牌、文件正文或聊天正文。
253
253
  - 新增根目录不会自动授权给已经配对的设备。
254
+ - 供应商 `baseURL` 只能指向公网 http(s) 地址;把「已经存有凭据」的供应商换到新 host
255
+ 还必须同时提供新的 API key —— 因为内核会带着那把 key 去请求这个地址,这是凭据边界,
256
+ 不只是 SSRF。
254
257
 
255
258
  只有来自回环地址的管理界面可以添加根目录、调整设备权限、生成配对码、修改监听设置和永久清理回收站。
256
259
 
260
+ ### 自建 LLM(ollama / LM Studio)
261
+
262
+ 私网、回环、链路本地与 CGNAT 的供应商地址**默认拒绝**。要用本机模型服务,在插件配置里
263
+ 显式放行:
264
+
265
+ ```yaml
266
+ allowPrivateProviderHosts:
267
+ - 127.0.0.1:11434 # 推荐:钉住端口
268
+ - 192.168.1.5 # 裸 host:放行该主机的全部端口
269
+ ```
270
+
271
+ 只接受精确 `host` 或 `host:port`,**不支持 CIDR**。推荐带端口写法:放行 `192.168.1.5`
272
+ 等于放行它上面所有服务,而 ollama 只在 11434。理由与审计字段见
273
+ [docs/project/SECURITY.md](docs/project/SECURITY.md#供应商-baseurl-的地址策略261-起sec-h11)。
274
+
257
275
  ## 文件边界
258
276
 
259
277
  外部接口只接受 `rootId + relativePath`,不会接收服务器绝对路径。路径还会经过以下检查:
@@ -328,6 +346,16 @@ Authorization: Bearer DEVICE_TOKEN
328
346
 
329
347
  **新增的权限是配对前在「远程访问」里勾选的**:勾什么,生成的配对码就带什么;APP 输入配对码后这些权限随设备一起生效。已在用的设备则到「设备」里点它的「权限」修改。
330
348
 
349
+ 默认勾选是五档 —— `chat.read`、`chat.write`、`files.read`、`files.write`、`settings.read`,**刻意不含** `files.delete` 与 `settings.write`。你仍然可以自己勾上;默认值只是不再替你做这个决定。
350
+
351
+ ### 设备名与型号
352
+
353
+ 配对那一刻桥并不知道将来是谁来用这个令牌,所以新设备先得到一个**平台无关**的占位名(`Mobile device`)。设备第一次连上来时,桥会从请求里识别平台与型号,把名字自动换成「平台 · 型号」(比如 `HarmonyOS · ICL-AL10`)。
354
+
355
+ 平台判定优先看客户端上报的 `X-Dsh-Device: <platform>/<model>` 头,没有就从 `User-Agent` 兜底。**型号只认客户端上报**:OkHttp 与 ArkWeb 的默认 UA 里都没有机型,桥不会去猜 —— 猜错的型号比没有型号更糟。
356
+
357
+ 在「设备」页点「权限」可以直接改名。**你手动改过的名字不会再被自动覆盖**;自动生成的名字在设备上报了新身份时仍会更新。设备名只用于显示与排查,不参与任何授权判断。
358
+
331
359
  **撤销一台设备会直接把它从数据库里删除**,不是打个标记留着;它的授权随之清除,但审计记录(谁在什么时候撤了谁)会保留。
332
360
 
333
361
  ## 升级与卸载
@@ -385,20 +413,29 @@ pnpm pack
385
413
 
386
414
  `pnpm check` 覆盖类型检查、文档一致性与测试;当前 **67 个测试文件 / 557 个用例**,外加 **8 个浏览器层用例**(Playwright 实测量出的计算样式,含 375px 窄屏契约),全部通过。
387
415
 
388
- `npm pack` 的产物约 **0.61 MB**(v2.5.2 实测 635,325 字节,SHA-256 `18d36904…c8095`):2.2.0 删除独立页及其编辑器依赖、并关闭 source map 之后从 1.72 MB 降下来,之后随投影透传、权限 BFF 与五维加固小幅增长。
416
+ `npm pack` 的产物约 **0.64 MB**(v2.6.1 实测 669,434 字节,SHA-256 `9bf0189b…2cd35`):2.2.0 删除独立页及其编辑器依赖、并关闭 source map 之后从 1.72 MB 降下来,之后随投影透传、权限 BFF 与五维加固小幅增长。
389
417
 
390
418
  v2.5.0 在 DSH **0.2.1-rc.2** 上完成真机验证:14/14 宿主服务全部可注入、三个 jobs 端点实测通过、
391
419
  权限档位目录返回内核真实三档。开发基线为上游 `dsh-v0.2.1-rc.2`(commit `687f282e8625c6b8d11ff58211a90524be2e4317`),
392
420
  兼容范围见上文 `engines.dsh`。CI 覆盖 Windows、Ubuntu 和 macOS。
393
421
 
394
- **v2.6.0 的验证状态**:70 个测试文件 / 593 个用例全绿、8 个 Playwright 浏览器用例
395
- (含 375px 窄屏契约)、`typecheck`/`docs:check`/构建通过;隧道管理面三轮真机回归均用
396
- React18 + Playwright 真点击台复现并验证(一键出令牌、停止出口、重进设置页锁定态),
397
- 用户真机手工复测通过。
422
+ **v2.6.1 的验证状态**:74 个测试文件 / 682 个用例全绿、8 个 Playwright 浏览器用例
423
+ (含 375px 窄屏契约)、`typecheck`/`docs:check`/构建通过。SEC-H11 的攻击链是**用实测**
424
+ 确认的(起一个假 LLM 端点,抓到内核把真实 `Authorization: Bearer` 打过去),修复后的
425
+ 四道闸门各有回归断言;设备身份的三个 spec(27 + 13 + 12 例)覆盖 UA 判定顺序、
426
+ 「`declared` 永不覆盖」与老表结构迁移幂等。
398
427
 
399
428
  ## 项目信息
400
429
 
401
- - 当前版本:`v2.6.0`
430
+ - 当前版本:`v2.6.1`
431
+ - **v2.6.1 是安全修复 + 设备身份版**:修掉一个**高危**问题 —— 供应商 `baseURL` 的三个入口
432
+ (新建 / 修改 / 探测模型)此前不做任何校验,持有 `settings.write` 的设备可以把 baseURL
433
+ 指向自己的服务器,内核会带着**该供应商的真实 API key** 去请求 `/models`,等于凭据直接外泄;
434
+ 持久化变体更糟:改完 baseURL 之后正常聊天都会把 key 发过去。现在三道闸门(协议白名单、
435
+ 私网默认拒绝 + `allowPrivateProviderHosts` 显式放行、换 host 且已有凭据时必须同时给新 key),
436
+ 配对默认权限也从七档收到五档(不再默认给 `settings.write` 与 `files.delete`)。
437
+ 另修「设备名永远是『Android 设备』」:桥现在从 `X-Dsh-Device` 头(UA 兜底)识别平台与型号,
438
+ 自动把占位名换成「平台 · 型号」,并新增改名通道(手动改过的名字永不被自动覆盖)。
402
439
  - **v2.6.0 是通用设置面 + IM 入口版**:新增「按插件划分的设置命名空间」两个端点(手机端照 schema 自建表单,新装插件无需改 App),以及「人已出门、电脑还锁着」时用 IM 一句话开隧道并收回配对配置的双入口(宿主命令 `/tunnel` + 工具 `open_tunnel_config`);另加一键清理 N 天未使用设备、移除审计页,并把「激活期降级保护」补上(apply 尾部抛错不再拆掉整条插件上下文)。
403
440
  - **v2.5.2 是全量完善版**:落地五维审计(安全/功能/性能/界面/按钮反馈)——含高危的
404
441
  `debug/host-services` 免鉴权信息泄露修复、限流双桶、广播背压、弱 ETag、审计保留策略等,
@@ -1,7 +1,7 @@
1
1
  asyncapi: 3.0.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile Events
4
- version: 2.6.0
4
+ version: 2.6.1
5
5
  description: |
6
6
  Live file and authorized DSH chat events. Reconnect clients must refresh
7
7
  REST state; unbounded replay is not provided.
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: DeepSeek Harness Mobile API
4
- version: 2.6.0
4
+ version: 2.6.1
5
5
  description: Versioned file and chat BFF for mobile and third-party DSH clients. HTTP and HTTPS are both supported.
6
6
  servers:
7
7
  - url: http://127.0.0.1:3090/api/v1
@@ -24,7 +24,7 @@ paths:
24
24
  properties:
25
25
  ok: { type: boolean, const: true }
26
26
  version: { type: string, const: v1 }
27
- pluginVersion: { type: string, const: 2.6.0 }
27
+ pluginVersion: { type: string, const: 2.6.1 }
28
28
  /debug/host-services:
29
29
  get:
30
30
  operationId: hostServices
@@ -506,7 +506,16 @@ paths:
506
506
  '403': { $ref: '#/components/responses/Error' }
507
507
  post:
508
508
  operationId: createCustomProvider
509
- description: Creates a fully custom llm-pi-ai provider using the protocols reported by ProviderSettings.customProvider.
509
+ description: |
510
+ Creates a fully custom llm-pi-ai provider using the protocols reported by
511
+ ProviderSettings.customProvider.
512
+
513
+ `baseURL` must be an absolute http(s) URL. Private, loopback, link-local and
514
+ CGNAT destinations are rejected with `403 PROVIDER_BASE_URL_FORBIDDEN` unless the
515
+ deployment lists the host in `allowPrivateProviderHosts` (exact `host` or
516
+ `host:port`, no CIDR). The kernel sends the provider's stored API key to this
517
+ address, so the check is what keeps a paired device from redirecting credentials.
518
+ See SECURITY.md ("供应商 baseURL 的地址策略").
510
519
  requestBody:
511
520
  required: true
512
521
  content:
@@ -519,6 +528,7 @@ paths:
519
528
  application/json:
520
529
  schema: { $ref: '#/components/schemas/ProviderSettings' }
521
530
  '400': { $ref: '#/components/responses/Error' }
531
+ '403': { $ref: '#/components/responses/Error' }
522
532
  '409': { $ref: '#/components/responses/Error' }
523
533
  /settings/plugins:
524
534
  get:
@@ -649,7 +659,15 @@ paths:
649
659
  schema: { type: string }
650
660
  patch:
651
661
  operationId: updateProvider
652
- description: Updates provider settings and optionally writes an API key to the DSH credential store.
662
+ description: |
663
+ Updates provider settings and optionally writes an API key to the DSH credential store.
664
+
665
+ `baseURL` goes through the same address policy as creation. In addition, moving a
666
+ provider that already holds a credential to a different host requires supplying a
667
+ new `apiKey` in the same patch (`403 PROVIDER_BASE_URL_REPOINT_FORBIDDEN`) —
668
+ otherwise a paired device could point a live key at its own public domain, which
669
+ the private-address rule cannot catch. Changing models, the display name, or the
670
+ path within the same host is unaffected.
653
671
  requestBody:
654
672
  required: true
655
673
  content:
@@ -675,6 +693,11 @@ paths:
675
693
  /settings/providers/{providerId}/discover:
676
694
  post:
677
695
  operationId: discoverProviderModels
696
+ description: |
697
+ Fetches `GET {baseURL}/models` through the kernel, carrying the provider's stored
698
+ credential. `baseURL` here is a temporary override that is NOT persisted, so a
699
+ rejected address never reaches the config — but it does reach the network unless
700
+ the address policy allows it. Same rules as creation.
678
701
  parameters:
679
702
  - name: providerId
680
703
  in: path
@@ -702,6 +725,8 @@ paths:
702
725
  models:
703
726
  type: array
704
727
  items: { $ref: '#/components/schemas/ProviderModel' }
728
+ '400': { $ref: '#/components/responses/Error' }
729
+ '403': { $ref: '#/components/responses/Error' }
705
730
  /chat/sessions:
706
731
  get:
707
732
  operationId: listChatSessions
@@ -1412,6 +1437,28 @@ components:
1412
1437
  rootIds:
1413
1438
  type: array
1414
1439
  items: { type: string }
1440
+ platform:
1441
+ type: string
1442
+ description: >-
1443
+ Platform the device reported, or the one inferred from its
1444
+ User-Agent when it reported nothing. Absent for devices that never
1445
+ connected since the bridge started collecting it. Informational
1446
+ only — it is client-declared and never takes part in authorization.
1447
+ enum: [android, harmonyos, ios, ipados, web, windows, macos, linux]
1448
+ model:
1449
+ type: string
1450
+ description: >-
1451
+ Model the device reported. Often absent: neither okhttp nor ArkWeb
1452
+ puts a model in its default User-Agent, and the bridge deliberately
1453
+ does not guess — a wrong model is worse than no model.
1454
+ nameSource:
1455
+ type: string
1456
+ description: >-
1457
+ Where `name` came from. `default` (pairing placeholder) and
1458
+ `detected` (auto-generated from the reported identity) may still be
1459
+ replaced when the device reports a different identity; `declared`
1460
+ means a human set it and it is never overwritten automatically.
1461
+ enum: [default, detected, declared]
1415
1462
  FileEntry:
1416
1463
  type: object
1417
1464
  required: [name, path, kind, size, modifiedAt, writable]
@@ -46,6 +46,12 @@ data class DeviceView(
46
46
  val name: String,
47
47
  val scopes: List<String>,
48
48
  val rootIds: List<String>,
49
+ /** 平台:`android` / `harmonyos` / `ios` / …。老桥与从未连过的设备没有这个字段。 */
50
+ val platform: String? = null,
51
+ /** 型号。桥刻意不猜(okhttp/ArkWeb 默认 UA 里没有机型),所以多数时候是 null。 */
52
+ val model: String? = null,
53
+ /** `name` 的来源:`default` / `detected` / `declared`。null 等价于 `declared`。 */
54
+ val nameSource: String? = null,
49
55
  )
50
56
 
51
57
  @Serializable
package/lib/client.cjs CHANGED
@@ -149,6 +149,19 @@ window.__ModuleLoader__.load({
149
149
  })
150
150
  });
151
151
  }
152
+ /**
153
+ * 只改名,不动权限。
154
+ *
155
+ * 走同一个 PATCH 但只带 `name` —— 服务端见到没有 scopes/rootIds 就按「纯改名」
156
+ * 处理。分成两个调用点(而不是把 name 塞进 `updateDevice`)是因为它们是两件
157
+ * 不同的事:改名不需要先想清楚权限,也不该因为权限校验失败而失败。
158
+ */
159
+ async renameDevice(deviceId, name) {
160
+ await this.manageJson(`/devices/${encodeURIComponent(deviceId)}`, {
161
+ method: "PATCH",
162
+ body: JSON.stringify({ name })
163
+ });
164
+ }
152
165
  async revokeDevice(deviceId) {
153
166
  await this.manageJson(`/devices/${encodeURIComponent(deviceId)}`, { method: "DELETE" });
154
167
  }
@@ -30017,6 +30030,11 @@ window.__ModuleLoader__.load({
30017
30030
  trashEmpty: "回收站是空的",
30018
30031
  deviceLastSeen: "最后使用 {time}",
30019
30032
  deviceNeverSeen: "从未连接",
30033
+ deviceRename: "改名",
30034
+ deviceRenamePlaceholder: "给这台设备起个名字",
30035
+ deviceRenameHint: "手动改过的名字不会被自动覆盖",
30036
+ deviceNameSaved: "设备名已保存",
30037
+ deviceDetectedName: "自动识别",
30020
30038
  imageLoadFailed: "图片加载失败",
30021
30039
  prevImage: "上一张",
30022
30040
  nextImage: "下一张",
@@ -30243,6 +30261,11 @@ window.__ModuleLoader__.load({
30243
30261
  trashEmpty: "The recycle bin is empty",
30244
30262
  deviceLastSeen: "Last seen {time}",
30245
30263
  deviceNeverSeen: "Never connected",
30264
+ deviceRename: "Rename",
30265
+ deviceRenamePlaceholder: "Name this device",
30266
+ deviceRenameHint: "A name you set is never overwritten automatically",
30267
+ deviceNameSaved: "Device name saved",
30268
+ deviceDetectedName: "Detected",
30246
30269
  imageLoadFailed: "Image failed to load",
30247
30270
  prevImage: "Previous image",
30248
30271
  nextImage: "Next image",
@@ -30889,6 +30912,23 @@ window.__ModuleLoader__.load({
30889
30912
  "settings.read",
30890
30913
  "settings.write"
30891
30914
  ];
30915
+ /**
30916
+ * 授予新设备时的默认权限(**五个非破坏性权限**)。
30917
+ *
30918
+ * 刻意不含 `files.delete` 与 `settings.write`:这两个是破坏性权限,应当是用户显式
30919
+ * 要求才授予。`settings.write` 尤其危险 —— 拿到它就能改供应商 `baseURL`,把模型
30920
+ * 请求连同真实凭据引到任意地址(SEC-H11),所以它绝不能是默认值。
30921
+ *
30922
+ * 三条路径必须共用这一份,否则「同一个产品里三处默认权限不一样」这种缺陷会反复
30923
+ * 复发:服务端生成配置文本时的缺省、Web UI 配对码面板的初始勾选、隧道一键路径。
30924
+ */
30925
+ const DEFAULT_DEVICE_SCOPES = [
30926
+ "chat.read",
30927
+ "chat.write",
30928
+ "files.read",
30929
+ "files.write",
30930
+ "settings.read"
30931
+ ];
30892
30932
  new Set(DEVICE_SCOPES);
30893
30933
  //#endregion
30894
30934
  //#region src/client/app-qr.ts
@@ -31314,7 +31354,7 @@ window.__ModuleLoader__.load({
31314
31354
  }, [status?.configuredHost, status?.configuredPort]);
31315
31355
  const roots = status?.roots ?? [];
31316
31356
  const managementLocked = tunnel?.running === true;
31317
- const [grantedScopes, setGrantedScopes] = (0, react.useState)([...DEVICE_SCOPES]);
31357
+ const [grantedScopes, setGrantedScopes] = (0, react.useState)([...DEFAULT_DEVICE_SCOPES]);
31318
31358
  const toggleScope = (0, react.useCallback)((scope) => {
31319
31359
  setGrantedScopes((current) => current.includes(scope) ? current.filter((item) => item !== scope) : [...current, scope]);
31320
31360
  }, []);
@@ -32103,9 +32143,19 @@ window.__ModuleLoader__.load({
32103
32143
  const [editing, setEditing] = (0, react.useState)(false);
32104
32144
  const [scopes, setScopes] = (0, react.useState)(props.device.scopes);
32105
32145
  const [rootIds, setRootIds] = (0, react.useState)(props.device.rootIds);
32146
+ const [name, setName] = (0, react.useState)(props.device.name);
32106
32147
  const toggle = (list, value) => list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
32107
32148
  const shortId = props.device.id.length > 14 ? `${props.device.id.slice(0, 8)}…${props.device.id.slice(-4)}` : props.device.id;
32108
32149
  const lastSeen = props.device.lastSeenAt === void 0 ? t("deviceNeverSeen") : t("deviceLastSeen", { time: new Date(props.device.lastSeenAt).toLocaleString(locale) });
32150
+ const autoNamed = props.device.nameSource === "default" || props.device.nameSource === "detected";
32151
+ const saveAll = () => {
32152
+ const trimmed = name.trim();
32153
+ const rename = trimmed !== "" && trimmed !== props.device.name;
32154
+ (rename ? props.api.renameDevice(props.device.id, trimmed) : Promise.resolve()).then(() => props.api.updateDevice(props.device.id, scopes, rootIds)).then(props.onChanged).then(() => {
32155
+ setEditing(false);
32156
+ props.onNotice(rename ? "deviceNameSaved" : "permissionsSaved");
32157
+ }).catch(props.onError);
32158
+ };
32109
32159
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32110
32160
  className: "daw-list-row",
32111
32161
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
@@ -32113,10 +32163,14 @@ window.__ModuleLoader__.load({
32113
32163
  className: "daw-list-title",
32114
32164
  children: [
32115
32165
  props.device.name,
32116
- " ",
32117
32166
  props.device.revokedAt !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32118
32167
  className: "daw-pill",
32119
32168
  children: t("revoked")
32169
+ }),
32170
+ autoNamed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32171
+ className: "daw-pill",
32172
+ title: t("deviceRenameHint"),
32173
+ children: t("deviceDetectedName")
32120
32174
  })
32121
32175
  ]
32122
32176
  }),
@@ -32127,47 +32181,66 @@ window.__ModuleLoader__.load({
32127
32181
  title: props.device.id,
32128
32182
  children: shortId
32129
32183
  }),
32184
+ props.device.platform !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
32185
+ " · ",
32186
+ props.device.platform,
32187
+ props.device.model === void 0 ? "" : ` · ${props.device.model}`
32188
+ ] }),
32130
32189
  " · ",
32131
32190
  lastSeen,
32132
32191
  " · ",
32133
32192
  props.device.scopes.length === 0 ? t("noScopes") : props.device.scopes.map((scope) => scopeLabel(locale, scope)).join(" · ")
32134
32193
  ]
32135
32194
  }),
32136
- editing && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32137
- className: "daw-scope-grid",
32138
- children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32139
- className: "daw-check",
32140
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32141
- type: "checkbox",
32142
- checked: scopes.includes(scope),
32143
- onChange: () => setScopes((current) => toggle(current, scope))
32144
- }), scopeLabel(locale, scope)]
32145
- }, scope))
32146
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32147
- className: "daw-scope-grid",
32148
- children: props.roots.map((root) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32149
- className: "daw-check",
32150
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32151
- type: "checkbox",
32152
- checked: rootIds.includes(root.id),
32153
- onChange: () => setRootIds((current) => toggle(current, root.id))
32154
- }), root.label]
32155
- }, root.id))
32156
- })] })
32195
+ editing && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
32196
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32197
+ className: "daw-field",
32198
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("deviceRename") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32199
+ className: "daw-input",
32200
+ value: name,
32201
+ placeholder: t("deviceRenamePlaceholder"),
32202
+ title: t("deviceRenameHint"),
32203
+ onChange: (event) => setName(event.target.value),
32204
+ onKeyDown: (event) => {
32205
+ if (event.key === "Enter") saveAll();
32206
+ }
32207
+ })]
32208
+ }),
32209
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32210
+ className: "daw-scope-grid",
32211
+ children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32212
+ className: "daw-check",
32213
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32214
+ type: "checkbox",
32215
+ checked: scopes.includes(scope),
32216
+ onChange: () => setScopes((current) => toggle(current, scope))
32217
+ }), scopeLabel(locale, scope)]
32218
+ }, scope))
32219
+ }),
32220
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32221
+ className: "daw-scope-grid",
32222
+ children: props.roots.map((root) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32223
+ className: "daw-check",
32224
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32225
+ type: "checkbox",
32226
+ checked: rootIds.includes(root.id),
32227
+ onChange: () => setRootIds((current) => toggle(current, root.id))
32228
+ }), root.label]
32229
+ }, root.id))
32230
+ })
32231
+ ] })
32157
32232
  ] }), props.device.revokedAt === void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
32158
32233
  editing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32159
32234
  className: "daw-command primary",
32160
- onClick: () => {
32161
- props.api.updateDevice(props.device.id, scopes, rootIds).then(props.onChanged).then(() => {
32162
- setEditing(false);
32163
- props.onNotice("permissionsSaved");
32164
- }).catch(props.onError);
32165
- },
32235
+ onClick: saveAll,
32166
32236
  children: t("save")
32167
32237
  }),
32168
32238
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32169
32239
  className: "daw-command",
32170
- onClick: () => setEditing((value) => !value),
32240
+ onClick: () => {
32241
+ setName(props.device.name);
32242
+ setEditing((value) => !value);
32243
+ },
32171
32244
  children: editing ? t("cancel") : t("permissions")
32172
32245
  }),
32173
32246
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {