@twinsearth/dsh-master 0.0.0-stage → 3.2.8
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/LICENSE +661 -0
- package/README.md +89 -2
- package/client.js +263 -0
- package/cordis.patch.yml +71 -0
- package/dist-host/contract/client.js +82 -0
- package/dist-host/contract/constants.js +70 -0
- package/dist-host/contract/envelope.js +84 -0
- package/dist-host/contract/index.js +17 -0
- package/dist-host/contract/negotiate.js +87 -0
- package/dist-host/contract/server.js +71 -0
- package/dist-host/contract/transports.js +360 -0
- package/dist-host/index.js +260 -0
- package/dist-host/kernel.js +186 -0
- package/dist-host/plugins/cost-optimizer.js +147 -0
- package/dist-host/plugins/dual-manager.js +251 -0
- package/dist-host/plugins/superagent-router.js +499 -0
- package/dist-host/plugins/superagent-worker.js +73 -0
- package/dist-host/superagent/bridge-tokens.js +236 -0
- package/dist-host/superagent/dispatch.js +198 -0
- package/dist-host/superagent/feishu.js +115 -0
- package/dist-host/superagent/github.js +119 -0
- package/dist-host/superagent/pr-delivery.js +351 -0
- package/dist-host/superagent/router.js +219 -0
- package/dist-host/superagent/stream-adapter.js +238 -0
- package/dist-host/superagent/stream-tokens.js +141 -0
- package/dist-host/superagent/ticket-store.js +108 -0
- package/dist-host/superagent/trust.js +145 -0
- package/dist-host/superagent/types.js +144 -0
- package/dist-host/superagent/worker.js +95 -0
- package/dist-host/superagent/writeback.js +162 -0
- package/dist-worker/agent-loop.js +493 -0
- package/dist-worker/errors.js +41 -0
- package/dist-worker/github-pr.js +44 -0
- package/dist-worker/index.js +323 -0
- package/dist-worker/llm-http.js +351 -0
- package/dist-worker/main.js +45 -0
- package/dist-worker/prompt.js +35 -0
- package/dist-worker/sink-http.js +135 -0
- package/dist-worker/tools.js +542 -0
- package/dist-worker/types.js +14 -0
- package/docs/ARCHITECTURE.md +114 -0
- package/docs/CHANGELOG.md +377 -0
- package/docs/INTEROP.md +60 -0
- package/docs/REVIEW-v3.2.7.md +121 -0
- package/docs/ROADMAP.md +106 -0
- package/docs/SECURITY.md +153 -0
- package/docs/SMOKE-3.2.5.md +77 -0
- package/docs/SMOKE-3.2.7.md +73 -0
- package/docs/SUPERAGENT.md +141 -0
- package/docs/WORKER-AGENT-LOOP.md +139 -0
- package/icon.svg +9 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +79 -4
- package/src/contract/client.js +86 -0
- package/src/contract/constants.js +79 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +19 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.ts +265 -0
- package/src/kernel.ts +190 -0
- package/src/plugins/cost-optimizer.ts +155 -0
- package/src/plugins/dual-manager.ts +252 -0
- package/src/plugins/superagent-router.ts +465 -0
- package/src/plugins/superagent-worker.ts +85 -0
- package/src/superagent/bridge-tokens.ts +236 -0
- package/src/superagent/dispatch.ts +197 -0
- package/src/superagent/feishu.ts +104 -0
- package/src/superagent/github.ts +111 -0
- package/src/superagent/pr-delivery.ts +308 -0
- package/src/superagent/router.ts +201 -0
- package/src/superagent/stream-adapter.ts +184 -0
- package/src/superagent/stream-tokens.ts +142 -0
- package/src/superagent/ticket-store.ts +104 -0
- package/src/superagent/trust.ts +139 -0
- package/src/superagent/types.ts +135 -0
- package/src/superagent/worker.ts +100 -0
- package/src/superagent/writeback.ts +154 -0
- package/tsconfig.host.build.json +12 -0
- package/tsconfig.host.json +21 -0
- package/tsconfig.worker.build.json +13 -0
- package/tsconfig.worker.json +17 -0
- package/types/superagent.d.ts +99 -0
- package/worker/agent-loop.ts +524 -0
- package/worker/errors.ts +47 -0
- package/worker/github-pr.ts +65 -0
- package/worker/index.ts +334 -0
- package/worker/llm-http.ts +359 -0
- package/worker/main.ts +45 -0
- package/worker/prompt.ts +39 -0
- package/worker/sink-http.ts +159 -0
- package/worker/tools.ts +531 -0
- package/worker/types.ts +247 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 桥 token 的自动轮换与审计(v3.2.5)。
|
|
7
|
+
*
|
|
8
|
+
* 覆盖母体↔原身派发桥(`POST /dsh-master/api/bridge/spawn`)使用的 `bridgeToken`:
|
|
9
|
+
* * 自动轮换:按 `rotateMs` 周期性签发新 token;
|
|
10
|
+
* * 宽限重叠:旧 token 在 `graceMs` 内仍可用,避免在途请求瞬间失败;
|
|
11
|
+
* * 持久化:只落 **sha256 摘要**(不是明文),进程重启后仍可用;
|
|
12
|
+
* * 审计:动作 + token 摘要前缀写入 `token-audit.jsonl`(append-only),不含明文;
|
|
13
|
+
* * 常量时间比较,避免时序侧信道。
|
|
14
|
+
*
|
|
15
|
+
* 静态配置的 `bridgeToken` 仍然可用(作为“长期 token”参与校验),便于旧部署平滑升级。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto'
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_ROTATE_MS = 30 * 60_000
|
|
23
|
+
const DEFAULT_GRACE_MS = 5 * 60_000
|
|
24
|
+
const DEFAULT_AUDIT_LIMIT = 500
|
|
25
|
+
|
|
26
|
+
function nowOf(clock) {
|
|
27
|
+
return typeof clock === 'function' ? clock() : Date.now()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function digestOf(token) {
|
|
31
|
+
return crypto.createHash('sha256').update(String(token ?? ''), 'utf8').digest('hex')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeEqualHex(a, b) {
|
|
35
|
+
const left = String(a ?? '')
|
|
36
|
+
const right = String(b ?? '')
|
|
37
|
+
if (!left || left.length !== right.length) return false
|
|
38
|
+
try {
|
|
39
|
+
return crypto.timingSafeEqual(Buffer.from(left, 'hex'), Buffer.from(right, 'hex'))
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} [options]
|
|
47
|
+
* @param {string} [options.staticToken] 配置里的长期 token(可选)
|
|
48
|
+
* @param {number} [options.rotateMs] 自动轮换周期(0 = 关闭)
|
|
49
|
+
* @param {number} [options.graceMs] 旧 token 宽限
|
|
50
|
+
* @param {string} [options.dataDir] 持久化目录(空 = 仅内存)
|
|
51
|
+
* @param {boolean} [options.persist=true]
|
|
52
|
+
* @param {() => number} [options.clock]
|
|
53
|
+
*/
|
|
54
|
+
export function createBridgeTokenManager(options = {}) {
|
|
55
|
+
const staticToken = String(options.staticToken ?? '').trim()
|
|
56
|
+
const rotateMs = Math.max(0, Number(options.rotateMs ?? DEFAULT_ROTATE_MS) || 0)
|
|
57
|
+
const graceMs = Math.max(0, Number(options.graceMs ?? DEFAULT_GRACE_MS) || 0)
|
|
58
|
+
const dataDir = String(options.dataDir ?? '').trim()
|
|
59
|
+
const persist = dataDir !== '' && options.persist !== false
|
|
60
|
+
const auditLimit = Math.max(10, Number(options.auditLimit ?? DEFAULT_AUDIT_LIMIT))
|
|
61
|
+
const clock = typeof options.clock === 'function' ? options.clock : () => Date.now()
|
|
62
|
+
|
|
63
|
+
/** @type {{id:string,digest:string,issuedAt:number,retireAt:number|null}[]} */
|
|
64
|
+
let entries = []
|
|
65
|
+
const auditLog = []
|
|
66
|
+
const counters = { issued: 0, rotated: 0, verified: 0, rejected: 0, expired: 0 }
|
|
67
|
+
let timer = null
|
|
68
|
+
let lastRotatedAt = 0
|
|
69
|
+
|
|
70
|
+
const storageFile = persist ? path.join(dataDir, 'bridge-tokens.json') : ''
|
|
71
|
+
const auditFile = persist ? path.join(dataDir, 'token-audit.jsonl') : ''
|
|
72
|
+
|
|
73
|
+
const audit = (action, detail = {}) => {
|
|
74
|
+
const record = Object.freeze({ at: nowOf(clock), scope: 'bridge', action, ...detail })
|
|
75
|
+
auditLog.push(record)
|
|
76
|
+
if (auditLog.length > auditLimit) auditLog.splice(0, auditLog.length - auditLimit)
|
|
77
|
+
if (auditFile) {
|
|
78
|
+
try {
|
|
79
|
+
fs.mkdirSync(path.dirname(auditFile), { recursive: true })
|
|
80
|
+
fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', { mode: 0o600 })
|
|
81
|
+
} catch { /* 审计落盘失败不影响鉴权 */ }
|
|
82
|
+
}
|
|
83
|
+
return record
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const persistNow = () => {
|
|
87
|
+
if (!storageFile) return
|
|
88
|
+
try {
|
|
89
|
+
fs.mkdirSync(path.dirname(storageFile), { recursive: true })
|
|
90
|
+
const payload = { version: 1, savedAt: nowOf(clock), entries }
|
|
91
|
+
fs.writeFileSync(storageFile, JSON.stringify(payload, null, 2), { mode: 0o600 })
|
|
92
|
+
} catch { /* 落盘失败:退化为内存态 */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const loadPersisted = () => {
|
|
96
|
+
if (!storageFile) return
|
|
97
|
+
try {
|
|
98
|
+
const raw = fs.readFileSync(storageFile, 'utf8')
|
|
99
|
+
const parsed = JSON.parse(raw)
|
|
100
|
+
if (Array.isArray(parsed?.entries)) {
|
|
101
|
+
entries = parsed.entries
|
|
102
|
+
.filter((item) => typeof item?.digest === 'string' && /^[0-9a-f]{64}$/.test(item.digest))
|
|
103
|
+
.map((item) => ({
|
|
104
|
+
id: String(item.id ?? item.digest.slice(0, 8)),
|
|
105
|
+
digest: item.digest,
|
|
106
|
+
issuedAt: Number(item.issuedAt ?? 0),
|
|
107
|
+
retireAt: item.retireAt == null ? null : Number(item.retireAt),
|
|
108
|
+
}))
|
|
109
|
+
audit('load', { count: entries.length })
|
|
110
|
+
}
|
|
111
|
+
} catch { /* 首次运行或文件损坏:从空开始 */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const issue = (detail = {}) => {
|
|
115
|
+
const token = crypto.randomBytes(24).toString('hex')
|
|
116
|
+
const digest = digestOf(token)
|
|
117
|
+
const entry = { id: digest.slice(0, 8), digest, issuedAt: nowOf(clock), retireAt: null }
|
|
118
|
+
entries.push(entry)
|
|
119
|
+
counters.issued += 1
|
|
120
|
+
audit('issue', { tokenId: entry.id, ...detail })
|
|
121
|
+
persistNow()
|
|
122
|
+
return { token, tokenId: entry.id, issuedAt: entry.issuedAt }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const sweep = () => {
|
|
126
|
+
const now = nowOf(clock)
|
|
127
|
+
const kept = []
|
|
128
|
+
let removed = 0
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (entry.retireAt !== null && now >= entry.retireAt) {
|
|
131
|
+
removed += 1
|
|
132
|
+
counters.expired += 1
|
|
133
|
+
audit('expire', { tokenId: entry.id })
|
|
134
|
+
} else {
|
|
135
|
+
kept.push(entry)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (removed > 0) {
|
|
139
|
+
entries = kept
|
|
140
|
+
persistNow()
|
|
141
|
+
}
|
|
142
|
+
return removed
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 轮换:签发新 token,旧的活动 token 进入宽限期。
|
|
147
|
+
* @param {{reason?:string}} [detail]
|
|
148
|
+
*/
|
|
149
|
+
const rotate = (detail = {}) => {
|
|
150
|
+
const now = nowOf(clock)
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
if (entry.retireAt === null) entry.retireAt = now + graceMs
|
|
153
|
+
}
|
|
154
|
+
const issued = issue({ reason: detail.reason ?? 'auto-rotate' })
|
|
155
|
+
counters.rotated += 1
|
|
156
|
+
lastRotatedAt = now
|
|
157
|
+
rotateCount += 1
|
|
158
|
+
persistNow()
|
|
159
|
+
return { ...issued, graceMs }
|
|
160
|
+
}
|
|
161
|
+
let rotateCount = 0
|
|
162
|
+
|
|
163
|
+
const verify = (token) => {
|
|
164
|
+
const presented = digestOf(token)
|
|
165
|
+
const now = nowOf(clock)
|
|
166
|
+
if (staticToken && safeEqualHex(presented, digestOf(staticToken))) {
|
|
167
|
+
counters.verified += 1
|
|
168
|
+
audit('verify-ok', { tokenId: 'static', source: 'static' })
|
|
169
|
+
return { ok: true, tokenId: 'static', source: 'static' }
|
|
170
|
+
}
|
|
171
|
+
let expiredMatch = null
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (!safeEqualHex(entry.digest, presented)) continue
|
|
174
|
+
if (entry.retireAt !== null && now >= entry.retireAt) {
|
|
175
|
+
expiredMatch = entry
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
counters.verified += 1
|
|
179
|
+
audit('verify-ok', { tokenId: entry.id, source: entry.retireAt === null ? 'active' : 'grace' })
|
|
180
|
+
return { ok: true, tokenId: entry.id, source: entry.retireAt === null ? 'active' : 'grace' }
|
|
181
|
+
}
|
|
182
|
+
counters.rejected += 1
|
|
183
|
+
const reason = expiredMatch ? 'token-expired' : presented === digestOf('') ? 'token-missing' : 'token-unknown'
|
|
184
|
+
audit('verify-fail', { reason, ...(expiredMatch ? { tokenId: expiredMatch.id } : {}) })
|
|
185
|
+
return { ok: false, reason }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const describe = () => {
|
|
189
|
+
const now = nowOf(clock)
|
|
190
|
+
return {
|
|
191
|
+
enabled: Boolean(staticToken) || entries.length > 0,
|
|
192
|
+
staticToken: Boolean(staticToken),
|
|
193
|
+
rotateMs,
|
|
194
|
+
graceMs,
|
|
195
|
+
autoRotate: rotateMs > 0,
|
|
196
|
+
persist,
|
|
197
|
+
tokens: entries.map((entry) => ({
|
|
198
|
+
tokenId: entry.id,
|
|
199
|
+
issuedAt: entry.issuedAt,
|
|
200
|
+
retiringAt: entry.retireAt,
|
|
201
|
+
state: entry.retireAt === null ? 'active' : (now >= entry.retireAt ? 'expired' : 'retiring'),
|
|
202
|
+
})),
|
|
203
|
+
nextRotationAt: rotateMs > 0 ? (lastRotatedAt || now) + rotateMs : null,
|
|
204
|
+
lastRotatedAt: lastRotatedAt || null,
|
|
205
|
+
rotations: rotateCount,
|
|
206
|
+
counts: { ...counters },
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const start = () => {
|
|
211
|
+
if (timer || rotateMs <= 0) return false
|
|
212
|
+
timer = setInterval(() => {
|
|
213
|
+
try { sweep(); rotate({ reason: 'scheduled' }) } catch { /* 定时轮换失败不致命 */ }
|
|
214
|
+
}, rotateMs)
|
|
215
|
+
timer.unref?.()
|
|
216
|
+
audit('schedule', { rotateMs, graceMs })
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const stop = () => {
|
|
221
|
+
if (timer) { clearInterval(timer); timer = null; return true }
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
loadPersisted()
|
|
226
|
+
// 有静态 token 或已有持久化 token 时,启动自动轮换才有意义。
|
|
227
|
+
if ((staticToken || entries.length > 0) && rotateMs > 0 && options.autoStart !== false) start()
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
issue, verify, rotate, sweep, describe, start, stop,
|
|
231
|
+
audit: (limit = 50) => auditLog.slice(-Math.max(1, Number(limit) || 50)),
|
|
232
|
+
staticToken: () => staticToken,
|
|
233
|
+
/** 一次性接管:轮换后返回新 token(供运维/宿主下发)。 */
|
|
234
|
+
rotateAndGet: (detail = {}) => rotate(detail).token,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Router → 瓮城 DerivedManager 的派发适配(v3.2.4)。
|
|
7
|
+
*
|
|
8
|
+
* 优先走独立契约 SDK(`@twinsearth/dsh-fang-contract` 的内嵌副本):
|
|
9
|
+
* local(同 Host 的 fangContract 服务)→ http(/fang/api/contract/*)
|
|
10
|
+
* → file(文件 outbox 兜底)→ legacy(3.1.x 旧服务键)。
|
|
11
|
+
* 若契约全部失败,再回退到本包自带的 local forker / 母体 bridge(兼容旧部署)。
|
|
12
|
+
* 所有路径只传 Ticket + 受限 DSH_MASTER_* env;缺能力时 fail-closed。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { createContractClient } from '../contract/index.js'
|
|
18
|
+
import { FANG_SERVICE, masterError, safeGet } from '../kernel.ts'
|
|
19
|
+
|
|
20
|
+
const BRIDGE_PATH = '/dsh-master/api/bridge/spawn'
|
|
21
|
+
const ENV_PREFIX = 'DSH_MASTER_'
|
|
22
|
+
|
|
23
|
+
function asString(value) { return String(value ?? '').trim() }
|
|
24
|
+
|
|
25
|
+
function filterEnv(env) {
|
|
26
|
+
const out = {}
|
|
27
|
+
if (!env || typeof env !== 'object') return out
|
|
28
|
+
for (const [key, value] of Object.entries(env)) {
|
|
29
|
+
if (!String(key).startsWith(ENV_PREFIX)) continue
|
|
30
|
+
if (typeof value !== 'string') continue
|
|
31
|
+
out[String(key)] = value.slice(0, 32_000)
|
|
32
|
+
}
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} ctx
|
|
38
|
+
* @param {{dispatch?:{
|
|
39
|
+
* homeDir?:string, cliPath?:string, profile?:string,
|
|
40
|
+
* motherUrl?:string, motherToken?:string,
|
|
41
|
+
* contract?:boolean, contractFileDir?:string, contractOrder?:string[],
|
|
42
|
+
* contractTimeoutMs?:number, contractFileTimeoutMs?:number,
|
|
43
|
+
* }}} [config]
|
|
44
|
+
* @param {{fetchImpl?:typeof fetch, getOrigin?:()=>string}} [options]
|
|
45
|
+
*/
|
|
46
|
+
export function createFangDispatcher(ctx, config = {}, options = {}) {
|
|
47
|
+
const raw = config.dispatch && typeof config.dispatch === 'object' ? config.dispatch : {}
|
|
48
|
+
const homeDir = asString(raw.homeDir)
|
|
49
|
+
const cliPath = asString(raw.cliPath)
|
|
50
|
+
const profile = asString(raw.profile) || 'default'
|
|
51
|
+
const motherUrl = asString(raw.motherUrl).replace(/\/+$/, '')
|
|
52
|
+
const motherToken = asString(raw.motherToken)
|
|
53
|
+
const motherAuthToken = asString(raw.motherAuthToken)
|
|
54
|
+
// v3.2.5:essence 运行态由瓮城注入 DSH_FANG_MOTHER(母体 dataDir);
|
|
55
|
+
// 默认用共享 contract-outbox 作为跨进程 file bridge,无需手工配置 URL。
|
|
56
|
+
const motherDataDir = asString(process.env.DSH_FANG_MOTHER)
|
|
57
|
+
const contractFileDir = asString(raw.contractFileDir) || (motherDataDir ? path.join(motherDataDir, 'contract-outbox') : '')
|
|
58
|
+
// 派发超时必须覆盖母体 fork 预置(provision 常需 10–30s);默认 180s,可由 dispatch 配置覆盖。
|
|
59
|
+
const contractTimeoutMs = Math.max(5_000, Number(raw.contractTimeoutMs ?? 180_000) || 180_000)
|
|
60
|
+
const contractFileTimeoutMs = Math.max(5_000, Number(raw.contractFileTimeoutMs ?? contractTimeoutMs) || contractTimeoutMs)
|
|
61
|
+
const workerEnv = raw.workerEnv && typeof raw.workerEnv === 'object' ? raw.workerEnv : {}
|
|
62
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
63
|
+
const contractEnabled = raw.contract === true || Boolean(contractFileDir)
|
|
64
|
+
const contractClient = contractEnabled
|
|
65
|
+
? createContractClient({
|
|
66
|
+
ctx,
|
|
67
|
+
config: {
|
|
68
|
+
httpBaseUrl: motherUrl,
|
|
69
|
+
httpToken: motherToken,
|
|
70
|
+
httpHeaders: motherAuthToken ? { 'x-fang-auth': motherAuthToken } : undefined,
|
|
71
|
+
fileDir: contractFileDir,
|
|
72
|
+
timeoutMs: contractTimeoutMs,
|
|
73
|
+
fileTimeoutMs: contractFileTimeoutMs,
|
|
74
|
+
order: Array.isArray(raw.contractOrder) ? raw.contractOrder : undefined,
|
|
75
|
+
},
|
|
76
|
+
fetchImpl,
|
|
77
|
+
})
|
|
78
|
+
: null
|
|
79
|
+
|
|
80
|
+
const resolveForker = () => {
|
|
81
|
+
const forker = safeGet(ctx, FANG_SERVICE.forker)
|
|
82
|
+
return forker && typeof forker.spawn === 'function' ? forker : null
|
|
83
|
+
}
|
|
84
|
+
const localReady = () => Boolean(resolveForker() && homeDir && cliPath)
|
|
85
|
+
const bridgeReady = () => Boolean(motherUrl && motherToken)
|
|
86
|
+
const contractReady = () => Boolean(contractClient && contractClient.transports().length > 0)
|
|
87
|
+
const available = () => contractReady() || localReady() || bridgeReady()
|
|
88
|
+
const mode = () => {
|
|
89
|
+
if (contractReady()) return 'contract'
|
|
90
|
+
if (localReady()) return 'local'
|
|
91
|
+
if (bridgeReady()) return 'bridge'
|
|
92
|
+
return 'none'
|
|
93
|
+
}
|
|
94
|
+
const describe = () => ({
|
|
95
|
+
mode: mode(),
|
|
96
|
+
forker: Boolean(resolveForker()),
|
|
97
|
+
homeDir: Boolean(homeDir),
|
|
98
|
+
cliPath: Boolean(cliPath),
|
|
99
|
+
profile,
|
|
100
|
+
bridgeUrl: motherUrl ? `${motherUrl}${BRIDGE_PATH}` : null,
|
|
101
|
+
bridgeConfigured: Boolean(motherUrl && motherToken),
|
|
102
|
+
contract: contractClient ? { ...contractClient.describe(), attempts: contractClient.attempts?.() ?? [] } : null,
|
|
103
|
+
contractFileDir: contractFileDir || null,
|
|
104
|
+
contractTimeoutMs,
|
|
105
|
+
contractFileTimeoutMs,
|
|
106
|
+
workerEnvKeys: Object.keys(workerEnv).filter((key) => String(key).startsWith('WORKER_') || String(key).startsWith('DSH_MASTER_')),
|
|
107
|
+
available: available(),
|
|
108
|
+
reason: available() ? null : (!resolveForker() && !bridgeReady() && !contractReady() ? 'fang-forker-unavailable' : 'dispatch-config-incomplete'),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const baseEnv = (ticket) => {
|
|
112
|
+
const env = {
|
|
113
|
+
DSH_MASTER_TICKET: JSON.stringify(ticket),
|
|
114
|
+
DSH_MASTER_TICKET_ID: String(ticket?.id ?? ''),
|
|
115
|
+
DSH_MASTER_TRUST_MODE: 'auto',
|
|
116
|
+
DSH_MASTER_MANDATE_SCOPE: String(ticket?.mandate?.scope ?? ''),
|
|
117
|
+
}
|
|
118
|
+
for (const [key, value] of Object.entries(workerEnv)) {
|
|
119
|
+
if (!String(key).startsWith('WORKER_') && !String(key).startsWith('DSH_MASTER_')) continue
|
|
120
|
+
if (typeof value === 'string') env[String(key)] = value
|
|
121
|
+
}
|
|
122
|
+
return env
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
available,
|
|
127
|
+
describe,
|
|
128
|
+
/**
|
|
129
|
+
* @param {object} ticket
|
|
130
|
+
* @param {Record<string,string>} [extraEnv] 仅 DSH_MASTER_* 会被下发
|
|
131
|
+
*/
|
|
132
|
+
async dispatch(ticket, extraEnv = {}) {
|
|
133
|
+
const env = { ...baseEnv(ticket), ...filterEnv(extraEnv) }
|
|
134
|
+
let contractFailure = null
|
|
135
|
+
if (contractReady()) {
|
|
136
|
+
const params = {
|
|
137
|
+
homeDir: homeDir || undefined,
|
|
138
|
+
cliPath: cliPath || undefined,
|
|
139
|
+
profile,
|
|
140
|
+
taskTitle: `SuperAgent ${String(ticket?.id ?? '')}`,
|
|
141
|
+
env,
|
|
142
|
+
}
|
|
143
|
+
const result = await contractClient.spawnFork(params, { idempotencyKey: String(ticket?.id ?? '') })
|
|
144
|
+
if (result.ok) {
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
model: `contract:${result.transport}`,
|
|
148
|
+
fork: result.response?.result ?? result.response,
|
|
149
|
+
transport: result.transport,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
contractFailure = result
|
|
153
|
+
}
|
|
154
|
+
if (localReady()) {
|
|
155
|
+
try {
|
|
156
|
+
const fork = await resolveForker().spawn({
|
|
157
|
+
homeDir, cliPath, profile,
|
|
158
|
+
taskTitle: `SuperAgent ${String(ticket?.id ?? '')}`,
|
|
159
|
+
env,
|
|
160
|
+
})
|
|
161
|
+
if (fork && fork.ok === false) return masterError('fang-spawn-failed', 'fangForker.spawn returned an error', fork)
|
|
162
|
+
return { ok: true, model: 'local', fork }
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return masterError('fang-spawn-threw', String(error?.message ?? error))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (bridgeReady()) {
|
|
168
|
+
try {
|
|
169
|
+
const response = await fetchImpl(`${motherUrl}${BRIDGE_PATH}`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${motherToken}` },
|
|
172
|
+
body: JSON.stringify({ ticket, env }),
|
|
173
|
+
})
|
|
174
|
+
const text = await response.text()
|
|
175
|
+
let body = null
|
|
176
|
+
try { body = JSON.parse(text) } catch { body = null }
|
|
177
|
+
if (!response.ok || !body || body.ok !== true) {
|
|
178
|
+
return masterError('bridge-spawn-failed', `mother bridge rejected spawn (HTTP ${response.status})`, body ? { code: body.code ?? null, error: body.error ?? null } : null)
|
|
179
|
+
}
|
|
180
|
+
return { ok: true, model: 'bridge', fork: body.fork }
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return masterError('bridge-spawn-threw', String(error?.message ?? error))
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (contractFailure) {
|
|
186
|
+
return masterError(
|
|
187
|
+
'contract-dispatch-failed',
|
|
188
|
+
'all fang-master contract transports failed and no local/bridge fallback is available',
|
|
189
|
+
{ code: contractFailure.code ?? null, failures: contractFailure.failures ?? null, attempts: contractFailure.attempts ?? null },
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
return masterError('dispatch-unavailable', 'no fang contract transport, local fangForker or mother bridge configured')
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export { BRIDGE_PATH, ENV_PREFIX, filterEnv }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 飞书事件源适配(v3.2.1):challenge / verification token + 归一化为 SourceEvent。
|
|
7
|
+
*
|
|
8
|
+
* 当前只处理明文 `im.message.receive_v1` 文本消息;加密事件(Encrypt Key)在
|
|
9
|
+
* 3.2.2 接入。token 未配置时 fail-closed。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import { truncateText } from './types.ts'
|
|
14
|
+
|
|
15
|
+
/** 飞书 Encrypt Key:SHA-256(key) 作为 AES-256-CBC 密钥,密文前 16 字节为 IV。 */
|
|
16
|
+
export function decryptFeishuPayload(encrypted, encryptKey) {
|
|
17
|
+
const key = String(encryptKey ?? '')
|
|
18
|
+
const value = String(encrypted ?? '')
|
|
19
|
+
if (!key) throw new Error('feishu-encrypt-key-missing')
|
|
20
|
+
if (!value) throw new Error('feishu-encrypt-payload-missing')
|
|
21
|
+
const packed = Buffer.from(value, 'base64')
|
|
22
|
+
if (packed.length <= 16 || packed.length % 16 !== 0) throw new Error('feishu-encrypt-payload-invalid')
|
|
23
|
+
const keyBytes = crypto.createHash('sha256').update(key, 'utf8').digest()
|
|
24
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', keyBytes, packed.subarray(0, 16))
|
|
25
|
+
decipher.setAutoPadding(true)
|
|
26
|
+
const text = Buffer.concat([decipher.update(packed.subarray(16)), decipher.final()]).toString('utf8')
|
|
27
|
+
const parsed = JSON.parse(text)
|
|
28
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('feishu-decrypted-payload-invalid')
|
|
29
|
+
return parsed
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 飞书 verification token 校验(大小写/空白严格比较)。
|
|
34
|
+
* @returns {{ok:boolean, code?:string}}
|
|
35
|
+
*/
|
|
36
|
+
export function verifyFeishuToken(payload, verificationToken) {
|
|
37
|
+
if (!verificationToken) return { ok: false, code: 'feishu-token-missing' }
|
|
38
|
+
const token = String(payload?.header?.token ?? payload?.token ?? '')
|
|
39
|
+
if (!token) return { ok: false, code: 'feishu-token-missing' }
|
|
40
|
+
return token === String(verificationToken) ? { ok: true } : { ok: false, code: 'feishu-token-mismatch' }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 飞书事件归一化。challenge 由 handleFeishuWebhook 处理;本函数只处理消息事件。
|
|
45
|
+
* @param {object} payload
|
|
46
|
+
* @param {{mentionHandles?:string[]}} [options]
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeFeishuEvent(payload, options = {}) {
|
|
49
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
50
|
+
const eventType = String(body.header?.event_type ?? '')
|
|
51
|
+
if (eventType !== 'im.message.receive_v1') return null
|
|
52
|
+
const event = body.event && typeof body.event === 'object' ? body.event : {}
|
|
53
|
+
const message = event.message && typeof event.message === 'object' ? event.message : {}
|
|
54
|
+
if (String(message.message_type ?? '') !== 'text') return null
|
|
55
|
+
let text = ''
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(String(message.content ?? '{}'))
|
|
58
|
+
text = String(parsed?.text ?? '')
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
const content = truncateText(text).trim()
|
|
63
|
+
const actor = String(event.sender?.sender_id?.open_id ?? event.sender?.sender_id?.user_id ?? '').trim()
|
|
64
|
+
const chatId = String(message.chat_id ?? '').trim()
|
|
65
|
+
const messageId = String(message.message_id ?? '').trim()
|
|
66
|
+
if (!content || !actor || !chatId) return null
|
|
67
|
+
const mentionHandles = Array.isArray(options.mentionHandles) && options.mentionHandles.length > 0
|
|
68
|
+
? options.mentionHandles
|
|
69
|
+
: ['@dsh', '@superagent']
|
|
70
|
+
const mentions = Array.isArray(message.mentions) ? message.mentions : []
|
|
71
|
+
return {
|
|
72
|
+
eventId: String(body.header?.event_id ?? messageId),
|
|
73
|
+
sourceId: `feishu:${chatId}`,
|
|
74
|
+
eventType: 'feishu_message',
|
|
75
|
+
actor,
|
|
76
|
+
actorIsBot: String(event.sender?.sender_type ?? '') === 'bot',
|
|
77
|
+
isMention: mentions.length > 0 || mentionHandles.some((handle) => content.includes(handle)),
|
|
78
|
+
content,
|
|
79
|
+
threadId: `feishu:${chatId}:${String(message.thread_id ?? message.root_id ?? messageId)}`,
|
|
80
|
+
repo: '',
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 处理一次飞书 Webhook:token 校验 → challenge 应答 / SourceEvent。
|
|
86
|
+
* @returns {{status:number, body:object}}
|
|
87
|
+
*/
|
|
88
|
+
export function handleFeishuWebhook({ payload, verificationToken, mentionHandles, encryptKey } = {}) {
|
|
89
|
+
let body = payload && typeof payload === 'object' ? payload : {}
|
|
90
|
+
if (typeof body.encrypt === 'string' && body.encrypt) {
|
|
91
|
+
try { body = decryptFeishuPayload(body.encrypt, encryptKey) }
|
|
92
|
+
catch (error) { return { status: 400, body: { ok: false, code: 'feishu-decrypt-failed', error: String(error?.message ?? error) } } }
|
|
93
|
+
}
|
|
94
|
+
if (String(body.type ?? '') === 'url_verification') {
|
|
95
|
+
const verify = verifyFeishuToken(body, verificationToken)
|
|
96
|
+
if (!verify.ok) return { status: 401, body: { ok: false, code: verify.code } }
|
|
97
|
+
return { status: 200, body: { challenge: String(body.challenge ?? '') } }
|
|
98
|
+
}
|
|
99
|
+
const verify = verifyFeishuToken(body, verificationToken)
|
|
100
|
+
if (!verify.ok) return { status: 401, body: { ok: false, code: verify.code } }
|
|
101
|
+
const event = normalizeFeishuEvent(body, { mentionHandles })
|
|
102
|
+
if (!event) return { status: 204, body: { ok: true, ignored: true } }
|
|
103
|
+
return { status: 200, body: { ok: true, event } }
|
|
104
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck — host JS→TS 迁移阶段:运行时行为优先,类型注解逐步补齐(worker/ 仍严格检查)。
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GitHub 事件源适配(v3.2.1):Webhook 签名校验 + 归一化为 SourceEvent。
|
|
7
|
+
*
|
|
8
|
+
* 外部输入不可信:只有 HMAC-SHA256 签名通过、事件类型受支持、正文非空才进入 Router。
|
|
9
|
+
* 本模块不做网络请求(Webhook 由 Router 的 HTTP 路由接收)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
import { truncateText } from './types.ts'
|
|
15
|
+
|
|
16
|
+
export const SUPPORTED_GITHUB_EVENTS = Object.freeze(['issue_comment', 'pull_request_review'])
|
|
17
|
+
|
|
18
|
+
/** 大小写不敏感地取 header(Node 原生 headers 通常已小写)。 */
|
|
19
|
+
function headerOf(headers, name) {
|
|
20
|
+
if (!headers || typeof headers !== 'object') return ''
|
|
21
|
+
const target = String(name).toLowerCase()
|
|
22
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
23
|
+
if (String(key).toLowerCase() === target) {
|
|
24
|
+
if (Array.isArray(value)) return String(value[0] ?? '')
|
|
25
|
+
return String(value ?? '')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return ''
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 校验 GitHub Webhook 的 `X-Hub-Signature-256`。
|
|
33
|
+
* secret 为空时 fail-closed(未配置密钥不允许接收事件)。
|
|
34
|
+
*/
|
|
35
|
+
export function verifyGithubSignature(rawBody, signatureHeader, secret) {
|
|
36
|
+
if (!secret) return { ok: false, code: 'github-secret-missing' }
|
|
37
|
+
const received = String(signatureHeader ?? '').trim()
|
|
38
|
+
if (!received.startsWith('sha256=')) return { ok: false, code: 'signature-missing' }
|
|
39
|
+
const expected = 'sha256=' + crypto.createHmac('sha256', String(secret)).update(rawBody).digest('hex')
|
|
40
|
+
const a = Buffer.from(expected, 'utf8')
|
|
41
|
+
const b = Buffer.from(received, 'utf8')
|
|
42
|
+
if (a.length !== b.length) return { ok: false, code: 'signature-mismatch' }
|
|
43
|
+
return crypto.timingSafeEqual(a, b) ? { ok: true } : { ok: false, code: 'signature-mismatch' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 把 GitHub Webhook payload 归一化为 SourceEvent;不支持的事件返回 null。
|
|
48
|
+
* @param {object} payload JSON body
|
|
49
|
+
* @param {object} headers HTTP headers
|
|
50
|
+
* @param {{mentionHandles?:string[]}} [options]
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeGithubWebhook(payload, headers, options = {}) {
|
|
53
|
+
const eventName = headerOf(headers, 'x-github-event').toLowerCase()
|
|
54
|
+
if (!SUPPORTED_GITHUB_EVENTS.includes(eventName)) return null
|
|
55
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
56
|
+
const repo = String(body.repository?.full_name ?? '').trim()
|
|
57
|
+
const actor = String(body.sender?.login ?? '').trim()
|
|
58
|
+
if (!repo || !actor) return null
|
|
59
|
+
const mentionHandles = Array.isArray(options.mentionHandles) && options.mentionHandles.length > 0
|
|
60
|
+
? options.mentionHandles.map((item) => String(item))
|
|
61
|
+
: ['@dsh', '@superagent']
|
|
62
|
+
const delivery = headerOf(headers, 'x-github-delivery')
|
|
63
|
+
let content = ''
|
|
64
|
+
let threadId = ''
|
|
65
|
+
let eventType = ''
|
|
66
|
+
if (eventName === 'issue_comment') {
|
|
67
|
+
if (String(body.action ?? '') !== 'created') return null
|
|
68
|
+
content = truncateText(body.comment?.body)
|
|
69
|
+
const number = body.issue?.number
|
|
70
|
+
if (number === undefined || number === null) return null
|
|
71
|
+
threadId = `${repo}#${number}`
|
|
72
|
+
eventType = 'issue_comment'
|
|
73
|
+
} else if (eventName === 'pull_request_review') {
|
|
74
|
+
if (String(body.action ?? '') !== 'submitted') return null
|
|
75
|
+
content = truncateText(body.review?.body)
|
|
76
|
+
const number = body.pull_request?.number
|
|
77
|
+
if (number === undefined || number === null) return null
|
|
78
|
+
threadId = `${repo}#${number}`
|
|
79
|
+
eventType = 'pull_request_review'
|
|
80
|
+
}
|
|
81
|
+
if (!content.trim()) return null
|
|
82
|
+
return {
|
|
83
|
+
eventId: delivery || `gh:${threadId}:${String(body.comment?.id ?? body.review?.id ?? '')}`,
|
|
84
|
+
sourceId: `github:${repo}`,
|
|
85
|
+
eventType,
|
|
86
|
+
actor,
|
|
87
|
+
actorIsBot: String(body.sender?.type ?? '') === 'Bot',
|
|
88
|
+
isMention: mentionHandles.some((handle) => content.includes(handle)),
|
|
89
|
+
content,
|
|
90
|
+
threadId,
|
|
91
|
+
repo,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 处理一次 GitHub Webhook:签名 → 归一化 → SourceEvent(或明确错误)。
|
|
97
|
+
* @returns {{status:number, body:object}}
|
|
98
|
+
*/
|
|
99
|
+
export function handleGithubWebhook({ rawBody, headers, secret, mentionHandles } = {}) {
|
|
100
|
+
const verify = verifyGithubSignature(rawBody ?? '', headerOf(headers, 'x-hub-signature-256'), secret)
|
|
101
|
+
if (!verify.ok) return { status: 401, body: { ok: false, code: verify.code } }
|
|
102
|
+
let payload = null
|
|
103
|
+
try {
|
|
104
|
+
payload = JSON.parse(String(rawBody ?? '{}'))
|
|
105
|
+
} catch {
|
|
106
|
+
return { status: 400, body: { ok: false, code: 'invalid-json' } }
|
|
107
|
+
}
|
|
108
|
+
const event = normalizeGithubWebhook(payload, headers, { mentionHandles })
|
|
109
|
+
if (!event) return { status: 204, body: { ok: true, ignored: true } }
|
|
110
|
+
return { status: 200, body: { ok: true, event } }
|
|
111
|
+
}
|