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.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- 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
|
-
|
|
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
|
|
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,
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
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 !==
|
|
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')
|