@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.
Files changed (95) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +89 -2
  3. package/client.js +263 -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 +377 -0
  43. package/docs/INTEROP.md +60 -0
  44. package/docs/REVIEW-v3.2.7.md +121 -0
  45. package/docs/ROADMAP.md +106 -0
  46. package/docs/SECURITY.md +153 -0
  47. package/docs/SMOKE-3.2.5.md +77 -0
  48. package/docs/SMOKE-3.2.7.md +73 -0
  49. package/docs/SUPERAGENT.md +141 -0
  50. package/docs/WORKER-AGENT-LOOP.md +139 -0
  51. package/icon.svg +9 -0
  52. package/locale/en.json +6 -0
  53. package/locale/zh.json +6 -0
  54. package/package.json +79 -4
  55. package/src/contract/client.js +86 -0
  56. package/src/contract/constants.js +79 -0
  57. package/src/contract/envelope.js +83 -0
  58. package/src/contract/index.js +19 -0
  59. package/src/contract/negotiate.js +106 -0
  60. package/src/contract/server.js +77 -0
  61. package/src/contract/transports.js +319 -0
  62. package/src/index.ts +265 -0
  63. package/src/kernel.ts +190 -0
  64. package/src/plugins/cost-optimizer.ts +155 -0
  65. package/src/plugins/dual-manager.ts +252 -0
  66. package/src/plugins/superagent-router.ts +465 -0
  67. package/src/plugins/superagent-worker.ts +85 -0
  68. package/src/superagent/bridge-tokens.ts +236 -0
  69. package/src/superagent/dispatch.ts +197 -0
  70. package/src/superagent/feishu.ts +104 -0
  71. package/src/superagent/github.ts +111 -0
  72. package/src/superagent/pr-delivery.ts +308 -0
  73. package/src/superagent/router.ts +201 -0
  74. package/src/superagent/stream-adapter.ts +184 -0
  75. package/src/superagent/stream-tokens.ts +142 -0
  76. package/src/superagent/ticket-store.ts +104 -0
  77. package/src/superagent/trust.ts +139 -0
  78. package/src/superagent/types.ts +135 -0
  79. package/src/superagent/worker.ts +100 -0
  80. package/src/superagent/writeback.ts +154 -0
  81. package/tsconfig.host.build.json +12 -0
  82. package/tsconfig.host.json +21 -0
  83. package/tsconfig.worker.build.json +13 -0
  84. package/tsconfig.worker.json +17 -0
  85. package/types/superagent.d.ts +99 -0
  86. package/worker/agent-loop.ts +524 -0
  87. package/worker/errors.ts +47 -0
  88. package/worker/github-pr.ts +65 -0
  89. package/worker/index.ts +334 -0
  90. package/worker/llm-http.ts +359 -0
  91. package/worker/main.ts +45 -0
  92. package/worker/prompt.ts +39 -0
  93. package/worker/sink-http.ts +159 -0
  94. package/worker/tools.ts +531 -0
  95. package/worker/types.ts +247 -0
@@ -0,0 +1,499 @@
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
+ * 子插件:SuperAgent Router(v3.2.3)—— 常驻原身(essence)。
6
+ *
7
+ * v3.2.3 增量:
8
+ * * 派发桥:本地 fangForker 或母体 `/dsh-master/api/bridge/spawn`(Bearer bridgeToken);
9
+ * * 真实 streaming:Worker → Router `/dsh-master/api/superagent/worker-events`(per-ticket token),
10
+ * Router 落库 Ticket 状态并在 done 时触发回写(webhook/GitHub comment);
11
+ * * 状态接口暴露事件计数/回写模式,不暴露任何 token。
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { FANG_SERVICE, ROW, SERVICE, bearerOf, detectRole, enabledOf, jsonResponse, readJsonBody, roleAllowed, safeGet, } from "../kernel.js";
16
+ import { createTicketStore } from "../superagent/ticket-store.js";
17
+ import { createFangDispatcher } from "../superagent/dispatch.js";
18
+ import { createSuperagentRouter } from "../superagent/router.js";
19
+ import { createStreamTokenManager } from "../superagent/stream-tokens.js";
20
+ import { createNodeStreamTransport, createStreamBroadcaster, resolveHostStreamAdapter, } from "../superagent/stream-adapter.js";
21
+ import { createWriteback } from "../superagent/writeback.js";
22
+ import { createPrDelivery } from "../superagent/pr-delivery.js";
23
+ export const name = 'dsh-master-superagent-router';
24
+ export const inject = ['dshMaster'];
25
+ export const ROW_ID = ROW.superagentRouter;
26
+ const EVENTS_PATH = '/dsh-master/api/superagent/worker-events';
27
+ const STREAM_PATH = '/dsh-master/api/superagent/stream';
28
+ const STREAM_TOKEN_PATH = '/dsh-master/api/superagent/stream-token';
29
+ const STATUS_PATH = '/dsh-master/api/superagent/status';
30
+ const SESSION_PATH = '/dsh-master/api/superagent/session';
31
+ const PR_PATH = '/dsh-master/api/superagent/pr';
32
+ const GITHUB_PATH = '/dsh-master/api/superagent/github';
33
+ const FEISHU_PATH = '/dsh-master/api/superagent/feishu';
34
+ const MAX_BODY_BYTES = 1024 * 1024;
35
+ const MAX_EVENTS_PER_TICKET = 500;
36
+ async function readRawBody(req, limit = MAX_BODY_BYTES) {
37
+ try {
38
+ const chunks = [];
39
+ let total = 0;
40
+ if (req && typeof req[Symbol.asyncIterator] === 'function') {
41
+ for await (const chunk of req) {
42
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
43
+ total += buffer.length;
44
+ if (total > limit)
45
+ return null;
46
+ chunks.push(buffer);
47
+ }
48
+ }
49
+ else if (req && typeof req.on === 'function') {
50
+ await new Promise((resolve, reject) => {
51
+ req.on('data', (chunk) => {
52
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
53
+ total += buffer.length;
54
+ if (total > limit)
55
+ reject(new Error('body-too-large'));
56
+ else
57
+ chunks.push(buffer);
58
+ });
59
+ req.on('end', resolve);
60
+ req.on('error', reject);
61
+ });
62
+ }
63
+ return Buffer.concat(chunks).toString('utf8');
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * @param {object} ctx
71
+ * @param {object} [config]
72
+ */
73
+ export function apply(ctx, config = {}) {
74
+ if (!enabledOf(config))
75
+ return { skipped: true, reason: 'disabled-by-config' };
76
+ const role = detectRole();
77
+ if (!roleAllowed(ROW_ID, role)) {
78
+ try {
79
+ ctx?.logger?.info?.(`[dsh-master] superagent-router skipped: role=${role} not in essence`);
80
+ }
81
+ catch { /* ignore */ }
82
+ return { skipped: true, reason: `role-not-allowed:${role}` };
83
+ }
84
+ const master = safeGet(ctx, SERVICE.master);
85
+ if (!master || typeof master.registerSubplugin !== 'function') {
86
+ return { skipped: true, reason: 'master-core-unavailable' };
87
+ }
88
+ const store = createTicketStore({ dataDir: config.dataDir, logger: ctx.logger });
89
+ const writeback = createWriteback(config.writeback, { logger: ctx.logger });
90
+ const prDelivery = createPrDelivery({
91
+ token: config.writeback?.githubToken,
92
+ repo: config.writeback?.githubRepo ?? config.prRepo,
93
+ apiBase: config.writeback?.githubApiBase,
94
+ baseBranch: config.writeback?.baseBranch,
95
+ });
96
+ /** Ticket → 3_Sessions 会话映射:threadId 优先,缺省用 ticket id。 */
97
+ const sessionMap = new Map();
98
+ const tokenAuditFile = config.dataDir ? path.join(String(config.dataDir), 'token-audit.jsonl') : '';
99
+ const tokenManager = createStreamTokenManager({
100
+ workerTtlMs: config.streamTokenTtlMs,
101
+ uiTtlMs: config.uiTokenTtlMs,
102
+ auditSink: tokenAuditFile ? (record) => {
103
+ try {
104
+ fs.mkdirSync(path.dirname(tokenAuditFile), { recursive: true });
105
+ fs.appendFileSync(tokenAuditFile, JSON.stringify(record) + '\n', { mode: 0o600 });
106
+ }
107
+ catch { /* 审计落盘失败不影响鉴权 */ }
108
+ } : null,
109
+ });
110
+ // 定时清理过期 token(避免 TTL 之后仍占内存)
111
+ const sweepTimer = setInterval(() => { try {
112
+ tokenManager.sweep();
113
+ }
114
+ catch { /* ignore */ } }, 60_000);
115
+ sweepTimer.unref?.();
116
+ const quotaCfg = {
117
+ enabled: !(config.workerQuota && config.workerQuota.enabled === false),
118
+ maxConcurrent: Math.max(1, Math.floor(Number(config.workerQuota?.maxConcurrent ?? 2))),
119
+ maxPerSource: Math.max(0, Math.floor(Number(config.workerQuota?.maxPerSource ?? 0))),
120
+ };
121
+ const broadcaster = createStreamBroadcaster({ ringSize: 300 });
122
+ const hostAdapter = resolveHostStreamAdapter(ctx);
123
+ if (hostAdapter)
124
+ broadcaster.useHostAdapter(hostAdapter);
125
+ let origin = '';
126
+ const workerEvents = new Map();
127
+ const dispatcher = createFangDispatcher(ctx, config, { getOrigin: () => origin });
128
+ const wrappedDispatcher = {
129
+ available: () => dispatcher.available(),
130
+ describe: () => ({ ...dispatcher.describe(), streaming: Boolean(origin), writeback: writeback.describe().enabled }),
131
+ async dispatch(ticket) {
132
+ // Ticket → 会话仓映射:threadId 是 GitHub/飞书里的会话键,缺失时退回 ticket id。
133
+ const sessionKey = String(ticket?.threadId ?? ticket?.id ?? '');
134
+ sessionMap.set(String(ticket.id), {
135
+ ticketId: String(ticket.id),
136
+ sessionKey,
137
+ sourceId: String(ticket.sourceId ?? ''),
138
+ status: 'dispatched',
139
+ dispatchedAt: Date.now(),
140
+ });
141
+ let extraEnv = {};
142
+ let issued = null;
143
+ if (origin) {
144
+ issued = tokenManager.issue({ kind: 'worker', ticketId: ticket.id });
145
+ extraEnv = {
146
+ DSH_MASTER_STREAM_URL: `${origin}${EVENTS_PATH}`,
147
+ DSH_MASTER_STREAM_TOKEN: issued.token,
148
+ };
149
+ }
150
+ try {
151
+ const out = await dispatcher.dispatch(ticket, extraEnv);
152
+ if (out && out.ok !== true && issued)
153
+ tokenManager.revoke(issued.token, 'dispatch-failed');
154
+ if (out && out.ok !== true) {
155
+ const mapped = sessionMap.get(String(ticket.id));
156
+ if (mapped)
157
+ sessionMap.set(String(ticket.id), { ...mapped, status: 'failed', finishedAt: Date.now(), reason: String(out.code ?? 'dispatch-failed') });
158
+ }
159
+ return out;
160
+ }
161
+ catch (error) {
162
+ if (issued)
163
+ tokenManager.revoke(issued.token, 'dispatch-threw');
164
+ const mapped = sessionMap.get(String(ticket.id));
165
+ if (mapped)
166
+ sessionMap.set(String(ticket.id), { ...mapped, status: 'failed', finishedAt: Date.now(), reason: 'dispatch-threw' });
167
+ throw error;
168
+ }
169
+ },
170
+ };
171
+ const dispatchTimeoutMs = Math.max(60_000, Number(config.dispatchTimeoutMs ?? 15 * 60_000) || 15 * 60_000);
172
+ const WATCHDOG_INTERVAL_MS = 60_000;
173
+ let watchdogSwept = 0;
174
+ const activeTickets = () => store.list({ limit: 5000 }).filter((record) => ['pending', 'dispatched', 'running'].includes(record.status));
175
+ /** 派发超时看护:Worker 长时间无终态 → 判 failed(worker-timeout) 并释放配额。 */
176
+ const watchdogSweep = () => {
177
+ const now = Date.now();
178
+ for (const record of activeTickets()) {
179
+ const status = String(record.status);
180
+ const startedAt = Math.max(Number(record.updatedAt ?? 0), Number(record.ticket?.createdAt ?? 0), Number(workerEvents.get(record.ticket.id)?.at(-1)?.at ?? 0)) || now;
181
+ const age = now - startedAt;
182
+ if (age < dispatchTimeoutMs)
183
+ continue;
184
+ store.setStatus(record.ticket.id, 'failed', { code: 'worker-timeout', ageMs: age });
185
+ const mapped = sessionMap.get(record.ticket.id);
186
+ if (mapped)
187
+ sessionMap.set(record.ticket.id, { ...mapped, status: 'failed', finishedAt: now, reason: 'worker-timeout' });
188
+ watchdogSwept += 1;
189
+ broadcast({ type: 'timeout', ticketId: record.ticket.id, at: now, payload: { code: 'worker-timeout', ageMs: age } });
190
+ }
191
+ };
192
+ const watchdogTimer = setInterval(() => { try {
193
+ watchdogSweep();
194
+ }
195
+ catch { /* 看护失败不致命 */ } }, WATCHDOG_INTERVAL_MS);
196
+ watchdogTimer.unref?.();
197
+ const quotaStatus = () => {
198
+ const active = activeTickets();
199
+ const bySource = {};
200
+ for (const record of active)
201
+ bySource[record.ticket.sourceId] = (bySource[record.ticket.sourceId] ?? 0) + 1;
202
+ return { ...quotaCfg, active: active.length, bySource, remaining: Math.max(0, quotaCfg.maxConcurrent - active.length) };
203
+ };
204
+ const beforeDispatch = async (ticket) => {
205
+ if (!quotaCfg.enabled)
206
+ return { ok: true };
207
+ const active = activeTickets();
208
+ if (active.length >= quotaCfg.maxConcurrent) {
209
+ return { ok: false, reason: 'worker-quota-exceeded', quota: { ...quotaStatus(), limit: quotaCfg.maxConcurrent } };
210
+ }
211
+ if (quotaCfg.maxPerSource > 0) {
212
+ const sameSource = active.filter((record) => record.ticket.sourceId === ticket.sourceId).length;
213
+ if (sameSource >= quotaCfg.maxPerSource) {
214
+ return { ok: false, reason: 'worker-quota-per-source-exceeded', quota: { ...quotaStatus(), perSourceLimit: quotaCfg.maxPerSource } };
215
+ }
216
+ }
217
+ return { ok: true, quota: quotaStatus() };
218
+ };
219
+ const broadcast = (event) => broadcaster.publish(event);
220
+ const router = createSuperagentRouter({ config, store, dispatcher: wrappedDispatcher, logger: ctx.logger, beforeDispatch });
221
+ const hasForker = () => Boolean(safeGet(ctx, FANG_SERVICE.forker));
222
+ const appendEvents = (ticketId, events) => {
223
+ const list = workerEvents.get(ticketId) ?? [];
224
+ for (const event of events) {
225
+ const enriched = { ...event, ticketId, at: event?.at ?? Date.now() };
226
+ list.push(enriched);
227
+ broadcast(enriched);
228
+ }
229
+ if (list.length > MAX_EVENTS_PER_TICKET)
230
+ list.splice(0, list.length - MAX_EVENTS_PER_TICKET);
231
+ workerEvents.set(ticketId, list);
232
+ return list;
233
+ };
234
+ const handleWorkerEvents = async (ticketId, events, token = '') => {
235
+ appendEvents(ticketId, events);
236
+ const done = [...events].reverse().find((event) => event && event.type === 'done');
237
+ if (!done)
238
+ return;
239
+ const result = done.payload?.result ?? null;
240
+ store.setStatus(ticketId, result?.ok === true ? 'done' : 'failed', { result });
241
+ const record = store.get(ticketId);
242
+ if (record) {
243
+ try {
244
+ await writeback.send({ ticket: record.ticket, result });
245
+ }
246
+ catch { /* 回写失败不影响状态 */ }
247
+ }
248
+ // 会话映射进入终态(3_Sessions 侧据此归档/回放)。
249
+ const mapped = sessionMap.get(ticketId) ?? { ticketId };
250
+ sessionMap.set(ticketId, { ...mapped, status: result?.ok === true ? 'done' : 'failed', finishedAt: Date.now() });
251
+ // PR 交付:分支由宿主/分身推好后,Router 用宿主 token 开 PR(Worker 永不持凭据)。
252
+ const pr = result?.pr ?? null;
253
+ if (pr && prDelivery.enabled && pr.branch) {
254
+ const files = Array.isArray(pr.files) ? pr.files : [];
255
+ const created = files.length > 0
256
+ ? await prDelivery.deliver({
257
+ ticketId,
258
+ source: record?.ticket?.sourceId ?? '',
259
+ branch: String(pr.branch),
260
+ title: String(pr.title ?? `SuperAgent: ${ticketId}`),
261
+ body: String(pr.body ?? ''),
262
+ base: pr.base,
263
+ draft: pr.draft === true,
264
+ files,
265
+ })
266
+ : await prDelivery.create({
267
+ branch: String(pr.branch),
268
+ title: String(pr.title ?? `SuperAgent: ${ticketId}`),
269
+ body: String(pr.body ?? ''),
270
+ base: pr.base,
271
+ draft: pr.draft === true,
272
+ });
273
+ broadcast({ type: 'pr', ticketId, at: Date.now(), payload: created });
274
+ }
275
+ if (token)
276
+ tokenManager.revoke(token, 'ticket-done');
277
+ };
278
+ const service = {
279
+ id: 'superagent-router',
280
+ version: '3.2.5',
281
+ role,
282
+ status: () => ({
283
+ ...router.status(),
284
+ forkerService: hasForker(),
285
+ streaming: { configured: Boolean(origin), ...broadcaster.describe(), tokens: tokenManager.counts() },
286
+ workerEvents: Object.fromEntries([...workerEvents.entries()].map(([k, v]) => [k, v.length])),
287
+ writeback: writeback.describe(),
288
+ quota: quotaStatus(),
289
+ sessions: { count: sessionMap.size, last: [...sessionMap.values()].slice(-3) },
290
+ watchdog: { intervalMs: WATCHDOG_INTERVAL_MS, timeoutMs: dispatchTimeoutMs, swept: watchdogSwept },
291
+ pr: prDelivery.describe(),
292
+ tokenAudit: tokenManager.audit(10),
293
+ }),
294
+ ingest: (event) => router.ingest(event),
295
+ ingestGithubWebhook: (input) => router.ingestGithubWebhook(input),
296
+ ingestFeishuWebhook: (input) => router.ingestFeishuWebhook(input),
297
+ tickets: (limit) => router.tickets(limit),
298
+ recent: (limit) => router.recent(limit),
299
+ policy: () => router.policy(),
300
+ setTrustMode: (mode) => router.setTrustMode(mode),
301
+ workerEvents(ticketId) {
302
+ return { ok: true, ticketId: String(ticketId ?? ''), events: workerEvents.get(String(ticketId ?? '')) ?? [] };
303
+ },
304
+ tokenAudit(limit) { return tokenManager.audit(limit); },
305
+ rotateStreamToken(oldToken = '') { return tokenManager.rotate(oldToken, { kind: 'ui' }); },
306
+ quota: () => quotaStatus(),
307
+ close() {
308
+ try {
309
+ clearInterval(sweepTimer);
310
+ }
311
+ catch { /* ignore */ }
312
+ try {
313
+ clearInterval(watchdogTimer);
314
+ }
315
+ catch { /* ignore */ }
316
+ broadcaster.closeAll();
317
+ },
318
+ };
319
+ try {
320
+ ctx.effect(() => ctx.provide(SERVICE.superagentRouter, service));
321
+ }
322
+ catch { /* fake ctx */ }
323
+ try {
324
+ ctx.effect(() => () => service.close());
325
+ }
326
+ catch { /* fake ctx */ }
327
+ master.registerSubplugin({
328
+ id: 'superagent-router',
329
+ name: 'dsh-master-superagent-router',
330
+ capabilities: ['webhook-ingest', 'trust-eval', 'ticket-mandate', 'worker-dispatch', 'worker-stream', 'writeback'],
331
+ description: 'SuperAgent Router(仅原身;只派发不执行)',
332
+ });
333
+ // HTTP:Webhook 入口 + Worker 实时事件 + 只读状态。
334
+ try {
335
+ if (typeof ctx.inject === 'function') {
336
+ ctx.inject(['webServer'], (child) => {
337
+ const webServer = child?.get?.('webServer') ?? child?.webServer;
338
+ if (!webServer || typeof webServer.register !== 'function')
339
+ return;
340
+ try {
341
+ origin = typeof webServer.getOrigin === 'function' ? String(webServer.getOrigin() ?? '').replace(/\/+$/, '') : '';
342
+ }
343
+ catch {
344
+ origin = '';
345
+ }
346
+ const register = (spec) => { try {
347
+ webServer.register(spec);
348
+ }
349
+ catch { /* duplicate route */ } };
350
+ register({ kind: 'exact', path: STATUS_PATH, method: 'GET', handler: async (req, res) => jsonResponse(res, 200, service.status()) });
351
+ // DSH webServer 的路由键是 (kind,path),不包含 HTTP method;同一路径注册两次
352
+ // 会抛 duplicate,旧实现把第二次注册吞掉,导致 GET 实际落进 POST handler。
353
+ // 因此 EVENTS_PATH 只注册一次,在 handler 内显式分流。
354
+ register({
355
+ kind: 'exact', path: EVENTS_PATH, method: 'ANY',
356
+ handler: async (req, res) => {
357
+ const method = String(req?.method ?? 'POST').toUpperCase();
358
+ if (method === 'POST') {
359
+ const token = bearerOf(req?.headers ?? {});
360
+ const parsed = await readJsonBody(req);
361
+ if (!parsed.ok)
362
+ return jsonResponse(res, parsed.code === 'body-too-large' ? 413 : 400, { ok: false, code: parsed.code });
363
+ const ticketId = String(parsed.body?.ticketId ?? '');
364
+ const verified = tokenManager.verify(token, { kind: 'worker' });
365
+ if (!verified.ok)
366
+ return jsonResponse(res, 401, { ok: false, code: 'worker-token-invalid', reason: verified.reason });
367
+ if (!ticketId || ticketId !== verified.entry.ticketId)
368
+ return jsonResponse(res, 403, { ok: false, code: 'ticket-token-mismatch' });
369
+ const events = Array.isArray(parsed.body?.events) ? parsed.body.events.slice(0, 100) : [];
370
+ await handleWorkerEvents(ticketId, events, token);
371
+ return jsonResponse(res, 200, { ok: true, accepted: events.length });
372
+ }
373
+ if (method === 'GET') {
374
+ const query = new URL(String(req?.url ?? '/'), 'http://x').searchParams;
375
+ const ticketId = String(query.get('ticketId') ?? '');
376
+ const supplied = String(query.get('token') ?? '') || bearerOf(req?.headers ?? {});
377
+ const verified = tokenManager.verify(supplied);
378
+ if (!verified.ok)
379
+ return jsonResponse(res, 401, { ok: false, code: 'events-token-invalid', reason: verified.reason });
380
+ if (verified.entry.kind === 'worker' && verified.entry.ticketId !== ticketId)
381
+ return jsonResponse(res, 403, { ok: false, code: 'ticket-token-mismatch' });
382
+ return jsonResponse(res, 200, service.workerEvents(ticketId));
383
+ }
384
+ return jsonResponse(res, 405, { ok: false, code: 'method-not-allowed' });
385
+ },
386
+ });
387
+ // v3.2.5:浏览器 SSE 订阅 token(短 TTL、可轮换);不暴露 worker token。
388
+ register({
389
+ kind: 'exact', path: STREAM_TOKEN_PATH, method: 'POST',
390
+ handler: async (req, res) => {
391
+ const parsed = await readJsonBody(req);
392
+ const oldToken = parsed.ok ? String(parsed.body?.token ?? '') : '';
393
+ if (oldToken) {
394
+ const issued = tokenManager.rotate(oldToken, { kind: 'ui' });
395
+ if (!issued.ok || !issued.token)
396
+ return jsonResponse(res, 401, { ok: false, code: 'stream-token-rotate-failed', reason: issued.error ?? 'invalid-token' });
397
+ return jsonResponse(res, 200, { ok: true, token: issued.token, expiresAt: issued.expiresAt });
398
+ }
399
+ // 首次签发只允许本机回环或受信浏览器同源;显式 bootstrap token 可用于受控远程部署。
400
+ const remote = String(req?.socket?.remoteAddress ?? req?.connection?.remoteAddress ?? '');
401
+ const loopback = !remote || /^(127\.|::1$|::ffff:127\.)/.test(remote);
402
+ const bootstrap = String(config.streamBootstrapToken ?? '');
403
+ const supplied = bearerOf(req?.headers ?? {});
404
+ if (bootstrap) {
405
+ if (supplied !== bootstrap)
406
+ return jsonResponse(res, 401, { ok: false, code: 'stream-bootstrap-required' });
407
+ }
408
+ else if (!loopback) {
409
+ return jsonResponse(res, 403, { ok: false, code: 'stream-local-only' });
410
+ }
411
+ const issued = tokenManager.issue({ kind: 'ui' });
412
+ return jsonResponse(res, 200, { ok: true, token: issued.token, expiresAt: issued.expiresAt });
413
+ },
414
+ });
415
+ // v3.2.5:实时事件流(SSE)。默认要求 UI token;config.streamTokenRequired=false 可关闭。
416
+ register({
417
+ kind: 'exact', path: STREAM_PATH, method: 'GET',
418
+ handler: async (req, res) => {
419
+ const query = new URL(String(req?.url ?? '/'), 'http://x').searchParams;
420
+ if (config.streamTokenRequired !== false) {
421
+ const verified = tokenManager.verify(String(query.get('token') ?? ''), { kind: 'ui' });
422
+ if (!verified.ok)
423
+ return jsonResponse(res, 401, { ok: false, code: 'stream-token-invalid', reason: verified.reason });
424
+ }
425
+ try {
426
+ res.writeHead?.(200, {
427
+ 'content-type': 'text/event-stream; charset=utf-8',
428
+ 'cache-control': 'no-store',
429
+ connection: 'keep-alive',
430
+ 'x-accel-buffering': 'no',
431
+ });
432
+ }
433
+ catch {
434
+ return jsonResponse(res, 500, { ok: false, code: 'sse-failed' });
435
+ }
436
+ // 传输细节交给 adapter:内置 Node SSE;宿主可注入 WS/自有 SSE。
437
+ const transport = createNodeStreamTransport(res);
438
+ broadcaster.attach(transport, { replay: true });
439
+ const heartbeat = setInterval(() => broadcaster.heartbeat(': ping\n\n'), 15_000);
440
+ const cleanup = () => { clearInterval(heartbeat); transport.end(); broadcaster.detach(transport); };
441
+ transport.onClose(cleanup);
442
+ try {
443
+ req.on?.('close', cleanup);
444
+ }
445
+ catch { /* ignore */ }
446
+ return undefined;
447
+ },
448
+ });
449
+ register({
450
+ kind: 'exact', path: SESSION_PATH, method: 'GET',
451
+ handler: async (req, res) => {
452
+ const query = new URL(String(req?.url ?? '/'), 'http://x').searchParams;
453
+ const ticketId = String(query.get('ticketId') ?? '');
454
+ if (!ticketId)
455
+ return jsonResponse(res, 400, { ok: false, code: 'ticket-id-required' });
456
+ const mapped = sessionMap.get(ticketId);
457
+ if (!mapped)
458
+ return jsonResponse(res, 404, { ok: false, code: 'session-not-found', ticketId });
459
+ return jsonResponse(res, 200, { ok: true, session: mapped });
460
+ },
461
+ });
462
+ register({
463
+ kind: 'exact', path: GITHUB_PATH, method: 'POST',
464
+ handler: async (req, res) => {
465
+ const raw = await readRawBody(req);
466
+ if (raw === null)
467
+ return jsonResponse(res, 413, { ok: false, code: 'body-too-large' });
468
+ const out = await service.ingestGithubWebhook({
469
+ rawBody: raw, headers: req?.headers ?? {}, secret: config.githubSecret, mentionHandles: config.mentionHandles,
470
+ });
471
+ return jsonResponse(res, out.status, out.body);
472
+ },
473
+ });
474
+ register({
475
+ kind: 'exact', path: FEISHU_PATH, method: 'POST',
476
+ handler: async (req, res) => {
477
+ const raw = await readRawBody(req);
478
+ if (raw === null)
479
+ return jsonResponse(res, 413, { ok: false, code: 'body-too-large' });
480
+ let payload = null;
481
+ try {
482
+ payload = JSON.parse(raw);
483
+ }
484
+ catch {
485
+ return jsonResponse(res, 400, { ok: false, code: 'invalid-json' });
486
+ }
487
+ const out = await service.ingestFeishuWebhook({
488
+ payload, verificationToken: config.feishuVerificationToken, mentionHandles: config.mentionHandles, encryptKey: config.feishuEncryptKey,
489
+ });
490
+ return jsonResponse(res, out.status, out.body);
491
+ },
492
+ });
493
+ });
494
+ }
495
+ }
496
+ catch { /* 无 webServer 时仍可通过服务调用 ingest */ }
497
+ return service;
498
+ }
499
+ export { EVENTS_PATH, STREAM_PATH, STREAM_TOKEN_PATH, STATUS_PATH, SESSION_PATH, readRawBody };
@@ -0,0 +1,73 @@
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
+ * 子插件:SuperAgent Worker(v3.2.1)—— 只允许在 fork(分身 VM)。
6
+ *
7
+ * 物理隔离由瓮城负责;本插件只恢复 Router 注入的 Ticket、校验 Mandate 边界、
8
+ * 暴露 Worker 提示词与长任务后台化契约。它不执行命令、不访问网络、不持有母体凭据。
9
+ */
10
+ import { ROW, SERVICE, detectRole, enabledOf, roleAllowed, safeGet, } from "../kernel.js";
11
+ import { publicTicket } from "../superagent/types.js";
12
+ import { createWorkerGuard, planShellCommand, ticketFromEnv, workerPrompt, } from "../superagent/worker.js";
13
+ export const name = 'dsh-master-superagent-worker';
14
+ export const inject = ['dshMaster'];
15
+ export const ROW_ID = ROW.superagentWorker;
16
+ /**
17
+ * @param {object} ctx
18
+ * @param {{enabled?:boolean}} [config]
19
+ */
20
+ export function apply(ctx, config = {}) {
21
+ if (!enabledOf(config))
22
+ return { skipped: true, reason: 'disabled-by-config' };
23
+ const role = detectRole();
24
+ if (!roleAllowed(ROW_ID, role)) {
25
+ try {
26
+ ctx?.logger?.info?.(`[dsh-master] superagent-worker skipped: role=${role} not in fork`);
27
+ }
28
+ catch { /* ignore */ }
29
+ return { skipped: true, reason: `role-not-allowed:${role}` };
30
+ }
31
+ const master = safeGet(ctx, SERVICE.master);
32
+ if (!master || typeof master.registerSubplugin !== 'function') {
33
+ return { skipped: true, reason: 'master-core-unavailable' };
34
+ }
35
+ const restored = ticketFromEnv();
36
+ const guard = restored.ok ? createWorkerGuard(restored.ticket) : null;
37
+ const service = {
38
+ id: 'superagent-worker',
39
+ version: '3.2.5',
40
+ role,
41
+ ready: restored.ok,
42
+ reason: restored.ok ? null : restored.reason,
43
+ ticket: restored.ok ? publicTicket(restored.ticket) : null,
44
+ prompt: () => (restored.ok ? workerPrompt(restored.ticket) : ''),
45
+ assertScope: (scope) => (guard ? guard.assertScope(scope) : { ok: false, code: 'worker-no-ticket' }),
46
+ /** 长任务后台化契约:>5s 的 Shell 命令必须走异步 job。 */
47
+ planShellCommand: (input) => planShellCommand(input),
48
+ status() {
49
+ return {
50
+ ok: true,
51
+ id: 'superagent-worker',
52
+ version: '3.2.5',
53
+ role,
54
+ ready: restored.ok,
55
+ reason: restored.ok ? null : restored.reason,
56
+ ticket: restored.ok ? publicTicket(restored.ticket) : null,
57
+ mandateScope: restored.ok ? restored.ticket.mandate.scope : null,
58
+ longTaskThresholdMs: 5000,
59
+ };
60
+ },
61
+ };
62
+ try {
63
+ ctx.effect(() => ctx.provide(SERVICE.superagentWorker, service));
64
+ }
65
+ catch { /* fake ctx */ }
66
+ master.registerSubplugin({
67
+ id: 'superagent-worker',
68
+ name: 'dsh-master-superagent-worker',
69
+ capabilities: ['ticket-context', 'mandate-scope-guard', 'worker-prompt', 'long-task-plan'],
70
+ description: restored.ok ? 'SuperAgent Worker 已恢复工单(仅 fork)' : 'SuperAgent Worker 待工单(仅 fork)',
71
+ });
72
+ return service;
73
+ }