@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/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
- const {
549
- parseJsonObjectLine,
550
- collectStructuredText,
551
- extractAgentMessageFromStructuredOutput
552
- } = createStructuredOutputHelpers({
553
- pickFirstString,
554
- toPlainObject,
555
- extractAgentMessageFromCodexJsonl
556
- });
557
-
558
- const STRUCTURED_TRACE_DEPS = {
559
- pickFirstString,
560
- toPlainObject,
561
- collectStructuredText,
562
- clipText,
563
- stripAnsi
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
- const {
567
- execCommandInWebContainer,
568
- execAgentInWebContainerStream
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
- const {
582
- createInitialWebRuntimeState,
583
- stopWebAgentRun,
584
- cleanupWebRuntimeState
585
- } = createWebRuntimeStateHelpers();
551
+ function quoteBashSingleValue(value) {
552
+ const text = String(value || '');
553
+ return `'${text.replace(/'/g, `'\"'\"'`)}'`;
554
+ }
586
555
 
587
- const {
588
- createWebServerContext,
589
- createWebServerState
590
- } = createWebServerContextHelpers({
591
- createInitialWebRuntimeState,
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
- const {
596
- normalizeTerminalSize,
597
- sendWebSocketUpgradeError,
598
- bindTerminalWebSocket
599
- } = createWebTerminalHelpers({
600
- WebSocket,
601
- spawn,
602
- forceKillMs: WEB_TERMINAL_FORCE_KILL_MS,
603
- defaultCols: WEB_TERMINAL_DEFAULT_COLS,
604
- defaultRows: WEB_TERMINAL_DEFAULT_ROWS,
605
- minCols: WEB_TERMINAL_MIN_COLS,
606
- minRows: WEB_TERMINAL_MIN_ROWS
607
- });
608
-
609
- const handleWebUpgradeRequest = createWebUpgradeHandler({
610
- formatUrlHost,
611
- sendWebSocketUpgradeError,
612
- getWebAuthSession,
613
- parseWebSessionKey,
614
- decodeSessionName,
615
- safeContainerNamePattern: SAFE_CONTAINER_NAME_PATTERN,
616
- normalizeTerminalSize,
617
- ensureWebContainer,
618
- maxTerminalSessions: WEB_TERMINAL_MAX_SESSIONS
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
- const {
622
- createWsServer,
623
- createHttpServer,
624
- listenWebServer,
625
- closeWebServer
626
- } = createWebServerLifecycleHelpers({
627
- http,
628
- WebSocket,
629
- formatUrlHost,
630
- normalizeTerminalSize,
631
- bindTerminalWebSocket,
632
- handleWebUpgradeRequest,
633
- sendJson,
634
- sendHtml,
635
- cleanupWebRuntimeState
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(resolvedBase.env).forEach(([key, value]) => {
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
- resolvedBase.envFile.forEach(filePath => {
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
- resolvedBase.volumes.forEach(volume => {
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
- resolvedBase.ports.forEach(port => {
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
- ...createSystemApiRoutes({
2020
- req,
2021
- res,
2022
- ctx,
2023
- state,
2024
- fs,
2025
- os,
2026
- path,
2027
- withJsonBody,
2028
- sendJson,
2029
- expandHomeAliasPath,
2030
- readWebConfigSnapshot,
2031
- buildSafeWebConfigSnapshot,
2032
- restoreWebConfigSecrets,
2033
- parseAndValidateConfigRaw,
2034
- buildConfigDefaults
2035
- }),
2036
- ...createSessionApiRoutes({
2037
- req,
2038
- res,
2039
- ctx,
2040
- state,
2041
- WEB_DEFAULT_AGENT_ID,
2042
- withSessionRef,
2043
- withJsonBody,
2044
- withSessionJsonBody,
2045
- getRequiredBodyText,
2046
- prepareAgentRequest,
2047
- sendJson,
2048
- sendNdjson,
2049
- buildCreateRuntime,
2050
- ensureWebContainer,
2051
- setWebSessionAgentPromptCommand,
2052
- patchWebSessionHistory,
2053
- listWebManyoyoContainers,
2054
- listWebHistorySessionNames,
2055
- loadWebSessionHistory,
2056
- listWebAgentSessions,
2057
- buildSessionSummary,
2058
- createWebAgentSession,
2059
- saveWebSessionHistory,
2060
- buildWebSessionKey,
2061
- getWebAgentSession,
2062
- createEmptyWebAgentSession,
2063
- buildSessionDetail,
2064
- hasOwn,
2065
- setWebAgentSessionPromptCommand,
2066
- appendWebSessionMessage,
2067
- execCommandInWebContainer,
2068
- finalizeWebAgentExecution,
2069
- execAgentInWebContainerStream,
2070
- appendWebAgentTraceMessage,
2071
- stopWebAgentRun,
2072
- removeWebSessionHistory
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
- return await runMatchedRoute(routes, req.method, pathname);
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 ctx = createWebServerContext(options);
2080
- const state = createWebServerState(options);
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 = createWsServer(ctx, state);
2085
-
2086
- const { handleWebHttpRequest } = createWebHttpHandlers({
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
- const server = createHttpServer(ctx, state, wsServer, handleWebHttpRequest);
2107
- const listenPort = await listenWebServer(server, ctx);
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: () => closeWebServer(server, wsServer, ctx, state)
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