@zmainer/dsh-wx-bridge 1.0.16 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.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": { "preset": "<预设 id,留空跟随 DSH 默认预设>", "enabled": true, "permPolicy": "allow" }
73
+ "acp": {
74
+ "preset": "<预设 id,留空跟随 DSH 默认预设>",
75
+ "enabled": true,
76
+ "permPolicy": "ask",
77
+ "permPreset": "workspace-write",
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` | 权限请求策略:`allow` / `reject` | allow |
97
+ | `acp.permPolicy` | 审批策略:`ask`(默认,推到微信等回复)/ `allow`(自动批准)/ `reject`(一律拒绝) | ask |
98
+ | `acp.permPreset` | 手机通道权限预设(写进 ACP 叠层):`workspace-write`(默认)/ `read-only` / `danger-full-access`。越界的写与提权会在微信里弹审批卡 | workspace-write |
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
- - **权限预设(1.0.7 起为 `danger-full-access`)**:手机端**没有可应答审批的界面**,
120
- 而 DSH 的审批在无应答者时 fail-closed → 任何需要审批的操作都会直接失败。
121
- 因此本插件把**手机通道**的权限预设放到最宽;**这等于把本机交给能驱动该机器人的人**——
122
- 所以准入默认是 `strict`(只有登记过的设备能驱动)。收紧办法:叠层里 `permission.defaultPreset` 改
123
- `workspace-write` 或 `read-only`(后者只读)。
130
+ - **权限预设(`acp.permPreset`,默认 `workspace-write`)**:手机任务默认只能写工作区;**越界的写与提权会变成
131
+ 审批卡推到微信**,回「批准 / 拒绝」即可(1.1.0 起)。这正是默认从 `danger-full-access` 收紧的原因——
132
+ 手机可应答审批后,不必再用"最宽权限"来绕开"无人可答"。要更严:`read-only`;要旧行为(免审批全权,
133
+ 适合 `mvn` 写 `~/.m2` 这类高频越界任务):`danger-full-access`。
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
+ > 什么时候会真的看到审批卡?**当通道权限预设被收紧时**——默认的 `workspace-write` 就意味着
170
+ > "工作区之外的写 / 提权"会先问你;若不想要这种打扰,把 `acp.permPreset` 改成 `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
- | `/help` `/ping` `/approve` `/reject` | 帮助 / 探活 / 审批记录 |
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,22 @@ ode_modules\@zmainer\dsh-wx-bridge` → 改名成 `...dsh-wx-bridge.bak` 更稳
199
266
 
200
267
  ## 最近变更
201
268
 
269
+ - **1.1.1**:**默认权限预设从 `danger-full-access` 收紧为 `workspace-write`**——审批能在微信里答了以后,
270
+ "手机端无人可答审批"就不再是把通道放到最宽的理由。行为变化:工作区之外的写与提权会先在微信里弹审批卡
271
+ (回「批准 / 拒绝」),要旧行为就把 `acp.permPreset` 设回 `danger-full-access`,要更严设 `read-only`。
272
+ 同时把子进程 home 的 `settings.yaml` / headless profile 补丁里写死的 `workspace-write` 接到同一个配置项,
273
+ 并修好「对话模式」生成叠层的 `defaultPreset` 替换(此前只在模板写死旧值时才替换)。
274
+ - **1.1.0**:**微信内审批应答 + 主动推送**(两条都是用户要求)——
275
+ ① **审批**:ACP 的 `session/request_permission` 不再由桥按固定策略静默回答,而是把卡片推到微信
276
+ (选项带编号),回「批准 / 拒绝 / 1 / 2」即映射成 ACP 的 `optionId` 回去;**超时 120 秒按拒绝**
277
+ (fail-closed),找不到归属联系人同样按拒绝;同一人同时只留一张卡。策略 `ask`(新默认)/`allow`/`reject`
278
+ 可用 `/审批` 或 `POST /wxbridge/approval` 切换。因此 `danger-full-access` 不再是"手机端无法应答审批"的
279
+ 唯一出路:把 `acp.permPreset` 收紧到 `workspace-write` 后,越界的操作会先在微信里问你。
280
+ ② **主动推送**:`<dataDir>/wxpush.mjs`(桥每次启动刷新一份)把一条消息写进 `outbox/`,桥每 5 秒取件代发,
281
+ 结果落 `outbox/sent` / `outbox/failed`;收件人只在已登记白名单里选(推送不绕过准入)。
282
+ 「普通对话」档的提示词里写明了这条命令,agent 可自行"干完提醒你";宿主侧另有 `POST /wxbridge/push`。
283
+ ③ 工程:新增纯函数层 `approval-reply.mjs` / `outbox.mjs` + `--selftest-approval` / `--selftest-outbox`
284
+ 两个离线自测(各 13 / 6 条断言),package.json 补 `repository`(npm 与仓库的自动关联需要它)。
202
285
  - **1.0.15**:**准入默认改回 `strict`(与 README 一致)**,并把选择权交给用户——
203
286
  起因是 awesome-dsh-plugin 的收录评审指出的一个真实矛盾:代码默认 `ALLOWLIST=auto`(**任何能给机器人发消息的人
204
287
  首次发言即自动登记**)叠加手机通道的 `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,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
- # 手机通道**不需要审批**:手机端没有可应答审批的界面(ACP 只应答带 callId 的工具审批,
12
- # 沙箱升级类审批会转给桌面端弹窗 → 手机无从应答 → fail-closed 执行失败)。
13
- # 所以这里把权限预设放到最宽。改窄:改成 read-only / workspace-write 即可。
14
- - id: permission
15
- config:
16
- defaultPreset: danger-full-access
17
- - id: skill-filesystem
18
- disabled: true
19
- - id: tool-skill
20
- disabled: true
21
- - id: system-prompt
22
- config:
23
- persona: >-
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,默认 workspace-write):
12
+ # 越界(工作区之外)的写与提权会把审批卡推到微信——回「批准 / 拒绝」(或 1 / 2)即可,
13
+ # 120 秒未回按拒绝处理(fail-closed)。要更严:read-only;要旧行为(免审批全权):danger-full-access。
14
+ - id: permission
15
+ config:
16
+ defaultPreset: workspace-write
17
+ - id: skill-filesystem
18
+ disabled: true
19
+ - id: tool-skill
20
+ disabled: true
21
+ - id: system-prompt
22
+ config:
23
+ persona: >-
24
+ 你是「手机微信通道」里的对话助手,用中文简洁回答。直接用当前会话已有的上下文回答,
25
+ 不要为了"记住"去读写工作区文件或知识库;只有当用户明确要求(例如"记到库里""归档""落盘")
26
+ 时才动文件。
@@ -1,22 +1,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
- # 手机通道**不需要审批**:手机端没有可应答审批的界面(ACP 只应答带 callId 的工具审批,
12
- # 沙箱升级类审批会转给桌面端弹窗 → 手机无从应答 → fail-closed 执行失败)。
13
- # 所以这里把权限预设放到最宽。改窄:改成 read-only / workspace-write 即可。
14
- - id: permission
15
- config:
16
- defaultPreset: danger-full-access
17
- #
18
- # 备注(2026-09-21 实测,别重复踩):**模型目录不随行配置走**。
19
- # 本文件曾试在两处放 llm-deepseek 目录(改行 config;停用 base 行 + 换行 id 插副本),
20
- # 配置树(--dump-config)里都生效了,但 `session/new` 返回的目录**始终不变** ——
21
- # 运行时的模型目录来自 settings(`llm-deepseek` / `llm-pi-ai` 段)而非行 config。
22
- # 所以「让手机清单 = 桌面端清单」这件事,只能改 settings,改不成(见 L1 §9.6)。
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,默认 workspace-write):
12
+ # 越界(工作区之外)的写与提权会把审批卡推到微信——回「批准 / 拒绝」(或 1 / 2)即可,
13
+ # 120 秒未回按拒绝处理(fail-closed)。要更严:read-only;要旧行为(免审批全权):danger-full-access。
14
+ - id: permission
15
+ config:
16
+ defaultPreset: workspace-write
17
+ #
18
+ # 备注(2026-09-21 实测,别重复踩):**模型目录不随行配置走**。
19
+ # 本文件曾试在两处放 llm-deepseek 目录(改行 config;停用 base 行 + 换行 id 插副本),
20
+ # 配置树(--dump-config)里都生效了,但 `session/new` 返回的目录**始终不变** ——
21
+ # 运行时的模型目录来自 settings(`llm-deepseek` / `llm-pi-ai` 段)而非行 config。
22
+ # 所以「让手机清单 = 桌面端清单」这件事,只能改 settings,改不成(见 L1 §9.6)。
@@ -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 {string} [o.permPolicy] 权限策略:'allow'(默认)| 'reject'
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 = o.permPolicy === 'reject' ? 'reject' : 'allow'
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 opts = f.params?.options ?? []
137
- const pick = this.permPolicy === 'allow'
138
- ? opts.find((o) => /allow/.test(String(o.kind))) || opts.find((o) => /allow/.test(String(o.optionId)))
139
- : opts.find((o) => /reject/.test(String(o.kind))) || opts.find((o) => /reject/.test(String(o.optionId)))
140
- this.log('acp-permission', { tool: f.params?.toolCall?.title, policy: this.permPolicy, picked: pick?.optionId ?? null })
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
+ }