zen-gitsync 2.17.15 → 2.17.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/cli/ai/agent.js +24 -2
- package/src/cli/ai/runtime.test.js +1 -1
- package/src/cli/ai/tools.js +7 -1
- package/src/cli/ai/tools.test.js +11 -0
- package/src/config.js +4 -4
- package/src/paths.js +3 -0
- package/src/ui/public/assets/AgentView-CXZUWLPL.css +1 -0
- package/src/ui/public/assets/AgentView-Dhs3eKFY.js +7 -0
- package/src/ui/public/assets/AppVersionBadge-D70AjIBo.js +2 -0
- package/src/ui/public/assets/AppVersionBadge-DDYQqoy7.css +1 -0
- package/src/ui/public/assets/BranchSelector-F4ibldMN.css +1 -0
- package/src/ui/public/assets/BranchSelector-t3fnGS_5.js +1 -0
- package/src/ui/public/assets/CommitForm-4kI2csgs.css +1 -0
- package/src/ui/public/assets/CommitForm-DmlTnTwc.js +6 -0
- package/src/ui/public/assets/CommonDialog-CecgvEzd.js +1 -0
- package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +1 -0
- package/src/ui/public/assets/EditorView-DBqhjm84.css +1 -0
- package/src/ui/public/assets/EditorView-DVRemzZU.js +16 -0
- package/src/ui/public/assets/FileDiffViewer-DH8Y2lUO.js +15 -0
- package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +1 -0
- package/src/ui/public/assets/{FlowExecutionViewer-BmfstdaT.js → FlowExecutionViewer-Cie2_TG4.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-Bbf8X1KP.js +21 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +1 -0
- package/src/ui/public/assets/IconButton-BEiZI4F6.js +1 -0
- package/src/ui/public/assets/IconButton-RlXqKOeu.css +1 -0
- package/src/ui/public/assets/LogList-DvuC03Tx.js +7 -0
- package/src/ui/public/assets/LogList-XspUhx-C.css +1 -0
- package/src/ui/public/assets/MindmapView-B6rITyNs.js +2 -0
- package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +1 -0
- package/src/ui/public/assets/MonitorView-DwUwJXnY.css +1 -0
- package/src/ui/public/assets/MonitorView-q92EZvay.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +1 -0
- package/src/ui/public/assets/ProjectStartupButton-RiIi8TwW.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-qtBotYAQ.js +4 -0
- package/src/ui/public/assets/RemoteRepoCard-DvtzfwHV.js +7 -0
- package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +1 -0
- package/src/ui/public/assets/SourceMapView-CfDydtsu.css +1 -0
- package/src/ui/public/assets/SourceMapView-EEWAmy4U.js +3 -0
- package/src/ui/public/assets/SvgIcon-Bkc_oGUI.js +1 -0
- package/src/ui/public/assets/TemplateManager-9e4amMlI.js +1 -0
- package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +1 -0
- package/src/ui/public/assets/UserInputNode-4FoJblJi.js +1 -0
- package/src/ui/public/assets/UserInputNode-DDNSarag.css +1 -0
- package/src/ui/public/assets/WorkbenchView-BFaTinH8.js +56 -0
- package/src/ui/public/assets/WorkbenchView-BU2kJCAA.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-B1mHBne9.js → _plugin-vue_export-helper-BLGowG5t.js} +7 -7
- package/src/ui/public/assets/agentConversations-BQCIv4u1.js +5 -0
- package/src/ui/public/assets/configStore-BUXv5i5j.js +1 -0
- package/src/ui/public/assets/element-plus-C1xzgNSR.css +1 -0
- package/src/ui/public/assets/{element-plus-DxBPQwL7.js → element-plus-D7LDHcAn.js} +3 -3
- package/src/ui/public/assets/flow-mindmap-zXXF3qDh.js +139 -0
- package/src/ui/public/assets/gitStore-BGbEYd0U.js +6 -0
- package/src/ui/public/assets/index-DIU5200G.css +1 -0
- package/src/ui/public/assets/index-Di5cimoI.js +115 -0
- package/src/ui/public/assets/{office-docx-CZXLkCf5.js → office-docx-C8sYW8O1.js} +5 -5
- package/src/ui/public/assets/{office-excel-CBKszjVA.js → office-excel-DpZn5My2.js} +1 -1
- package/src/ui/public/assets/{office-pptx-NeUSnN3k.js → office-pptx-BnYBTwT7.js} +1 -1
- package/src/ui/public/assets/previewSandbox-CxLaNg4X.css +1 -0
- package/src/ui/public/assets/previewSandbox-DEQpyVNu.js +0 -0
- package/src/ui/public/assets/toolsStore-DMCSd287.js +1 -0
- package/src/ui/public/assets/{useThemeObserver-E7Hk50l3.js → useThemeObserver-QpveGpnn.js} +1 -1
- package/src/ui/public/assets/{vendor-C5qX_I8r.css → vendor-C-GCxMMu.css} +1 -1
- package/src/ui/public/assets/{vendor-IQQMq-1c.js → vendor-CLmPUj3K.js} +43 -43
- package/src/ui/public/assets/vue-flow-CxnoC7Uq.js +39 -0
- package/src/ui/public/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/src/ui/public/fonts/plus-jakarta-sans-latin-ext.woff2 +0 -0
- package/src/ui/public/fonts/plus-jakarta-sans-latin-italic.woff2 +0 -0
- package/src/ui/public/fonts/plus-jakarta-sans-latin.woff2 +0 -0
- package/src/ui/public/index.html +28 -27
- package/src/ui/server/routes/workbench/agentChat.js +69 -12
- package/src/ui/server/routes/workbench/agentChatContext.test.js +106 -0
- package/src/ui/server/routes/workbench/agentChatShared.test.js +33 -3
- package/src/ui/server/routes/workbench/agentMarketplace.js +23 -9
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +32 -0
- package/src/ui/server/routes/workbench/agentRoutes.js +57 -15
- package/src/ui/server/routes/workbench/agentRoutes.test.js +142 -2
- package/src/ui/server/routes/workbench/taskRunner.codex.test.js +173 -0
- package/src/ui/server/routes/workbench/taskRunner.js +259 -10
- package/src/ui/server/routes/workbench/taskRunner.opencode.test.js +4 -2
- package/src/ui/server/utils/agentAttachments.js +169 -0
- package/src/ui/server/utils/agentAttachments.test.js +140 -0
- package/src/ui/public/assets/AgentView-Bz9LFamz.js +0 -5
- package/src/ui/public/assets/AgentView-vvVhbIQ5.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-CJrDr7GV.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-D5iXcAEV.js +0 -2
- package/src/ui/public/assets/BranchSelector-B0eLkAdv.css +0 -1
- package/src/ui/public/assets/BranchSelector-DP4u1f_B.js +0 -1
- package/src/ui/public/assets/CommitForm-CQ0K8Yy2.css +0 -1
- package/src/ui/public/assets/CommitForm-boOw8uGS.js +0 -6
- package/src/ui/public/assets/CommonDialog-B_qUsIGr.js +0 -1
- package/src/ui/public/assets/CommonDialog-jP0E9mco.css +0 -1
- package/src/ui/public/assets/EditorView-DZMLvQds.css +0 -1
- package/src/ui/public/assets/EditorView-ZULtfIzl.js +0 -1
- package/src/ui/public/assets/FileDiffViewer-B9EkuKdF.css +0 -1
- package/src/ui/public/assets/FileDiffViewer-C4xxq5S0.js +0 -15
- package/src/ui/public/assets/FlowOrchestrationWorkspace-28kErqrn.js +0 -21
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CYGp-PqS.css +0 -1
- package/src/ui/public/assets/IconButton-DcOAqpq0.js +0 -1
- package/src/ui/public/assets/IconButton-tjkOjzkC.css +0 -1
- package/src/ui/public/assets/LogList-BlJplIi1.css +0 -1
- package/src/ui/public/assets/LogList-FEP7EeJt.js +0 -7
- package/src/ui/public/assets/MindmapView-8X6z245q.css +0 -1
- package/src/ui/public/assets/MindmapView-NDJvfSCn.js +0 -2
- package/src/ui/public/assets/MonitorView-DU4aBCK0.js +0 -1
- package/src/ui/public/assets/MonitorView-DetlFFXq.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-JQk4Etsu.js +0 -1
- package/src/ui/public/assets/ProjectStartupButton-QLPltCK5.css +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-BauAeH2w.css +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-Di2WizMS.js +0 -4
- package/src/ui/public/assets/RemoteRepoCard-CG-jARTe.css +0 -1
- package/src/ui/public/assets/RemoteRepoCard-DxB8Q8yE.js +0 -7
- package/src/ui/public/assets/SourceMapView-CN4Hkyp4.css +0 -1
- package/src/ui/public/assets/SourceMapView-CfmrPMBm.js +0 -3
- package/src/ui/public/assets/SvgIcon-yefEMV0-.js +0 -1
- package/src/ui/public/assets/TemplateManager-CCy_WcSK.js +0 -1
- package/src/ui/public/assets/TemplateManager-DMuG4i3G.css +0 -1
- package/src/ui/public/assets/UserInputNode-3Gv_9YZx.js +0 -1
- package/src/ui/public/assets/UserInputNode-BVgQkB2l.css +0 -1
- package/src/ui/public/assets/WorkbenchView-B1BJhWHi.css +0 -1
- package/src/ui/public/assets/WorkbenchView-Dgp6A83v.js +0 -56
- package/src/ui/public/assets/configStore-BDxel6Co.js +0 -1
- package/src/ui/public/assets/element-plus-Cwwq-Gje.css +0 -1
- package/src/ui/public/assets/flow-mindmap-BqFb1WO5.js +0 -139
- package/src/ui/public/assets/gitStore-D5CWxPLv.js +0 -6
- package/src/ui/public/assets/index-CmbatbSR.css +0 -1
- package/src/ui/public/assets/index-gpuX9fhG.js +0 -111
- package/src/ui/public/assets/previewSandbox-CgdwPcuW.css +0 -1
- package/src/ui/public/assets/previewSandbox-qLKxghfr.js +0 -0
- package/src/ui/public/assets/vue-flow-fLUQ0_kE.js +0 -39
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
// 任务执行引擎:spawn 本地 agent CLI 跑子任务,流式收集输出,更新 job 状态。
|
|
16
16
|
// 拆分自原 routes/workbench.js 1108-1564 行。
|
|
17
17
|
//
|
|
18
|
-
//
|
|
18
|
+
// 支持三种执行器(executor):
|
|
19
19
|
// - claude —— claude -p --output-format stream-json(默认,历史行为)
|
|
20
20
|
// - opencode —— opencode run --format json --auto(sst/opencode CLI,跟随其自身默认模型)
|
|
21
|
+
// - codex —— codex exec --json(OpenAI Codex CLI,跟随其自身配置;2026-09-27 加入)
|
|
21
22
|
//
|
|
22
23
|
// 核心 API:
|
|
23
24
|
// - launchClaudeInNewWindow(cwd, prompt, resumeSessionId) spawn claude,返回 {pid, child}
|
|
24
25
|
// - launchOpencodeRun(cwd, prompt, resumeSessionId) spawn opencode,返回 {pid, child}
|
|
26
|
+
// - launchCodexExec(cwd, prompt, resumeSessionId) spawn codex,返回 {pid, child}
|
|
25
27
|
// - normalizeTaskExecutor(value) 非法值回落 'claude'
|
|
26
28
|
// - runSingleSubtask(task, sub, repoPath, branch, opts) 跑一次任务(sub 是运行载体,见下)
|
|
27
29
|
// - waitProcessExit(pid) polling 等进程退出
|
|
@@ -185,10 +187,11 @@ function attachPromptStdin(child, promptText, resolve, reject) {
|
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
// ── 执行器(executor) ──────────────────────────────────────────────────
|
|
188
|
-
// 工作台任务默认由 claude CLI 执行;opencode
|
|
189
|
-
//
|
|
190
|
-
// 差异只在协议:claude 是 stream-json,opencode 是 --format json 的事件 NDJSON
|
|
191
|
-
|
|
190
|
+
// 工作台任务默认由 claude CLI 执行;opencode(2026-09-20)、codex(2026-09-27)
|
|
191
|
+
// 作为可选执行器。三者都用「stdin 喂 prompt + stdout 结构化 JSON 事件流」的同构形态,
|
|
192
|
+
// 差异只在协议:claude 是 stream-json,opencode 是 --format json 的事件 NDJSON,
|
|
193
|
+
// codex 是 exec --json 的 thread/item 事件 NDJSON。
|
|
194
|
+
export const TASK_EXECUTORS = ['claude', 'opencode', 'codex'];
|
|
192
195
|
|
|
193
196
|
/** 非法/缺省的 executor 一律回落 'claude'(历史行为的兼容兜底)。
|
|
194
197
|
* body.executor 来自网络,做一次 trim + 小写归一,与 config.js 同名函数同语义。 */
|
|
@@ -269,6 +272,95 @@ export function launchOpencodeRun(cwd, promptText, resumeSessionId) {
|
|
|
269
272
|
});
|
|
270
273
|
}
|
|
271
274
|
|
|
275
|
+
// ── codex 执行器(2026-09-27 加入) ──────────────────────────────────────
|
|
276
|
+
// codex exec 是非交互模式,形态与另两个执行器同构(stdin 喂 prompt + stdout NDJSON):
|
|
277
|
+
//
|
|
278
|
+
// codex exec [resume <thread_id>] --json --skip-git-repo-check
|
|
279
|
+
// --dangerously-bypass-approvals-and-sandbox - (`-` = prompt 从 stdin 读)
|
|
280
|
+
//
|
|
281
|
+
// - --json 输出 JSONL 事件流(thread.started / item.* / turn.*)
|
|
282
|
+
// - --dangerously-bypass-approvals-and-sandbox
|
|
283
|
+
// 免批准 + 免沙箱,对应 claude 的 bypassPermissions、opencode 的 --auto。
|
|
284
|
+
// 工作台任务全程无人应答批准提示,不跳过的话一遇审批就卡死
|
|
285
|
+
// - --skip-git-repo-check
|
|
286
|
+
// 工作台允许在非 git 目录建任务,不跳过的话 codex 直接拒绝启动
|
|
287
|
+
// - 不传 -m / -s:模型与沙箱跟随 codex 自身配置(同 opencode 的口径)
|
|
288
|
+
// - 续聊:codex exec resume <thread_id>,thread_id 来自 thread.started 事件
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Windows 下把 codex 解析成可直接 spawn 的 exe。
|
|
292
|
+
*
|
|
293
|
+
* npm 全局安装的 codex 是 .cmd shim(Node 23+ 拒绝 spawn .cmd,EINVAL),shim 指向
|
|
294
|
+
* `node bin/codex.js`;而 bin/codex.js 自己也只是一层壳,真正干活的原生二进制在平台
|
|
295
|
+
* 子包里:node_modules/@openai/codex/node_modules/@openai/codex-win32-<arch>/vendor/
|
|
296
|
+
* <triple>/bin/codex.exe(与 bin/codex.js 的查找口径一致,另留一份无平台子包的兜底路径)。
|
|
297
|
+
*
|
|
298
|
+
* 为什么不直接 spawn(process.execPath, [bin/codex.js, ...]):那层壳会再 spawn 一次原生
|
|
299
|
+
* 二进制,取消任务时 kill 到的只是壳进程,原生 codex 会变孤儿继续改文件。
|
|
300
|
+
*/
|
|
301
|
+
function resolveCodexExe() {
|
|
302
|
+
try {
|
|
303
|
+
const lines = execFileSync('where', ['codex'], { encoding: 'utf8', windowsHide: true })
|
|
304
|
+
.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
305
|
+
// 独立安装(scoop / 手动下载 / 包管理器直装)优先:PATH 上直接就是原生 exe
|
|
306
|
+
const directExe = lines.find(s => /\.exe$/i.test(s));
|
|
307
|
+
if (directExe && fs.existsSync(directExe)) return directExe;
|
|
308
|
+
const cmdShim = lines.find(s => /\.cmd$/i.test(s));
|
|
309
|
+
if (cmdShim) {
|
|
310
|
+
const prefix = path.dirname(cmdShim);
|
|
311
|
+
const triple = process.arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc';
|
|
312
|
+
const archTag = process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
313
|
+
const candidates = [
|
|
314
|
+
path.join(prefix, 'node_modules', '@openai', 'codex', 'node_modules', '@openai',
|
|
315
|
+
`codex-win32-${archTag}`, 'vendor', triple, 'bin', 'codex.exe'),
|
|
316
|
+
path.join(prefix, 'node_modules', '@openai', 'codex', 'vendor', triple, 'bin', 'codex.exe'),
|
|
317
|
+
];
|
|
318
|
+
for (const exe of candidates) {
|
|
319
|
+
if (fs.existsSync(exe)) return exe;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch { /* fallback */ }
|
|
323
|
+
// 兜底交给 PATH:装在非 npm 全局目录时通常有 codex.exe
|
|
324
|
+
return 'codex.exe';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* codex 子进程环境。
|
|
329
|
+
* RUST_LOG=error:codex 默认把 tracing 的 INFO 日志写 stderr,而 stderr 里非 JSON 行
|
|
330
|
+
* 会被当正文贴进输出面板(实测一次执行能刷出上百行);压到 error 档即可清干净,
|
|
331
|
+
* 真正的报错走 stdout 的 JSON 事件流(error / turn.failed),信息不丢。
|
|
332
|
+
*/
|
|
333
|
+
function buildCodexEnv() {
|
|
334
|
+
return { ...process.env, LANG: 'zh_CN.UTF-8', RUST_LOG: 'error' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** spawn codex exec 跑一个 prompt,返回 { pid, child }(参数口径见上方注释)。 */
|
|
338
|
+
export function launchCodexExec(cwd, promptText, resumeSessionId) {
|
|
339
|
+
return new Promise((resolve, reject) => {
|
|
340
|
+
const flags = ['--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox'];
|
|
341
|
+
const args = resumeSessionId
|
|
342
|
+
? ['exec', 'resume', ...flags, String(resumeSessionId), '-']
|
|
343
|
+
: ['exec', ...flags, '-'];
|
|
344
|
+
let child;
|
|
345
|
+
if (process.platform === 'win32') {
|
|
346
|
+
child = spawn(resolveCodexExe(), args, {
|
|
347
|
+
cwd,
|
|
348
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
349
|
+
windowsHide: false,
|
|
350
|
+
env: buildCodexEnv()
|
|
351
|
+
});
|
|
352
|
+
} else {
|
|
353
|
+
child = spawn('codex', args, {
|
|
354
|
+
cwd,
|
|
355
|
+
detached: true,
|
|
356
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
357
|
+
env: buildCodexEnv()
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
attachPromptStdin(child, promptText, resolve, reject);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
272
364
|
// ── 工具调用(tool_use)收集 ──────────────────────────────────────────────
|
|
273
365
|
// 为什么要有这一层:job.output 只承载「模型说的话」。工具调用(读了哪个文件、跑了什么命令、
|
|
274
366
|
// 拿到了什么结果)原本在两个事件处理器里被整段丢掉,于是前端只剩「思考 + 正文」两块,
|
|
@@ -585,6 +677,156 @@ export function createOpencodeEventHandler(job, appendOutput, appendThinking, to
|
|
|
585
677
|
};
|
|
586
678
|
}
|
|
587
679
|
|
|
680
|
+
/**
|
|
681
|
+
* codex `exec --json` 事件处理器。
|
|
682
|
+
*
|
|
683
|
+
* 事件形态(codex-cli 0.157.x;schema 源:openai/codex codex-rs/exec/src/exec_events.rs):
|
|
684
|
+
* { type:'thread.started', thread_id } 会话续接标识(exec resume <id>)
|
|
685
|
+
* { type:'turn.started' } / { type:'turn.completed', usage }
|
|
686
|
+
* { type:'turn.failed', error:{ message } } 本轮终态失败(进程退出码可能仍是 0)
|
|
687
|
+
* { type:'item.started'|'item.updated'|'item.completed', item:{ id, type, ... } }
|
|
688
|
+
* { type:'error', message } 重试等非终止信息(真失败走 turn.failed)
|
|
689
|
+
*
|
|
690
|
+
* item.type(snake_case)分派:
|
|
691
|
+
* agent_message 正文 → output(按 item.id 做累积/增量去重,两种推送语义都兼容)
|
|
692
|
+
* reasoning 思考 → thinking
|
|
693
|
+
* command_execution 命令 → 工具调用(command 作参数摘要,aggregated_output 作结果)
|
|
694
|
+
* file_change 改动文件清单 → 工具调用
|
|
695
|
+
* mcp_tool_call MCP 工具 → 工具调用(server.tool 作名字)
|
|
696
|
+
* web_search 搜索 → 工具调用
|
|
697
|
+
* error 非致命告警(配置项被忽略 / 模型元信息缺失等)→ 贴进 output,不判失败
|
|
698
|
+
* 其余(todo_list / collab_tool_call / 未来新增)忽略 —— 事件枚举是 non_exhaustive,
|
|
699
|
+
* 未知类型不能影响正文收集与终态判定。
|
|
700
|
+
* (导出仅为回归测试可见:taskRunner.codex.test.js)
|
|
701
|
+
*/
|
|
702
|
+
export function createCodexEventHandler(job, appendOutput, appendThinking, toolCalls) {
|
|
703
|
+
const tracker = toolCalls || createToolCallTracker(job);
|
|
704
|
+
const seenTextItems = new Map(); // item.id -> 已落地的累计文本
|
|
705
|
+
|
|
706
|
+
const appendItemText = (item, isThinking) => {
|
|
707
|
+
const text = typeof item?.text === 'string' ? item.text : '';
|
|
708
|
+
if (!text) return;
|
|
709
|
+
const id = typeof item?.id === 'string' ? item.id : '';
|
|
710
|
+
const prev = id ? seenTextItems.get(id) : undefined;
|
|
711
|
+
let delta = text;
|
|
712
|
+
if (prev !== undefined) {
|
|
713
|
+
delta = text.startsWith(prev) ? text.slice(prev.length) : text;
|
|
714
|
+
}
|
|
715
|
+
if (!delta) return;
|
|
716
|
+
if (id) seenTextItems.set(id, prev !== undefined ? prev + delta : text);
|
|
717
|
+
if (isThinking) appendThinking(delta);
|
|
718
|
+
else appendOutput(delta);
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
// codex 的 status: in_progress | completed | failed | declined(snake_case)
|
|
722
|
+
const mapItemStatus = (raw) => {
|
|
723
|
+
const s = typeof raw === 'string' ? raw.toLowerCase() : '';
|
|
724
|
+
if (s === 'completed') return 'done';
|
|
725
|
+
if (s === 'failed' || s === 'declined') return 'error';
|
|
726
|
+
return 'running';
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const changesText = (changes) => (Array.isArray(changes) ? changes : [])
|
|
730
|
+
.map(c => (c && c.path ? `${c.kind || 'update'} ${c.path}` : ''))
|
|
731
|
+
.filter(Boolean)
|
|
732
|
+
.join('\n');
|
|
733
|
+
|
|
734
|
+
const handleItem = (item) => {
|
|
735
|
+
if (!item || typeof item !== 'object') return;
|
|
736
|
+
switch (item.type) {
|
|
737
|
+
case 'agent_message':
|
|
738
|
+
appendItemText(item, false);
|
|
739
|
+
return;
|
|
740
|
+
case 'reasoning':
|
|
741
|
+
appendItemText(item, true);
|
|
742
|
+
return;
|
|
743
|
+
case 'error':
|
|
744
|
+
// 非致命告警:原样贴出来让用户看得见,但不置 agentError —— 那会把告警变成任务失败
|
|
745
|
+
if (item.message) appendOutput(`\n> [codex] ${item.message}\n`);
|
|
746
|
+
return;
|
|
747
|
+
case 'command_execution': {
|
|
748
|
+
const status = mapItemStatus(item.status);
|
|
749
|
+
tracker.record({
|
|
750
|
+
id: item.id,
|
|
751
|
+
name: 'command_execution',
|
|
752
|
+
input: { command: item.command },
|
|
753
|
+
status,
|
|
754
|
+
output: status === 'running' ? undefined : item.aggregated_output,
|
|
755
|
+
error: status === 'error' ? `命令未成功完成(exit_code=${item.exit_code ?? 'null'})` : ''
|
|
756
|
+
});
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
case 'file_change': {
|
|
760
|
+
const status = mapItemStatus(item.status);
|
|
761
|
+
tracker.record({
|
|
762
|
+
id: item.id,
|
|
763
|
+
name: 'file_change',
|
|
764
|
+
input: { changes: item.changes },
|
|
765
|
+
status,
|
|
766
|
+
output: status === 'running' ? undefined : changesText(item.changes),
|
|
767
|
+
error: status === 'error' ? '文件改动未应用' : ''
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
case 'mcp_tool_call': {
|
|
772
|
+
const status = mapItemStatus(item.status);
|
|
773
|
+
tracker.record({
|
|
774
|
+
id: item.id,
|
|
775
|
+
name: item.server && item.tool ? `${item.server}.${item.tool}` : (item.tool || 'mcp_tool_call'),
|
|
776
|
+
input: item.arguments,
|
|
777
|
+
// result.content 是 MCP 的内容块数组(与 claude 的 tool_result 同形态),
|
|
778
|
+
// 直接喂给 extractToolText 能压成文本;没有 content 时整包 JSON 兜底
|
|
779
|
+
status,
|
|
780
|
+
output: status === 'running' ? undefined : (item.result?.content ?? item.result),
|
|
781
|
+
error: item.error?.message || (status === 'error' ? 'MCP 工具调用失败' : '')
|
|
782
|
+
});
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
case 'web_search': {
|
|
786
|
+
const status = mapItemStatus(item.status);
|
|
787
|
+
tracker.record({
|
|
788
|
+
id: item.id,
|
|
789
|
+
name: 'web_search',
|
|
790
|
+
input: { query: item.query },
|
|
791
|
+
status,
|
|
792
|
+
output: status === 'running' ? undefined : item.results
|
|
793
|
+
});
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
default:
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
return (evt) => {
|
|
802
|
+
if (!evt || typeof evt !== 'object') return;
|
|
803
|
+
// thread_id 是 codex 的会话续接标识(exec resume <id>)。复用 claudeSessionId 字段:
|
|
804
|
+
// 前端与续接路由都认它,换执行器只是取值来源不同。
|
|
805
|
+
if (evt.type === 'thread.started') {
|
|
806
|
+
if (typeof evt.thread_id === 'string' && evt.thread_id && job.claudeSessionId !== evt.thread_id) {
|
|
807
|
+
job.claudeSessionId = evt.thread_id;
|
|
808
|
+
publish('job:update', job);
|
|
809
|
+
}
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (evt.type === 'item.started' || evt.type === 'item.updated' || evt.type === 'item.completed') {
|
|
813
|
+
handleItem(evt.item);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (evt.type === 'turn.failed') {
|
|
817
|
+
const msg = evt.error?.message
|
|
818
|
+
|| (evt.error?.additional_details ? String(evt.error.additional_details) : '')
|
|
819
|
+
|| 'codex 执行出错';
|
|
820
|
+
if (!job.agentError) job.agentError = msg;
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (evt.type === 'error') {
|
|
824
|
+
// 非终止信息(如 "Reconnecting... 1/5" 重试提示):贴进 output,终态仍由 turn.* 决定
|
|
825
|
+
if (evt.message) appendOutput(`\n> [codex] ${evt.message}\n`);
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
588
830
|
/**
|
|
589
831
|
* 执行一次任务。任务一次只跑一个进程,本函数是唯一的执行入口
|
|
590
832
|
* (首次执行、手动重跑、续聊都走它)。
|
|
@@ -595,10 +837,11 @@ export function createOpencodeEventHandler(job, appendOutput, appendThinking, to
|
|
|
595
837
|
* @param {string} branch 分支名(可空)
|
|
596
838
|
* @param {object} [options]
|
|
597
839
|
* @param {string|null} [options.resumeSessionId]
|
|
598
|
-
* 续接历史会话:claude 走 --resume <id>,opencode 走 --session <id
|
|
599
|
-
*
|
|
840
|
+
* 续接历史会话:claude 走 --resume <id>,opencode 走 --session <id>,
|
|
841
|
+
* codex 走 exec resume <thread_id>。
|
|
842
|
+
* id 来自上一轮 stream 事件捕获的会话标识(三者都记在 job.claudeSessionId)。
|
|
600
843
|
* @param {string} [options.executor]
|
|
601
|
-
* 执行器 'claude' | 'opencode'。缺省回落 'claude';路由层负责把
|
|
844
|
+
* 执行器 'claude' | 'opencode' | 'codex'。缺省回落 'claude';路由层负责把
|
|
602
845
|
* "body 指定 > 配置默认"先解析好再传进来。
|
|
603
846
|
* @returns {Promise<'done'|'cancelled'|'error'>} 本次运行的终态
|
|
604
847
|
*/
|
|
@@ -677,7 +920,11 @@ export async function runSingleSubtask(task, sub, repoPath, branch, options) {
|
|
|
677
920
|
let toolTracker = null;
|
|
678
921
|
|
|
679
922
|
try {
|
|
680
|
-
const launcher = executor === 'opencode'
|
|
923
|
+
const launcher = executor === 'opencode'
|
|
924
|
+
? launchOpencodeRun
|
|
925
|
+
: executor === 'codex'
|
|
926
|
+
? launchCodexExec
|
|
927
|
+
: launchClaudeInNewWindow;
|
|
681
928
|
const { pid, child } = await launcher(repoPath || process.cwd(), prompt, resumeSessionId);
|
|
682
929
|
job.pid = pid;
|
|
683
930
|
// 保存 child 引用,供 cancel 接口调用 kill
|
|
@@ -729,7 +976,9 @@ export async function runSingleSubtask(task, sub, repoPath, branch, options) {
|
|
|
729
976
|
|
|
730
977
|
const handleEvent = executor === 'opencode'
|
|
731
978
|
? createOpencodeEventHandler(job, appendOutput, appendThinking, toolTracker)
|
|
732
|
-
:
|
|
979
|
+
: executor === 'codex'
|
|
980
|
+
? createCodexEventHandler(job, appendOutput, appendThinking, toolTracker)
|
|
981
|
+
: createClaudeEventHandler(job, appendOutput, appendThinking, toolTracker);
|
|
733
982
|
|
|
734
983
|
const handleLine = (line, channel) => {
|
|
735
984
|
const trimmed = line.trim();
|
|
@@ -54,12 +54,14 @@ function makeSink() {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
test('normalizeTaskExecutor:白名单 + 大小写 + 非法回落', () => {
|
|
57
|
-
assert.deepEqual(TASK_EXECUTORS, ['claude', 'opencode']);
|
|
57
|
+
assert.deepEqual(TASK_EXECUTORS, ['claude', 'opencode', 'codex']);
|
|
58
58
|
assert.equal(normalizeTaskExecutor('opencode'), 'opencode');
|
|
59
|
+
assert.equal(normalizeTaskExecutor('codex'), 'codex');
|
|
59
60
|
// 大小写不敏感(存储前统一小写)
|
|
60
61
|
assert.equal(normalizeTaskExecutor('OpenCode'), 'opencode');
|
|
61
62
|
assert.equal(normalizeTaskExecutor('opencode '), 'opencode');
|
|
62
|
-
assert.equal(normalizeTaskExecutor('
|
|
63
|
+
assert.equal(normalizeTaskExecutor('Codex '), 'codex');
|
|
64
|
+
assert.equal(normalizeTaskExecutor('gpt'), 'claude');
|
|
63
65
|
assert.equal(normalizeTaskExecutor(undefined), 'claude');
|
|
64
66
|
assert.equal(normalizeTaskExecutor(null), 'claude');
|
|
65
67
|
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
/**
|
|
16
|
+
* 智能体对话的**非图片附件**落盘。
|
|
17
|
+
*
|
|
18
|
+
* 设计口径(2026-09-27 与用户确认的方案"附件当路径提示"):
|
|
19
|
+
* - 图片继续走多模态:前端转 dataURL → 请求体的 images[] → image_url part,模型直接"看"。
|
|
20
|
+
* - 非图片不进消息体,只落盘 + 把**绝对路径**写进 system 提示,模型需要时用 read / grep
|
|
21
|
+
* 工具自己去读。理由:
|
|
22
|
+
* a) .log / .csv / 代码文件动辄几百 KB,内联进消息既费 token 又容易把上下文撑爆;
|
|
23
|
+
* b) 模型本来就有文件工具,给路径让它按需取,比一次全塞进去划算得多;
|
|
24
|
+
* c) 这也是唯一可行路径 —— 浏览器 <input type=file> 出于安全**不提供磁盘路径**,
|
|
25
|
+
* 只有 File 对象,所以想"只给路径"就必须由服务端落盘一次。
|
|
26
|
+
*
|
|
27
|
+
* 落盘位置:~/.zen-gitsync/agent-attachments/<轮次目录>/<文件名>(见 src/paths.js)。
|
|
28
|
+
* 按轮次分子目录,是为了同名文件在第二轮不被覆盖、第一次给过的路径一直有效。
|
|
29
|
+
*
|
|
30
|
+
* 安全口径:客户端传来的 name 一律当作**不可信输入** —— 只取 basename、剥掉控制字符与
|
|
31
|
+
* 路径分隔符、限长;大小与数量在服务端再卡一遍(不能只信前端)。
|
|
32
|
+
*/
|
|
33
|
+
import fs from 'fs/promises'
|
|
34
|
+
import path from 'path'
|
|
35
|
+
import { AGENT_ATTACHMENTS_DIR } from '../../../paths.js'
|
|
36
|
+
|
|
37
|
+
/** 单轮最多几个附件 */
|
|
38
|
+
export const MAX_ATTACHMENTS = 10
|
|
39
|
+
/** 单个附件解密后最大字节数 */
|
|
40
|
+
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
|
|
41
|
+
/** 单轮附件合计最大字节数(前端 base64 后约 ×1.37,须留在 /api/agent/chat 的 50mb 之内) */
|
|
42
|
+
export const MAX_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024
|
|
43
|
+
/** 保留最近多少轮的上传目录,多余的顺手清掉 */
|
|
44
|
+
export const KEEP_UPLOAD_TURNS = 30
|
|
45
|
+
/** 文件名最长字符数(保留扩展名) */
|
|
46
|
+
const MAX_NAME_LEN = 80
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 把客户端给的文件名洗成安全的单段文件名。
|
|
50
|
+
* 只做"文件名"该做的事:app 后面所有路径都是自己拼的,这里防的是 `../../x` 这类穿越与
|
|
51
|
+
* Windows 保留字符。
|
|
52
|
+
*/
|
|
53
|
+
export function sanitizeFileName(raw, fallback = 'file') {
|
|
54
|
+
let name = String(raw || '')
|
|
55
|
+
// 归一化分隔符后只取最后一段 —— Windows 的 \\ 与 POSIX 的 / 都要挡
|
|
56
|
+
name = name.split(/[\\/]/).pop() || ''
|
|
57
|
+
// 控制字符(含 \0)与 Windows 非法字符
|
|
58
|
+
name = name.replace(/[\x00-\x1f\x7f<>:"|?*]/g, '_')
|
|
59
|
+
// 纯点名".."/"." 不算文件名
|
|
60
|
+
name = name.replace(/^\.+$/, '')
|
|
61
|
+
name = name.trim()
|
|
62
|
+
if (!name) return fallback
|
|
63
|
+
if (name.length <= MAX_NAME_LEN) return name
|
|
64
|
+
// 超长时保留扩展名,截前半段
|
|
65
|
+
const ext = path.extname(name).slice(0, 16)
|
|
66
|
+
const base = name.slice(0, MAX_NAME_LEN - ext.length)
|
|
67
|
+
return base + ext
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `data:<mime>;base64,<payload>` → Buffer;不是 base64 dataURL 就返回 null */
|
|
71
|
+
export function decodeDataUrl(dataUrl) {
|
|
72
|
+
if (typeof dataUrl !== 'string') return null
|
|
73
|
+
const m = /^data:[^,]*;base64,(.+)$/s.exec(dataUrl)
|
|
74
|
+
if (!m) return null
|
|
75
|
+
try {
|
|
76
|
+
return Buffer.from(m[1], 'base64')
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 一轮一个子目录名:时间戳 + 随机尾巴(同一毫秒内两次调用也不会撞) */
|
|
83
|
+
function turnDirName(now = new Date()) {
|
|
84
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
85
|
+
const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
|
|
86
|
+
const rand = Math.random().toString(36).slice(2, 6)
|
|
87
|
+
return `${stamp}-${rand}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 落盘一批附件。
|
|
92
|
+
*
|
|
93
|
+
* @param {Array<{name?: string, dataUrl?: string}>} list 客户端传来的原始数组(不可信)
|
|
94
|
+
* @param {{ dir?: string, now?: Date, onSkip?: (info: {name: string, reason: string}) => void }} [opts]
|
|
95
|
+
* @returns {Promise<Array<{ name: string, path: string, bytes: number }>>} 落盘成功的附件(路径为绝对路径)
|
|
96
|
+
*/
|
|
97
|
+
export async function saveAgentAttachments(list, opts = {}) {
|
|
98
|
+
const dir = opts.dir || AGENT_ATTACHMENTS_DIR
|
|
99
|
+
const onSkip = typeof opts.onSkip === 'function' ? opts.onSkip : () => {}
|
|
100
|
+
const items = (Array.isArray(list) ? list : []).slice(0, MAX_ATTACHMENTS)
|
|
101
|
+
if (items.length === 0) return []
|
|
102
|
+
|
|
103
|
+
const saved = []
|
|
104
|
+
const used = new Set()
|
|
105
|
+
let total = 0
|
|
106
|
+
// 目录按需创建:一次都没上传过附件的用户不会平白多一个目录
|
|
107
|
+
let ensured = false
|
|
108
|
+
let turnDir = ''
|
|
109
|
+
|
|
110
|
+
for (const raw of items) {
|
|
111
|
+
const name = sanitizeFileName(raw?.name)
|
|
112
|
+
if (total >= MAX_ATTACHMENT_TOTAL_BYTES) {
|
|
113
|
+
onSkip({ name, reason: 'total-too-large' })
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
const buf = decodeDataUrl(raw?.dataUrl)
|
|
117
|
+
if (!buf || buf.length === 0) {
|
|
118
|
+
onSkip({ name, reason: 'bad-payload' })
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (buf.length > MAX_ATTACHMENT_BYTES) {
|
|
122
|
+
onSkip({ name, reason: 'too-large' })
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (total + buf.length > MAX_ATTACHMENT_TOTAL_BYTES) {
|
|
126
|
+
onSkip({ name, reason: 'total-too-large' })
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (!ensured) {
|
|
130
|
+
await fs.mkdir(dir, { recursive: true })
|
|
131
|
+
turnDir = path.join(dir, turnDirName(opts.now))
|
|
132
|
+
await fs.mkdir(turnDir, { recursive: true })
|
|
133
|
+
ensured = true
|
|
134
|
+
}
|
|
135
|
+
// 同一轮里两个文件同名:加 -1 / -2 后缀,不要互相覆盖
|
|
136
|
+
let finalName = name
|
|
137
|
+
let n = 1
|
|
138
|
+
while (used.has(finalName.toLowerCase())) {
|
|
139
|
+
const ext = path.extname(name)
|
|
140
|
+
finalName = `${name.slice(0, name.length - ext.length)}-${n++}${ext}`
|
|
141
|
+
}
|
|
142
|
+
used.add(finalName.toLowerCase())
|
|
143
|
+
const target = path.join(turnDir, finalName)
|
|
144
|
+
try {
|
|
145
|
+
await fs.writeFile(target, buf)
|
|
146
|
+
} catch (err) {
|
|
147
|
+
onSkip({ name, reason: `write-failed:${err?.code || err?.message || 'unknown'}` })
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
total += buf.length
|
|
151
|
+
saved.push({ name: finalName, path: target, bytes: buf.length })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (saved.length > 0) {
|
|
155
|
+
await pruneUploadDirs(dir, opts.now).catch(() => {})
|
|
156
|
+
}
|
|
157
|
+
return saved
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 只保留最近 keep 轮目录(尽力而为:失败不抛,不影响本轮对话) */
|
|
161
|
+
export async function pruneUploadDirs(dir = AGENT_ATTACHMENTS_DIR, now = new Date(), keep = KEEP_UPLOAD_TURNS) {
|
|
162
|
+
const dirs = (await fs.readdir(dir, { withFileTypes: true }).catch(() => []))
|
|
163
|
+
.filter(e => e.isDirectory()).map(e => e.name).sort()
|
|
164
|
+
const extra = dirs.slice(0, Math.max(0, dirs.length - keep))
|
|
165
|
+
for (const name of extra) {
|
|
166
|
+
await fs.rm(path.join(dir, name), { recursive: true, force: true }).catch(() => {})
|
|
167
|
+
}
|
|
168
|
+
return extra.length
|
|
169
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// 智能体非图片附件落盘:文件名消毒 / 体积与数量上限 / 同名不覆盖 / 轮次目录清理。
|
|
2
|
+
// 关键口径:客户端传的 name 是不可信输入,任何情况下都不能拼进目录外。
|
|
3
|
+
import { test } from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import fs from 'node:fs/promises'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import {
|
|
9
|
+
saveAgentAttachments,
|
|
10
|
+
sanitizeFileName,
|
|
11
|
+
decodeDataUrl,
|
|
12
|
+
pruneUploadDirs,
|
|
13
|
+
MAX_ATTACHMENT_BYTES,
|
|
14
|
+
MAX_ATTACHMENTS
|
|
15
|
+
} from './agentAttachments.js'
|
|
16
|
+
|
|
17
|
+
async function tmpDir(prefix = 'zen-att-') {
|
|
18
|
+
return await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dataUrl(text, mime = 'text/plain') {
|
|
22
|
+
return `data:${mime};base64,${Buffer.from(text, 'utf8').toString('base64')}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('sanitizeFileName: 剥路径穿越、控制字符与 Windows 非法字符', () => {
|
|
26
|
+
assert.equal(sanitizeFileName('../../etc/passwd'), 'passwd')
|
|
27
|
+
assert.equal(sanitizeFileName('C:\\Windows\\evil.txt'), 'evil.txt')
|
|
28
|
+
assert.equal(sanitizeFileName('a\u0000b.txt'), 'a_b.txt')
|
|
29
|
+
assert.equal(sanitizeFileName('bad:name?.log'), 'bad_name_.log')
|
|
30
|
+
assert.equal(sanitizeFileName('..'), 'file', '纯点名要回落默认名')
|
|
31
|
+
assert.equal(sanitizeFileName(''), 'file')
|
|
32
|
+
assert.equal(sanitizeFileName(null), 'file')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('sanitizeFileName: 超长名截断但保留扩展名', () => {
|
|
36
|
+
const long = 'x'.repeat(200) + '.log'
|
|
37
|
+
const out = sanitizeFileName(long)
|
|
38
|
+
assert.ok(out.length <= 80, `长度应被截断,实际 ${out.length}`)
|
|
39
|
+
assert.ok(out.endsWith('.log'), '扩展名要保留')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('decodeDataUrl: 只认 base64 dataURL', () => {
|
|
43
|
+
assert.equal(decodeDataUrl(dataUrl('hi')).toString('utf8'), 'hi')
|
|
44
|
+
assert.equal(decodeDataUrl('data:text/plain,hi'), null, '非 base64 不认')
|
|
45
|
+
assert.equal(decodeDataUrl('not-a-data-url'), null)
|
|
46
|
+
assert.equal(decodeDataUrl(undefined), null)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('saveAgentAttachments: 落盘到指定目录并返回绝对路径,内容一致', async () => {
|
|
50
|
+
const dir = await tmpDir()
|
|
51
|
+
const saved = await saveAgentAttachments(
|
|
52
|
+
[{ name: '错误日志.log', dataUrl: dataUrl('boom\n', 'text/plain') }],
|
|
53
|
+
{ dir }
|
|
54
|
+
)
|
|
55
|
+
assert.equal(saved.length, 1)
|
|
56
|
+
assert.equal(saved[0].name, '错误日志.log')
|
|
57
|
+
assert.ok(path.isAbsolute(saved[0].path), '返回的必须是绝对路径(模型按绝对路径读)')
|
|
58
|
+
assert.equal(await fs.readFile(saved[0].path, 'utf8'), 'boom\n')
|
|
59
|
+
// 落在 dir 的子目录里(一轮一个目录)
|
|
60
|
+
assert.equal(path.dirname(path.dirname(saved[0].path)), dir)
|
|
61
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('saveAgentAttachments: 目录穿越的 name 写不到目录外', async () => {
|
|
65
|
+
const dir = await tmpDir()
|
|
66
|
+
const saved = await saveAgentAttachments(
|
|
67
|
+
[{ name: '../../escaped.txt', dataUrl: dataUrl('x') }],
|
|
68
|
+
{ dir }
|
|
69
|
+
)
|
|
70
|
+
assert.equal(saved.length, 1)
|
|
71
|
+
assert.equal(saved[0].name, 'escaped.txt')
|
|
72
|
+
assert.ok(saved[0].path.startsWith(dir), '必须仍在传入目录内')
|
|
73
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('saveAgentAttachments: 超大 / 空 payload / 数量超限都会被跳过并回调', async () => {
|
|
77
|
+
const dir = await tmpDir()
|
|
78
|
+
const skipped = []
|
|
79
|
+
const big = Buffer.alloc(MAX_ATTACHMENT_BYTES + 1, 0x61).toString('base64')
|
|
80
|
+
const list = [
|
|
81
|
+
{ name: 'ok.txt', dataUrl: dataUrl('fine') },
|
|
82
|
+
{ name: 'big.bin', dataUrl: `data:application/octet-stream;base64,${big}` },
|
|
83
|
+
{ name: 'broken.txt', dataUrl: 'not-a-data-url' },
|
|
84
|
+
{ name: 'empty.txt', dataUrl: 'data:text/plain;base64,' }
|
|
85
|
+
]
|
|
86
|
+
const saved = await saveAgentAttachments(list, { dir, onSkip: i => skipped.push(i.reason) })
|
|
87
|
+
assert.deepEqual(saved.map(s => s.name), ['ok.txt'])
|
|
88
|
+
assert.ok(skipped.includes('too-large'))
|
|
89
|
+
assert.ok(skipped.includes('bad-payload'))
|
|
90
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('saveAgentAttachments: 超过 MAX_ATTACHMENTS 的余量直接截掉', async () => {
|
|
94
|
+
const dir = await tmpDir()
|
|
95
|
+
const list = Array.from({ length: MAX_ATTACHMENTS + 3 }, (_, i) => ({
|
|
96
|
+
name: `f${i}.txt`,
|
|
97
|
+
dataUrl: dataUrl(String(i))
|
|
98
|
+
}))
|
|
99
|
+
const saved = await saveAgentAttachments(list, { dir })
|
|
100
|
+
assert.equal(saved.length, MAX_ATTACHMENTS)
|
|
101
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('saveAgentAttachments: 同一轮内同名文件加序号,不互相覆盖', async () => {
|
|
105
|
+
const dir = await tmpDir()
|
|
106
|
+
const saved = await saveAgentAttachments(
|
|
107
|
+
[
|
|
108
|
+
{ name: 'a.txt', dataUrl: dataUrl('first') },
|
|
109
|
+
{ name: 'a.txt', dataUrl: dataUrl('second') }
|
|
110
|
+
],
|
|
111
|
+
{ dir }
|
|
112
|
+
)
|
|
113
|
+
assert.equal(saved.length, 2)
|
|
114
|
+
assert.notEqual(saved[0].path, saved[1].path)
|
|
115
|
+
assert.equal(await fs.readFile(saved[0].path, 'utf8'), 'first')
|
|
116
|
+
assert.equal(await fs.readFile(saved[1].path, 'utf8'), 'second')
|
|
117
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('saveAgentAttachments: 空数组不建目录(没人上传就别留垃圾)', async () => {
|
|
121
|
+
const dir = path.join(await tmpDir(), 'never')
|
|
122
|
+
const saved = await saveAgentAttachments([], { dir })
|
|
123
|
+
assert.deepEqual(saved, [])
|
|
124
|
+
await assert.rejects(fs.stat(dir), '目录不该被创建')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('pruneUploadDirs: 只保留最近 KEEP_UPLOAD_TURNS 轮', async () => {
|
|
128
|
+
const dir = await tmpDir()
|
|
129
|
+
// 目录名按时间戳字典序排序,这里直接造有序名字
|
|
130
|
+
for (const n of ['20260101-000000-aaaa', '20260102-000000-bbbb', '20260103-000000-cccc']) {
|
|
131
|
+
const d = path.join(dir, n)
|
|
132
|
+
await fs.mkdir(d, { recursive: true })
|
|
133
|
+
await fs.writeFile(path.join(d, 'x.txt'), 'x')
|
|
134
|
+
}
|
|
135
|
+
const removed = await pruneUploadDirs(dir, new Date(), 2)
|
|
136
|
+
assert.equal(removed, 1)
|
|
137
|
+
const left = (await fs.readdir(dir)).sort()
|
|
138
|
+
assert.deepEqual(left, ['20260102-000000-bbbb', '20260103-000000-cccc'])
|
|
139
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
140
|
+
})
|