@zmainer/dsh-wx-bridge 1.1.1 → 1.1.3
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 +12 -1
- package/lib/kernel/bridge.mjs +47 -0
- package/lib/kernel/wxpush.mjs +30 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -229,7 +229,7 @@ echo "来自管道的长文本" | node "<dataDir>/wxpush.mjs" --stdin
|
|
|
229
229
|
| Node | **`>=22.19.0`**(`package.json` 的 `engines`):会话文件是**多帧 zstd**,逐帧解压用到 `node:zlib` 的 `zstdDecompressSync`。桌面端自带运行时(本机实测 v26)与系统 Node(本机 v22.19)都验证过 |
|
|
230
230
|
| 操作系统 | **Windows 已实测**(进程树终止用 `taskkill /T /F`;运行时入口探测含 `%LOCALAPPDATA%\Programs\*` 扫描、PID 复用判断)。macOS / Linux **未实测**:内核是纯 Node,但上述几处是 Windows 实现,跨平台请先跑 `node lib/kernel/bridge.mjs --selftest-runtime` |
|
|
231
231
|
| 依赖 | **零运行时依赖**(只用 Node 内建模块);不依赖任何 `@deepseek-ai/*` 包(宿主能力通过 `ctx.inject` 取用,缺失即降级) |
|
|
232
|
-
| 自测 | `--selftest-runtime`(运行时入口)/ `--selftest-approval`(审批卡与回复解析)/ `--selftest-outbox`(推送信箱)/ `--selftest-files` / `--selftest-inbound` / `--selftest-presets` |
|
|
232
|
+
| 自测 | `--selftest-runtime`(运行时入口)/ `--selftest-approval`(审批卡与回复解析)/ `--selftest-acp-approval`(审批 ↔ ACP `session/request_permission` 的线上契约)/ `--selftest-outbox`(推送信箱)/ `--selftest-files` / `--selftest-inbound` / `--selftest-presets` |
|
|
233
233
|
|
|
234
234
|
## 常见问题 / Troubleshooting
|
|
235
235
|
|
|
@@ -266,6 +266,17 @@ ode_modules\@zmainer\dsh-wx-bridge` → 改名成 `...dsh-wx-bridge.bak` 更稳
|
|
|
266
266
|
|
|
267
267
|
## 最近变更
|
|
268
268
|
|
|
269
|
+
- **1.1.3**:新增 `--selftest-acp-approval` —— 把审批卡 ↔ ACP `session/request_permission` 的**线上契约**
|
|
270
|
+
变成可离线跑的断言:按 `acp.mjs` 实际收发的帧形状构造四类选项集(三选一 / 允许+拒绝 / 只有允许 / 空),
|
|
271
|
+
验证「批准 / 拒绝」映射到的 `optionId`、以及应答负载(`{outcome:{outcome:'selected',optionId}}`,
|
|
272
|
+
挑不到时 `cancelled`)。空选项集必须两侧都为空(fail-closed)。运行日志侧另记一条协议事实:
|
|
273
|
+
iLink 的 `getupdates` **不会**回投我们自己发出的消息(2026-09-24 实测)。
|
|
274
|
+
- **1.1.2**:修 `wxpush.mjs` 的**默认数据目录解析**——原来只按 `$DSH_HOME/wxbridge` 兜底,桌面端
|
|
275
|
+
(Electron)里 `DSH_HOME` 常为空或指向另一个 home,于是消息被排进 `~/.dsh/wxbridge/outbox`,
|
|
276
|
+
而桥在 `%APPDATA%\dsh-desktop\harness\wxbridge` 取件 ⇒ **排了队没人发**(本机实测)。
|
|
277
|
+
现在顺序是:`--data-dir`/`WXBRIDGE_DATA`/`BRIDGE_DATA` → `WXBRIDGE_CONFIG` 里的 `dataDir` →
|
|
278
|
+
**本文件自己所在的目录**(桥每次启动都把 CLI 刷到 `<dataDir>/wxpush.mjs`,跟着文件走永远对)。
|
|
279
|
+
另加 `--print-dir` 便于排查「消息推到哪去了」。
|
|
269
280
|
- **1.1.1**:**默认权限预设从 `danger-full-access` 收紧为 `workspace-write`**——审批能在微信里答了以后,
|
|
270
281
|
"手机端无人可答审批"就不再是把通道放到最宽的理由。行为变化:工作区之外的写与提权会先在微信里弹审批卡
|
|
271
282
|
(回「批准 / 拒绝」),要旧行为就把 `acp.permPreset` 设回 `danger-full-access`,要更严设 `read-only`。
|
package/lib/kernel/bridge.mjs
CHANGED
|
@@ -2123,6 +2123,49 @@ async function main() {
|
|
|
2123
2123
|
}, null, 1))
|
|
2124
2124
|
process.exit(rows.every((r) => r.ok) && cli.ok ? 0 : 1)
|
|
2125
2125
|
}
|
|
2126
|
+
if (args.includes('--selftest-acp-approval')) {
|
|
2127
|
+
// 离线自测:审批卡 ↔ ACP `session/request_permission` 的**线上契约**
|
|
2128
|
+
// (帧形状、optionId 选择、应答负载都按 acp.mjs 里实际收发的样子构造;不连网络、不拉子进程)
|
|
2129
|
+
const cases = [
|
|
2130
|
+
{ name: '标准三选一', options: [
|
|
2131
|
+
{ optionId: 'allow-once', kind: 'allow_once', name: '允许一次' },
|
|
2132
|
+
{ optionId: 'allow-always', kind: 'allow_always', name: '总是允许' },
|
|
2133
|
+
{ optionId: 'reject-once', kind: 'reject_once', name: '拒绝' }],
|
|
2134
|
+
allow: 'allow-once', reject: 'reject-once' },
|
|
2135
|
+
{ name: '只有允许/拒绝两选', options: [
|
|
2136
|
+
{ optionId: 'a1', kind: 'allow', name: '允许' },
|
|
2137
|
+
{ optionId: 'r1', kind: 'reject', name: '拒绝' }],
|
|
2138
|
+
allow: 'a1', reject: 'r1' },
|
|
2139
|
+
{ name: '无拒绝项(上游只给允许)', options: [{ optionId: 'a1', kind: 'allow', name: '允许' }],
|
|
2140
|
+
allow: 'a1', reject: null },
|
|
2141
|
+
{ name: '空选项 → 两侧都必须为空(fail-closed)', options: [], allow: null, reject: null },
|
|
2142
|
+
]
|
|
2143
|
+
const resolved = cases.map((c) => {
|
|
2144
|
+
const opts = c.options
|
|
2145
|
+
const card = formatApprovalCard({ n: 1, tool: 'Bash: 删除 build 目录', options: opts, timeoutSec: 120 })
|
|
2146
|
+
const fromReply = (t) => { const p = parseApprovalReply(t); return pickOptionByDecision(opts, p.decision)?.optionId ?? null }
|
|
2147
|
+
const payload = (optionId) => optionId
|
|
2148
|
+
? { jsonrpc: '2.0', id: 7, result: { outcome: { outcome: 'selected', optionId } } }
|
|
2149
|
+
: { jsonrpc: '2.0', id: 7, result: { outcome: { outcome: 'cancelled' } } }
|
|
2150
|
+
return {
|
|
2151
|
+
name: c.name,
|
|
2152
|
+
allowByReply: fromReply('批准'), expectAllow: c.allow,
|
|
2153
|
+
rejectByReply: fromReply('拒绝'), expectReject: c.reject,
|
|
2154
|
+
ok: fromReply('批准') === c.allow && fromReply('拒绝') === c.reject,
|
|
2155
|
+
wireOnAllow: payload(c.allow),
|
|
2156
|
+
cardLines: card.split(NL).length,
|
|
2157
|
+
}
|
|
2158
|
+
})
|
|
2159
|
+
const ok = resolved.every((r) => r.ok)
|
|
2160
|
+
console.log('[selftest-acp-approval] ' + JSON.stringify({
|
|
2161
|
+
frame: { jsonrpc: '2.0', id: 7, method: 'session/request_permission',
|
|
2162
|
+
params: { sessionId: '<peer-session>', toolCall: { title: 'Bash: 删除 build 目录' }, options: cases[0].options } },
|
|
2163
|
+
timeoutSec: Math.round(APPROVAL_TIMEOUT_MS / 1000),
|
|
2164
|
+
permPreset: ACP_PERM_PRESET, permPolicy: ACP_PERM,
|
|
2165
|
+
resolved,
|
|
2166
|
+
}, null, 1))
|
|
2167
|
+
process.exit(ok ? 0 : 1)
|
|
2168
|
+
}
|
|
2126
2169
|
acquireLock()
|
|
2127
2170
|
// 上次进程遗留的运行态任务:本轮一启动就标成 interrupted。
|
|
2128
2171
|
// 否则 runningCount() 永远虚高,`while (runningCount() >= MAX_CONCURRENT_TASKS)` 会把新任务永久挡住。
|
|
@@ -2215,6 +2258,10 @@ async function main() {
|
|
|
2215
2258
|
const key = msgKey(msg)
|
|
2216
2259
|
if (seen.has(key)) continue
|
|
2217
2260
|
seen.add(key); state.processed.push(key)
|
|
2261
|
+
// 自回显(协议事实,2026-09-24 实测):iLink 的 getupdates **不会**把我们自己发出的消息投回来
|
|
2262
|
+
//(两条主动推送发出后无任何 inbound 回流);但随出站下发的是 message_type=2(MESSAGE_TYPE_BOT),
|
|
2263
|
+
// 若上游将来改成回显,症状是"桥把自己的回执/审批卡当成新任务反复执行"——届时在此按
|
|
2264
|
+
// `message_type === MESSAGE_TYPE_BOT` 或自己的 user_id 拦截即可。
|
|
2218
2265
|
log('inbound', { text: inboundText(msg).slice(0, 80), types: inboundItemTypes(msg) })
|
|
2219
2266
|
try { await handleMessage(wechatToken, msg) } catch (e) { log('handle-error', { error: String(e?.message ?? e) }) }
|
|
2220
2267
|
}
|
package/lib/kernel/wxpush.mjs
CHANGED
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* 它只做一件事:把一条 JSON 丢进 <dataDir>/outbox/,桥轮询时替它发出去。
|
|
12
12
|
* 零依赖、零网络:没有微信凭据 / 桥没在跑时也只是排队,不会报错给你。
|
|
13
13
|
*/
|
|
14
|
-
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
15
|
-
import { join, resolve } from 'node:path'
|
|
14
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { join, resolve, dirname } from 'node:path'
|
|
16
16
|
import { homedir } from 'node:os'
|
|
17
17
|
import { randomUUID } from 'node:crypto'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
18
19
|
|
|
19
20
|
const args = process.argv.slice(2)
|
|
20
21
|
const argOf = (name, def = '') => { const i = args.indexOf(name); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : def }
|
|
@@ -37,14 +38,39 @@ async function readStdin() {
|
|
|
37
38
|
return out.trim()
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 数据目录解析(顺序很重要,2026-09-24 实测踩过):
|
|
43
|
+
* ① --data-dir / WXBRIDGE_DATA / BRIDGE_DATA —— 显式指定,永远最大
|
|
44
|
+
* ② WXBRIDGE_CONFIG 指向的配置里的 dataDir —— 同样的显式配置链
|
|
45
|
+
* ③ **本文件自己所在的目录** —— 桥每次启动都会把本文件刷到 <dataDir>/wxpush.mjs 一份,
|
|
46
|
+
* 所以"跟着这个文件走"永远等于桥真正在用的目录,不依赖任何环境变量
|
|
47
|
+
* 刻意**不读** $DSH_HOME/wxbridge/config.json 之类:桌面端(Electron)里 DSH_HOME 常为空或指向
|
|
48
|
+
* 另一个 home,读到的可能是旧目录里的旧 dataDir;实测症状就是"推送排了队,桥那头没人取件"。
|
|
49
|
+
*/
|
|
50
|
+
const SELF_DIR = dirname(fileURLToPath(import.meta.url))
|
|
51
|
+
|
|
52
|
+
function explicitConfigDataDir() {
|
|
53
|
+
const f = process.env.WXBRIDGE_CONFIG
|
|
54
|
+
if (!f) return ''
|
|
55
|
+
try {
|
|
56
|
+
const d = JSON.parse(readFileSync(f, 'utf8')).dataDir
|
|
57
|
+
return d ? resolve(String(d)) : ''
|
|
58
|
+
} catch { return '' }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dataDir = resolve(argOf('--data-dir') || process.env.WXBRIDGE_DATA || process.env.BRIDGE_DATA
|
|
62
|
+
|| explicitConfigDataDir() || SELF_DIR)
|
|
63
|
+
|
|
40
64
|
const text = (argOf('--text') || (args.includes('--stdin') ? await readStdin() : '') || positionalText()).trim()
|
|
65
|
+
if (args.includes('--print-dir')) {
|
|
66
|
+
console.log('[wxpush] dataDir=' + dataDir + (text ? '' : '(未给内容,仅打印目录)'))
|
|
67
|
+
process.exit(0)
|
|
68
|
+
}
|
|
41
69
|
if (!text) {
|
|
42
70
|
console.error('[wxpush] 没有内容:用法 node wxpush.mjs "要推送的话"(或 --text / --stdin)')
|
|
43
71
|
process.exit(2)
|
|
44
72
|
}
|
|
45
73
|
|
|
46
|
-
const dataDir = resolve(argOf('--data-dir') || process.env.WXBRIDGE_DATA || process.env.BRIDGE_DATA
|
|
47
|
-
|| join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'wxbridge'))
|
|
48
74
|
const outbox = join(dataDir, 'outbox')
|
|
49
75
|
const entry = {
|
|
50
76
|
text: text.slice(0, 4000),
|