dsh-remote-bridge 2.4.3 → 2.5.0
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 +18 -10
- package/README.md +18 -10
- package/cordis.patch.yml +14 -10
- package/docs/api/asyncapi.yaml +1 -1
- package/docs/api/openapi.yaml +21 -2
- package/kotlin-sdk/src/main/kotlin/ai/deepseek/dsh/workspace/DshWorkspaceClient.kt +33 -0
- package/lib/index.d.ts +54 -6
- package/lib/index.js +214 -44
- package/package.json +1 -1
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.
|
|
12
|
+
<img alt="Version" src="https://img.shields.io/badge/version-v2.5.0-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">
|
|
@@ -36,9 +36,17 @@ The interface is bilingual and defaults to Chinese. Prebuilt packages run on Win
|
|
|
36
36
|
> `engines.dsh` is recognized by the 0.2.1 kernel (`prepareProfileEntries` in
|
|
37
37
|
> `dsh-app-boot/lib/index.js`) — an unmet range only sets that entry to `disabled` and prints to
|
|
38
38
|
> stderr; **the host process does not exit**. So no code change is needed to run on `0.2.1-rc.2`.
|
|
39
|
-
>
|
|
40
|
-
>
|
|
41
|
-
>
|
|
39
|
+
>
|
|
40
|
+
> **Functional differences between the two versions (important)**: the `inject` list holds **only
|
|
41
|
+
> services that exist in both versions** — cordis treats `inject` as a **hard dependency**, and a
|
|
42
|
+
> single missing service leaves the whole plugin inactive (proven by CI on the 0.2.0-rc.2 baseline:
|
|
43
|
+
> writing a 0.2.1-only service into `inject` once made the plugin fail to activate on 0.2.0).
|
|
44
|
+
> The services that only 0.2.1 provides (**background jobs** `jobController`, the **permission
|
|
45
|
+
> catalog** `permissionPresets`, file upload `fileUploads`, attachment storage `attachments`) are
|
|
46
|
+
> therefore read at runtime through `ctx.reflect.get(name, false)`: **all of them work on
|
|
47
|
+
> 0.2.1-rc.2 and degrade automatically on 0.2.0-rc.2** (the jobs endpoints answer 404 there, the
|
|
48
|
+
> permission catalog falls back to its built-in one) — the plugin itself runs on both kernels.
|
|
49
|
+
> `GET /api/v1/debug/host-services` reports which optional capabilities the current machine has.
|
|
42
50
|
|
|
43
51
|
## What it looks like
|
|
44
52
|
|
|
@@ -94,10 +102,10 @@ dsh plugin --profile web add dsh-remote-bridge
|
|
|
94
102
|
dsh plugin --profile web add dsh-remote-bridge --registry https://registry.npmmirror.com
|
|
95
103
|
|
|
96
104
|
# 2. tarball from the GitHub release
|
|
97
|
-
dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.
|
|
105
|
+
dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.5.0/dsh-remote-bridge-v2.5.0.tgz
|
|
98
106
|
|
|
99
107
|
# 3. tarball from the Gitee release (faster from mainland China)
|
|
100
|
-
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.
|
|
108
|
+
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.5.0/dsh-remote-bridge-v2.5.0.tgz
|
|
101
109
|
|
|
102
110
|
npx @deepseek-ai/dsh web
|
|
103
111
|
```
|
|
@@ -114,7 +122,7 @@ Restart DSH after installation, then open settings — The section is titled **
|
|
|
114
122
|
To install a downloaded release archive:
|
|
115
123
|
|
|
116
124
|
```sh
|
|
117
|
-
dsh plugin --profile web add ./dsh-remote-bridge-v2.
|
|
125
|
+
dsh plugin --profile web add ./dsh-remote-bridge-v2.5.0.tgz
|
|
118
126
|
```
|
|
119
127
|
|
|
120
128
|
## First connection
|
|
@@ -346,13 +354,13 @@ pnpm pack
|
|
|
346
354
|
|
|
347
355
|
`pnpm check` covers type-checking, documentation consistency, and tests. It currently passes **30 test files / 226 cases**, plus **4 browser-layer cases** (Playwright measuring real computed styles).
|
|
348
356
|
|
|
349
|
-
`npm pack` produces roughly a **0.55 MB** archive (v2.
|
|
357
|
+
`npm pack` produces roughly a **0.55 MB** archive (v2.5.0 measured 572,951 bytes, SHA-256 `38f25c10…299c93`): 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 and the permission BFF.
|
|
350
358
|
|
|
351
|
-
v2.
|
|
359
|
+
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.
|
|
352
360
|
|
|
353
361
|
## Project
|
|
354
362
|
|
|
355
|
-
- Version: `v2.
|
|
363
|
+
- Version: `v2.5.0`
|
|
356
364
|
- Author: upstream [Hakunm](https://github.com/Hakunm); this fork maintained by [westanke](https://github.com/westanke)
|
|
357
365
|
- Repository: [GitHub](https://github.com/westanke/dsh-remote-bridge) · [Gitee](https://gitee.com/westanke/dsh-remote-bridge)
|
|
358
366
|
- npm: [`dsh-remote-bridge`](https://www.npmjs.com/package/dsh-remote-bridge) ([mainland-China mirror](https://registry.npmmirror.com/dsh-remote-bridge))
|
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.
|
|
12
|
+
<img alt="Version" src="https://img.shields.io/badge/version-v2.5.0-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">
|
|
@@ -26,13 +26,21 @@
|
|
|
26
26
|
> 0.2.x 上无法启动(表现为「添加根目录返回 405」)。兼容层的完整说明——基于什么、为什么、改了什么、
|
|
27
27
|
> 测了什么——见 [DSH 0.2.x 兼容性适配说明](./docs/project/DSH-0.2-COMPAT.md)。
|
|
28
28
|
>
|
|
29
|
-
> **对 DSH 0.2.1-rc.2 的适配结论**(2026-10-20 复核,依据
|
|
29
|
+
> **对 DSH 0.2.1-rc.2 的适配结论**(2026-10-20 复核,依据 `docs/project/DSH-0.2.1-ADAPTATION.md`):
|
|
30
30
|
> 本插件**刻意不 import 任何 `@deepseek-ai/*` 包**,全部宿主服务经结构类型 + Host 注入消费
|
|
31
31
|
> (见 `src/host/dsh-0.2-bridge.ts` 头注释)。逐一到 `0.2.1-rc.2` 实装包核对:`cordis.patch.yml`
|
|
32
32
|
> 的 10 项 `inject` 服务名**全部仍存在且签名未变**,`engines.dsh` 在 0.2.1 被内核识别
|
|
33
33
|
> (`dsh-app-boot/lib/index.js` 的 `prepareProfileEntries`),不满足时只把该 entry 置
|
|
34
34
|
> `disabled` 并打 stderr,**宿主进程不退出**。故本插件无需改码即可运行于 `0.2.1-rc.2`。
|
|
35
|
-
>
|
|
35
|
+
>
|
|
36
|
+
> **两版的功能差异(重要)**:`inject` 里只放**两个版本都存在**的服务 —— cordis 的 inject 是
|
|
37
|
+
> **硬依赖**,任何一个服务缺失就整个插件不激活(CI 在 0.2.0-rc.2 基线上实锤过:
|
|
38
|
+
> 一度因为把 0.2.1 才有的服务写进 inject 而让插件在 0.2.0 上整体不激活)。
|
|
39
|
+
> 因此 0.2.1 才提供的服务(**后台任务** `jobController`、**权限档位目录**
|
|
40
|
+
> `permissionPresets`、文件上传 `fileUploads`、附件存储 `attachments`)改为运行时经
|
|
41
|
+
> `ctx.reflect.get(name, false)` 读取:**0.2.1-rc.2 上这些功能全部可用,0.2.0-rc.2 上
|
|
42
|
+
> 自动降级**(如后台任务端点返回 404、权限档位走内置目录),插件本体两种内核都能跑。
|
|
43
|
+
> 用 `GET /api/v1/debug/host-services` 可以查这台机器上哪些可选能力可用。
|
|
36
44
|
|
|
37
45
|
## 实际效果
|
|
38
46
|
|
|
@@ -85,10 +93,10 @@ dsh plugin --profile web add dsh-remote-bridge
|
|
|
85
93
|
dsh plugin --profile web add dsh-remote-bridge --registry https://registry.npmmirror.com
|
|
86
94
|
|
|
87
95
|
# ② GitHub Release 的 tarball
|
|
88
|
-
dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.
|
|
96
|
+
dsh plugin --profile web add https://github.com/westanke/dsh-remote-bridge/releases/download/v2.5.0/dsh-remote-bridge-v2.5.0.tgz
|
|
89
97
|
|
|
90
98
|
# ③ Gitee Release 的 tarball(国内访问更快)
|
|
91
|
-
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.
|
|
99
|
+
dsh plugin --profile web add https://gitee.com/westanke/dsh-remote-bridge/releases/download/v2.5.0/dsh-remote-bridge-v2.5.0.tgz
|
|
92
100
|
|
|
93
101
|
npx @deepseek-ai/dsh web
|
|
94
102
|
```
|
|
@@ -104,7 +112,7 @@ npx @deepseek-ai/dsh web
|
|
|
104
112
|
从 GitHub Release 下载离线安装包时,可直接安装 tarball:
|
|
105
113
|
|
|
106
114
|
```sh
|
|
107
|
-
dsh plugin --profile web add ./dsh-remote-bridge-v2.
|
|
115
|
+
dsh plugin --profile web add ./dsh-remote-bridge-v2.5.0.tgz
|
|
108
116
|
```
|
|
109
117
|
|
|
110
118
|
## 第一次使用
|
|
@@ -332,15 +340,15 @@ pnpm pack
|
|
|
332
340
|
|
|
333
341
|
`pnpm check` 覆盖类型检查、文档一致性与测试;当前 **30 个测试文件 / 226 个用例**,外加 **4 个浏览器层用例**(Playwright 实测量出的计算样式),全部通过。
|
|
334
342
|
|
|
335
|
-
`npm pack` 的产物约 **0.55 MB**(v2.
|
|
343
|
+
`npm pack` 的产物约 **0.55 MB**(v2.5.0 实测 572,951 字节,SHA-256 `38f25c10…299c93`):2.2.0 删除独立页及其编辑器依赖、并关闭 source map 之后从 1.72 MB 降下来,之后随投影透传与权限 BFF 小幅增长。
|
|
336
344
|
|
|
337
|
-
v2.
|
|
338
|
-
|
|
345
|
+
v2.5.0 在 DSH **0.2.1-rc.2** 上完成真机验证:14/14 宿主服务全部可注入、三个 jobs 端点实测通过、
|
|
346
|
+
权限档位目录返回内核真实三档。开发基线为上游 `dsh-v0.2.1-rc.2`(commit `687f282e8625c6b8d11ff58211a90524be2e4317`),
|
|
339
347
|
兼容范围见上文 `engines.dsh`。CI 覆盖 Windows、Ubuntu 和 macOS。
|
|
340
348
|
|
|
341
349
|
## 项目信息
|
|
342
350
|
|
|
343
|
-
- 当前版本:`v2.
|
|
351
|
+
- 当前版本:`v2.5.0`
|
|
344
352
|
- 作者:上游 [Hakunm](https://github.com/Hakunm),本 fork 维护 [westanke](https://github.com/westanke)
|
|
345
353
|
- 仓库:[GitHub](https://github.com/westanke/dsh-remote-bridge) · [Gitee](https://gitee.com/westanke/dsh-remote-bridge)
|
|
346
354
|
- npm:[`dsh-remote-bridge`](https://www.npmjs.com/package/dsh-remote-bridge)([国内镜像](https://registry.npmmirror.com/dsh-remote-bridge))
|
package/cordis.patch.yml
CHANGED
|
@@ -16,6 +16,20 @@
|
|
|
16
16
|
# 这一条曾长期掩盖真相:`permissionPresets` 一直以为「web profile 注不进、
|
|
17
17
|
# 只能走内置兜底目录」,实际是这里没声明它。`jobController` 加进来之后
|
|
18
18
|
# 同样注不进,才顺着 `_refresh()` 的 INACTIVE 逻辑反查到这条覆盖规则。
|
|
19
|
+
# ⚠️ 这份列表**只能放「两个支持版本上都存在的服务」**。
|
|
20
|
+
# cordis 的 inject 是**硬依赖**:任何一个服务缺失 → 整个 entry 永久 INACTIVE。
|
|
21
|
+
# 2026-10-20 CI 实锤:把 jobController / fileUploads / attachments /
|
|
22
|
+
# permissionPresets 加进来后,插件在 **0.2.0-rc.2**(smoke-profile-install
|
|
23
|
+
# 检出的上游基线)上整体不激活,报
|
|
24
|
+
# "waiting for services: ..., jobController, fileUploads" ——
|
|
25
|
+
# 那一版内核的 web profile 没挂这些服务。
|
|
26
|
+
#
|
|
27
|
+
# 可选服务(permissionPresets / jobController / fileUploads / attachments /
|
|
28
|
+
# workingDirectory)**不在这里声明**,由插件在运行时经
|
|
29
|
+
# `ctx.reflect.get(name, false)` 读取(cordis 给「不带 inject 要求」读服务
|
|
30
|
+
# 准备的正路,见 dsh-0.2-bridge.ts 的 optionalService 注释):
|
|
31
|
+
# 0.2.1-rc.2 上读到就走全功能,0.2.0-rc.2 上读不到就按能力位降级。
|
|
32
|
+
# 探针(GET /debug/host-services)继续报告这 5 个名字的真实可用性。
|
|
19
33
|
inject: [
|
|
20
34
|
sessionController,
|
|
21
35
|
workspaceController,
|
|
@@ -23,20 +37,10 @@
|
|
|
23
37
|
settingsController,
|
|
24
38
|
credentialsController,
|
|
25
39
|
agentPresets,
|
|
26
|
-
# 权限档位目录(`ctx.permissionPresets`):内核把它做成独立的 Typert
|
|
27
|
-
# 远程服务,会话投影那条 wire 视图只给 currentValue、不带候选项。
|
|
28
|
-
permissionPresets,
|
|
29
40
|
llm,
|
|
30
41
|
webServer,
|
|
31
42
|
agents,
|
|
32
43
|
commands,
|
|
33
|
-
# 后台任务(`ctx.jobController`):远程场景下任务跑飞了要能停掉。
|
|
34
|
-
jobController,
|
|
35
|
-
# 文件上传服务(`ctx.fileUploads`):用它而不是内核那条只绑 loopback 的
|
|
36
|
-
# /api/session/uploadFileBinary HTTP 路由。
|
|
37
|
-
fileUploads,
|
|
38
|
-
# 附件存储(`ctx.attachments`):会话历史里的图片以 attachmentId 引用存在。
|
|
39
|
-
attachments,
|
|
40
44
|
]
|
|
41
45
|
config:
|
|
42
46
|
# 状态目录**不改名**:已装的机器上有既有数据库与回收站,换目录等于让用户
|
package/docs/api/asyncapi.yaml
CHANGED
package/docs/api/openapi.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: DeepSeek Harness Mobile API
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.5.0
|
|
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
|
|
@@ -1408,8 +1408,26 @@ components:
|
|
|
1408
1408
|
configurable: { type: boolean }
|
|
1409
1409
|
configured: { type: boolean }
|
|
1410
1410
|
removable: { type: boolean }
|
|
1411
|
+
defaultBaseURL: { type: string, description: '目录供应商的适配器默认 API 地址(未配置时使用)。' }
|
|
1412
|
+
baseURLEnv:
|
|
1413
|
+
type: array
|
|
1414
|
+
items: { type: string }
|
|
1415
|
+
description: '可能覆盖默认地址的环境变量,按优先级排序。'
|
|
1416
|
+
nativeAuth: { $ref: '#/components/schemas/NativeAuthHint' }
|
|
1417
|
+
settingsNs:
|
|
1418
|
+
type: string
|
|
1419
|
+
description: 目录条目的设置命名空间;客户端用它捕获 expectedRevision(打开卡片那一刻的 revision)。
|
|
1411
1420
|
credential: { $ref: '#/components/schemas/CredentialState' }
|
|
1412
1421
|
config: { $ref: '#/components/schemas/ProviderConfig' }
|
|
1422
|
+
NativeAuthHint:
|
|
1423
|
+
type: object
|
|
1424
|
+
required: [env, oauth]
|
|
1425
|
+
properties:
|
|
1426
|
+
env:
|
|
1427
|
+
type: array
|
|
1428
|
+
items: { type: string }
|
|
1429
|
+
description: '适配器原生认证依赖的环境变量。'
|
|
1430
|
+
oauth: { type: boolean, description: '是否支持 OAuth 登录。' }
|
|
1413
1431
|
ProviderSettings:
|
|
1414
1432
|
type: object
|
|
1415
1433
|
required: [writable, revisionByNamespace, customProvider, providers]
|
|
@@ -1433,7 +1451,8 @@ components:
|
|
|
1433
1451
|
thinking: { type: [string, 'null'] }
|
|
1434
1452
|
reasoningEffort: { type: [string, 'null'] }
|
|
1435
1453
|
models:
|
|
1436
|
-
type: array
|
|
1454
|
+
type: [array, 'null']
|
|
1455
|
+
description: '数组=整体替换模型目录(空数组=自定义空目录);`null`=恢复适配器默认目录(unset)。'
|
|
1437
1456
|
items: { $ref: '#/components/schemas/ProviderModel' }
|
|
1438
1457
|
expectedRevision: { type: integer, minimum: 0 }
|
|
1439
1458
|
CustomProviderCapability:
|
|
@@ -206,6 +206,12 @@ data class CredentialState(
|
|
|
206
206
|
val writable: Boolean,
|
|
207
207
|
)
|
|
208
208
|
|
|
209
|
+
@Serializable
|
|
210
|
+
data class NativeAuthHint(
|
|
211
|
+
val env: List<String> = emptyList(),
|
|
212
|
+
val oauth: Boolean = false,
|
|
213
|
+
)
|
|
214
|
+
|
|
209
215
|
@Serializable
|
|
210
216
|
data class ProviderView(
|
|
211
217
|
val id: String,
|
|
@@ -215,6 +221,11 @@ data class ProviderView(
|
|
|
215
221
|
val configurable: Boolean,
|
|
216
222
|
val configured: Boolean,
|
|
217
223
|
val removable: Boolean,
|
|
224
|
+
val defaultBaseURL: String? = null,
|
|
225
|
+
val baseURLEnv: List<String>? = null,
|
|
226
|
+
val nativeAuth: NativeAuthHint? = null,
|
|
227
|
+
/** 目录条目的设置命名空间;客户端用它捕获 expectedRevision(打开卡片那一刻的 revision)。 */
|
|
228
|
+
val settingsNs: String? = null,
|
|
218
229
|
val credential: CredentialState,
|
|
219
230
|
val config: ProviderConfig,
|
|
220
231
|
)
|
|
@@ -242,6 +253,7 @@ data class ProviderPatch(
|
|
|
242
253
|
val apiKey: String? = null,
|
|
243
254
|
val thinking: String? = null,
|
|
244
255
|
val reasoningEffort: String? = null,
|
|
256
|
+
/** 数组=整体替换(空数组=自定义空目录);恢复默认目录用 [DshWorkspaceClient.resetProviderModels](wire `models: null`)。 */
|
|
245
257
|
val models: List<ProviderModel>? = null,
|
|
246
258
|
val expectedRevision: Int? = null,
|
|
247
259
|
)
|
|
@@ -426,6 +438,27 @@ class DshWorkspaceClient(
|
|
|
426
438
|
return executeJson("settings/providers/$providerId", "PATCH", body, ProviderSettings.serializer())
|
|
427
439
|
}
|
|
428
440
|
|
|
441
|
+
/**
|
|
442
|
+
* 恢复供应商的适配器默认模型目录(wire `models: null`)。data class 无法把
|
|
443
|
+
* null 默认值与「字段缺席」区分开,所以这里手工拼 body。
|
|
444
|
+
*/
|
|
445
|
+
fun resetProviderModels(providerId: String, expectedRevision: Int? = null): ProviderSettings {
|
|
446
|
+
val body = buildString {
|
|
447
|
+
append("{\"models\":null")
|
|
448
|
+
if (expectedRevision != null) append(",\"expectedRevision\":").append(expectedRevision)
|
|
449
|
+
append('}')
|
|
450
|
+
}
|
|
451
|
+
return executeJson("settings/providers/$providerId", "PATCH", body, ProviderSettings.serializer())
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
fun deleteProvider(providerId: String, expectedRevision: Int? = null) {
|
|
455
|
+
val relative = buildString {
|
|
456
|
+
append("settings/providers/").append(providerId)
|
|
457
|
+
if (expectedRevision != null) append("?expectedRevision=").append(expectedRevision)
|
|
458
|
+
}
|
|
459
|
+
executeUnit(relative, "DELETE", null)
|
|
460
|
+
}
|
|
461
|
+
|
|
429
462
|
fun createCustomProvider(provider: CustomProviderCreate): ProviderSettings {
|
|
430
463
|
val body = json.encodeToString(CustomProviderCreate.serializer(), provider)
|
|
431
464
|
return executeJson("settings/providers", "POST", body, ProviderSettings.serializer())
|
package/lib/index.d.ts
CHANGED
|
@@ -78,7 +78,9 @@ declare class WorkspaceDatabase {
|
|
|
78
78
|
readonly dataDir: string;
|
|
79
79
|
readonly trashDir: string;
|
|
80
80
|
private readonly db;
|
|
81
|
+
private closed;
|
|
81
82
|
constructor(dataDir: string);
|
|
83
|
+
/** 关闭数据库。**幂等** —— 重复调用不抛错。 */
|
|
82
84
|
close(): void;
|
|
83
85
|
/**
|
|
84
86
|
* 导出 `roots` 的建表语句(只读,诊断与迁移测试用)。
|
|
@@ -218,6 +220,12 @@ interface ConfigurableProvider {
|
|
|
218
220
|
settingsPath: string[];
|
|
219
221
|
active: boolean;
|
|
220
222
|
declared?: boolean;
|
|
223
|
+
defaultBaseURL?: string;
|
|
224
|
+
baseURLEnv?: string[];
|
|
225
|
+
nativeAuth?: {
|
|
226
|
+
env?: string[];
|
|
227
|
+
oauth?: boolean;
|
|
228
|
+
};
|
|
221
229
|
}
|
|
222
230
|
interface SettingsApiProxy extends DshApiProxy {
|
|
223
231
|
settings: {
|
|
@@ -326,6 +334,14 @@ interface ProviderView {
|
|
|
326
334
|
configurable: boolean;
|
|
327
335
|
configured: boolean;
|
|
328
336
|
removable: boolean;
|
|
337
|
+
defaultBaseURL?: string;
|
|
338
|
+
baseURLEnv?: string[];
|
|
339
|
+
nativeAuth?: {
|
|
340
|
+
env?: string[];
|
|
341
|
+
oauth?: boolean;
|
|
342
|
+
};
|
|
343
|
+
/** 目录条目的设置命名空间;客户端用它捕获 expectedRevision(打开卡片那一刻的 revision)。 */
|
|
344
|
+
settingsNs?: string;
|
|
329
345
|
credential: {
|
|
330
346
|
ref: string;
|
|
331
347
|
configured: boolean;
|
|
@@ -341,7 +357,8 @@ interface ProviderPatch {
|
|
|
341
357
|
apiKey?: string | null;
|
|
342
358
|
thinking?: string | null;
|
|
343
359
|
reasoningEffort?: string | null;
|
|
344
|
-
|
|
360
|
+
/** `null` 恢复适配器默认模型目录(unset),`[]` 写入自定义空目录。 */
|
|
361
|
+
models?: ProviderModelView[] | null;
|
|
345
362
|
expectedRevision?: number;
|
|
346
363
|
}
|
|
347
364
|
interface CustomProviderCreate {
|
|
@@ -738,6 +755,14 @@ interface LlmLike {
|
|
|
738
755
|
settingsNs: string;
|
|
739
756
|
settingsPath: readonly string[];
|
|
740
757
|
declared?: boolean;
|
|
758
|
+
defaultBaseURL?: string;
|
|
759
|
+
/** Environment variables that may override the default base URL, in priority order. */
|
|
760
|
+
baseURLEnv?: readonly string[];
|
|
761
|
+
/** Native authentication the adapter can fall back to when no key is stored. */
|
|
762
|
+
nativeAuth?: {
|
|
763
|
+
env?: readonly string[];
|
|
764
|
+
oauth?: boolean;
|
|
765
|
+
};
|
|
741
766
|
error?: string;
|
|
742
767
|
}[];
|
|
743
768
|
discoverModels(settingsNs: string, request: unknown, signal?: AbortSignal): Promise<readonly unknown[]>;
|
|
@@ -761,7 +786,14 @@ interface Dsh02HostServices {
|
|
|
761
786
|
settingsController: SettingsControllerLike;
|
|
762
787
|
credentialsController: CredentialsControllerLike;
|
|
763
788
|
agentPresets: AgentPresetsLike;
|
|
764
|
-
|
|
789
|
+
/**
|
|
790
|
+
* 权限档位目录(`ctx.permissionPresets`)——**可选**。
|
|
791
|
+
*
|
|
792
|
+
* 0.2.1 的 web profile 上有;0.2.0-rc.2 上没有。inject 是硬依赖,
|
|
793
|
+
* 所以它不能进 inject,只能声明成可选,由 `optionalService` 运行时读;
|
|
794
|
+
* 读不到时权限档位走 `FALLBACK_PERMISSION_CATALOG` + BFF。
|
|
795
|
+
*/
|
|
796
|
+
permissionPresets?: PermissionPresetsLike;
|
|
765
797
|
/**
|
|
766
798
|
* 后台任务控制器(`ctx.jobController`,`@deepseek-ai/dsh-api-job-controller`)。
|
|
767
799
|
*
|
|
@@ -771,7 +803,14 @@ interface Dsh02HostServices {
|
|
|
771
803
|
* 与 `sessionController` 不同,这个是 `TypertRemoteService`(不是 `@Remote`
|
|
772
804
|
* 控制器集合),但结构类型消费方式完全一样:只声明用到的成员,缺了就明确报错。
|
|
773
805
|
*/
|
|
774
|
-
|
|
806
|
+
/**
|
|
807
|
+
* 后台任务控制器(`ctx.jobController`)——**可选**。
|
|
808
|
+
*
|
|
809
|
+
* 只有 DSH 0.2.1 的 web profile 挂了这个服务;0.2.0-rc.2 上没有,
|
|
810
|
+
* 而 inject 是硬依赖(缺一个整个插件不激活),所以它**不能**出现在 inject 里,
|
|
811
|
+
* 只能在这里声明成可选,由 `optionalService` 运行时读。
|
|
812
|
+
*/
|
|
813
|
+
jobController?: JobControllerLike;
|
|
775
814
|
llm: LlmLike;
|
|
776
815
|
/**
|
|
777
816
|
* 内核附件存储(`ctx.attachments`);会话历史里的图片要经它取字节。
|
|
@@ -782,14 +821,23 @@ interface Dsh02HostServices {
|
|
|
782
821
|
* `cannot get property "attachments" without inject` —— 请求链路本身没问题,
|
|
783
822
|
* 挂在服务端、而且只在真的去取图时才炸,所以很难在本地测出来。
|
|
784
823
|
*/
|
|
785
|
-
attachments: AttachmentStoreLike;
|
|
786
824
|
/**
|
|
787
|
-
*
|
|
825
|
+
* 内核附件存储(`ctx.attachments`)——**可选**。
|
|
826
|
+
*
|
|
827
|
+
* 0.2.0-rc.2 的 web profile 上没有这个服务(诊断日志实测 MISSING),
|
|
828
|
+
* 而 inject 是硬依赖,所以它只能声明成可选,由 `optionalService` 运行时读;
|
|
829
|
+
* 读不到时附件读取走内核 `sessionController.attachment()`。
|
|
830
|
+
*/
|
|
831
|
+
attachments?: AttachmentStoreLike;
|
|
832
|
+
/**
|
|
833
|
+
* Kernel file-upload service (`ctx.fileUploads`)——**可选**。
|
|
788
834
|
*
|
|
789
835
|
* 为什么用它而不是内核那条 `/api/session/uploadFileBinary` HTTP 路由:那条路由
|
|
790
836
|
* 只绑 loopback(手机根本到不了),而这个 cordis 服务就在同一个进程里,插件可直接调。
|
|
837
|
+
*
|
|
838
|
+
* 可选的原因同 [attachments]:0.2.0-rc.2 的 web profile 上没有它。
|
|
791
839
|
*/
|
|
792
|
-
fileUploads
|
|
840
|
+
fileUploads?: FileUploadsLike;
|
|
793
841
|
/**
|
|
794
842
|
* Cordis event bus of the host context. Used for the two facts the old
|
|
795
843
|
* `apiProxy` event streams carried that no controller returns: session
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import path, { join } from "node:path";
|
|
1
|
+
import path, { join, sep } from "node:path";
|
|
2
2
|
import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { execFile, spawn } from "node:child_process";
|
|
4
4
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
@@ -134,10 +134,22 @@ function isLoopbackOrigin(origin) {
|
|
|
134
134
|
}
|
|
135
135
|
//#endregion
|
|
136
136
|
//#region src/host/cloudflared.ts
|
|
137
|
-
/**
|
|
138
|
-
|
|
137
|
+
/**
|
|
138
|
+
* 平台/架构 -> cloudflared 归档后缀。
|
|
139
|
+
*
|
|
140
|
+
* **架构必须能单独传**,不能读 `process.arch`:
|
|
141
|
+
* 2026-10-20 CI 实锤 —— 旧实现里 platform 是参数、arch 却写死 `process.arch`,
|
|
142
|
+
* 于是 `archSuffix('linux')` 在 macOS runner(arm64)上返回 `linux-arm64`,
|
|
143
|
+
* 而调用方要的是「linux 平台该用哪个官方归档」。同一函数里 platform 可控、
|
|
144
|
+
* arch 不可控,是**自相矛盾的设计**;测试在 macOS 上红、Linux 上绿,全平台矩阵
|
|
145
|
+
* (ubuntu/windows/macos × 两个 Node 版本)里只有 Linux 那两格能过。
|
|
146
|
+
*
|
|
147
|
+
* 两个参数都带默认值时保持旧行为(本机平台 + 本机架构);显式传入时,
|
|
148
|
+
* 结果只由入参决定 —— 这才让「算一个别的平台的后缀」成为可能。
|
|
149
|
+
*/
|
|
150
|
+
function archSuffix(platform = process.platform, arch = process.arch) {
|
|
139
151
|
if (platform !== "linux") throw new Error(`cloudflared 尚不支持 ${platform} 平台。`);
|
|
140
|
-
switch (
|
|
152
|
+
switch (arch) {
|
|
141
153
|
case "x64": return "linux-amd64";
|
|
142
154
|
case "arm64": return "linux-arm64";
|
|
143
155
|
default: throw new Error(`cloudflared 尚不支持 ${process.arch} 架构。`);
|
|
@@ -197,8 +209,20 @@ function isDebSource() {
|
|
|
197
209
|
return mirrorBase().startsWith("deb+");
|
|
198
210
|
}
|
|
199
211
|
/** 返回要下载的源 URL:deb 源返回完整 .deb 地址;裸镜像基址拼架构后缀;否则官方 GitHub。 */
|
|
200
|
-
|
|
201
|
-
|
|
212
|
+
/**
|
|
213
|
+
* cloudflared 官方归档的下载地址。
|
|
214
|
+
*
|
|
215
|
+
* **platform/arch 都能显式传入**(2026-10-20 CI 实锤后加):旧实现里
|
|
216
|
+
* `downloadUrlFor()` 走 `archSuffix()` 的默认参数 —— 也就是**本机** platform/arch。
|
|
217
|
+
* 于是这个函数在 macOS runner 上直接抛「尚不支持 darwin 平台」,
|
|
218
|
+
* 而它要回答的问题其实是「linux 的 cloudflared 该从哪下」。
|
|
219
|
+
*
|
|
220
|
+
* 默认值保持本机(生产调用点 `ensureCloudflared` 就在 linux 上跑,行为不变),
|
|
221
|
+
* 但显式传入后结果只由入参决定 —— 这才让「算别的平台的 URL」成为可能,
|
|
222
|
+
* 也让测试不依赖跑在什么机器上。
|
|
223
|
+
*/
|
|
224
|
+
function downloadUrlFor(platform = process.platform, arch = process.arch) {
|
|
225
|
+
const suffix = archSuffix(platform, arch);
|
|
202
226
|
const mirror = mirrorBase();
|
|
203
227
|
if (mirror === "") return `${OFFICIAL_BASE}${suffix}`;
|
|
204
228
|
if (mirror.startsWith("deb+")) return mirror.slice(4);
|
|
@@ -796,7 +820,33 @@ function parentWirePath(relativePath) {
|
|
|
796
820
|
* 调用方必须自己给出可读的错误(见 readAttachment),而不是让用户看到一个
|
|
797
821
|
* 语焉不详的 500。
|
|
798
822
|
*/
|
|
823
|
+
/**
|
|
824
|
+
* 读一个**可选**宿主服务:存在就返回,不存在返回 null。
|
|
825
|
+
*
|
|
826
|
+
* ## 为什么必须走 `ctx.reflect.get(name, false)`,不能直接读属性
|
|
827
|
+
*
|
|
828
|
+
* cordis 的 `inject` 是**硬依赖**(`registry.d.ts` 的 `Inject` 类型只有数组与映射两种
|
|
829
|
+
* 形式,注释原文是 "required services"):inject 里任何一个服务缺失,`_refresh()`
|
|
830
|
+
* 就把 epoch 置为 INACTIVE,**整个插件不激活**。
|
|
831
|
+
*
|
|
832
|
+
* 所以「可选服务」**不能**写进 inject —— 写了就变成「哪个内核版本缺它、插件在哪一版
|
|
833
|
+
* 整个死掉」。而直接读属性(`host[name]`)也不行:不在 inject 里的属性会抛
|
|
834
|
+
* `cannot get property "x" without inject`,与「服务不存在」**报同一个错**,
|
|
835
|
+
* 于是这个函数会永远返回 null(这正是它此前的实际行为,
|
|
836
|
+
* `attachments` / `fileUploads` 一直在走降级路径)。
|
|
837
|
+
*
|
|
838
|
+
* `ctx.reflect.get(name, false)` 是 cordis 给这件事准备的正路
|
|
839
|
+
* (`cordis/lib/index.js` 的 ReflectService.get,注释原文:
|
|
840
|
+
* "Read a service from the store **without the inject requirement**"):
|
|
841
|
+
* 不要求 inject 声明,服务不存在时返回 `undefined` 而不是抛错。
|
|
842
|
+
*/
|
|
799
843
|
function optionalService(host, name) {
|
|
844
|
+
const reflect = host.reflect;
|
|
845
|
+
if (reflect !== void 0 && typeof reflect.get === "function") try {
|
|
846
|
+
const value = reflect.get(name, false);
|
|
847
|
+
if (value === void 0 || value === null) return null;
|
|
848
|
+
return value;
|
|
849
|
+
} catch {}
|
|
800
850
|
try {
|
|
801
851
|
return host[name];
|
|
802
852
|
} catch {
|
|
@@ -1413,7 +1463,7 @@ function writeHandoffFile(dir, workspace, mediaType, data) {
|
|
|
1413
1463
|
if (!existsSync(absolute)) writeFileSync(absolute, bytes, { mode: 384 });
|
|
1414
1464
|
return {
|
|
1415
1465
|
absolute,
|
|
1416
|
-
relative: workspace !== void 0 && absolute.startsWith(workspace +
|
|
1466
|
+
relative: workspace !== void 0 && absolute.startsWith(workspace + sep) ? absolute.slice(workspace.length + 1).split(sep).join("/") : fileName
|
|
1417
1467
|
};
|
|
1418
1468
|
}
|
|
1419
1469
|
/** 交接文件名只允许「内容哈希 + 图片扩展名」:挡住一切路径穿越。 */
|
|
@@ -2013,7 +2063,13 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2013
2063
|
settingsNs: entry.settingsNs,
|
|
2014
2064
|
settingsPath: [...entry.settingsPath],
|
|
2015
2065
|
active: live.has(entry.provider),
|
|
2016
|
-
...entry.declared === void 0 ? {} : { declared: entry.declared }
|
|
2066
|
+
...entry.declared === void 0 ? {} : { declared: entry.declared },
|
|
2067
|
+
...entry.defaultBaseURL === void 0 ? {} : { defaultBaseURL: entry.defaultBaseURL },
|
|
2068
|
+
...entry.baseURLEnv === void 0 ? {} : { baseURLEnv: [...entry.baseURLEnv] },
|
|
2069
|
+
...entry.nativeAuth === void 0 ? {} : { nativeAuth: {
|
|
2070
|
+
...entry.nativeAuth,
|
|
2071
|
+
...entry.nativeAuth.env === void 0 ? {} : { env: [...entry.nativeAuth.env] }
|
|
2072
|
+
} }
|
|
2017
2073
|
})) };
|
|
2018
2074
|
}),
|
|
2019
2075
|
models: () => call(async () => {
|
|
@@ -2036,7 +2092,7 @@ function createDsh02ApiProxy(host, options = {}) {
|
|
|
2036
2092
|
}
|
|
2037
2093
|
//#endregion
|
|
2038
2094
|
//#region src/shared/version.ts
|
|
2039
|
-
const PLUGIN_VERSION = "2.
|
|
2095
|
+
const PLUGIN_VERSION = "2.5.0";
|
|
2040
2096
|
//#endregion
|
|
2041
2097
|
//#region src/host/capabilities.ts
|
|
2042
2098
|
/**
|
|
@@ -3235,10 +3291,51 @@ function redactApprovalText(value, maxLength = 1200) {
|
|
|
3235
3291
|
}
|
|
3236
3292
|
//#endregion
|
|
3237
3293
|
//#region src/host/database.ts
|
|
3294
|
+
/**
|
|
3295
|
+
* 本进程内打开的库(弱引用集合)。
|
|
3296
|
+
*
|
|
3297
|
+
* ## 为什么需要它
|
|
3298
|
+
*
|
|
3299
|
+
* `WorkspaceDatabase` 用 `PRAGMA journal_mode=WAL` 打开 sqlite,会留下
|
|
3300
|
+
* `workspace.sqlite` / `-wal` / `-shm` 三个文件,**且句柄不关就删不掉**。
|
|
3301
|
+
* Linux/macOS 上删一个仍被打开的文件是允许的(unlink 只是断链),所以问题
|
|
3302
|
+
* 在那里看不出来;**Windows 上直接 EBUSY**:
|
|
3303
|
+
*
|
|
3304
|
+
* ```
|
|
3305
|
+
* EBUSY: resource busy or locked, unlink 'C:\...\Temp\daw-xxx\state\workspace.sqlite'
|
|
3306
|
+
* ```
|
|
3307
|
+
*
|
|
3308
|
+
* 2026-10-20 CI 在 windows-latest 上实锤(macOS/Linux 全绿、Windows 全红)。
|
|
3309
|
+
*
|
|
3310
|
+
* ## 它的作用
|
|
3311
|
+
*
|
|
3312
|
+
* 进程退出时统一关闭所有还开着的库 —— 无论调用方记不记得 `close()`。
|
|
3313
|
+
* 这不是「代替 close」,而是**兜底**:正常路径仍应显式 close(早关早释放),
|
|
3314
|
+
* 但漏掉的那一个不会在 Windows 上变成 EBUSY 式的删除失败。
|
|
3315
|
+
*
|
|
3316
|
+
* 弱引用:不阻止 GC,也不阻止正常 close 后的移除(close 时会从集合里摘掉)。
|
|
3317
|
+
*/
|
|
3318
|
+
const openDatabases = /* @__PURE__ */ new Set();
|
|
3319
|
+
/** 进程退出时尽力关闭所有还开着的库。 */
|
|
3320
|
+
function closeAllOpenDatabases() {
|
|
3321
|
+
for (const ref of [...openDatabases]) {
|
|
3322
|
+
const db = ref.deref();
|
|
3323
|
+
if (db === void 0) {
|
|
3324
|
+
openDatabases.delete(ref);
|
|
3325
|
+
continue;
|
|
3326
|
+
}
|
|
3327
|
+
try {
|
|
3328
|
+
db.close();
|
|
3329
|
+
} catch {}
|
|
3330
|
+
}
|
|
3331
|
+
openDatabases.clear();
|
|
3332
|
+
}
|
|
3333
|
+
process.once("exit", closeAllOpenDatabases);
|
|
3238
3334
|
var WorkspaceDatabase = class {
|
|
3239
3335
|
dataDir;
|
|
3240
3336
|
trashDir;
|
|
3241
3337
|
db;
|
|
3338
|
+
closed = false;
|
|
3242
3339
|
constructor(dataDir) {
|
|
3243
3340
|
this.dataDir = path.resolve(dataDir);
|
|
3244
3341
|
this.trashDir = path.join(this.dataDir, "trash");
|
|
@@ -3246,8 +3343,13 @@ var WorkspaceDatabase = class {
|
|
|
3246
3343
|
this.db = new DatabaseSync(path.join(this.dataDir, "workspace.sqlite"));
|
|
3247
3344
|
this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
|
|
3248
3345
|
this.migrate();
|
|
3346
|
+
openDatabases.add(new WeakRef(this));
|
|
3249
3347
|
}
|
|
3348
|
+
/** 关闭数据库。**幂等** —— 重复调用不抛错。 */
|
|
3250
3349
|
close() {
|
|
3350
|
+
if (this.closed) return;
|
|
3351
|
+
this.closed = true;
|
|
3352
|
+
for (const ref of [...openDatabases]) if (ref.deref() === this) openDatabases.delete(ref);
|
|
3251
3353
|
this.db.close();
|
|
3252
3354
|
}
|
|
3253
3355
|
/**
|
|
@@ -4064,7 +4166,14 @@ function trashView(record) {
|
|
|
4064
4166
|
* 使用方式:`GET /dsh-workspace-api/api/v1/debug/host-services`
|
|
4065
4167
|
* (需要设备 token 或 loopback admin,与其它 REST 端点同一套鉴权)。
|
|
4066
4168
|
*/
|
|
4067
|
-
/**
|
|
4169
|
+
/**
|
|
4170
|
+
* 插件 `cordis.patch.yml` 与 `src/index.ts` 里 inject 声明的全部服务名
|
|
4171
|
+
* (**两份清单必须一致**,docs:check 有对齐门禁)。
|
|
4172
|
+
*
|
|
4173
|
+
* ⚠️ 只能放「两个支持版本(0.2.0-rc.2 与 0.2.1-rc.2)上都存在的服务」——
|
|
4174
|
+
* cordis 的 inject 是硬依赖,缺一个整个插件不激活(2026-10-20 在 0.2.0-rc.2 的
|
|
4175
|
+
* smoke-profile-install 上实锤过)。可选服务一律走 OPTIONAL_SERVICE_NAMES。
|
|
4176
|
+
*/
|
|
4068
4177
|
const INJECTED_SERVICE_NAMES = [
|
|
4069
4178
|
"sessionController",
|
|
4070
4179
|
"workspaceController",
|
|
@@ -4072,17 +4181,26 @@ const INJECTED_SERVICE_NAMES = [
|
|
|
4072
4181
|
"settingsController",
|
|
4073
4182
|
"credentialsController",
|
|
4074
4183
|
"agentPresets",
|
|
4075
|
-
"permissionPresets",
|
|
4076
4184
|
"llm",
|
|
4077
4185
|
"webServer",
|
|
4078
4186
|
"agents",
|
|
4079
|
-
"commands"
|
|
4187
|
+
"commands"
|
|
4188
|
+
];
|
|
4189
|
+
/**
|
|
4190
|
+
* **可选服务**:不写进 inject(inject 是硬依赖),由插件经
|
|
4191
|
+
* `ctx.reflect.get(name, false)` 运行时读取 —— 读到就走该能力,读不到按能力位降级。
|
|
4192
|
+
*
|
|
4193
|
+
* 探针用同一条 `reflect.get` 读取它们:这样报告的是「内核**真的**提供了什么」,
|
|
4194
|
+
* 而不是「我们 inject 了什么」。两者的差就是「可选能力在这台内核上可用与否」。
|
|
4195
|
+
*/
|
|
4196
|
+
const OPTIONAL_SERVICE_NAMES = [
|
|
4197
|
+
"permissionPresets",
|
|
4080
4198
|
"jobController",
|
|
4081
4199
|
"fileUploads",
|
|
4082
|
-
"attachments"
|
|
4200
|
+
"attachments",
|
|
4201
|
+
"workingDirectory",
|
|
4202
|
+
"workspaceFiles"
|
|
4083
4203
|
];
|
|
4084
|
-
/** 0.2.1 新增、插件尚未 inject 但已确认存在于 web profile 的服务。 */
|
|
4085
|
-
const OBSERVED_ONLY_SERVICE_NAMES = ["workingDirectory", "workspaceFiles"];
|
|
4086
4204
|
/**
|
|
4087
4205
|
* 在注入属性**仍可读**的时机(即 `apply` 执行期间)逐个试读服务。
|
|
4088
4206
|
*
|
|
@@ -4096,6 +4214,32 @@ const OBSERVED_ONLY_SERVICE_NAMES = ["workingDirectory", "workspaceFiles"];
|
|
|
4096
4214
|
* @returns 每个服务的可解析性报告。
|
|
4097
4215
|
*/
|
|
4098
4216
|
function probeHostServices(host, pluginVersion) {
|
|
4217
|
+
const reflect = host.reflect;
|
|
4218
|
+
const probeOptional = (name) => {
|
|
4219
|
+
if (reflect === void 0 || typeof reflect.get !== "function") return {
|
|
4220
|
+
name,
|
|
4221
|
+
available: false,
|
|
4222
|
+
resolvable: false,
|
|
4223
|
+
error: "ctx.reflect.get unavailable on this cordis"
|
|
4224
|
+
};
|
|
4225
|
+
try {
|
|
4226
|
+
const value = reflect.get(name, false);
|
|
4227
|
+
const resolvable = value !== null && value !== void 0;
|
|
4228
|
+
return {
|
|
4229
|
+
name,
|
|
4230
|
+
available: true,
|
|
4231
|
+
resolvable,
|
|
4232
|
+
...resolvable ? {} : { error: "not provided by this kernel/profile" }
|
|
4233
|
+
};
|
|
4234
|
+
} catch (caught) {
|
|
4235
|
+
return {
|
|
4236
|
+
name,
|
|
4237
|
+
available: false,
|
|
4238
|
+
resolvable: false,
|
|
4239
|
+
error: caught instanceof Error ? caught.message : String(caught)
|
|
4240
|
+
};
|
|
4241
|
+
}
|
|
4242
|
+
};
|
|
4099
4243
|
const probeOne = (name) => {
|
|
4100
4244
|
let available = false;
|
|
4101
4245
|
let resolvable = false;
|
|
@@ -4116,7 +4260,7 @@ function probeHostServices(host, pluginVersion) {
|
|
|
4116
4260
|
};
|
|
4117
4261
|
};
|
|
4118
4262
|
const injected = INJECTED_SERVICE_NAMES.map(probeOne);
|
|
4119
|
-
const observed =
|
|
4263
|
+
const observed = OPTIONAL_SERVICE_NAMES.map(probeOptional);
|
|
4120
4264
|
return {
|
|
4121
4265
|
pluginVersion,
|
|
4122
4266
|
injected,
|
|
@@ -4125,7 +4269,7 @@ function probeHostServices(host, pluginVersion) {
|
|
|
4125
4269
|
injectedTotal: injected.length,
|
|
4126
4270
|
injectedAvailable: injected.filter((service) => service.available).length,
|
|
4127
4271
|
injectedMissing: injected.filter((service) => !service.available).map((service) => service.name),
|
|
4128
|
-
observedAvailable: observed.filter((service) => service.
|
|
4272
|
+
observedAvailable: observed.filter((service) => service.resolvable).map((service) => service.name)
|
|
4129
4273
|
}
|
|
4130
4274
|
};
|
|
4131
4275
|
}
|
|
@@ -4787,20 +4931,36 @@ function timestamp() {
|
|
|
4787
4931
|
//#endregion
|
|
4788
4932
|
//#region src/host/root-resolver.ts
|
|
4789
4933
|
/**
|
|
4790
|
-
*
|
|
4934
|
+
* 与 [matchAuthorizedRoot] 相同,但**两侧都做 realpath 归一**再比较。
|
|
4935
|
+
*
|
|
4936
|
+
* ## 为什么需要它
|
|
4791
4937
|
*
|
|
4792
|
-
*
|
|
4793
|
-
*
|
|
4938
|
+
* 授权根在入库时走了 `canonicalRoot()`(`database.ts` 的 addRoot),存的是**解析过
|
|
4939
|
+
* 符号链接**的路径;而客户端请求里的路径通常**没解析**。两者字面不同就匹配不上,
|
|
4940
|
+
* 结果是「明明在授权根内」却报 404 `PATH_OUTSIDE_ROOTS`。
|
|
4794
4941
|
*
|
|
4795
|
-
*
|
|
4796
|
-
*
|
|
4942
|
+
* 最常见的触发场景就是 **macOS 的临时目录**:`os.tmpdir()` 给的是
|
|
4943
|
+
* `/var/folders/...`,而它是指向 `/private/var/folders/...` 的**符号链接**。
|
|
4944
|
+
* 于是测试里 `mkdtemp(os.tmpdir())` 造出的根与请求路径字面不一致 → 404。
|
|
4945
|
+
* 2026-10-20 CI 在 macOS runner 上实锤(Linux 上 tmpdir 不带 symlink,所以只在 macOS 炸)。
|
|
4946
|
+
*
|
|
4947
|
+
* 这不只是测试问题:用户给一个经 symlink 的路径加授权根、或客户端用 symlink 别名
|
|
4948
|
+
* 请求,都会踩到。**归一后两侧是同一条真实路径**,语义也就正确了。
|
|
4949
|
+
*
|
|
4950
|
+
* ## 安全边界不变
|
|
4951
|
+
*
|
|
4952
|
+
* 归一只解决「同一个目录的两种写法」,**不放宽**任何权限:
|
|
4953
|
+
* 真正的越权校验仍由 [inspectResolvedPath] → `resolveAuthorizedPath` 逐段做
|
|
4954
|
+
* (它会拒绝指向根外的符号链接)。这里只是把「找哪个根」这一步做准。
|
|
4797
4955
|
*/
|
|
4798
|
-
function
|
|
4956
|
+
async function matchAuthorizedRootResolved(absolutePath, roots) {
|
|
4957
|
+
const candidate = await canonicalOrSelf(absolutePath);
|
|
4799
4958
|
let best;
|
|
4800
4959
|
for (const root of roots) {
|
|
4801
|
-
const
|
|
4960
|
+
const canonicalRoot = await canonicalOrSelf(root.realPath);
|
|
4961
|
+
const relative = wireRelative(canonicalRoot, candidate);
|
|
4802
4962
|
if (relative === void 0) continue;
|
|
4803
|
-
if (best === void 0 ||
|
|
4963
|
+
if (best === void 0 || canonicalRoot.length > best.root.realPath.length) best = {
|
|
4804
4964
|
root,
|
|
4805
4965
|
relative
|
|
4806
4966
|
};
|
|
@@ -4810,6 +4970,14 @@ function matchAuthorizedRoot(absolutePath, roots) {
|
|
|
4810
4970
|
path: best.relative
|
|
4811
4971
|
};
|
|
4812
4972
|
}
|
|
4973
|
+
/** 取路径的真实形式;取不到(路径不存在、无权限等)就原样返回。 */
|
|
4974
|
+
async function canonicalOrSelf(target) {
|
|
4975
|
+
try {
|
|
4976
|
+
return await realpath(target);
|
|
4977
|
+
} catch {
|
|
4978
|
+
return target;
|
|
4979
|
+
}
|
|
4980
|
+
}
|
|
4813
4981
|
const CONTENT_TYPES = {
|
|
4814
4982
|
".md": "text/markdown",
|
|
4815
4983
|
".txt": "text/plain",
|
|
@@ -5071,7 +5239,7 @@ var ApiRouter = class {
|
|
|
5071
5239
|
const requested = url.searchParams.get("path")?.trim() ?? "";
|
|
5072
5240
|
if (requested === "") throw new ApiError(400, "BODY_INVALID", "path is required.");
|
|
5073
5241
|
if (!path.isAbsolute(requested)) throw new ApiError(400, "PATH_NOT_ABSOLUTE", "path must be an absolute path.");
|
|
5074
|
-
const match =
|
|
5242
|
+
const match = await matchAuthorizedRootResolved(path.resolve(requested), this.authorizedRoots(principal));
|
|
5075
5243
|
if (match === void 0) throw new ApiError(404, "PATH_OUTSIDE_ROOTS", "The path is not inside any root this device may access.");
|
|
5076
5244
|
sendJson(res, 200, await inspectResolvedPath(this.requireRoot(principal, match.rootId), match.path));
|
|
5077
5245
|
return;
|
|
@@ -6424,6 +6592,10 @@ var DshSettingsAdapter = class {
|
|
|
6424
6592
|
configurable: entry.settingsNs !== "",
|
|
6425
6593
|
configured,
|
|
6426
6594
|
removable,
|
|
6595
|
+
...entry.defaultBaseURL === void 0 ? {} : { defaultBaseURL: entry.defaultBaseURL },
|
|
6596
|
+
...entry.baseURLEnv === void 0 ? {} : { baseURLEnv: entry.baseURLEnv },
|
|
6597
|
+
...entry.nativeAuth === void 0 ? {} : { nativeAuth: entry.nativeAuth },
|
|
6598
|
+
...entry.settingsNs === "" ? {} : { settingsNs: entry.settingsNs },
|
|
6427
6599
|
credential: {
|
|
6428
6600
|
ref,
|
|
6429
6601
|
...credential
|
|
@@ -6463,12 +6635,18 @@ var DshSettingsAdapter = class {
|
|
|
6463
6635
|
});
|
|
6464
6636
|
}
|
|
6465
6637
|
if (patch.models !== void 0) {
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
path: [...context.entry.settingsPath, "models"],
|
|
6470
|
-
value: patch.models
|
|
6638
|
+
if (patch.models === null) ops.push({
|
|
6639
|
+
op: "unset",
|
|
6640
|
+
path: [...context.entry.settingsPath, "models"]
|
|
6471
6641
|
});
|
|
6642
|
+
else {
|
|
6643
|
+
validateModels(patch.models);
|
|
6644
|
+
ops.push({
|
|
6645
|
+
op: "set",
|
|
6646
|
+
path: [...context.entry.settingsPath, "models"],
|
|
6647
|
+
value: patch.models
|
|
6648
|
+
});
|
|
6649
|
+
}
|
|
6472
6650
|
}
|
|
6473
6651
|
const ref = text(context.profile?.apiKeyEnv) ?? keyRef(providerId);
|
|
6474
6652
|
if (context.entry.settingsPath.length > 0 && context.profile === void 0) ops.unshift({
|
|
@@ -6718,14 +6896,10 @@ const inject = [
|
|
|
6718
6896
|
"settingsController",
|
|
6719
6897
|
"credentialsController",
|
|
6720
6898
|
"agentPresets",
|
|
6721
|
-
"permissionPresets",
|
|
6722
6899
|
"llm",
|
|
6723
6900
|
"webServer",
|
|
6724
6901
|
"agents",
|
|
6725
|
-
"commands"
|
|
6726
|
-
"jobController",
|
|
6727
|
-
"fileUploads",
|
|
6728
|
-
"attachments"
|
|
6902
|
+
"commands"
|
|
6729
6903
|
];
|
|
6730
6904
|
async function apply(ctx, config = {}) {
|
|
6731
6905
|
const dataDir = path.resolve(config.dataDir ?? path.join(process.cwd(), ".dsh-workspace"));
|
|
@@ -6770,13 +6944,9 @@ async function apply(ctx, config = {}) {
|
|
|
6770
6944
|
ctx.logger.warn(/* @__PURE__ */ new Error(`dsh-remote-bridge: host service probe failed: ${String(error)}`));
|
|
6771
6945
|
}
|
|
6772
6946
|
let router;
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
jobControllerAvailable = true;
|
|
6777
|
-
} catch {
|
|
6778
|
-
ctx.logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: ctx.jobController unavailable, background-job endpoints disabled"));
|
|
6779
|
-
}
|
|
6947
|
+
const reflect = ctx.reflect;
|
|
6948
|
+
const jobControllerAvailable = reflect?.get !== void 0 && reflect.get("jobController", false) !== void 0;
|
|
6949
|
+
if (!jobControllerAvailable) ctx.logger.warn(/* @__PURE__ */ new Error("dsh-remote-bridge: jobController not provided by this kernel, background-job endpoints disabled"));
|
|
6780
6950
|
const remote = new RemoteApiServer({
|
|
6781
6951
|
port,
|
|
6782
6952
|
remoteHost,
|
|
@@ -6798,7 +6968,7 @@ async function apply(ctx, config = {}) {
|
|
|
6798
6968
|
maxUploadBytes,
|
|
6799
6969
|
imageFallbackDir: imageHandoffDir,
|
|
6800
6970
|
...hostServiceReport === void 0 ? {} : { hostServiceReport },
|
|
6801
|
-
...jobControllerAvailable ? { jobs: new JobAdapter({ jobController:
|
|
6971
|
+
...jobControllerAvailable && reflect?.get ? { jobs: new JobAdapter({ jobController: reflect.get("jobController", false) }) } : {}
|
|
6802
6972
|
});
|
|
6803
6973
|
try {
|
|
6804
6974
|
await remote.start();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Versioned REST bridge for DeepSeek Harness: remote access for trusted clients (chat, files, devices) plus a Phone-settings panel inside the DSH WebUI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.19.0",
|