@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,542 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ /**
4
+ * Worker 的安全工具集(v3.2.3)。
5
+ *
6
+ * v3.2.2 安全审计后的加固:
7
+ * * Shell 使用白名单环境变量(剥离 KEY/TOKEN/SECRET/DSH_*),防密钥回灌模型;
8
+ * * Shell 超时/中止时 kill 整个进程树(Windows taskkill /T /F;POSIX 进程组),
9
+ * 不再出现“工具已返回但后台进程继续跑”;
10
+ * * fs_write_file 的 Mandate 来自每次调用的 context.mandate(而非构造闭包),
11
+ * 并默认拒绝 .git/.dsh/.env 等敏感路径;
12
+ * * 路径 jail + realpath 防 symlink 逃逸保持不变。
13
+ *
14
+ * 仍然坚持:Shell 的高危语义无法用黑名单彻底解决,**VM/容器隔离是主防线**;
15
+ * allowShell 应与“已证明隔离的 VM”绑定(见 docs/SECURITY.md)。
16
+ */
17
+ import fs from 'node:fs/promises';
18
+ import path from 'node:path';
19
+ import { spawn } from 'node:child_process';
20
+ export const LONG_SYNC_COMMAND_THRESHOLD_MS = 5000;
21
+ const MAX_READ_BYTES = 256 * 1024;
22
+ const MAX_WRITE_BYTES = 1024 * 1024;
23
+ const MAX_SHELL_OUTPUT_CHARS = 16_000;
24
+ const SHELL_ENV_ALLOWLIST = new Set([
25
+ 'PATH', 'Path', 'PATHEXT', 'SYSTEMROOT', 'SystemRoot', 'WINDIR', 'COMSPEC', 'ComSpec',
26
+ 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'LANG', 'LC_ALL', 'TZ', 'SHELL',
27
+ ]);
28
+ /** 默认拒绝写入的敏感路径(相对 workspace;可在 config 覆盖)。 */
29
+ export const DEFAULT_DENY_WRITE_PATHS = ['.git', '.dsh', '.env', '.env.local', '.ssh', 'node_modules/.cache'];
30
+ /**
31
+ * 命令级禁锢(v3.2.5 P0-2 纵深防御):
32
+ *
33
+ * 黑名单**不是**安全边界—— type ../x / echo > ../x / sh -c ... / powershell -Command ...
34
+ * 都能绕过字面黑名单。这里在命令串层面硬拒绝能逃出 jail 的构造:
35
+ * * 重定向与管道(> >> < | && || ;):可把输出写到任意路径、可串联任意解释器;
36
+ * * 目录切换(cd / chdir / pushd / popd):一旦 cd 出 jail,后续相对路径全部逃离;
37
+ * * 父目录穿越(..):相对逃逸的主要形式;
38
+ * * 绝对路径(盘符 / 前导斜杠 / ~):jail 外的绝对路径;
39
+ * * 环境变量展开(%VAR% / $VAR / ${VAR}):可拼出密钥名或路径;
40
+ * * 解释器(sh/bash/cmd/powershell/node/python/...):等价任意代码执行。
41
+ *
42
+ * 真正的边界仍是 VM/容器(见 docs/SECURITY.md):本函数只保证「未证明隔离时不可用」。
43
+ */
44
+ const SHELL_INTERPRETERS = new Set([
45
+ 'sh', 'bash', 'zsh', 'dash', 'ksh', 'fish',
46
+ 'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe',
47
+ 'node', 'nodejs', 'python', 'python3', 'perl', 'ruby', 'php', 'deno', 'bun',
48
+ 'wscript', 'cscript', 'mshta', 'rundll32', 'regsvr32', 'certutil', 'bitsadmin',
49
+ ]);
50
+ /** 返回拒绝原因;null 表示通过命令级禁锢检查。 */
51
+ export function shellConfinementViolation(command) {
52
+ const raw = String(command ?? '');
53
+ if (!raw.trim())
54
+ return 'empty-command';
55
+ // 1) 重定向 / 管道 / 串联 / 命令替换
56
+ if (/[<>|&;`]/.test(raw))
57
+ return 'shell-redirection-or-chaining';
58
+ if (/\$\(/.test(raw))
59
+ return 'shell-substitution';
60
+ // 2) 目录切换
61
+ if (/\b(cd|chdir|pushd|popd)\b/i.test(raw))
62
+ return 'shell-cd';
63
+ // 3) 父目录穿越(路径段形式,避免误伤 a..b 文件名)
64
+ if (/(^|[\s"'])\.\.([\\/]|$)/.test(raw))
65
+ return 'shell-parent-traversal';
66
+ // 4) 绝对路径(盘符 / 前导斜杠 / UNC / ~)
67
+ if (/[A-Za-z]:[\\/]/.test(raw))
68
+ return 'shell-absolute-path';
69
+ if (/(^|[\s"'])[\\/]{1,2}[^\s"']/.test(raw))
70
+ return 'shell-absolute-path';
71
+ if (/(^|[\s"'])~[\\/]/.test(raw))
72
+ return 'shell-home-path';
73
+ // 5) 环境变量展开(可能拼出密钥名或 jail 外路径)
74
+ if (/%[A-Za-z_][A-Za-z0-9_]*%/.test(raw))
75
+ return 'shell-env-expansion';
76
+ if (/\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/.test(raw))
77
+ return 'shell-env-expansion';
78
+ // 6) 解释器作为命令词(任意代码执行)
79
+ const firstWord = raw.trim().split(/\s+/)[0].replace(/^["']|["']$/g, '').toLowerCase();
80
+ const base = firstWord.split(/[\\/]/).pop() ?? firstWord;
81
+ if (SHELL_INTERPRETERS.has(base))
82
+ return 'shell-interpreter:' + base;
83
+ // 7) git 危险子命令(含 -C / --git-dir / -c 变体)
84
+ if (/\bgit\b[^;&|]*\b(push|remote|config|credential|submodule)\b/i.test(raw))
85
+ return 'shell-git-write';
86
+ return null;
87
+ }
88
+ /** 危险 Shell 模式(最小黑名单;VM 网络/资源隔离仍是主防线)。 */
89
+ export const DEFAULT_DENY_PATTERNS = [
90
+ /\bsudo\b/i,
91
+ /\bmkfs\b/i,
92
+ /\bdd\s+if=/i,
93
+ /\bshutdown\b/i,
94
+ /\breboot\b/i,
95
+ /\b(curl|wget)\b/i,
96
+ /\b(ssh|scp|nc|ncat|telnet)\b/i,
97
+ /\bchmod\s+777\b/i,
98
+ /\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+\/(\s|$)/i,
99
+ /\bgit\s+push\b/i,
100
+ /\bgh\s+(auth|token)\b/i,
101
+ /\bpowershell(\.exe)?\b.*\s-(enc|e)\b/i,
102
+ /\b(base64\s+-d|frombase64string)\b.*\|\s*(sh|bash|powershell)/i,
103
+ // 解释器 -e/-c 任意代码执行:黑名单挡不住,直接拒绝(VM 内也应尽量少用)。
104
+ /\b(node|python|python3|perl|ruby|php)\b.*\s-(e|c)\b/i,
105
+ ];
106
+ /** 只保留安全环境变量;任何 KEY/TOKEN/SECRET/PASSWORD/AUTH/DSH_* 一律剔除。 */
107
+ export function sanitizeShellEnv(env = process.env) {
108
+ const safe = {};
109
+ for (const [key, value] of Object.entries(env)) {
110
+ if (value === undefined)
111
+ continue;
112
+ if (!SHELL_ENV_ALLOWLIST.has(key))
113
+ continue;
114
+ safe[key] = value;
115
+ }
116
+ // 最小可运行集:没有 PATH 时用系统默认(exec 会在 shell 内置 PATH,但显式更稳)。
117
+ if (!safe.PATH && !safe.Path)
118
+ safe.PATH = process.platform === 'win32' ? 'C:\\Windows\\System32;C:\\Windows' : '/usr/local/bin:/usr/bin:/bin';
119
+ return safe;
120
+ }
121
+ /** 杀掉整棵进程树(Windows 用 taskkill /T;POSIX 用负 pid 信号)。 */
122
+ export function killProcessTree(child, platform = process.platform) {
123
+ const pid = Number(child?.pid);
124
+ if (!Number.isInteger(pid) || pid <= 0)
125
+ return Promise.resolve();
126
+ return new Promise((resolve) => {
127
+ try {
128
+ if (platform === 'win32') {
129
+ const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true });
130
+ killer.on('close', () => resolve());
131
+ killer.on('error', () => resolve());
132
+ }
133
+ else {
134
+ try {
135
+ process.kill(-pid, 'SIGKILL');
136
+ }
137
+ catch { /* already gone */ }
138
+ try {
139
+ process.kill(pid, 'SIGKILL');
140
+ }
141
+ catch { /* already gone */ }
142
+ resolve();
143
+ }
144
+ }
145
+ catch {
146
+ resolve();
147
+ }
148
+ });
149
+ }
150
+ /**
151
+ * 在 workspace 内执行 Shell;超时/中止时杀死进程树并等待退出。
152
+ * 输出上限 2MB(超出 kill),返回值再截断到 16k 字符。
153
+ */
154
+ export function runShell(command, options) {
155
+ return new Promise((resolve) => {
156
+ const child = spawn(command, {
157
+ cwd: options.cwd,
158
+ env: sanitizeShellEnv(options.env ?? process.env),
159
+ shell: true,
160
+ windowsHide: true,
161
+ detached: process.platform !== 'win32',
162
+ });
163
+ let stdout = '';
164
+ let stderr = '';
165
+ let settled = false;
166
+ let timedOut = false;
167
+ let aborted = false;
168
+ let size = 0;
169
+ let killTimer;
170
+ const finish = (code) => {
171
+ if (settled)
172
+ return;
173
+ settled = true;
174
+ clearTimeout(timer);
175
+ if (killTimer)
176
+ clearTimeout(killTimer);
177
+ options.signal.removeEventListener('abort', onAbort);
178
+ // close 表示 stdio 已关闭;只有超时/中止/输出爆量时才需补杀进程树。
179
+ // 不再对每次成功命令都调用 taskkill,也不等待一个可能永远不来的第二次 close。
180
+ resolve({ stdout, stderr, code, timedOut, aborted });
181
+ };
182
+ const killAndSettle = () => {
183
+ void killProcessTree(child);
184
+ // 某些孙进程持有管道时 close 可能迟到;给正常关闭一个短窗口后硬结算。
185
+ if (killTimer)
186
+ clearTimeout(killTimer);
187
+ killTimer = setTimeout(() => finish(null), 500);
188
+ killTimer.unref?.();
189
+ };
190
+ const timer = setTimeout(() => {
191
+ timedOut = true;
192
+ killAndSettle();
193
+ }, Math.max(1, options.timeoutMs));
194
+ const onAbort = () => {
195
+ aborted = true;
196
+ killAndSettle();
197
+ };
198
+ options.signal.addEventListener('abort', onAbort, { once: true });
199
+ child.stdout?.on('data', (chunk) => {
200
+ size += chunk.length;
201
+ if (size > 2 * 1024 * 1024) {
202
+ killAndSettle();
203
+ return;
204
+ }
205
+ if (stdout.length < MAX_SHELL_OUTPUT_CHARS * 2)
206
+ stdout += chunk.toString('utf8');
207
+ });
208
+ child.stderr?.on('data', (chunk) => {
209
+ size += chunk.length;
210
+ if (size > 2 * 1024 * 1024) {
211
+ killAndSettle();
212
+ return;
213
+ }
214
+ if (stderr.length < MAX_SHELL_OUTPUT_CHARS * 2)
215
+ stderr += chunk.toString('utf8');
216
+ });
217
+ child.on('error', (error) => { stderr += `\n${error.message}`; void finish(null); });
218
+ child.on('close', (code) => { void finish(code); });
219
+ });
220
+ }
221
+ /** 解析 Mandate.scope 为写 jail 根;scope 是 threadId/非路径时退回 workspace。 */
222
+ export function resolveMandateRoot(workspace, scope) {
223
+ const root = path.resolve(workspace);
224
+ const raw = String(scope ?? '').trim();
225
+ if (!raw)
226
+ return root;
227
+ // scope 首先是一张 Ticket 的逻辑边界(GitHub issue/飞书 thread),不是路径。
228
+ // 仅当配置明确给出已存在的仓库子目录时才收窄写 jail,绝不把飞书 ID 当成路径。
229
+ if (raw.includes('#') || raw.includes(':'))
230
+ return root;
231
+ const candidate = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw);
232
+ const rel = path.relative(root, candidate);
233
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)))
234
+ return candidate;
235
+ return root;
236
+ }
237
+ function isInside(root, candidate) {
238
+ const rel = path.relative(root, candidate);
239
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
240
+ }
241
+ async function realpathNearest(target) {
242
+ let current = target;
243
+ for (let i = 0; i < 32; i += 1) {
244
+ try {
245
+ return await fs.realpath(current);
246
+ }
247
+ catch {
248
+ const parent = path.dirname(current);
249
+ if (parent === current)
250
+ return current;
251
+ current = parent;
252
+ }
253
+ }
254
+ return target;
255
+ }
256
+ export async function resolveInside(root, target) {
257
+ const text = String(target ?? '').trim();
258
+ if (!text || text.includes('\u0000'))
259
+ throw new Error('invalid-path');
260
+ const base = path.resolve(root);
261
+ const resolved = path.isAbsolute(text) ? path.resolve(text) : path.resolve(base, text);
262
+ if (!isInside(base, resolved))
263
+ throw new Error('path-outside-workspace');
264
+ const realBase = await realpathNearest(base);
265
+ const realTarget = await realpathNearest(resolved);
266
+ if (!isInside(realBase, realTarget))
267
+ throw new Error('path-outside-workspace');
268
+ return resolved;
269
+ }
270
+ function requireString(args, key) {
271
+ const value = args[key];
272
+ if (typeof value !== 'string')
273
+ throw new Error(`argument ${key} must be a string`);
274
+ return value;
275
+ }
276
+ function truncateOutput(text) {
277
+ if (text.length <= MAX_SHELL_OUTPUT_CHARS)
278
+ return text;
279
+ return text.slice(0, MAX_SHELL_OUTPUT_CHARS) + `\n…[输出截断,共 ${text.length} 字符]`;
280
+ }
281
+ function commandDenied(command, patterns) {
282
+ for (const pattern of patterns)
283
+ if (pattern.test(command))
284
+ return String(pattern);
285
+ return null;
286
+ }
287
+ /** 写入前检查敏感相对路径;在 workspace 与 mandateRoot 之间都检查。 */
288
+ function deniedWritePath(workspace, target, denyPaths) {
289
+ const rel = path.relative(workspace, target).split(path.sep).join('/');
290
+ for (const denied of denyPaths) {
291
+ const normalized = denied.replace(/\\/g, '/').replace(/^\/+/, '');
292
+ if (normalized && (rel === normalized || rel.startsWith(normalized + '/')))
293
+ return normalized;
294
+ }
295
+ // v3.2.5 P2:.env 的所有变体(.env、.env.local、.env.production、.env.example…)一律拒绝写入,
296
+ // 避免仅靠枚举漏掉 .env.production 之类。
297
+ const baseName = rel.split('/').pop() ?? '';
298
+ if (/^\.env(\.|$)/i.test(baseName))
299
+ return '.env*';
300
+ return null;
301
+ }
302
+ function createPrTool(client) {
303
+ return {
304
+ name: 'create_pr',
305
+ description: '在允许的仓库创建一个 Pull Request(需要宿主注入 PR 客户端;默认不可用)',
306
+ parameters: {
307
+ type: 'object',
308
+ properties: {
309
+ title: { type: 'string' }, body: { type: 'string' },
310
+ branch: { type: 'string' }, base: { type: 'string' },
311
+ },
312
+ required: ['title', 'body'],
313
+ },
314
+ async execute(args) {
315
+ if (!client)
316
+ return '【系统拒绝】PR 后端未配置;请输出最终报告,由 Router/宿主完成回写。';
317
+ // v3.2.5 P2:分支/base 白名单校验——Worker 不得直接对默认分支开 PR,也不得使用非法分支名。
318
+ const PROTECTED_BASES = ['main', 'master', 'trunk', 'release', 'production', 'stable'];
319
+ const branch = typeof args.branch === 'string' ? args.branch.trim() : '';
320
+ const base = typeof args.base === 'string' && args.base.trim() ? args.base.trim() : undefined;
321
+ if (!branch)
322
+ return '【系统拒绝】create_pr 必须显式提供 branch(分支需由宿主/分身先推送)。';
323
+ if (!/^[A-Za-z0-9._/-]{1,120}$/.test(branch))
324
+ return '【系统拒绝】branch 含非法字符。';
325
+ if (branch.startsWith('-') || branch.includes('..') || branch.includes('//'))
326
+ return '【系统拒绝】branch 不合法。';
327
+ if (PROTECTED_BASES.includes(branch.toLowerCase()))
328
+ return '【系统拒绝】不能直接向受保护分支开 PR(branch=' + branch + ')。';
329
+ if (base && PROTECTED_BASES.includes(base.toLowerCase()) === false)
330
+ return '【系统拒绝】base 必须是受保护主干分支,当前 base=' + base + '。';
331
+ if (base && branch.toLowerCase() === base.toLowerCase())
332
+ return '【系统拒绝】branch 与 base 相同。';
333
+ try {
334
+ const url = await client.createPullRequest({
335
+ title: requireString(args, 'title'),
336
+ body: requireString(args, 'body'),
337
+ branch,
338
+ base,
339
+ });
340
+ return `PR created: ${url}`;
341
+ }
342
+ catch (error) {
343
+ return `创建 PR 失败: ${error instanceof Error ? error.message : String(error)}`;
344
+ }
345
+ },
346
+ };
347
+ }
348
+ export function createSandboxTools(options) {
349
+ const workspace = path.resolve(options.workspace);
350
+ const denyPatterns = [...DEFAULT_DENY_PATTERNS, ...(options.denyPatterns ?? [])];
351
+ const denyWritePaths = [...DEFAULT_DENY_WRITE_PATHS, ...(options.denyWritePaths ?? [])];
352
+ const backgroundAfterMs = Math.max(20, Number(options.backgroundAfterMs ?? LONG_SYNC_COMMAND_THRESHOLD_MS) || LONG_SYNC_COMMAND_THRESHOLD_MS);
353
+ const shellJobs = new Map();
354
+ let shellJobSeq = 0;
355
+ const formatShellResult = (result, timeoutMs) => {
356
+ const stdout = truncateOutput(result.stdout);
357
+ const stderr = truncateOutput(result.stderr);
358
+ if (result.timedOut || result.aborted)
359
+ return `执行报错: 超时(>${timeoutMs}ms)或已中止;进程树已终止\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`;
360
+ return `STDOUT:\n${stdout}\nSTDERR:\n${stderr}`;
361
+ };
362
+ const startShellJob = (command, context) => {
363
+ if (shellJobs.size >= 50) {
364
+ const oldestDone = [...shellJobs.values()].find((job) => job.status === 'done');
365
+ if (oldestDone) {
366
+ if (oldestDone.cleanup)
367
+ clearTimeout(oldestDone.cleanup);
368
+ shellJobs.delete(oldestDone.id);
369
+ }
370
+ else
371
+ throw new Error('too-many-background-shell-jobs');
372
+ }
373
+ const id = `vm-job-${Date.now().toString(36)}-${(++shellJobSeq).toString(36)}`;
374
+ const job = { id, status: 'running', startedAt: Date.now(), timeoutMs: Math.max(1, context.timeoutMs) };
375
+ shellJobs.set(id, job);
376
+ void runShell(command, { cwd: workspace, timeoutMs: Math.max(1, context.timeoutMs), signal: context.signal })
377
+ .then((result) => {
378
+ job.result = result;
379
+ job.status = 'done';
380
+ job.cleanup = setTimeout(() => shellJobs.delete(id), 10 * 60_000);
381
+ job.cleanup.unref?.();
382
+ })
383
+ .catch((error) => {
384
+ job.result = { stdout: '', stderr: String(error instanceof Error ? error.message : error), code: null, timedOut: false, aborted: false };
385
+ job.status = 'done';
386
+ });
387
+ return job;
388
+ };
389
+ return [
390
+ {
391
+ name: 'fs_read_file',
392
+ description: '读取 workspace 内文件的 UTF-8 内容(最大 256KB)',
393
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
394
+ async execute(args) {
395
+ const file = await resolveInside(workspace, requireString(args, 'path'));
396
+ const stat = await fs.stat(file);
397
+ if (!stat.isFile())
398
+ throw new Error('not-a-file');
399
+ if (stat.size > MAX_READ_BYTES)
400
+ throw new Error('file-too-large');
401
+ return await fs.readFile(file, 'utf8');
402
+ },
403
+ },
404
+ {
405
+ name: 'fs_list_dir',
406
+ description: '列出 workspace 内目录条目(最多 200 项)',
407
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
408
+ async execute(args) {
409
+ const dir = await resolveInside(workspace, args.path === undefined ? '.' : requireString(args, 'path'));
410
+ const entries = await fs.readdir(dir, { withFileTypes: true });
411
+ return entries.slice(0, 200).map((entry) => `${entry.isDirectory() ? 'dir ' : 'file'} ${entry.name}`).join('\n') || '(empty)';
412
+ },
413
+ },
414
+ {
415
+ name: 'fs_write_file',
416
+ description: '写入文件(workspace 内且位于 Mandate.scope;拒绝 .git/.env 等敏感路径)',
417
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
418
+ async execute(args, context) {
419
+ // v3.2.3:Mandate 每次从 context 取,避免构造闭包与本次 Ticket 不一致。
420
+ const mandateRoot = resolveMandateRoot(workspace, context.mandate.scope);
421
+ const target = await resolveInside(workspace, requireString(args, 'path'));
422
+ if (!isInside(mandateRoot, target))
423
+ throw new Error('path-outside-mandate');
424
+ const denied = deniedWritePath(workspace, target, denyWritePaths);
425
+ if (denied)
426
+ throw new Error(`sensitive-path-denied:${denied}`);
427
+ const content = requireString(args, 'content');
428
+ if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES)
429
+ throw new Error('content-too-large');
430
+ await fs.mkdir(path.dirname(target), { recursive: true });
431
+ const realParent = await realpathNearest(path.dirname(target));
432
+ if (!isInside(await realpathNearest(mandateRoot), realParent))
433
+ throw new Error('path-outside-mandate');
434
+ await fs.writeFile(target, content, 'utf8');
435
+ return `written ${Buffer.byteLength(content, 'utf8')} bytes to ${path.relative(workspace, target)}`;
436
+ },
437
+ },
438
+ {
439
+ name: 'exec_shell_in_vm',
440
+ description: '在分身 workspace 内执行 Shell(需 Mandate.allowShell;脱敏环境、黑名单、进程树超时)',
441
+ parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
442
+ async execute(args, context) {
443
+ // P0-2 fail-closed:没有隔离证明就不允许 Shell(黑名单不是安全边界)。
444
+ if (options.vmIsolation !== true) {
445
+ return '【系统拒绝】Shell 仅在已证明隔离的 VM/容器内可用;当前运行态未提供隔离证明(DSH_MASTER_VM_ISOLATION=attested)。';
446
+ }
447
+ if (context.mandate.allowShell !== true)
448
+ return '【系统拒绝】当前工单未授权执行 Shell 命令。';
449
+ const command = requireString(args, 'command').trim();
450
+ if (!command)
451
+ throw new Error('command-required');
452
+ const denied = commandDenied(command, denyPatterns);
453
+ if (denied)
454
+ return `【系统拒绝】命令命中安全黑名单(${denied})。`;
455
+ // P0-2:命令级禁锢(重定向/管道/cd/../绝对路径/env 展开/解释器/git 写操作)。
456
+ const escaped = shellConfinementViolation(command);
457
+ if (escaped)
458
+ return `【系统拒绝】命令试图逃出 workspace/Mandate jail(${escaped})。`;
459
+ const job = startShellJob(command, context);
460
+ let timer;
461
+ try {
462
+ return await Promise.race([
463
+ (async () => {
464
+ while (job.status === 'running')
465
+ await new Promise((resolve) => setTimeout(resolve, 10));
466
+ return formatShellResult(job.result, context.timeoutMs);
467
+ })(),
468
+ new Promise((resolve) => {
469
+ timer = setTimeout(() => resolve(`后台任务 ${job.id} 已启动(超过 ${backgroundAfterMs}ms);请调用 vm_job_result 查询,参数 {"jobId":"${job.id}"}。`), backgroundAfterMs);
470
+ timer.unref?.();
471
+ }),
472
+ ]);
473
+ }
474
+ finally {
475
+ if (timer)
476
+ clearTimeout(timer);
477
+ }
478
+ },
479
+ },
480
+ {
481
+ name: 'vm_job_result',
482
+ description: '查询 exec_shell_in_vm 后台作业的终态与输出;参数 jobId',
483
+ parameters: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] },
484
+ async execute(args) {
485
+ const id = requireString(args, 'jobId');
486
+ const job = shellJobs.get(id);
487
+ if (!job)
488
+ return `【系统拒绝】未知或已过期的后台任务: ${id}`;
489
+ if (job.status === 'running')
490
+ return `{"jobId":"${id}","status":"running"}`;
491
+ return formatShellResult(job.result, job.timeoutMs);
492
+ },
493
+ },
494
+ createPrTool(options.prClient),
495
+ ];
496
+ }
497
+ export function createToolRegistry(tools) {
498
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
499
+ return {
500
+ schemas() {
501
+ return tools.map((tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters }));
502
+ },
503
+ async execute(call, context) {
504
+ const tool = byName.get(String(call?.name ?? ''));
505
+ if (!tool)
506
+ return `【系统拒绝】未知工具: ${String(call?.name ?? '')}`;
507
+ let args = {};
508
+ try {
509
+ const parsed = JSON.parse(String(call?.arguments ?? '{}'));
510
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
511
+ args = parsed;
512
+ else
513
+ return '执行报错: 工具参数必须是 JSON object';
514
+ }
515
+ catch (error) {
516
+ return `执行报错: 参数 JSON 解析失败: ${error instanceof Error ? error.message : String(error)}`;
517
+ }
518
+ const controller = new AbortController();
519
+ const timer = setTimeout(() => controller.abort(), Math.max(1, context.timeoutMs));
520
+ const onAbort = () => controller.abort();
521
+ context.signal.addEventListener('abort', onAbort, { once: true });
522
+ try {
523
+ return await Promise.race([
524
+ tool.execute(args, { ...context, signal: controller.signal }),
525
+ new Promise((resolve) => {
526
+ controller.signal.addEventListener('abort', () => resolve(`执行报错: 工具超时或被中止(>${context.timeoutMs}ms)`), { once: true });
527
+ }),
528
+ ]);
529
+ }
530
+ catch (error) {
531
+ return `执行报错: ${error instanceof Error ? error.message : String(error)}`;
532
+ }
533
+ finally {
534
+ clearTimeout(timer);
535
+ context.signal.removeEventListener('abort', onAbort);
536
+ }
537
+ },
538
+ };
539
+ }
540
+ export function isPathInside(root, target) {
541
+ return isInside(path.resolve(root), path.resolve(target));
542
+ }
@@ -0,0 +1,14 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+ /** 默认限制(可被 config/构造参数覆盖,但不可关闭硬上限)。 */
4
+ export const DEFAULT_LIMITS = {
5
+ maxSteps: 15,
6
+ maxTokens: 50_000,
7
+ maxWallClockMs: 15 * 60_000,
8
+ llmTimeoutMs: 120_000,
9
+ toolTimeoutMs: 120_000,
10
+ maxToolCallsPerStep: 4,
11
+ maxIdenticalToolRepeats: 3,
12
+ maxToolResultChars: 8_000,
13
+ maxContextChars: 400_000,
14
+ };
@@ -0,0 +1,114 @@
1
+ # dsh-Master 架构(3.2.8)
2
+
3
+ > 当前实现基线为 3.2.8;本文保留设计演进记录。最新实机证据与剩余限制见 `REVIEW-v3.2.7.md`。
4
+
5
+ ## 1. 为什么是“并行主插件”
6
+
7
+ 瓮城 dsh-Fang 负责**环境**:镜像(母体)、配置与调度(原身/元宝运行态)、隔离生产(分身)。
8
+ 房主 dsh-Master 负责**Agent 组织**:主动响应(SuperAgent Router/Worker)、成本(HarnessMM)、
9
+ 组织协同(PM/PJM)。
10
+
11
+ 两者是并列的 DSH bundle,而不是父子插件:
12
+
13
+ - 各自独立的 `package.json`、`cordis.patch.yml`、版本与发布节奏;
14
+ - 房主 patch 只 `insert` 自己的行,**不覆盖、不修改** fang 的任何行;
15
+ - 协作只走 DSH 服务键(下方契约),房主不 import fang 源码,也不读取 fang profile;
16
+ - 任一协作服务缺失时,房主子插件返回结构化错误(fail-closed),绝不在房主进程本地执行。
17
+
18
+ ## 2. 运行时拓扑(v3.2.1:Router / Worker 双层)
19
+
20
+ ```text
21
+ 外部事件(GitHub / 飞书)
22
+ │ 验签 + 归一化(不可信输入)
23
+ ▼
24
+ ┌──────────────────────────────┐
25
+ │ Router(essence / 原身) │ 评估 → Ticket + Mandate → 派发
26
+ │ master-superagent-router │ trustMode: off / observe / auto
27
+ │ 不执行代码、不跑命令 │ Ticket 持久化(可选 dataDir)
28
+ └──────────────┬───────────────┘
29
+ │ fangForker.spawn({ env: ticket })
30
+ ▼
31
+ ┌──────────────────────────────┐
32
+ │ Worker(fork / 分身 VM) │ 每 Ticket 一个隔离环境
33
+ │ master-superagent-worker │ worker/agent-loop.ts:
34
+ │ + worker preset (TS) │ 多轮工具调用 / 熔断 / 安全工具 / 审计快照
35
+ └──────────────────────────────┘
36
+
37
+ 旁路(后续版本):
38
+ cost-optimizer → fork:HarnessMM observe 层(3.3.0)
39
+ dual-manager → mother/essence:PM + PJM(3.4.0)
40
+ ```
41
+
42
+ ### 角色矩阵(代码强制,见 `src/kernel.mjs ROLE_MATRIX`)
43
+
44
+ | 行 | mother | essence(原身/元宝运行态) | fork(分身) |
45
+ |---|---|---|---|
46
+ | `master-core` | ✅ | ✅ | ✅ |
47
+ | `master-superagent-router` | ❌ | ✅ | ❌ |
48
+ | `master-superagent-worker` | ❌ | ❌ | ✅ |
49
+ | `master-superagent-loop` | ❌ | ❌ | ✅ |
50
+ | `master-cost-optimizer` | ❌ | ❌ | ✅ |
51
+ | `master-dual-manager` | ✅ | ✅ | ❌ |
52
+
53
+ - Router 是纯控制面(评估/建票/派发),不执行任何代码改动,因此可以常驻原身;
54
+ - Worker 是研究原型,**只在 fork**,用物理隔离兜底其不稳定性;
55
+ - 母体(mother)不接纳任何 SuperAgent 行;
56
+ - 3.2.0 的“SuperAgent 整体仅 fork”在 3.2.1 被 Router/Worker 拆分取代,底线是
57
+ “原型 Worker 绝不进入母体/元主”(见 `docs/SECURITY.md`)。
58
+
59
+ ## 3. 与瓮城的服务契约
60
+
61
+ 房主只依赖以下**服务键**(字符串来自 `dsh-fang >= 3.1.14` kernel SERVICE):
62
+
63
+ | 键 | fang 服务 | 用途 | 缺失时行为 |
64
+ |---|---|---|---|
65
+ | `fangForker` | `fangForker` | `spawn({homeDir,cliPath,profile,env})` 派生 Worker 分身 | Router auto 返回 `dispatch-unavailable`;不本地执行 |
66
+ | `fangDecisionCenter` | `fangDecisionCenter` | PM 歧义澄清(`create`) | `pm.askUser` 返回 `fang-decision-center-unavailable` |
67
+ | `fangSessionCatalog` | `fangSessionCatalog` | 会话归属(3.2.x) | 状态页显示 unavailable |
68
+ | `fang` | `fang`(orchestrator) | 任务/状态视图(3.2.x) | 状态页显示 unavailable |
69
+
70
+ 契约基线:`FANG_CONTRACT_MIN = '3.1.14'`。
71
+
72
+ > 部署现实:3.1.14 的 `fangForker` 只在母体激活。Router 在 essence 内派发需要
73
+ > 瓮城提供跨进程 DerivedManager 桥(3.2.2);在桥落地前 Router 的
74
+ > `dispatch.available=false`,auto 模式返回 unavailable——这是 fail-closed。
75
+
76
+ ## 4. 插件行与 bundle
77
+
78
+ `cordis.patch.yml` 只做一次 insert(6 行):
79
+
80
+ ```yaml
81
+ - insert:
82
+ - master-core
83
+ - master-superagent-router
84
+ - master-superagent-worker
85
+ - master-superagent-loop
86
+ - master-cost-optimizer
87
+ - master-dual-manager
88
+ ```
89
+
90
+ 子插件 `inject = ['dshMaster']` 等待 core 的 `dshMaster` 服务;core 不在时子插件不激活。
91
+
92
+ ## 5. 控制面与 UI
93
+
94
+ - `master-core` 提供 `dshMaster` 服务:
95
+ - `status()`:角色、版本、子插件矩阵、与瓮城服务可用性;
96
+ - `health()`:缺协作服务时给出可读 warnings;
97
+ - `registerSubplugin()`:子插件注册能力,重复 id 直接抛错(fail loud)。
98
+ - 只读路由:
99
+ - `/dsh-master/api/status`(核心状态)
100
+ - `/dsh-master/api/superagent/status`(Router 状态)
101
+ - `POST /dsh-master/api/superagent/github`、`POST /dsh-master/api/superagent/feishu`(Webhook)
102
+ - Web Client:`sidebar.footer.action`(id `dsh-master-home`,label「房主」)打开只读状态抽屉;
103
+ 不写业务状态,不接管宿主根节点。
104
+
105
+ ## 6. 3.2.3-dev 的边界
106
+
107
+ - Worker preset 已 tsc 构建到 `dist-worker/` 并接入 bundle 行(fork only,有 Ticket 自动跑);
108
+ - Router 两种派发:本地 `fangForker.spawn`(母体/测试)或 HTTP 母体桥
109
+ `POST /dsh-master/api/bridge/spawn`(Bearer bridgeToken,仅透传 DSH_MASTER_* env);
110
+ - Worker→Router 实时事件:per-ticket token POST `/dsh-master/api/superagent/worker-events`,
111
+ Router 落库并推进 Ticket 状态;done 触发 webhook/GitHub 回写(默认关闭);
112
+ - 前端 SSE/WS、Worker 并发/配额看护、PR 创建仍待 3.2.4+;
113
+ - 安全审计 P0/P1 已加固(env 脱敏、进程树 kill、Mandate 一致性、secret 脱敏、token 估算);
114
+ - 不安装、不发布;等审核。