@xcanwin/manyoyo 5.8.9 → 5.8.10
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/bin/manyoyo.js +24 -66
- package/lib/plugin/playwright.js +1049 -169
- package/lib/web/server.js +2173 -209
- package/package.json +1 -1
- package/lib/plugin/playwright-bootstrap.js +0 -116
- package/lib/plugin/playwright-command-output.js +0 -95
- package/lib/plugin/playwright-container-runtime.js +0 -94
- package/lib/plugin/playwright-extension-manager.js +0 -265
- package/lib/plugin/playwright-extension-paths.js +0 -98
- package/lib/plugin/playwright-host-runtime.js +0 -114
- package/lib/plugin/playwright-scene-config.js +0 -137
- package/lib/plugin/playwright-scene-drivers.js +0 -285
- package/lib/plugin/playwright-scene-state.js +0 -80
- package/lib/web/agent-command.js +0 -153
- package/lib/web/api-route-helpers.js +0 -88
- package/lib/web/container-exec.js +0 -215
- package/lib/web/http-handlers.js +0 -163
- package/lib/web/runtime-state.js +0 -50
- package/lib/web/server-context.js +0 -71
- package/lib/web/server-lifecycle.js +0 -129
- package/lib/web/session-api-routes.js +0 -390
- package/lib/web/structured-output.js +0 -149
- package/lib/web/structured-trace.js +0 -603
- package/lib/web/system-api-routes.js +0 -114
- package/lib/web/terminal-session.js +0 -205
- package/lib/web/upgrade-handler.js +0 -94
package/lib/web/server.js
CHANGED
|
@@ -12,24 +12,6 @@ const { buildContainerRunArgs } = require('../container-run');
|
|
|
12
12
|
const { extractAgentMessageFromCodexJsonl } = require('../codex-output');
|
|
13
13
|
const { findValueRangeByPath, applyTextReplacements } = require('../json5-text-edit');
|
|
14
14
|
const { resolveRuntimeConfig } = require('../runtime-resolver');
|
|
15
|
-
const {
|
|
16
|
-
normalizeAgentPromptCommandTemplate,
|
|
17
|
-
isAgentPromptCommandEnabled,
|
|
18
|
-
buildWebAgentExecCommand,
|
|
19
|
-
getAgentRuntimeMeta
|
|
20
|
-
} = require('./agent-command');
|
|
21
|
-
const { createStructuredOutputHelpers } = require('./structured-output');
|
|
22
|
-
const { prepareStructuredTraceEvents, extractContentDeltaFromPayload } = require('./structured-trace');
|
|
23
|
-
const { createWebContainerExecHelpers } = require('./container-exec');
|
|
24
|
-
const { createWebRuntimeStateHelpers } = require('./runtime-state');
|
|
25
|
-
const { createWebTerminalHelpers } = require('./terminal-session');
|
|
26
|
-
const { createWebUpgradeHandler } = require('./upgrade-handler');
|
|
27
|
-
const { createWebHttpHandlers } = require('./http-handlers');
|
|
28
|
-
const { createWebServerContextHelpers } = require('./server-context');
|
|
29
|
-
const { createWebServerLifecycleHelpers } = require('./server-lifecycle');
|
|
30
|
-
const { createApiRouteHelpers, runMatchedRoute } = require('./api-route-helpers');
|
|
31
|
-
const { createSystemApiRoutes } = require('./system-api-routes');
|
|
32
|
-
const { createSessionApiRoutes } = require('./session-api-routes');
|
|
33
15
|
const {
|
|
34
16
|
parseEnvEntry,
|
|
35
17
|
expandHomeAliasPath,
|
|
@@ -59,9 +41,6 @@ const WEB_DEFAULT_AGENT_ID = 'default';
|
|
|
59
41
|
const WEB_DEFAULT_AGENT_NAME = 'AGENT 1';
|
|
60
42
|
const WEB_CONFIG_KEEP_SECRET_PLACEHOLDER = '***HIDDEN_SECRET***';
|
|
61
43
|
const FRONTEND_DIR = path.join(__dirname, 'frontend');
|
|
62
|
-
const AUTH_FRONTEND_ASSETS = new Set(['login.css', 'login.js']);
|
|
63
|
-
const APP_FRONTEND_ASSETS = new Set(['app.css', 'app.js', 'markdown.css', 'markdown-renderer.js']);
|
|
64
|
-
const APP_VENDOR_ASSETS = new Set(['xterm.css', 'xterm.js', 'xterm-addon-fit.js', 'marked.min.js']);
|
|
65
44
|
const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
66
45
|
const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
|
|
67
46
|
const SENSITIVE_CONFIG_KEY_PATTERN = /(pass(word)?|passwd|secret|token|api(?:_|-)?key|auth(?:_|-)?token|oauth(?:_|-)?token)$/i;
|
|
@@ -545,95 +524,273 @@ function clipText(text, maxChars = WEB_OUTPUT_MAX_CHARS) {
|
|
|
545
524
|
return `${text.slice(0, maxChars)}\n...[truncated]`;
|
|
546
525
|
}
|
|
547
526
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
}
|
|
527
|
+
function normalizeAgentPromptCommandTemplate(value, sourceLabel = 'agentPromptCommand') {
|
|
528
|
+
if (value === undefined || value === null) {
|
|
529
|
+
return '';
|
|
530
|
+
}
|
|
531
|
+
if (typeof value !== 'string') {
|
|
532
|
+
throw new Error(`${sourceLabel} 必须是字符串`);
|
|
533
|
+
}
|
|
534
|
+
const text = value.trim();
|
|
535
|
+
if (!text) {
|
|
536
|
+
return '';
|
|
537
|
+
}
|
|
538
|
+
if (!text.includes('{prompt}')) {
|
|
539
|
+
throw new Error(`${sourceLabel} 必须包含 {prompt} 占位符`);
|
|
540
|
+
}
|
|
541
|
+
if (/^codex\s+exec(?:\s|$)/.test(text) && !text.includes('--skip-git-repo-check')) {
|
|
542
|
+
return text.replace(/^codex\s+exec\b/, 'codex exec --skip-git-repo-check');
|
|
543
|
+
}
|
|
544
|
+
return text;
|
|
545
|
+
}
|
|
565
546
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
} = createWebContainerExecHelpers({
|
|
570
|
-
buildWebSessionKey,
|
|
571
|
-
defaultAgentId: WEB_DEFAULT_AGENT_ID,
|
|
572
|
-
extractAgentMessageFromStructuredOutput,
|
|
573
|
-
parseJsonObjectLine,
|
|
574
|
-
prepareStructuredTraceEvents,
|
|
575
|
-
extractContentDeltaFromPayload,
|
|
576
|
-
structuredTraceDeps: STRUCTURED_TRACE_DEPS,
|
|
577
|
-
clipText,
|
|
578
|
-
stripAnsi
|
|
579
|
-
});
|
|
547
|
+
function isAgentPromptCommandEnabled(value) {
|
|
548
|
+
return typeof value === 'string' && value.includes('{prompt}') && Boolean(value.trim());
|
|
549
|
+
}
|
|
580
550
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
} = createWebRuntimeStateHelpers();
|
|
551
|
+
function quoteBashSingleValue(value) {
|
|
552
|
+
const text = String(value || '');
|
|
553
|
+
return `'${text.replace(/'/g, `'\"'\"'`)}'`;
|
|
554
|
+
}
|
|
586
555
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
getDefaultWebConfigPath
|
|
593
|
-
});
|
|
556
|
+
function renderAgentPromptCommand(template, prompt) {
|
|
557
|
+
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
558
|
+
const safePrompt = quoteBashSingleValue(prompt);
|
|
559
|
+
return templateText.replace(/\{prompt\}/g, safePrompt);
|
|
560
|
+
}
|
|
594
561
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
562
|
+
function buildCodexAgentExecCommand(template, prompt) {
|
|
563
|
+
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
564
|
+
const execMatch = templateText.match(
|
|
565
|
+
/^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)codex\s+exec\b/
|
|
566
|
+
);
|
|
567
|
+
let codexTemplate = templateText;
|
|
568
|
+
if (execMatch) {
|
|
569
|
+
const prefix = execMatch[1] || '';
|
|
570
|
+
const suffix = templateText.slice(execMatch[0].length);
|
|
571
|
+
const hasJson = /(?:^|\s)--json(?:\s|$)/.test(suffix);
|
|
572
|
+
const injectedFlags = hasJson ? '' : ' --json';
|
|
573
|
+
codexTemplate = `${prefix}codex exec${injectedFlags}${suffix}`;
|
|
574
|
+
}
|
|
575
|
+
return codexTemplate === templateText
|
|
576
|
+
? renderAgentPromptCommand(templateText, prompt)
|
|
577
|
+
: renderAgentPromptCommand(codexTemplate, prompt);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function prependAgentFlags(commandText, matchPattern, flagSpecs) {
|
|
581
|
+
const matched = String(commandText || '').match(matchPattern);
|
|
582
|
+
if (!matched) {
|
|
583
|
+
return String(commandText || '');
|
|
584
|
+
}
|
|
585
|
+
const prefix = matched[1] || '';
|
|
586
|
+
let suffix = matched[matched.length - 1] || '';
|
|
587
|
+
for (let i = flagSpecs.length - 1; i >= 0; i -= 1) {
|
|
588
|
+
const spec = flagSpecs[i];
|
|
589
|
+
if (!spec || !spec.flag || !(spec.pattern instanceof RegExp) || spec.pattern.test(suffix)) {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
suffix = ` ${spec.flag}${suffix}`;
|
|
593
|
+
}
|
|
594
|
+
return `${prefix}${suffix}`;
|
|
595
|
+
}
|
|
620
596
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
597
|
+
function buildClaudeAgentExecCommand(template, prompt) {
|
|
598
|
+
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
599
|
+
const claudeTemplate = prependAgentFlags(
|
|
600
|
+
templateText,
|
|
601
|
+
/^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/,
|
|
602
|
+
[
|
|
603
|
+
{ flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
|
|
604
|
+
{ flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
|
|
605
|
+
]
|
|
606
|
+
);
|
|
607
|
+
return claudeTemplate === templateText
|
|
608
|
+
? renderAgentPromptCommand(templateText, prompt)
|
|
609
|
+
: renderAgentPromptCommand(claudeTemplate, prompt);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function buildGeminiAgentExecCommand(template, prompt) {
|
|
613
|
+
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
614
|
+
const geminiTemplate = prependAgentFlags(
|
|
615
|
+
templateText,
|
|
616
|
+
/^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)gemini\b)(.*)$/,
|
|
617
|
+
[
|
|
618
|
+
{ flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
|
|
619
|
+
]
|
|
620
|
+
);
|
|
621
|
+
return geminiTemplate === templateText
|
|
622
|
+
? renderAgentPromptCommand(templateText, prompt)
|
|
623
|
+
: renderAgentPromptCommand(geminiTemplate, prompt);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function buildOpenCodeAgentExecCommand(template, prompt) {
|
|
627
|
+
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
628
|
+
const opencodeTemplate = prependAgentFlags(
|
|
629
|
+
templateText,
|
|
630
|
+
/^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)opencode\s+run\b)(.*)$/,
|
|
631
|
+
[
|
|
632
|
+
{ flag: '--format json', pattern: /(?:^|\s)--format(?:\s|$)/ }
|
|
633
|
+
]
|
|
634
|
+
);
|
|
635
|
+
return opencodeTemplate === templateText
|
|
636
|
+
? renderAgentPromptCommand(templateText, prompt)
|
|
637
|
+
: renderAgentPromptCommand(opencodeTemplate, prompt);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function buildWebAgentExecCommand(template, prompt, agentProgram) {
|
|
641
|
+
switch (agentProgram) {
|
|
642
|
+
case 'claude':
|
|
643
|
+
return buildClaudeAgentExecCommand(template, prompt);
|
|
644
|
+
case 'gemini':
|
|
645
|
+
return buildGeminiAgentExecCommand(template, prompt);
|
|
646
|
+
case 'codex':
|
|
647
|
+
return buildCodexAgentExecCommand(template, prompt);
|
|
648
|
+
case 'opencode':
|
|
649
|
+
return buildOpenCodeAgentExecCommand(template, prompt);
|
|
650
|
+
default:
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
return renderAgentPromptCommand(template, prompt);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function parseJsonObjectLine(line) {
|
|
657
|
+
const text = String(line || '').trim();
|
|
658
|
+
if (!text) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
const payload = JSON.parse(text);
|
|
663
|
+
return payload && typeof payload === 'object' ? payload : null;
|
|
664
|
+
} catch (e) {
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function collectStructuredText(value) {
|
|
670
|
+
if (typeof value === 'string') {
|
|
671
|
+
return value.trim();
|
|
672
|
+
}
|
|
673
|
+
if (Array.isArray(value)) {
|
|
674
|
+
return value.map(item => collectStructuredText(item)).filter(Boolean).join('\n').trim();
|
|
675
|
+
}
|
|
676
|
+
if (!value || typeof value !== 'object') {
|
|
677
|
+
return '';
|
|
678
|
+
}
|
|
679
|
+
if (typeof value.text === 'string' && value.text.trim()) {
|
|
680
|
+
return value.text.trim();
|
|
681
|
+
}
|
|
682
|
+
if (typeof value.content === 'string' && value.content.trim()) {
|
|
683
|
+
return value.content.trim();
|
|
684
|
+
}
|
|
685
|
+
if (Array.isArray(value.content)) {
|
|
686
|
+
return value.content.map(item => collectStructuredText(item)).filter(Boolean).join('\n').trim();
|
|
687
|
+
}
|
|
688
|
+
return '';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function extractClaudeAgentMessage(text) {
|
|
692
|
+
let lastMessage = '';
|
|
693
|
+
for (const rawLine of String(text || '').split('\n')) {
|
|
694
|
+
const payload = parseJsonObjectLine(rawLine);
|
|
695
|
+
if (!payload || payload.type !== 'assistant') {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const message = toPlainObject(payload.message);
|
|
699
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
700
|
+
const nextMessage = content
|
|
701
|
+
.filter(item => item && typeof item === 'object' && item.type === 'text')
|
|
702
|
+
.map(item => collectStructuredText(item))
|
|
703
|
+
.filter(Boolean)
|
|
704
|
+
.join('\n')
|
|
705
|
+
.trim();
|
|
706
|
+
if (nextMessage) {
|
|
707
|
+
lastMessage = nextMessage;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return lastMessage.trim();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function extractGeminiAgentMessage(text) {
|
|
714
|
+
let lastMessage = '';
|
|
715
|
+
let deltaMessage = '';
|
|
716
|
+
for (const rawLine of String(text || '').split('\n')) {
|
|
717
|
+
const payload = parseJsonObjectLine(rawLine);
|
|
718
|
+
if (!payload || payload.type !== 'message' || payload.role !== 'assistant') {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const content = collectStructuredText(payload.content);
|
|
722
|
+
if (!content) {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (payload.delta === true) {
|
|
726
|
+
deltaMessage += content;
|
|
727
|
+
lastMessage = deltaMessage.trim();
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
deltaMessage = '';
|
|
731
|
+
lastMessage = content;
|
|
732
|
+
}
|
|
733
|
+
return lastMessage.trim();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function extractOpenCodeAgentMessage(text) {
|
|
737
|
+
let lastMessage = '';
|
|
738
|
+
let deltaMessage = '';
|
|
739
|
+
for (const rawLine of String(text || '').split('\n')) {
|
|
740
|
+
const payload = parseJsonObjectLine(rawLine);
|
|
741
|
+
if (!payload) {
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const eventType = pickFirstString(payload.type);
|
|
745
|
+
const message = toPlainObject(payload.message);
|
|
746
|
+
const role = pickFirstString(payload.role, message.role);
|
|
747
|
+
if (eventType !== 'message' && eventType !== 'assistant' && eventType !== 'assistant_message' && eventType !== 'text') {
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (role && role !== 'assistant') {
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
const content = collectStructuredText(message.content || payload.content || payload.text || payload);
|
|
754
|
+
if (!content) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (payload.delta === true) {
|
|
758
|
+
deltaMessage += content;
|
|
759
|
+
lastMessage = deltaMessage.trim();
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
deltaMessage = '';
|
|
763
|
+
lastMessage = content;
|
|
764
|
+
}
|
|
765
|
+
return lastMessage.trim();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function extractAgentMessageFromStructuredOutput(agentProgram, text) {
|
|
769
|
+
if (agentProgram === 'codex') {
|
|
770
|
+
return extractAgentMessageFromCodexJsonl(text);
|
|
771
|
+
}
|
|
772
|
+
if (agentProgram === 'claude') {
|
|
773
|
+
return extractClaudeAgentMessage(text);
|
|
774
|
+
}
|
|
775
|
+
if (agentProgram === 'gemini') {
|
|
776
|
+
return extractGeminiAgentMessage(text);
|
|
777
|
+
}
|
|
778
|
+
if (agentProgram === 'opencode') {
|
|
779
|
+
return extractOpenCodeAgentMessage(text);
|
|
780
|
+
}
|
|
781
|
+
return '';
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function getAgentRuntimeMeta(template) {
|
|
785
|
+
const normalizedTemplate = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
786
|
+
const agentProgram = resolveAgentProgram(normalizedTemplate);
|
|
787
|
+
const resumeCommand = buildAgentResumeCommand(agentProgram);
|
|
788
|
+
return {
|
|
789
|
+
agentProgram: agentProgram || '',
|
|
790
|
+
resumeCommand: resumeCommand || '',
|
|
791
|
+
resumeSupported: Boolean(resumeCommand)
|
|
792
|
+
};
|
|
793
|
+
}
|
|
637
794
|
|
|
638
795
|
function hasAgentConversationHistory(history) {
|
|
639
796
|
const messages = history && Array.isArray(history.messages) ? history.messages : [];
|
|
@@ -695,6 +852,599 @@ function buildAgentPromptWithHistory(history, prompt) {
|
|
|
695
852
|
].join('\n');
|
|
696
853
|
}
|
|
697
854
|
|
|
855
|
+
function shortenTraceText(value, maxChars = 140) {
|
|
856
|
+
const raw = clipText(stripAnsi(String(value || '')).replace(/\s+/g, ' ').trim(), maxChars);
|
|
857
|
+
return raw.trim();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function summarizeTraceArguments(args) {
|
|
861
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
862
|
+
return '';
|
|
863
|
+
}
|
|
864
|
+
const parts = [];
|
|
865
|
+
for (const [key, value] of Object.entries(args)) {
|
|
866
|
+
if (value === undefined || value === null) continue;
|
|
867
|
+
if (typeof value === 'string') {
|
|
868
|
+
const textValue = value.trim();
|
|
869
|
+
if (!textValue) continue;
|
|
870
|
+
parts.push(`${key}=${shortenTraceText(textValue, 80)}`);
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
874
|
+
parts.push(`${key}=${String(value)}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return parts.slice(0, 3).join(', ');
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function createStructuredTraceEvent(provider, kind, eventType, textValue, extra = {}) {
|
|
881
|
+
const normalizedText = String(textValue || '').trim();
|
|
882
|
+
if (!normalizedText) {
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
provider,
|
|
887
|
+
kind,
|
|
888
|
+
eventType,
|
|
889
|
+
text: normalizedText,
|
|
890
|
+
...extra
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function prepareClaudeTraceEvents(payload, state) {
|
|
895
|
+
const eventType = pickFirstString(payload.type);
|
|
896
|
+
const subtype = pickFirstString(payload.subtype);
|
|
897
|
+
const message = toPlainObject(payload.message);
|
|
898
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
899
|
+
const toolNamesById = state.toolNamesById;
|
|
900
|
+
const events = [];
|
|
901
|
+
|
|
902
|
+
if (eventType === 'system' && subtype === 'init') {
|
|
903
|
+
events.push(createStructuredTraceEvent('claude', 'thread', eventType, '[会话] Claude 已开始处理', {
|
|
904
|
+
phase: 'started',
|
|
905
|
+
status: 'started',
|
|
906
|
+
subtype
|
|
907
|
+
}));
|
|
908
|
+
return events.filter(Boolean);
|
|
909
|
+
}
|
|
910
|
+
if (eventType === 'assistant') {
|
|
911
|
+
content.forEach(item => {
|
|
912
|
+
if (!item || typeof item !== 'object') {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (item.type === 'text') {
|
|
916
|
+
const detail = collectStructuredText(item);
|
|
917
|
+
if (detail) {
|
|
918
|
+
events.push(createStructuredTraceEvent('claude', 'agent_message', eventType, `[说明] ${detail}`, {
|
|
919
|
+
phase: 'completed',
|
|
920
|
+
status: 'completed',
|
|
921
|
+
detail
|
|
922
|
+
}));
|
|
923
|
+
}
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (item.type === 'tool_use') {
|
|
927
|
+
const toolName = pickFirstString(item.name, item.id, 'tool');
|
|
928
|
+
const toolId = pickFirstString(item.id);
|
|
929
|
+
if (toolId) {
|
|
930
|
+
toolNamesById.set(toolId, toolName);
|
|
931
|
+
}
|
|
932
|
+
const summary = summarizeTraceArguments(toPlainObject(item.input));
|
|
933
|
+
events.push(createStructuredTraceEvent(
|
|
934
|
+
'claude',
|
|
935
|
+
'tool',
|
|
936
|
+
eventType,
|
|
937
|
+
summary ? `[工具开始] ${toolName} (${summary})` : `[工具开始] ${toolName}`,
|
|
938
|
+
{
|
|
939
|
+
phase: 'started',
|
|
940
|
+
status: 'in_progress',
|
|
941
|
+
toolName,
|
|
942
|
+
toolId,
|
|
943
|
+
arguments: toPlainObject(item.input),
|
|
944
|
+
argumentSummary: summary
|
|
945
|
+
}
|
|
946
|
+
));
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
return events.filter(Boolean);
|
|
950
|
+
}
|
|
951
|
+
if (eventType === 'user') {
|
|
952
|
+
content.forEach(item => {
|
|
953
|
+
if (!item || typeof item !== 'object' || item.type !== 'tool_result') {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const toolId = pickFirstString(item.tool_use_id);
|
|
957
|
+
const toolName = pickFirstString(toolNamesById.get(toolId), toolId, 'tool');
|
|
958
|
+
const status = item.is_error === true ? 'error' : 'success';
|
|
959
|
+
events.push(createStructuredTraceEvent('claude', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
|
|
960
|
+
phase: 'completed',
|
|
961
|
+
status,
|
|
962
|
+
toolName,
|
|
963
|
+
toolId,
|
|
964
|
+
result: collectStructuredText(item.content),
|
|
965
|
+
error: item.is_error === true ? collectStructuredText(item.content) : ''
|
|
966
|
+
}));
|
|
967
|
+
});
|
|
968
|
+
return events.filter(Boolean);
|
|
969
|
+
}
|
|
970
|
+
if (eventType === 'result') {
|
|
971
|
+
events.push(createStructuredTraceEvent('claude', 'turn', eventType, '[回合] 响应完成', {
|
|
972
|
+
phase: 'completed',
|
|
973
|
+
status: pickFirstString(subtype, 'completed'),
|
|
974
|
+
subtype
|
|
975
|
+
}));
|
|
976
|
+
return events.filter(Boolean);
|
|
977
|
+
}
|
|
978
|
+
if (eventType === 'error') {
|
|
979
|
+
const detail = pickFirstString(payload.message, payload.error);
|
|
980
|
+
events.push(createStructuredTraceEvent('claude', 'error', eventType, detail ? `[错误] ${detail}` : '[错误] Claude 返回了错误事件', {
|
|
981
|
+
status: 'error',
|
|
982
|
+
detail
|
|
983
|
+
}));
|
|
984
|
+
return events.filter(Boolean);
|
|
985
|
+
}
|
|
986
|
+
return [];
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function prepareGeminiTraceEvents(payload, state) {
|
|
990
|
+
const eventType = pickFirstString(payload.type);
|
|
991
|
+
const toolNamesById = state.toolNamesById;
|
|
992
|
+
const events = [];
|
|
993
|
+
|
|
994
|
+
if (eventType === 'init') {
|
|
995
|
+
events.push(createStructuredTraceEvent('gemini', 'thread', eventType, '[会话] Gemini 已开始处理', {
|
|
996
|
+
phase: 'started',
|
|
997
|
+
status: 'started',
|
|
998
|
+
sessionId: pickFirstString(payload.session_id),
|
|
999
|
+
model: pickFirstString(payload.model)
|
|
1000
|
+
}));
|
|
1001
|
+
return events.filter(Boolean);
|
|
1002
|
+
}
|
|
1003
|
+
if (eventType === 'message' && payload.role === 'assistant') {
|
|
1004
|
+
if (payload.delta === true) {
|
|
1005
|
+
return [];
|
|
1006
|
+
}
|
|
1007
|
+
const detail = collectStructuredText(payload.content);
|
|
1008
|
+
if (!detail) {
|
|
1009
|
+
return [];
|
|
1010
|
+
}
|
|
1011
|
+
events.push(createStructuredTraceEvent('gemini', 'agent_message', eventType, `[说明] ${detail}`, {
|
|
1012
|
+
phase: 'completed',
|
|
1013
|
+
status: 'completed',
|
|
1014
|
+
detail
|
|
1015
|
+
}));
|
|
1016
|
+
return events.filter(Boolean);
|
|
1017
|
+
}
|
|
1018
|
+
if (eventType === 'tool_use') {
|
|
1019
|
+
const toolName = pickFirstString(payload.tool_name, payload.tool_id, 'tool');
|
|
1020
|
+
const toolId = pickFirstString(payload.tool_id);
|
|
1021
|
+
if (toolId) {
|
|
1022
|
+
toolNamesById.set(toolId, toolName);
|
|
1023
|
+
}
|
|
1024
|
+
const summary = summarizeTraceArguments(toPlainObject(payload.parameters));
|
|
1025
|
+
events.push(createStructuredTraceEvent(
|
|
1026
|
+
'gemini',
|
|
1027
|
+
'tool',
|
|
1028
|
+
eventType,
|
|
1029
|
+
summary ? `[工具开始] ${toolName} (${summary})` : `[工具开始] ${toolName}`,
|
|
1030
|
+
{
|
|
1031
|
+
phase: 'started',
|
|
1032
|
+
status: 'in_progress',
|
|
1033
|
+
toolName,
|
|
1034
|
+
toolId,
|
|
1035
|
+
arguments: toPlainObject(payload.parameters),
|
|
1036
|
+
argumentSummary: summary
|
|
1037
|
+
}
|
|
1038
|
+
));
|
|
1039
|
+
return events.filter(Boolean);
|
|
1040
|
+
}
|
|
1041
|
+
if (eventType === 'tool_result') {
|
|
1042
|
+
const toolId = pickFirstString(payload.tool_id);
|
|
1043
|
+
const toolName = pickFirstString(toolNamesById.get(toolId), toolId, 'tool');
|
|
1044
|
+
const status = pickFirstString(payload.status, 'completed');
|
|
1045
|
+
events.push(createStructuredTraceEvent('gemini', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
|
|
1046
|
+
phase: 'completed',
|
|
1047
|
+
status,
|
|
1048
|
+
toolName,
|
|
1049
|
+
toolId,
|
|
1050
|
+
result: collectStructuredText(payload.output),
|
|
1051
|
+
error: toPlainObject(payload.error)
|
|
1052
|
+
}));
|
|
1053
|
+
return events.filter(Boolean);
|
|
1054
|
+
}
|
|
1055
|
+
if (eventType === 'result') {
|
|
1056
|
+
events.push(createStructuredTraceEvent('gemini', 'turn', eventType, '[回合] 响应完成', {
|
|
1057
|
+
phase: 'completed',
|
|
1058
|
+
status: pickFirstString(payload.status, 'completed')
|
|
1059
|
+
}));
|
|
1060
|
+
return events.filter(Boolean);
|
|
1061
|
+
}
|
|
1062
|
+
if (eventType === 'error') {
|
|
1063
|
+
const detail = pickFirstString(payload.message);
|
|
1064
|
+
events.push(createStructuredTraceEvent('gemini', 'error', eventType, detail ? `[错误] ${detail}` : '[错误] Gemini 返回了错误事件', {
|
|
1065
|
+
status: pickFirstString(payload.severity, 'error'),
|
|
1066
|
+
detail
|
|
1067
|
+
}));
|
|
1068
|
+
return events.filter(Boolean);
|
|
1069
|
+
}
|
|
1070
|
+
return [];
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function prepareOpenCodeTraceEvents(payload, state) {
|
|
1074
|
+
const eventType = pickFirstString(payload.type);
|
|
1075
|
+
const message = toPlainObject(payload.message);
|
|
1076
|
+
const role = pickFirstString(payload.role, message.role);
|
|
1077
|
+
const toolNamesById = state.toolNamesById;
|
|
1078
|
+
const events = [];
|
|
1079
|
+
|
|
1080
|
+
if (eventType === 'session.start' || eventType === 'init') {
|
|
1081
|
+
events.push(createStructuredTraceEvent('opencode', 'thread', eventType, '[会话] OpenCode 已开始处理', {
|
|
1082
|
+
phase: 'started',
|
|
1083
|
+
status: 'started',
|
|
1084
|
+
sessionId: pickFirstString(payload.session_id, payload.sessionID)
|
|
1085
|
+
}));
|
|
1086
|
+
return events.filter(Boolean);
|
|
1087
|
+
}
|
|
1088
|
+
if (eventType === 'message' || eventType === 'assistant' || eventType === 'assistant_message' || eventType === 'text') {
|
|
1089
|
+
if (role && role !== 'assistant') {
|
|
1090
|
+
return [];
|
|
1091
|
+
}
|
|
1092
|
+
if (payload.delta === true) {
|
|
1093
|
+
return [];
|
|
1094
|
+
}
|
|
1095
|
+
const detail = collectStructuredText(message.content || payload.content || payload.text || payload);
|
|
1096
|
+
if (!detail) {
|
|
1097
|
+
return [];
|
|
1098
|
+
}
|
|
1099
|
+
events.push(createStructuredTraceEvent('opencode', 'agent_message', eventType, `[说明] ${detail}`, {
|
|
1100
|
+
phase: 'completed',
|
|
1101
|
+
status: 'completed',
|
|
1102
|
+
detail
|
|
1103
|
+
}));
|
|
1104
|
+
return events.filter(Boolean);
|
|
1105
|
+
}
|
|
1106
|
+
if (eventType === 'tool_use' || eventType === 'step_start') {
|
|
1107
|
+
const toolName = pickFirstString(payload.tool_name, payload.name, payload.tool, payload.step, payload.tool_id, 'tool');
|
|
1108
|
+
const toolId = pickFirstString(payload.tool_id, payload.id);
|
|
1109
|
+
if (toolId) {
|
|
1110
|
+
toolNamesById.set(toolId, toolName);
|
|
1111
|
+
}
|
|
1112
|
+
const argumentsValue = toPlainObject(payload.parameters || payload.input || payload.arguments);
|
|
1113
|
+
const summary = summarizeTraceArguments(argumentsValue);
|
|
1114
|
+
events.push(createStructuredTraceEvent(
|
|
1115
|
+
'opencode',
|
|
1116
|
+
'tool',
|
|
1117
|
+
eventType,
|
|
1118
|
+
summary ? `[工具开始] ${toolName} (${summary})` : `[工具开始] ${toolName}`,
|
|
1119
|
+
{
|
|
1120
|
+
phase: 'started',
|
|
1121
|
+
status: pickFirstString(payload.status, 'in_progress'),
|
|
1122
|
+
toolName,
|
|
1123
|
+
toolId,
|
|
1124
|
+
arguments: argumentsValue,
|
|
1125
|
+
argumentSummary: summary
|
|
1126
|
+
}
|
|
1127
|
+
));
|
|
1128
|
+
return events.filter(Boolean);
|
|
1129
|
+
}
|
|
1130
|
+
if (eventType === 'tool_result' || eventType === 'step_finish') {
|
|
1131
|
+
const toolId = pickFirstString(payload.tool_id, payload.id);
|
|
1132
|
+
const toolName = pickFirstString(toolNamesById.get(toolId), payload.tool_name, payload.name, payload.tool, toolId, 'tool');
|
|
1133
|
+
const status = pickFirstString(payload.status, payload.state, 'completed');
|
|
1134
|
+
events.push(createStructuredTraceEvent('opencode', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
|
|
1135
|
+
phase: 'completed',
|
|
1136
|
+
status,
|
|
1137
|
+
toolName,
|
|
1138
|
+
toolId,
|
|
1139
|
+
result: collectStructuredText(payload.output || payload.result),
|
|
1140
|
+
error: toPlainObject(payload.error)
|
|
1141
|
+
}));
|
|
1142
|
+
return events.filter(Boolean);
|
|
1143
|
+
}
|
|
1144
|
+
if (eventType === 'result') {
|
|
1145
|
+
events.push(createStructuredTraceEvent('opencode', 'turn', eventType, '[回合] 响应完成', {
|
|
1146
|
+
phase: 'completed',
|
|
1147
|
+
status: pickFirstString(payload.status, 'completed')
|
|
1148
|
+
}));
|
|
1149
|
+
return events.filter(Boolean);
|
|
1150
|
+
}
|
|
1151
|
+
if (eventType === 'error') {
|
|
1152
|
+
const detail = pickFirstString(payload.message, payload.error && payload.error.message);
|
|
1153
|
+
events.push(createStructuredTraceEvent('opencode', 'error', eventType, detail ? `[错误] ${detail}` : '[错误] OpenCode 返回了错误事件', {
|
|
1154
|
+
status: 'error',
|
|
1155
|
+
detail
|
|
1156
|
+
}));
|
|
1157
|
+
return events.filter(Boolean);
|
|
1158
|
+
}
|
|
1159
|
+
return [];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function prepareStructuredTraceEvents(agentProgram, payload, state) {
|
|
1163
|
+
if (!payload || typeof payload !== 'object') {
|
|
1164
|
+
return [];
|
|
1165
|
+
}
|
|
1166
|
+
if (agentProgram === 'codex') {
|
|
1167
|
+
const traceEvent = prepareCodexTraceEvent(payload);
|
|
1168
|
+
return traceEvent ? [traceEvent] : [];
|
|
1169
|
+
}
|
|
1170
|
+
if (agentProgram === 'claude') {
|
|
1171
|
+
return prepareClaudeTraceEvents(payload, state);
|
|
1172
|
+
}
|
|
1173
|
+
if (agentProgram === 'gemini') {
|
|
1174
|
+
return prepareGeminiTraceEvents(payload, state);
|
|
1175
|
+
}
|
|
1176
|
+
if (agentProgram === 'opencode') {
|
|
1177
|
+
return prepareOpenCodeTraceEvents(payload, state);
|
|
1178
|
+
}
|
|
1179
|
+
return [];
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function extractContentDeltaFromPayload(agentProgram, payload) {
|
|
1183
|
+
if (!payload || typeof payload !== 'object') {
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
1186
|
+
if (agentProgram === 'claude') {
|
|
1187
|
+
if (pickFirstString(payload.type) !== 'assistant') {
|
|
1188
|
+
return null;
|
|
1189
|
+
}
|
|
1190
|
+
const message = toPlainObject(payload.message);
|
|
1191
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
1192
|
+
const text = content
|
|
1193
|
+
.filter(item => item && typeof item === 'object' && item.type === 'text')
|
|
1194
|
+
.map(item => collectStructuredText(item))
|
|
1195
|
+
.filter(Boolean)
|
|
1196
|
+
.join('\n')
|
|
1197
|
+
.trim();
|
|
1198
|
+
if (!text) {
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
1201
|
+
return { text, reset: true };
|
|
1202
|
+
}
|
|
1203
|
+
if (agentProgram === 'gemini' || agentProgram === 'opencode') {
|
|
1204
|
+
const eventType = pickFirstString(payload.type);
|
|
1205
|
+
if (eventType !== 'message') {
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
const role = pickFirstString(payload.role);
|
|
1209
|
+
if (role !== 'assistant') {
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
const text = collectStructuredText(payload.content);
|
|
1213
|
+
if (!text) {
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
if (payload.delta === true) {
|
|
1217
|
+
return { text, reset: false };
|
|
1218
|
+
}
|
|
1219
|
+
return { text, reset: true };
|
|
1220
|
+
}
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function prepareCodexTraceEvent(payload) {
|
|
1225
|
+
if (!payload || typeof payload !== 'object') {
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const eventType = typeof payload.type === 'string' ? payload.type : '';
|
|
1230
|
+
const item = payload.item && typeof payload.item === 'object' && !Array.isArray(payload.item)
|
|
1231
|
+
? payload.item
|
|
1232
|
+
: {};
|
|
1233
|
+
const itemType = typeof item.type === 'string' ? item.type : '';
|
|
1234
|
+
const text = pickFirstString(
|
|
1235
|
+
item.title,
|
|
1236
|
+
item.summary,
|
|
1237
|
+
item.text,
|
|
1238
|
+
item.name,
|
|
1239
|
+
item.command,
|
|
1240
|
+
payload.message,
|
|
1241
|
+
payload.text
|
|
1242
|
+
);
|
|
1243
|
+
const toolName = pickFirstString(
|
|
1244
|
+
item.name,
|
|
1245
|
+
item.tool_name,
|
|
1246
|
+
item.tool,
|
|
1247
|
+
item.command
|
|
1248
|
+
);
|
|
1249
|
+
const commandText = pickFirstString(item.command);
|
|
1250
|
+
const mcpServer = pickFirstString(item.server);
|
|
1251
|
+
const mcpTool = pickFirstString(item.tool);
|
|
1252
|
+
const itemStatus = pickFirstString(item.status);
|
|
1253
|
+
|
|
1254
|
+
function shortenText(value, maxChars = 140) {
|
|
1255
|
+
const raw = clipText(stripAnsi(String(value || '')).replace(/\s+/g, ' ').trim(), maxChars);
|
|
1256
|
+
return raw.trim();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function summarizeArguments(args) {
|
|
1260
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
1261
|
+
return '';
|
|
1262
|
+
}
|
|
1263
|
+
const parts = [];
|
|
1264
|
+
for (const [key, value] of Object.entries(args)) {
|
|
1265
|
+
if (value === undefined || value === null) continue;
|
|
1266
|
+
if (typeof value === 'string') {
|
|
1267
|
+
const textValue = value.trim();
|
|
1268
|
+
if (!textValue) continue;
|
|
1269
|
+
parts.push(`${key}=${shortenText(textValue, 80)}`);
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
1273
|
+
parts.push(`${key}=${String(value)}`);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return parts.slice(0, 3).join(', ');
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function pickDisplayStatus(defaultStatus) {
|
|
1280
|
+
const status = String(itemStatus || defaultStatus || '').trim();
|
|
1281
|
+
return status || '';
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function createTraceEvent(kind, textValue, extra = {}) {
|
|
1285
|
+
const normalizedText = String(textValue || '').trim();
|
|
1286
|
+
if (!normalizedText) {
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
return {
|
|
1290
|
+
provider: 'codex',
|
|
1291
|
+
kind,
|
|
1292
|
+
eventType,
|
|
1293
|
+
itemType: itemType || '',
|
|
1294
|
+
text: normalizedText,
|
|
1295
|
+
...extra
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (eventType === 'thread.started') {
|
|
1300
|
+
return createTraceEvent('thread', '[会话] Codex 已开始处理', {
|
|
1301
|
+
phase: 'started',
|
|
1302
|
+
status: 'started'
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
if (eventType === 'thread.completed') {
|
|
1306
|
+
return createTraceEvent('thread', '[会话] Codex 已完成当前任务', {
|
|
1307
|
+
phase: 'completed',
|
|
1308
|
+
status: 'completed'
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
if (eventType === 'turn.started') {
|
|
1312
|
+
return createTraceEvent('turn', '[回合] 开始生成响应', {
|
|
1313
|
+
phase: 'started',
|
|
1314
|
+
status: 'started'
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
if (eventType === 'turn.completed') {
|
|
1318
|
+
return createTraceEvent('turn', '[回合] 响应完成', {
|
|
1319
|
+
phase: 'completed',
|
|
1320
|
+
status: 'completed'
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
if (eventType === 'item.started') {
|
|
1324
|
+
if (itemType === 'tool_call') {
|
|
1325
|
+
return createTraceEvent('tool', `[工具开始] ${toolName || 'tool_call'}`, {
|
|
1326
|
+
phase: 'started',
|
|
1327
|
+
status: pickDisplayStatus('in_progress'),
|
|
1328
|
+
toolName: toolName || 'tool_call'
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
if (itemType === 'command_execution') {
|
|
1332
|
+
return createTraceEvent('command', `[命令开始] ${commandText || 'command_execution'}`, {
|
|
1333
|
+
phase: 'started',
|
|
1334
|
+
status: pickDisplayStatus('in_progress'),
|
|
1335
|
+
command: commandText || 'command_execution'
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
if (itemType === 'mcp_tool_call') {
|
|
1339
|
+
const summary = summarizeArguments(item.arguments);
|
|
1340
|
+
return createTraceEvent(
|
|
1341
|
+
'mcp',
|
|
1342
|
+
summary
|
|
1343
|
+
? `[MCP开始] ${mcpServer || 'mcp'}.${mcpTool || 'tool'} (${summary})`
|
|
1344
|
+
: `[MCP开始] ${mcpServer || 'mcp'}.${mcpTool || 'tool'}`,
|
|
1345
|
+
{
|
|
1346
|
+
phase: 'started',
|
|
1347
|
+
status: pickDisplayStatus('in_progress'),
|
|
1348
|
+
server: mcpServer || 'mcp',
|
|
1349
|
+
tool: mcpTool || 'tool',
|
|
1350
|
+
arguments: item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)
|
|
1351
|
+
? item.arguments
|
|
1352
|
+
: null,
|
|
1353
|
+
argumentSummary: summary
|
|
1354
|
+
}
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
if (itemType === 'reasoning') {
|
|
1358
|
+
return createTraceEvent('status', text ? `[状态] ${text}` : '[状态] Codex 正在分析', {
|
|
1359
|
+
phase: 'started',
|
|
1360
|
+
status: pickDisplayStatus('in_progress'),
|
|
1361
|
+
detail: text || 'Codex 正在分析'
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
if (itemType === 'agent_message') {
|
|
1365
|
+
return createTraceEvent('agent_message', text ? `[说明] ${text}` : '[回复] 正在生成最终答复', {
|
|
1366
|
+
phase: 'started',
|
|
1367
|
+
status: pickDisplayStatus('in_progress'),
|
|
1368
|
+
detail: text || '正在生成最终答复'
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
return createTraceEvent('event', text ? `[事件开始] ${text}` : `[事件开始] ${itemType || eventType}`, {
|
|
1372
|
+
phase: 'started',
|
|
1373
|
+
status: pickDisplayStatus('in_progress'),
|
|
1374
|
+
detail: text || itemType || eventType
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
if (eventType === 'item.completed') {
|
|
1378
|
+
if (itemType === 'tool_call') {
|
|
1379
|
+
return createTraceEvent('tool', `[工具完成] ${toolName || 'tool_call'}`, {
|
|
1380
|
+
phase: 'completed',
|
|
1381
|
+
status: pickDisplayStatus('completed'),
|
|
1382
|
+
toolName: toolName || 'tool_call'
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
if (itemType === 'command_execution') {
|
|
1386
|
+
const suffix = itemStatus || (typeof item.exit_code === 'number' ? `exit=${item.exit_code}` : 'completed');
|
|
1387
|
+
return createTraceEvent('command', `[命令完成] ${commandText || 'command_execution'} (${suffix})`, {
|
|
1388
|
+
phase: 'completed',
|
|
1389
|
+
status: pickDisplayStatus(suffix),
|
|
1390
|
+
command: commandText || 'command_execution',
|
|
1391
|
+
exitCode: typeof item.exit_code === 'number' ? item.exit_code : null
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
if (itemType === 'mcp_tool_call') {
|
|
1395
|
+
const summary = summarizeArguments(item.arguments);
|
|
1396
|
+
return createTraceEvent(
|
|
1397
|
+
'mcp',
|
|
1398
|
+
summary
|
|
1399
|
+
? `[MCP完成] ${mcpServer || 'mcp'}.${mcpTool || 'tool'} (${summary})`
|
|
1400
|
+
: `[MCP完成] ${mcpServer || 'mcp'}.${mcpTool || 'tool'}`,
|
|
1401
|
+
{
|
|
1402
|
+
phase: 'completed',
|
|
1403
|
+
status: pickDisplayStatus('completed'),
|
|
1404
|
+
server: mcpServer || 'mcp',
|
|
1405
|
+
tool: mcpTool || 'tool',
|
|
1406
|
+
arguments: item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)
|
|
1407
|
+
? item.arguments
|
|
1408
|
+
: null,
|
|
1409
|
+
argumentSummary: summary,
|
|
1410
|
+
result: item.result !== undefined ? item.result : null,
|
|
1411
|
+
error: item.error !== undefined ? item.error : null
|
|
1412
|
+
}
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
if (itemType === 'reasoning') {
|
|
1416
|
+
return createTraceEvent('status', text ? `[状态] ${text}` : '', {
|
|
1417
|
+
phase: 'completed',
|
|
1418
|
+
status: pickDisplayStatus('completed'),
|
|
1419
|
+
detail: text || ''
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
if (itemType === 'agent_message') {
|
|
1423
|
+
return createTraceEvent('agent_message', text ? `[说明] ${text}` : '[回复] 已生成', {
|
|
1424
|
+
phase: 'completed',
|
|
1425
|
+
status: pickDisplayStatus('completed'),
|
|
1426
|
+
detail: text || '已生成'
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return createTraceEvent('event', text ? `[事件完成] ${text}` : `[事件完成] ${itemType || eventType}`, {
|
|
1430
|
+
phase: 'completed',
|
|
1431
|
+
status: pickDisplayStatus('completed'),
|
|
1432
|
+
detail: text || itemType || eventType
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
if (eventType === 'error') {
|
|
1436
|
+
return createTraceEvent('error', text ? `[错误] ${text}` : '[错误] Codex 返回了错误事件', {
|
|
1437
|
+
status: 'error',
|
|
1438
|
+
detail: text || 'Codex 返回了错误事件'
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
return createTraceEvent('event', `[事件] ${eventType}`, {
|
|
1443
|
+
status: itemStatus || '',
|
|
1444
|
+
detail: eventType
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
|
|
698
1448
|
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
699
1449
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
700
1450
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true });
|
|
@@ -1415,12 +2165,12 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
1415
2165
|
const hasConfigVolumes = hasOwn(config, 'volumes');
|
|
1416
2166
|
const hasConfigPorts = hasOwn(config, 'ports');
|
|
1417
2167
|
|
|
2168
|
+
const requestName = pickFirstString(requestOptions.containerName, body.name);
|
|
1418
2169
|
const requestEnvMap = hasRequestEnv ? normalizeEnvMap(requestOptions.env, 'createOptions.env') : {};
|
|
1419
2170
|
const requestEnvList = Object.entries(requestEnvMap).map(([key, value]) => `${key}=${value}`);
|
|
1420
2171
|
const requestEnvFileList = hasRequestEnvFile ? normalizeStringArray(requestOptions.envFile, 'createOptions.envFile') : [];
|
|
1421
2172
|
const requestVolumeList = hasRequestVolumes ? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes') : [];
|
|
1422
2173
|
const requestPortList = hasRequestPorts ? normalizeStringArray(requestOptions.ports, 'createOptions.ports') : [];
|
|
1423
|
-
const requestName = pickFirstString(requestOptions.containerName, body.name);
|
|
1424
2174
|
|
|
1425
2175
|
const resolvedBase = resolveRuntimeConfig({
|
|
1426
2176
|
cliOptions: {
|
|
@@ -1533,14 +2283,16 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
1533
2283
|
const hasRunEnv = hasOwn(runConfig, 'env');
|
|
1534
2284
|
const hasRunEnvFile = hasOwn(runConfig, 'envFile');
|
|
1535
2285
|
if (hasRequestEnv || hasRequestEnvFile || hasRunEnv || hasRunEnvFile || hasConfigEnv || hasConfigEnvFile) {
|
|
2286
|
+
const mergedEnv = resolvedBase.env;
|
|
1536
2287
|
const envArgs = [];
|
|
1537
|
-
Object.entries(
|
|
2288
|
+
Object.entries(mergedEnv).forEach(([key, value]) => {
|
|
1538
2289
|
const parsed = parseEnvEntry(`${key}=${value}`);
|
|
1539
2290
|
envArgs.push('--env', `${parsed.key}=${parsed.value}`);
|
|
1540
2291
|
});
|
|
1541
2292
|
|
|
2293
|
+
const envFileList = resolvedBase.envFile;
|
|
1542
2294
|
const envFileArgs = [];
|
|
1543
|
-
|
|
2295
|
+
envFileList.forEach(filePath => {
|
|
1544
2296
|
envFileArgs.push(...parseEnvFileToArgs(filePath));
|
|
1545
2297
|
});
|
|
1546
2298
|
|
|
@@ -1550,8 +2302,9 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
1550
2302
|
let containerVolumes = Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [];
|
|
1551
2303
|
const hasRunVolumes = hasOwn(runConfig, 'volumes');
|
|
1552
2304
|
if (hasRequestVolumes || hasRunVolumes || hasConfigVolumes) {
|
|
2305
|
+
const volumeList = resolvedBase.volumes;
|
|
1553
2306
|
containerVolumes = [];
|
|
1554
|
-
|
|
2307
|
+
volumeList.forEach(volume => {
|
|
1555
2308
|
containerVolumes.push('--volume', normalizeVolume(volume));
|
|
1556
2309
|
});
|
|
1557
2310
|
}
|
|
@@ -1559,8 +2312,9 @@ function buildCreateRuntime(ctx, state, payload) {
|
|
|
1559
2312
|
let containerPorts = Array.isArray(ctx.containerPorts) ? ctx.containerPorts.slice() : [];
|
|
1560
2313
|
const hasRunPorts = hasOwn(runConfig, 'ports');
|
|
1561
2314
|
if (hasRequestPorts || hasRunPorts || hasConfigPorts) {
|
|
2315
|
+
const portList = resolvedBase.ports;
|
|
1562
2316
|
containerPorts = [];
|
|
1563
|
-
|
|
2317
|
+
portList.forEach(port => {
|
|
1564
2318
|
containerPorts.push('--publish', port);
|
|
1565
2319
|
});
|
|
1566
2320
|
}
|
|
@@ -1723,6 +2477,229 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
|
|
|
1723
2477
|
}
|
|
1724
2478
|
}
|
|
1725
2479
|
|
|
2480
|
+
async function execCommandInWebContainer(ctx, containerName, command, options = {}) {
|
|
2481
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
2482
|
+
const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
|
|
2483
|
+
return await new Promise((resolve, reject) => {
|
|
2484
|
+
const process = spawn(
|
|
2485
|
+
ctx.dockerCmd,
|
|
2486
|
+
['exec', containerName, '/bin/bash', '-lc', command],
|
|
2487
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
2488
|
+
);
|
|
2489
|
+
|
|
2490
|
+
const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
|
|
2491
|
+
let stdoutOutput = '';
|
|
2492
|
+
let stderrOutput = '';
|
|
2493
|
+
let stdoutTruncated = false;
|
|
2494
|
+
let stderrTruncated = false;
|
|
2495
|
+
|
|
2496
|
+
function appendChunk(chunk, target) {
|
|
2497
|
+
if (!chunk) return;
|
|
2498
|
+
const text = chunk.toString('utf-8');
|
|
2499
|
+
if (!text) return;
|
|
2500
|
+
if (target.value.length >= MAX_RAW_OUTPUT_CHARS) {
|
|
2501
|
+
target.truncated = true;
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
const remain = MAX_RAW_OUTPUT_CHARS - target.value.length;
|
|
2505
|
+
if (text.length > remain) {
|
|
2506
|
+
target.value += text.slice(0, remain);
|
|
2507
|
+
target.truncated = true;
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
target.value += text;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
process.stdout.on('data', chunk => appendChunk(chunk, {
|
|
2514
|
+
get value() { return stdoutOutput; },
|
|
2515
|
+
set value(nextValue) { stdoutOutput = nextValue; },
|
|
2516
|
+
get truncated() { return stdoutTruncated; },
|
|
2517
|
+
set truncated(nextValue) { stdoutTruncated = nextValue; }
|
|
2518
|
+
}));
|
|
2519
|
+
process.stderr.on('data', chunk => appendChunk(chunk, {
|
|
2520
|
+
get value() { return stderrOutput; },
|
|
2521
|
+
set value(nextValue) { stderrOutput = nextValue; },
|
|
2522
|
+
get truncated() { return stderrTruncated; },
|
|
2523
|
+
set truncated(nextValue) { stderrTruncated = nextValue; }
|
|
2524
|
+
}));
|
|
2525
|
+
|
|
2526
|
+
process.on('error', reject);
|
|
2527
|
+
process.on('close', code => {
|
|
2528
|
+
const exitCode = typeof code === 'number' ? code : 1;
|
|
2529
|
+
const clippedStdout = stdoutTruncated ? `${stdoutOutput}\n...[stdout-truncated]` : stdoutOutput;
|
|
2530
|
+
const clippedStderr = stderrTruncated ? `${stderrOutput}\n...[stderr-truncated]` : stderrOutput;
|
|
2531
|
+
const clippedRaw = `${clippedStdout}${clippedStdout && clippedStderr ? '\n' : ''}${clippedStderr}`;
|
|
2532
|
+
const extractedAgentMessage = extractAgentMessageFromStructuredOutput(agentProgram, clippedStdout);
|
|
2533
|
+
const cleanOutputSource = extractedAgentMessage || clippedRaw;
|
|
2534
|
+
const output = clipText(stripAnsi(cleanOutputSource).trim() || '(无输出)');
|
|
2535
|
+
resolve({ exitCode, output });
|
|
2536
|
+
});
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
|
|
2541
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
2542
|
+
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
2543
|
+
? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
|
|
2544
|
+
: sessionRefOrContainerName;
|
|
2545
|
+
const sessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
|
|
2546
|
+
const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
|
|
2547
|
+
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => {};
|
|
2548
|
+
const process = spawn(
|
|
2549
|
+
ctx.dockerCmd,
|
|
2550
|
+
['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
|
|
2551
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
2552
|
+
);
|
|
2553
|
+
|
|
2554
|
+
const runState = {
|
|
2555
|
+
containerName: sessionRef.containerName,
|
|
2556
|
+
sessionKey,
|
|
2557
|
+
process,
|
|
2558
|
+
command,
|
|
2559
|
+
startedAt: new Date().toISOString(),
|
|
2560
|
+
stopping: false
|
|
2561
|
+
};
|
|
2562
|
+
state.agentRuns.set(sessionRef.containerName, runState);
|
|
2563
|
+
|
|
2564
|
+
return await new Promise((resolve, reject) => {
|
|
2565
|
+
const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
|
|
2566
|
+
let stdoutOutput = '';
|
|
2567
|
+
let stderrOutput = '';
|
|
2568
|
+
let stdoutTruncated = false;
|
|
2569
|
+
let stderrTruncated = false;
|
|
2570
|
+
let stdoutPending = '';
|
|
2571
|
+
let stderrPending = '';
|
|
2572
|
+
const structuredTraceState = {
|
|
2573
|
+
toolNamesById: new Map()
|
|
2574
|
+
};
|
|
2575
|
+
let contentDeltaAccumulator = '';
|
|
2576
|
+
function appendChunk(chunk, target) {
|
|
2577
|
+
if (!chunk) return;
|
|
2578
|
+
const text = chunk.toString('utf-8');
|
|
2579
|
+
if (!text) return;
|
|
2580
|
+
if (target.value.length >= MAX_RAW_OUTPUT_CHARS) {
|
|
2581
|
+
target.truncated = true;
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
const remain = MAX_RAW_OUTPUT_CHARS - target.value.length;
|
|
2585
|
+
if (text.length > remain) {
|
|
2586
|
+
target.value += text.slice(0, remain);
|
|
2587
|
+
target.truncated = true;
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
target.value += text;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
function emitStdoutTraceLine(line) {
|
|
2594
|
+
const rawLine = String(line || '').trim();
|
|
2595
|
+
if (!rawLine) {
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
if (agentProgram === 'claude' || agentProgram === 'gemini' || agentProgram === 'codex' || agentProgram === 'opencode') {
|
|
2599
|
+
const payload = parseJsonObjectLine(rawLine);
|
|
2600
|
+
if (payload) {
|
|
2601
|
+
const traceEvents = prepareStructuredTraceEvents(agentProgram, payload, structuredTraceState);
|
|
2602
|
+
traceEvents.forEach(traceEvent => {
|
|
2603
|
+
if (!traceEvent || !traceEvent.text) {
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
onEvent({
|
|
2607
|
+
type: 'trace',
|
|
2608
|
+
stream: 'stdout',
|
|
2609
|
+
text: traceEvent.text,
|
|
2610
|
+
traceEvent
|
|
2611
|
+
});
|
|
2612
|
+
});
|
|
2613
|
+
const deltaContent = extractContentDeltaFromPayload(agentProgram, payload, structuredTraceState);
|
|
2614
|
+
if (deltaContent !== null) {
|
|
2615
|
+
if (deltaContent.reset) {
|
|
2616
|
+
contentDeltaAccumulator = deltaContent.text;
|
|
2617
|
+
} else {
|
|
2618
|
+
contentDeltaAccumulator += deltaContent.text;
|
|
2619
|
+
}
|
|
2620
|
+
onEvent({
|
|
2621
|
+
type: 'content_delta',
|
|
2622
|
+
content: contentDeltaAccumulator
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
if (agentProgram === 'codex' && (/^OpenAI Codex\b/.test(rawLine) || /^tokens used\b/i.test(rawLine))) {
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
onEvent({ type: 'trace', stream: 'stdout', text: rawLine });
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
function emitStderrTraceLine(line) {
|
|
2635
|
+
const rawLine = String(line || '').trim();
|
|
2636
|
+
if (!rawLine) {
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
onEvent({ type: 'trace', stream: 'stderr', text: `[stderr] ${rawLine}` });
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
function drainLines(text, carry, handleLine) {
|
|
2643
|
+
let pending = carry + String(text || '');
|
|
2644
|
+
let newlineIndex = pending.indexOf('\n');
|
|
2645
|
+
while (newlineIndex !== -1) {
|
|
2646
|
+
const line = pending.slice(0, newlineIndex).replace(/\r$/, '');
|
|
2647
|
+
handleLine(line);
|
|
2648
|
+
pending = pending.slice(newlineIndex + 1);
|
|
2649
|
+
newlineIndex = pending.indexOf('\n');
|
|
2650
|
+
}
|
|
2651
|
+
return pending;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
process.stdout.on('data', chunk => {
|
|
2655
|
+
appendChunk(chunk, {
|
|
2656
|
+
get value() { return stdoutOutput; },
|
|
2657
|
+
set value(nextValue) { stdoutOutput = nextValue; },
|
|
2658
|
+
get truncated() { return stdoutTruncated; },
|
|
2659
|
+
set truncated(nextValue) { stdoutTruncated = nextValue; }
|
|
2660
|
+
});
|
|
2661
|
+
stdoutPending = drainLines(chunk.toString('utf-8'), stdoutPending, emitStdoutTraceLine);
|
|
2662
|
+
});
|
|
2663
|
+
process.stderr.on('data', chunk => {
|
|
2664
|
+
appendChunk(chunk, {
|
|
2665
|
+
get value() { return stderrOutput; },
|
|
2666
|
+
set value(nextValue) { stderrOutput = nextValue; },
|
|
2667
|
+
get truncated() { return stderrTruncated; },
|
|
2668
|
+
set truncated(nextValue) { stderrTruncated = nextValue; }
|
|
2669
|
+
});
|
|
2670
|
+
stderrPending = drainLines(chunk.toString('utf-8'), stderrPending, emitStderrTraceLine);
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
process.on('error', error => {
|
|
2674
|
+
state.agentRuns.delete(sessionRef.containerName);
|
|
2675
|
+
reject(error);
|
|
2676
|
+
});
|
|
2677
|
+
process.on('close', code => {
|
|
2678
|
+
state.agentRuns.delete(sessionRef.containerName);
|
|
2679
|
+
if (stdoutPending) {
|
|
2680
|
+
emitStdoutTraceLine(stdoutPending);
|
|
2681
|
+
stdoutPending = '';
|
|
2682
|
+
}
|
|
2683
|
+
if (stderrPending) {
|
|
2684
|
+
emitStderrTraceLine(stderrPending);
|
|
2685
|
+
stderrPending = '';
|
|
2686
|
+
}
|
|
2687
|
+
const exitCode = typeof code === 'number' ? code : 1;
|
|
2688
|
+
const clippedStdout = stdoutTruncated ? `${stdoutOutput}\n...[stdout-truncated]` : stdoutOutput;
|
|
2689
|
+
const clippedStderr = stderrTruncated ? `${stderrOutput}\n...[stderr-truncated]` : stderrOutput;
|
|
2690
|
+
const clippedRaw = `${clippedStdout}${clippedStdout && clippedStderr ? '\n' : ''}${clippedStderr}`;
|
|
2691
|
+
const extractedAgentMessage = extractAgentMessageFromStructuredOutput(agentProgram, clippedStdout);
|
|
2692
|
+
const cleanOutputSource = extractedAgentMessage || clippedRaw;
|
|
2693
|
+
const output = clipText(stripAnsi(cleanOutputSource).trim() || '(无输出)');
|
|
2694
|
+
resolve({
|
|
2695
|
+
exitCode,
|
|
2696
|
+
output,
|
|
2697
|
+
interrupted: exitCode !== 0 && runState.stopping === true
|
|
2698
|
+
});
|
|
2699
|
+
});
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
|
|
1726
2703
|
function readRequestBody(req) {
|
|
1727
2704
|
return new Promise((resolve, reject) => {
|
|
1728
2705
|
let body = '';
|
|
@@ -1763,6 +2740,20 @@ function sendNdjson(res, payload) {
|
|
|
1763
2740
|
res.write(`${JSON.stringify(payload)}\n`);
|
|
1764
2741
|
}
|
|
1765
2742
|
|
|
2743
|
+
function stopWebAgentRun(state, containerName) {
|
|
2744
|
+
const runState = state.agentRuns.get(containerName);
|
|
2745
|
+
if (!runState || !runState.process || runState.process.killed) {
|
|
2746
|
+
return false;
|
|
2747
|
+
}
|
|
2748
|
+
runState.stopping = true;
|
|
2749
|
+
try {
|
|
2750
|
+
runState.process.kill('SIGTERM');
|
|
2751
|
+
} catch (e) {
|
|
2752
|
+
return false;
|
|
2753
|
+
}
|
|
2754
|
+
return true;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
1766
2757
|
function sendHtml(res, statusCode, html, extraHeaders = {}) {
|
|
1767
2758
|
res.writeHead(statusCode, {
|
|
1768
2759
|
'Content-Type': 'text/html; charset=utf-8',
|
|
@@ -1988,6 +2979,277 @@ function loadTemplate(name) {
|
|
|
1988
2979
|
return fs.readFileSync(filePath, 'utf-8');
|
|
1989
2980
|
}
|
|
1990
2981
|
|
|
2982
|
+
function toPositiveInt(value, fallback) {
|
|
2983
|
+
const parsed = Number.parseInt(value, 10);
|
|
2984
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
2985
|
+
return fallback;
|
|
2986
|
+
}
|
|
2987
|
+
return parsed;
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
function normalizeTerminalSize(cols, rows) {
|
|
2991
|
+
return {
|
|
2992
|
+
cols: Math.max(WEB_TERMINAL_MIN_COLS, toPositiveInt(cols, WEB_TERMINAL_DEFAULT_COLS)),
|
|
2993
|
+
rows: Math.max(WEB_TERMINAL_MIN_ROWS, toPositiveInt(rows, WEB_TERMINAL_DEFAULT_ROWS))
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
function getUpgradeStatusText(statusCode) {
|
|
2998
|
+
if (statusCode === 400) return 'Bad Request';
|
|
2999
|
+
if (statusCode === 401) return 'Unauthorized';
|
|
3000
|
+
if (statusCode === 404) return 'Not Found';
|
|
3001
|
+
if (statusCode === 429) return 'Too Many Requests';
|
|
3002
|
+
if (statusCode === 500) return 'Internal Server Error';
|
|
3003
|
+
return 'Error';
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
function sendWebSocketUpgradeError(socket, statusCode, message) {
|
|
3007
|
+
const body = String(message || getUpgradeStatusText(statusCode));
|
|
3008
|
+
const reason = getUpgradeStatusText(statusCode);
|
|
3009
|
+
if (!socket.destroyed) {
|
|
3010
|
+
socket.write(
|
|
3011
|
+
`HTTP/1.1 ${statusCode} ${reason}\r\n` +
|
|
3012
|
+
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
3013
|
+
'Connection: close\r\n' +
|
|
3014
|
+
`Content-Length: ${Buffer.byteLength(body, 'utf-8')}\r\n` +
|
|
3015
|
+
'\r\n' +
|
|
3016
|
+
body
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
socket.destroy();
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
function sendTerminalEvent(ws, type, payload = {}) {
|
|
3023
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
3024
|
+
return;
|
|
3025
|
+
}
|
|
3026
|
+
ws.send(JSON.stringify({ type, ...payload }));
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
3030
|
+
const terminalBootstrap = [
|
|
3031
|
+
'MANYOYO_WEB_BASHRC="$(mktemp /tmp/manyoyo-web-bashrc.XXXXXX 2>/dev/null || mktemp)"',
|
|
3032
|
+
'cat > "$MANYOYO_WEB_BASHRC" <<\'EOF_MANYOYO_RC\'',
|
|
3033
|
+
'if [ -f /etc/bash.bashrc ]; then',
|
|
3034
|
+
' . /etc/bash.bashrc',
|
|
3035
|
+
'fi',
|
|
3036
|
+
'if [ -f ~/.bashrc ]; then',
|
|
3037
|
+
' . ~/.bashrc',
|
|
3038
|
+
'fi',
|
|
3039
|
+
'if [ -n "${MANYOYO_TERM_COLS:-}" ] && [ -n "${MANYOYO_TERM_ROWS:-}" ]; then',
|
|
3040
|
+
' COLUMNS="$MANYOYO_TERM_COLS"',
|
|
3041
|
+
' LINES="$MANYOYO_TERM_ROWS"',
|
|
3042
|
+
' export COLUMNS LINES',
|
|
3043
|
+
' stty cols "$MANYOYO_TERM_COLS" rows "$MANYOYO_TERM_ROWS" >/dev/null 2>&1 || true',
|
|
3044
|
+
'fi',
|
|
3045
|
+
'EOF_MANYOYO_RC',
|
|
3046
|
+
'chmod 600 "$MANYOYO_WEB_BASHRC" >/dev/null 2>&1 || true',
|
|
3047
|
+
'if command -v script >/dev/null 2>&1; then',
|
|
3048
|
+
' exec script -qefc "/bin/bash --rcfile $MANYOYO_WEB_BASHRC -i" /dev/null;',
|
|
3049
|
+
'fi;',
|
|
3050
|
+
'if command -v python3 >/dev/null 2>&1; then',
|
|
3051
|
+
' exec python3 -c \'import os, pty; pty.spawn(["/bin/bash","--rcfile",os.environ.get("MANYOYO_WEB_BASHRC","/dev/null"),"-i"])\';',
|
|
3052
|
+
'fi;',
|
|
3053
|
+
'if command -v python >/dev/null 2>&1; then',
|
|
3054
|
+
' exec python -c \'import os, pty; pty.spawn(["/bin/bash","--rcfile",os.environ.get("MANYOYO_WEB_BASHRC","/dev/null"),"-i"])\';',
|
|
3055
|
+
'fi;',
|
|
3056
|
+
'echo "[manyoyo] 容器内未找到 script/python,终端将降级为非 TTY 模式" >&2;',
|
|
3057
|
+
'exec /bin/bash --rcfile "$MANYOYO_WEB_BASHRC" -i'
|
|
3058
|
+
].join('\n');
|
|
3059
|
+
|
|
3060
|
+
const termValue = process.env.TERM && process.env.TERM !== 'dumb' ? process.env.TERM : 'xterm-256color';
|
|
3061
|
+
const colorTermValue = process.env.COLORTERM || 'truecolor';
|
|
3062
|
+
const dockerExecArgs = [
|
|
3063
|
+
'exec',
|
|
3064
|
+
'-i',
|
|
3065
|
+
'-e', `TERM=${termValue}`,
|
|
3066
|
+
'-e', `COLORTERM=${colorTermValue}`,
|
|
3067
|
+
'-e', `MANYOYO_TERM_COLS=${String(cols)}`,
|
|
3068
|
+
'-e', `MANYOYO_TERM_ROWS=${String(rows)}`,
|
|
3069
|
+
containerName,
|
|
3070
|
+
'/bin/bash',
|
|
3071
|
+
'-lc',
|
|
3072
|
+
terminalBootstrap
|
|
3073
|
+
];
|
|
3074
|
+
|
|
3075
|
+
return spawn(ctx.dockerCmd, dockerExecArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
3079
|
+
const sessionId = `${containerName}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
3080
|
+
const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows);
|
|
3081
|
+
const session = {
|
|
3082
|
+
id: sessionId,
|
|
3083
|
+
containerName,
|
|
3084
|
+
ptyProcess,
|
|
3085
|
+
closing: false
|
|
3086
|
+
};
|
|
3087
|
+
|
|
3088
|
+
state.terminalSessions.set(sessionId, session);
|
|
3089
|
+
sendTerminalEvent(ws, 'status', {
|
|
3090
|
+
phase: 'ready',
|
|
3091
|
+
sessionId,
|
|
3092
|
+
containerName,
|
|
3093
|
+
cols,
|
|
3094
|
+
rows
|
|
3095
|
+
});
|
|
3096
|
+
|
|
3097
|
+
const cleanup = () => {
|
|
3098
|
+
if (session.closing) {
|
|
3099
|
+
return;
|
|
3100
|
+
}
|
|
3101
|
+
session.closing = true;
|
|
3102
|
+
state.terminalSessions.delete(sessionId);
|
|
3103
|
+
if (ptyProcess && !ptyProcess.killed) {
|
|
3104
|
+
ptyProcess.kill('SIGTERM');
|
|
3105
|
+
setTimeout(() => {
|
|
3106
|
+
if (!ptyProcess.killed) {
|
|
3107
|
+
ptyProcess.kill('SIGKILL');
|
|
3108
|
+
}
|
|
3109
|
+
}, WEB_TERMINAL_FORCE_KILL_MS);
|
|
3110
|
+
}
|
|
3111
|
+
};
|
|
3112
|
+
|
|
3113
|
+
ptyProcess.stdout.on('data', chunk => {
|
|
3114
|
+
sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
|
|
3115
|
+
});
|
|
3116
|
+
|
|
3117
|
+
ptyProcess.stderr.on('data', chunk => {
|
|
3118
|
+
sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
|
|
3119
|
+
});
|
|
3120
|
+
|
|
3121
|
+
ptyProcess.on('error', err => {
|
|
3122
|
+
sendTerminalEvent(ws, 'error', {
|
|
3123
|
+
error: err && err.message ? err.message : '终端进程启动失败'
|
|
3124
|
+
});
|
|
3125
|
+
});
|
|
3126
|
+
|
|
3127
|
+
ptyProcess.on('close', (code, signal) => {
|
|
3128
|
+
sendTerminalEvent(ws, 'status', {
|
|
3129
|
+
phase: 'closed',
|
|
3130
|
+
code: typeof code === 'number' ? code : null,
|
|
3131
|
+
signal: signal || null
|
|
3132
|
+
});
|
|
3133
|
+
cleanup();
|
|
3134
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
3135
|
+
ws.close();
|
|
3136
|
+
}
|
|
3137
|
+
});
|
|
3138
|
+
|
|
3139
|
+
ws.on('message', raw => {
|
|
3140
|
+
let payload = null;
|
|
3141
|
+
try {
|
|
3142
|
+
payload = JSON.parse(raw.toString('utf-8'));
|
|
3143
|
+
} catch (e) {
|
|
3144
|
+
payload = {
|
|
3145
|
+
type: 'input',
|
|
3146
|
+
data: raw.toString('utf-8')
|
|
3147
|
+
};
|
|
3148
|
+
}
|
|
3149
|
+
if (!payload || typeof payload !== 'object') {
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
if (payload.type === 'input' && typeof payload.data === 'string' && payload.data.length) {
|
|
3154
|
+
ptyProcess.stdin.write(payload.data);
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
if (payload.type === 'resize') {
|
|
3159
|
+
// 当前后端不直接驱动 docker exec 的 TTY 动态 resize,保留事件以便后续扩展。
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
if (payload.type === 'close') {
|
|
3164
|
+
ws.close();
|
|
3165
|
+
}
|
|
3166
|
+
});
|
|
3167
|
+
|
|
3168
|
+
ws.on('close', cleanup);
|
|
3169
|
+
ws.on('error', cleanup);
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
3173
|
+
if (req.method === 'GET' && pathname === '/favicon.ico') {
|
|
3174
|
+
res.writeHead(204, { 'Cache-Control': 'no-store' });
|
|
3175
|
+
res.end();
|
|
3176
|
+
return true;
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
if (req.method === 'GET' && pathname === '/auth/login') {
|
|
3180
|
+
sendHtml(res, 200, loadTemplate('login.html'));
|
|
3181
|
+
return true;
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
const authFrontendMatch = pathname.match(/^\/auth\/frontend\/([A-Za-z0-9._-]+)$/);
|
|
3185
|
+
if (req.method === 'GET' && authFrontendMatch) {
|
|
3186
|
+
const assetName = authFrontendMatch[1];
|
|
3187
|
+
if (!(assetName === 'login.css' || assetName === 'login.js')) {
|
|
3188
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
3189
|
+
return true;
|
|
3190
|
+
}
|
|
3191
|
+
sendStaticAsset(res, assetName);
|
|
3192
|
+
return true;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
if (req.method === 'POST' && pathname === '/auth/login') {
|
|
3196
|
+
const payload = await readJsonBody(req);
|
|
3197
|
+
const username = String(payload.username || '').trim();
|
|
3198
|
+
const password = String(payload.password || '');
|
|
3199
|
+
|
|
3200
|
+
if (!username || !password) {
|
|
3201
|
+
sendJson(res, 400, { error: '用户名和密码不能为空' });
|
|
3202
|
+
return true;
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
const userOk = secureStringEqual(username, ctx.authUser);
|
|
3206
|
+
const passOk = secureStringEqual(password, ctx.authPass);
|
|
3207
|
+
if (!(userOk && passOk)) {
|
|
3208
|
+
sendJson(res, 401, { error: '用户名或密码错误' });
|
|
3209
|
+
return true;
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
const sessionId = createWebAuthSession(state, username);
|
|
3213
|
+
sendJson(
|
|
3214
|
+
res,
|
|
3215
|
+
200,
|
|
3216
|
+
{ ok: true, username },
|
|
3217
|
+
{ 'Set-Cookie': getWebAuthCookie(sessionId) }
|
|
3218
|
+
);
|
|
3219
|
+
return true;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
if (req.method === 'POST' && pathname === '/auth/logout') {
|
|
3223
|
+
clearWebAuthSession(state, req);
|
|
3224
|
+
sendJson(
|
|
3225
|
+
res,
|
|
3226
|
+
200,
|
|
3227
|
+
{ ok: true },
|
|
3228
|
+
{ 'Set-Cookie': getWebAuthClearCookie() }
|
|
3229
|
+
);
|
|
3230
|
+
return true;
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
return false;
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
function sendWebUnauthorized(res, pathname) {
|
|
3237
|
+
if (pathname.startsWith('/api/') || pathname.startsWith('/auth/')) {
|
|
3238
|
+
sendJson(res, 401, { error: 'UNAUTHORIZED' });
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
if (pathname === '/' || pathname === '') {
|
|
3242
|
+
sendRedirect(res, 302, '/auth/login', { 'Set-Cookie': getWebAuthClearCookie() });
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
sendHtml(
|
|
3246
|
+
res,
|
|
3247
|
+
401,
|
|
3248
|
+
loadTemplate('login.html'),
|
|
3249
|
+
{ 'Set-Cookie': getWebAuthClearCookie() }
|
|
3250
|
+
);
|
|
3251
|
+
}
|
|
3252
|
+
|
|
1991
3253
|
async function handleWebApi(req, res, pathname, ctx, state) {
|
|
1992
3254
|
// [P2-03] 对非只读请求校验自定义头,防止 CSRF 攻击
|
|
1993
3255
|
// 跨站请求无法设置自定义头(浏览器同源策略),合法前端请求统一携带此头
|
|
@@ -1997,121 +3259,823 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
1997
3259
|
return true;
|
|
1998
3260
|
}
|
|
1999
3261
|
}
|
|
2000
|
-
|
|
2001
|
-
const {
|
|
2002
|
-
withSessionRef,
|
|
2003
|
-
withJsonBody,
|
|
2004
|
-
withSessionJsonBody,
|
|
2005
|
-
getRequiredBodyText,
|
|
2006
|
-
prepareAgentRequest
|
|
2007
|
-
} = createApiRouteHelpers({
|
|
2008
|
-
req,
|
|
2009
|
-
res,
|
|
2010
|
-
ctx,
|
|
2011
|
-
state,
|
|
2012
|
-
sendJson,
|
|
2013
|
-
readJsonBody,
|
|
2014
|
-
getValidSessionRef,
|
|
2015
|
-
prepareWebAgentExecution
|
|
2016
|
-
});
|
|
2017
|
-
|
|
2018
3262
|
const routes = [
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
3263
|
+
{
|
|
3264
|
+
method: 'GET',
|
|
3265
|
+
match: currentPath => currentPath === '/api/fs/directories' ? [] : null,
|
|
3266
|
+
handler: async () => {
|
|
3267
|
+
const requestUrl = new URL(req.url || '/api/fs/directories', 'http://localhost');
|
|
3268
|
+
const requestedPath = expandHomeAliasPath(String(requestUrl.searchParams.get('path') || '').trim() || os.homedir());
|
|
3269
|
+
const requestedBasePath = expandHomeAliasPath(String(requestUrl.searchParams.get('basePath') || '').trim());
|
|
3270
|
+
const realPath = fs.realpathSync(requestedPath);
|
|
3271
|
+
if (!fs.statSync(realPath).isDirectory()) {
|
|
3272
|
+
sendJson(res, 400, { error: `目录不存在: ${realPath}` });
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
let realBasePath = '';
|
|
3277
|
+
if (requestedBasePath) {
|
|
3278
|
+
realBasePath = fs.realpathSync(requestedBasePath);
|
|
3279
|
+
if (!fs.statSync(realBasePath).isDirectory()) {
|
|
3280
|
+
sendJson(res, 400, { error: `basePath 不是目录: ${realBasePath}` });
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
const relativeToBase = path.relative(realBasePath, realPath);
|
|
3284
|
+
if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) {
|
|
3285
|
+
sendJson(res, 400, { error: '目录超出 basePath 范围' });
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
const parentPath = realBasePath
|
|
3291
|
+
? (realPath === realBasePath ? '' : path.dirname(realPath))
|
|
3292
|
+
: (realPath === path.parse(realPath).root ? '' : path.dirname(realPath));
|
|
3293
|
+
const entries = fs.readdirSync(realPath, { withFileTypes: true })
|
|
3294
|
+
.filter(entry => entry && entry.isDirectory())
|
|
3295
|
+
.map(entry => ({
|
|
3296
|
+
name: entry.name,
|
|
3297
|
+
path: path.join(realPath, entry.name)
|
|
3298
|
+
}))
|
|
3299
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|
3300
|
+
|
|
3301
|
+
sendJson(res, 200, {
|
|
3302
|
+
currentPath: realPath,
|
|
3303
|
+
basePath: realBasePath || '',
|
|
3304
|
+
parentPath,
|
|
3305
|
+
entries
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
},
|
|
3309
|
+
{
|
|
3310
|
+
method: 'GET',
|
|
3311
|
+
match: currentPath => currentPath === '/api/config' ? [] : null,
|
|
3312
|
+
handler: async () => {
|
|
3313
|
+
const snapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
3314
|
+
sendJson(res, 200, buildSafeWebConfigSnapshot(snapshot, ctx));
|
|
3315
|
+
}
|
|
3316
|
+
},
|
|
3317
|
+
{
|
|
3318
|
+
method: 'PUT',
|
|
3319
|
+
match: currentPath => currentPath === '/api/config' ? [] : null,
|
|
3320
|
+
handler: async () => {
|
|
3321
|
+
const payload = await readJsonBody(req);
|
|
3322
|
+
const raw = typeof payload.raw === 'string' ? payload.raw : '';
|
|
3323
|
+
if (!raw.trim()) {
|
|
3324
|
+
sendJson(res, 400, { error: '配置内容不能为空' });
|
|
3325
|
+
return;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
const currentSnapshot = readWebConfigSnapshot(state.webConfigPath);
|
|
3329
|
+
let finalRaw = raw;
|
|
3330
|
+
let parsed = null;
|
|
3331
|
+
try {
|
|
3332
|
+
finalRaw = restoreWebConfigSecrets(raw, currentSnapshot);
|
|
3333
|
+
parsed = parseAndValidateConfigRaw(finalRaw);
|
|
3334
|
+
} catch (e) {
|
|
3335
|
+
sendJson(res, 400, { error: '配置格式错误', detail: e.message || '解析失败' });
|
|
3336
|
+
return;
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
const savePath = path.resolve(state.webConfigPath);
|
|
3340
|
+
fs.mkdirSync(path.dirname(savePath), { recursive: true });
|
|
3341
|
+
fs.writeFileSync(savePath, finalRaw, 'utf-8');
|
|
3342
|
+
|
|
3343
|
+
sendJson(res, 200, {
|
|
3344
|
+
saved: true,
|
|
3345
|
+
path: savePath,
|
|
3346
|
+
defaults: buildConfigDefaults(ctx, parsed)
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
},
|
|
3350
|
+
{
|
|
3351
|
+
method: 'GET',
|
|
3352
|
+
match: currentPath => currentPath === '/api/sessions' ? [] : null,
|
|
3353
|
+
handler: async () => {
|
|
3354
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
3355
|
+
const names = new Set([
|
|
3356
|
+
...Object.keys(containerMap),
|
|
3357
|
+
...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
|
|
3358
|
+
]);
|
|
3359
|
+
|
|
3360
|
+
const sessions = Array.from(names)
|
|
3361
|
+
.flatMap(name => {
|
|
3362
|
+
const history = loadWebSessionHistory(state.webHistoryDir, name);
|
|
3363
|
+
return listWebAgentSessions(history, { includeSyntheticDefault: true })
|
|
3364
|
+
.map(agentSession => buildSessionSummary(ctx, state, containerMap, {
|
|
3365
|
+
containerName: name,
|
|
3366
|
+
agentId: agentSession.agentId
|
|
3367
|
+
}))
|
|
3368
|
+
.filter(Boolean);
|
|
3369
|
+
})
|
|
3370
|
+
.sort((a, b) => {
|
|
3371
|
+
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
|
3372
|
+
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
|
3373
|
+
return timeB - timeA;
|
|
3374
|
+
});
|
|
3375
|
+
|
|
3376
|
+
sendJson(res, 200, { sessions });
|
|
3377
|
+
}
|
|
3378
|
+
},
|
|
3379
|
+
{
|
|
3380
|
+
method: 'POST',
|
|
3381
|
+
match: currentPath => currentPath === '/api/sessions' ? [] : null,
|
|
3382
|
+
handler: async () => {
|
|
3383
|
+
const payload = await readJsonBody(req);
|
|
3384
|
+
let runtime = null;
|
|
3385
|
+
try {
|
|
3386
|
+
runtime = buildCreateRuntime(ctx, state, payload);
|
|
3387
|
+
} catch (e) {
|
|
3388
|
+
sendJson(res, 400, { error: e.message || '创建参数错误' });
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
await ensureWebContainer(ctx, state, runtime);
|
|
3393
|
+
setWebSessionAgentPromptCommand(state.webHistoryDir, runtime.containerName, runtime.agentPromptCommand);
|
|
3394
|
+
patchWebSessionHistory(state.webHistoryDir, runtime.containerName, {
|
|
3395
|
+
applied: runtime.applied
|
|
3396
|
+
});
|
|
3397
|
+
sendJson(res, 200, { name: runtime.containerName, applied: runtime.applied });
|
|
3398
|
+
}
|
|
3399
|
+
},
|
|
3400
|
+
{
|
|
3401
|
+
method: 'POST',
|
|
3402
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agents$/),
|
|
3403
|
+
handler: async match => {
|
|
3404
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3405
|
+
if (!sessionRef) {
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
3409
|
+
const agentSession = createWebAgentSession(history);
|
|
3410
|
+
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
|
|
3411
|
+
sendJson(res, 200, {
|
|
3412
|
+
name: buildWebSessionKey(sessionRef.containerName, agentSession.agentId),
|
|
3413
|
+
containerName: sessionRef.containerName,
|
|
3414
|
+
agentId: agentSession.agentId,
|
|
3415
|
+
agentName: agentSession.agentName
|
|
3416
|
+
});
|
|
3417
|
+
}
|
|
3418
|
+
},
|
|
3419
|
+
{
|
|
3420
|
+
method: 'GET',
|
|
3421
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages$/),
|
|
3422
|
+
handler: async match => {
|
|
3423
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3424
|
+
if (!sessionRef) {
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
3428
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId)
|
|
3429
|
+
|| createEmptyWebAgentSession(sessionRef.agentId);
|
|
3430
|
+
sendJson(res, 200, {
|
|
3431
|
+
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
3432
|
+
containerName: sessionRef.containerName,
|
|
3433
|
+
agentId: sessionRef.agentId,
|
|
3434
|
+
messages: agentSession.messages
|
|
3435
|
+
});
|
|
3436
|
+
}
|
|
3437
|
+
},
|
|
3438
|
+
{
|
|
3439
|
+
method: 'GET',
|
|
3440
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/detail$/),
|
|
3441
|
+
handler: async match => {
|
|
3442
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3443
|
+
if (!sessionRef) {
|
|
3444
|
+
return;
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
3448
|
+
const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
|
|
3449
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
|
|
3450
|
+
}
|
|
3451
|
+
},
|
|
3452
|
+
{
|
|
3453
|
+
method: 'PUT',
|
|
3454
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent-template$/),
|
|
3455
|
+
handler: async match => {
|
|
3456
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3457
|
+
if (!sessionRef) {
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
let payload = null;
|
|
3462
|
+
try {
|
|
3463
|
+
payload = await readJsonBody(req);
|
|
3464
|
+
} catch (e) {
|
|
3465
|
+
sendJson(res, 400, { error: e.message || '请求参数错误' });
|
|
3466
|
+
return;
|
|
3467
|
+
}
|
|
3468
|
+
const normalizedPayload = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
|
|
3469
|
+
const hasContainerTemplate = hasOwn(normalizedPayload, 'containerAgentPromptCommand');
|
|
3470
|
+
const hasAgentOverride = hasOwn(normalizedPayload, 'agentPromptCommandOverride');
|
|
3471
|
+
if (!hasContainerTemplate && !hasAgentOverride) {
|
|
3472
|
+
sendJson(res, 400, { error: '至少提供一个模板字段' });
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
if (hasAgentOverride && sessionRef.agentId === WEB_DEFAULT_AGENT_ID) {
|
|
3476
|
+
sendJson(res, 400, { error: '默认 AGENT 不支持单独覆盖模板,请直接修改容器模板' });
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
try {
|
|
3481
|
+
if (hasContainerTemplate) {
|
|
3482
|
+
setWebSessionAgentPromptCommand(
|
|
3483
|
+
state.webHistoryDir,
|
|
3484
|
+
sessionRef.containerName,
|
|
3485
|
+
normalizedPayload.containerAgentPromptCommand
|
|
3486
|
+
);
|
|
3487
|
+
}
|
|
3488
|
+
if (hasAgentOverride) {
|
|
3489
|
+
setWebAgentSessionPromptCommand(
|
|
3490
|
+
state.webHistoryDir,
|
|
3491
|
+
sessionRef,
|
|
3492
|
+
normalizedPayload.agentPromptCommandOverride
|
|
3493
|
+
);
|
|
3494
|
+
}
|
|
3495
|
+
} catch (e) {
|
|
3496
|
+
sendJson(res, 400, { error: e.message || '保存 Agent 模板失败' });
|
|
3497
|
+
return;
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
const containerMap = listWebManyoyoContainers(ctx);
|
|
3501
|
+
const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
|
|
3502
|
+
sendJson(res, 200, {
|
|
3503
|
+
saved: true,
|
|
3504
|
+
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
3505
|
+
detail
|
|
3506
|
+
});
|
|
3507
|
+
}
|
|
3508
|
+
},
|
|
3509
|
+
{
|
|
3510
|
+
method: 'POST',
|
|
3511
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/run$/),
|
|
3512
|
+
handler: async match => {
|
|
3513
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3514
|
+
if (!sessionRef) {
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
const payload = await readJsonBody(req);
|
|
3519
|
+
const command = (payload.command || '').trim();
|
|
3520
|
+
if (!command) {
|
|
3521
|
+
sendJson(res, 400, { error: 'command 不能为空' });
|
|
3522
|
+
return;
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
3526
|
+
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', command);
|
|
3527
|
+
const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command);
|
|
3528
|
+
appendWebSessionMessage(
|
|
3529
|
+
state.webHistoryDir,
|
|
3530
|
+
sessionRef,
|
|
3531
|
+
'assistant',
|
|
3532
|
+
result.output,
|
|
3533
|
+
{ exitCode: result.exitCode }
|
|
3534
|
+
);
|
|
3535
|
+
sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
|
|
3536
|
+
}
|
|
3537
|
+
},
|
|
3538
|
+
{
|
|
3539
|
+
method: 'POST',
|
|
3540
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent$/),
|
|
3541
|
+
handler: async match => {
|
|
3542
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3543
|
+
if (!sessionRef) {
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
const payload = await readJsonBody(req);
|
|
3548
|
+
const prompt = (payload.prompt || '').trim();
|
|
3549
|
+
if (!prompt) {
|
|
3550
|
+
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
let prepared = null;
|
|
3555
|
+
try {
|
|
3556
|
+
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
3557
|
+
} catch (e) {
|
|
3558
|
+
sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError } = prepared;
|
|
3563
|
+
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
3564
|
+
mode: 'agent',
|
|
3565
|
+
contextMode
|
|
3566
|
+
});
|
|
3567
|
+
const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
|
|
3568
|
+
agentProgram: agentMeta.agentProgram
|
|
3569
|
+
});
|
|
3570
|
+
finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
|
|
3571
|
+
contextMode,
|
|
3572
|
+
resumeAttempted,
|
|
3573
|
+
resumeSucceeded,
|
|
3574
|
+
resumeError
|
|
3575
|
+
}, result);
|
|
3576
|
+
sendJson(res, 200, {
|
|
3577
|
+
exitCode: result.exitCode,
|
|
3578
|
+
output: result.output,
|
|
3579
|
+
contextMode,
|
|
3580
|
+
resumeAttempted,
|
|
3581
|
+
resumeSucceeded,
|
|
3582
|
+
interrupted: result.interrupted === true
|
|
3583
|
+
});
|
|
3584
|
+
}
|
|
3585
|
+
},
|
|
3586
|
+
{
|
|
3587
|
+
method: 'POST',
|
|
3588
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/stream$/),
|
|
3589
|
+
handler: async match => {
|
|
3590
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3591
|
+
if (!sessionRef) {
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
const payload = await readJsonBody(req);
|
|
3596
|
+
const prompt = (payload.prompt || '').trim();
|
|
3597
|
+
if (!prompt) {
|
|
3598
|
+
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
3599
|
+
return;
|
|
3600
|
+
}
|
|
3601
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
3602
|
+
sendJson(res, 409, { error: '当前会话已有运行中的 agent 任务' });
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
let prepared = null;
|
|
3607
|
+
try {
|
|
3608
|
+
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
3609
|
+
} catch (e) {
|
|
3610
|
+
sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
|
|
3611
|
+
return;
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError } = prepared;
|
|
3615
|
+
const traceLines = ['[执行过程]'];
|
|
3616
|
+
const traceEvents = [];
|
|
3617
|
+
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
3618
|
+
mode: 'agent',
|
|
3619
|
+
contextMode
|
|
3620
|
+
});
|
|
3621
|
+
|
|
3622
|
+
res.writeHead(200, {
|
|
3623
|
+
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
3624
|
+
'Cache-Control': 'no-store',
|
|
3625
|
+
'X-Accel-Buffering': 'no'
|
|
3626
|
+
});
|
|
3627
|
+
sendNdjson(res, {
|
|
3628
|
+
type: 'meta',
|
|
3629
|
+
containerName: sessionRef.containerName,
|
|
3630
|
+
sessionName: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
3631
|
+
contextMode,
|
|
3632
|
+
resumeAttempted,
|
|
3633
|
+
resumeSucceeded,
|
|
3634
|
+
agentProgram: agentMeta.agentProgram
|
|
3635
|
+
});
|
|
3636
|
+
if (contextMode) {
|
|
3637
|
+
traceLines.push(`上下文模式: ${contextMode}`);
|
|
3638
|
+
}
|
|
3639
|
+
if (resumeAttempted) {
|
|
3640
|
+
traceLines.push(resumeSucceeded ? '会话恢复成功' : '会话恢复失败,已回退到历史注入');
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
try {
|
|
3644
|
+
const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
3645
|
+
agentProgram: agentMeta.agentProgram,
|
|
3646
|
+
onEvent: event => {
|
|
3647
|
+
if (event && event.type === 'trace' && event.text) {
|
|
3648
|
+
traceLines.push(String(event.text));
|
|
3649
|
+
if (event.traceEvent && typeof event.traceEvent === 'object') {
|
|
3650
|
+
traceEvents.push(event.traceEvent);
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
sendNdjson(res, event);
|
|
3654
|
+
}
|
|
3655
|
+
});
|
|
3656
|
+
traceLines.push(result.interrupted === true ? '[任务] 已停止' : '[任务] 已完成');
|
|
3657
|
+
appendWebAgentTraceMessage(state.webHistoryDir, sessionRef, traceLines.join('\n'), {
|
|
3658
|
+
traceEvents,
|
|
3659
|
+
contextMode,
|
|
3660
|
+
resumeAttempted,
|
|
3661
|
+
resumeSucceeded,
|
|
3662
|
+
interrupted: result.interrupted === true
|
|
3663
|
+
});
|
|
3664
|
+
finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
|
|
3665
|
+
contextMode,
|
|
3666
|
+
resumeAttempted,
|
|
3667
|
+
resumeSucceeded,
|
|
3668
|
+
resumeError
|
|
3669
|
+
}, result);
|
|
3670
|
+
sendNdjson(res, {
|
|
3671
|
+
type: 'result',
|
|
3672
|
+
exitCode: result.exitCode,
|
|
3673
|
+
output: result.output,
|
|
3674
|
+
contextMode,
|
|
3675
|
+
resumeAttempted,
|
|
3676
|
+
resumeSucceeded,
|
|
3677
|
+
interrupted: result.interrupted === true
|
|
3678
|
+
});
|
|
3679
|
+
} catch (e) {
|
|
3680
|
+
traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
|
|
3681
|
+
appendWebAgentTraceMessage(state.webHistoryDir, sessionRef, traceLines.join('\n'), {
|
|
3682
|
+
traceEvents,
|
|
3683
|
+
contextMode,
|
|
3684
|
+
resumeAttempted,
|
|
3685
|
+
resumeSucceeded,
|
|
3686
|
+
interrupted: true
|
|
3687
|
+
});
|
|
3688
|
+
sendNdjson(res, {
|
|
3689
|
+
type: 'error',
|
|
3690
|
+
error: e && e.message ? e.message : 'Agent 执行失败'
|
|
3691
|
+
});
|
|
3692
|
+
} finally {
|
|
3693
|
+
res.end();
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
},
|
|
3697
|
+
{
|
|
3698
|
+
method: 'POST',
|
|
3699
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/stop$/),
|
|
3700
|
+
handler: async match => {
|
|
3701
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3702
|
+
if (!sessionRef) {
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
3705
|
+
const stopped = stopWebAgentRun(state, sessionRef.containerName);
|
|
3706
|
+
if (!stopped) {
|
|
3707
|
+
sendJson(res, 404, { error: '当前会话没有运行中的 agent 任务' });
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
sendJson(res, 200, { ok: true, stopping: true });
|
|
3711
|
+
}
|
|
3712
|
+
},
|
|
3713
|
+
{
|
|
3714
|
+
method: 'POST',
|
|
3715
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/remove$/),
|
|
3716
|
+
handler: async match => {
|
|
3717
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3718
|
+
if (!sessionRef) {
|
|
3719
|
+
return;
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
if (ctx.containerExists(sessionRef.containerName)) {
|
|
3723
|
+
ctx.removeContainer(sessionRef.containerName);
|
|
3724
|
+
appendWebSessionMessage(state.webHistoryDir, sessionRef, 'system', `容器 ${sessionRef.containerName} 已删除。`);
|
|
3725
|
+
}
|
|
3726
|
+
|
|
3727
|
+
sendJson(res, 200, { removed: true, name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId) });
|
|
3728
|
+
}
|
|
3729
|
+
},
|
|
3730
|
+
{
|
|
3731
|
+
method: 'POST',
|
|
3732
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/remove-with-history$/),
|
|
3733
|
+
handler: async match => {
|
|
3734
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
3735
|
+
if (!sessionRef) {
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
3740
|
+
if (history.agents && typeof history.agents === 'object') {
|
|
3741
|
+
if (sessionRef.agentId === WEB_DEFAULT_AGENT_ID) {
|
|
3742
|
+
delete history.agents[WEB_DEFAULT_AGENT_ID];
|
|
3743
|
+
} else {
|
|
3744
|
+
delete history.agents[sessionRef.agentId];
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
if (!Object.keys(history.agents || {}).length && !ctx.containerExists(sessionRef.containerName)) {
|
|
3748
|
+
removeWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
3749
|
+
} else {
|
|
3750
|
+
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
|
|
3751
|
+
}
|
|
3752
|
+
sendJson(res, 200, {
|
|
3753
|
+
removedHistory: true,
|
|
3754
|
+
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
|
|
3755
|
+
});
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
2074
3758
|
];
|
|
2075
|
-
|
|
3759
|
+
|
|
3760
|
+
for (const route of routes) {
|
|
3761
|
+
if (route.method !== req.method) {
|
|
3762
|
+
continue;
|
|
3763
|
+
}
|
|
3764
|
+
const matched = route.match(pathname);
|
|
3765
|
+
if (!matched) {
|
|
3766
|
+
continue;
|
|
3767
|
+
}
|
|
3768
|
+
await route.handler(matched);
|
|
3769
|
+
return true;
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
return false;
|
|
2076
3773
|
}
|
|
2077
3774
|
|
|
2078
3775
|
async function startWebServer(options) {
|
|
2079
|
-
const
|
|
2080
|
-
|
|
3776
|
+
const fallbackLogger = {
|
|
3777
|
+
info: () => {},
|
|
3778
|
+
warn: () => {},
|
|
3779
|
+
error: () => {}
|
|
3780
|
+
};
|
|
3781
|
+
const ctx = {
|
|
3782
|
+
serverHost: options.serverHost || '127.0.0.1',
|
|
3783
|
+
serverPort: options.serverPort,
|
|
3784
|
+
authUser: options.authUser,
|
|
3785
|
+
authPass: options.authPass,
|
|
3786
|
+
authPassAuto: options.authPassAuto,
|
|
3787
|
+
dockerCmd: options.dockerCmd,
|
|
3788
|
+
hostPath: options.hostPath,
|
|
3789
|
+
containerPath: options.containerPath,
|
|
3790
|
+
imageName: options.imageName,
|
|
3791
|
+
imageVersion: options.imageVersion,
|
|
3792
|
+
execCommandPrefix: options.execCommandPrefix,
|
|
3793
|
+
execCommand: options.execCommand,
|
|
3794
|
+
execCommandSuffix: options.execCommandSuffix,
|
|
3795
|
+
contModeArgs: options.contModeArgs,
|
|
3796
|
+
containerExtraArgs: options.containerExtraArgs,
|
|
3797
|
+
containerEnvs: options.containerEnvs,
|
|
3798
|
+
containerVolumes: options.containerVolumes,
|
|
3799
|
+
containerPorts: options.containerPorts,
|
|
3800
|
+
validateHostPath: options.validateHostPath,
|
|
3801
|
+
formatDate: options.formatDate,
|
|
3802
|
+
isValidContainerName: options.isValidContainerName,
|
|
3803
|
+
containerExists: options.containerExists,
|
|
3804
|
+
getContainerStatus: options.getContainerStatus,
|
|
3805
|
+
waitForContainerReady: options.waitForContainerReady,
|
|
3806
|
+
dockerExecArgs: options.dockerExecArgs,
|
|
3807
|
+
showImagePullHint: options.showImagePullHint,
|
|
3808
|
+
removeContainer: options.removeContainer,
|
|
3809
|
+
logger: options.logger && typeof options.logger.info === 'function' ? options.logger : fallbackLogger,
|
|
3810
|
+
colors: options.colors || {
|
|
3811
|
+
GREEN: '',
|
|
3812
|
+
CYAN: '',
|
|
3813
|
+
YELLOW: '',
|
|
3814
|
+
NC: ''
|
|
3815
|
+
}
|
|
3816
|
+
};
|
|
3817
|
+
|
|
3818
|
+
if (!ctx.authUser || !ctx.authPass) {
|
|
3819
|
+
throw new Error('Web 认证配置缺失,请设置 serve -U / serve -P');
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
const state = {
|
|
3823
|
+
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
3824
|
+
webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
|
|
3825
|
+
authSessions: new Map(),
|
|
3826
|
+
terminalSessions: new Map(),
|
|
3827
|
+
agentRuns: new Map()
|
|
3828
|
+
};
|
|
2081
3829
|
|
|
2082
3830
|
ensureWebHistoryDir(state.webHistoryDir);
|
|
2083
3831
|
|
|
2084
|
-
const wsServer =
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
loadTemplate,
|
|
2088
|
-
sendHtml,
|
|
2089
|
-
sendJson,
|
|
2090
|
-
sendRedirect,
|
|
2091
|
-
sendStaticAsset,
|
|
2092
|
-
sendVendorAsset,
|
|
2093
|
-
readJsonBody,
|
|
2094
|
-
secureStringEqual,
|
|
2095
|
-
createWebAuthSession,
|
|
2096
|
-
clearWebAuthSession,
|
|
2097
|
-
getWebAuthCookie,
|
|
2098
|
-
getWebAuthClearCookie,
|
|
2099
|
-
getWebAuthSession,
|
|
2100
|
-
AUTH_FRONTEND_ASSETS,
|
|
2101
|
-
APP_FRONTEND_ASSETS,
|
|
2102
|
-
APP_VENDOR_ASSETS,
|
|
2103
|
-
handleWebApi
|
|
3832
|
+
const wsServer = new WebSocket.Server({
|
|
3833
|
+
noServer: true,
|
|
3834
|
+
maxPayload: 1024 * 1024
|
|
2104
3835
|
});
|
|
3836
|
+
wsServer.on('error', err => {
|
|
3837
|
+
ctx.logger.error('ws server error', err);
|
|
3838
|
+
});
|
|
3839
|
+
|
|
3840
|
+
wsServer.on('connection', (ws, req, meta = {}) => {
|
|
3841
|
+
const containerName = meta.containerName;
|
|
3842
|
+
if (!containerName || !ctx.isValidContainerName(containerName)) {
|
|
3843
|
+
ws.close();
|
|
3844
|
+
return;
|
|
3845
|
+
}
|
|
3846
|
+
const { cols, rows } = normalizeTerminalSize(meta.cols, meta.rows);
|
|
3847
|
+
bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows);
|
|
3848
|
+
});
|
|
3849
|
+
|
|
3850
|
+
const server = http.createServer(async (req, res) => {
|
|
3851
|
+
try {
|
|
3852
|
+
const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
|
|
3853
|
+
const url = new URL(req.url, `http://${req.headers.host || fallbackHost}`);
|
|
3854
|
+
const pathname = url.pathname;
|
|
2105
3855
|
|
|
2106
|
-
|
|
2107
|
-
|
|
3856
|
+
// 全局认证入口:除登录路由外,默认全部请求都要求认证
|
|
3857
|
+
if (await handleWebAuthRoutes(req, res, pathname, ctx, state)) {
|
|
3858
|
+
return;
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
const authSession = getWebAuthSession(state, req);
|
|
3862
|
+
if (!authSession) {
|
|
3863
|
+
sendWebUnauthorized(res, pathname);
|
|
3864
|
+
return;
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
if (req.method === 'GET' && pathname === '/') {
|
|
3868
|
+
sendHtml(res, 200, loadTemplate('app.html'));
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
|
|
3873
|
+
if (req.method === 'GET' && appFrontendMatch) {
|
|
3874
|
+
const assetName = appFrontendMatch[1];
|
|
3875
|
+
if (!(assetName === 'app.css' || assetName === 'app.js' || assetName === 'markdown.css' || assetName === 'markdown-renderer.js')) {
|
|
3876
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
3877
|
+
return;
|
|
3878
|
+
}
|
|
3879
|
+
sendStaticAsset(res, assetName);
|
|
3880
|
+
return;
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
const appVendorMatch = pathname.match(/^\/app\/vendor\/([A-Za-z0-9._-]+)$/);
|
|
3884
|
+
if (req.method === 'GET' && appVendorMatch) {
|
|
3885
|
+
const assetName = appVendorMatch[1];
|
|
3886
|
+
if (!(assetName === 'xterm.css' || assetName === 'xterm.js' || assetName === 'xterm-addon-fit.js' || assetName === 'marked.min.js')) {
|
|
3887
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
3888
|
+
return;
|
|
3889
|
+
}
|
|
3890
|
+
sendVendorAsset(res, assetName);
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
if (pathname === '/healthz') {
|
|
3895
|
+
sendJson(res, 200, { ok: true });
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
if (pathname.startsWith('/api/')) {
|
|
3900
|
+
const handled = await handleWebApi(req, res, pathname, ctx, state);
|
|
3901
|
+
if (!handled) {
|
|
3902
|
+
sendJson(res, 404, { error: 'Not Found' });
|
|
3903
|
+
}
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
3908
|
+
} catch (e) {
|
|
3909
|
+
ctx.logger.error('http request error', {
|
|
3910
|
+
method: req && req.method ? req.method : '',
|
|
3911
|
+
url: req && req.url ? req.url : '',
|
|
3912
|
+
message: e && e.message ? e.message : 'Server Error'
|
|
3913
|
+
});
|
|
3914
|
+
if ((req.url || '').startsWith('/api/')) {
|
|
3915
|
+
sendJson(res, 500, { error: e.message || 'Server Error' });
|
|
3916
|
+
} else {
|
|
3917
|
+
sendHtml(res, 500, '<h1>500 Server Error</h1>');
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
});
|
|
3921
|
+
server.on('error', err => {
|
|
3922
|
+
ctx.logger.error('http server error', err);
|
|
3923
|
+
});
|
|
3924
|
+
server.on('close', () => {
|
|
3925
|
+
ctx.logger.warn('http server closed');
|
|
3926
|
+
});
|
|
3927
|
+
|
|
3928
|
+
server.on('upgrade', (req, socket, head) => {
|
|
3929
|
+
const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
|
|
3930
|
+
let url;
|
|
3931
|
+
try {
|
|
3932
|
+
url = new URL(req.url || '/', `http://${req.headers.host || fallbackHost}`);
|
|
3933
|
+
} catch (e) {
|
|
3934
|
+
sendWebSocketUpgradeError(socket, 400, 'Invalid URL');
|
|
3935
|
+
return;
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
const terminalMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/terminal\/ws$/);
|
|
3939
|
+
if (!terminalMatch) {
|
|
3940
|
+
socket.destroy();
|
|
3941
|
+
return;
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
// [P1-03] Origin 校验,防止跨站 WebSocket 劫持(CSWSH)
|
|
3945
|
+
// 浏览器发起的 WebSocket 请求必须携带 Origin 头,非浏览器客户端(如 curl)不携带则放行
|
|
3946
|
+
const requestOrigin = req.headers.origin;
|
|
3947
|
+
if (requestOrigin) {
|
|
3948
|
+
const allowedOrigins = new Set();
|
|
3949
|
+
// 始终以请求的 Host 头构造允许来源,兼容 nginx 等反向代理场景
|
|
3950
|
+
const hostHeader = req.headers.host || '';
|
|
3951
|
+
if (hostHeader) {
|
|
3952
|
+
allowedOrigins.add(`http://${hostHeader}`);
|
|
3953
|
+
allowedOrigins.add(`https://${hostHeader}`);
|
|
3954
|
+
}
|
|
3955
|
+
if (ctx.serverHost !== '0.0.0.0') {
|
|
3956
|
+
allowedOrigins.add(`http://${formatUrlHost(ctx.serverHost)}:${listenPort}`);
|
|
3957
|
+
if (ctx.serverHost === '127.0.0.1') {
|
|
3958
|
+
allowedOrigins.add(`http://localhost:${listenPort}`);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
if (allowedOrigins.size > 0 && !allowedOrigins.has(requestOrigin)) {
|
|
3962
|
+
sendWebSocketUpgradeError(socket, 403, 'Forbidden');
|
|
3963
|
+
return;
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
const authSession = getWebAuthSession(state, req);
|
|
3968
|
+
if (!authSession) {
|
|
3969
|
+
sendWebSocketUpgradeError(socket, 401, 'UNAUTHORIZED');
|
|
3970
|
+
return;
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
const sessionRef = parseWebSessionKey(decodeSessionName(terminalMatch[1]));
|
|
3974
|
+
if (!ctx.isValidContainerName(sessionRef.containerName)) {
|
|
3975
|
+
sendWebSocketUpgradeError(socket, 400, `containerName 非法: ${sessionRef.containerName}`);
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
if (!SAFE_CONTAINER_NAME_PATTERN.test(sessionRef.agentId)) {
|
|
3979
|
+
sendWebSocketUpgradeError(socket, 400, `agentId 非法: ${sessionRef.agentId}`);
|
|
3980
|
+
return;
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
if (state.terminalSessions.size >= WEB_TERMINAL_MAX_SESSIONS) {
|
|
3984
|
+
sendWebSocketUpgradeError(socket, 429, 'TERMINAL_LIMIT_REACHED');
|
|
3985
|
+
return;
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
const { cols, rows } = normalizeTerminalSize(
|
|
3989
|
+
url.searchParams.get('cols'),
|
|
3990
|
+
url.searchParams.get('rows')
|
|
3991
|
+
);
|
|
3992
|
+
|
|
3993
|
+
ensureWebContainer(ctx, state, sessionRef.containerName)
|
|
3994
|
+
.then(() => {
|
|
3995
|
+
wsServer.handleUpgrade(req, socket, head, ws => {
|
|
3996
|
+
wsServer.emit('connection', ws, req, {
|
|
3997
|
+
containerName: sessionRef.containerName,
|
|
3998
|
+
cols,
|
|
3999
|
+
rows
|
|
4000
|
+
});
|
|
4001
|
+
});
|
|
4002
|
+
})
|
|
4003
|
+
.catch(e => {
|
|
4004
|
+
sendWebSocketUpgradeError(socket, 500, e && e.message ? e.message : '终端创建失败');
|
|
4005
|
+
});
|
|
4006
|
+
});
|
|
4007
|
+
|
|
4008
|
+
let listenPort = ctx.serverPort;
|
|
4009
|
+
|
|
4010
|
+
await new Promise((resolve, reject) => {
|
|
4011
|
+
server.once('error', err => {
|
|
4012
|
+
ctx.logger.error('http server listen failed', err);
|
|
4013
|
+
reject(err);
|
|
4014
|
+
});
|
|
4015
|
+
server.listen(ctx.serverPort, ctx.serverHost, () => {
|
|
4016
|
+
const address = server.address();
|
|
4017
|
+
if (address && typeof address === 'object' && typeof address.port === 'number') {
|
|
4018
|
+
listenPort = address.port;
|
|
4019
|
+
}
|
|
4020
|
+
const { GREEN, CYAN, YELLOW, NC } = ctx.colors;
|
|
4021
|
+
const listenHost = formatUrlHost(ctx.serverHost);
|
|
4022
|
+
console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${listenPort}${NC}`);
|
|
4023
|
+
console.log(`${CYAN}提示: 左侧是 manyoyo 容器会话列表,中间是活动/终端/配置/检查工作台,右侧显示当前会话上下文。${NC}`);
|
|
4024
|
+
if (ctx.serverHost === '0.0.0.0') {
|
|
4025
|
+
console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 IP 访问。${NC}`);
|
|
4026
|
+
}
|
|
4027
|
+
console.log(`${CYAN}🔐 登录用户名: ${YELLOW}${ctx.authUser}${NC}`);
|
|
4028
|
+
if (ctx.authPassAuto) {
|
|
4029
|
+
console.log(`${CYAN}🔐 登录密码(本次随机): ${YELLOW}${ctx.authPass}${NC}`);
|
|
4030
|
+
} else {
|
|
4031
|
+
console.log(`${CYAN}🔐 登录密码: 使用你配置的 serve -P / serverPass / MANYOYO_SERVER_PASS${NC}`);
|
|
4032
|
+
}
|
|
4033
|
+
ctx.logger.info('web server started', {
|
|
4034
|
+
host: ctx.serverHost,
|
|
4035
|
+
port: listenPort,
|
|
4036
|
+
authUser: ctx.authUser,
|
|
4037
|
+
authPassAuto: Boolean(ctx.authPassAuto)
|
|
4038
|
+
});
|
|
4039
|
+
resolve();
|
|
4040
|
+
});
|
|
4041
|
+
});
|
|
2108
4042
|
|
|
2109
4043
|
return {
|
|
2110
4044
|
server,
|
|
2111
4045
|
wsServer,
|
|
2112
4046
|
host: ctx.serverHost,
|
|
2113
4047
|
port: listenPort,
|
|
2114
|
-
close: () =>
|
|
4048
|
+
close: () => new Promise(resolve => {
|
|
4049
|
+
ctx.logger.info('web server closing');
|
|
4050
|
+
for (const session of state.terminalSessions.values()) {
|
|
4051
|
+
const ptyProcess = session && session.ptyProcess;
|
|
4052
|
+
if (ptyProcess && !ptyProcess.killed) {
|
|
4053
|
+
try { ptyProcess.kill('SIGTERM'); } catch (e) {}
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
state.terminalSessions.clear();
|
|
4057
|
+
for (const runState of state.agentRuns.values()) {
|
|
4058
|
+
const child = runState && runState.process;
|
|
4059
|
+
if (child && !child.killed) {
|
|
4060
|
+
try { child.kill('SIGTERM'); } catch (e) {}
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
state.agentRuns.clear();
|
|
4064
|
+
|
|
4065
|
+
const closeHttp = () => {
|
|
4066
|
+
if (!server.listening) {
|
|
4067
|
+
resolve();
|
|
4068
|
+
return;
|
|
4069
|
+
}
|
|
4070
|
+
server.close(() => resolve());
|
|
4071
|
+
};
|
|
4072
|
+
|
|
4073
|
+
try {
|
|
4074
|
+
wsServer.close(() => closeHttp());
|
|
4075
|
+
} catch (e) {
|
|
4076
|
+
closeHttp();
|
|
4077
|
+
}
|
|
4078
|
+
})
|
|
2115
4079
|
};
|
|
2116
4080
|
}
|
|
2117
4081
|
|