dsh-jace-remote 0.2.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/DESIGN.md +208 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/cordis.patch.yml +19 -0
- package/lib/client.js +123 -0
- package/package.json +72 -0
- package/src/devices.js +105 -0
- package/src/gateway.js +286 -0
- package/src/html/pair.html.js +45 -0
- package/src/index.js +263 -0
- package/src/pairing.js +98 -0
- package/src/upstream.js +62 -0
package/DESIGN.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# dsh-jace-remote — 设计方案(v1)
|
|
2
|
+
|
|
3
|
+
> 目标:给 **dsh web** 加一个局域网入口:手机/平板/另一台电脑在同一 Wi-Fi 下,用**动态 4 位数字**配对一次,之后随时访问 dsh web GUI。
|
|
4
|
+
> 作者:Jace 🃏 | 创建:2026-09-12 | 状态:设计定稿,开发中
|
|
5
|
+
>
|
|
6
|
+
> **基线版本**:`dsh 0.1.5-rc.1`(`dsh --version`;同批子包 0.1.5-rc.2)——本方案引用的行号/契约均以该版本为准。
|
|
7
|
+
> 参考源码 `deepseek-harness` 为 `0.1.3-alpha.1`;两版在本文涉及的契约上无行为差异(唯一差异:0.1.5 的 patch insert 行会把绝对路径 `name` 也转成 `file://` URL)。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. 问题与约束
|
|
12
|
+
|
|
13
|
+
| 事实 | 证据 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| dsh web 只允许绑 `127.0.0.1` 或 `0.0.0.0` | `dsh-host-webserver/lib/index.js:141`(schema 是两个字面量的 union) |
|
|
16
|
+
| `--host 0.0.0.0` 被 CLI 硬拦(防网络 RCE) | `dsh-web-app/lib/startup.js:40` |
|
|
17
|
+
| dsh web 有进程级 launch token:首次 `/?token=<token>` 换签名 cookie,之后才 200 | `dsh-client-connection/lib/index.js:347-400` |
|
|
18
|
+
| 该 cookie 绑定 authority(host:port) | 同上 `isAuthenticated()`:`payload.authority !== authority` 即失效 |
|
|
19
|
+
|
|
20
|
+
**结论**:不能改 dsh 自己暴露方式(改源码/换 flag 都不可取),但**插件可以自己起一个 LAN 监听**,把流量代回 `127.0.0.1:3080`。认证由我们做,dsh 的上游认证由网关内部完成。
|
|
21
|
+
|
|
22
|
+
## 2. 目标 / 非目标
|
|
23
|
+
|
|
24
|
+
**目标**
|
|
25
|
+
1. 动态 4 位数字配对(TTL + 限速 + 单次有效)。
|
|
26
|
+
2. 配对后 LAN 设备可**完整**使用 dsh web:页面、静态资源、`/api/*`、SSE 流、**WebSocket**。
|
|
27
|
+
3. 设备会话持久化、可列出、可撤销、可过期;一键 panic 关闭网关。
|
|
28
|
+
4. 零额外依赖(Node 内置模块 + 原生 http/net)。
|
|
29
|
+
|
|
30
|
+
**非目标(v1 不做)**
|
|
31
|
+
- 公网暴露 / NAT 穿透 / 端口转发(明确不做,也不建议)。
|
|
32
|
+
- 多用户体系(只有 cm 一个人 + 若干台自己的设备)。
|
|
33
|
+
- GUI 内嵌管理面板(v2 再做;v1 用终端日志 + agent 工具出示配对码)。
|
|
34
|
+
|
|
35
|
+
## 3. 架构
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
手机浏览器 (192.168.x.x)
|
|
39
|
+
│ http://<本机IP>:3081/
|
|
40
|
+
▼
|
|
41
|
+
┌──────────────────────────────────────────────┐
|
|
42
|
+
│ dsh-jace-remote gateway (0.0.0.0:3081) │ ← 插件自建监听
|
|
43
|
+
│ ├─ 无 jace_remote cookie → 配对页 │
|
|
44
|
+
│ ├─ 有 cookie → 反代(HTTP/SSE/WS) │
|
|
45
|
+
│ └─ 私网源地址校验 + 限速 │
|
|
46
|
+
└──────────────┬───────────────────────────────┘
|
|
47
|
+
│ 注入上游 cookie,Host 改写为 127.0.0.1:3080
|
|
48
|
+
▼
|
|
49
|
+
dsh web (127.0.0.1:3080,官方,不动)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
插件与 dsh web **同进程**,因此能直接拿到:
|
|
53
|
+
- `webServer` 服务 → 真实端口(默认 3080)
|
|
54
|
+
- `connection` 服务 → `authenticatedUrl(base)`,即带 launch token 的 URL
|
|
55
|
+
|
|
56
|
+
### 3.1 上游认证(本方案最关键的一环)
|
|
57
|
+
|
|
58
|
+
LAN 浏览器**不能**复用 dsh 的 cookie(它绑在 `127.0.0.1:3080` 这个 authority 上)。做法:
|
|
59
|
+
|
|
60
|
+
1. 网关启动(或上游 401 时)用 `connection.authenticatedUrl('http://127.0.0.1:<port>')` 拿带 token 的 URL;
|
|
61
|
+
2. loopback 发一次 `GET /`,**截获 `Set-Cookie`**(dsh 的会话 cookie)并保存在内存;
|
|
62
|
+
3. 之后所有上游请求替换/注入该 cookie;若上游返回 401 → 重新执行 1-2(覆盖重启后 secret 变化的情况);
|
|
63
|
+
4. LAN 客户端只持有我们自己的 `jace_remote` cookie,与 dsh 认证完全解耦。
|
|
64
|
+
|
|
65
|
+
### 3.2 转发规则
|
|
66
|
+
|
|
67
|
+
| 层 | 处理 |
|
|
68
|
+
|---|---|
|
|
69
|
+
| HTTP | 方法/路径/query/body 原样转发;改写 `Host: 127.0.0.1:<port>`;`Origin`/`Referer` 改写为 loopback 源;剥离 hop-by-hop 头;不缓冲响应体(SSE 直通) |
|
|
70
|
+
| WebSocket | 监听 `upgrade` 事件 → 与上游建 TCP → 重写握手头(含上游 cookie)→ 双向 `pipe` |
|
|
71
|
+
| 来源 | 只接受私网源地址(`10/8`、`172.16/12`、`192.168/16`、`127/8`、链路本地),其余 403 |
|
|
72
|
+
| 路径保留 | `/__jace/*` 为网关内部路由(配对/健康),不透传 |
|
|
73
|
+
|
|
74
|
+
## 4. 配对协议(动态 4 位数字)
|
|
75
|
+
|
|
76
|
+
### 4.1 状态机
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
未配对 ──GET /──▶ 配对页 ──POST /__jace/pair {code}──▶ 校验
|
|
80
|
+
│成功 │失败
|
|
81
|
+
▼ ▼
|
|
82
|
+
下发 device cookie 失败计数++(超限 429)
|
|
83
|
+
│
|
|
84
|
+
已配对 ──▶ 反代全部流量
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 4.2 码的生成与轮换
|
|
88
|
+
- `crypto.randomInt(0, 10000)` → 补零成 4 位;**TTL 默认 300s**,到期自动换新码(旧码立即失效)。
|
|
89
|
+
- 单码**一次性**:配对成功即作废并立即生成新码。
|
|
90
|
+
|
|
91
|
+
### 4.3 码的出示渠道
|
|
92
|
+
| 渠道 | 说明 |
|
|
93
|
+
|---|---|
|
|
94
|
+
| **设置页栏目** ✅ | dsh web → 设置 → **jace remote**(client 半边 `lib/client.js` 注册到 `settings.section`);每 5s 刷新,显示码 + 剩余时间 + 局域网地址 + 网关状态 + 已配对设备 |
|
|
95
|
+
| 终端日志 | `dsh-jace-remote: pairing code 4821 (expires in 04:59)`(dsh web 启动它的终端) |
|
|
96
|
+
| **agent 工具** | `jace_remote_code` → 问 Jace「配对码多少」即可,返回当前码 + 剩余时间 |
|
|
97
|
+
| 状态文件 | `~/.dsh/jace-remote/pairing.json`(0600):当前码 + 过期时间 |
|
|
98
|
+
| 二维码 | 未做(GUI 面板已能直接看到码,v2 可加) |
|
|
99
|
+
|
|
100
|
+
> 原则:配对码只在**本机/已配对**的可信面出现;**未配对**的 LAN 客户端拿不到。
|
|
101
|
+
> 具体实现:状态接口挂在 dsh 自己的 webServer 上(`GET /__jace/remote/status`,同源、受 dsh 访问控制保护);
|
|
102
|
+
> LAN 网关仅对**已配对设备**转发该路径(`src/gateway.js`),未配对一律 401。
|
|
103
|
+
>
|
|
104
|
+
> ⚠️ client 半边与 Node 路由属于**模块改动**,本 profile 的 `hmr` 行是 disabled,因此**需重启 `dsh web` 生效**;
|
|
105
|
+
> 只有 `cordis.patch.yml` 的配置改动能热生效。
|
|
106
|
+
|
|
107
|
+
### 4.4 防爆破
|
|
108
|
+
- 常数时间比较(`crypto.timingSafeEqual`)。
|
|
109
|
+
- 每 IP:**10 分钟内最多 5 次失败**;全局:10 分钟内最多 20 次 → 超限 `429` 并进入冷却。
|
|
110
|
+
- 失败统一文案,不区分"码错"与"码过期"。
|
|
111
|
+
- 概率:4 位码 10⁴ 空间,5 次/10 分钟 → 猜中期望时间 ≫ 码 TTL,实际不可行。**但仍按弱口令对待**(见 §7)。
|
|
112
|
+
|
|
113
|
+
### 4.5 配对成功 → 设备会话
|
|
114
|
+
- 生成 32 字节随机 `deviceToken`,**只存 sha256 哈希**,state.json 记录:`{id, name(UA 摘要), ip, createdAt, lastSeenAt, expiresAt}`。
|
|
115
|
+
- 下发 cookie:`jace_remote=<token>; HttpOnly; SameSite=Lax; Path=/; Max-Age=<sessionTtlHours>`(局域网 http,不加 `Secure`)。
|
|
116
|
+
- 默认 TTL **168h(7 天)**,每次使用自动续期(滑动过期)。
|
|
117
|
+
- 撤销:工具 `jace_remote_devices` 列出、`jace_remote_revoke(id)` 删除。
|
|
118
|
+
|
|
119
|
+
## 5. 配置(在 `~/.dsh/profiles/web/cordis.patch.yml` 覆盖)
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
- id: jace-remote
|
|
123
|
+
config:
|
|
124
|
+
enabled: true
|
|
125
|
+
bindHost: 0.0.0.0 # 网关自身监听(不是 dsh web 的 bind)
|
|
126
|
+
port: 3081
|
|
127
|
+
codeTtlSeconds: 300
|
|
128
|
+
sessionTtlHours: 168
|
|
129
|
+
maxAttemptsPerWindow: 5
|
|
130
|
+
rateWindowMinutes: 10
|
|
131
|
+
logCodes: true # 关掉则只在 agent 工具/文件里出示
|
|
132
|
+
allowPrivateOnly: true # 仅私网源地址
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## 6. 目录结构
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
dsh-jace-remote/
|
|
139
|
+
├── DESIGN.md # 本文件
|
|
140
|
+
├── README.md # 使用/安装/安全须知
|
|
141
|
+
├── package.json # dsh 插件包元数据(含 dsh.bundle.patch)
|
|
142
|
+
├── cordis.patch.yml # bundle 层补丁:以包名 insert 出行 id=jace-remote
|
|
143
|
+
├── src/
|
|
144
|
+
│ ├── index.js # 插件入口:Config schema + inject + apply(起停网关、注册工具)
|
|
145
|
+
│ ├── pairing.js # 4 位码生成/校验/限速(纯函数,可单测)
|
|
146
|
+
│ ├── devices.js # 设备 token 签发/校验/撤销 + state.json 持久化
|
|
147
|
+
│ ├── gateway.js # LAN 监听:配对页、HTTP/SSE 反代、WS 透传
|
|
148
|
+
│ ├── upstream.js # dsh 上游认证(launch token → cookie 交换/刷新)
|
|
149
|
+
│ └── html/pair.html.js # 配对页(零依赖,内联样式)
|
|
150
|
+
├── lib/
|
|
151
|
+
│ └── client.js # client 半边:设置页 “jace remote” 栏目(ModuleLoader 工厂格式)
|
|
152
|
+
├── scripts/install.mjs # 同步源码进 profile 树 + `dsh plugin add/remove`
|
|
153
|
+
└── test/
|
|
154
|
+
├── unit.mjs # 纯逻辑单测
|
|
155
|
+
└── smoke.mjs # 网关冒烟(假上游:配对/限速/代理/401 重认证/WS 101)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### 6.0 双半边(Node + client)
|
|
159
|
+
|
|
160
|
+
| 半边 | 入口 | 作用 |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| Node | `src/index.js` | 起网关、配对、注册 agent 工具、挂 `GET /__jace/remote/status` |
|
|
163
|
+
| client | `lib/client.js` | 注册 `settings.section` 的 `jace remote` 栏目,轮询上面的接口 |
|
|
164
|
+
|
|
165
|
+
- client 声明:`package.json` → `"dsh": { "client": { "platform": "web", "inject": ["@deepseek-ai/dsh-client-ui-settings"] } }`
|
|
166
|
+
与 `exports["./client"] = { default: "./lib/client.js" }`;loader 会自动把 client 半边编进 `__DSH_BOOT__` 清单。
|
|
167
|
+
- 客户端可 `require` 的只有 seed 表:`react` / `react/jsx-runtime` / `react-dom` / `react-dom/client` /
|
|
168
|
+
`@deepseek-ai/cordis` / `dsh-client-store` / `dsh-client-ui-slots` / `dsh-client-ui-primitives` /
|
|
169
|
+
`dsh-client-ui-dockkit`,外加 `dsh.client.inject` 里声明的包。
|
|
170
|
+
- **红线**:slot 注册**不要写 `locale`**(未注册词典会抛 `SlotAssemblyError` → 整个设置页崩);
|
|
171
|
+
数据请求放在组件内 try/catch(组件抛错只会 abdicate 该 entry)。
|
|
172
|
+
- 生效:模块代码改动**需重启 `dsh web`**(`hmr` 行 disabled);`cordis.patch.yml` 配置改动可热生效。
|
|
173
|
+
|
|
174
|
+
### 6.1 打包与装载(标准 Cordis bundle)
|
|
175
|
+
|
|
176
|
+
- `package.json` 声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,并导出 `./cordis.patch.yml`。
|
|
177
|
+
- 包内 `cordis.patch.yml` 以**包名**插入:`- id: jace-remote` / `name: dsh-jace-remote` / `config: {...}`。
|
|
178
|
+
- `dsh plugin --profile web add <profile树内的本包目录>` → 依赖记为 `link:`,并自动把
|
|
179
|
+
`dsh-jace-remote` 追加进该 profile 的 `dsh.profile.bundles`;`dsh --profile web --dump-config`
|
|
180
|
+
会显示 `# == dsh-jace-remote` 这一层。
|
|
181
|
+
- **必须落在 profile 树内**(`$DSH_HOME/profiles/<profile>/plugins/`):ESM 按 realpath 解析,
|
|
182
|
+
否则 `@deepseek-ai/*` 解析失败(见 README 说明)。
|
|
183
|
+
- 卸载:`dsh plugin --profile web remove dsh-jace-remote`(脚本封装为 `--uninstall`)。
|
|
184
|
+
|
|
185
|
+
## 7. 安全边界(必须写进 README,也必须让 cm 知情)
|
|
186
|
+
|
|
187
|
+
1. **这是完整 RCE 入口**:能打开 dsh web 就等于能在这台 Mac 上跑任意命令。
|
|
188
|
+
2. **4 位码是弱口令**:靠 TTL + 限速 + 一次性 + 仅私网来兜底,只适用于**可信局域网**。
|
|
189
|
+
3. 不要在公共/咖啡厅 Wi-Fi、访客网络、公司不可信网络下开启;建议同时开启 macOS 防火墙(当前是关闭状态)。
|
|
190
|
+
4. 一键关闭:工具 `jace_remote_panic`,或配置 `enabled: false`。
|
|
191
|
+
5. 日志与文件**不打印 device token / dsh cookie 原文**,只打印哈希前 8 位;state.json 权限 0600。
|
|
192
|
+
6. 不监听公网、不做 NAT 穿透;如果哪天真要外网用,走 Tailscale/WireGuard 这类 overlay,而不是直接暴露端口。
|
|
193
|
+
|
|
194
|
+
## 8. 里程碑与验收
|
|
195
|
+
|
|
196
|
+
| 里程碑 | 内容 | 验收 |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| M1 ✅ | 骨架 + 配对页 + 码生成/轮换/限速 | unit.mjs 11 项通过(错码/过期/限速/一次性) |
|
|
199
|
+
| M2 ✅ | HTTP/SSE 反代 | 经 LAN IP 配对后 `GET /` → 200 且返回 dsh 前端(27KB,含 `__DSH_BOOT__`) |
|
|
200
|
+
| M3 ✅ | WebSocket 透传 | smoke 中 upgrade → `101`(裸 TCP 透传);未配对 → 401 |
|
|
201
|
+
| M4 ✅ | 设备管理 + 持久化 + agent 工具 | 重启后设备仍有效(单测);`jace_remote_devices` / `revoke` / `panic` 已注册 |
|
|
202
|
+
| M5 ✅ | 测试 profile 联调 | dev profile(3088 网关 + 3090 web)跑通,未影响当前会话;已清理 |
|
|
203
|
+
| M6 ✅ | 装入 web profile + 文档 | 热加载生效(`*:3081` 监听、health ok);README/DESIGN 完成 |
|
|
204
|
+
| M7 ✅ | 标准 Cordis bundle + 设置页栏目 | `bundles` 含 `dsh-jace-remote`、`dump-config` 见 `# == dsh-jace-remote`;dev 冷启动下 `__DSH_BOOT__` 含本插件 id、`/__jace/remote/status` 返回 200 JSON、未配对经网关 401;安装/卸载脚本在临时 profile 验证通过 |
|
|
205
|
+
|
|
206
|
+
> 开发期**绝不重启当前 web profile**(会杀掉承载本对话的进程)。联调一律用独立 profile(如 `jaceremote-dev`)+ 独立端口。
|
|
207
|
+
>
|
|
208
|
+
> **实测结论(2026-09-12)**:`cordis.patch.yml` 的 `patchReload: live` 对新插入的 `insert` 行同样生效——`touch` 该文件后,运行中的 web 进程立即起了网关,无需重启。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# dsh-jace-remote
|
|
2
|
+
|
|
3
|
+
> 给 **dsh web** 加一个局域网入口:手机/平板/另一台电脑在同一 Wi-Fi 下,输入本机显示的 **4 位配对码**,之后就能像本机一样使用 dsh web。
|
|
4
|
+
> 设计文档见 [`DESIGN.md`](DESIGN.md)。
|
|
5
|
+
|
|
6
|
+
**npm**:[`dsh-jace-remote`](https://www.npmjs.com/package/dsh-jace-remote) `0.2.0` · **dsh**:`0.1.5-rc.1`(实测) · **License**:MIT · **仓库**:[gitee.com/SapientialM/dsh-jace-remote](https://gitee.com/SapientialM/dsh-jace-remote)
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# 一行装进 dsh(标准 Cordis bundle:同时带 Node 半边 + 设置页 client 半边)
|
|
10
|
+
dsh plugin --profile web add dsh-jace-remote
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 兼容性
|
|
14
|
+
|
|
15
|
+
| 项 | 值 |
|
|
16
|
+
|---|---|
|
|
17
|
+
| 实测 dsh 版本 | **`0.1.5-rc.1`**(`dsh --version`;同批子包 0.1.5-rc.2) |
|
|
18
|
+
| 依赖的 dsh 内部契约 | Cordis bundle 装载(`dsh.bundle.patch`)、`ctx.webServer.register`、`ctx.slots.register` 的 `settings.section` slot、client seed 表(`react` 等) |
|
|
19
|
+
| Node | `>= 22` |
|
|
20
|
+
| 其它版本 | 未验证。dsh 升级后若 slot 名 / 服务名 / loader 行为有变,`client` 半边或网关可能失效——按下方「验证装载」清单重跑一遍即可 |
|
|
21
|
+
|
|
22
|
+
> 本项目按 `dsh 0.1.5-rc.1` 的插件契约开发;契约调研记录见 `DESIGN.md` §6。
|
|
23
|
+
|
|
24
|
+
## 它做什么
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
手机 (192.168.x.x) 本机 Mac
|
|
28
|
+
│ http://<本机IP>:3081/ │
|
|
29
|
+
├── 未配对 → 配对页(输入 4 位码) │
|
|
30
|
+
└── 已配对 → dsh-jace-remote 网关 ──────▶ dsh web (127.0.0.1:3080)
|
|
31
|
+
· HTTP / SSE / WebSocket 全透传
|
|
32
|
+
· 上游认证由网关内部完成
|
|
33
|
+
· 设置页内置 “jace remote” 栏目显示配对码
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> 文档中的 IP 一律是占位符(`<本机IP>` / `192.168.x.x`),实际地址以设置页 `jace remote` 栏目或启动日志为准。
|
|
37
|
+
|
|
38
|
+
- **动态 4 位码**:默认 5 分钟轮换、一次性使用;每 IP 10 分钟内 5 次失败即限速。
|
|
39
|
+
- **设备会话**:配对成功签发 HttpOnly cookie,默认 7 天(滑动续期),可列出/撤销。
|
|
40
|
+
- **零依赖**:只用 Node 内置模块(http/net/crypto/fs)。
|
|
41
|
+
- **仅私网**:只接受 RFC1918 / 回环来源,拒绝其它源地址。
|
|
42
|
+
|
|
43
|
+
## 安装 / 卸载(标准 Cordis bundle)
|
|
44
|
+
|
|
45
|
+
本包是**标准 dsh Cordis bundle**:
|
|
46
|
+
`package.json` 里 `dsh.bundle.patch → ./cordis.patch.yml`,包内 patch 用**包名**插入 `id: jace-remote` 行。
|
|
47
|
+
把它装进 profile 后,`dsh plugin` 会把 `dsh-jace-remote` 追加进该 profile 的 `dsh.profile.bundles`,由 loader 作为一层 bundle 应用。
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# ✅ 方式一(推荐,已发布到 npm):直接从 registry 装
|
|
51
|
+
dsh plugin --profile web add dsh-jace-remote
|
|
52
|
+
|
|
53
|
+
# 方式二(本地开发/改代码):同步源码到 profile 树内,再执行 dsh plugin add
|
|
54
|
+
node scripts/install.mjs --profile web
|
|
55
|
+
|
|
56
|
+
# 查看装载状态(bundles / dependency / 插件目录)
|
|
57
|
+
node scripts/install.mjs --profile web --print-config
|
|
58
|
+
|
|
59
|
+
# 卸载(两种方式通用)
|
|
60
|
+
dsh plugin --profile web remove dsh-jace-remote # 或 node scripts/install.mjs --uninstall --profile web
|
|
61
|
+
|
|
62
|
+
# 校验组合树确实带上了这一层
|
|
63
|
+
dsh --profile web --dump-config | grep -A3 'jace-remote'
|
|
64
|
+
# 期望: # == dsh-jace-remote 然后 - id: jace-remote / name: dsh-jace-remote ...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> **装完需要重启 `dsh web`**:Node 半边与 client 半边都属于模块代码,本 profile 的 cordis `hmr` 行是 disabled,只有 `cordis.patch.yml` 的配置改动能热生效。
|
|
68
|
+
|
|
69
|
+
> **为什么脚本要先把源码拷进 `$DSH_HOME/profiles/<profile>/plugins/`**:
|
|
70
|
+
> 本包 `import '@deepseek-ai/dsh-tools'` 等,Node ESM 按 **realpath** 解析模块。若用
|
|
71
|
+
> `dsh plugin add /外部目录`,pnpm 只建 symlink,realpath 落在 profile 树外 → 父级回退链
|
|
72
|
+
> 找不到 `@deepseek-ai/*` → `ERR_MODULE_NOT_FOUND`(`NODE_OPTIONS=--preserve-symlinks` 对 ESM 无效)。
|
|
73
|
+
> 放进 profile 树内后,`profiles/web/node_modules → profiles/node_modules` 回退链可正常解析。
|
|
74
|
+
|
|
75
|
+
配置覆盖:在该 profile 的 `cordis.patch.yml` 里按**同一个 row id** 写(注意 patch 整块替换 config,需重述要保留的键):
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
- id: jace-remote
|
|
79
|
+
config:
|
|
80
|
+
enabled: true
|
|
81
|
+
port: 3081
|
|
82
|
+
codeTtlSeconds: 300
|
|
83
|
+
sessionTtlHours: 168
|
|
84
|
+
maxAttemptsPerWindow: 5
|
|
85
|
+
rateWindowMinutes: 10
|
|
86
|
+
logCodes: true
|
|
87
|
+
allowPrivateOnly: true
|
|
88
|
+
bindHost: 0.0.0.0
|
|
89
|
+
stateFile: ''
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 使用
|
|
93
|
+
|
|
94
|
+
1. 手机连同一个 Wi-Fi,浏览器打开局域网地址(默认 `http://<本机IP>:3081/`)。
|
|
95
|
+
2. 页面要求输入 4 位配对码。获取方式(任选):
|
|
96
|
+
- **dsh web → 设置 → `jace remote` 栏目**(推荐,见下节)
|
|
97
|
+
- 本机终端里 dsh web 的日志:`pairing code 8539 (expires in 04:59)`
|
|
98
|
+
- 问本机 agent:「配对码多少」(工具 `jace_remote_code`)
|
|
99
|
+
- 读文件:`~/.dsh/jace-remote/pairing.json`
|
|
100
|
+
3. 配对成功后即可正常使用;之后 7 天内无需再输码。
|
|
101
|
+
|
|
102
|
+
### 设置页栏目:jace remote(client 半边)
|
|
103
|
+
|
|
104
|
+
dsh web 的「设置」里会多出一节 **jace remote**(order 50,排在 models/plugins 之后),显示:
|
|
105
|
+
|
|
106
|
+
- **当前 4 位配对码** + 剩余有效秒数(每 5 秒自动刷新)
|
|
107
|
+
- 局域网地址、网关运行状态(端口)
|
|
108
|
+
- 已配对设备列表(设备名 / IP / 最后活跃)
|
|
109
|
+
|
|
110
|
+
实现:`lib/client.js`(无需构建,直接是 ModuleLoader 工厂格式,`ctx.slots.register` 到 `settings.section`);
|
|
111
|
+
数据来自同源 `GET /__jace/remote/status`(由 Node 半边挂在 dsh webServer 上)。
|
|
112
|
+
局域网里**已配对**的设备也能看到这一节;**未配对**设备访问该接口会被网关拒绝(拿不到码)。
|
|
113
|
+
|
|
114
|
+
> ⚠️ **client 半边与 Node 路由的改动需要重启 `dsh web` 才生效**(本 profile 的 cordis `hmr` 行是 disabled,
|
|
115
|
+
> 只有 `cordis.patch.yml` 的配置改动能热生效;模块代码不会热重载)。
|
|
116
|
+
|
|
117
|
+
### agent 工具
|
|
118
|
+
|
|
119
|
+
| 工具 | 作用 |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `jace_remote_code` | 当前配对码 + 局域网地址 + 网关状态 |
|
|
122
|
+
| `jace_remote_devices` | 列出已配对设备(id / 名称 / IP / 最后活跃 / 过期) |
|
|
123
|
+
| `jace_remote_revoke` | 按 id 撤销设备 |
|
|
124
|
+
| `jace_remote_panic` | 紧急关闭网关(立即断开所有已配对设备) |
|
|
125
|
+
|
|
126
|
+
## 配置
|
|
127
|
+
|
|
128
|
+
在 `$DSH_HOME/profiles/<profile>/cordis.patch.yml` 里按**同一个 row id** 覆盖(patch 是**整块替换 config**,要重述想保留的键;若文件里是 `[]` 占位,直接替换成下面的列表):
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
- id: jace-remote
|
|
132
|
+
config:
|
|
133
|
+
enabled: true
|
|
134
|
+
bindHost: 0.0.0.0
|
|
135
|
+
port: 3081
|
|
136
|
+
codeTtlSeconds: 300 # 配对码有效期
|
|
137
|
+
sessionTtlHours: 168 # 设备会话有效期(滑动)
|
|
138
|
+
maxAttemptsPerWindow: 5 # 每 IP 失败上限
|
|
139
|
+
rateWindowMinutes: 10 # 限速窗口
|
|
140
|
+
logCodes: true # 是否把码打到 dsh 日志
|
|
141
|
+
allowPrivateOnly: true # 仅私网来源
|
|
142
|
+
stateFile: '' # 空 = ~/.dsh/jace-remote/state.json
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## ⚠️ 安全须知(务必读)
|
|
146
|
+
|
|
147
|
+
1. **这是完整的 RCE 入口**:能打开 dsh web 就等于能在这台 Mac 上跑任意命令(agent 有 shell/文件权限)。
|
|
148
|
+
2. **4 位码是弱口令**:靠"5 分钟轮换 + 一次性 + 每 IP 限速 + 仅私网"兜底,只适用于**可信局域网**。
|
|
149
|
+
3. **不要在公共/咖啡厅/访客 Wi-Fi 开启**;建议同时打开 macOS 防火墙(当前系统默认是关闭的)。
|
|
150
|
+
4. 不用时:`jace_remote_panic` 或 `--disable` 重装。
|
|
151
|
+
5. 不监听公网、不做 NAT 穿透。真要外网访问请用 Tailscale / WireGuard 等 overlay。
|
|
152
|
+
6. 状态文件权限 0600,只存 device token 的 **sha256 哈希**;日志不打印 token 原文。
|
|
153
|
+
|
|
154
|
+
## 开发
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm test # 单元测试(配对/限速/设备会话)+ 网关冒烟(假上游,含 WS 101)
|
|
158
|
+
node scripts/install.mjs --profile jaceremote-dev --port 3088 # 装到测试 profile 联调
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
> 联调**不要重启正在承载你对话的 web profile**;用独立 profile + 独立端口(见 DESIGN.md §8)。
|
|
162
|
+
|
|
163
|
+
## 发布(维护者)
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# 1) 改版本号(语义化)+ 提交推送
|
|
167
|
+
npm version patch # 或 minor / major
|
|
168
|
+
git push origin main --tags
|
|
169
|
+
|
|
170
|
+
# 2) 发布到 npm(账号 agrinjpg;发布需带 Bypass 2FA 的 granular token,见 ~/.npmrc)
|
|
171
|
+
npm publish
|
|
172
|
+
|
|
173
|
+
# 3) 校验
|
|
174
|
+
npm view dsh-jace-remote version
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
> npm 现在不允许用旧式 token 发布:报 `403 ... granular access token with bypass 2fa` 时,
|
|
178
|
+
> 去 npmjs.com → Access Tokens → 生成 **Granular Access Token** 并勾选 **Bypass 2FA**,
|
|
179
|
+
> 然后 `npm config set //registry.npmjs.org/:_authToken=<token>`。
|
|
180
|
+
> 本地 `.npmrc` 已在 `.gitignore` 中,token 不会入库。
|
|
181
|
+
|
|
182
|
+
## 目录
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
src/index.js 插件入口(Config / inject / apply / agent 工具 / 设置页状态路由)
|
|
186
|
+
src/gateway.js 局域网网关:配对页、HTTP/SSE 反代、WS 透传、私网校验
|
|
187
|
+
src/pairing.js 4 位码生成/轮换/限速(纯逻辑)
|
|
188
|
+
src/devices.js 设备 token 签发/校验/撤销 + state.json
|
|
189
|
+
src/upstream.js 用 launch token 换 dsh 会话 cookie(上游认证)
|
|
190
|
+
src/html/ 配对页
|
|
191
|
+
lib/client.js client 半边:设置页新增 “jace remote” 栏目(ModuleLoader 工厂格式)
|
|
192
|
+
cordis.patch.yml bundle 层 patch(以包名 insert 出 id: jace-remote)
|
|
193
|
+
scripts/install.mjs 安装/卸载到 profile
|
|
194
|
+
test/ 单元 + 冒烟测试
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
[MIT](LICENSE) © 2026 cm
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# dsh-jace-remote bundle patch
|
|
2
|
+
#
|
|
3
|
+
# 作为 dsh bundle 层被 `dsh plugin --profile web add` 引入时,由 loader 应用本文件。
|
|
4
|
+
# 行 id 为 `jace-remote`,用户可在 profile 的 cordis.patch.yml 里按同一 id 覆盖 config
|
|
5
|
+
# (注意:patch 是整块替换 config,需重述想保留的键)。
|
|
6
|
+
- insert:
|
|
7
|
+
- id: jace-remote
|
|
8
|
+
name: dsh-jace-remote
|
|
9
|
+
config:
|
|
10
|
+
enabled: true
|
|
11
|
+
bindHost: 0.0.0.0
|
|
12
|
+
port: 3081
|
|
13
|
+
codeTtlSeconds: 300
|
|
14
|
+
sessionTtlHours: 168
|
|
15
|
+
maxAttemptsPerWindow: 5
|
|
16
|
+
rateWindowMinutes: 10
|
|
17
|
+
logCodes: true
|
|
18
|
+
allowPrivateOnly: true
|
|
19
|
+
stateFile: ''
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// dsh-jace-remote — client 半边(无需构建:直接是 ModuleLoader 工厂格式)
|
|
2
|
+
//
|
|
3
|
+
// 作用:在 dsh web 的「设置」页注册一个新栏目 “jace remote”,
|
|
4
|
+
// 显示当前 4 位配对码 / 剩余时间 / 局域网地址 / 网关状态 / 已配对设备。
|
|
5
|
+
// 数据来源:同源 GET /__jace/remote/status(由本包的 Node 半边挂在 dsh webServer 上)。
|
|
6
|
+
window.__ModuleLoader__.load({
|
|
7
|
+
id: 'dsh-jace-remote',
|
|
8
|
+
factory: (require) => {
|
|
9
|
+
var module = { exports: {} }
|
|
10
|
+
var exports = module.exports
|
|
11
|
+
const React = require('react')
|
|
12
|
+
const h = React.createElement
|
|
13
|
+
|
|
14
|
+
const POLL_MS = 5000
|
|
15
|
+
|
|
16
|
+
/** 轮询状态接口。 */
|
|
17
|
+
function useRemoteStatus() {
|
|
18
|
+
const [state, setState] = React.useState({ loading: true, error: '', data: null })
|
|
19
|
+
React.useEffect(() => {
|
|
20
|
+
let alive = true
|
|
21
|
+
const load = async () => {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch('/__jace/remote/status', { headers: { accept: 'application/json' } })
|
|
24
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
25
|
+
const data = await res.json()
|
|
26
|
+
if (alive) setState({ loading: false, error: '', data })
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (alive) setState({ loading: false, error: String((err && err.message) || err), data: null })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
load()
|
|
32
|
+
const timer = setInterval(load, POLL_MS)
|
|
33
|
+
return () => { alive = false; clearInterval(timer) }
|
|
34
|
+
}, [])
|
|
35
|
+
return state
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const S = {
|
|
39
|
+
wrap: { padding: '4px 2px', fontSize: 13, lineHeight: 1.6 },
|
|
40
|
+
h2: { margin: '0 0 4px', fontSize: 15, fontWeight: 600 },
|
|
41
|
+
sub: { margin: '0 0 16px', fontSize: 12, opacity: 0.6 },
|
|
42
|
+
card: { padding: '14px 16px', border: '1px solid rgba(127,127,127,0.25)', borderRadius: 10, marginBottom: 12 },
|
|
43
|
+
code: { fontSize: 30, letterSpacing: 10, fontWeight: 700, fontVariantNumeric: 'tabular-nums' },
|
|
44
|
+
label: { fontSize: 12, opacity: 0.6, minWidth: 84, display: 'inline-block' },
|
|
45
|
+
err: { color: '#d9534f', fontSize: 12 },
|
|
46
|
+
table: { width: '100%', borderCollapse: 'collapse', fontSize: 12 },
|
|
47
|
+
th: { textAlign: 'left', padding: '4px 8px 6px 0', opacity: 0.55, fontWeight: 500 },
|
|
48
|
+
td: { padding: '4px 8px 4px 0' },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Row({ label, children }) {
|
|
52
|
+
return h('div', { style: { marginBottom: 6 } }, [
|
|
53
|
+
h('span', { key: 'l', style: S.label }, label),
|
|
54
|
+
h('span', { key: 'v' }, children),
|
|
55
|
+
])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function DeviceTable({ devices }) {
|
|
59
|
+
if (!devices || devices.length === 0) {
|
|
60
|
+
return h('div', { style: { fontSize: 12, opacity: 0.6 } }, '还没有已配对设备。')
|
|
61
|
+
}
|
|
62
|
+
const head = h('tr', { key: 'h' }, [
|
|
63
|
+
h('th', { key: 'n', style: S.th }, '设备'),
|
|
64
|
+
h('th', { key: 'i', style: S.th }, 'IP'),
|
|
65
|
+
h('th', { key: 't', style: S.th }, '最后活跃'),
|
|
66
|
+
])
|
|
67
|
+
const rows = devices.map((d) => h('tr', { key: d.id }, [
|
|
68
|
+
h('td', { key: 'n', style: S.td }, d.name || d.id),
|
|
69
|
+
h('td', { key: 'i', style: S.td }, d.ip || '-'),
|
|
70
|
+
h('td', { key: 't', style: S.td }, new Date(d.lastSeenAt).toLocaleString()),
|
|
71
|
+
]))
|
|
72
|
+
return h('table', { style: S.table }, [h('thead', { key: 'thead' }, head), h('tbody', { key: 'tbody' }, rows)])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function JaceRemoteSection() {
|
|
76
|
+
const { loading, error, data } = useRemoteStatus()
|
|
77
|
+
|
|
78
|
+
if (loading && !data) return h('div', { style: S.wrap }, '读取配对码…')
|
|
79
|
+
if (error) {
|
|
80
|
+
return h('div', { style: S.wrap }, [
|
|
81
|
+
h('h2', { key: 't', style: S.h2 }, 'jace remote'),
|
|
82
|
+
h('div', { key: 'e', style: S.err }, `读取失败:${error}`),
|
|
83
|
+
])
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const mins = Math.round(data.expiresInSeconds)
|
|
87
|
+
return h('div', { style: S.wrap }, [
|
|
88
|
+
h('h2', { key: 't', style: S.h2 }, 'jace remote'),
|
|
89
|
+
h('p', { key: 's', style: S.sub }, '手机/其它设备在同一局域网访问下面地址,输入配对码即可使用 dsh web。'),
|
|
90
|
+
h('div', { key: 'c', style: S.card }, [
|
|
91
|
+
h('div', { key: 'code', style: S.code }, data.code),
|
|
92
|
+
h('div', { key: 'exp', style: { fontSize: 12, opacity: 0.6, marginTop: 6 } },
|
|
93
|
+
`剩余 ${mins} 秒后自动更换(过期即失效)`),
|
|
94
|
+
]),
|
|
95
|
+
h('div', { key: 'd', style: S.card }, [
|
|
96
|
+
h(Row, { key: 'url', label: '局域网地址' }, data.lanUrl || '-'),
|
|
97
|
+
h(Row, { key: 'gw', label: '网关' },
|
|
98
|
+
data.listening ? `运行中(端口 ${data.gatewayPort})` : '未运行'),
|
|
99
|
+
]),
|
|
100
|
+
h('div', { key: 'dev', style: S.card }, [
|
|
101
|
+
h('div', { key: 'h', style: { fontSize: 12, opacity: 0.6, marginBottom: 8 } }, '已配对设备'),
|
|
102
|
+
h(DeviceTable, { key: 'table', devices: data.devices }),
|
|
103
|
+
]),
|
|
104
|
+
])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 只依赖 slot 注册表;settings.section 由 dsh-client-ui-settings 声明。 */
|
|
108
|
+
const inject = ['slots']
|
|
109
|
+
|
|
110
|
+
function apply(ctx) {
|
|
111
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
112
|
+
name: 'settings.section',
|
|
113
|
+
id: 'jace-remote',
|
|
114
|
+
order: 50,
|
|
115
|
+
label: () => 'jace remote',
|
|
116
|
+
}, JaceRemoteSection))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
exports.apply = apply
|
|
120
|
+
exports.inject = inject
|
|
121
|
+
return module.exports
|
|
122
|
+
},
|
|
123
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-jace-remote",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "局域网网关:动态 4 位数字配对 + 反代 dsh web(HTTP/SSE/WebSocket);设置页内置 jace remote 栏目",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": {
|
|
10
|
+
"default": "./lib/client.js"
|
|
11
|
+
},
|
|
12
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dsh": {
|
|
16
|
+
"bundle": {
|
|
17
|
+
"patch": "./cordis.patch.yml"
|
|
18
|
+
},
|
|
19
|
+
"client": {
|
|
20
|
+
"platform": "web",
|
|
21
|
+
"inject": [
|
|
22
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"lib",
|
|
29
|
+
"cordis.patch.yml",
|
|
30
|
+
"DESIGN.md",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "node test/unit.mjs && node test/smoke.mjs",
|
|
36
|
+
"install:profile": "node scripts/install.mjs"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"dsh",
|
|
40
|
+
"deepseek-harness",
|
|
41
|
+
"cordis",
|
|
42
|
+
"cordis-plugin",
|
|
43
|
+
"plugin",
|
|
44
|
+
"lan",
|
|
45
|
+
"pairing",
|
|
46
|
+
"remote"
|
|
47
|
+
],
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://gitee.com/SapientialM/dsh-jace-remote.git"
|
|
51
|
+
},
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@deepseek-ai/cordis": "*",
|
|
58
|
+
"@deepseek-ai/dsh-tools": "*",
|
|
59
|
+
"@deepseek-ai/schemastery": "*"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"@deepseek-ai/cordis": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@deepseek-ai/dsh-tools": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@deepseek-ai/schemastery": {
|
|
69
|
+
"optional": true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|