@zmainer/dsh-wx-bridge 1.0.14 → 1.0.16
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 +50 -4
- package/lib/client.js +41 -0
- package/lib/index.js +22 -1
- package/lib/kernel/acp-preset-shim.mjs +33 -3
- package/lib/kernel/bridge.mjs +63 -15
- package/package.json +1 -1
- package/src/client.js +41 -0
package/README.md
CHANGED
|
@@ -78,6 +78,7 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
78
78
|
| `dataDir` | 桥的状态/日志/工作目录 | `$DSH_HOME/wxbridge` |
|
|
79
79
|
| `cwd` | 微信任务的默认工作目录 | 宿主启动目录 |
|
|
80
80
|
| `vault` | Obsidian 知识库绝对路径(可选,供提示词模板占位符使用) | 空 |
|
|
81
|
+
| `allowlist` | 准入模式:`strict`(默认)/ `auto` / `open` —— 面板「准入模式」亦可切换 | `strict` |
|
|
81
82
|
| `dshBin` | DSH 运行时入口(`<安装目录>/resources/dsh-runtime/lib/bin.js`)。**留空=五级自动探测**:显式参数/环境变量 → 宿主自证快照 → PATH/npm 全局 → 桌面安装目录扫描 → 运行中进程嗅探 | 自动 |
|
|
82
83
|
| `intervalMs` / `staleMs` | 自检间隔 / 心跳判新阈值 | 300000 |
|
|
83
84
|
| `prompt` | 手机任务的**固定前置提示词**(兜底路径用;留空 = 不加任何前缀) | 空 |
|
|
@@ -96,7 +97,7 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
96
97
|
|
|
97
98
|
1. 宿主启动后,桥会在 `dataDir/auth-token.txt` 生成一次性登记 token(ACL 限本人)。
|
|
98
99
|
2. 在手机微信里把该 token 发给机器人(例如 `<token> /help`)完成登记;之后该设备免 token。
|
|
99
|
-
3.
|
|
100
|
+
3. 未登记的发送者**不会被执行**:桥回一次提示(告诉对方怎么登记、或提示你去面板切 `auto`)并写审计日志。
|
|
100
101
|
4. 面板「扫码配对」可直接出二维码(需本机 `qrcode` 可用;扫码会**重新绑定** ClawBot)。
|
|
101
102
|
|
|
102
103
|
## HTTP 接口(宿主半)
|
|
@@ -108,6 +109,7 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
108
109
|
| GET | `/wxbridge/log` | 宿主半操作日志(最近 60 条) |
|
|
109
110
|
| GET | `/wxbridge/presets` | **预设名册**(id/name/description)+ 当前选中值 |
|
|
110
111
|
| POST | `/wxbridge/preset` | 写入预设选择(`{ "preset": "<id|空>" }`) |
|
|
112
|
+
| POST | `/wxbridge/allowlist` | 写入准入模式(`{ "mode": "strict|auto|open", "restart": true }`) |
|
|
111
113
|
| POST | `/wxbridge/start` · `/stop` · `/restart` · `/tick` | 桥生命周期(`tick` = 立即体检并按需自愈) |
|
|
112
114
|
| POST | `/wxbridge/config` | 写配置文件(`dataDir`/`cwd`/`vault`/`intervalMs`/`staleMs`/`prompt`) |
|
|
113
115
|
| POST | `/wxbridge/task` · `/scan` · `/attach` | 宿主内执行 / 会话归组 |
|
|
@@ -116,10 +118,12 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
116
118
|
|
|
117
119
|
- **权限预设(1.0.7 起为 `danger-full-access`)**:手机端**没有可应答审批的界面**,
|
|
118
120
|
而 DSH 的审批在无应答者时 fail-closed → 任何需要审批的操作都会直接失败。
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
因此本插件把**手机通道**的权限预设放到最宽;**这等于把本机交给能驱动该机器人的人**——
|
|
122
|
+
所以准入默认是 `strict`(只有登记过的设备能驱动)。收紧办法:叠层里 `permission.defaultPreset` 改
|
|
121
123
|
`workspace-write` 或 `read-only`(后者只读)。
|
|
122
|
-
-
|
|
124
|
+
- **准入默认 `strict`**(TOFU 白名单 + 一次性登记 token):未登记的发送者**不执行**,但只回一次可操作提示
|
|
125
|
+
(10 分钟/人节流;提示里给出登记方式),并写审计日志。面板「准入模式」可切 `auto`(首次发言即登记)或
|
|
126
|
+
`open`(不检查,不建议)——`auto` 的边界是「谁拿到了这个机器人」,机器人被拉进群/被加好友会连带放权。
|
|
123
127
|
- 子进程使用**专用 `DSH_HOME`**(`dataDir/dsh-home`),权限 `workspace-write`,**只拿模型密钥、不含微信 token**。
|
|
124
128
|
- 状态文件原子写 + SHA256 校验,校验失败即隔离为 `.tampered-*`。
|
|
125
129
|
- 输出审计:密钥形态脱敏、外发命令阻断、超大输出截断。
|
|
@@ -160,8 +164,50 @@ dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge
|
|
|
160
164
|
|
|
161
165
|
其余任意文本 → 交给 DSH 执行。
|
|
162
166
|
|
|
167
|
+
## 常见问题 / Troubleshooting
|
|
168
|
+
|
|
169
|
+
### 插件管理里点「更新」报 `Cannot switch a non-link plugin directory`
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
更新失败: dsh-wx-bridge — 更新失败,且更新前的构建未能验证恢复
|
|
173
|
+
(Cannot switch a non-link plugin directory: <...>\profiles\web
|
|
174
|
+
ode_modules\@zmainer\dsh-wx-bridge)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**含义**:桌面端的插件更新是**事务式**的——它把 `<profile>/node_modules/<包名>` 当作一个**链接**,
|
|
178
|
+
更新时把这个链接从旧版本"切"到新版本;而该位置现在是**普通目录**(文件是被复制进去的,不是链接),
|
|
179
|
+
无法切换 ⇒ 更新失败并回滚,回滚时又验证不了"更新前的构建已恢复",于是提示检查该 profile 再重启。
|
|
180
|
+
|
|
181
|
+
**原因**:该 profile 里的本插件**不是通过市场/CLI 的链接式安装装上去的**(手工复制、旧版本管理器、
|
|
182
|
+
或中途失败留下的目录都会这样)。"更新"走链接式管理,两边形态不一致就会撞上这个错。
|
|
183
|
+
|
|
184
|
+
**修复(不影响任何配置与数据)**:
|
|
185
|
+
|
|
186
|
+
1. **关闭 DSH 桌面端**(避免文件占用);
|
|
187
|
+
2. 删除或改名这个目录(注意真实路径里有 `\@zmainer\`,报错信息里可能少个反斜杠):
|
|
188
|
+
`<harness home>\profiles\<profile>
|
|
189
|
+
ode_modules\@zmainer\dsh-wx-bridge` → 改名成 `...dsh-wx-bridge.bak` 更稳妥;
|
|
190
|
+
3. **重启桌面端**,在插件管理里**重新安装**(不是"更新");装好后可删掉 `.bak`;
|
|
191
|
+
4. 想用命令行装:`dsh plugin --profile <profile> add @zmainer/dsh-wx-bridge@latest`(pnpm 会建立链接式安装),
|
|
192
|
+
然后重启宿主;
|
|
193
|
+
5. 配置与数据**不在 profile 里**,所以重装/换安装方式都不会丢:
|
|
194
|
+
配置在 `<DSH_HOME>/wxbridge/config.json`、数据在配置里的 `dataDir`;插件的 id、路由前缀、
|
|
195
|
+
配置目录名都没变过。
|
|
196
|
+
|
|
197
|
+
**避免复发**:在一个 profile 上**只用一个安装通道**(要么一直用市场,要么一直用 CLI),
|
|
198
|
+
不要手工复制文件进 `node_modules`,也不要在两个通道之间来回切。
|
|
199
|
+
|
|
163
200
|
## 最近变更
|
|
164
201
|
|
|
202
|
+
- **1.0.15**:**准入默认改回 `strict`(与 README 一致)**,并把选择权交给用户——
|
|
203
|
+
起因是 awesome-dsh-plugin 的收录评审指出的一个真实矛盾:代码默认 `ALLOWLIST=auto`(**任何能给机器人发消息的人
|
|
204
|
+
首次发言即自动登记**)叠加手机通道的 `danger-full-access`(**不经审批**),等价于"任何能给它发消息的人
|
|
205
|
+
都能免审批完全控制这台机器";而 README「安全基线」写的是"未登记静默丢弃"(那是 `strict` 的行为)。
|
|
206
|
+
现在:① 默认 `strict`(`--allowlist` / `WXBRIDGE_ALLOWLIST` / `config.json` 的 `allowlist` 均可覆盖);
|
|
207
|
+
② 未登记**不执行但不再静默**——回一次可操作提示(每人 10 分钟一次),告诉对方怎么登记;
|
|
208
|
+
③ 面板新增「**准入模式**」开关(`strict`/`auto`/`open`,一键写入配置并重启桥),换号/多设备的用户
|
|
209
|
+
自己决定放开;④ README 的「安全基线」「配对」与配置表同步对齐。
|
|
210
|
+
权限预设(`danger-full-access`)与准入是两件事:前者是本通道"无法应答审批"的取舍,后者决定**谁能**驱动它。
|
|
165
211
|
- **1.0.14**:**文件/图片消息:先收下 → 反向提问 → 第二句话触发**(用户设计拍板)——
|
|
166
212
|
微信的文件消息带不了附言,所以插件把文件**取件**(CDN 下载 + AES-128-ECB 解密 + **按原名**落盘)后
|
|
167
213
|
先反问"你想让我做什么",等你下一句话再连同附件清单交给会话。
|
package/lib/client.js
CHANGED
|
@@ -121,6 +121,7 @@ window.__ModuleLoader__.load({
|
|
|
121
121
|
const skin = controlSkin()
|
|
122
122
|
const [presets, setPresets] = R.useState([])
|
|
123
123
|
const [presetSel, setPresetSel] = R.useState('')
|
|
124
|
+
const [allowlist, setAllowlist] = R.useState('strict')
|
|
124
125
|
const [presetState, setPresetState] = R.useState(null)
|
|
125
126
|
useWxStyle()
|
|
126
127
|
|
|
@@ -133,6 +134,7 @@ window.__ModuleLoader__.load({
|
|
|
133
134
|
fetch('/wxbridge/log', { cache: 'no-store' }),
|
|
134
135
|
])
|
|
135
136
|
const d = await sr.json()
|
|
137
|
+
if (d && typeof d.allowlist === 'string') setAllowlist(d.allowlist)
|
|
136
138
|
const lg = await lr.json()
|
|
137
139
|
if (!alive) return
|
|
138
140
|
setJ(d); setErr(''); setLog(lg.actions || [])
|
|
@@ -159,6 +161,21 @@ window.__ModuleLoader__.load({
|
|
|
159
161
|
|
|
160
162
|
R.useEffect(() => { let alive = true; loadPresets().then(() => {}); return () => { alive = false } }, [])
|
|
161
163
|
|
|
164
|
+
const saveAllowlist = async (mode) => {
|
|
165
|
+
setBusy('allowlist')
|
|
166
|
+
try {
|
|
167
|
+
const r = await fetch('/wxbridge/allowlist', {
|
|
168
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({ mode, restart: true }),
|
|
170
|
+
})
|
|
171
|
+
const d = await r.json()
|
|
172
|
+
if (typeof d.allowlist === 'string') setAllowlist(d.allowlist)
|
|
173
|
+
setNote(d.ok
|
|
174
|
+
? ('准入模式已切到 ' + mode + ':' + (d.note || ''))
|
|
175
|
+
: ('切换失败:' + (d.error || '未知错误')))
|
|
176
|
+
} catch (e) { setNote('切换失败:' + String(e && e.message ? e.message : e)) } finally { setBusy('') }
|
|
177
|
+
}
|
|
178
|
+
|
|
162
179
|
const savePreset = async (want) => {
|
|
163
180
|
setBusy('preset')
|
|
164
181
|
try {
|
|
@@ -410,6 +427,30 @@ window.__ModuleLoader__.load({
|
|
|
410
427
|
)
|
|
411
428
|
),
|
|
412
429
|
|
|
430
|
+
R.createElement('div', { style: { marginTop: '14px', paddingTop: '12px', borderTop: subtle } },
|
|
431
|
+
R.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } },
|
|
432
|
+
wxBubbles(16, WX, skin.dark ? '#0d1220' : '#ffffff', 'transparent'),
|
|
433
|
+
R.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '准入模式'),
|
|
434
|
+
R.createElement('span', { style: { fontSize: '11px', color: dim.color } }, '(决定谁能驱动这台机器)')),
|
|
435
|
+
R.createElement('select', {
|
|
436
|
+
value: allowlist, onChange: (e) => setAllowlist(e.target.value),
|
|
437
|
+
style: { ...skin.control, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: '8px', fontSize: '12px' },
|
|
438
|
+
}, [
|
|
439
|
+
R.createElement('option', { key: 'strict', value: 'strict', style: skin.option }, 'strict(默认)· 只服务已登记的设备;未登记不执行,只回一次提示'),
|
|
440
|
+
R.createElement('option', { key: 'auto', value: 'auto', style: skin.option }, 'auto · 任何能给机器人发消息的人首次发言即登记(机器人别被拉进群/加好友)'),
|
|
441
|
+
R.createElement('option', { key: 'open', value: 'open', style: skin.option }, 'open · 不做任何检查(强烈不建议)'),
|
|
442
|
+
]),
|
|
443
|
+
R.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center' } },
|
|
444
|
+
R.createElement('button', {
|
|
445
|
+
className: 'wxbridge-btn', onClick: () => saveAllowlist(allowlist), disabled: !!busy,
|
|
446
|
+
style: {
|
|
447
|
+
marginRight: '10px', padding: '6px 14px', borderRadius: '999px', fontSize: '12px', fontWeight: 500,
|
|
448
|
+
cursor: busy ? 'default' : 'pointer', border: '1px solid ' + WX, background: WX, color: '#fff',
|
|
449
|
+
},
|
|
450
|
+
}, busy === 'allowlist' ? '处理中…' : '保存并重启桥'),
|
|
451
|
+
R.createElement('span', { style: dim }, '当前:' + allowlist + '(改完会写入 config.json 并重启桥,立即生效)')),
|
|
452
|
+
),
|
|
453
|
+
|
|
413
454
|
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,
|
|
414
455
|
|
|
415
456
|
// ── 操作日志 ───────────────────────────────────────────────────
|
package/lib/index.js
CHANGED
|
@@ -313,6 +313,7 @@ export function apply(ctx, config) {
|
|
|
313
313
|
peers, tasks, allowedUsers: allowed, stateOk, manualStop,
|
|
314
314
|
dataDir: st.dataDir, cwd: st.cwd, intervalMs: st.intervalMs, staleMs: st.staleMs,
|
|
315
315
|
runtimeBin: rtBin, runtimeBinOk: !!rtBin && existsSync(rtBin), runtimeSource: runtimeInfo.source,
|
|
316
|
+
allowlist: String((() => { try { return readConfigFile().allowlist || 'strict' } catch { return 'strict' } })()),
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
319
|
|
|
@@ -693,12 +694,32 @@ export function apply(ctx, config) {
|
|
|
693
694
|
const body = await readBody(req)
|
|
694
695
|
const f = configPath()
|
|
695
696
|
const keep = readJsonFile(f)
|
|
696
|
-
for (const k of ['dataDir', 'cwd', 'vault', 'dshBin', 'intervalMs', 'staleMs']) if (body && body[k]) keep[k] = body[k]
|
|
697
|
+
for (const k of ['dataDir', 'cwd', 'vault', 'dshBin', 'intervalMs', 'staleMs', 'allowlist']) if (body && body[k]) keep[k] = body[k]
|
|
697
698
|
if (body && typeof body.prompt === 'string') keep.prompt = body.prompt
|
|
698
699
|
mkdirSync(dirname(f), { recursive: true })
|
|
699
700
|
writeFileSync(f, JSON.stringify(keep, null, 2))
|
|
700
701
|
return send(res, { ok: true, written: f, applied: keep, note: '提示词下次任务即生效;其余项重启宿主后生效' })
|
|
701
702
|
}
|
|
703
|
+
if (p === '/allowlist' && req.method === 'POST') {
|
|
704
|
+
// 准入模式:strict(默认,只认已登记)/ auto(首次发言即登记)/ open(不检查)
|
|
705
|
+
const body = await readBody(req)
|
|
706
|
+
const mode = String((body && body.mode) || '').toLowerCase()
|
|
707
|
+
if (!['strict', 'auto', 'open'].includes(mode)) {
|
|
708
|
+
return send(res, { ok: false, error: 'mode must be strict | auto | open' }, 400)
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
const f = configPath()
|
|
712
|
+
const keep = readJsonFile(f)
|
|
713
|
+
keep.allowlist = mode
|
|
714
|
+
mkdirSync(dirname(f), { recursive: true })
|
|
715
|
+
writeFileSync(f, JSON.stringify(keep, null, 2))
|
|
716
|
+
pushAction({ action: 'allowlist', ok: true, detail: mode })
|
|
717
|
+
let restarted = 0
|
|
718
|
+
if (body && body.restart && keeper) { try { restarted = keeper.startBridge('allowlist-change') || 0 } catch (e) { ctx.logger?.warn?.('wxbridge: 重启失败 ' + String(e?.message ?? e)) } }
|
|
719
|
+
return send(res, { ok: true, allowlist: mode, configFile: f, restarted,
|
|
720
|
+
note: restarted ? '已写入配置并重启桥,立即生效' : '已写入配置;重启桥后生效(面板「重启」或等下次宿主启动)' })
|
|
721
|
+
} catch (e) { return send(res, { ok: false, error: String(e?.message ?? e) }, 500) }
|
|
722
|
+
}
|
|
702
723
|
if (p === '/prompt') {
|
|
703
724
|
if (req.method === 'POST') {
|
|
704
725
|
const body = await readBody(req)
|
|
@@ -100,9 +100,35 @@ const EDITS = [
|
|
|
100
100
|
]
|
|
101
101
|
|
|
102
102
|
/** 由 dsh 入口(<runtime>/lib/bin.js)推出已装 dsh-acp 的文件路径。 */
|
|
103
|
+
/**
|
|
104
|
+
* dsh-acp 的候选路径(2026-09-24:只认旧布局导致预设静默失效)。
|
|
105
|
+
* 三种真实布局:
|
|
106
|
+
* A. 旧桌面/CLI:`<root>/lib/bin.js` + `<root>/node_modules/@deepseek-ai/dsh-acp`
|
|
107
|
+
* B. **新版桌面(hoisted)**:`<app>/node_modules/@deepseek-ai/dsh/lib/bin.js`
|
|
108
|
+
* + `<app>/node_modules/@deepseek-ai/dsh-acp` ← 与 dsh 在同一个 scope 目录下(兄弟)
|
|
109
|
+
* C. 单包自带:`<pkg>/node_modules/@deepseek-ai/dsh-acp`
|
|
110
|
+
* @param {string} runtimeBin dsh 入口(bin.js 绝对路径)
|
|
111
|
+
* @returns {string[]} 候选(按可能性排序;第一个存在的即命中)
|
|
112
|
+
*/
|
|
113
|
+
export function acpPackageCandidates(runtimeBin) {
|
|
114
|
+
if (!runtimeBin) return []
|
|
115
|
+
const lib = dirname(runtimeBin) // .../lib
|
|
116
|
+
const pkg = resolve(lib, '..') // .../dsh(包根)或运行时根
|
|
117
|
+
const parent = resolve(pkg, '..') // .../@deepseek-ai(hoisted scope)或 node_modules
|
|
118
|
+
const nm = resolve(parent, '..') // .../node_modules
|
|
119
|
+
return [
|
|
120
|
+
resolve(pkg, 'node_modules', '@deepseek-ai', 'dsh-acp', 'lib', 'index.js'), // C
|
|
121
|
+
resolve(parent, 'dsh-acp', 'lib', 'index.js'), // B(新版桌面)
|
|
122
|
+
resolve(parent, '@deepseek-ai', 'dsh-acp', 'lib', 'index.js'),
|
|
123
|
+
resolve(nm, '@deepseek-ai', 'dsh-acp', 'lib', 'index.js'), // A
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 命中第一个存在的候选;都不存在时返回首选路径(调用方用 acpPackageCandidates 出诊断)。 */
|
|
103
128
|
export function acpPackageFile(runtimeBin) {
|
|
104
|
-
|
|
105
|
-
|
|
129
|
+
const cands = acpPackageCandidates(runtimeBin)
|
|
130
|
+
for (const c of cands) { try { if (existsSync(c)) return c } catch {} }
|
|
131
|
+
return cands[0] || ''
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
/** 该文件是否已经支持预设(原生支持或已打过补丁)。 */
|
|
@@ -121,7 +147,11 @@ export function acpPresetSupported(file) {
|
|
|
121
147
|
export function ensureAcpPresetSupport(o) {
|
|
122
148
|
const log = o.log || (() => {})
|
|
123
149
|
const file = acpPackageFile(o.runtimeBin)
|
|
124
|
-
if (!file || !existsSync(file))
|
|
150
|
+
if (!file || !existsSync(file)) {
|
|
151
|
+
// 把候选清单一起带出去:区分「真的没装 dsh-acp」与「路径没找对」(2026-09-24 的坑)
|
|
152
|
+
return { status: 'missing', file, candidates: acpPackageCandidates(o.runtimeBin),
|
|
153
|
+
detail: '未找到已装的 dsh-acp;已试路径见 candidates(新版桌面常见于 <app>/node_modules/@deepseek-ai/dsh-acp)' }
|
|
154
|
+
}
|
|
125
155
|
|
|
126
156
|
let src
|
|
127
157
|
try { src = readFileSync(file, 'utf8') } catch (e) { return { status: 'error', file, detail: String(e?.message ?? e) } }
|
package/lib/kernel/bridge.mjs
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* 运行时入口 dshBin(5 级解析,见 resolveDshBin):--dsh-bin/BRIDGE_DSH_BIN/DSH_BIN →
|
|
12
12
|
* <dataDir>/host-runtime.json(宿主自证)→ PATH/npm 全局 → 桌面安装目录 → 运行中进程嗅探
|
|
13
13
|
*/
|
|
14
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, renameSync, statSync, readdirSync } from 'node:fs'
|
|
14
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, renameSync, statSync, readdirSync, copyFileSync } from 'node:fs'
|
|
15
15
|
import { spawn, execFileSync } from 'node:child_process'
|
|
16
16
|
import { AcpHost } from './acp.mjs'
|
|
17
|
-
import { ensureAcpPresetSupport, acpPresetSupported, acpPackageFile } from './acp-preset-shim.mjs'
|
|
17
|
+
import { ensureAcpPresetSupport, acpPresetSupported, acpPackageFile, acpPackageCandidates } from './acp-preset-shim.mjs'
|
|
18
18
|
import { randomUUID, randomBytes, createHash, createDecipheriv } from 'node:crypto'
|
|
19
19
|
import { zstdDecompressSync } from 'node:zlib'
|
|
20
20
|
import { homedir } from 'node:os'
|
|
@@ -190,15 +190,6 @@ const HEARTBEAT_MS = 30000
|
|
|
190
190
|
const TASK_TIMEOUT_MS = Number(process.env.BRIDGE_TASK_TIMEOUT_MS || 300000)
|
|
191
191
|
const MAX_CONCURRENT_TASKS = Number(process.env.BRIDGE_MAX_TASKS || 2)
|
|
192
192
|
const DEDUP_WINDOW_MS = 24 * 3600 * 1000
|
|
193
|
-
/**
|
|
194
|
-
* 授权策略:
|
|
195
|
-
* auto (默认)—— 任何给本 bot 发消息的联系人自动登记 + 记审计日志。
|
|
196
|
-
* 准入边界前移到「扫码拿到 bot 控制权」那一步:能扫码 = 本来就有权。
|
|
197
|
-
* 换号/多台手机都无需干预(2026-09-21 用户反馈后定为默认)。
|
|
198
|
-
* strict —— 只认白名单;未登记的一律丢弃(旧行为)。
|
|
199
|
-
* open —— 不做任何检查(不建议:任何能找到该 bot 的号都能驱动本机)。
|
|
200
|
-
*/
|
|
201
|
-
const ALLOWLIST = (argOf('--allowlist') || process.env.WXBRIDGE_ALLOWLIST || 'auto').toLowerCase()
|
|
202
193
|
/**
|
|
203
194
|
* 执行模式:
|
|
204
195
|
* host (默认,若宿主可达)—— 任务交给宿主进程执行:原生会话 + 原生上下文,
|
|
@@ -218,6 +209,18 @@ function loadBridgeConfig() {
|
|
|
218
209
|
}
|
|
219
210
|
const BRIDGE_CONFIG = loadBridgeConfig()
|
|
220
211
|
const ACP_CFG = (BRIDGE_CONFIG && typeof BRIDGE_CONFIG.acp === 'object') ? BRIDGE_CONFIG.acp : {}
|
|
212
|
+
/**
|
|
213
|
+
* 授权策略(2026-09-23 起默认 `strict`,与 README 的「安全基线」一致):
|
|
214
|
+
* strict (默认)—— 只认已登记的发送者;**未登记不执行**,但只回一次可操作提示
|
|
215
|
+
* (10 分钟/人节流),不静默——否则"换号/换手机"的用户只会看到"发了没反应"。
|
|
216
|
+
* auto —— 任何能给本 bot 发消息的联系人首次发言即自动登记。
|
|
217
|
+
* 准入边界前移到「谁拿到了这个机器人」:**机器人被拉进群/被加好友时会连带放权**,
|
|
218
|
+
* 所以它不再是默认,只在你确认机器人只有自己用、或想省掉换号登记时,在面板里显式切过去。
|
|
219
|
+
* open —— 不做任何检查(不建议:任何能找到该 bot 的号都能驱动本机)。
|
|
220
|
+
* 可用参数/环境变量/配置覆盖:--allowlist / WXBRIDGE_ALLOWLIST / config.json 的 allowlist。
|
|
221
|
+
*/
|
|
222
|
+
const ALLOWLIST = (argOf('--allowlist') || process.env.WXBRIDGE_ALLOWLIST || BRIDGE_CONFIG.allowlist || 'strict').toLowerCase()
|
|
223
|
+
|
|
221
224
|
/**
|
|
222
225
|
* ACP 路径(首选执行方式):桥起一个常驻 `dsh --profile acp`,用 Agent Client Protocol
|
|
223
226
|
* 驱动 **DSH 原生会话** —— 上下文由 DSH 持久化持有(可 resume),桥不再把历史拼进提示词。
|
|
@@ -299,11 +302,25 @@ function loadPromptText() {
|
|
|
299
302
|
const cfg = JSON.parse(readFileSync(cfgFile, 'utf8'))
|
|
300
303
|
if (typeof cfg.prompt === 'string') return cfg.prompt.trim()
|
|
301
304
|
} catch {}
|
|
305
|
+
// 契约文件不存在时:从包内模板复制一份到数据目录(面板/手改都行)并**出声**。
|
|
306
|
+
// 静默缺席的后果是「通道没有任何固定身份」,而现象看起来跟"预设没生效"一模一样(2026-09-24 实况)。
|
|
307
|
+
if (!existsSync(CONTRACT_FILE)) {
|
|
308
|
+
try {
|
|
309
|
+
const tpl = join(HERE, 'second-brain-contract.txt')
|
|
310
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
311
|
+
if (existsSync(tpl)) {
|
|
312
|
+
copyFileSync(tpl, CONTRACT_FILE)
|
|
313
|
+
log('contract-template-copied', { to: CONTRACT_FILE })
|
|
314
|
+
}
|
|
315
|
+
} catch (e) { log('contract-template-copy-failed', { error: String(e?.message ?? e) }) }
|
|
316
|
+
log('contract-missing', { file: CONTRACT_FILE,
|
|
317
|
+
note: '通道没有固定身份契约;如需「先检索 vault / 落盘铁律」这类前缀,请编辑该文件或在配置里给 vault' })
|
|
318
|
+
}
|
|
302
319
|
try {
|
|
303
320
|
return readFileSync(CONTRACT_FILE, 'utf8').trim()
|
|
304
321
|
.replace(/\{\{vault\}\}/g, VAULT_PATH)
|
|
305
322
|
.replace(/\{\{cwd\}\}/g, CWD)
|
|
306
|
-
.replace(/\{\{model\}\}/g, process.env.BRIDGE_MODEL_LABEL || 'DeepSeek')
|
|
323
|
+
.replace(/\{\{model\}\}/g, process.env.BRIDGE_MODEL_VAL || process.env.BRIDGE_MODEL_LABEL || 'DeepSeek')
|
|
307
324
|
} catch { return '' }
|
|
308
325
|
}
|
|
309
326
|
let CONTRACT = loadPromptText()
|
|
@@ -659,6 +676,8 @@ function attachmentBlock(atts) {
|
|
|
659
676
|
}
|
|
660
677
|
|
|
661
678
|
const NOTEXT_NOTICE_AT = new Map()
|
|
679
|
+
/** 未登记提示的节流(每人 10 分钟一次)。 */
|
|
680
|
+
const UNREG_NOTICE_AT = new Map()
|
|
662
681
|
const NOTEXT_NOTICE = '这条消息里没有我能处理的内容(表情、链接卡片、位置这类条目暂时不支持)。'
|
|
663
682
|
+ NL + '发文字、或者直接发图片 / 文件(Excel、Word、PDF 都行)都可以。'
|
|
664
683
|
/** 该不该给他回「只认文字」:同一个人 60 秒内只回一次。返回要发的文本,'' = 这次不回。 */
|
|
@@ -1022,8 +1041,12 @@ async function reportSessionToHost(sessionId, cwd, isRetry) {
|
|
|
1022
1041
|
})
|
|
1023
1042
|
const j = await r.json().catch(() => null)
|
|
1024
1043
|
log('attach-report', { session: sessionId, ok: !!(j && j.ok), workspace: (j && j.workspacePath) || null, error: (j && j.error) || null })
|
|
1025
|
-
// 会话文件刚落盘时宿主可能还读不到 →
|
|
1026
|
-
|
|
1044
|
+
// 会话文件刚落盘时宿主可能还读不到 → **只**补一次(幂等;再不行还有宿主侧 10 分钟的兜底扫描)。
|
|
1045
|
+
// 2026-09-24 修:原来补试路径也会再排一次补试,失败即每 4 秒无限重试、把日志刷爆。
|
|
1046
|
+
if (!(j && j.ok)) {
|
|
1047
|
+
if (!isRetry) setTimeout(() => { void reportSessionToHost(sessionId, cwd, true) }, 4000)
|
|
1048
|
+
else log('attach-report-give-up', { session: sessionId, error: (j && j.error) || null, note: '补试一次仍失败,交给宿主侧周期扫描' })
|
|
1049
|
+
}
|
|
1027
1050
|
} catch (e) { log('attach-report-failed', { session: sessionId, error: String(e?.message ?? e) }) }
|
|
1028
1051
|
}
|
|
1029
1052
|
|
|
@@ -1371,8 +1394,18 @@ async function handleMessage(token, msg) {
|
|
|
1371
1394
|
isAllowed = true
|
|
1372
1395
|
try { await reply(token, from, '✅ 已登记本机授权(多台手机/换号都无需再设置)。发 /help 看指令。', ctx) } catch {}
|
|
1373
1396
|
} else {
|
|
1374
|
-
// strict
|
|
1397
|
+
// strict(默认):未登记**不执行**,但别静默——回一次可操作的提示(每人 10 分钟一次)。
|
|
1398
|
+
// 静默丢弃对"换手机/换号"的本人来说等于"坏了",而提示对陌生人也无害。
|
|
1375
1399
|
log('rejected-unauthenticated', { from, fromShort: from.slice(0, 12) })
|
|
1400
|
+
if (Date.now() - (UNREG_NOTICE_AT.get(from) || 0) > 600000) {
|
|
1401
|
+
UNREG_NOTICE_AT.set(from, Date.now())
|
|
1402
|
+
try {
|
|
1403
|
+
await reply(token, from, '未登记:这条通道默认只服务已登记的设备,所以刚才那条我没有执行。' + NL
|
|
1404
|
+
+ '登记方式:把设置页「微信连接」里显示的**登记 token** 发我一次(形如 `abc123… /help`)。' + NL
|
|
1405
|
+
+ '(也可以在面板里把「准入模式」切成 auto —— 那样任何能给机器人发消息的人首次发言即登记,'
|
|
1406
|
+
+ '请先确认这个机器人没有被拉进群、也没被别人加好友。)', ctx)
|
|
1407
|
+
} catch (e) { log('send-failed', { error: String(e?.message ?? e) }) }
|
|
1408
|
+
}
|
|
1376
1409
|
return
|
|
1377
1410
|
}
|
|
1378
1411
|
}
|
|
@@ -1641,6 +1674,19 @@ async function main() {
|
|
|
1641
1674
|
}, null, 1))
|
|
1642
1675
|
process.exit(0)
|
|
1643
1676
|
}
|
|
1677
|
+
if (args.includes('--selftest-shim')) {
|
|
1678
|
+
const bin = resolveAcpBin()
|
|
1679
|
+
const cands = acpPackageCandidates(bin)
|
|
1680
|
+
const r = ensureAcpPresetSupport({ runtimeBin: bin, log, dryRun: true })
|
|
1681
|
+
console.log('[selftest-shim] ' + JSON.stringify({
|
|
1682
|
+
acpBin: bin,
|
|
1683
|
+
candidates: cands.map((c) => ({ path: c, exists: existsSync(c) })),
|
|
1684
|
+
status: r.status, file: r.file || null, detail: r.detail || null, backup: r.backup || null,
|
|
1685
|
+
supportedNow: r.file ? acpPresetSupported(r.file) : false,
|
|
1686
|
+
contractFile: CONTRACT_FILE, contractExists: existsSync(CONTRACT_FILE), contractHash: CONTRACT_HASH,
|
|
1687
|
+
}, null, 1))
|
|
1688
|
+
process.exit(0)
|
|
1689
|
+
}
|
|
1644
1690
|
if (args.includes('--selftest-files')) {
|
|
1645
1691
|
const K = 'u-selftest'
|
|
1646
1692
|
state.pendingFiles = {}
|
|
@@ -1726,6 +1772,8 @@ async function main() {
|
|
|
1726
1772
|
voice: { text: voiceTranscript(voiceMsg), types: inboundItemTypes(voiceMsg) },
|
|
1727
1773
|
cooldown: { at0: !!first, at30s: !!second, at61s: !!third },
|
|
1728
1774
|
noticeText: NOTEXT_NOTICE,
|
|
1775
|
+
allowlist: ALLOWLIST,
|
|
1776
|
+
allowlistSource: argOf('--allowlist') ? 'arg' : (process.env.WXBRIDGE_ALLOWLIST ? 'env' : (BRIDGE_CONFIG.allowlist ? 'config' : 'default')),
|
|
1729
1777
|
}, null, 1))
|
|
1730
1778
|
process.exit(0)
|
|
1731
1779
|
}
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -115,6 +115,7 @@ function Panel() {
|
|
|
115
115
|
const skin = controlSkin()
|
|
116
116
|
const [presets, setPresets] = R.useState([])
|
|
117
117
|
const [presetSel, setPresetSel] = R.useState('')
|
|
118
|
+
const [allowlist, setAllowlist] = R.useState('strict')
|
|
118
119
|
const [presetState, setPresetState] = R.useState(null)
|
|
119
120
|
useWxStyle()
|
|
120
121
|
|
|
@@ -127,6 +128,7 @@ function Panel() {
|
|
|
127
128
|
fetch('/wxbridge/log', { cache: 'no-store' }),
|
|
128
129
|
])
|
|
129
130
|
const d = await sr.json()
|
|
131
|
+
if (d && typeof d.allowlist === 'string') setAllowlist(d.allowlist)
|
|
130
132
|
const lg = await lr.json()
|
|
131
133
|
if (!alive) return
|
|
132
134
|
setJ(d); setErr(''); setLog(lg.actions || [])
|
|
@@ -153,6 +155,21 @@ function Panel() {
|
|
|
153
155
|
|
|
154
156
|
R.useEffect(() => { let alive = true; loadPresets().then(() => {}); return () => { alive = false } }, [])
|
|
155
157
|
|
|
158
|
+
const saveAllowlist = async (mode) => {
|
|
159
|
+
setBusy('allowlist')
|
|
160
|
+
try {
|
|
161
|
+
const r = await fetch('/wxbridge/allowlist', {
|
|
162
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({ mode, restart: true }),
|
|
164
|
+
})
|
|
165
|
+
const d = await r.json()
|
|
166
|
+
if (typeof d.allowlist === 'string') setAllowlist(d.allowlist)
|
|
167
|
+
setNote(d.ok
|
|
168
|
+
? ('准入模式已切到 ' + mode + ':' + (d.note || ''))
|
|
169
|
+
: ('切换失败:' + (d.error || '未知错误')))
|
|
170
|
+
} catch (e) { setNote('切换失败:' + String(e && e.message ? e.message : e)) } finally { setBusy('') }
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
const savePreset = async (want) => {
|
|
157
174
|
setBusy('preset')
|
|
158
175
|
try {
|
|
@@ -404,6 +421,30 @@ function Panel() {
|
|
|
404
421
|
)
|
|
405
422
|
),
|
|
406
423
|
|
|
424
|
+
R.createElement('div', { style: { marginTop: '14px', paddingTop: '12px', borderTop: subtle } },
|
|
425
|
+
R.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '6px' } },
|
|
426
|
+
wxBubbles(16, WX, skin.dark ? '#0d1220' : '#ffffff', 'transparent'),
|
|
427
|
+
R.createElement('span', { style: { fontSize: '13px', fontWeight: 600 } }, '准入模式'),
|
|
428
|
+
R.createElement('span', { style: { fontSize: '11px', color: dim.color } }, '(决定谁能驱动这台机器)')),
|
|
429
|
+
R.createElement('select', {
|
|
430
|
+
value: allowlist, onChange: (e) => setAllowlist(e.target.value),
|
|
431
|
+
style: { ...skin.control, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: '8px', fontSize: '12px' },
|
|
432
|
+
}, [
|
|
433
|
+
R.createElement('option', { key: 'strict', value: 'strict', style: skin.option }, 'strict(默认)· 只服务已登记的设备;未登记不执行,只回一次提示'),
|
|
434
|
+
R.createElement('option', { key: 'auto', value: 'auto', style: skin.option }, 'auto · 任何能给机器人发消息的人首次发言即登记(机器人别被拉进群/加好友)'),
|
|
435
|
+
R.createElement('option', { key: 'open', value: 'open', style: skin.option }, 'open · 不做任何检查(强烈不建议)'),
|
|
436
|
+
]),
|
|
437
|
+
R.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center' } },
|
|
438
|
+
R.createElement('button', {
|
|
439
|
+
className: 'wxbridge-btn', onClick: () => saveAllowlist(allowlist), disabled: !!busy,
|
|
440
|
+
style: {
|
|
441
|
+
marginRight: '10px', padding: '6px 14px', borderRadius: '999px', fontSize: '12px', fontWeight: 500,
|
|
442
|
+
cursor: busy ? 'default' : 'pointer', border: '1px solid ' + WX, background: WX, color: '#fff',
|
|
443
|
+
},
|
|
444
|
+
}, busy === 'allowlist' ? '处理中…' : '保存并重启桥'),
|
|
445
|
+
R.createElement('span', { style: dim }, '当前:' + allowlist + '(改完会写入 config.json 并重启桥,立即生效)')),
|
|
446
|
+
),
|
|
447
|
+
|
|
407
448
|
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,
|
|
408
449
|
|
|
409
450
|
// ── 操作日志 ───────────────────────────────────────────────────
|