@twinsearth/dsh-master 0.0.0-stage → 3.2.9

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 (97) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +93 -2
  3. package/client.js +334 -0
  4. package/cordis.patch.yml +71 -0
  5. package/dist-host/contract/client.js +82 -0
  6. package/dist-host/contract/constants.js +70 -0
  7. package/dist-host/contract/envelope.js +84 -0
  8. package/dist-host/contract/index.js +17 -0
  9. package/dist-host/contract/negotiate.js +87 -0
  10. package/dist-host/contract/server.js +71 -0
  11. package/dist-host/contract/transports.js +360 -0
  12. package/dist-host/index.js +260 -0
  13. package/dist-host/kernel.js +186 -0
  14. package/dist-host/plugins/cost-optimizer.js +147 -0
  15. package/dist-host/plugins/dual-manager.js +251 -0
  16. package/dist-host/plugins/superagent-router.js +499 -0
  17. package/dist-host/plugins/superagent-worker.js +73 -0
  18. package/dist-host/superagent/bridge-tokens.js +236 -0
  19. package/dist-host/superagent/dispatch.js +198 -0
  20. package/dist-host/superagent/feishu.js +115 -0
  21. package/dist-host/superagent/github.js +119 -0
  22. package/dist-host/superagent/pr-delivery.js +351 -0
  23. package/dist-host/superagent/router.js +219 -0
  24. package/dist-host/superagent/stream-adapter.js +238 -0
  25. package/dist-host/superagent/stream-tokens.js +141 -0
  26. package/dist-host/superagent/ticket-store.js +108 -0
  27. package/dist-host/superagent/trust.js +145 -0
  28. package/dist-host/superagent/types.js +144 -0
  29. package/dist-host/superagent/worker.js +95 -0
  30. package/dist-host/superagent/writeback.js +162 -0
  31. package/dist-worker/agent-loop.js +493 -0
  32. package/dist-worker/errors.js +41 -0
  33. package/dist-worker/github-pr.js +44 -0
  34. package/dist-worker/index.js +323 -0
  35. package/dist-worker/llm-http.js +351 -0
  36. package/dist-worker/main.js +45 -0
  37. package/dist-worker/prompt.js +35 -0
  38. package/dist-worker/sink-http.js +135 -0
  39. package/dist-worker/tools.js +542 -0
  40. package/dist-worker/types.js +14 -0
  41. package/docs/ARCHITECTURE.md +114 -0
  42. package/docs/CHANGELOG.md +389 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/RELEASE-v3.2.8.md +27 -0
  45. package/docs/RELEASE-v3.2.9.md +29 -0
  46. package/docs/REVIEW-v3.2.7.md +121 -0
  47. package/docs/ROADMAP.md +106 -0
  48. package/docs/SECURITY.md +153 -0
  49. package/docs/SMOKE-3.2.5.md +77 -0
  50. package/docs/SMOKE-3.2.7.md +73 -0
  51. package/docs/SUPERAGENT.md +141 -0
  52. package/docs/WORKER-AGENT-LOOP.md +139 -0
  53. package/icon.svg +9 -0
  54. package/locale/en.json +6 -0
  55. package/locale/zh.json +6 -0
  56. package/package.json +79 -4
  57. package/src/contract/client.js +86 -0
  58. package/src/contract/constants.js +79 -0
  59. package/src/contract/envelope.js +83 -0
  60. package/src/contract/index.js +19 -0
  61. package/src/contract/negotiate.js +106 -0
  62. package/src/contract/server.js +77 -0
  63. package/src/contract/transports.js +319 -0
  64. package/src/index.ts +265 -0
  65. package/src/kernel.ts +190 -0
  66. package/src/plugins/cost-optimizer.ts +155 -0
  67. package/src/plugins/dual-manager.ts +252 -0
  68. package/src/plugins/superagent-router.ts +465 -0
  69. package/src/plugins/superagent-worker.ts +85 -0
  70. package/src/superagent/bridge-tokens.ts +236 -0
  71. package/src/superagent/dispatch.ts +197 -0
  72. package/src/superagent/feishu.ts +104 -0
  73. package/src/superagent/github.ts +111 -0
  74. package/src/superagent/pr-delivery.ts +308 -0
  75. package/src/superagent/router.ts +201 -0
  76. package/src/superagent/stream-adapter.ts +184 -0
  77. package/src/superagent/stream-tokens.ts +142 -0
  78. package/src/superagent/ticket-store.ts +104 -0
  79. package/src/superagent/trust.ts +139 -0
  80. package/src/superagent/types.ts +135 -0
  81. package/src/superagent/worker.ts +100 -0
  82. package/src/superagent/writeback.ts +154 -0
  83. package/tsconfig.host.build.json +12 -0
  84. package/tsconfig.host.json +21 -0
  85. package/tsconfig.worker.build.json +13 -0
  86. package/tsconfig.worker.json +17 -0
  87. package/types/superagent.d.ts +99 -0
  88. package/worker/agent-loop.ts +524 -0
  89. package/worker/errors.ts +47 -0
  90. package/worker/github-pr.ts +65 -0
  91. package/worker/index.ts +334 -0
  92. package/worker/llm-http.ts +359 -0
  93. package/worker/main.ts +45 -0
  94. package/worker/prompt.ts +39 -0
  95. package/worker/sink-http.ts +159 -0
  96. package/worker/tools.ts +531 -0
  97. 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
+ }