sintbot 1.3.0 → 1.4.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.
Files changed (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
package/src/cdp.js ADDED
@@ -0,0 +1,254 @@
1
+ 'use strict'
2
+ // 常驻浏览器的 CDP 客户端(Chrome DevTools Protocol)—— 控制台「接管浏览器」页的底座。
3
+ //
4
+ // 为什么自己写而不用 chrome-devtools-mcp:MCP 是给 agent 用的工具面(元素 uid、快照),接管页要的是
5
+ // **原始画面 + 原始输入** —— 截屏推流(Page.startScreencast)与按坐标的鼠标 / 键盘事件(Input.*),
6
+ // 且要在 daemon 进程里长连接、按需 attach 到某个标签页。MCP 没有这两样,也不该有(它面向 agent)。
7
+ //
8
+ // 传输:HTTP 端点(/json、/json/new …)走 node:http;协议通道走 WebSocket(`ws` 包 —— 飞书 SDK 已带同一份,
9
+ // 声明进依赖是为了不靠提升的巧合;Node 18/20 没有全局 WebSocket)。`WebSocketImpl` 可注入 ⇒ 单测用假的。
10
+ //
11
+ // ⚠ 只连 cfg.browser.cdpUrl(默认本机 9222)。9222 只绑 localhost,但本机任意进程都能经它操控已登录的浏览器
12
+ // (docs/BROWSER.md 安全口径)—— 这里不再加门,门在控制台那层(登录 / 一次性链接)。
13
+
14
+ const http = require('node:http')
15
+
16
+ const DEFAULT_CDP_URL = 'http://127.0.0.1:9222'
17
+
18
+ // —— HTTP 端点 ——
19
+ function httpJSON(url, { method = 'GET', timeoutMs = 3000 } = {}) {
20
+ return new Promise((resolve, reject) => {
21
+ const u = new URL(url)
22
+ const req = http.request({ host: u.hostname, port: u.port || 80, path: u.pathname + u.search, method, timeout: timeoutMs }, (res) => {
23
+ let b = ''
24
+ res.setEncoding('utf8')
25
+ res.on('data', (c) => { b += c })
26
+ res.on('end', () => {
27
+ if (res.statusCode >= 400) return reject(new Error(`浏览器端点 ${u.pathname} 返回 HTTP ${res.statusCode}`)) // 文案可能原样到接管页 / 群里,不带协议名
28
+ if (!b.trim()) return resolve(null)
29
+ try { resolve(JSON.parse(b)) } catch { resolve(b) } // /json/close 之类返回纯文本
30
+ })
31
+ })
32
+ req.on('timeout', () => req.destroy(new Error('浏览器端点超时')))
33
+ req.on('error', reject)
34
+ req.end()
35
+ })
36
+ }
37
+
38
+ // 浏览器还活着吗:/json/version(Browser 字段形如 "Chrome/152.0.7977.83")
39
+ const versionInfo = (cdpUrl = DEFAULT_CDP_URL, opts = {}) => httpJSON(`${cdpUrl}/json/version`, opts)
40
+
41
+ // 页面型 target 清单(过滤掉 chrome:// / devtools:// / 扩展页这类不是「网页」的东西)
42
+ async function listTargets(cdpUrl = DEFAULT_CDP_URL) {
43
+ const all = (await httpJSON(`${cdpUrl}/json`)) || []
44
+ return all
45
+ .filter((t) => t && t.type === 'page' && /^(https?:|about:|file:)/.test(String(t.url || '')))
46
+ .map((t) => ({ id: t.id, title: t.title || '', url: t.url || '', wsUrl: t.webSocketDebuggerUrl || '' }))
47
+ }
48
+
49
+ const newTarget = (cdpUrl, url) => httpJSON(`${cdpUrl}/json/new?${encodeURIComponent(url || 'about:blank')}`, { method: 'PUT' })
50
+ const activateTarget = (cdpUrl, id) => httpJSON(`${cdpUrl}/json/activate/${encodeURIComponent(id)}`)
51
+ const closeTarget = (cdpUrl, id) => httpJSON(`${cdpUrl}/json/close/${encodeURIComponent(id)}`)
52
+
53
+ // —— 协议会话 ——
54
+ // 一个 target 一条 ws。call() 按 id 配对回包;on() 收事件(Page.screencastFrame 之类)。
55
+ // 超时 / 断线时把所有挂着的 call 一起拒掉,调用方不会悬着。
56
+ class CdpSession {
57
+ constructor(wsUrl, { WebSocketImpl = null, timeoutMs = 15000 } = {}) {
58
+ this.wsUrl = wsUrl
59
+ this.WebSocketImpl = WebSocketImpl || require('ws')
60
+ this.timeoutMs = timeoutMs
61
+ this.ws = null
62
+ this.connected = false
63
+ this._id = 0
64
+ this._pending = new Map() // id -> { resolve, reject, timer }
65
+ this._handlers = new Map() // method -> Set(fn)
66
+ this._onClose = new Set()
67
+ }
68
+
69
+ connect() {
70
+ return new Promise((resolve, reject) => {
71
+ const ws = new this.WebSocketImpl(this.wsUrl)
72
+ this.ws = ws
73
+ const fail = (e) => { if (!this.connected) reject(e instanceof Error ? e : new Error(String(e && e.message || e || '连不上浏览器'))) }
74
+ ws.on('open', () => { this.connected = true; resolve(this) })
75
+ ws.on('error', (e) => { fail(e); this._failAll(e) })
76
+ ws.on('close', () => { this.connected = false; this._failAll(new Error('浏览器连接已断开')); for (const fn of this._onClose) { try { fn() } catch {} } })
77
+ ws.on('message', (data) => this._onMessage(data))
78
+ })
79
+ }
80
+
81
+ _onMessage(data) {
82
+ let m
83
+ try { m = JSON.parse(typeof data === 'string' ? data : data.toString('utf8')) } catch { return }
84
+ if (m.id != null && this._pending.has(m.id)) {
85
+ const p = this._pending.get(m.id); this._pending.delete(m.id); clearTimeout(p.timer)
86
+ if (m.error) p.reject(new Error(`${p.method}: ${m.error.message || JSON.stringify(m.error)}`))
87
+ else p.resolve(m.result || {})
88
+ return
89
+ }
90
+ if (m.method) {
91
+ const hs = this._handlers.get(m.method)
92
+ if (hs) for (const fn of hs) { try { fn(m.params || {}) } catch (e) { /* 单个处理器出错不影响别的 */ } }
93
+ }
94
+ }
95
+
96
+ _failAll(e) {
97
+ for (const [, p] of this._pending) { clearTimeout(p.timer); p.reject(e) }
98
+ this._pending.clear()
99
+ }
100
+
101
+ call(method, params = {}) {
102
+ if (!this.connected || !this.ws) return Promise.reject(new Error('浏览器连接未就绪'))
103
+ const id = ++this._id
104
+ return new Promise((resolve, reject) => {
105
+ const timer = setTimeout(() => { if (this._pending.delete(id)) reject(new Error(`${method} 超时`)) }, this.timeoutMs)
106
+ this._pending.set(id, { resolve, reject, timer, method })
107
+ try { this.ws.send(JSON.stringify({ id, method, params })) } catch (e) { this._pending.delete(id); clearTimeout(timer); reject(e) }
108
+ })
109
+ }
110
+
111
+ on(method, fn) { if (!this._handlers.has(method)) this._handlers.set(method, new Set()); this._handlers.get(method).add(fn); return () => this.off(method, fn) }
112
+ off(method, fn) { const hs = this._handlers.get(method); if (hs) hs.delete(fn) }
113
+ onClose(fn) { this._onClose.add(fn); return () => this._onClose.delete(fn) }
114
+
115
+ close() { this.connected = false; try { this.ws && this.ws.close() } catch {} ; this._failAll(new Error('已关闭')) }
116
+ }
117
+
118
+ // —— 截屏推流 ——
119
+ // 帧只在页面重绘时才来(静止页面零流量),所以开流后先补一张 captureScreenshot 当首帧,免得人盯着空白。
120
+ // ack 放在 onFrame 之后:消费方(SSE 写入)慢了,浏览器就攒着不发下一帧 —— 天然背压,不用自己丢帧。
121
+ // 默认值的来由(2026-09-10 真机实测,手机尺寸):640 宽 / 45 质量一帧约 12 KB;everyNthFrame=2 把不限速时的 ~28 fps 压到一半,
122
+ // 再由「onFrame 完成后才 ack」的背压按链路自然降到几帧每秒 —— 画面够顺、公网隧道与手机流量都扛得住。
123
+ async function startScreencast(session, { maxWidth = 640, maxHeight = 1280, quality = 45, everyNthFrame = 2 } = {}, onFrame) {
124
+ await session.call('Page.enable')
125
+ const off = session.on('Page.screencastFrame', async (p) => {
126
+ try { await onFrame({ data: p.data, meta: p.metadata || {}, sessionId: p.sessionId }) } catch {}
127
+ try { await session.call('Page.screencastFrameAck', { sessionId: p.sessionId }) } catch {}
128
+ })
129
+ await session.call('Page.startScreencast', { format: 'jpeg', quality, maxWidth, maxHeight, everyNthFrame })
130
+ try { await seedFrame(session, { maxWidth, maxHeight, quality }, onFrame) } catch { /* 首帧补不上就等真帧 */ }
131
+ return async () => { off(); try { await session.call('Page.stopScreencast') } catch {} }
132
+ }
133
+
134
+ // 补一张首帧(开流时、以及后来才加入同一路推流的订阅者)。
135
+ // ⚠ 首帧必须与推流帧同尺寸:captureScreenshot 默认按视口原尺寸出图,而推流帧被 maxWidth/maxHeight 缩过 ——
136
+ // 两者不一致时,接管页在第二帧到来那一刻画面会跳一下,手指正按着的位置就漂了(2026-09-10 真机:拖滑块位移 0)。
137
+ // 先读视口尺寸,按同样的比例给截图加 clip.scale。
138
+ async function seedFrame(session, { maxWidth = 640, maxHeight = 1280, quality = 45 } = {}, onFrame) {
139
+ const lm = await session.call('Page.getLayoutMetrics')
140
+ const vv = (lm && (lm.cssVisualViewport || lm.visualViewport)) || {}
141
+ const cw = vv.clientWidth || 0, ch = vv.clientHeight || 0
142
+ const scale = cw > 0 && ch > 0 ? Math.min(1, maxWidth / cw, maxHeight / ch) : 1
143
+ const params = { format: 'jpeg', quality }
144
+ if (cw > 0 && ch > 0) params.clip = { x: 0, y: 0, width: cw, height: ch, scale }
145
+ const shot = await session.call('Page.captureScreenshot', params)
146
+ await onFrame({ data: shot.data, meta: { deviceWidth: cw, deviceHeight: ch, seed: true }, sessionId: null })
147
+ }
148
+
149
+ // 给 CLI 用:截某个标签页一张缩略图(接管卡片上的「卡在哪个页面」)。连一次、截一张、关掉;失败抛错由调用方决定要不要图。
150
+ async function captureTarget(cdpUrl, targetId, { maxWidth = 640, quality = 60, WebSocketImpl = null } = {}) {
151
+ const targets = await listTargets(cdpUrl)
152
+ const t = targets.find((x) => x.id === targetId)
153
+ if (!t) throw new Error('这个标签页已经不在了')
154
+ const session = new CdpSession(t.wsUrl, { WebSocketImpl })
155
+ await session.connect()
156
+ try {
157
+ let data = null
158
+ await seedFrame(session, { maxWidth, maxHeight: maxWidth * 2, quality }, (f) => { data = f.data })
159
+ return { data, title: t.title, url: t.url }
160
+ } finally { session.close() }
161
+ }
162
+
163
+ // —— 输入:接管页发来的事件 → CDP 调用(纯函数,可测)——
164
+ // 坐标一律是页面 CSS 像素(接管页按帧的 deviceWidth/Height 换算好再发),这里只做校验与翻译。
165
+ const KEYS = {
166
+ Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' },
167
+ Backspace: { key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 },
168
+ Tab: { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 },
169
+ Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
170
+ Delete: { key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 },
171
+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },
172
+ ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
173
+ ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 },
174
+ ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 },
175
+ Home: { key: 'Home', code: 'Home', windowsVirtualKeyCode: 36 },
176
+ End: { key: 'End', code: 'End', windowsVirtualKeyCode: 35 },
177
+ PageUp: { key: 'PageUp', code: 'PageUp', windowsVirtualKeyCode: 33 },
178
+ PageDown: { key: 'PageDown', code: 'PageDown', windowsVirtualKeyCode: 34 },
179
+ }
180
+ // 一次插入的文本上限:验证码 / 账号 / 一段地址都远小于此;更长的多半是误粘贴(整页文本),不该一股脑塞进输入框
181
+ const MAX_TEXT = 4000
182
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v * 10) / 10 : null)
183
+
184
+ // 非文本键必须带 windowsVirtualKeyCode:CDP 只凭 key/code 不会触发页面里按键盘码判断的逻辑(表单的 Enter 提交、退格删字)—— 与 puppeteer 的键表同款。
185
+ // 坐标上限 20000 / 滚轮上限 5000:视口再大也到不了的值,只用来拦明显的垃圾输入(NaN、负数、乱填),不是精确边界。
186
+ // 一个接管页事件 → 若干 { method, params };不认识 / 不合法 ⇒ null(调用方跳过,不报错,免得一个坏事件卡住整批)
187
+ function inputToCdp(ev) {
188
+ if (!ev || typeof ev !== 'object') return null
189
+ const t = ev.t
190
+ if (t === 'text') {
191
+ const text = String(ev.text == null ? '' : ev.text)
192
+ if (!text || text.length > MAX_TEXT) return null
193
+ return [{ method: 'Input.insertText', params: { text } }]
194
+ }
195
+ if (t === 'key') {
196
+ const k = Object.prototype.hasOwnProperty.call(KEYS, String(ev.key)) ? KEYS[ev.key] : null // hasOwn:'__proto__' 之类的键名不能摸到原型
197
+ if (!k) return null
198
+ const mods = (ev.shift ? 8 : 0) | (ev.ctrl ? 2 : 0) | (ev.alt ? 1 : 0) | (ev.meta ? 4 : 0)
199
+ // Enter 的 text 让输入框真的换行 / 提交:keyDown 带 text 才触发 keypress 语义(与 puppeteer 同款)
200
+ const down = { type: k.text ? 'keyDown' : 'rawKeyDown', key: k.key, code: k.code, windowsVirtualKeyCode: k.windowsVirtualKeyCode, modifiers: mods }
201
+ if (k.text) down.text = k.text
202
+ return [{ method: 'Input.dispatchKeyEvent', params: down }, { method: 'Input.dispatchKeyEvent', params: { type: 'keyUp', key: k.key, code: k.code, windowsVirtualKeyCode: k.windowsVirtualKeyCode, modifiers: mods } }]
203
+ }
204
+ const x = num(ev.x), y = num(ev.y)
205
+ if (x == null || y == null || x < 0 || y < 0 || x > 20000 || y > 20000) return null
206
+ if (t === 'move') return [{ method: 'Input.dispatchMouseEvent', params: { type: 'mouseMoved', x, y, button: ev.down ? 'left' : 'none', buttons: ev.down ? 1 : 0 } }]
207
+ if (t === 'down') return [{ method: 'Input.dispatchMouseEvent', params: { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 } }]
208
+ if (t === 'up') return [{ method: 'Input.dispatchMouseEvent', params: { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 } }]
209
+ if (t === 'tap') return [
210
+ { method: 'Input.dispatchMouseEvent', params: { type: 'mouseMoved', x, y, button: 'none', buttons: 0 } },
211
+ { method: 'Input.dispatchMouseEvent', params: { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: 1 } },
212
+ { method: 'Input.dispatchMouseEvent', params: { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: 1 } },
213
+ ]
214
+ if (t === 'wheel') {
215
+ const dx = num(ev.dx) || 0, dy = num(ev.dy) || 0
216
+ if (Math.abs(dx) > 5000 || Math.abs(dy) > 5000) return null
217
+ return [{ method: 'Input.dispatchMouseEvent', params: { type: 'mouseWheel', x, y, deltaX: dx, deltaY: dy } }]
218
+ }
219
+ return null
220
+ }
221
+
222
+ // 一批事件按序下发;坏事件跳过(计入 skipped),CDP 报错(页面正在导航之类)记下继续,不中断整批。
223
+ // 超过 max 时只丢 move / wheel(丢掉中间几步无伤),**down / up / key / text 一个都不能丢** —— 丢了 up 鼠标就卡在按下态
224
+ // (对抗审查 🟡7);丢了多少报 dropped,让页面知道。
225
+ function trimEvents(events, max) {
226
+ const list = Array.isArray(events) ? events : []
227
+ if (list.length <= max) return { list, dropped: 0 }
228
+ const must = list.filter((e) => e && e.t !== 'move' && e.t !== 'wheel')
229
+ const room = Math.max(0, max - must.length)
230
+ const soft = list.filter((e) => e && (e.t === 'move' || e.t === 'wheel'))
231
+ const keepSoft = new Set(soft.slice(soft.length - room)) // 留最近的几步,老的丢
232
+ const out = list.filter((e) => e && (e.t !== 'move' && e.t !== 'wheel' ? true : keepSoft.has(e))).slice(0, Math.max(max, must.length))
233
+ return { list: out, dropped: list.length - out.length }
234
+ }
235
+ async function dispatchInputs(session, events, { max = 200 } = {}) {
236
+ const { list, dropped } = trimEvents(events, max)
237
+ let sent = 0, skipped = 0, failed = 0
238
+ for (const ev of list) {
239
+ const calls = inputToCdp(ev)
240
+ if (!calls) { skipped++; continue }
241
+ for (const c of calls) { try { await session.call(c.method, c.params); sent++ } catch { failed++ } }
242
+ }
243
+ return { sent, skipped, failed, dropped }
244
+ }
245
+
246
+ // 接管页 → 页面坐标:帧图片的像素坐标按帧元数据里的视口尺寸(CSS px)线性换算;越界夹回
247
+ function pointerToCss({ px, py, imgW, imgH, cssW, cssH }) {
248
+ if (!(imgW > 0) || !(imgH > 0) || !(cssW > 0) || !(cssH > 0)) return null
249
+ const x = Math.min(cssW, Math.max(0, (px / imgW) * cssW))
250
+ const y = Math.min(cssH, Math.max(0, (py / imgH) * cssH))
251
+ return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
252
+ }
253
+
254
+ module.exports = { DEFAULT_CDP_URL, httpJSON, versionInfo, listTargets, newTarget, activateTarget, closeTarget, CdpSession, startScreencast, seedFrame, captureTarget, inputToCdp, dispatchInputs, trimEvents, pointerToCss, KEYS, MAX_TEXT }
package/src/config.js CHANGED
@@ -83,6 +83,17 @@ function loadConfig(explicit) {
83
83
  }
84
84
  }
85
85
 
86
+ return normalizeConfig(user, p)
87
+ }
88
+
89
+ // The console validates proposed JSON before replacing the live file. Keep
90
+ // normalization identical to disk loading without mutating the proposed object
91
+ // or changing the live redaction registry during validation.
92
+ function normalizeConfig(raw, p = null, { registerSecrets = true } = {}) {
93
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
94
+ throw new TypeError('Configuration must be a JSON object')
95
+ }
96
+ const user = JSON.parse(JSON.stringify(raw))
86
97
  const cfg = {
87
98
  agent: { ...DEFAULTS.agent, ...(user.agent || {}) },
88
99
  timezone: normalizeTz(user.timezone || DEFAULTS.timezone), // 写错回退默认,不漂到主机时区
@@ -172,7 +183,7 @@ function loadConfig(explicit) {
172
183
  // 把配置里凭据的**值**登记给出站脱敏(src/core/redact.js 按值替换):每个 provider 的 apiKey、
173
184
  // provider env 里名字带 TOKEN / KEY / SECRET / PASSWORD 的值、飞书 appSecret。形状模式认不出的
174
185
  // 凭据(UUID 形的火山 key、裸十六进制的 appSecret)靠这条兜住;热重载走的也是这里 ⇒ 换 key 即换名单。
175
- setKnownSecrets(collectSecretValues(cfg))
186
+ if (registerSecrets) setKnownSecrets(collectSecretValues(cfg))
176
187
 
177
188
  return cfg
178
189
  }
@@ -281,4 +292,4 @@ function chatPlatform(cfg, chatId) {
281
292
  return live.length === 1 ? live[0] : null
282
293
  }
283
294
 
284
- module.exports = { loadConfig, findConfigPath, defaultConfigPath, DEFAULTS, applyProxyEnv, deriveNoProxy, resolveSecret, platformsConfigured, inferChatPlatform, normalizeGroupPlatforms, chatPlatform, PLATFORMS }
295
+ module.exports = { loadConfig, normalizeConfig, findConfigPath, defaultConfigPath, DEFAULTS, applyProxyEnv, deriveNoProxy, resolveSecret, platformsConfigured, inferChatPlatform, normalizeGroupPlatforms, chatPlatform, PLATFORMS }
@@ -458,7 +458,8 @@ function terminalSettlementCandidate(record, authority, requireLocal) {
458
458
  const { state, receipts, effects, attempts, descriptors, notices, local,
459
459
  validateState, validateReceipts, validateRelation } = authority
460
460
  const batchRecord = Object.hasOwn(record, 'batchReceiptIds')
461
- if (Object.keys(record).sort().join(',') !==
461
+ const steerRecord = Object.hasOwn(record, 'steerReceiptIds')
462
+ if (Object.keys(record).filter(key => key !== 'steerReceiptIds').sort().join(',') !==
462
463
  (batchRecord
463
464
  ? 'batchReceiptIds,chatId,cursor,effectId,kind,receiptId,settledAt,status,turnToken'
464
465
  : 'chatId,cursor,effectId,kind,receiptId,settledAt,status,turnToken') ||
@@ -513,7 +514,14 @@ function terminalSettlementCandidate(record, authority, requireLocal) {
513
514
  requireLocal && !local.has(record.effectId)) {
514
515
  throw new Error('C2 terminal settlement effect attempt invalid')
515
516
  }
516
- const candidate = completedBatch(receipts, receiptIds, record.settledAt)
517
+ const steered = lane.steerReceiptIds || []
518
+ if (steerRecord !== !!lane.steerReceiptIds || steerRecord && !isDeepStrictEqual(record.steerReceiptIds, steered) ||
519
+ steered.some(id => receiptIds.includes(id) || notices.has(id) ||
520
+ receipts.pending.find(item => item.id === id)?.chatId !== record.chatId ||
521
+ ![...descriptors.values()].some(effect => effect.domain === 'steer' && effect.type === 'callSteer' &&
522
+ effect.scope === record.chatId && effect.turnToken === record.turnToken && effect.receiptId === id &&
523
+ effects.get(effect.effectId) === 'acked'))) throw new Error('terminal steer settlement invalid')
524
+ const candidate = completedBatch(receipts, [...receiptIds, ...steered], record.settledAt)
517
525
  validateSnapshots({ state, receipts: candidate }, validateState, validateReceipts,
518
526
  validateRelation)
519
527
  validateReceiptProgress(receipts, candidate)
@@ -823,10 +831,9 @@ class C2DurableJournal {
823
831
  // after this settlement and terminal prune.
824
832
  settleTerminalEffect(value) {
825
833
  if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
826
- noticeFields(value, value?.batchReceiptIds === undefined
827
- ? ['effectId', 'receiptId', 'chatId', 'turnToken', 'status', 'settledAt']
828
- : ['effectId', 'receiptId', 'chatId', 'turnToken', 'status', 'settledAt',
829
- 'batchReceiptIds'])
834
+ noticeFields(value, ['effectId', 'receiptId', 'chatId', 'turnToken', 'status', 'settledAt',
835
+ ...(value?.batchReceiptIds === undefined ? [] : ['batchReceiptIds']),
836
+ ...(value?.steerReceiptIds === undefined ? [] : ['steerReceiptIds'])])
830
837
  const record = { kind: 'terminalSettlement', cursor: this.#cursor, ...value }
831
838
  terminalSettlementCandidate(record, this.#settlementAuthority(), true)
832
839
  this.#append(record)
@@ -1235,14 +1242,23 @@ class C2DurableJournal {
1235
1242
  const item = exactJson(raw)
1236
1243
  if (!item || typeof item !== 'object' || Array.isArray(item) ||
1237
1244
  !['chatId,receiptId,settledAt,turnToken',
1238
- 'batchReceiptIds,chatId,receiptId,settledAt,turnToken']
1245
+ 'batchReceiptIds,chatId,receiptId,settledAt,turnToken',
1246
+ 'chatId,receiptId,settledAt,steerReceiptIds,turnToken',
1247
+ 'batchReceiptIds,chatId,receiptId,settledAt,steerReceiptIds,turnToken']
1239
1248
  .includes(Object.keys(item).sort().join(','))) {
1240
1249
  throw new Error('invalid C2 checkpoint terminal settlement')
1241
1250
  }
1242
1251
  noticeIdentity(item.receiptId, item.chatId)
1243
1252
  noticeTime(item.settledAt)
1244
1253
  const descriptor = effectDescriptors.get(effectId)
1245
- const settledIds = item.batchReceiptIds || [item.receiptId]
1254
+ if (item.steerReceiptIds !== undefined &&
1255
+ (!Array.isArray(item.steerReceiptIds) || !item.steerReceiptIds.length || item.steerReceiptIds.length > 32 ||
1256
+ item.steerReceiptIds.some(id => !RECEIPT_ID.test(id) ||
1257
+ ![...effectDescriptors.values()].some(effect => effect.domain === 'steer' &&
1258
+ effect.type === 'callSteer' && effect.scope === item.chatId &&
1259
+ effect.turnToken === item.turnToken && effect.receiptId === id &&
1260
+ effects.get(effect.effectId) === 'acked')))) throw new Error('invalid C2 checkpoint steer settlement')
1261
+ const settledIds = [...(item.batchReceiptIds || [item.receiptId]), ...(item.steerReceiptIds || [])]
1246
1262
  if (!Array.isArray(settledIds) || !settledIds.length ||
1247
1263
  new Set(settledIds).size !== settledIds.length ||
1248
1264
  !settledIds.includes(item.receiptId) ||
@@ -1428,7 +1444,8 @@ class C2DurableJournal {
1428
1444
  this.#terminalSettlements.set(record.effectId, { receiptId: record.receiptId,
1429
1445
  chatId: record.chatId, turnToken: record.turnToken, settledAt: record.settledAt,
1430
1446
  ...(record.batchReceiptIds === undefined ? {} : {
1431
- batchReceiptIds: [...record.batchReceiptIds] }) })
1447
+ batchReceiptIds: [...record.batchReceiptIds] }),
1448
+ ...(record.steerReceiptIds === undefined ? {} : { steerReceiptIds: [...record.steerReceiptIds] }) })
1432
1449
  return
1433
1450
  }
1434
1451
  if (record.kind === 'stopAckSettlement') {
@@ -320,6 +320,107 @@ class C2DurableRuntime {
320
320
  // A visible busy receipt remains pending after its queue acknowledgement.
321
321
  // The effect descriptor and target identity are durable before any send;
322
322
  // only the same process may turn one attempted call into an ACK.
323
+ prepareSteer({ receiptId, chatId, parentReceiptId, turnToken, generation, payloadHandles } = {}) {
324
+ const before = this.#completionCapacity()
325
+ const witness = this.#activeRuns.get(chatId)
326
+ const receipt = before.receipts.pending.find(item => item.id === receiptId)
327
+ const job = payloadHandles?.requireQueued(receiptId)
328
+ if (!RECEIPT_ID.test(receiptId || '') || !GENERATION.test(generation || '') ||
329
+ receipt?.chatId !== chatId || receipt.source !== 'message' || receipt.quiet ||
330
+ job?.msg?._c2ReceiptId !== receiptId || witness?.receiptId !== parentReceiptId ||
331
+ witness.turnToken !== turnToken || this.#journal.effectStatus(witness.dispatchEffectId) !== 'acked' ||
332
+ this.#journal.lookupRegistration(callbackRegistrationId({ receiptId: parentReceiptId,
333
+ generation, domain: 'atomicHandoff', kind: 'runnerAccepted' }))?.generation !== generation) {
334
+ throw new Error('C2 steer lacks accepted live parent and queued receipt')
335
+ }
336
+ const id = `sbot-c2-steer-v1:${receiptId}:${generation}:prepare`
337
+ try {
338
+ const result = this.#coordinator.transition({ id, domain: 'steer', scope: chatId,
339
+ payload: { type: 'prepare', receiptId, parentReceiptId, turnToken, generation },
340
+ validate: candidate => {
341
+ const effect = candidate.effects[0]
342
+ if (candidate.effects.length !== 1 || effect.type !== 'callSteer' ||
343
+ effect.receiptId !== receiptId || effect.turnToken !== turnToken ||
344
+ candidate.effectDescriptors[0].receiptId !== receiptId) throw new Error('C2 steer decision invalid')
345
+ } })
346
+ return { effectId: result.effects[0].effectId, receiptId, chatId,
347
+ parentReceiptId, turnToken, generation }
348
+ } catch (error) { this.close(); throw error }
349
+ }
350
+
351
+ commitSteerObservation({ receiptId, chatId, parentReceiptId, turnToken, generation, type } = {}) {
352
+ const before = this.#completionCapacity()
353
+ const entry = before.state.lanes.steer?.[chatId]?.entries.find(item => item.receiptId === receiptId)
354
+ const lane = before.state.lanes.steer?.[chatId]
355
+ if (!entry || lane.parentReceiptId !== parentReceiptId || lane.turnToken !== turnToken ||
356
+ lane.generation !== generation || !['accepted', 'rejected', 'landed'].includes(type) ||
357
+ this.#journal.effectStatus(entry.effectId) !== 'acked') throw new Error('C2 steer observation lacks confirmed RPC')
358
+ if (entry.status === 'landed' && type === 'landed') return false
359
+ try {
360
+ this.#coordinator.transition({ id: `sbot-c2-steer-v1:${receiptId}:${generation}:${type}`,
361
+ domain: 'steer', scope: chatId,
362
+ payload: { type, receiptId, parentReceiptId, turnToken, generation } })
363
+ return true
364
+ } catch (error) { this.close(); throw error }
365
+ }
366
+
367
+ async executeSteer(identity, { invoke, assertWriterLock } = {}) {
368
+ this.#completionCapacity()
369
+ const { effectId, receiptId, chatId, parentReceiptId, turnToken, generation } = identity || {}
370
+ const descriptor = this.#journal.effectDescriptor(effectId)
371
+ if (descriptor?.domain !== 'steer' || descriptor.type !== 'callSteer' ||
372
+ descriptor.scope !== chatId || descriptor.receiptId !== receiptId ||
373
+ descriptor.turnToken !== turnToken || this.#journal.effectStatus(effectId) !== 'pending' ||
374
+ typeof invoke !== 'function' || typeof assertWriterLock !== 'function' ||
375
+ assertWriterLock.constructor?.name === 'AsyncFunction' || assertWriterLock() !== true) {
376
+ throw new Error('C2 steer attempt lacks exact RPC authority')
377
+ }
378
+ const attemptedAt = this.#now()
379
+ try {
380
+ this.#journal.beginEffectAttempt({ effectId, attemptedAt })
381
+ let accepted, known = false
382
+ try {
383
+ accepted = await invoke()
384
+ known = typeof accepted === 'boolean' && this.#effectAuthorityReady && assertWriterLock() === true
385
+ } catch { /* A transport failure may already have delivered the message. */ }
386
+ this.#journal.recordEffectOutcome({ effectId, status: known ? 'acked' : 'unknown',
387
+ resolvedAt: Math.max(attemptedAt, this.#now()) })
388
+ if (!known) throw new Error('C2 steer outcome unknown; replay forbidden')
389
+ this.commitSteerObservation({ receiptId, chatId, parentReceiptId, turnToken, generation,
390
+ type: accepted ? 'accepted' : 'rejected' })
391
+ return accepted
392
+ } catch (error) { this.close(); throw error }
393
+ }
394
+
395
+ cancelClosedSteer({ chatId, parentReceiptId, turnToken, generation, assertWriterLock } = {}) {
396
+ const before = this.#completionCapacity()
397
+ const lane = before.state.lanes.steer?.[chatId]
398
+ if (!lane) return []
399
+ const closeEvidenceId = `sbot-c2-runner-closed-v1:${parentReceiptId}:${generation}`
400
+ const proof = this.#journal.lookupRunnerClosed(closeEvidenceId)
401
+ if (typeof assertWriterLock !== 'function' || assertWriterLock.constructor?.name === 'AsyncFunction' ||
402
+ assertWriterLock() !== true || proof?.chatId !== chatId || proof.receiptId !== parentReceiptId ||
403
+ proof.generation !== generation || proof.turnToken !== turnToken ||
404
+ lane.entries.some(entry => this.#journal.effectStatus(entry.effectId) !== 'acked')) {
405
+ throw new Error('C2 steer cancellation lacks exact closed RPC authority')
406
+ }
407
+ const ids = lane.entries.map(entry => entry.receiptId)
408
+ const settledAt = this.#now()
409
+ const receipts = { pending: before.receipts.pending.filter(item => !ids.includes(item.id)),
410
+ completed: [...before.receipts.completed, ...ids.map(id => ({ id, settledAt }))] }
411
+ try {
412
+ this.#coordinator.transition({ id: `sbot-c2-steer-cancel-v1:${parentReceiptId}:${generation}`,
413
+ domain: 'steer', scope: chatId, payload: { type: 'cancelAll', parentReceiptId,
414
+ turnToken, generation, runnerClosed: true, closeEvidenceId }, receipts,
415
+ validate: result => {
416
+ if (!isDeepStrictEqual(result.decision.cancelledReceiptIds, ids) || result.effects.length) {
417
+ throw new Error('C2 steer cancellation result invalid')
418
+ }
419
+ } })
420
+ return ids
421
+ } catch (error) { this.close(); throw error }
422
+ }
423
+
323
424
  prepareQueueNotice({ receiptId, chatId, payloadHandles } = {}) {
324
425
  const before = this.#completionCapacity()
325
426
  if (!RECEIPT_ID.test(receiptId || '') ||
@@ -2220,7 +2321,9 @@ class C2DurableRuntime {
2220
2321
  try {
2221
2322
  const result = this.#journal.settleTerminalEffect({ effectId, receiptId,
2222
2323
  chatId, turnToken, status: 'acked', settledAt,
2223
- ...(batchReceiptIds ? { batchReceiptIds: [...batchReceiptIds] } : {}) })
2324
+ ...(batchReceiptIds ? { batchReceiptIds: [...batchReceiptIds] } : {}),
2325
+ ...(before.state.lanes.terminal?.[chatId]?.steerReceiptIds ? {
2326
+ steerReceiptIds: [...before.state.lanes.terminal[chatId].steerReceiptIds] } : {}) })
2224
2327
  pruneConfirmedTerminal({ journal: this.#journal,
2225
2328
  coordinator: this.#coordinator, scope: chatId })
2226
2329
  this.#terminalAwaitingSettlement.delete(chatId)
@@ -0,0 +1,45 @@
1
+ 'use strict'
2
+
3
+ // User input the durable v2 route refuses before any receipt exists, such as
4
+ // an unmigrated chat command or /stop with nothing to stop. No durable write
5
+ // happened, so refusing it must not stop the service or sibling groups. Every
6
+ // other route error keeps the existing fault contract and closes ingress.
7
+ const CODE = 'C2_V2_INPUT_REJECTED'
8
+ const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
9
+
10
+ function rejectC2Input(message, { reason, notice = null } = {}) {
11
+ if (typeof message !== 'string' || !message ||
12
+ typeof reason !== 'string' || !/^[a-z][a-z-]{0,40}$/.test(reason) ||
13
+ notice !== null && (typeof notice !== 'string' || !notice)) {
14
+ throw new TypeError('C2 input rejection needs a message, reason and optional notice')
15
+ }
16
+ const error = new Error(message)
17
+ error.code = CODE
18
+ error.reason = reason
19
+ error.notice = notice
20
+ return error
21
+ }
22
+
23
+ function isC2InputRejection(error) {
24
+ return !!error && error.code === CODE && typeof error.reason === 'string'
25
+ }
26
+
27
+ // One best-effort reply under the refused message. It is not a durable effect:
28
+ // a failure is logged, never retried and never escalated to a fault.
29
+ async function answerC2InputRejection({ error, msg, platform, log = () => {} } = {}) {
30
+ if (!isC2InputRejection(error)) throw new TypeError('C2 input rejection expected')
31
+ const target = msg?.replyCtx
32
+ let notified = false
33
+ if (error.notice && typeof platform?.replyOnce === 'function' &&
34
+ typeof msg?.chatId === 'string' && target?.chatId === msg.chatId &&
35
+ FEISHU_MESSAGE_ID.test(target?.messageId || '')) {
36
+ try {
37
+ const response = await platform.replyOnce(target, error.notice)
38
+ notified = response?.code === 0
39
+ } catch {}
40
+ if (!notified) log(`input rejection notice not confirmed (${error.reason})`)
41
+ }
42
+ return Object.freeze({ rejected: true, reason: error.reason, notified })
43
+ }
44
+
45
+ module.exports = { rejectC2Input, isC2InputRejection, answerC2InputRejection }
@@ -132,7 +132,7 @@ function createC2LocalEffectExecutor({ journal, store, resolveTurn,
132
132
  ? ['effectId', 'success', 'type'] : ['effectId', 'type']
133
133
  if (Object.keys(effect).sort().join(',') !== required.join(',') ||
134
134
  effect.type !== descriptor.type ||
135
- descriptor.type === 'verifyTurn' && effect.success !== true) {
135
+ descriptor.type === 'verifyTurn' && typeof effect.success !== 'boolean') {
136
136
  throw new Error('C2 local effect payload differs from durable descriptor')
137
137
  }
138
138
  const turn = resolveTurn({ chatId: descriptor.scope,
@@ -145,6 +145,17 @@ function createC2LocalEffectExecutor({ journal, store, resolveTurn,
145
145
  throw new Error('C2 local effect turn identity unavailable')
146
146
  }
147
147
  const chatId = descriptor.scope
148
+ if (effect.type === 'verifyTurn' && effect.success === false) {
149
+ const outcome = journal.snapshot().state.lanes.terminal?.[chatId]?.outcome
150
+ // A known quota failure says nothing about credential validity. The
151
+ // binary's failed verification intent is acknowledged without writing
152
+ // a false success or altering the previous credential-health record.
153
+ if (outcome?.status !== 'error' || outcome.quotaError !== true ||
154
+ outcome.sideEffects !== false) {
155
+ throw new Error('C2 local effect payload differs: failed verification lacks a supported quota outcome')
156
+ }
157
+ return true
158
+ }
148
159
  if (effect.type === 'clearTainted') {
149
160
  store.clearTainted(chatId)
150
161
  const disk = readJson(store.file)
@@ -121,6 +121,17 @@ class C2PayloadHandles {
121
121
  return job
122
122
  }
123
123
 
124
+ takeSteer(id, chatId, effectId) {
125
+ const snapshot = authority(this.#journal)
126
+ const entry = snapshot.state.lanes.steer?.[chatId]?.entries.find(item => item.receiptId === id)
127
+ const receipt = pending(snapshot, id)
128
+ const job = this.#jobs.get(id)
129
+ if (this.blocked || !job || receipt?.chatId !== chatId || entry?.effectId !== effectId ||
130
+ entry.status !== 'pending' || queued(snapshot, id, chatId)) throw new Error('steer payload authority invalid')
131
+ this.#jobs.delete(id)
132
+ return job
133
+ }
134
+
124
135
  takeHandoff(id) {
125
136
  receiptId(id)
126
137
  if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
@@ -115,7 +115,27 @@ function validatePersistentRelation(state, receipts) {
115
115
  }
116
116
  }
117
117
  }
118
+ for (const [scope, steer] of Object.entries(state.lanes.steer || {})) {
119
+ const parent = byId.get(steer.parentReceiptId)
120
+ if (!parent || parent.chatId !== scope || parent.source !== 'message' || parent.quiet ||
121
+ state.lanes.turn?.[scope]?.active !== steer.turnToken ||
122
+ state.lanes.handoff?.[scope]?.phase !== 'running' ||
123
+ state.lanes.handoff[scope].turnToken !== steer.turnToken) throw new TypeError('steer parent authority invalid')
124
+ for (const entry of steer.entries) {
125
+ const receipt = byId.get(entry.receiptId)
126
+ if (!receipt || receipt.chatId !== scope || receipt.source !== 'message' || receipt.quiet ||
127
+ allJobIds.has(`receipt:job:${entry.receiptId}`) ||
128
+ state.lanes.handoff[scope].acceptedReceiptIds.includes(entry.receiptId)) {
129
+ throw new TypeError('steer receipt authority invalid')
130
+ }
131
+ }
132
+ }
118
133
  for (const [scope, terminal] of Object.entries(state.lanes.terminal || {})) {
134
+ for (const id of terminal.steerReceiptIds || []) {
135
+ if (!byId.has(id) || byId.get(id) && byId.get(id).chatId !== scope || allJobIds.has(`receipt:job:${id}`)) {
136
+ throw new TypeError('terminal steer receipt invalid')
137
+ }
138
+ }
119
139
  if (!terminal.finalized || !terminal.batchReceiptIds) continue
120
140
  for (const id of terminal.batchReceiptIds) {
121
141
  if (!byId.has(id)) throw new TypeError('terminal batch receipt missing')