dsh-mobile-plugin 0.0.0-stage → 0.1.58
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.md +159 -2
- package/config/agent-presets/standard/agent.cordis.yml +170 -0
- package/config/agent-presets/standard/preset.yml +3 -0
- package/cordis.mobile-host.yml +48 -0
- package/cordis.patch.yml +21 -0
- package/dsh-mobile-credentials/lib/credentials.js +135 -0
- package/dsh-mobile-credentials/package.json +13 -0
- package/host.js +128 -0
- package/lib/auto-archive.cjs +98 -0
- package/lib/client.js +690 -0
- package/lib/desktop-half-stats.cjs +101 -0
- package/lib/desktop-view.cjs +109 -0
- package/lib/host-guard.cjs +230 -0
- package/lib/host-patches.cjs +46 -0
- package/lib/http/package.json +3 -0
- package/lib/http/routing.js +84 -0
- package/lib/impl.js +406 -0
- package/lib/mirror/pusher.cjs +706 -0
- package/lib/mirror-snapshot.cjs +39 -0
- package/lib/mobile/agent-presets.js +182 -0
- package/lib/mobile/attachments.js +116 -0
- package/lib/mobile/auth.js +78 -0
- package/lib/mobile/completion.js +114 -0
- package/lib/mobile/credentials.js +150 -0
- package/lib/mobile/directory-browse.js +295 -0
- package/lib/mobile/envelope.js +51 -0
- package/lib/mobile/events.js +293 -0
- package/lib/mobile/history-derive.js +608 -0
- package/lib/mobile/host-bridge/endpoints.js +189 -0
- package/lib/mobile/host-bridge/event-synthesis.js +232 -0
- package/lib/mobile/host-bridge/shims.js +218 -0
- package/lib/mobile/host-bridge/transport.js +168 -0
- package/lib/mobile/host-bridge.js +71 -0
- package/lib/mobile/host-settings.js +282 -0
- package/lib/mobile/idempotency.js +78 -0
- package/lib/mobile/jobs.js +97 -0
- package/lib/mobile/local-images.js +153 -0
- package/lib/mobile/package.json +3 -0
- package/lib/mobile/plugins.js +149 -0
- package/lib/mobile/questions.js +71 -0
- package/lib/mobile/references.js +209 -0
- package/lib/mobile/rpc.js +76 -0
- package/lib/mobile/session-cache.js +263 -0
- package/lib/mobile/session-goal.js +44 -0
- package/lib/mobile/session-log.js +94 -0
- package/lib/mobile/session-mirror.js +177 -0
- package/lib/mobile/session-models.js +153 -0
- package/lib/mobile/session-presets.js +52 -0
- package/lib/mobile/session-search.js +65 -0
- package/lib/mobile/session-send.js +278 -0
- package/lib/mobile/session-snapshot.js +142 -0
- package/lib/mobile/sessions.js +462 -0
- package/lib/mobile/skills.js +80 -0
- package/lib/mobile/stream.js +268 -0
- package/lib/mobile/subagents.js +265 -0
- package/lib/mobile/workspaces.js +214 -0
- package/lib/panel/index.html +33 -0
- package/lib/read-receipt.cjs +124 -0
- package/lib/reconnect-scheduler.cjs +37 -0
- package/lib/relay/core.js +77 -0
- package/lib/relay/lan-network.cjs +85 -0
- package/lib/relay/lan-proxy.js +593 -0
- package/lib/relay/package.json +3 -0
- package/lib/relay/pairing-policy.js +45 -0
- package/lib/relay/port-allocator.cjs +60 -0
- package/lib/relay/preview.js +374 -0
- package/lib/relay/protocol.js +177 -0
- package/lib/relay/status.js +145 -0
- package/lib/relay/tunnel.js +721 -0
- package/lib/routes/control-plane.js +377 -0
- package/lib/routes/mobile-api.js +208 -0
- package/lib/routes/package.json +3 -0
- package/lib/session-cleanup.cjs +153 -0
- package/lib/shared/credentials-entry.js +48 -0
- package/lib/shared/dshtoken.js +62 -0
- package/lib/shared/fresh-request.js +35 -0
- package/lib/shared/http-util.js +71 -0
- package/lib/shared/list-revision.js +51 -0
- package/lib/shared/list-signal.js +87 -0
- package/lib/shared/package.json +3 -0
- package/lib/shared/paths.js +27 -0
- package/lib/shared/session-signal.js +110 -0
- package/lib/shared/workspace-guard.js +127 -0
- package/lib/sidecar-facade-worker.cjs +353 -0
- package/lib/sidecar-facade.cjs +141 -0
- package/lib/sidecar-lifecycle.cjs +246 -0
- package/package.json +74 -4
package/README.md
CHANGED
|
@@ -1,3 +1,160 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-mobile-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AI-HUB 手机端的对端插件,同时充当**无头 Cordis Host**:它自带 Host 进程入口,提供 relay v2
|
|
4
|
+
隧道客户端、局域网代理,以及受限的 `/api/mobile/v1/*` 移动 API。本包**不依赖任何桌面外壳或
|
|
5
|
+
Electron 运行时**,`node host.js` 即可独立运行。
|
|
6
|
+
|
|
7
|
+
`POST /api/mobile/v1/session/new` 的 body 支持 `workspaceId` 或 `cwd`(二选一,前者优先):传 `workspaceId` 时宿主会把新会话 attach 到该项目账上,宿主项目列表下立即可见;仅传 `cwd` 时会话归入宿主「未分组」。
|
|
8
|
+
|
|
9
|
+
`POST /api/mobile/v1/workspace/move-session` 透传宿主 `workspace.insertSessionBefore`,移动端拖动会话排序会同步写入宿主工作区。
|
|
10
|
+
|
|
11
|
+
设备级实时兼容出口:`GET /api/mobile/v1/events/stream`(Bearer token)以 SSE 转发宿主 `events.mux` 的所有设备事件。每帧包装为 `{ type: "device/event", version: 1, eventId, sessionId?, rpcId?, payload }`;`payload` 保留原 mux 负载,旧的 `/session/stream` 与 relay 协议不变。`session/event` 的 `eventId` 为 `${sessionId}:${event.seq}`,其他帧使用连接内序号。工作区变更是否可见取决于宿主 mux 是否发布对应帧;当前插件没有额外伪造工作区事件。
|
|
12
|
+
|
|
13
|
+
## 无头部署
|
|
14
|
+
|
|
15
|
+
部署到 Linux 服务器(无桌面环境)时的要点,完整 runbook 见仓库根 [`../README.md`](../README.md) 的「服务器部署」。
|
|
16
|
+
|
|
17
|
+
**目录布局不能漏掉凭据库。** 设备凭据库 `dsh-mobile-credentials` 就在本包目录内部(发布时它仍是独立的一个包,装进来后嵌在插件目录里),代码用包内相对路径引用它:
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
lib/impl.js:26 require('../dsh-mobile-credentials/lib/credentials.js')
|
|
21
|
+
lib/mobile/auth.js:15 require('../../dsh-mobile-credentials/lib/credentials.js')
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
/opt/dsh-mobile/
|
|
26
|
+
├── dsh-mobile-plugin/
|
|
27
|
+
│ └── dsh-mobile-credentials/ # 嵌在插件目录内,漏掉就启动即崩
|
|
28
|
+
└── dsh-mobile-server/
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **用 pnpm 装,不要加 `--legacy-peer-deps`。**
|
|
32
|
+
>
|
|
33
|
+
> 本包的 `pnpm-lock.yaml` 是入库的依赖锁,而 pnpm 的 `autoInstallPeers` 会把 peer 冲突
|
|
34
|
+
> 一起解掉:`cordis-plugin-hmr@1.0.19` 要 `@deepseek-ai/cordis@~4.0.4`,锁里解析到的
|
|
35
|
+
> 正是 4.0.4。
|
|
36
|
+
>
|
|
37
|
+
> 这份文档以前教的是 `npm install --legacy-peer-deps`。那个参数只是让 npm 跳过 peer
|
|
38
|
+
> 冲突检查,不是解决冲突;仓库里又没有 `package-lock.json`,也就是说那次安装**什么都
|
|
39
|
+
> 没锁住**——每次干净安装解析出的树都可能不同。
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
cd dsh-mobile-plugin
|
|
43
|
+
pnpm install --frozen-lockfile # 锁文件与 package.json 不同步时直接失败,而不是悄悄升级
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> 锁与 `package.json` 的一致性由 `dsh-mobile-plugin/test/lockfile-sync.test.cjs` 守住。
|
|
47
|
+
> 真要让 cordis 对齐到别的版本,改 `package.json` 后跑一次 `pnpm install` 把锁更新进来。
|
|
48
|
+
|
|
49
|
+
**启动与数据根:**
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
DSH_HOME=/opt/dsh-mobile/.dsh DSH_MOBILE_PROFILE=mobile node host.js
|
|
53
|
+
# → dsh-mobile-plugin: mobile host ready (profile mobile)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`DSH_HOME` 决定数据根(profile、relay 配置、会话),不设则用 `~/.dsh`;`DSH_MOBILE_PROFILE`
|
|
57
|
+
决定 profile 名(默认 `mobile`),文件生成在 `$DSH_HOME/profiles/<name>/`。
|
|
58
|
+
|
|
59
|
+
**Web 端口固定 `3080` 且只绑 `127.0.0.1`**(`webserver` 行的端口表达式是 `ctx.webStartup.port ?? 3080`,
|
|
60
|
+
而 `host.js` 传空命令行参数,因此 `--port` 无效)。无头模式的 `web-runtime` 行关闭了
|
|
61
|
+
`openBrowser`/`printUrl`,所以**启动日志不会打印端口**,这不是故障。
|
|
62
|
+
|
|
63
|
+
## 安装与更新
|
|
64
|
+
|
|
65
|
+
本包有两条完全不同的安装路径,别混用。
|
|
66
|
+
|
|
67
|
+
**服务器 / 无头部署:源码同步。** 直接把仓库目录 rsync 到 `/opt/dsh-mobile/`,依赖只在首次装
|
|
68
|
+
或 `package.json` 变了时重装,然后 `systemctl restart dsh-mobile-host`。这条路的版本号不参与
|
|
69
|
+
任何判断,`package.json` 里的 version 写成什么都不会影响宿主行为。
|
|
70
|
+
|
|
71
|
+
**桌面端:从 npm 装,更新交给 DSH 的插件市场。**(2026-10 改的:此前是本包自带的 OTA +
|
|
72
|
+
「稳定外壳 + 可热替换的代」,那套已经删掉。)
|
|
73
|
+
|
|
74
|
+
- 包名 `dsh-mobile-plugin`,凭据库 `dsh-mobile-credentials` **内联在本包里**(`files` 收它),
|
|
75
|
+
所以只发一个包、只有一个版本号要管。
|
|
76
|
+
- 装/更新都在 DSH 里做:**设置 → 插件**(插件市场)里安装或更新本插件,底层就是 profile 里
|
|
77
|
+
跑一次 pnpm。装了之后「设置 → 移动端 → 打开配置页面」那一页的更新卡片会直接调市场的公共
|
|
78
|
+
更新接口(`/dsh-market/api/v1/*`),能在页面上检查更新、开始更新、看进度,并在需要时请
|
|
79
|
+
市场代劳重启 DSH。
|
|
80
|
+
- **装完/更新完要完整重启 DSH**:插件是 profile 层的 cordis bundle,进程起来时依赖树就定死了。
|
|
81
|
+
市场会把这件事报成 `restartRequired`,页面上有对应提示与按钮。
|
|
82
|
+
- 如果「设置 → 移动端 → 打开配置页面」打不开、页面吐一段 `panel_missing` JSON:那段文案会
|
|
83
|
+
写清是**哪一种**读不到 —— 「装完没重启」(旧实例还挂在这次安装删掉的那个目录上,最常见)、
|
|
84
|
+
「装了老版本」(0.1.47 及更早还没有这一页)还是「真没构建」(源码树里才会遇到)。三种解法
|
|
85
|
+
不同,别照着「没重启」的现场去跑 React 构建。同一句话也会进宿主 stderr。
|
|
86
|
+
- 从前那套的教训记在这里:热更新只换宿主侧的实现代,**换不掉设置菜单那一半**(渲染器直接从
|
|
87
|
+
安装目录读它),于是出现过「更新报成功、界面一动不动」,重启 DSH、重启 sidecar 都没用。
|
|
88
|
+
现在只有一条更新路径,这类「哪一半是旧的」不再存在。
|
|
89
|
+
|
|
90
|
+
### 发版流程(自己发一版更新)
|
|
91
|
+
|
|
92
|
+
1. **抬版本号**:`node scripts/bump-version.mjs`(三段各 `0..999`)。
|
|
93
|
+
2. **改过 `React/` 就先重建面板**:`cd React && pnpm run build`。产物 `lib/panel/index.html`
|
|
94
|
+
是**提交物**,要和源码一起提交 —— 忘了重建,发出去的就是旧页面,而仓库里没有任何东西能
|
|
95
|
+
替你发现(守卫只比字节形状,不比新旧)。
|
|
96
|
+
3. **自检**:`npm test`(其中 `panel-artifact` 管产物形状与「读不到时报什么」,
|
|
97
|
+
`publish-payload` 管发布清单收得住每个会被加载的文件);`npm pack --dry-run` 扫一眼发了什么。
|
|
98
|
+
4. **发布**:`npm publish`。
|
|
99
|
+
5. **核对**:`npm view dsh-mobile-plugin version`。
|
|
100
|
+
6. **到机器上更新**:DSH → 设置 → 插件 里更新,**然后完整重启 DSH**。
|
|
101
|
+
|
|
102
|
+
**发布这一步现在要你现场过二次验证**(2026-10 起,别以为是配错了):
|
|
103
|
+
|
|
104
|
+
- `npm publish` 会打印一条 `https://www.npmjs.com/auth/cli/…` 链接(或直接开浏览器),用账号
|
|
105
|
+
绑定的**安全密钥**确认一下,它自己接着把包发出去。
|
|
106
|
+
- 所以这一步**必须能打开 www.npmjs.com**:机房/代理出口 IP 会被它的反机器人拦成 403
|
|
107
|
+
(页面写「访问暂时受限」并带上你的 IP)。换个节点或直连再发。
|
|
108
|
+
- npm 已经关掉了「命令行建号」和「验证器 App(TOTP)」两条路:建号只能在网页上做,2FA 只能挂
|
|
109
|
+
安全密钥。这两件事上试命令是白费时间,认它就行。
|
|
110
|
+
- 想少点一次确认,可以建一把「允许绕过 2FA」的细粒度令牌少输几次验证:
|
|
111
|
+
`npm token create --bypass-2fa --packages dsh-mobile-plugin --packages-and-scopes-permission read-write --expires 90`
|
|
112
|
+
(**最长 90 天**)。代价:这类令牌 **2027-01 起不能再直接发布**,到期或到点都得回到上面
|
|
113
|
+
那条交互式路径。
|
|
114
|
+
- 还有个死结记在这里:令牌不能指向**还不存在**的包(会报
|
|
115
|
+
`You do not have access to the requested package(s)`),所以**第一版只能用浏览器验证发**,
|
|
116
|
+
令牌是「包已经存在之后」才有的便利。
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
插件没有 GUI 外壳,配置入口是插件注册在 `webServer` 上的**仅限本机访问**(`localOnly` 守卫)
|
|
120
|
+
的 HTTP 端点。`host.js` 启动后,其 `webServer.port` 就是下面 URL 里的 `<port>`
|
|
121
|
+
(无头部署下固定 `3080`):
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
curl http://127.0.0.1:<port>/api/relay/status # 实例 ID、连接地址、开关状态
|
|
125
|
+
curl http://127.0.0.1:<port>/api/relay/pairing # 配对码 + 可直接扫码的 SVG 二维码
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- `POST /api/relay/toggle` body `{"enabled":true|false}` —— 开关远程控制
|
|
129
|
+
- `POST /api/relay/regenerate` —— 轮换配对码
|
|
130
|
+
- `POST /api/relay/config` —— 改写 `relayUrl` / `lanEnabled` / `lanPort` / `name` / `pairingPolicy`(无 GUI 外壳时唯一的配置入口)
|
|
131
|
+
- `POST /api/relay/reconnect` —— 重新握手隧道与局域网代理
|
|
132
|
+
|
|
133
|
+
持久化配置在 `mobile-remote-control` 设置命名空间:`enabled`(默认 `false`)、`relayUrl`
|
|
134
|
+
(默认空,即只用局域网)、`lanEnabled`(默认 `true`)、`lanPort`(默认 `18787`)、`name`。
|
|
135
|
+
插件启动后**不向控制台打印配对信息**,只能经上述端点获取。
|
|
136
|
+
|
|
137
|
+
## 自动归档
|
|
138
|
+
|
|
139
|
+
插件默认启用 Host 侧自动归档:激活时扫描一次,此后每 12 小时扫描一次;项目内会话数超过 10 且最后更新时间超过 24 小时的非运行、非空白会话会被归档。归档只隐藏会话,不删除日志。
|
|
140
|
+
|
|
141
|
+
## 会话清理工具
|
|
142
|
+
|
|
143
|
+
插件注册模型工具 `session_cleanup`,可直接通过对话执行归档或永久删除。`action: "archive"` 按项目路径、闲置分钟数或会话 ID 筛选并隐藏会话;`action: "delete"` 只处理已经归档的会话,并且必须显式传入 `confirm: true`,删除会话日志和本地投影缓存。删除不可恢复,运行中、空白会话默认不会处理。
|
|
144
|
+
|
|
145
|
+
## 远程控制元数据
|
|
146
|
+
|
|
147
|
+
配对请求(relay 的 `pairRequest` 或局域网 `POST /__lan_pair`)可携带 `device` 对象,字段会原样转发并兼容旧版仅传 `code` 的请求。当前 App 发送 `id`、`deviceId`、`name`、`platform`、`osVersion`、`appVersion`,局域网代理与中转会补充服务端观察到的手机 `ip`。
|
|
148
|
+
|
|
149
|
+
## 配对授权策略
|
|
150
|
+
|
|
151
|
+
配对码校验始终由传输层完成(中转与局域网代理都用恒定时间比较,且中转对每实例每 IP 做 5 次失败锁定 60 秒);校验通过后,宿主端由 `pairingPolicy` 决定是否准入,两条链路都汇总到同一个 `POST /api/relay/internal/approval`:
|
|
152
|
+
|
|
153
|
+
| 取值 | 行为 |
|
|
154
|
+
| --- | --- |
|
|
155
|
+
| `code`(默认) | 凭配对码放行 |
|
|
156
|
+
| `deny` | 拒绝一切新配对 |
|
|
157
|
+
|
|
158
|
+
原来的桌面端弹窗确认随桌面端一并删除,宿主已无 UI 承接逐次确认,因此改为该策略;`deny` 与删除前的 fail-closed 行为完全等价。策略求值在 [`lib/relay/pairing-policy.js`](lib/relay/pairing-policy.js),不经过 sidecar worker,故未列入 worker 的 `configure` 白名单。走 `deny` 时每次决策都会写一行审计日志到 stderr。
|
|
159
|
+
|
|
160
|
+
`/api/mobile/v1/status` 和 `/api/relay/status` 返回宿主 `os`、`osVersion`、`arch`、`appVersion`;移动 token 的签发与校验仍严格绑定宿主 `instanceId`。
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
- id: agent-instructions
|
|
2
|
+
name: '@deepseek-ai/dsh-agent-instructions'
|
|
3
|
+
config:
|
|
4
|
+
maxBytes: 65536
|
|
5
|
+
- id: tool-bash
|
|
6
|
+
name: '@deepseek-ai/dsh-tool-bash'
|
|
7
|
+
disabled: !!js process.platform === 'win32'
|
|
8
|
+
- id: tool-pwsh
|
|
9
|
+
name: '@deepseek-ai/dsh-tool-pwsh'
|
|
10
|
+
disabled: !!js process.platform !== 'win32'
|
|
11
|
+
- id: tool-fs
|
|
12
|
+
name: '@deepseek-ai/dsh-tool-fs'
|
|
13
|
+
- id: tool-fs-search
|
|
14
|
+
name: '@deepseek-ai/dsh-tool-fs-search'
|
|
15
|
+
config:
|
|
16
|
+
sampleOverCapGlobResults: false
|
|
17
|
+
- id: tool-jobs
|
|
18
|
+
name: '@deepseek-ai/dsh-tool-jobs'
|
|
19
|
+
- id: skill-filesystem
|
|
20
|
+
name: '@deepseek-ai/dsh-skill-filesystem'
|
|
21
|
+
- id: tool-skill
|
|
22
|
+
name: '@deepseek-ai/dsh-tool-skill'
|
|
23
|
+
- id: command-goal
|
|
24
|
+
name: '@deepseek-ai/dsh-command-goal'
|
|
25
|
+
- id: tool-goal
|
|
26
|
+
name: '@deepseek-ai/dsh-tool-goal'
|
|
27
|
+
- id: planning
|
|
28
|
+
name: cordis:group
|
|
29
|
+
group: true
|
|
30
|
+
isolate:
|
|
31
|
+
planMode: true
|
|
32
|
+
config:
|
|
33
|
+
- id: plan-mode
|
|
34
|
+
name: '@deepseek-ai/dsh-plan-mode'
|
|
35
|
+
config:
|
|
36
|
+
section: >
|
|
37
|
+
You are in plan mode. Stay in plan mode until exit_plan_mode
|
|
38
|
+
succeeds or the user switches the session mode. Imperative
|
|
39
|
+
language to implement changes means plan the implementation, not
|
|
40
|
+
execute it. A user's conversational agreement — including an
|
|
41
|
+
answer confirming something you asked — approves nothing and
|
|
42
|
+
does not end plan mode; fold the confirmed decision into the
|
|
43
|
+
plan and submit it through exit_plan_mode.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Explore first. Use non-mutating reads, searches, static
|
|
47
|
+
analysis, and checks to ground the plan in the actual
|
|
48
|
+
repository. Do not edit or write files, change configuration,
|
|
49
|
+
run formatters or code generation that rewrites tracked files,
|
|
50
|
+
commit, or otherwise carry out the plan. Prefer existing
|
|
51
|
+
functions and patterns over new machinery.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
The tool catalog stays the same across modes for request-cache
|
|
55
|
+
stability. These plan-mode rules override any later tool
|
|
56
|
+
description or guidance that suggests using mutation tools;
|
|
57
|
+
those tools remain listed to keep the tool catalog unchanged. Do
|
|
58
|
+
not use todo_write to track this planning phase: it tracks
|
|
59
|
+
implementation after an approved plan, while the plan itself
|
|
60
|
+
belongs in exit_plan_mode.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
Resolve discoverable facts by inspection. Use ask_user_question
|
|
64
|
+
only for user-owned choices or material ambiguity that
|
|
65
|
+
inspection cannot answer. Do not ask the user where code lives
|
|
66
|
+
or how current behavior works when you can find out.
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Make the plan decision-complete: state the goal and success
|
|
70
|
+
criteria; group implementation changes by subsystem; identify
|
|
71
|
+
public API, schema, and data-flow changes; cover edge cases,
|
|
72
|
+
failure modes, tests, acceptance criteria, and explicit
|
|
73
|
+
assumptions. Keep it concise enough to review but detailed
|
|
74
|
+
enough that another engineer can implement it without making
|
|
75
|
+
design decisions.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
When ready, call exit_plan_mode with the complete plan markdown,
|
|
79
|
+
starting with a # title. Make exit_plan_mode the only and final
|
|
80
|
+
tool call in that assistant response: it presents the plan for
|
|
81
|
+
approval, and implementation begins only in a later step after
|
|
82
|
+
approval. Do not paste the final plan as a plain reply or ask
|
|
83
|
+
"should I proceed?" through prose or ask_user_question. If
|
|
84
|
+
review rejects it, incorporate the feedback and present again.
|
|
85
|
+
If the review channel is unavailable or aborted, stay in plan
|
|
86
|
+
mode and ask the user to switch modes manually; do not proceed
|
|
87
|
+
with implementation.
|
|
88
|
+
- id: compaction
|
|
89
|
+
name: cordis:group
|
|
90
|
+
group: true
|
|
91
|
+
isolate:
|
|
92
|
+
compaction: true
|
|
93
|
+
toolResultPruner: true
|
|
94
|
+
config:
|
|
95
|
+
- id: compaction-basic
|
|
96
|
+
name: '@deepseek-ai/dsh-compaction-basic'
|
|
97
|
+
- id: command-compact
|
|
98
|
+
name: '@deepseek-ai/dsh-command-compact'
|
|
99
|
+
- id: tool-result-pruner
|
|
100
|
+
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
|
101
|
+
config:
|
|
102
|
+
thresholdChars: 8192
|
|
103
|
+
headChars: 4096
|
|
104
|
+
tailChars: 1024
|
|
105
|
+
- id: delegation
|
|
106
|
+
name: cordis:group
|
|
107
|
+
group: true
|
|
108
|
+
isolate:
|
|
109
|
+
workflowEngine: true
|
|
110
|
+
config:
|
|
111
|
+
- id: tool-subagent-control
|
|
112
|
+
name: '@deepseek-ai/dsh-tool-subagent-control'
|
|
113
|
+
- id: tool-subagent-list-agents
|
|
114
|
+
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
|
115
|
+
- id: tool-subagent
|
|
116
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
117
|
+
config:
|
|
118
|
+
provider: spawn
|
|
119
|
+
toolName: subagent
|
|
120
|
+
modelSelectionSettings: true
|
|
121
|
+
backgroundMode: continuable
|
|
122
|
+
- id: tool-subagent-fork
|
|
123
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
124
|
+
config:
|
|
125
|
+
provider: fork
|
|
126
|
+
toolName: subagent_fork
|
|
127
|
+
backgroundMode: continuable
|
|
128
|
+
- id: tool-subagent-codex
|
|
129
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
130
|
+
disabled: true
|
|
131
|
+
config:
|
|
132
|
+
provider: codex
|
|
133
|
+
toolName: subagent_codex
|
|
134
|
+
backgroundMode: one-shot
|
|
135
|
+
maxDepth: provider-managed
|
|
136
|
+
- id: tool-subagent-claude-code
|
|
137
|
+
name: '@deepseek-ai/dsh-tool-subagent'
|
|
138
|
+
disabled: true
|
|
139
|
+
config:
|
|
140
|
+
provider: claude-code
|
|
141
|
+
toolName: subagent_claude_code
|
|
142
|
+
backgroundMode: one-shot
|
|
143
|
+
maxDepth: provider-managed
|
|
144
|
+
# `tool-workflow` is deliberately absent: it injects `workflowEngine`,
|
|
145
|
+
# which only `@deepseek-ai/dsh-workflow-ptc` provides, and that package
|
|
146
|
+
# has no 0.1.1-rc.2 release (its earliest is 0.1.6-alpha.1). The App's
|
|
147
|
+
# workflow surfaces therefore have nothing to run on this host.
|
|
148
|
+
- id: tool-ralph
|
|
149
|
+
name: '@deepseek-ai/dsh-tool-ralph'
|
|
150
|
+
disabled: true
|
|
151
|
+
config:
|
|
152
|
+
subagentProvider: spawn
|
|
153
|
+
maxRounds: 64
|
|
154
|
+
- id: tool-todo
|
|
155
|
+
name: '@deepseek-ai/dsh-tool-todo'
|
|
156
|
+
config:
|
|
157
|
+
allowParallelInProgress: true
|
|
158
|
+
- id: tool-web
|
|
159
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
160
|
+
config:
|
|
161
|
+
fetch: true
|
|
162
|
+
searchTimeoutMs: 60000
|
|
163
|
+
# Without this row the mobile host's tool catalog has no `ask_user_question`,
|
|
164
|
+
# so the App's question dock could never appear: the model simply reports that
|
|
165
|
+
# the tool is unavailable. `@deepseek-ai/dsh-host-apiproxy` already registers
|
|
166
|
+
# the `ctx.userQuestions` provider that bridges `ask()` to the mux
|
|
167
|
+
# `question/requested` frame, and the plugin's `lib/mobile/questions.js`
|
|
168
|
+
# answers it — only the tool row was missing.
|
|
169
|
+
- id: tool-ask-user
|
|
170
|
+
name: '@deepseek-ai/dsh-tool-ask-user'
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 只给**无头移动宿主**用的 overlay 层,由 host.js 显式加载(不是 bundle 层)。
|
|
2
|
+
#
|
|
3
|
+
# 为什么单独一个文件:这个包同时有两种宿主,而这一层里的每一行都只对其中一种成立。
|
|
4
|
+
# - 桌面 GUI:`~/.dsh/profiles/desktop` 通过 DSH 内置插件管理器把本包装成 bundle,
|
|
5
|
+
# 于是 `dsh.bundle.patch`(cordis.patch.yml)会作为一层落到桌面 profile 的树上。
|
|
6
|
+
# 下面 directory-picker 的两行如果留在那一层,桌面的「选择目录」就会从原生
|
|
7
|
+
# 对话框换成 browse —— 桌面自己不需要这个改动(它的操作者就坐在机器前)。
|
|
8
|
+
# - 无头移动宿主:`host.js` 逐层拼 patch,它想开浏览器/想让手机能选目录,所以才要这些行。
|
|
9
|
+
# 所以「挂载自己」留在 cordis.patch.yml(两种宿主都要),「宿主专属配置」放这里。
|
|
10
|
+
#
|
|
11
|
+
# 行序即优先级:host.js 把它排在 profile 自身的 patch 层之后,所以这里的每一行都
|
|
12
|
+
# 能盖掉更早的层。
|
|
13
|
+
|
|
14
|
+
# A headless mobile host has no operator waiting for a browser window, so the
|
|
15
|
+
# web runtime must neither open one nor print a URL nobody will read.
|
|
16
|
+
- id: web-runtime
|
|
17
|
+
config:
|
|
18
|
+
openBrowser: false
|
|
19
|
+
printUrl: false
|
|
20
|
+
surfaceContext: false
|
|
21
|
+
trustedHosts: []
|
|
22
|
+
|
|
23
|
+
# 手机是远程客户端,看不到被控端的原生选择框。directory-picker-auto 只在「宿主 web
|
|
24
|
+
# 服务绑定到非 127.0.0.1」时才认为存在远程客户端,从而挂 browse;而移动宿主故意把
|
|
25
|
+
# web 服务留在回环(手机是从 sidecar/中转进来的,不直连宿主的 web 端口,见上面
|
|
26
|
+
# web-runtime 一行),于是它在启动时必定解析成 native —— 手机点「添加工作区」只能看到
|
|
27
|
+
# host.listDirectory needs the browse capability,既列不出目录也建不了目录,只能登记
|
|
28
|
+
# 一个已经存在的路径,「新建工作目录」这条流程从根上就走不通。
|
|
29
|
+
#
|
|
30
|
+
# upstream 那行自己留了出口:「Mount -native or -browse directly in an overlay to pin
|
|
31
|
+
# the interaction.」这里就按它说的把交互钉成 browse:这个宿主存在的意义就是给手机用,
|
|
32
|
+
# 而 browse 后端(dsh-host-directory-picker-browse,纯 node stdlib 实现)不依赖任何
|
|
33
|
+
# 本机显示会话,正是为远程客户端准备的。
|
|
34
|
+
#
|
|
35
|
+
# 注意 patch 的语义(cordis-plugin-include 的 applyEntryPatches):一行 patch 里的
|
|
36
|
+
# `name` 是「断言」而不是「改写」—— 只有和原行同名才会应用,而且 `name` 本身被排除在
|
|
37
|
+
# 覆盖字段之外。所以想换掉一个插件的挂载,只能先 `disabled: true` 关掉原行,再 insert
|
|
38
|
+
# 一行自己的。直接写 `- id: <原 id>` + 新 `name:` 会被静默跳过(只留一行 loader 警告)。
|
|
39
|
+
#
|
|
40
|
+
# 只 insert 宿主后端、不 insert 客户端界面:移动宿主不开渲染器
|
|
41
|
+
# (web-runtime 的 surfaceContext: false),而且 dsh-client-ui-directory-picker-browse
|
|
42
|
+
# 并不在本包的依赖闭包里(它只嵌在 dsh-web-app 的 node_modules 下),按名字解析不到。
|
|
43
|
+
- id: directory-picker
|
|
44
|
+
disabled: true
|
|
45
|
+
|
|
46
|
+
- insert:
|
|
47
|
+
- id: directory-picker-browse
|
|
48
|
+
name: '@deepseek-ai/dsh-host-directory-picker-browse'
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# dsh bundle patch:把移动 API 插进 profile 的层栈。
|
|
2
|
+
#
|
|
3
|
+
# 这一层**两种宿主都会加载**(桌面 GUI 走 DSH 内置插件管理器把它当 bundle;无头宿主
|
|
4
|
+
# 由 host.js 显式作为 overlay 加载),因此这里只放「两种宿主都需要」的行 —— 目前就是
|
|
5
|
+
# 挂载本插件自己这一行。宿主专属的行(不开浏览器、把目录选择器钉成 browse)在
|
|
6
|
+
# cordis.mobile-host.yml,只由 host.js 加载,桌面碰不到。
|
|
7
|
+
#
|
|
8
|
+
# 挂载本插件自己的那一行在这里:`dsh.profile.bundles` 只让这个包的 patch 成为叠加层,
|
|
9
|
+
# 它不会凭空挂载任何东西(没有 insert 行的话,插件装了但永远不会被载入 —— 不报错、
|
|
10
|
+
# 只是没有 /api/mobile/v1,也没有 sidecar)。桌面端装进 `~/.dsh/profiles/desktop` 后
|
|
11
|
+
# 插件就在 profile 的 node_modules 里,按包名解析得到的就是 lib/index.js。
|
|
12
|
+
#
|
|
13
|
+
# 无头宿主 host.js 会**在装载前把这一行摘掉**(见 host.js 的 dropBareNameSelfMounts):
|
|
14
|
+
# 那个场景下插件不在自己的 node_modules 里,裸名解析不到,而 loader 对解析失败不是
|
|
15
|
+
# 「留一条警告」而是整棵树加载失败(实测 `plugin tree failed to load: failed to import
|
|
16
|
+
# loader entry dsh-mobile (dsh-mobile-plugin): Cannot find package 'dsh-mobile-plugin'`
|
|
17
|
+
# —— 服务器宿主曾因此完全起不来)。host.js 用绝对路径另插一行 `mobile-api`,那才是
|
|
18
|
+
# 无头宿主的挂载点。
|
|
19
|
+
- insert:
|
|
20
|
+
- id: dsh-mobile
|
|
21
|
+
name: 'dsh-mobile-plugin'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Persistent per-device relay credentials. The bearer secret is returned once;
|
|
4
|
+
* only its hash is stored on disk. The host token is retained so a durable
|
|
5
|
+
* relay credential can continue to authenticate legacy 30-day clients. */
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const os = require('node:os');
|
|
10
|
+
|
|
11
|
+
const FILE = process.env.DEVICE_CREDENTIALS_FILE || path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'relay-device-credentials.json');
|
|
12
|
+
/** Default credential lifetime. `issue(..., ttlMs)` may override; `ttlMs <= 0` opts out. */
|
|
13
|
+
const DEFAULT_TTL_MS = 365 * 24 * 3600 * 1000;
|
|
14
|
+
let loaded = false;
|
|
15
|
+
let records = [];
|
|
16
|
+
function digest(value) { return crypto.createHash('sha256').update(String(value)).digest('hex'); }
|
|
17
|
+
/** Constant-time hash comparison (both sides are 64-char hex). */
|
|
18
|
+
function hashEquals(a, b) {
|
|
19
|
+
const left = Buffer.from(String(a), 'utf8');
|
|
20
|
+
const right = Buffer.from(String(b), 'utf8');
|
|
21
|
+
if (left.length !== right.length) return false;
|
|
22
|
+
return crypto.timingSafeEqual(left, right);
|
|
23
|
+
}
|
|
24
|
+
function load() {
|
|
25
|
+
if (loaded) return;
|
|
26
|
+
loaded = true;
|
|
27
|
+
try { const data = JSON.parse(fs.readFileSync(FILE, 'utf8')); records = Array.isArray(data) ? data : []; } catch { records = []; }
|
|
28
|
+
}
|
|
29
|
+
function save() {
|
|
30
|
+
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
|
31
|
+
const tmp = `${FILE}.tmp`;
|
|
32
|
+
fs.writeFileSync(tmp, JSON.stringify(records, null, 2), { mode: 0o600 });
|
|
33
|
+
fs.renameSync(tmp, FILE);
|
|
34
|
+
}
|
|
35
|
+
function issue(instanceId, device, hostToken, ttlMs = DEFAULT_TTL_MS) {
|
|
36
|
+
load();
|
|
37
|
+
const deviceId = String(device?.deviceId || device?.id || crypto.randomUUID()).slice(0, 128);
|
|
38
|
+
const credential = `dshc_${crypto.randomBytes(32).toString('base64url')}`;
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
// A host instance may be paired with multiple phone installations.
|
|
41
|
+
// deviceId is client metadata and can collide after app-data migration or
|
|
42
|
+
// cloning, so it must not be used as a uniqueness constraint for credentials.
|
|
43
|
+
const record = { instanceId, deviceId, hash: digest(credential), hostToken: typeof hostToken === 'string' ? hostToken : '', createdAt: now };
|
|
44
|
+
// `find()` has always honoured `expiresAt`, but nothing ever set it — so every
|
|
45
|
+
// credential was permanent and "revoke" only marked records that the cookie
|
|
46
|
+
// path never consulted. Issue an explicit expiry by default.
|
|
47
|
+
if (Number.isFinite(ttlMs) && ttlMs > 0) record.expiresAt = now + ttlMs;
|
|
48
|
+
records.push(record);
|
|
49
|
+
save();
|
|
50
|
+
return { credential, deviceId, expiresAt: record.expiresAt || null };
|
|
51
|
+
}
|
|
52
|
+
function find(credential, instanceId) {
|
|
53
|
+
load();
|
|
54
|
+
if (typeof credential !== 'string' || !credential) return null;
|
|
55
|
+
const hash = digest(credential);
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
let match = null;
|
|
58
|
+
// Scan every record: an early-exit `Array.find` would leak the matching
|
|
59
|
+
// position through response timing.
|
|
60
|
+
for (const record of records) {
|
|
61
|
+
const same = hashEquals(record.hash, hash);
|
|
62
|
+
if (same && record.instanceId === instanceId && isLive(record, now)) match = record;
|
|
63
|
+
}
|
|
64
|
+
return match;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Look up a credential this store issued, whether or not it is still live.
|
|
68
|
+
*
|
|
69
|
+
* `find()` answers "may this caller proceed?". This answers "did we ever issue
|
|
70
|
+
* this here?" — and the difference is load-bearing. The desktop host and the
|
|
71
|
+
* Relay keep separate credential stores, so a phone paired over the public
|
|
72
|
+
* Relay presents a `dshc_` the host has never seen. That one must fall back to
|
|
73
|
+
* its stateless token instead of being rejected. A `dshc_` the host *did* issue
|
|
74
|
+
* must instead be authoritative, so that revoking it takes effect immediately.
|
|
75
|
+
*/
|
|
76
|
+
function lookup(credential, instanceId) {
|
|
77
|
+
load();
|
|
78
|
+
if (typeof credential !== 'string' || !credential) return null;
|
|
79
|
+
const hash = digest(credential);
|
|
80
|
+
let match = null;
|
|
81
|
+
for (const record of records) {
|
|
82
|
+
const same = hashEquals(record.hash, hash);
|
|
83
|
+
if (same && record.instanceId === instanceId) match = record;
|
|
84
|
+
}
|
|
85
|
+
return match;
|
|
86
|
+
}
|
|
87
|
+
/** True when a record is neither revoked nor past its expiry. */
|
|
88
|
+
function isLive(record, now = Date.now()) {
|
|
89
|
+
return Boolean(record) && !record.revokedAt && (!record.expiresAt || record.expiresAt > now);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Look up the newest live credential issued to a device.
|
|
93
|
+
*
|
|
94
|
+
* Pairing cookies carry a deviceId instead of the bearer secret, so this is the
|
|
95
|
+
* only way for the Relay to notice that a device was revoked (or that its
|
|
96
|
+
* credential expired) without the phone having to present the secret itself.
|
|
97
|
+
*/
|
|
98
|
+
function isActiveDevice(instanceId, deviceId) {
|
|
99
|
+
load();
|
|
100
|
+
if (typeof deviceId !== 'string' || !deviceId) return null;
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
let match = null;
|
|
103
|
+
for (const record of records) {
|
|
104
|
+
if (record.instanceId === instanceId && record.deviceId === deviceId && isLive(record, now)) match = record;
|
|
105
|
+
}
|
|
106
|
+
return match;
|
|
107
|
+
}
|
|
108
|
+
function revoke(credential, instanceId) {
|
|
109
|
+
load();
|
|
110
|
+
const record = find(credential, instanceId);
|
|
111
|
+
if (!record) return false;
|
|
112
|
+
record.revokedAt = Date.now();
|
|
113
|
+
save();
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
function listActive() {
|
|
117
|
+
load();
|
|
118
|
+
return records.filter((record) => !record.revokedAt && (!record.expiresAt || record.expiresAt > Date.now())).map(({ instanceId, deviceId, createdAt, expiresAt }) => ({ instanceId, deviceId, createdAt, ...(expiresAt ? { expiresAt } : {}) }));
|
|
119
|
+
}
|
|
120
|
+
function revokeDevice(instanceId, deviceId) {
|
|
121
|
+
load();
|
|
122
|
+
let count = 0;
|
|
123
|
+
for (const record of records) if (record.instanceId === instanceId && record.deviceId === deviceId && !record.revokedAt) { record.revokedAt = Date.now(); count += 1; }
|
|
124
|
+
if (count) save();
|
|
125
|
+
return count;
|
|
126
|
+
}
|
|
127
|
+
/** Revoke every live credential issued to a host instance (clear-all-authorization). */
|
|
128
|
+
function revokeAllByInstance(instanceId) {
|
|
129
|
+
load();
|
|
130
|
+
let count = 0;
|
|
131
|
+
for (const record of records) if (record.instanceId === instanceId && !record.revokedAt) { record.revokedAt = Date.now(); count += 1; }
|
|
132
|
+
if (count) save();
|
|
133
|
+
return count;
|
|
134
|
+
}
|
|
135
|
+
module.exports = { FILE, DEFAULT_TTL_MS, issue, find, lookup, isLive, isActiveDevice, revoke, revokeDevice, revokeAllByInstance, listActive };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-mobile-credentials",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "DSH 移动端共享凭据库:签发/校验/吊销手机端的持久化中转凭据(中转服务器与宿主插件共用)",
|
|
6
|
+
"main": "lib/credentials.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib/**/*.js"
|
|
9
|
+
],
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">= 20"
|
|
12
|
+
}
|
|
13
|
+
}
|