@zmainer/dsh-wx-bridge 1.0.16 → 1.1.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.md +86 -8
- package/lib/client.js +81 -0
- package/lib/index.js +39 -0
- package/lib/kernel/acp-overlay-chat.yml +27 -26
- package/lib/kernel/acp-overlay.yml +23 -22
- package/lib/kernel/acp.mjs +38 -7
- package/lib/kernel/approval-reply.mjs +86 -0
- package/lib/kernel/bridge.mjs +2229 -1916
- package/lib/kernel/outbox.mjs +42 -0
- package/lib/kernel/wxpush.mjs +63 -0
- package/package.json +15 -3
- package/src/client.js +81 -0
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ DSH(DeepSeek Harness)插件:把**手机微信**接到本机 DSH,让微
|
|
|
5
5
|
- **宿主半**:注册独立前缀路由 `/wxbridge/*`,托管桥进程(启动/停止/重启/体检),向设置页提供实时状态与**预设下拉**。
|
|
6
6
|
- **客户端半**:设置 → **微信连接** 面板(React),显示桥的真实状态、启停按钮、扫码配对,以及**手机通道预设**选择。
|
|
7
7
|
- **内核**:`lib/kernel/` 内置零依赖的 `bridge.mjs`(iLink 长轮询收发 + ACP 会话执行)与 `keeper.mjs`(存活自检 + 脱树重启)。路径、工作区、知识库、预设全部可配置,无硬编码。
|
|
8
|
+
- **1.1.0 新增**:**微信内审批应答**(审批卡推到微信,回「批准 / 拒绝」即可)与**主动推送**(任何进程用一行命令把消息推到手机)。
|
|
8
9
|
|
|
9
10
|
## 执行路径(1.0 起:ACP 原生会话)
|
|
10
11
|
|
|
@@ -69,7 +70,13 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
69
70
|
"vault": "<可选:Obsidian 知识库绝对路径>",
|
|
70
71
|
"intervalMs": 300000,
|
|
71
72
|
"staleMs": 300000,
|
|
72
|
-
"acp": {
|
|
73
|
+
"acp": {
|
|
74
|
+
"preset": "<预设 id,留空跟随 DSH 默认预设>",
|
|
75
|
+
"enabled": true,
|
|
76
|
+
"permPolicy": "ask",
|
|
77
|
+
"permPreset": "danger-full-access",
|
|
78
|
+
"approvalTimeoutMs": 120000
|
|
79
|
+
}
|
|
73
80
|
}
|
|
74
81
|
```
|
|
75
82
|
|
|
@@ -87,7 +94,9 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
87
94
|
| `acp.home` | ACP 子进程的 `DSH_HOME`(决定会话写进哪个仓库) | 宿主 home |
|
|
88
95
|
| `acp.dshBin` | ACP 用的 dsh 入口(默认跟随宿主运行时,schema 才一致) | 自动 |
|
|
89
96
|
| `acp.patch` | 覆盖叠层文件路径(预设档自动生成) | 插件内置 |
|
|
90
|
-
| `acp.permPolicy` |
|
|
97
|
+
| `acp.permPolicy` | 审批策略:`ask`(默认,推到微信等回复)/ `allow`(自动批准)/ `reject`(一律拒绝) | ask |
|
|
98
|
+
| `acp.permPreset` | 手机通道权限预设(写进 ACP 叠层):`danger-full-access` / `workspace-write` / `read-only`。收紧后产生的审批会在微信里问你 | danger-full-access |
|
|
99
|
+
| `acp.approvalTimeoutMs` | 审批卡等待时长(超时按拒绝) | 120000 |
|
|
91
100
|
| `acp.patchAcp` | 是否允许给已装 `dsh-acp` 打预设补丁 | true |
|
|
92
101
|
|
|
93
102
|
环境变量:`WXBRIDGE_DATA`、`BRIDGE_CWD`、`BRAIN_VAULT`、`DSH_BIN`、`BRIDGE_HOST_HOME`、`WXBRIDGE_ACP=off`;
|
|
@@ -110,17 +119,20 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
110
119
|
| GET | `/wxbridge/presets` | **预设名册**(id/name/description)+ 当前选中值 |
|
|
111
120
|
| POST | `/wxbridge/preset` | 写入预设选择(`{ "preset": "<id|空>" }`) |
|
|
112
121
|
| POST | `/wxbridge/allowlist` | 写入准入模式(`{ "mode": "strict|auto|open", "restart": true }`) |
|
|
122
|
+
| GET · POST | `/wxbridge/approval` | 读/写审批策略(`{ "mode": "ask|allow|reject" }`) |
|
|
123
|
+
| POST | `/wxbridge/push` | **主动推送**:把一条消息排队给桥代发(`{ "text": "…", "to": "<可选 peerId>" }`) |
|
|
113
124
|
| POST | `/wxbridge/start` · `/stop` · `/restart` · `/tick` | 桥生命周期(`tick` = 立即体检并按需自愈) |
|
|
114
125
|
| POST | `/wxbridge/config` | 写配置文件(`dataDir`/`cwd`/`vault`/`intervalMs`/`staleMs`/`prompt`) |
|
|
115
126
|
| POST | `/wxbridge/task` · `/scan` · `/attach` | 宿主内执行 / 会话归组 |
|
|
116
127
|
|
|
117
128
|
## 安全基线
|
|
118
129
|
|
|
119
|
-
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
- **权限预设(`acp.permPreset`,默认 `danger-full-access`)**:默认仍是最宽——手机任务常要跑 `mvn`、写
|
|
131
|
+
`~/.m2`、动工作区之外的文件,收紧到 `workspace-write` 会直接把这些操作拦掉。想要更安全就把 `acp.permPreset`
|
|
132
|
+
改成 `workspace-write` / `read-only`:**1.1.0 起审批会在微信里问你**(回「批准 / 拒绝」),不再"无应答直接失败",
|
|
133
|
+
所以收紧是可行的取舍,而不是硬伤。
|
|
134
|
+
- **审批默认 `ask`**:需要审批的操作会把卡片推到微信等回复;**120 秒未回按拒绝**(fail-closed),
|
|
135
|
+
也可发 `/审批 自动` 恢复"自动批准"、`/审批 拒绝` 变成一律拒绝。找不到归属联系人的审批一律拒绝。
|
|
124
136
|
- **准入默认 `strict`**(TOFU 白名单 + 一次性登记 token):未登记的发送者**不执行**,但只回一次可操作提示
|
|
125
137
|
(10 分钟/人节流;提示里给出登记方式),并写审计日志。面板「准入模式」可切 `auto`(首次发言即登记)或
|
|
126
138
|
`open`(不检查,不建议)——`auto` 的边界是「谁拿到了这个机器人」,机器人被拉进群/被加好友会连带放权。
|
|
@@ -132,6 +144,48 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
132
144
|
|
|
133
145
|
> 数据经腾讯 iLink 通道,**不得用于涉密内容**。状态与凭据文件为明文,仅靠 ACL 保护。
|
|
134
146
|
|
|
147
|
+
## 微信内审批应答(1.1.0)
|
|
148
|
+
|
|
149
|
+
ACP 的 `session/request_permission` 请求**必须被应答**。早先版本只能按固定策略自动答(`allow`/`reject`),
|
|
150
|
+
所以"手机端没法应答审批"就成了把通道权限放到 `danger-full-access` 的理由。现在桥把审批**推到你手机上**:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
【需要你批准】#3
|
|
154
|
+
操作:Bash: 删除 build 目录
|
|
155
|
+
选项:
|
|
156
|
+
1) 允许一次
|
|
157
|
+
2) 总是允许
|
|
158
|
+
3) 拒绝
|
|
159
|
+
回复:批准 / 拒绝(也可以回 1 / 2)
|
|
160
|
+
120 秒内没回复=按拒绝处理(需要审批的操作会失败;想让桥不再问,发 /审批 自动)。
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- 回复支持:`批准` / `同意` / `允许` / `yes` / `1`,`拒绝` / `不同意` / `no` / `2`,以及 `#3 批准` 这种带编号的形式。
|
|
164
|
+
- 桥会把你的答复映射回 ACP 的 `optionId`(允许类选项优先,挑不到就按拒绝)。
|
|
165
|
+
- **超时、找不到归属联系人、或你回了别的内容** → 该项审批按**拒绝**处理(fail-closed,不静默放权)。
|
|
166
|
+
- 同一时刻每个人只留一张卡:新卡到达会作废旧卡(避免"批准"批到过期的那张)。
|
|
167
|
+
- 开关:`/审批` 看策略、`/审批 询问|自动|拒绝` 改;面板/HTTP 侧用 `POST /wxbridge/approval`。
|
|
168
|
+
|
|
169
|
+
> 什么时候会真的看到审批卡?**当通道权限预设被收紧时**(`acp.permPreset=workspace-write`/`read-only`)。
|
|
170
|
+
> 默认的 `danger-full-access` 不产生审批——想用这个能力,就把它改窄。
|
|
171
|
+
|
|
172
|
+
## 主动推送(1.1.0)
|
|
173
|
+
|
|
174
|
+
任何进程(手机通道里的 agent、桌面会话、定时任务)都能往微信推消息——**只写文件,不需要网络与鉴权**:
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
node "<dataDir>/wxpush.mjs" --text "跑完了:1442 条全部落库"
|
|
178
|
+
node "<dataDir>/wxpush.mjs" --text "上午的任务失败在税局 504" --to <peerId> # 指定收件人(可选)
|
|
179
|
+
echo "来自管道的长文本" | node "<dataDir>/wxpush.mjs" --stdin
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- 它在 `<dataDir>/outbox/` 丢一个 JSON;桥**每 5 秒取件**并代发(结果用文件位置表达:
|
|
183
|
+
成功移到 `outbox/sent/`,失败移到 `outbox/failed/` 并附原因)。
|
|
184
|
+
- 收件人:显式 `--to` > 最近联系过的人;**只在已登记白名单里选**(推送不能绕过准入),无人可选即失败。
|
|
185
|
+
- 「普通对话」档的提示词里已写明这条命令,所以你可以直接对手机说"干完给我发一条""每 10 分钟报个进度"。
|
|
186
|
+
- 桌面/面板/其它插件也可以走宿主路由:`POST /wxbridge/push {"text": "…", "to": "…"}`。
|
|
187
|
+
- 只支持文本推送(插件目前没有出站附件通道;要发文件请用会话里的 agent 直接操作)。
|
|
188
|
+
|
|
135
189
|
## 微信侧指令
|
|
136
190
|
|
|
137
191
|
| 指令 | 说明 |
|
|
@@ -146,7 +200,10 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
146
200
|
| `/status` | 桥状态、档位、原生会话 id、模型与强度 |
|
|
147
201
|
| `/task` · `/cancel` | 任务与排队 / 取消运行中任务 |
|
|
148
202
|
| `/files` | 看待用附件队列(默认 30 分钟有效);`/files clear` 清空 |
|
|
149
|
-
|
|
|
203
|
+
| `/审批 [询问\|自动\|拒绝]` | 审批策略:默认**询问**(卡片推微信等你回);`自动` 不打扰你;`拒绝` 一律失败 |
|
|
204
|
+
| `/权限` | 看当前审批策略与通道权限预设 |
|
|
205
|
+
| `/help` `/ping` | 帮助 / 探活 |
|
|
206
|
+
| `/approve` · `/reject` | 命令别名:审批一律自动批准 / 一律拒绝(不等卡片) |
|
|
150
207
|
|
|
151
208
|
**文件与图片(1.0.14)**:直接把文件或图片发给机器人 —— 微信的文件消息**带不了附言**(条目里就没有文字字段),
|
|
152
209
|
所以流程是「先收下 → 反问你一句话 → 用你的第二句话触发任务」:
|
|
@@ -164,6 +221,16 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
164
221
|
|
|
165
222
|
其余任意文本 → 交给 DSH 执行。
|
|
166
223
|
|
|
224
|
+
## 兼容性
|
|
225
|
+
|
|
226
|
+
| 维度 | 说明 |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| DSH / harness | 按官方插件协议安装(`dsh.bundle` 清单 + `dsh plugin add`);内核只用 `dsh --profile acp` 与 `--profile headless` 两个内置 profile,**不绑定某个 harness 小版本**。预设功能依赖已装 `@deepseek-ai/dsh-acp` 支持 `Config.preset`——不支持/被覆盖时自动退回宿主组合(见「预设补丁」) |
|
|
229
|
+
| Node | **`>=22.19.0`**(`package.json` 的 `engines`):会话文件是**多帧 zstd**,逐帧解压用到 `node:zlib` 的 `zstdDecompressSync`。桌面端自带运行时(本机实测 v26)与系统 Node(本机 v22.19)都验证过 |
|
|
230
|
+
| 操作系统 | **Windows 已实测**(进程树终止用 `taskkill /T /F`;运行时入口探测含 `%LOCALAPPDATA%\Programs\*` 扫描、PID 复用判断)。macOS / Linux **未实测**:内核是纯 Node,但上述几处是 Windows 实现,跨平台请先跑 `node lib/kernel/bridge.mjs --selftest-runtime` |
|
|
231
|
+
| 依赖 | **零运行时依赖**(只用 Node 内建模块);不依赖任何 `@deepseek-ai/*` 包(宿主能力通过 `ctx.inject` 取用,缺失即降级) |
|
|
232
|
+
| 自测 | `--selftest-runtime`(运行时入口)/ `--selftest-approval`(审批卡与回复解析)/ `--selftest-outbox`(推送信箱)/ `--selftest-files` / `--selftest-inbound` / `--selftest-presets` |
|
|
233
|
+
|
|
167
234
|
## 常见问题 / Troubleshooting
|
|
168
235
|
|
|
169
236
|
### 插件管理里点「更新」报 `Cannot switch a non-link plugin directory`
|
|
@@ -199,6 +266,17 @@ ode_modules\@zmainer\dsh-wx-bridge` → 改名成 `...dsh-wx-bridge.bak` 更稳
|
|
|
199
266
|
|
|
200
267
|
## 最近变更
|
|
201
268
|
|
|
269
|
+
- **1.1.0**:**微信内审批应答 + 主动推送**(两条都是用户要求)——
|
|
270
|
+
① **审批**:ACP 的 `session/request_permission` 不再由桥按固定策略静默回答,而是把卡片推到微信
|
|
271
|
+
(选项带编号),回「批准 / 拒绝 / 1 / 2」即映射成 ACP 的 `optionId` 回去;**超时 120 秒按拒绝**
|
|
272
|
+
(fail-closed),找不到归属联系人同样按拒绝;同一人同时只留一张卡。策略 `ask`(新默认)/`allow`/`reject`
|
|
273
|
+
可用 `/审批` 或 `POST /wxbridge/approval` 切换。因此 `danger-full-access` 不再是"手机端无法应答审批"的
|
|
274
|
+
唯一出路:把 `acp.permPreset` 收紧到 `workspace-write` 后,越界的操作会先在微信里问你。
|
|
275
|
+
② **主动推送**:`<dataDir>/wxpush.mjs`(桥每次启动刷新一份)把一条消息写进 `outbox/`,桥每 5 秒取件代发,
|
|
276
|
+
结果落 `outbox/sent` / `outbox/failed`;收件人只在已登记白名单里选(推送不绕过准入)。
|
|
277
|
+
「普通对话」档的提示词里写明了这条命令,agent 可自行"干完提醒你";宿主侧另有 `POST /wxbridge/push`。
|
|
278
|
+
③ 工程:新增纯函数层 `approval-reply.mjs` / `outbox.mjs` + `--selftest-approval` / `--selftest-outbox`
|
|
279
|
+
两个离线自测(各 13 / 6 条断言),package.json 补 `repository`(npm 与仓库的自动关联需要它)。
|
|
202
280
|
- **1.0.15**:**准入默认改回 `strict`(与 README 一致)**,并把选择权交给用户——
|
|
203
281
|
起因是 awesome-dsh-plugin 的收录评审指出的一个真实矛盾:代码默认 `ALLOWLIST=auto`(**任何能给机器人发消息的人
|
|
204
282
|
首次发言即自动登记**)叠加手机通道的 `danger-full-access`(**不经审批**),等价于"任何能给它发消息的人
|
package/lib/client.js
CHANGED
|
@@ -122,6 +122,8 @@ window.__ModuleLoader__.load({
|
|
|
122
122
|
const [presets, setPresets] = R.useState([])
|
|
123
123
|
const [presetSel, setPresetSel] = R.useState('')
|
|
124
124
|
const [allowlist, setAllowlist] = R.useState('strict')
|
|
125
|
+
const [approval, setApproval] = R.useState('ask')
|
|
126
|
+
const [pushText, setPushText] = R.useState('')
|
|
125
127
|
const [presetState, setPresetState] = R.useState(null)
|
|
126
128
|
useWxStyle()
|
|
127
129
|
|
|
@@ -151,9 +153,43 @@ window.__ModuleLoader__.load({
|
|
|
151
153
|
.then((r) => r.json())
|
|
152
154
|
.then((d) => { if (alive) setPair(d) })
|
|
153
155
|
.catch(() => {})
|
|
156
|
+
fetch('/wxbridge/approval', { cache: 'no-store' })
|
|
157
|
+
.then((r) => r.json())
|
|
158
|
+
.then((d) => { if (alive && d && typeof d.approval === 'string') setApproval(d.approval) })
|
|
159
|
+
.catch(() => {})
|
|
154
160
|
return () => { alive = false }
|
|
155
161
|
}, [])
|
|
156
162
|
|
|
163
|
+
const saveApproval = async (mode) => {
|
|
164
|
+
setBusy('approval')
|
|
165
|
+
try {
|
|
166
|
+
const r = await fetch('/wxbridge/approval', {
|
|
167
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
168
|
+
body: JSON.stringify({ mode }),
|
|
169
|
+
})
|
|
170
|
+
const d = await r.json()
|
|
171
|
+
if (typeof d.approval === 'string') setApproval(d.approval)
|
|
172
|
+
setNote(d.ok
|
|
173
|
+
? ('审批策略已切到 ' + mode + ':' + (d.note || ''))
|
|
174
|
+
: ('切换失败:' + (d.error || '未知错误')))
|
|
175
|
+
} catch (e) { setNote('切换失败:' + String(e && e.message ? e.message : e)) } finally { setBusy('') }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const sendPush = async () => {
|
|
179
|
+
const text = String(pushText || '').trim()
|
|
180
|
+
if (!text) { setNote('先写一条要推送的内容。'); return }
|
|
181
|
+
setBusy('push')
|
|
182
|
+
try {
|
|
183
|
+
const r = await fetch('/wxbridge/push', {
|
|
184
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({ text }),
|
|
186
|
+
})
|
|
187
|
+
const d = await r.json()
|
|
188
|
+
setNote(d.ok ? ('已排队推送:' + (d.note || '')) : ('推送失败:' + (d.error || '未知错误')))
|
|
189
|
+
if (d.ok) setPushText('')
|
|
190
|
+
} catch (e) { setNote('推送失败:' + String(e && e.message ? e.message : e)) } finally { setBusy('') }
|
|
191
|
+
}
|
|
192
|
+
|
|
157
193
|
const loadPresets = () => fetch('/wxbridge/presets', { cache: 'no-store' })
|
|
158
194
|
.then((r) => r.json())
|
|
159
195
|
.then((d) => { setPresetState(d); setPresets(d.presets || []); setPresetSel(typeof d.selected === 'string' ? d.selected : '') })
|
|
@@ -451,6 +487,51 @@ window.__ModuleLoader__.load({
|
|
|
451
487
|
R.createElement('span', { style: dim }, '当前:' + allowlist + '(改完会写入 config.json 并重启桥,立即生效)')),
|
|
452
488
|
),
|
|
453
489
|
|
|
490
|
+
R.createElement('div', { style: { marginTop: '14px', paddingTop: '12px', borderTop: subtle } },
|
|
491
|
+
R.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } },
|
|
492
|
+
wxBubbles(16, WX, skin.dark ? '#0d1220' : '#ffffff', 'transparent'),
|
|
493
|
+
R.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '审批策略'),
|
|
494
|
+
R.createElement('span', { style: { fontSize: '11px', color: dim.color } }, '(需要审批的操作怎么答)')),
|
|
495
|
+
R.createElement('select', {
|
|
496
|
+
value: approval, onChange: (e) => setApproval(e.target.value),
|
|
497
|
+
style: { ...skin.control, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: '8px', fontSize: '12px' },
|
|
498
|
+
}, [
|
|
499
|
+
R.createElement('option', { key: 'ask', value: 'ask', style: skin.option }, 'ask(默认)· 把审批卡推到微信,等你回「批准 / 拒绝」,120 秒未回按拒绝'),
|
|
500
|
+
R.createElement('option', { key: 'allow', value: 'allow', style: skin.option }, 'allow · 自动批准,不打扰你(旧行为)'),
|
|
501
|
+
R.createElement('option', { key: 'reject', value: 'reject', style: skin.option }, 'reject · 一律拒绝(需要审批的操作会失败)'),
|
|
502
|
+
]),
|
|
503
|
+
R.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center' } },
|
|
504
|
+
R.createElement('button', {
|
|
505
|
+
className: 'wxbridge-btn', onClick: () => saveApproval(approval), disabled: !!busy,
|
|
506
|
+
style: {
|
|
507
|
+
marginRight: '10px', padding: '6px 14px', borderRadius: '999px', fontSize: '12px', fontWeight: 500,
|
|
508
|
+
cursor: busy ? 'default' : 'pointer', border: '1px solid ' + WX, background: WX, color: '#fff',
|
|
509
|
+
},
|
|
510
|
+
}, busy === 'approval' ? '处理中…' : '保存审批策略'),
|
|
511
|
+
R.createElement('span', { style: dim },
|
|
512
|
+
'当前:' + approval + '(保存在 config.json 的 acp.permPolicy;改了立即生效,不必重启)' +
|
|
513
|
+
(approval === 'ask' ? ' · 只有把通道权限预设收紧(acp.permPreset)才会真的产生审批卡' : ''))),
|
|
514
|
+
),
|
|
515
|
+
R.createElement('div', { style: { marginTop: '14px', paddingTop: '12px', borderTop: subtle } },
|
|
516
|
+
R.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } },
|
|
517
|
+
wxBubbles(16, WX, skin.dark ? '#0d1220' : '#ffffff', 'transparent'),
|
|
518
|
+
R.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '主动推送'),
|
|
519
|
+
R.createElement('span', { style: { fontSize: '11px', color: dim.color } }, '(试一下能不能推到你手机)')),
|
|
520
|
+
R.createElement('input', {
|
|
521
|
+
value: pushText, onChange: (e) => setPushText(e.target.value), placeholder: '要推到微信的一句话(例如:测试推送)',
|
|
522
|
+
style: { ...skin.control, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: '8px', fontSize: '12px' },
|
|
523
|
+
}),
|
|
524
|
+
R.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center' } },
|
|
525
|
+
R.createElement('button', {
|
|
526
|
+
className: 'wxbridge-btn', onClick: () => sendPush(), disabled: !!busy,
|
|
527
|
+
style: {
|
|
528
|
+
marginRight: '10px', padding: '6px 14px', borderRadius: '999px', fontSize: '12px', fontWeight: 500,
|
|
529
|
+
cursor: busy ? 'default' : 'pointer', border: '1px solid ' + WX, background: WX, color: '#fff',
|
|
530
|
+
},
|
|
531
|
+
}, busy === 'push' ? '发送中…' : '发一条测试推送'),
|
|
532
|
+
R.createElement('span', { style: dim }, '桥每 5 秒取件;结果在数据目录的 outbox/sent、outbox/failed')),
|
|
533
|
+
),
|
|
534
|
+
|
|
454
535
|
note ? R.createElement('pre', { style: { margin: '10px 0 0 0', padding: '9px 11px', borderRadius: '10px', background: mutedBg, border: subtle, fontSize: '11px', overflow: 'auto', maxHeight: '160px' } }, note) : null,
|
|
455
536
|
|
|
456
537
|
// ── 操作日志 ───────────────────────────────────────────────────
|
package/lib/index.js
CHANGED
|
@@ -700,6 +700,45 @@ export function apply(ctx, config) {
|
|
|
700
700
|
writeFileSync(f, JSON.stringify(keep, null, 2))
|
|
701
701
|
return send(res, { ok: true, written: f, applied: keep, note: '提示词下次任务即生效;其余项重启宿主后生效' })
|
|
702
702
|
}
|
|
703
|
+
if (p === '/push' && req.method === 'POST') {
|
|
704
|
+
// 主动推送:往桥的 outbox 丢一条(桥每 5 秒代发)。桌面会话 / 面板 / 其它插件都能调。
|
|
705
|
+
const body = await readBody(req)
|
|
706
|
+
const text = String((body && (body.text || body.message)) || '').trim()
|
|
707
|
+
if (!text) return send(res, { ok: false, error: 'text 不能为空' }, 400)
|
|
708
|
+
const dir = keeper ? keeper.dataDir : ''
|
|
709
|
+
if (!dir) return send(res, { ok: false, error: '桥的数据目录未知(桥没在运行?)' }, 503)
|
|
710
|
+
try {
|
|
711
|
+
const outbox = join(dir, 'outbox')
|
|
712
|
+
mkdirSync(outbox, { recursive: true })
|
|
713
|
+
const file = join(outbox, Date.now() + '-' + Math.random().toString(16).slice(2, 10) + '.json')
|
|
714
|
+
writeFileSync(file, JSON.stringify({
|
|
715
|
+
text: text.slice(0, 4000),
|
|
716
|
+
to: String((body && body.to) || '').trim(),
|
|
717
|
+
at: new Date().toISOString(),
|
|
718
|
+
source: 'host-route',
|
|
719
|
+
}, null, 2))
|
|
720
|
+
pushAction({ action: 'push', ok: true, detail: text.slice(0, 40) })
|
|
721
|
+
return send(res, { ok: true, file, note: '已排队;桥最多 5 秒内发出(结果看 outbox/sent 或 failed)' })
|
|
722
|
+
} catch (e) { return send(res, { ok: false, error: String(e?.message ?? e) }, 500) }
|
|
723
|
+
}
|
|
724
|
+
if (p === '/approval') {
|
|
725
|
+
const f = configPath()
|
|
726
|
+
const keep = readJsonFile(f)
|
|
727
|
+
const cur = String((keep.acp && keep.acp.permPolicy) || '').toLowerCase() || 'ask'
|
|
728
|
+
if (req.method !== 'POST') return send(res, { ok: true, approval: cur, configFile: f, modes: ['ask', 'allow', 'reject'] })
|
|
729
|
+
// 审批策略:ask(推到微信等回复)/ allow(自动批准)/ reject(一律拒绝)
|
|
730
|
+
const body = await readBody(req)
|
|
731
|
+
const mode = String((body && body.mode) || '').toLowerCase()
|
|
732
|
+
if (!['ask', 'allow', 'reject'].includes(mode)) return send(res, { ok: false, error: 'mode must be ask | allow | reject' }, 400)
|
|
733
|
+
keep.acp = Object.assign({}, (keep.acp && typeof keep.acp === 'object') ? keep.acp : {}, { permPolicy: mode })
|
|
734
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
735
|
+
writeFileSync(f, JSON.stringify(keep, null, 2))
|
|
736
|
+
pushAction({ action: 'approval', ok: true, detail: mode })
|
|
737
|
+
let restarted = 0
|
|
738
|
+
if (body && body.restart && keeper) { try { restarted = keeper.startBridge('approval-change') || 0 } catch (e) { ctx.logger?.warn?.('wxbridge: 重启失败 ' + String(e?.message ?? e)) } }
|
|
739
|
+
return send(res, { ok: true, approval: mode, configFile: f, restarted,
|
|
740
|
+
note: restarted ? '已写入配置并重启桥,立即生效' : '已写入配置;桥每条任务重读配置,手机发 /审批 也能立即改' })
|
|
741
|
+
}
|
|
703
742
|
if (p === '/allowlist' && req.method === 'POST') {
|
|
704
743
|
// 准入模式:strict(默认,只认已登记)/ auto(首次发言即登记)/ open(不检查)
|
|
705
744
|
const body = await readBody(req)
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
# wxbridge ACP 叠加层 · **对话模式**(手机默认档)
|
|
2
|
-
# 与 acp-overlay.yml 的差别:收窄注入面,避免「随口一句话」被当成第二大脑任务落盘。
|
|
3
|
-
# 1) 仍停用 dsh-computer-use(否则 profile 整树加载失败,原因见 acp-overlay.yml);
|
|
4
|
-
# 2) 停用技能目录(skill-filesystem 提供 ~/.ohdsh/skills,tool-skill 提供加载工具)——
|
|
5
|
-
# 手机提示里不再出现 second-brain-distill 这类"沉淀知识"的技能;
|
|
6
|
-
# 3) 覆盖 system-prompt 人格为「对话助手」:从本会话上下文回答,不主动读写文件/知识库。
|
|
7
|
-
# 需要真正的第二大脑行为时,手机发 `/brain on` 切到第二大脑模式(另一份叠加层)。
|
|
8
|
-
- id: dsh-computer-use
|
|
9
|
-
disabled: true
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
# wxbridge ACP 叠加层 · **对话模式**(手机默认档)
|
|
2
|
+
# 与 acp-overlay.yml 的差别:收窄注入面,避免「随口一句话」被当成第二大脑任务落盘。
|
|
3
|
+
# 1) 仍停用 dsh-computer-use(否则 profile 整树加载失败,原因见 acp-overlay.yml);
|
|
4
|
+
# 2) 停用技能目录(skill-filesystem 提供 ~/.ohdsh/skills,tool-skill 提供加载工具)——
|
|
5
|
+
# 手机提示里不再出现 second-brain-distill 这类"沉淀知识"的技能;
|
|
6
|
+
# 3) 覆盖 system-prompt 人格为「对话助手」:从本会话上下文回答,不主动读写文件/知识库。
|
|
7
|
+
# 需要真正的第二大脑行为时,手机发 `/brain on` 切到第二大脑模式(另一份叠加层)。
|
|
8
|
+
- id: dsh-computer-use
|
|
9
|
+
disabled: true
|
|
10
|
+
|
|
11
|
+
# 通道权限预设(config.acp.permPreset,默认 danger-full-access):
|
|
12
|
+
# 收紧到 workspace-write / read-only 后,需要审批的操作会把审批卡推到微信——
|
|
13
|
+
# 回「批准 / 拒绝」(或 1 / 2)即可,120 秒未回按拒绝处理(fail-closed)。
|
|
14
|
+
# 默认留在最宽,是因为 ~/.m2 之类的写操作会被 workspace-write 拦掉(maven 编译直接失败)。
|
|
15
|
+
- id: permission
|
|
16
|
+
config:
|
|
17
|
+
defaultPreset: danger-full-access
|
|
18
|
+
- id: skill-filesystem
|
|
19
|
+
disabled: true
|
|
20
|
+
- id: tool-skill
|
|
21
|
+
disabled: true
|
|
22
|
+
- id: system-prompt
|
|
23
|
+
config:
|
|
24
|
+
persona: >-
|
|
25
|
+
你是「手机微信通道」里的对话助手,用中文简洁回答。直接用当前会话已有的上下文回答,
|
|
26
|
+
不要为了"记住"去读写工作区文件或知识库;只有当用户明确要求(例如"记到库里""归档""落盘")
|
|
27
|
+
时才动文件。
|
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
# wxbridge ACP 叠加层(保持最小:只做已实证必要的一件事)
|
|
2
|
-
# 让 `dsh --profile acp` 在**宿主 home**(~/.ohdsh)里能起得来:
|
|
3
|
-
# ~/.ohdsh/cordis.patch.yml(根用户补丁层)引用 dsh-computer-use,该包只装在
|
|
4
|
-
# profiles/desktop/node_modules 里 → 新建的 profiles/acp 解析不到 → 整树加载失败
|
|
5
|
-
# (症状:initialize 正常、session/new 报 "the ACP bridge has been disposed")。
|
|
6
|
-
# ACP 是手机用的自动化通道,不需要桌面专属的 computer-use 插件。
|
|
7
|
-
# 注:停用一个不存在的 id 是无害空操作,因此本文件可无条件带上。
|
|
8
|
-
- id: dsh-computer-use
|
|
9
|
-
disabled: true
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
1
|
+
# wxbridge ACP 叠加层(保持最小:只做已实证必要的一件事)
|
|
2
|
+
# 让 `dsh --profile acp` 在**宿主 home**(~/.ohdsh)里能起得来:
|
|
3
|
+
# ~/.ohdsh/cordis.patch.yml(根用户补丁层)引用 dsh-computer-use,该包只装在
|
|
4
|
+
# profiles/desktop/node_modules 里 → 新建的 profiles/acp 解析不到 → 整树加载失败
|
|
5
|
+
# (症状:initialize 正常、session/new 报 "the ACP bridge has been disposed")。
|
|
6
|
+
# ACP 是手机用的自动化通道,不需要桌面专属的 computer-use 插件。
|
|
7
|
+
# 注:停用一个不存在的 id 是无害空操作,因此本文件可无条件带上。
|
|
8
|
+
- id: dsh-computer-use
|
|
9
|
+
disabled: true
|
|
10
|
+
|
|
11
|
+
# 通道权限预设(config.acp.permPreset,默认 danger-full-access):
|
|
12
|
+
# 收紧到 workspace-write / read-only 后,需要审批的操作会把审批卡推到微信——
|
|
13
|
+
# 回「批准 / 拒绝」(或 1 / 2)即可,120 秒未回按拒绝处理(fail-closed)。
|
|
14
|
+
# 默认留在最宽,是因为 ~/.m2 之类的写操作会被 workspace-write 拦掉(maven 编译直接失败)。
|
|
15
|
+
- id: permission
|
|
16
|
+
config:
|
|
17
|
+
defaultPreset: danger-full-access
|
|
18
|
+
#
|
|
19
|
+
# 备注(2026-09-21 实测,别重复踩):**模型目录不随行配置走**。
|
|
20
|
+
# 本文件曾试在两处放 llm-deepseek 目录(改行 config;停用 base 行 + 换行 id 插副本),
|
|
21
|
+
# 配置树(--dump-config)里都生效了,但 `session/new` 返回的目录**始终不变** ——
|
|
22
|
+
# 运行时的模型目录来自 settings(`llm-deepseek` / `llm-pi-ai` 段)而非行 config。
|
|
23
|
+
# 所以「让手机清单 = 桌面端清单」这件事,只能改 settings,改不成(见 L1 §9.6)。
|
package/lib/kernel/acp.mjs
CHANGED
|
@@ -28,7 +28,11 @@ export class AcpHost {
|
|
|
28
28
|
* @param {string} [o.home] 子进程 DSH_HOME(决定会话写进哪个仓库)
|
|
29
29
|
* @param {string} [o.cwd] 子进程工作目录(默认进程 cwd)
|
|
30
30
|
* @param {string} [o.patch] --patch 叠加层文件(如停用宿主专属插件)
|
|
31
|
-
* @param {
|
|
31
|
+
* @param {'ask'|'allow'|'reject'} [o.permPolicy] 权限策略:
|
|
32
|
+
* 'ask'(默认)把审批请求交给 onPermission 去问人;'allow'/'reject' 自动应答(旧行为)
|
|
33
|
+
* @param {(req:{sessionId:string, toolCall:object, options:Array, seq:number})=>Promise<string|null>} [o.onPermission]
|
|
34
|
+
* 返回要选的 optionId;返回 null/抛错 → 按 permFallback 应答
|
|
35
|
+
* @param {'allow'|'reject'} [o.permFallback] 问不到人时的兜底(默认 reject,fail-closed)
|
|
32
36
|
* @param {number} [o.promptTimeoutMs]
|
|
33
37
|
* @param {(m:string, e?:object)=>void} [o.log]
|
|
34
38
|
*/
|
|
@@ -38,7 +42,10 @@ export class AcpHost {
|
|
|
38
42
|
this.home = o.home || ''
|
|
39
43
|
this.cwd = o.cwd || process.cwd()
|
|
40
44
|
this.patch = o.patch || ''
|
|
41
|
-
this.permPolicy =
|
|
45
|
+
this.permPolicy = ['ask', 'allow', 'reject'].includes(o.permPolicy) ? o.permPolicy : 'ask'
|
|
46
|
+
this.onPermission = typeof o.onPermission === 'function' ? o.onPermission : null
|
|
47
|
+
this.permFallback = o.permFallback === 'allow' ? 'allow' : 'reject'
|
|
48
|
+
this.permSeq = 0
|
|
42
49
|
this.promptTimeoutMs = Number(o.promptTimeoutMs || 300000)
|
|
43
50
|
this.log = o.log || (() => {})
|
|
44
51
|
this.child = null
|
|
@@ -133,11 +140,35 @@ export class AcpHost {
|
|
|
133
140
|
return
|
|
134
141
|
}
|
|
135
142
|
if (f.method === 'session/request_permission') {
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
const params = f.params ?? {}
|
|
144
|
+
const opts = Array.isArray(params.options) ? params.options : []
|
|
145
|
+
const seq = ++this.permSeq
|
|
146
|
+
const pickByPolicy = (policy) => policy === 'reject'
|
|
147
|
+
? (opts.find((o) => /reject/.test(String(o.kind))) || opts.find((o) => /reject/.test(String(o.optionId))))
|
|
148
|
+
: (opts.find((o) => /allow/.test(String(o.kind))) || opts.find((o) => /allow/.test(String(o.optionId))))
|
|
149
|
+
if (this.permPolicy === 'ask' && this.onPermission) {
|
|
150
|
+
// 问人:**不能阻塞读循环**(同一进程还要收 session/update 与别的请求),所以异步应答。
|
|
151
|
+
const ask = this.onPermission
|
|
152
|
+
;(async () => {
|
|
153
|
+
let picked = null
|
|
154
|
+
let how = 'ask'
|
|
155
|
+
try {
|
|
156
|
+
const optionId = await ask({ sessionId: params.sessionId, toolCall: params.toolCall ?? {}, options: opts, seq })
|
|
157
|
+
picked = optionId ? (opts.find((o) => String(o.optionId) === String(optionId)) || { optionId }) : null
|
|
158
|
+
} catch (e) {
|
|
159
|
+
this.log('acp-permission-ask-failed', { seq, error: String(e?.message ?? e) })
|
|
160
|
+
}
|
|
161
|
+
if (!picked) {
|
|
162
|
+
how = 'fallback:' + this.permFallback
|
|
163
|
+
picked = pickByPolicy(this.permFallback === 'allow' ? 'allow' : 'reject')
|
|
164
|
+
}
|
|
165
|
+
this.log('acp-permission', { seq, tool: params.toolCall?.title, policy: 'ask', how, picked: picked?.optionId ?? null })
|
|
166
|
+
this._raw({ jsonrpc: '2.0', id: f.id, result: picked ? { outcome: { outcome: 'selected', optionId: picked.optionId } } : { outcome: { outcome: 'cancelled' } } })
|
|
167
|
+
})()
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
const pick = pickByPolicy(this.permPolicy)
|
|
171
|
+
this.log('acp-permission', { seq, tool: params.toolCall?.title, policy: this.permPolicy, picked: pick?.optionId ?? null })
|
|
141
172
|
this._raw({ jsonrpc: '2.0', id: f.id, result: pick ? { outcome: { outcome: 'selected', optionId: pick.optionId } } : { outcome: { outcome: 'cancelled' } } })
|
|
142
173
|
return
|
|
143
174
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* approval-reply.mjs — 审批卡与「微信里的回复」之间的纯函数层(无 IO,可离线自测)
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一层:审批的解析/选择/文案都必须可离线验证(桥本身的启动路径会连 iLink、拉子进程,
|
|
5
|
+
* 单测没法用)。协议事实:ACP 的 `session/request_permission` 请求**必须应答**,
|
|
6
|
+
* 参数里给的是若干 option(各带 optionId / kind / name),客户端要挑一个回去。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 同意 / 拒绝(对外只有这两个语义,选项到 optionId 的映射由 pickOptionByDecision 决定)。 */
|
|
10
|
+
export const DECISION_ALLOW = 'allow'
|
|
11
|
+
export const DECISION_REJECT = 'reject'
|
|
12
|
+
|
|
13
|
+
// 先判拒绝词:'不同意' 里含 '同意'、'不许' 里含 '许',顺序反了会把拒绝读成同意。
|
|
14
|
+
const REJECT_WORDS = ['拒绝', '不同意', '驳回', '不许', '不行', '不准', 'deny', 'reject', 'no']
|
|
15
|
+
const ALLOW_WORDS = ['批准', '同意', '允许', '准许', '通过', '可以', 'approve', 'allow', 'yes', 'ok']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 解析用户回复:支持「批准 / 拒绝 / 同意 / 不同意 / yes / no」与「#3 批准」「3 拒绝」「2」。
|
|
19
|
+
* @returns {{decision: 'allow'|'reject'|null, index: number|null, keyword: string}}
|
|
20
|
+
*/
|
|
21
|
+
export function parseApprovalReply(raw) {
|
|
22
|
+
const text = String(raw ?? '').trim()
|
|
23
|
+
if (!text) return { decision: null, index: null, keyword: '' }
|
|
24
|
+
const nums = text.match(/\d{1,2}/g) || []
|
|
25
|
+
const index = nums.length ? Number(nums[0]) : null
|
|
26
|
+
const lower = text.toLowerCase()
|
|
27
|
+
const hit = (words) => words.find((w) => lower.includes(w)) || ''
|
|
28
|
+
const rej = hit(REJECT_WORDS)
|
|
29
|
+
const alw = rej ? '' : hit(ALLOW_WORDS)
|
|
30
|
+
let decision = rej ? DECISION_REJECT : (alw ? DECISION_ALLOW : null)
|
|
31
|
+
// 只回数字:1=批准、2=拒绝(与卡片里「1) 允许 2) 拒绝」的编号一致)
|
|
32
|
+
if (!decision && index !== null) decision = index === 1 ? DECISION_ALLOW : (index === 2 ? DECISION_REJECT : null)
|
|
33
|
+
return { decision, index, keyword: rej || alw }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const optionText = (o) => [o?.optionId, o?.kind, o?.name, o?.title].map((v) => String(v ?? '')).join(' ').toLowerCase()
|
|
37
|
+
const looksReject = (o) => /reject|deny|refuse|abort|cancel|no\b/.test(optionText(o))
|
|
38
|
+
|
|
39
|
+
/** 选项的可读标签(卡片里显示编号用)。 */
|
|
40
|
+
export function optionLabel(o, i) {
|
|
41
|
+
const name = String(o?.name || o?.title || '').trim()
|
|
42
|
+
if (name) return name
|
|
43
|
+
const kind = String(o?.kind || '').trim()
|
|
44
|
+
if (kind) return kind
|
|
45
|
+
return '选项 ' + ((i ?? 0) + 1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 决策 → 具体 optionId。挑不到时返回 null(调用方按「取消应答」处理,即 fail-closed)。
|
|
50
|
+
*/
|
|
51
|
+
export function pickOptionByDecision(options, decision) {
|
|
52
|
+
const list = Array.isArray(options) ? options : []
|
|
53
|
+
if (!list.length) return null
|
|
54
|
+
if (decision === DECISION_ALLOW) {
|
|
55
|
+
const allow = list.find((o) => /allow|approve|accept|permit|once|yes/.test(optionText(o)) && !looksReject(o))
|
|
56
|
+
return allow || list.find((o) => !looksReject(o)) || null
|
|
57
|
+
}
|
|
58
|
+
if (decision === DECISION_REJECT) {
|
|
59
|
+
return list.find(looksReject) || null
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 微信侧审批卡(纯文本;微信只能收文本)。 */
|
|
65
|
+
export function formatApprovalCard(o = {}) {
|
|
66
|
+
const n = Number(o.n || 1)
|
|
67
|
+
const tool = String(o.tool || '工具调用').replace(/\s+/g, ' ').slice(0, 180)
|
|
68
|
+
const sec = Math.max(5, Number(o.timeoutSec || 120))
|
|
69
|
+
const options = Array.isArray(o.options) ? o.options : []
|
|
70
|
+
const rows = options.slice(0, 4).map((it, i) => (i + 1) + ') ' + optionLabel(it, i))
|
|
71
|
+
return [
|
|
72
|
+
'【需要你批准】#' + n,
|
|
73
|
+
'操作:' + tool,
|
|
74
|
+
].concat(rows.length ? ['选项:'].concat(rows) : [])
|
|
75
|
+
.concat([
|
|
76
|
+
'回复:批准 / 拒绝(也可以回 1 / 2)',
|
|
77
|
+
sec + ' 秒内没回复=按拒绝处理(需要审批的操作会失败;想让桥不再问,发 /审批 自动)。',
|
|
78
|
+
]).join('\n')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 决策确认语(回执给用户,让他知道刚才那句被当成了什么)。 */
|
|
82
|
+
export function decisionAck(decision, note) {
|
|
83
|
+
if (decision === DECISION_ALLOW) return '✅ 已批准' + (note ? '(' + note + ')' : '') + ',继续执行。'
|
|
84
|
+
if (decision === DECISION_REJECT) return '🚫 已拒绝' + (note ? '(' + note + ')' : '') + ',这一项不会执行。'
|
|
85
|
+
return '(审批应答已作废:' + (note || '无待批事项') + ')'
|
|
86
|
+
}
|