@xcanwin/manyoyo 5.8.5 → 5.8.9

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
@@ -10,6 +10,31 @@ const WebSocket = require('ws');
10
10
  const JSON5 = require('json5');
11
11
  const { buildContainerRunArgs } = require('../container-run');
12
12
  const { extractAgentMessageFromCodexJsonl } = require('../codex-output');
13
+ const { findValueRangeByPath, applyTextReplacements } = require('../json5-text-edit');
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
+ const {
34
+ parseEnvEntry,
35
+ expandHomeAliasPath,
36
+ normalizeVolume
37
+ } = require('../runtime-normalizers');
13
38
  const {
14
39
  resolveAgentProgram,
15
40
  resolveAgentPromptCommandTemplate,
@@ -34,6 +59,9 @@ const WEB_DEFAULT_AGENT_ID = 'default';
34
59
  const WEB_DEFAULT_AGENT_NAME = 'AGENT 1';
35
60
  const WEB_CONFIG_KEEP_SECRET_PLACEHOLDER = '***HIDDEN_SECRET***';
36
61
  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']);
37
65
  const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
38
66
  const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
39
67
  const SENSITIVE_CONFIG_KEY_PATTERN = /(pass(word)?|passwd|secret|token|api(?:_|-)?key|auth(?:_|-)?token|oauth(?:_|-)?token)$/i;
@@ -517,273 +545,95 @@ function clipText(text, maxChars = WEB_OUTPUT_MAX_CHARS) {
517
545
  return `${text.slice(0, maxChars)}\n...[truncated]`;
518
546
  }
519
547
 
520
- function normalizeAgentPromptCommandTemplate(value, sourceLabel = 'agentPromptCommand') {
521
- if (value === undefined || value === null) {
522
- return '';
523
- }
524
- if (typeof value !== 'string') {
525
- throw new Error(`${sourceLabel} 必须是字符串`);
526
- }
527
- const text = value.trim();
528
- if (!text) {
529
- return '';
530
- }
531
- if (!text.includes('{prompt}')) {
532
- throw new Error(`${sourceLabel} 必须包含 {prompt} 占位符`);
533
- }
534
- if (/^codex\s+exec(?:\s|$)/.test(text) && !text.includes('--skip-git-repo-check')) {
535
- return text.replace(/^codex\s+exec\b/, 'codex exec --skip-git-repo-check');
536
- }
537
- return text;
538
- }
539
-
540
- function isAgentPromptCommandEnabled(value) {
541
- return typeof value === 'string' && value.includes('{prompt}') && Boolean(value.trim());
542
- }
543
-
544
- function quoteBashSingleValue(value) {
545
- const text = String(value || '');
546
- return `'${text.replace(/'/g, `'\"'\"'`)}'`;
547
- }
548
-
549
- function renderAgentPromptCommand(template, prompt) {
550
- const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
551
- const safePrompt = quoteBashSingleValue(prompt);
552
- return templateText.replace(/\{prompt\}/g, safePrompt);
553
- }
554
-
555
- function buildCodexAgentExecCommand(template, prompt) {
556
- const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
557
- const execMatch = templateText.match(
558
- /^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)codex\s+exec\b/
559
- );
560
- let codexTemplate = templateText;
561
- if (execMatch) {
562
- const prefix = execMatch[1] || '';
563
- const suffix = templateText.slice(execMatch[0].length);
564
- const hasJson = /(?:^|\s)--json(?:\s|$)/.test(suffix);
565
- const injectedFlags = hasJson ? '' : ' --json';
566
- codexTemplate = `${prefix}codex exec${injectedFlags}${suffix}`;
567
- }
568
- return codexTemplate === templateText
569
- ? renderAgentPromptCommand(templateText, prompt)
570
- : renderAgentPromptCommand(codexTemplate, prompt);
571
- }
572
-
573
- function prependAgentFlags(commandText, matchPattern, flagSpecs) {
574
- const matched = String(commandText || '').match(matchPattern);
575
- if (!matched) {
576
- return String(commandText || '');
577
- }
578
- const prefix = matched[1] || '';
579
- let suffix = matched[matched.length - 1] || '';
580
- for (let i = flagSpecs.length - 1; i >= 0; i -= 1) {
581
- const spec = flagSpecs[i];
582
- if (!spec || !spec.flag || !(spec.pattern instanceof RegExp) || spec.pattern.test(suffix)) {
583
- continue;
584
- }
585
- suffix = ` ${spec.flag}${suffix}`;
586
- }
587
- return `${prefix}${suffix}`;
588
- }
589
-
590
- function buildClaudeAgentExecCommand(template, prompt) {
591
- const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
592
- const claudeTemplate = prependAgentFlags(
593
- templateText,
594
- /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/,
595
- [
596
- { flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
597
- { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
598
- ]
599
- );
600
- return claudeTemplate === templateText
601
- ? renderAgentPromptCommand(templateText, prompt)
602
- : renderAgentPromptCommand(claudeTemplate, prompt);
603
- }
604
-
605
- function buildGeminiAgentExecCommand(template, prompt) {
606
- const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
607
- const geminiTemplate = prependAgentFlags(
608
- templateText,
609
- /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)gemini\b)(.*)$/,
610
- [
611
- { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
612
- ]
613
- );
614
- return geminiTemplate === templateText
615
- ? renderAgentPromptCommand(templateText, prompt)
616
- : renderAgentPromptCommand(geminiTemplate, prompt);
617
- }
618
-
619
- function buildOpenCodeAgentExecCommand(template, prompt) {
620
- const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
621
- const opencodeTemplate = prependAgentFlags(
622
- templateText,
623
- /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)opencode\s+run\b)(.*)$/,
624
- [
625
- { flag: '--format json', pattern: /(?:^|\s)--format(?:\s|$)/ }
626
- ]
627
- );
628
- return opencodeTemplate === templateText
629
- ? renderAgentPromptCommand(templateText, prompt)
630
- : renderAgentPromptCommand(opencodeTemplate, prompt);
631
- }
632
-
633
- function buildWebAgentExecCommand(template, prompt, agentProgram) {
634
- switch (agentProgram) {
635
- case 'claude':
636
- return buildClaudeAgentExecCommand(template, prompt);
637
- case 'gemini':
638
- return buildGeminiAgentExecCommand(template, prompt);
639
- case 'codex':
640
- return buildCodexAgentExecCommand(template, prompt);
641
- case 'opencode':
642
- return buildOpenCodeAgentExecCommand(template, prompt);
643
- default:
644
- break;
645
- }
646
- return renderAgentPromptCommand(template, prompt);
647
- }
648
-
649
- function parseJsonObjectLine(line) {
650
- const text = String(line || '').trim();
651
- if (!text) {
652
- return null;
653
- }
654
- try {
655
- const payload = JSON.parse(text);
656
- return payload && typeof payload === 'object' ? payload : null;
657
- } catch (e) {
658
- return null;
659
- }
660
- }
661
-
662
- function collectStructuredText(value) {
663
- if (typeof value === 'string') {
664
- return value.trim();
665
- }
666
- if (Array.isArray(value)) {
667
- return value.map(item => collectStructuredText(item)).filter(Boolean).join('\n').trim();
668
- }
669
- if (!value || typeof value !== 'object') {
670
- return '';
671
- }
672
- if (typeof value.text === 'string' && value.text.trim()) {
673
- return value.text.trim();
674
- }
675
- if (typeof value.content === 'string' && value.content.trim()) {
676
- return value.content.trim();
677
- }
678
- if (Array.isArray(value.content)) {
679
- return value.content.map(item => collectStructuredText(item)).filter(Boolean).join('\n').trim();
680
- }
681
- return '';
682
- }
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
+ };
683
565
 
684
- function extractClaudeAgentMessage(text) {
685
- let lastMessage = '';
686
- for (const rawLine of String(text || '').split('\n')) {
687
- const payload = parseJsonObjectLine(rawLine);
688
- if (!payload || payload.type !== 'assistant') {
689
- continue;
690
- }
691
- const message = toPlainObject(payload.message);
692
- const content = Array.isArray(message.content) ? message.content : [];
693
- const nextMessage = content
694
- .filter(item => item && typeof item === 'object' && item.type === 'text')
695
- .map(item => collectStructuredText(item))
696
- .filter(Boolean)
697
- .join('\n')
698
- .trim();
699
- if (nextMessage) {
700
- lastMessage = nextMessage;
701
- }
702
- }
703
- return lastMessage.trim();
704
- }
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
+ });
705
580
 
706
- function extractGeminiAgentMessage(text) {
707
- let lastMessage = '';
708
- let deltaMessage = '';
709
- for (const rawLine of String(text || '').split('\n')) {
710
- const payload = parseJsonObjectLine(rawLine);
711
- if (!payload || payload.type !== 'message' || payload.role !== 'assistant') {
712
- continue;
713
- }
714
- const content = collectStructuredText(payload.content);
715
- if (!content) {
716
- continue;
717
- }
718
- if (payload.delta === true) {
719
- deltaMessage += content;
720
- lastMessage = deltaMessage.trim();
721
- continue;
722
- }
723
- deltaMessage = '';
724
- lastMessage = content;
725
- }
726
- return lastMessage.trim();
727
- }
581
+ const {
582
+ createInitialWebRuntimeState,
583
+ stopWebAgentRun,
584
+ cleanupWebRuntimeState
585
+ } = createWebRuntimeStateHelpers();
728
586
 
729
- function extractOpenCodeAgentMessage(text) {
730
- let lastMessage = '';
731
- let deltaMessage = '';
732
- for (const rawLine of String(text || '').split('\n')) {
733
- const payload = parseJsonObjectLine(rawLine);
734
- if (!payload) {
735
- continue;
736
- }
737
- const eventType = pickFirstString(payload.type);
738
- const message = toPlainObject(payload.message);
739
- const role = pickFirstString(payload.role, message.role);
740
- if (eventType !== 'message' && eventType !== 'assistant' && eventType !== 'assistant_message' && eventType !== 'text') {
741
- continue;
742
- }
743
- if (role && role !== 'assistant') {
744
- continue;
745
- }
746
- const content = collectStructuredText(message.content || payload.content || payload.text || payload);
747
- if (!content) {
748
- continue;
749
- }
750
- if (payload.delta === true) {
751
- deltaMessage += content;
752
- lastMessage = deltaMessage.trim();
753
- continue;
754
- }
755
- deltaMessage = '';
756
- lastMessage = content;
757
- }
758
- return lastMessage.trim();
759
- }
587
+ const {
588
+ createWebServerContext,
589
+ createWebServerState
590
+ } = createWebServerContextHelpers({
591
+ createInitialWebRuntimeState,
592
+ getDefaultWebConfigPath
593
+ });
760
594
 
761
- function extractAgentMessageFromStructuredOutput(agentProgram, text) {
762
- if (agentProgram === 'codex') {
763
- return extractAgentMessageFromCodexJsonl(text);
764
- }
765
- if (agentProgram === 'claude') {
766
- return extractClaudeAgentMessage(text);
767
- }
768
- if (agentProgram === 'gemini') {
769
- return extractGeminiAgentMessage(text);
770
- }
771
- if (agentProgram === 'opencode') {
772
- return extractOpenCodeAgentMessage(text);
773
- }
774
- return '';
775
- }
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
+ });
776
620
 
777
- function getAgentRuntimeMeta(template) {
778
- const normalizedTemplate = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
779
- const agentProgram = resolveAgentProgram(normalizedTemplate);
780
- const resumeCommand = buildAgentResumeCommand(agentProgram);
781
- return {
782
- agentProgram: agentProgram || '',
783
- resumeCommand: resumeCommand || '',
784
- resumeSupported: Boolean(resumeCommand)
785
- };
786
- }
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
+ });
787
637
 
788
638
  function hasAgentConversationHistory(history) {
789
639
  const messages = history && Array.isArray(history.messages) ? history.messages : [];
@@ -845,599 +695,6 @@ function buildAgentPromptWithHistory(history, prompt) {
845
695
  ].join('\n');
846
696
  }
847
697
 
848
- function shortenTraceText(value, maxChars = 140) {
849
- const raw = clipText(stripAnsi(String(value || '')).replace(/\s+/g, ' ').trim(), maxChars);
850
- return raw.trim();
851
- }
852
-
853
- function summarizeTraceArguments(args) {
854
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
855
- return '';
856
- }
857
- const parts = [];
858
- for (const [key, value] of Object.entries(args)) {
859
- if (value === undefined || value === null) continue;
860
- if (typeof value === 'string') {
861
- const textValue = value.trim();
862
- if (!textValue) continue;
863
- parts.push(`${key}=${shortenTraceText(textValue, 80)}`);
864
- continue;
865
- }
866
- if (typeof value === 'number' || typeof value === 'boolean') {
867
- parts.push(`${key}=${String(value)}`);
868
- }
869
- }
870
- return parts.slice(0, 3).join(', ');
871
- }
872
-
873
- function createStructuredTraceEvent(provider, kind, eventType, textValue, extra = {}) {
874
- const normalizedText = String(textValue || '').trim();
875
- if (!normalizedText) {
876
- return null;
877
- }
878
- return {
879
- provider,
880
- kind,
881
- eventType,
882
- text: normalizedText,
883
- ...extra
884
- };
885
- }
886
-
887
- function prepareClaudeTraceEvents(payload, state) {
888
- const eventType = pickFirstString(payload.type);
889
- const subtype = pickFirstString(payload.subtype);
890
- const message = toPlainObject(payload.message);
891
- const content = Array.isArray(message.content) ? message.content : [];
892
- const toolNamesById = state.toolNamesById;
893
- const events = [];
894
-
895
- if (eventType === 'system' && subtype === 'init') {
896
- events.push(createStructuredTraceEvent('claude', 'thread', eventType, '[会话] Claude 已开始处理', {
897
- phase: 'started',
898
- status: 'started',
899
- subtype
900
- }));
901
- return events.filter(Boolean);
902
- }
903
- if (eventType === 'assistant') {
904
- content.forEach(item => {
905
- if (!item || typeof item !== 'object') {
906
- return;
907
- }
908
- if (item.type === 'text') {
909
- const detail = collectStructuredText(item);
910
- if (detail) {
911
- events.push(createStructuredTraceEvent('claude', 'agent_message', eventType, `[说明] ${detail}`, {
912
- phase: 'completed',
913
- status: 'completed',
914
- detail
915
- }));
916
- }
917
- return;
918
- }
919
- if (item.type === 'tool_use') {
920
- const toolName = pickFirstString(item.name, item.id, 'tool');
921
- const toolId = pickFirstString(item.id);
922
- if (toolId) {
923
- toolNamesById.set(toolId, toolName);
924
- }
925
- const summary = summarizeTraceArguments(toPlainObject(item.input));
926
- events.push(createStructuredTraceEvent(
927
- 'claude',
928
- 'tool',
929
- eventType,
930
- summary ? `[工具开始] ${toolName} (${summary})` : `[工具开始] ${toolName}`,
931
- {
932
- phase: 'started',
933
- status: 'in_progress',
934
- toolName,
935
- toolId,
936
- arguments: toPlainObject(item.input),
937
- argumentSummary: summary
938
- }
939
- ));
940
- }
941
- });
942
- return events.filter(Boolean);
943
- }
944
- if (eventType === 'user') {
945
- content.forEach(item => {
946
- if (!item || typeof item !== 'object' || item.type !== 'tool_result') {
947
- return;
948
- }
949
- const toolId = pickFirstString(item.tool_use_id);
950
- const toolName = pickFirstString(toolNamesById.get(toolId), toolId, 'tool');
951
- const status = item.is_error === true ? 'error' : 'success';
952
- events.push(createStructuredTraceEvent('claude', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
953
- phase: 'completed',
954
- status,
955
- toolName,
956
- toolId,
957
- result: collectStructuredText(item.content),
958
- error: item.is_error === true ? collectStructuredText(item.content) : ''
959
- }));
960
- });
961
- return events.filter(Boolean);
962
- }
963
- if (eventType === 'result') {
964
- events.push(createStructuredTraceEvent('claude', 'turn', eventType, '[回合] 响应完成', {
965
- phase: 'completed',
966
- status: pickFirstString(subtype, 'completed'),
967
- subtype
968
- }));
969
- return events.filter(Boolean);
970
- }
971
- if (eventType === 'error') {
972
- const detail = pickFirstString(payload.message, payload.error);
973
- events.push(createStructuredTraceEvent('claude', 'error', eventType, detail ? `[错误] ${detail}` : '[错误] Claude 返回了错误事件', {
974
- status: 'error',
975
- detail
976
- }));
977
- return events.filter(Boolean);
978
- }
979
- return [];
980
- }
981
-
982
- function prepareGeminiTraceEvents(payload, state) {
983
- const eventType = pickFirstString(payload.type);
984
- const toolNamesById = state.toolNamesById;
985
- const events = [];
986
-
987
- if (eventType === 'init') {
988
- events.push(createStructuredTraceEvent('gemini', 'thread', eventType, '[会话] Gemini 已开始处理', {
989
- phase: 'started',
990
- status: 'started',
991
- sessionId: pickFirstString(payload.session_id),
992
- model: pickFirstString(payload.model)
993
- }));
994
- return events.filter(Boolean);
995
- }
996
- if (eventType === 'message' && payload.role === 'assistant') {
997
- if (payload.delta === true) {
998
- return [];
999
- }
1000
- const detail = collectStructuredText(payload.content);
1001
- if (!detail) {
1002
- return [];
1003
- }
1004
- events.push(createStructuredTraceEvent('gemini', 'agent_message', eventType, `[说明] ${detail}`, {
1005
- phase: 'completed',
1006
- status: 'completed',
1007
- detail
1008
- }));
1009
- return events.filter(Boolean);
1010
- }
1011
- if (eventType === 'tool_use') {
1012
- const toolName = pickFirstString(payload.tool_name, payload.tool_id, 'tool');
1013
- const toolId = pickFirstString(payload.tool_id);
1014
- if (toolId) {
1015
- toolNamesById.set(toolId, toolName);
1016
- }
1017
- const summary = summarizeTraceArguments(toPlainObject(payload.parameters));
1018
- events.push(createStructuredTraceEvent(
1019
- 'gemini',
1020
- 'tool',
1021
- eventType,
1022
- summary ? `[工具开始] ${toolName} (${summary})` : `[工具开始] ${toolName}`,
1023
- {
1024
- phase: 'started',
1025
- status: 'in_progress',
1026
- toolName,
1027
- toolId,
1028
- arguments: toPlainObject(payload.parameters),
1029
- argumentSummary: summary
1030
- }
1031
- ));
1032
- return events.filter(Boolean);
1033
- }
1034
- if (eventType === 'tool_result') {
1035
- const toolId = pickFirstString(payload.tool_id);
1036
- const toolName = pickFirstString(toolNamesById.get(toolId), toolId, 'tool');
1037
- const status = pickFirstString(payload.status, 'completed');
1038
- events.push(createStructuredTraceEvent('gemini', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
1039
- phase: 'completed',
1040
- status,
1041
- toolName,
1042
- toolId,
1043
- result: collectStructuredText(payload.output),
1044
- error: toPlainObject(payload.error)
1045
- }));
1046
- return events.filter(Boolean);
1047
- }
1048
- if (eventType === 'result') {
1049
- events.push(createStructuredTraceEvent('gemini', 'turn', eventType, '[回合] 响应完成', {
1050
- phase: 'completed',
1051
- status: pickFirstString(payload.status, 'completed')
1052
- }));
1053
- return events.filter(Boolean);
1054
- }
1055
- if (eventType === 'error') {
1056
- const detail = pickFirstString(payload.message);
1057
- events.push(createStructuredTraceEvent('gemini', 'error', eventType, detail ? `[错误] ${detail}` : '[错误] Gemini 返回了错误事件', {
1058
- status: pickFirstString(payload.severity, 'error'),
1059
- detail
1060
- }));
1061
- return events.filter(Boolean);
1062
- }
1063
- return [];
1064
- }
1065
-
1066
- function prepareOpenCodeTraceEvents(payload, state) {
1067
- const eventType = pickFirstString(payload.type);
1068
- const message = toPlainObject(payload.message);
1069
- const role = pickFirstString(payload.role, message.role);
1070
- const toolNamesById = state.toolNamesById;
1071
- const events = [];
1072
-
1073
- if (eventType === 'session.start' || eventType === 'init') {
1074
- events.push(createStructuredTraceEvent('opencode', 'thread', eventType, '[会话] OpenCode 已开始处理', {
1075
- phase: 'started',
1076
- status: 'started',
1077
- sessionId: pickFirstString(payload.session_id, payload.sessionID)
1078
- }));
1079
- return events.filter(Boolean);
1080
- }
1081
- if (eventType === 'message' || eventType === 'assistant' || eventType === 'assistant_message' || eventType === 'text') {
1082
- if (role && role !== 'assistant') {
1083
- return [];
1084
- }
1085
- if (payload.delta === true) {
1086
- return [];
1087
- }
1088
- const detail = collectStructuredText(message.content || payload.content || payload.text || payload);
1089
- if (!detail) {
1090
- return [];
1091
- }
1092
- events.push(createStructuredTraceEvent('opencode', 'agent_message', eventType, `[说明] ${detail}`, {
1093
- phase: 'completed',
1094
- status: 'completed',
1095
- detail
1096
- }));
1097
- return events.filter(Boolean);
1098
- }
1099
- if (eventType === 'tool_use' || eventType === 'step_start') {
1100
- const toolName = pickFirstString(payload.tool_name, payload.name, payload.tool, payload.step, payload.tool_id, 'tool');
1101
- const toolId = pickFirstString(payload.tool_id, payload.id);
1102
- if (toolId) {
1103
- toolNamesById.set(toolId, toolName);
1104
- }
1105
- const argumentsValue = toPlainObject(payload.parameters || payload.input || payload.arguments);
1106
- const summary = summarizeTraceArguments(argumentsValue);
1107
- events.push(createStructuredTraceEvent(
1108
- 'opencode',
1109
- 'tool',
1110
- eventType,
1111
- summary ? `[工具开始] ${toolName} (${summary})` : `[工具开始] ${toolName}`,
1112
- {
1113
- phase: 'started',
1114
- status: pickFirstString(payload.status, 'in_progress'),
1115
- toolName,
1116
- toolId,
1117
- arguments: argumentsValue,
1118
- argumentSummary: summary
1119
- }
1120
- ));
1121
- return events.filter(Boolean);
1122
- }
1123
- if (eventType === 'tool_result' || eventType === 'step_finish') {
1124
- const toolId = pickFirstString(payload.tool_id, payload.id);
1125
- const toolName = pickFirstString(toolNamesById.get(toolId), payload.tool_name, payload.name, payload.tool, toolId, 'tool');
1126
- const status = pickFirstString(payload.status, payload.state, 'completed');
1127
- events.push(createStructuredTraceEvent('opencode', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
1128
- phase: 'completed',
1129
- status,
1130
- toolName,
1131
- toolId,
1132
- result: collectStructuredText(payload.output || payload.result),
1133
- error: toPlainObject(payload.error)
1134
- }));
1135
- return events.filter(Boolean);
1136
- }
1137
- if (eventType === 'result') {
1138
- events.push(createStructuredTraceEvent('opencode', 'turn', eventType, '[回合] 响应完成', {
1139
- phase: 'completed',
1140
- status: pickFirstString(payload.status, 'completed')
1141
- }));
1142
- return events.filter(Boolean);
1143
- }
1144
- if (eventType === 'error') {
1145
- const detail = pickFirstString(payload.message, payload.error && payload.error.message);
1146
- events.push(createStructuredTraceEvent('opencode', 'error', eventType, detail ? `[错误] ${detail}` : '[错误] OpenCode 返回了错误事件', {
1147
- status: 'error',
1148
- detail
1149
- }));
1150
- return events.filter(Boolean);
1151
- }
1152
- return [];
1153
- }
1154
-
1155
- function prepareStructuredTraceEvents(agentProgram, payload, state) {
1156
- if (!payload || typeof payload !== 'object') {
1157
- return [];
1158
- }
1159
- if (agentProgram === 'codex') {
1160
- const traceEvent = prepareCodexTraceEvent(payload);
1161
- return traceEvent ? [traceEvent] : [];
1162
- }
1163
- if (agentProgram === 'claude') {
1164
- return prepareClaudeTraceEvents(payload, state);
1165
- }
1166
- if (agentProgram === 'gemini') {
1167
- return prepareGeminiTraceEvents(payload, state);
1168
- }
1169
- if (agentProgram === 'opencode') {
1170
- return prepareOpenCodeTraceEvents(payload, state);
1171
- }
1172
- return [];
1173
- }
1174
-
1175
- function extractContentDeltaFromPayload(agentProgram, payload) {
1176
- if (!payload || typeof payload !== 'object') {
1177
- return null;
1178
- }
1179
- if (agentProgram === 'claude') {
1180
- if (pickFirstString(payload.type) !== 'assistant') {
1181
- return null;
1182
- }
1183
- const message = toPlainObject(payload.message);
1184
- const content = Array.isArray(message.content) ? message.content : [];
1185
- const text = content
1186
- .filter(item => item && typeof item === 'object' && item.type === 'text')
1187
- .map(item => collectStructuredText(item))
1188
- .filter(Boolean)
1189
- .join('\n')
1190
- .trim();
1191
- if (!text) {
1192
- return null;
1193
- }
1194
- return { text, reset: true };
1195
- }
1196
- if (agentProgram === 'gemini' || agentProgram === 'opencode') {
1197
- const eventType = pickFirstString(payload.type);
1198
- if (eventType !== 'message') {
1199
- return null;
1200
- }
1201
- const role = pickFirstString(payload.role);
1202
- if (role !== 'assistant') {
1203
- return null;
1204
- }
1205
- const text = collectStructuredText(payload.content);
1206
- if (!text) {
1207
- return null;
1208
- }
1209
- if (payload.delta === true) {
1210
- return { text, reset: false };
1211
- }
1212
- return { text, reset: true };
1213
- }
1214
- return null;
1215
- }
1216
-
1217
- function prepareCodexTraceEvent(payload) {
1218
- if (!payload || typeof payload !== 'object') {
1219
- return null;
1220
- }
1221
-
1222
- const eventType = typeof payload.type === 'string' ? payload.type : '';
1223
- const item = payload.item && typeof payload.item === 'object' && !Array.isArray(payload.item)
1224
- ? payload.item
1225
- : {};
1226
- const itemType = typeof item.type === 'string' ? item.type : '';
1227
- const text = pickFirstString(
1228
- item.title,
1229
- item.summary,
1230
- item.text,
1231
- item.name,
1232
- item.command,
1233
- payload.message,
1234
- payload.text
1235
- );
1236
- const toolName = pickFirstString(
1237
- item.name,
1238
- item.tool_name,
1239
- item.tool,
1240
- item.command
1241
- );
1242
- const commandText = pickFirstString(item.command);
1243
- const mcpServer = pickFirstString(item.server);
1244
- const mcpTool = pickFirstString(item.tool);
1245
- const itemStatus = pickFirstString(item.status);
1246
-
1247
- function shortenText(value, maxChars = 140) {
1248
- const raw = clipText(stripAnsi(String(value || '')).replace(/\s+/g, ' ').trim(), maxChars);
1249
- return raw.trim();
1250
- }
1251
-
1252
- function summarizeArguments(args) {
1253
- if (!args || typeof args !== 'object' || Array.isArray(args)) {
1254
- return '';
1255
- }
1256
- const parts = [];
1257
- for (const [key, value] of Object.entries(args)) {
1258
- if (value === undefined || value === null) continue;
1259
- if (typeof value === 'string') {
1260
- const textValue = value.trim();
1261
- if (!textValue) continue;
1262
- parts.push(`${key}=${shortenText(textValue, 80)}`);
1263
- continue;
1264
- }
1265
- if (typeof value === 'number' || typeof value === 'boolean') {
1266
- parts.push(`${key}=${String(value)}`);
1267
- }
1268
- }
1269
- return parts.slice(0, 3).join(', ');
1270
- }
1271
-
1272
- function pickDisplayStatus(defaultStatus) {
1273
- const status = String(itemStatus || defaultStatus || '').trim();
1274
- return status || '';
1275
- }
1276
-
1277
- function createTraceEvent(kind, textValue, extra = {}) {
1278
- const normalizedText = String(textValue || '').trim();
1279
- if (!normalizedText) {
1280
- return null;
1281
- }
1282
- return {
1283
- provider: 'codex',
1284
- kind,
1285
- eventType,
1286
- itemType: itemType || '',
1287
- text: normalizedText,
1288
- ...extra
1289
- };
1290
- }
1291
-
1292
- if (eventType === 'thread.started') {
1293
- return createTraceEvent('thread', '[会话] Codex 已开始处理', {
1294
- phase: 'started',
1295
- status: 'started'
1296
- });
1297
- }
1298
- if (eventType === 'thread.completed') {
1299
- return createTraceEvent('thread', '[会话] Codex 已完成当前任务', {
1300
- phase: 'completed',
1301
- status: 'completed'
1302
- });
1303
- }
1304
- if (eventType === 'turn.started') {
1305
- return createTraceEvent('turn', '[回合] 开始生成响应', {
1306
- phase: 'started',
1307
- status: 'started'
1308
- });
1309
- }
1310
- if (eventType === 'turn.completed') {
1311
- return createTraceEvent('turn', '[回合] 响应完成', {
1312
- phase: 'completed',
1313
- status: 'completed'
1314
- });
1315
- }
1316
- if (eventType === 'item.started') {
1317
- if (itemType === 'tool_call') {
1318
- return createTraceEvent('tool', `[工具开始] ${toolName || 'tool_call'}`, {
1319
- phase: 'started',
1320
- status: pickDisplayStatus('in_progress'),
1321
- toolName: toolName || 'tool_call'
1322
- });
1323
- }
1324
- if (itemType === 'command_execution') {
1325
- return createTraceEvent('command', `[命令开始] ${commandText || 'command_execution'}`, {
1326
- phase: 'started',
1327
- status: pickDisplayStatus('in_progress'),
1328
- command: commandText || 'command_execution'
1329
- });
1330
- }
1331
- if (itemType === 'mcp_tool_call') {
1332
- const summary = summarizeArguments(item.arguments);
1333
- return createTraceEvent(
1334
- 'mcp',
1335
- summary
1336
- ? `[MCP开始] ${mcpServer || 'mcp'}.${mcpTool || 'tool'} (${summary})`
1337
- : `[MCP开始] ${mcpServer || 'mcp'}.${mcpTool || 'tool'}`,
1338
- {
1339
- phase: 'started',
1340
- status: pickDisplayStatus('in_progress'),
1341
- server: mcpServer || 'mcp',
1342
- tool: mcpTool || 'tool',
1343
- arguments: item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)
1344
- ? item.arguments
1345
- : null,
1346
- argumentSummary: summary
1347
- }
1348
- );
1349
- }
1350
- if (itemType === 'reasoning') {
1351
- return createTraceEvent('status', text ? `[状态] ${text}` : '[状态] Codex 正在分析', {
1352
- phase: 'started',
1353
- status: pickDisplayStatus('in_progress'),
1354
- detail: text || 'Codex 正在分析'
1355
- });
1356
- }
1357
- if (itemType === 'agent_message') {
1358
- return createTraceEvent('agent_message', text ? `[说明] ${text}` : '[回复] 正在生成最终答复', {
1359
- phase: 'started',
1360
- status: pickDisplayStatus('in_progress'),
1361
- detail: text || '正在生成最终答复'
1362
- });
1363
- }
1364
- return createTraceEvent('event', text ? `[事件开始] ${text}` : `[事件开始] ${itemType || eventType}`, {
1365
- phase: 'started',
1366
- status: pickDisplayStatus('in_progress'),
1367
- detail: text || itemType || eventType
1368
- });
1369
- }
1370
- if (eventType === 'item.completed') {
1371
- if (itemType === 'tool_call') {
1372
- return createTraceEvent('tool', `[工具完成] ${toolName || 'tool_call'}`, {
1373
- phase: 'completed',
1374
- status: pickDisplayStatus('completed'),
1375
- toolName: toolName || 'tool_call'
1376
- });
1377
- }
1378
- if (itemType === 'command_execution') {
1379
- const suffix = itemStatus || (typeof item.exit_code === 'number' ? `exit=${item.exit_code}` : 'completed');
1380
- return createTraceEvent('command', `[命令完成] ${commandText || 'command_execution'} (${suffix})`, {
1381
- phase: 'completed',
1382
- status: pickDisplayStatus(suffix),
1383
- command: commandText || 'command_execution',
1384
- exitCode: typeof item.exit_code === 'number' ? item.exit_code : null
1385
- });
1386
- }
1387
- if (itemType === 'mcp_tool_call') {
1388
- const summary = summarizeArguments(item.arguments);
1389
- return createTraceEvent(
1390
- 'mcp',
1391
- summary
1392
- ? `[MCP完成] ${mcpServer || 'mcp'}.${mcpTool || 'tool'} (${summary})`
1393
- : `[MCP完成] ${mcpServer || 'mcp'}.${mcpTool || 'tool'}`,
1394
- {
1395
- phase: 'completed',
1396
- status: pickDisplayStatus('completed'),
1397
- server: mcpServer || 'mcp',
1398
- tool: mcpTool || 'tool',
1399
- arguments: item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)
1400
- ? item.arguments
1401
- : null,
1402
- argumentSummary: summary,
1403
- result: item.result !== undefined ? item.result : null,
1404
- error: item.error !== undefined ? item.error : null
1405
- }
1406
- );
1407
- }
1408
- if (itemType === 'reasoning') {
1409
- return createTraceEvent('status', text ? `[状态] ${text}` : '', {
1410
- phase: 'completed',
1411
- status: pickDisplayStatus('completed'),
1412
- detail: text || ''
1413
- });
1414
- }
1415
- if (itemType === 'agent_message') {
1416
- return createTraceEvent('agent_message', text ? `[说明] ${text}` : '[回复] 已生成', {
1417
- phase: 'completed',
1418
- status: pickDisplayStatus('completed'),
1419
- detail: text || '已生成'
1420
- });
1421
- }
1422
- return createTraceEvent('event', text ? `[事件完成] ${text}` : `[事件完成] ${itemType || eventType}`, {
1423
- phase: 'completed',
1424
- status: pickDisplayStatus('completed'),
1425
- detail: text || itemType || eventType
1426
- });
1427
- }
1428
- if (eventType === 'error') {
1429
- return createTraceEvent('error', text ? `[错误] ${text}` : '[错误] Codex 返回了错误事件', {
1430
- status: 'error',
1431
- detail: text || 'Codex 返回了错误事件'
1432
- });
1433
- }
1434
-
1435
- return createTraceEvent('event', `[事件] ${eventType}`, {
1436
- status: itemStatus || '',
1437
- detail: eventType
1438
- });
1439
- }
1440
-
1441
698
  async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
1442
699
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
1443
700
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true });
@@ -1636,242 +893,22 @@ function isSensitiveConfigKey(key) {
1636
893
  return Boolean(normalized) && SENSITIVE_CONFIG_KEY_PATTERN.test(normalized);
1637
894
  }
1638
895
 
1639
- function readConfigQuotedString(text, startIndex) {
1640
- const quote = text[startIndex];
1641
- let value = '';
1642
-
1643
- for (let i = startIndex + 1; i < text.length; i += 1) {
1644
- const ch = text[i];
1645
- if (ch === '\\') {
1646
- value += ch;
1647
- if (i + 1 < text.length) {
1648
- value += text[i + 1];
1649
- i += 1;
1650
- }
1651
- continue;
1652
- }
1653
- if (ch === quote) {
1654
- return {
1655
- value,
1656
- end: i + 1
1657
- };
1658
- }
1659
- value += ch;
896
+ function collectSensitiveConfigPaths(value, pathParts = []) {
897
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
898
+ return [];
1660
899
  }
1661
-
1662
- return null;
1663
- }
1664
-
1665
- function isConfigIdentifierStart(ch) {
1666
- return /[A-Za-z_$]/.test(ch);
1667
- }
1668
-
1669
- function isConfigIdentifierPart(ch) {
1670
- return /[A-Za-z0-9_$]/.test(ch);
1671
- }
1672
-
1673
- function skipConfigTrivia(text, index) {
1674
- let cursor = index;
1675
- while (cursor < text.length) {
1676
- const ch = text[cursor];
1677
- const next = text[cursor + 1];
1678
- if (/\s/.test(ch)) {
1679
- cursor += 1;
1680
- continue;
1681
- }
1682
- if (ch === '/' && next === '/') {
1683
- cursor += 2;
1684
- while (cursor < text.length && text[cursor] !== '\n') {
1685
- cursor += 1;
1686
- }
1687
- continue;
900
+ const result = [];
901
+ Object.entries(toPlainObject(value)).forEach(([key, item]) => {
902
+ const nextPath = pathParts.concat(key);
903
+ if (isSensitiveConfigKey(key)) {
904
+ result.push(nextPath);
905
+ return;
1688
906
  }
1689
- if (ch === '/' && next === '*') {
1690
- cursor += 2;
1691
- while (cursor + 1 < text.length && !(text[cursor] === '*' && text[cursor + 1] === '/')) {
1692
- cursor += 1;
1693
- }
1694
- cursor = cursor + 1 < text.length ? cursor + 2 : text.length;
1695
- continue;
907
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
908
+ result.push(...collectSensitiveConfigPaths(item, nextPath));
1696
909
  }
1697
- break;
1698
- }
1699
- return cursor;
1700
- }
1701
-
1702
- function scanConfigValueEnd(text, startIndex) {
1703
- let cursor = startIndex;
1704
- let stringQuote = '';
1705
- let lineComment = false;
1706
- let blockComment = false;
1707
- let depth = 0;
1708
-
1709
- for (; cursor < text.length; cursor += 1) {
1710
- const ch = text[cursor];
1711
- const next = text[cursor + 1];
1712
-
1713
- if (lineComment) {
1714
- if (ch === '\n') {
1715
- lineComment = false;
1716
- }
1717
- continue;
1718
- }
1719
- if (blockComment) {
1720
- if (ch === '*' && next === '/') {
1721
- blockComment = false;
1722
- cursor += 1;
1723
- }
1724
- continue;
1725
- }
1726
- if (stringQuote) {
1727
- if (ch === '\\') {
1728
- cursor += 1;
1729
- continue;
1730
- }
1731
- if (ch === stringQuote) {
1732
- stringQuote = '';
1733
- }
1734
- continue;
1735
- }
1736
-
1737
- if (ch === '/' && next === '/') {
1738
- lineComment = true;
1739
- cursor += 1;
1740
- continue;
1741
- }
1742
- if (ch === '/' && next === '*') {
1743
- blockComment = true;
1744
- cursor += 1;
1745
- continue;
1746
- }
1747
- if (ch === '"' || ch === '\'') {
1748
- stringQuote = ch;
1749
- continue;
1750
- }
1751
- if (ch === '{' || ch === '[' || ch === '(') {
1752
- depth += 1;
1753
- continue;
1754
- }
1755
- if (ch === '}' || ch === ']' || ch === ')') {
1756
- if (depth === 0) {
1757
- break;
1758
- }
1759
- depth -= 1;
1760
- continue;
1761
- }
1762
- if (depth === 0 && ch === ',') {
1763
- break;
1764
- }
1765
- }
1766
-
1767
- let end = cursor;
1768
- while (end > startIndex && /\s/.test(text[end - 1])) {
1769
- end -= 1;
1770
- }
1771
- return end;
1772
- }
1773
-
1774
- function findConfigRootObjectStart(text) {
1775
- const start = skipConfigTrivia(String(text || ''), 0);
1776
- return text[start] === '{' ? start : -1;
1777
- }
1778
-
1779
- function readConfigPropertyToken(text, startIndex) {
1780
- const ch = text[startIndex];
1781
- if (ch === '"' || ch === '\'') {
1782
- const token = readConfigQuotedString(text, startIndex);
1783
- if (!token) {
1784
- return null;
1785
- }
1786
- return token;
1787
- }
1788
- if (!isConfigIdentifierStart(ch)) {
1789
- return null;
1790
- }
1791
- let end = startIndex + 1;
1792
- while (end < text.length && isConfigIdentifierPart(text[end])) {
1793
- end += 1;
1794
- }
1795
- return {
1796
- value: text.slice(startIndex, end),
1797
- end
1798
- };
1799
- }
1800
-
1801
- function findConfigObjectPropertyValueRange(text, objectStartIndex, propertyName) {
1802
- let cursor = skipConfigTrivia(text, objectStartIndex + 1);
1803
- while (cursor < text.length) {
1804
- cursor = skipConfigTrivia(text, cursor);
1805
- if (text[cursor] === '}') {
1806
- return null;
1807
- }
1808
- const token = readConfigPropertyToken(text, cursor);
1809
- if (!token) {
1810
- return null;
1811
- }
1812
- cursor = skipConfigTrivia(text, token.end);
1813
- if (text[cursor] !== ':') {
1814
- return null;
1815
- }
1816
- const valueStart = skipConfigTrivia(text, cursor + 1);
1817
- const valueEnd = scanConfigValueEnd(text, valueStart);
1818
- if (token.value === propertyName) {
1819
- return { start: valueStart, end: valueEnd };
1820
- }
1821
- cursor = skipConfigTrivia(text, valueEnd);
1822
- if (text[cursor] === ',') {
1823
- cursor += 1;
1824
- continue;
1825
- }
1826
- if (text[cursor] === '}') {
1827
- return null;
1828
- }
1829
- }
1830
- return null;
1831
- }
1832
-
1833
- function findConfigValueRangeByPath(text, pathParts) {
1834
- if (!Array.isArray(pathParts) || pathParts.length === 0) {
1835
- return null;
1836
- }
1837
- let objectStart = findConfigRootObjectStart(text);
1838
- if (objectStart === -1) {
1839
- return null;
1840
- }
1841
- let range = null;
1842
- for (let i = 0; i < pathParts.length; i += 1) {
1843
- range = findConfigObjectPropertyValueRange(text, objectStart, pathParts[i]);
1844
- if (!range) {
1845
- return null;
1846
- }
1847
- if (i === pathParts.length - 1) {
1848
- return range;
1849
- }
1850
- const nextObjectStart = skipConfigTrivia(text, range.start);
1851
- if (text[nextObjectStart] !== '{') {
1852
- return null;
1853
- }
1854
- objectStart = nextObjectStart;
1855
- }
1856
- return range;
1857
- }
1858
-
1859
- function collectSensitiveConfigPaths(value, pathParts = []) {
1860
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
1861
- return [];
1862
- }
1863
- const result = [];
1864
- Object.entries(toPlainObject(value)).forEach(([key, item]) => {
1865
- const nextPath = pathParts.concat(key);
1866
- if (isSensitiveConfigKey(key)) {
1867
- result.push(nextPath);
1868
- return;
1869
- }
1870
- if (item && typeof item === 'object' && !Array.isArray(item)) {
1871
- result.push(...collectSensitiveConfigPaths(item, nextPath));
1872
- }
1873
- });
1874
- return result;
910
+ });
911
+ return result;
1875
912
  }
1876
913
 
1877
914
  function collectSensitivePlaceholderPaths(value, pathParts = []) {
@@ -1894,13 +931,6 @@ function collectSensitivePlaceholderPaths(value, pathParts = []) {
1894
931
  return result;
1895
932
  }
1896
933
 
1897
- function applyConfigTextReplacements(text, replacements) {
1898
- return replacements
1899
- .slice()
1900
- .sort((a, b) => b.start - a.start)
1901
- .reduce((result, item) => `${result.slice(0, item.start)}${item.text}${result.slice(item.end)}`, text);
1902
- }
1903
-
1904
934
  function buildConfigPathLabel(pathParts) {
1905
935
  return (Array.isArray(pathParts) ? pathParts : []).join('.');
1906
936
  }
@@ -1908,7 +938,7 @@ function buildConfigPathLabel(pathParts) {
1908
938
  function maskWebConfigRaw(raw, parsed) {
1909
939
  const text = String(raw || '');
1910
940
  const replacements = collectSensitiveConfigPaths(parsed).map(pathParts => {
1911
- const range = findConfigValueRangeByPath(text, pathParts);
941
+ const range = findValueRangeByPath(text, pathParts);
1912
942
  if (!range) {
1913
943
  throw new Error(`敏感字段定位失败: ${buildConfigPathLabel(pathParts)}`);
1914
944
  }
@@ -1918,7 +948,7 @@ function maskWebConfigRaw(raw, parsed) {
1918
948
  text: JSON.stringify(WEB_CONFIG_KEEP_SECRET_PLACEHOLDER)
1919
949
  };
1920
950
  });
1921
- return applyConfigTextReplacements(text, replacements);
951
+ return applyTextReplacements(text, replacements);
1922
952
  }
1923
953
 
1924
954
  function parseConfigRawObject(raw) {
@@ -1946,8 +976,8 @@ function restoreWebConfigSecrets(raw, snapshot) {
1946
976
 
1947
977
  const currentRaw = String(snapshot.raw || '');
1948
978
  const replacements = placeholderPaths.map(pathParts => {
1949
- const editedRange = findConfigValueRangeByPath(text, pathParts);
1950
- const currentRange = findConfigValueRangeByPath(currentRaw, pathParts);
979
+ const editedRange = findValueRangeByPath(text, pathParts);
980
+ const currentRange = findValueRangeByPath(currentRaw, pathParts);
1951
981
  if (!editedRange) {
1952
982
  throw new Error(`敏感字段定位失败: ${buildConfigPathLabel(pathParts)}`);
1953
983
  }
@@ -1961,7 +991,7 @@ function restoreWebConfigSecrets(raw, snapshot) {
1961
991
  };
1962
992
  });
1963
993
 
1964
- return applyConfigTextReplacements(text, replacements);
994
+ return applyTextReplacements(text, replacements);
1965
995
  }
1966
996
 
1967
997
  function redactConfigValue(value) {
@@ -2078,23 +1108,6 @@ function validateWebHostPath(hostPath) {
2078
1108
  }
2079
1109
  }
2080
1110
 
2081
- function parseEnvEntry(entryText) {
2082
- const text = String(entryText || '');
2083
- const idx = text.indexOf('=');
2084
- if (idx <= 0) {
2085
- throw new Error(`env 格式应为 KEY=VALUE: ${text}`);
2086
- }
2087
- const key = text.slice(0, idx);
2088
- const value = text.slice(idx + 1);
2089
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
2090
- throw new Error(`env key 非法: ${key}`);
2091
- }
2092
- if (/[\r\n\0]/.test(value) || /[;&|`$<>]/.test(value)) {
2093
- throw new Error(`env value 含非法字符: ${key}`);
2094
- }
2095
- return { key, value };
2096
- }
2097
-
2098
1111
  function normalizeEnvMap(envConfig, sourceLabel) {
2099
1112
  if (envConfig === undefined || envConfig === null) {
2100
1113
  return {};
@@ -2113,6 +1126,15 @@ function normalizeEnvMap(envConfig, sourceLabel) {
2113
1126
  return result;
2114
1127
  }
2115
1128
 
1129
+ function normalizeCliEnvMap(envList) {
1130
+ const result = {};
1131
+ for (const envText of (envList || [])) {
1132
+ const parsed = parseEnvEntry(envText);
1133
+ result[parsed.key] = parsed.value;
1134
+ }
1135
+ return result;
1136
+ }
1137
+
2116
1138
  function normalizeStringArray(value, sourceLabel) {
2117
1139
  if (value === undefined || value === null) {
2118
1140
  return [];
@@ -2125,41 +1147,6 @@ function normalizeStringArray(value, sourceLabel) {
2125
1147
  .filter(Boolean);
2126
1148
  }
2127
1149
 
2128
- function expandHomeAliasPath(filePath) {
2129
- const text = String(filePath || '').trim();
2130
- if (!text) {
2131
- return text;
2132
- }
2133
- const homeDir = os.homedir();
2134
- if (text === '~') {
2135
- return homeDir;
2136
- }
2137
- if (text.startsWith('~/')) {
2138
- return path.join(homeDir, text.slice(2));
2139
- }
2140
- if (text === '$HOME') {
2141
- return homeDir;
2142
- }
2143
- if (text.startsWith('$HOME/')) {
2144
- return path.join(homeDir, text.slice('$HOME/'.length));
2145
- }
2146
- return text;
2147
- }
2148
-
2149
- function normalizeVolume(volume) {
2150
- const text = String(volume || '').trim();
2151
- if (!text.startsWith('~') && !text.startsWith('$HOME')) {
2152
- return text;
2153
- }
2154
- const separatorIndex = text.indexOf(':');
2155
- if (separatorIndex === -1) {
2156
- return expandHomeAliasPath(text);
2157
- }
2158
- const hostPath = text.slice(0, separatorIndex);
2159
- const rest = text.slice(separatorIndex);
2160
- return `${expandHomeAliasPath(hostPath)}${rest}`;
2161
- }
2162
-
2163
1150
  function parseEnvFileToArgs(filePath) {
2164
1151
  const resolvedPath = expandHomeAliasPath(filePath);
2165
1152
  if (!path.isAbsolute(resolvedPath)) {
@@ -2428,30 +1415,66 @@ function buildCreateRuntime(ctx, state, payload) {
2428
1415
  const hasConfigVolumes = hasOwn(config, 'volumes');
2429
1416
  const hasConfigPorts = hasOwn(config, 'ports');
2430
1417
 
1418
+ const requestEnvMap = hasRequestEnv ? normalizeEnvMap(requestOptions.env, 'createOptions.env') : {};
1419
+ const requestEnvList = Object.entries(requestEnvMap).map(([key, value]) => `${key}=${value}`);
1420
+ const requestEnvFileList = hasRequestEnvFile ? normalizeStringArray(requestOptions.envFile, 'createOptions.envFile') : [];
1421
+ const requestVolumeList = hasRequestVolumes ? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes') : [];
1422
+ const requestPortList = hasRequestPorts ? normalizeStringArray(requestOptions.ports, 'createOptions.ports') : [];
2431
1423
  const requestName = pickFirstString(requestOptions.containerName, body.name);
2432
- let containerName = pickFirstString(requestName, runConfig.containerName, config.containerName);
2433
- if (!containerName) {
2434
- containerName = `my-${ctx.formatDate()}`;
2435
- }
2436
- containerName = resolveNowTemplate(containerName, ctx.formatDate);
1424
+
1425
+ const resolvedBase = resolveRuntimeConfig({
1426
+ cliOptions: {
1427
+ hostPath: requestOptions.hostPath,
1428
+ contName: requestName,
1429
+ contPath: requestOptions.containerPath,
1430
+ imageName: requestOptions.imageName,
1431
+ imageVer: requestOptions.imageVersion,
1432
+ env: requestEnvList,
1433
+ envFile: requestEnvFileList,
1434
+ volume: requestVolumeList,
1435
+ port: requestPortList
1436
+ },
1437
+ globalConfig: config,
1438
+ runConfig,
1439
+ globalFirstConfig: {},
1440
+ runFirstConfig: {},
1441
+ defaults: {
1442
+ hostPath: ctx.hostPath,
1443
+ containerName: `my-${ctx.formatDate()}`,
1444
+ containerPath: ctx.containerPath,
1445
+ imageName: ctx.imageName,
1446
+ imageVersion: ctx.imageVersion
1447
+ },
1448
+ envVars: {},
1449
+ argv: [],
1450
+ isServerMode: false,
1451
+ isServerStopMode: false,
1452
+ pickConfigValue: pickFirstString,
1453
+ resolveContainerNameTemplate: value => resolveNowTemplate(value, ctx.formatDate),
1454
+ normalizeCommandSuffix: value => {
1455
+ const text = String(value || '').trim();
1456
+ return text ? ` ${text}` : '';
1457
+ },
1458
+ normalizeJsonEnvMap: normalizeEnvMap,
1459
+ normalizeCliEnvMap,
1460
+ mergeArrayConfig: (globalValue, runValue, cliValue) => [...(globalValue || []), ...(runValue || []), ...(cliValue || [])],
1461
+ normalizeVolume,
1462
+ parseServerListen: () => ({ host: '', port: 0 })
1463
+ });
1464
+
1465
+ const containerName = resolvedBase.containerName;
2437
1466
  validateContainerNameStrict(containerName);
2438
1467
 
2439
- const hostPath = pickFirstString(requestOptions.hostPath, runConfig.hostPath, config.hostPath, ctx.hostPath);
1468
+ const hostPath = resolvedBase.hostPath;
2440
1469
  if (typeof ctx.validateHostPath === 'function') {
2441
1470
  ctx.validateHostPath(hostPath);
2442
1471
  } else {
2443
1472
  validateWebHostPath(hostPath);
2444
1473
  }
2445
1474
 
2446
- const containerPath = pickFirstString(
2447
- requestOptions.containerPath,
2448
- runConfig.containerPath,
2449
- config.containerPath,
2450
- ctx.containerPath,
2451
- hostPath
2452
- ) || hostPath;
2453
- const imageName = pickFirstString(requestOptions.imageName, runConfig.imageName, config.imageName, ctx.imageName);
2454
- const imageVersion = pickFirstString(requestOptions.imageVersion, runConfig.imageVersion, config.imageVersion, ctx.imageVersion);
1475
+ const containerPath = resolvedBase.containerPath || hostPath;
1476
+ const imageName = resolvedBase.imageName;
1477
+ const imageVersion = resolvedBase.imageVersion;
2455
1478
 
2456
1479
  if (!/^[A-Za-z0-9][A-Za-z0-9._/:-]*$/.test(imageName)) {
2457
1480
  throw new Error(`imageName 非法: ${imageName}`);
@@ -2510,22 +1533,14 @@ function buildCreateRuntime(ctx, state, payload) {
2510
1533
  const hasRunEnv = hasOwn(runConfig, 'env');
2511
1534
  const hasRunEnvFile = hasOwn(runConfig, 'envFile');
2512
1535
  if (hasRequestEnv || hasRequestEnvFile || hasRunEnv || hasRunEnvFile || hasConfigEnv || hasConfigEnvFile) {
2513
- const configEnv = normalizeEnvMap(config.env, 'config.env');
2514
- const runEnv = normalizeEnvMap(runConfig.env, runName ? `runs.${runName}.env` : 'run.env');
2515
- const requestEnv = hasRequestEnv ? normalizeEnvMap(requestOptions.env, 'createOptions.env') : {};
2516
- const mergedEnv = { ...configEnv, ...runEnv, ...requestEnv };
2517
1536
  const envArgs = [];
2518
- Object.entries(mergedEnv).forEach(([key, value]) => {
1537
+ Object.entries(resolvedBase.env).forEach(([key, value]) => {
2519
1538
  const parsed = parseEnvEntry(`${key}=${value}`);
2520
1539
  envArgs.push('--env', `${parsed.key}=${parsed.value}`);
2521
1540
  });
2522
1541
 
2523
- const envFileList = []
2524
- .concat(normalizeStringArray(config.envFile, 'config.envFile'))
2525
- .concat(normalizeStringArray(runConfig.envFile, runName ? `runs.${runName}.envFile` : 'run.envFile'))
2526
- .concat(hasRequestEnvFile ? normalizeStringArray(requestOptions.envFile, 'createOptions.envFile') : []);
2527
1542
  const envFileArgs = [];
2528
- envFileList.forEach(filePath => {
1543
+ resolvedBase.envFile.forEach(filePath => {
2529
1544
  envFileArgs.push(...parseEnvFileToArgs(filePath));
2530
1545
  });
2531
1546
 
@@ -2535,12 +1550,8 @@ function buildCreateRuntime(ctx, state, payload) {
2535
1550
  let containerVolumes = Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [];
2536
1551
  const hasRunVolumes = hasOwn(runConfig, 'volumes');
2537
1552
  if (hasRequestVolumes || hasRunVolumes || hasConfigVolumes) {
2538
- const volumeList = []
2539
- .concat(normalizeStringArray(config.volumes, 'config.volumes'))
2540
- .concat(normalizeStringArray(runConfig.volumes, runName ? `runs.${runName}.volumes` : 'run.volumes'))
2541
- .concat(hasRequestVolumes ? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes') : []);
2542
1553
  containerVolumes = [];
2543
- volumeList.forEach(volume => {
1554
+ resolvedBase.volumes.forEach(volume => {
2544
1555
  containerVolumes.push('--volume', normalizeVolume(volume));
2545
1556
  });
2546
1557
  }
@@ -2548,12 +1559,8 @@ function buildCreateRuntime(ctx, state, payload) {
2548
1559
  let containerPorts = Array.isArray(ctx.containerPorts) ? ctx.containerPorts.slice() : [];
2549
1560
  const hasRunPorts = hasOwn(runConfig, 'ports');
2550
1561
  if (hasRequestPorts || hasRunPorts || hasConfigPorts) {
2551
- const portList = []
2552
- .concat(normalizeStringArray(config.ports, 'config.ports'))
2553
- .concat(normalizeStringArray(runConfig.ports, runName ? `runs.${runName}.ports` : 'run.ports'))
2554
- .concat(hasRequestPorts ? normalizeStringArray(requestOptions.ports, 'createOptions.ports') : []);
2555
1562
  containerPorts = [];
2556
- portList.forEach(port => {
1563
+ resolvedBase.ports.forEach(port => {
2557
1564
  containerPorts.push('--publish', port);
2558
1565
  });
2559
1566
  }
@@ -2716,229 +1723,6 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
2716
1723
  }
2717
1724
  }
2718
1725
 
2719
- async function execCommandInWebContainer(ctx, containerName, command, options = {}) {
2720
- const opts = options && typeof options === 'object' ? options : {};
2721
- const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
2722
- return await new Promise((resolve, reject) => {
2723
- const process = spawn(
2724
- ctx.dockerCmd,
2725
- ['exec', containerName, '/bin/bash', '-lc', command],
2726
- { stdio: ['ignore', 'pipe', 'pipe'] }
2727
- );
2728
-
2729
- const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
2730
- let stdoutOutput = '';
2731
- let stderrOutput = '';
2732
- let stdoutTruncated = false;
2733
- let stderrTruncated = false;
2734
-
2735
- function appendChunk(chunk, target) {
2736
- if (!chunk) return;
2737
- const text = chunk.toString('utf-8');
2738
- if (!text) return;
2739
- if (target.value.length >= MAX_RAW_OUTPUT_CHARS) {
2740
- target.truncated = true;
2741
- return;
2742
- }
2743
- const remain = MAX_RAW_OUTPUT_CHARS - target.value.length;
2744
- if (text.length > remain) {
2745
- target.value += text.slice(0, remain);
2746
- target.truncated = true;
2747
- return;
2748
- }
2749
- target.value += text;
2750
- }
2751
-
2752
- process.stdout.on('data', chunk => appendChunk(chunk, {
2753
- get value() { return stdoutOutput; },
2754
- set value(nextValue) { stdoutOutput = nextValue; },
2755
- get truncated() { return stdoutTruncated; },
2756
- set truncated(nextValue) { stdoutTruncated = nextValue; }
2757
- }));
2758
- process.stderr.on('data', chunk => appendChunk(chunk, {
2759
- get value() { return stderrOutput; },
2760
- set value(nextValue) { stderrOutput = nextValue; },
2761
- get truncated() { return stderrTruncated; },
2762
- set truncated(nextValue) { stderrTruncated = nextValue; }
2763
- }));
2764
-
2765
- process.on('error', reject);
2766
- process.on('close', code => {
2767
- const exitCode = typeof code === 'number' ? code : 1;
2768
- const clippedStdout = stdoutTruncated ? `${stdoutOutput}\n...[stdout-truncated]` : stdoutOutput;
2769
- const clippedStderr = stderrTruncated ? `${stderrOutput}\n...[stderr-truncated]` : stderrOutput;
2770
- const clippedRaw = `${clippedStdout}${clippedStdout && clippedStderr ? '\n' : ''}${clippedStderr}`;
2771
- const extractedAgentMessage = extractAgentMessageFromStructuredOutput(agentProgram, clippedStdout);
2772
- const cleanOutputSource = extractedAgentMessage || clippedRaw;
2773
- const output = clipText(stripAnsi(cleanOutputSource).trim() || '(无输出)');
2774
- resolve({ exitCode, output });
2775
- });
2776
- });
2777
- }
2778
-
2779
- async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
2780
- const opts = options && typeof options === 'object' ? options : {};
2781
- const sessionRef = typeof sessionRefOrContainerName === 'string'
2782
- ? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
2783
- : sessionRefOrContainerName;
2784
- const sessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
2785
- const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
2786
- const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => {};
2787
- const process = spawn(
2788
- ctx.dockerCmd,
2789
- ['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
2790
- { stdio: ['ignore', 'pipe', 'pipe'] }
2791
- );
2792
-
2793
- const runState = {
2794
- containerName: sessionRef.containerName,
2795
- sessionKey,
2796
- process,
2797
- command,
2798
- startedAt: new Date().toISOString(),
2799
- stopping: false
2800
- };
2801
- state.agentRuns.set(sessionRef.containerName, runState);
2802
-
2803
- return await new Promise((resolve, reject) => {
2804
- const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
2805
- let stdoutOutput = '';
2806
- let stderrOutput = '';
2807
- let stdoutTruncated = false;
2808
- let stderrTruncated = false;
2809
- let stdoutPending = '';
2810
- let stderrPending = '';
2811
- const structuredTraceState = {
2812
- toolNamesById: new Map()
2813
- };
2814
- let contentDeltaAccumulator = '';
2815
- function appendChunk(chunk, target) {
2816
- if (!chunk) return;
2817
- const text = chunk.toString('utf-8');
2818
- if (!text) return;
2819
- if (target.value.length >= MAX_RAW_OUTPUT_CHARS) {
2820
- target.truncated = true;
2821
- return;
2822
- }
2823
- const remain = MAX_RAW_OUTPUT_CHARS - target.value.length;
2824
- if (text.length > remain) {
2825
- target.value += text.slice(0, remain);
2826
- target.truncated = true;
2827
- return;
2828
- }
2829
- target.value += text;
2830
- }
2831
-
2832
- function emitStdoutTraceLine(line) {
2833
- const rawLine = String(line || '').trim();
2834
- if (!rawLine) {
2835
- return;
2836
- }
2837
- if (agentProgram === 'claude' || agentProgram === 'gemini' || agentProgram === 'codex' || agentProgram === 'opencode') {
2838
- const payload = parseJsonObjectLine(rawLine);
2839
- if (payload) {
2840
- const traceEvents = prepareStructuredTraceEvents(agentProgram, payload, structuredTraceState);
2841
- traceEvents.forEach(traceEvent => {
2842
- if (!traceEvent || !traceEvent.text) {
2843
- return;
2844
- }
2845
- onEvent({
2846
- type: 'trace',
2847
- stream: 'stdout',
2848
- text: traceEvent.text,
2849
- traceEvent
2850
- });
2851
- });
2852
- const deltaContent = extractContentDeltaFromPayload(agentProgram, payload, structuredTraceState);
2853
- if (deltaContent !== null) {
2854
- if (deltaContent.reset) {
2855
- contentDeltaAccumulator = deltaContent.text;
2856
- } else {
2857
- contentDeltaAccumulator += deltaContent.text;
2858
- }
2859
- onEvent({
2860
- type: 'content_delta',
2861
- content: contentDeltaAccumulator
2862
- });
2863
- }
2864
- return;
2865
- }
2866
- if (agentProgram === 'codex' && (/^OpenAI Codex\b/.test(rawLine) || /^tokens used\b/i.test(rawLine))) {
2867
- return;
2868
- }
2869
- }
2870
- onEvent({ type: 'trace', stream: 'stdout', text: rawLine });
2871
- }
2872
-
2873
- function emitStderrTraceLine(line) {
2874
- const rawLine = String(line || '').trim();
2875
- if (!rawLine) {
2876
- return;
2877
- }
2878
- onEvent({ type: 'trace', stream: 'stderr', text: `[stderr] ${rawLine}` });
2879
- }
2880
-
2881
- function drainLines(text, carry, handleLine) {
2882
- let pending = carry + String(text || '');
2883
- let newlineIndex = pending.indexOf('\n');
2884
- while (newlineIndex !== -1) {
2885
- const line = pending.slice(0, newlineIndex).replace(/\r$/, '');
2886
- handleLine(line);
2887
- pending = pending.slice(newlineIndex + 1);
2888
- newlineIndex = pending.indexOf('\n');
2889
- }
2890
- return pending;
2891
- }
2892
-
2893
- process.stdout.on('data', chunk => {
2894
- appendChunk(chunk, {
2895
- get value() { return stdoutOutput; },
2896
- set value(nextValue) { stdoutOutput = nextValue; },
2897
- get truncated() { return stdoutTruncated; },
2898
- set truncated(nextValue) { stdoutTruncated = nextValue; }
2899
- });
2900
- stdoutPending = drainLines(chunk.toString('utf-8'), stdoutPending, emitStdoutTraceLine);
2901
- });
2902
- process.stderr.on('data', chunk => {
2903
- appendChunk(chunk, {
2904
- get value() { return stderrOutput; },
2905
- set value(nextValue) { stderrOutput = nextValue; },
2906
- get truncated() { return stderrTruncated; },
2907
- set truncated(nextValue) { stderrTruncated = nextValue; }
2908
- });
2909
- stderrPending = drainLines(chunk.toString('utf-8'), stderrPending, emitStderrTraceLine);
2910
- });
2911
-
2912
- process.on('error', error => {
2913
- state.agentRuns.delete(sessionRef.containerName);
2914
- reject(error);
2915
- });
2916
- process.on('close', code => {
2917
- state.agentRuns.delete(sessionRef.containerName);
2918
- if (stdoutPending) {
2919
- emitStdoutTraceLine(stdoutPending);
2920
- stdoutPending = '';
2921
- }
2922
- if (stderrPending) {
2923
- emitStderrTraceLine(stderrPending);
2924
- stderrPending = '';
2925
- }
2926
- const exitCode = typeof code === 'number' ? code : 1;
2927
- const clippedStdout = stdoutTruncated ? `${stdoutOutput}\n...[stdout-truncated]` : stdoutOutput;
2928
- const clippedStderr = stderrTruncated ? `${stderrOutput}\n...[stderr-truncated]` : stderrOutput;
2929
- const clippedRaw = `${clippedStdout}${clippedStdout && clippedStderr ? '\n' : ''}${clippedStderr}`;
2930
- const extractedAgentMessage = extractAgentMessageFromStructuredOutput(agentProgram, clippedStdout);
2931
- const cleanOutputSource = extractedAgentMessage || clippedRaw;
2932
- const output = clipText(stripAnsi(cleanOutputSource).trim() || '(无输出)');
2933
- resolve({
2934
- exitCode,
2935
- output,
2936
- interrupted: exitCode !== 0 && runState.stopping === true
2937
- });
2938
- });
2939
- });
2940
- }
2941
-
2942
1726
  function readRequestBody(req) {
2943
1727
  return new Promise((resolve, reject) => {
2944
1728
  let body = '';
@@ -2979,20 +1763,6 @@ function sendNdjson(res, payload) {
2979
1763
  res.write(`${JSON.stringify(payload)}\n`);
2980
1764
  }
2981
1765
 
2982
- function stopWebAgentRun(state, containerName) {
2983
- const runState = state.agentRuns.get(containerName);
2984
- if (!runState || !runState.process || runState.process.killed) {
2985
- return false;
2986
- }
2987
- runState.stopping = true;
2988
- try {
2989
- runState.process.kill('SIGTERM');
2990
- } catch (e) {
2991
- return false;
2992
- }
2993
- return true;
2994
- }
2995
-
2996
1766
  function sendHtml(res, statusCode, html, extraHeaders = {}) {
2997
1767
  res.writeHead(statusCode, {
2998
1768
  'Content-Type': 'text/html; charset=utf-8',
@@ -3218,277 +1988,6 @@ function loadTemplate(name) {
3218
1988
  return fs.readFileSync(filePath, 'utf-8');
3219
1989
  }
3220
1990
 
3221
- function toPositiveInt(value, fallback) {
3222
- const parsed = Number.parseInt(value, 10);
3223
- if (!Number.isFinite(parsed) || parsed <= 0) {
3224
- return fallback;
3225
- }
3226
- return parsed;
3227
- }
3228
-
3229
- function normalizeTerminalSize(cols, rows) {
3230
- return {
3231
- cols: Math.max(WEB_TERMINAL_MIN_COLS, toPositiveInt(cols, WEB_TERMINAL_DEFAULT_COLS)),
3232
- rows: Math.max(WEB_TERMINAL_MIN_ROWS, toPositiveInt(rows, WEB_TERMINAL_DEFAULT_ROWS))
3233
- };
3234
- }
3235
-
3236
- function getUpgradeStatusText(statusCode) {
3237
- if (statusCode === 400) return 'Bad Request';
3238
- if (statusCode === 401) return 'Unauthorized';
3239
- if (statusCode === 404) return 'Not Found';
3240
- if (statusCode === 429) return 'Too Many Requests';
3241
- if (statusCode === 500) return 'Internal Server Error';
3242
- return 'Error';
3243
- }
3244
-
3245
- function sendWebSocketUpgradeError(socket, statusCode, message) {
3246
- const body = String(message || getUpgradeStatusText(statusCode));
3247
- const reason = getUpgradeStatusText(statusCode);
3248
- if (!socket.destroyed) {
3249
- socket.write(
3250
- `HTTP/1.1 ${statusCode} ${reason}\r\n` +
3251
- 'Content-Type: text/plain; charset=utf-8\r\n' +
3252
- 'Connection: close\r\n' +
3253
- `Content-Length: ${Buffer.byteLength(body, 'utf-8')}\r\n` +
3254
- '\r\n' +
3255
- body
3256
- );
3257
- }
3258
- socket.destroy();
3259
- }
3260
-
3261
- function sendTerminalEvent(ws, type, payload = {}) {
3262
- if (!ws || ws.readyState !== WebSocket.OPEN) {
3263
- return;
3264
- }
3265
- ws.send(JSON.stringify({ type, ...payload }));
3266
- }
3267
-
3268
- function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
3269
- const terminalBootstrap = [
3270
- 'MANYOYO_WEB_BASHRC="$(mktemp /tmp/manyoyo-web-bashrc.XXXXXX 2>/dev/null || mktemp)"',
3271
- 'cat > "$MANYOYO_WEB_BASHRC" <<\'EOF_MANYOYO_RC\'',
3272
- 'if [ -f /etc/bash.bashrc ]; then',
3273
- ' . /etc/bash.bashrc',
3274
- 'fi',
3275
- 'if [ -f ~/.bashrc ]; then',
3276
- ' . ~/.bashrc',
3277
- 'fi',
3278
- 'if [ -n "${MANYOYO_TERM_COLS:-}" ] && [ -n "${MANYOYO_TERM_ROWS:-}" ]; then',
3279
- ' COLUMNS="$MANYOYO_TERM_COLS"',
3280
- ' LINES="$MANYOYO_TERM_ROWS"',
3281
- ' export COLUMNS LINES',
3282
- ' stty cols "$MANYOYO_TERM_COLS" rows "$MANYOYO_TERM_ROWS" >/dev/null 2>&1 || true',
3283
- 'fi',
3284
- 'EOF_MANYOYO_RC',
3285
- 'chmod 600 "$MANYOYO_WEB_BASHRC" >/dev/null 2>&1 || true',
3286
- 'if command -v script >/dev/null 2>&1; then',
3287
- ' exec script -qefc "/bin/bash --rcfile $MANYOYO_WEB_BASHRC -i" /dev/null;',
3288
- 'fi;',
3289
- 'if command -v python3 >/dev/null 2>&1; then',
3290
- ' exec python3 -c \'import os, pty; pty.spawn(["/bin/bash","--rcfile",os.environ.get("MANYOYO_WEB_BASHRC","/dev/null"),"-i"])\';',
3291
- 'fi;',
3292
- 'if command -v python >/dev/null 2>&1; then',
3293
- ' exec python -c \'import os, pty; pty.spawn(["/bin/bash","--rcfile",os.environ.get("MANYOYO_WEB_BASHRC","/dev/null"),"-i"])\';',
3294
- 'fi;',
3295
- 'echo "[manyoyo] 容器内未找到 script/python,终端将降级为非 TTY 模式" >&2;',
3296
- 'exec /bin/bash --rcfile "$MANYOYO_WEB_BASHRC" -i'
3297
- ].join('\n');
3298
-
3299
- const termValue = process.env.TERM && process.env.TERM !== 'dumb' ? process.env.TERM : 'xterm-256color';
3300
- const colorTermValue = process.env.COLORTERM || 'truecolor';
3301
- const dockerExecArgs = [
3302
- 'exec',
3303
- '-i',
3304
- '-e', `TERM=${termValue}`,
3305
- '-e', `COLORTERM=${colorTermValue}`,
3306
- '-e', `MANYOYO_TERM_COLS=${String(cols)}`,
3307
- '-e', `MANYOYO_TERM_ROWS=${String(rows)}`,
3308
- containerName,
3309
- '/bin/bash',
3310
- '-lc',
3311
- terminalBootstrap
3312
- ];
3313
-
3314
- return spawn(ctx.dockerCmd, dockerExecArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
3315
- }
3316
-
3317
- function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
3318
- const sessionId = `${containerName}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
3319
- const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows);
3320
- const session = {
3321
- id: sessionId,
3322
- containerName,
3323
- ptyProcess,
3324
- closing: false
3325
- };
3326
-
3327
- state.terminalSessions.set(sessionId, session);
3328
- sendTerminalEvent(ws, 'status', {
3329
- phase: 'ready',
3330
- sessionId,
3331
- containerName,
3332
- cols,
3333
- rows
3334
- });
3335
-
3336
- const cleanup = () => {
3337
- if (session.closing) {
3338
- return;
3339
- }
3340
- session.closing = true;
3341
- state.terminalSessions.delete(sessionId);
3342
- if (ptyProcess && !ptyProcess.killed) {
3343
- ptyProcess.kill('SIGTERM');
3344
- setTimeout(() => {
3345
- if (!ptyProcess.killed) {
3346
- ptyProcess.kill('SIGKILL');
3347
- }
3348
- }, WEB_TERMINAL_FORCE_KILL_MS);
3349
- }
3350
- };
3351
-
3352
- ptyProcess.stdout.on('data', chunk => {
3353
- sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
3354
- });
3355
-
3356
- ptyProcess.stderr.on('data', chunk => {
3357
- sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
3358
- });
3359
-
3360
- ptyProcess.on('error', err => {
3361
- sendTerminalEvent(ws, 'error', {
3362
- error: err && err.message ? err.message : '终端进程启动失败'
3363
- });
3364
- });
3365
-
3366
- ptyProcess.on('close', (code, signal) => {
3367
- sendTerminalEvent(ws, 'status', {
3368
- phase: 'closed',
3369
- code: typeof code === 'number' ? code : null,
3370
- signal: signal || null
3371
- });
3372
- cleanup();
3373
- if (ws.readyState === WebSocket.OPEN) {
3374
- ws.close();
3375
- }
3376
- });
3377
-
3378
- ws.on('message', raw => {
3379
- let payload = null;
3380
- try {
3381
- payload = JSON.parse(raw.toString('utf-8'));
3382
- } catch (e) {
3383
- payload = {
3384
- type: 'input',
3385
- data: raw.toString('utf-8')
3386
- };
3387
- }
3388
- if (!payload || typeof payload !== 'object') {
3389
- return;
3390
- }
3391
-
3392
- if (payload.type === 'input' && typeof payload.data === 'string' && payload.data.length) {
3393
- ptyProcess.stdin.write(payload.data);
3394
- return;
3395
- }
3396
-
3397
- if (payload.type === 'resize') {
3398
- // 当前后端不直接驱动 docker exec 的 TTY 动态 resize,保留事件以便后续扩展。
3399
- return;
3400
- }
3401
-
3402
- if (payload.type === 'close') {
3403
- ws.close();
3404
- }
3405
- });
3406
-
3407
- ws.on('close', cleanup);
3408
- ws.on('error', cleanup);
3409
- }
3410
-
3411
- async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
3412
- if (req.method === 'GET' && pathname === '/favicon.ico') {
3413
- res.writeHead(204, { 'Cache-Control': 'no-store' });
3414
- res.end();
3415
- return true;
3416
- }
3417
-
3418
- if (req.method === 'GET' && pathname === '/auth/login') {
3419
- sendHtml(res, 200, loadTemplate('login.html'));
3420
- return true;
3421
- }
3422
-
3423
- const authFrontendMatch = pathname.match(/^\/auth\/frontend\/([A-Za-z0-9._-]+)$/);
3424
- if (req.method === 'GET' && authFrontendMatch) {
3425
- const assetName = authFrontendMatch[1];
3426
- if (!(assetName === 'login.css' || assetName === 'login.js')) {
3427
- sendHtml(res, 404, '<h1>404 Not Found</h1>');
3428
- return true;
3429
- }
3430
- sendStaticAsset(res, assetName);
3431
- return true;
3432
- }
3433
-
3434
- if (req.method === 'POST' && pathname === '/auth/login') {
3435
- const payload = await readJsonBody(req);
3436
- const username = String(payload.username || '').trim();
3437
- const password = String(payload.password || '');
3438
-
3439
- if (!username || !password) {
3440
- sendJson(res, 400, { error: '用户名和密码不能为空' });
3441
- return true;
3442
- }
3443
-
3444
- const userOk = secureStringEqual(username, ctx.authUser);
3445
- const passOk = secureStringEqual(password, ctx.authPass);
3446
- if (!(userOk && passOk)) {
3447
- sendJson(res, 401, { error: '用户名或密码错误' });
3448
- return true;
3449
- }
3450
-
3451
- const sessionId = createWebAuthSession(state, username);
3452
- sendJson(
3453
- res,
3454
- 200,
3455
- { ok: true, username },
3456
- { 'Set-Cookie': getWebAuthCookie(sessionId) }
3457
- );
3458
- return true;
3459
- }
3460
-
3461
- if (req.method === 'POST' && pathname === '/auth/logout') {
3462
- clearWebAuthSession(state, req);
3463
- sendJson(
3464
- res,
3465
- 200,
3466
- { ok: true },
3467
- { 'Set-Cookie': getWebAuthClearCookie() }
3468
- );
3469
- return true;
3470
- }
3471
-
3472
- return false;
3473
- }
3474
-
3475
- function sendWebUnauthorized(res, pathname) {
3476
- if (pathname.startsWith('/api/') || pathname.startsWith('/auth/')) {
3477
- sendJson(res, 401, { error: 'UNAUTHORIZED' });
3478
- return;
3479
- }
3480
- if (pathname === '/' || pathname === '') {
3481
- sendRedirect(res, 302, '/auth/login', { 'Set-Cookie': getWebAuthClearCookie() });
3482
- return;
3483
- }
3484
- sendHtml(
3485
- res,
3486
- 401,
3487
- loadTemplate('login.html'),
3488
- { 'Set-Cookie': getWebAuthClearCookie() }
3489
- );
3490
- }
3491
-
3492
1991
  async function handleWebApi(req, res, pathname, ctx, state) {
3493
1992
  // [P2-03] 对非只读请求校验自定义头,防止 CSRF 攻击
3494
1993
  // 跨站请求无法设置自定义头(浏览器同源策略),合法前端请求统一携带此头
@@ -3498,823 +1997,121 @@ async function handleWebApi(req, res, pathname, ctx, state) {
3498
1997
  return true;
3499
1998
  }
3500
1999
  }
3501
- const routes = [
3502
- {
3503
- method: 'GET',
3504
- match: currentPath => currentPath === '/api/fs/directories' ? [] : null,
3505
- handler: async () => {
3506
- const requestUrl = new URL(req.url || '/api/fs/directories', 'http://localhost');
3507
- const requestedPath = expandHomeAliasPath(String(requestUrl.searchParams.get('path') || '').trim() || os.homedir());
3508
- const requestedBasePath = expandHomeAliasPath(String(requestUrl.searchParams.get('basePath') || '').trim());
3509
- const realPath = fs.realpathSync(requestedPath);
3510
- if (!fs.statSync(realPath).isDirectory()) {
3511
- sendJson(res, 400, { error: `目录不存在: ${realPath}` });
3512
- return;
3513
- }
3514
-
3515
- let realBasePath = '';
3516
- if (requestedBasePath) {
3517
- realBasePath = fs.realpathSync(requestedBasePath);
3518
- if (!fs.statSync(realBasePath).isDirectory()) {
3519
- sendJson(res, 400, { error: `basePath 不是目录: ${realBasePath}` });
3520
- return;
3521
- }
3522
- const relativeToBase = path.relative(realBasePath, realPath);
3523
- if (relativeToBase.startsWith('..') || path.isAbsolute(relativeToBase)) {
3524
- sendJson(res, 400, { error: '目录超出 basePath 范围' });
3525
- return;
3526
- }
3527
- }
3528
-
3529
- const parentPath = realBasePath
3530
- ? (realPath === realBasePath ? '' : path.dirname(realPath))
3531
- : (realPath === path.parse(realPath).root ? '' : path.dirname(realPath));
3532
- const entries = fs.readdirSync(realPath, { withFileTypes: true })
3533
- .filter(entry => entry && entry.isDirectory())
3534
- .map(entry => ({
3535
- name: entry.name,
3536
- path: path.join(realPath, entry.name)
3537
- }))
3538
- .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
3539
-
3540
- sendJson(res, 200, {
3541
- currentPath: realPath,
3542
- basePath: realBasePath || '',
3543
- parentPath,
3544
- entries
3545
- });
3546
- }
3547
- },
3548
- {
3549
- method: 'GET',
3550
- match: currentPath => currentPath === '/api/config' ? [] : null,
3551
- handler: async () => {
3552
- const snapshot = readWebConfigSnapshot(state.webConfigPath);
3553
- sendJson(res, 200, buildSafeWebConfigSnapshot(snapshot, ctx));
3554
- }
3555
- },
3556
- {
3557
- method: 'PUT',
3558
- match: currentPath => currentPath === '/api/config' ? [] : null,
3559
- handler: async () => {
3560
- const payload = await readJsonBody(req);
3561
- const raw = typeof payload.raw === 'string' ? payload.raw : '';
3562
- if (!raw.trim()) {
3563
- sendJson(res, 400, { error: '配置内容不能为空' });
3564
- return;
3565
- }
3566
-
3567
- const currentSnapshot = readWebConfigSnapshot(state.webConfigPath);
3568
- let finalRaw = raw;
3569
- let parsed = null;
3570
- try {
3571
- finalRaw = restoreWebConfigSecrets(raw, currentSnapshot);
3572
- parsed = parseAndValidateConfigRaw(finalRaw);
3573
- } catch (e) {
3574
- sendJson(res, 400, { error: '配置格式错误', detail: e.message || '解析失败' });
3575
- return;
3576
- }
3577
-
3578
- const savePath = path.resolve(state.webConfigPath);
3579
- fs.mkdirSync(path.dirname(savePath), { recursive: true });
3580
- fs.writeFileSync(savePath, finalRaw, 'utf-8');
3581
-
3582
- sendJson(res, 200, {
3583
- saved: true,
3584
- path: savePath,
3585
- defaults: buildConfigDefaults(ctx, parsed)
3586
- });
3587
- }
3588
- },
3589
- {
3590
- method: 'GET',
3591
- match: currentPath => currentPath === '/api/sessions' ? [] : null,
3592
- handler: async () => {
3593
- const containerMap = listWebManyoyoContainers(ctx);
3594
- const names = new Set([
3595
- ...Object.keys(containerMap),
3596
- ...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
3597
- ]);
3598
-
3599
- const sessions = Array.from(names)
3600
- .flatMap(name => {
3601
- const history = loadWebSessionHistory(state.webHistoryDir, name);
3602
- return listWebAgentSessions(history, { includeSyntheticDefault: true })
3603
- .map(agentSession => buildSessionSummary(ctx, state, containerMap, {
3604
- containerName: name,
3605
- agentId: agentSession.agentId
3606
- }))
3607
- .filter(Boolean);
3608
- })
3609
- .sort((a, b) => {
3610
- const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
3611
- const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
3612
- return timeB - timeA;
3613
- });
3614
-
3615
- sendJson(res, 200, { sessions });
3616
- }
3617
- },
3618
- {
3619
- method: 'POST',
3620
- match: currentPath => currentPath === '/api/sessions' ? [] : null,
3621
- handler: async () => {
3622
- const payload = await readJsonBody(req);
3623
- let runtime = null;
3624
- try {
3625
- runtime = buildCreateRuntime(ctx, state, payload);
3626
- } catch (e) {
3627
- sendJson(res, 400, { error: e.message || '创建参数错误' });
3628
- return;
3629
- }
3630
-
3631
- await ensureWebContainer(ctx, state, runtime);
3632
- setWebSessionAgentPromptCommand(state.webHistoryDir, runtime.containerName, runtime.agentPromptCommand);
3633
- patchWebSessionHistory(state.webHistoryDir, runtime.containerName, {
3634
- applied: runtime.applied
3635
- });
3636
- sendJson(res, 200, { name: runtime.containerName, applied: runtime.applied });
3637
- }
3638
- },
3639
- {
3640
- method: 'POST',
3641
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agents$/),
3642
- handler: async match => {
3643
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3644
- if (!sessionRef) {
3645
- return;
3646
- }
3647
- const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
3648
- const agentSession = createWebAgentSession(history);
3649
- saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
3650
- sendJson(res, 200, {
3651
- name: buildWebSessionKey(sessionRef.containerName, agentSession.agentId),
3652
- containerName: sessionRef.containerName,
3653
- agentId: agentSession.agentId,
3654
- agentName: agentSession.agentName
3655
- });
3656
- }
3657
- },
3658
- {
3659
- method: 'GET',
3660
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages$/),
3661
- handler: async match => {
3662
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3663
- if (!sessionRef) {
3664
- return;
3665
- }
3666
- const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
3667
- const agentSession = getWebAgentSession(history, sessionRef.agentId)
3668
- || createEmptyWebAgentSession(sessionRef.agentId);
3669
- sendJson(res, 200, {
3670
- name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
3671
- containerName: sessionRef.containerName,
3672
- agentId: sessionRef.agentId,
3673
- messages: agentSession.messages
3674
- });
3675
- }
3676
- },
3677
- {
3678
- method: 'GET',
3679
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/detail$/),
3680
- handler: async match => {
3681
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3682
- if (!sessionRef) {
3683
- return;
3684
- }
3685
-
3686
- const containerMap = listWebManyoyoContainers(ctx);
3687
- const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
3688
- sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
3689
- }
3690
- },
3691
- {
3692
- method: 'PUT',
3693
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent-template$/),
3694
- handler: async match => {
3695
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3696
- if (!sessionRef) {
3697
- return;
3698
- }
3699
-
3700
- let payload = null;
3701
- try {
3702
- payload = await readJsonBody(req);
3703
- } catch (e) {
3704
- sendJson(res, 400, { error: e.message || '请求参数错误' });
3705
- return;
3706
- }
3707
- const normalizedPayload = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
3708
- const hasContainerTemplate = hasOwn(normalizedPayload, 'containerAgentPromptCommand');
3709
- const hasAgentOverride = hasOwn(normalizedPayload, 'agentPromptCommandOverride');
3710
- if (!hasContainerTemplate && !hasAgentOverride) {
3711
- sendJson(res, 400, { error: '至少提供一个模板字段' });
3712
- return;
3713
- }
3714
- if (hasAgentOverride && sessionRef.agentId === WEB_DEFAULT_AGENT_ID) {
3715
- sendJson(res, 400, { error: '默认 AGENT 不支持单独覆盖模板,请直接修改容器模板' });
3716
- return;
3717
- }
3718
-
3719
- try {
3720
- if (hasContainerTemplate) {
3721
- setWebSessionAgentPromptCommand(
3722
- state.webHistoryDir,
3723
- sessionRef.containerName,
3724
- normalizedPayload.containerAgentPromptCommand
3725
- );
3726
- }
3727
- if (hasAgentOverride) {
3728
- setWebAgentSessionPromptCommand(
3729
- state.webHistoryDir,
3730
- sessionRef,
3731
- normalizedPayload.agentPromptCommandOverride
3732
- );
3733
- }
3734
- } catch (e) {
3735
- sendJson(res, 400, { error: e.message || '保存 Agent 模板失败' });
3736
- return;
3737
- }
3738
-
3739
- const containerMap = listWebManyoyoContainers(ctx);
3740
- const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
3741
- sendJson(res, 200, {
3742
- saved: true,
3743
- name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
3744
- detail
3745
- });
3746
- }
3747
- },
3748
- {
3749
- method: 'POST',
3750
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/run$/),
3751
- handler: async match => {
3752
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3753
- if (!sessionRef) {
3754
- return;
3755
- }
3756
-
3757
- const payload = await readJsonBody(req);
3758
- const command = (payload.command || '').trim();
3759
- if (!command) {
3760
- sendJson(res, 400, { error: 'command 不能为空' });
3761
- return;
3762
- }
3763
-
3764
- await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
3765
- appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', command);
3766
- const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command);
3767
- appendWebSessionMessage(
3768
- state.webHistoryDir,
3769
- sessionRef,
3770
- 'assistant',
3771
- result.output,
3772
- { exitCode: result.exitCode }
3773
- );
3774
- sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
3775
- }
3776
- },
3777
- {
3778
- method: 'POST',
3779
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent$/),
3780
- handler: async match => {
3781
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3782
- if (!sessionRef) {
3783
- return;
3784
- }
3785
-
3786
- const payload = await readJsonBody(req);
3787
- const prompt = (payload.prompt || '').trim();
3788
- if (!prompt) {
3789
- sendJson(res, 400, { error: 'prompt 不能为空' });
3790
- return;
3791
- }
3792
-
3793
- let prepared = null;
3794
- try {
3795
- prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
3796
- } catch (e) {
3797
- sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
3798
- return;
3799
- }
3800
-
3801
- const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError } = prepared;
3802
- appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
3803
- mode: 'agent',
3804
- contextMode
3805
- });
3806
- const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
3807
- agentProgram: agentMeta.agentProgram
3808
- });
3809
- finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
3810
- contextMode,
3811
- resumeAttempted,
3812
- resumeSucceeded,
3813
- resumeError
3814
- }, result);
3815
- sendJson(res, 200, {
3816
- exitCode: result.exitCode,
3817
- output: result.output,
3818
- contextMode,
3819
- resumeAttempted,
3820
- resumeSucceeded,
3821
- interrupted: result.interrupted === true
3822
- });
3823
- }
3824
- },
3825
- {
3826
- method: 'POST',
3827
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/stream$/),
3828
- handler: async match => {
3829
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3830
- if (!sessionRef) {
3831
- return;
3832
- }
3833
-
3834
- const payload = await readJsonBody(req);
3835
- const prompt = (payload.prompt || '').trim();
3836
- if (!prompt) {
3837
- sendJson(res, 400, { error: 'prompt 不能为空' });
3838
- return;
3839
- }
3840
- if (state.agentRuns.has(sessionRef.containerName)) {
3841
- sendJson(res, 409, { error: '当前会话已有运行中的 agent 任务' });
3842
- return;
3843
- }
3844
-
3845
- let prepared = null;
3846
- try {
3847
- prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
3848
- } catch (e) {
3849
- sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
3850
- return;
3851
- }
3852
-
3853
- const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError } = prepared;
3854
- const traceLines = ['[执行过程]'];
3855
- const traceEvents = [];
3856
- appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
3857
- mode: 'agent',
3858
- contextMode
3859
- });
3860
-
3861
- res.writeHead(200, {
3862
- 'Content-Type': 'application/x-ndjson; charset=utf-8',
3863
- 'Cache-Control': 'no-store',
3864
- 'X-Accel-Buffering': 'no'
3865
- });
3866
- sendNdjson(res, {
3867
- type: 'meta',
3868
- containerName: sessionRef.containerName,
3869
- sessionName: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
3870
- contextMode,
3871
- resumeAttempted,
3872
- resumeSucceeded,
3873
- agentProgram: agentMeta.agentProgram
3874
- });
3875
- if (contextMode) {
3876
- traceLines.push(`上下文模式: ${contextMode}`);
3877
- }
3878
- if (resumeAttempted) {
3879
- traceLines.push(resumeSucceeded ? '会话恢复成功' : '会话恢复失败,已回退到历史注入');
3880
- }
3881
-
3882
- try {
3883
- const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
3884
- agentProgram: agentMeta.agentProgram,
3885
- onEvent: event => {
3886
- if (event && event.type === 'trace' && event.text) {
3887
- traceLines.push(String(event.text));
3888
- if (event.traceEvent && typeof event.traceEvent === 'object') {
3889
- traceEvents.push(event.traceEvent);
3890
- }
3891
- }
3892
- sendNdjson(res, event);
3893
- }
3894
- });
3895
- traceLines.push(result.interrupted === true ? '[任务] 已停止' : '[任务] 已完成');
3896
- appendWebAgentTraceMessage(state.webHistoryDir, sessionRef, traceLines.join('\n'), {
3897
- traceEvents,
3898
- contextMode,
3899
- resumeAttempted,
3900
- resumeSucceeded,
3901
- interrupted: result.interrupted === true
3902
- });
3903
- finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
3904
- contextMode,
3905
- resumeAttempted,
3906
- resumeSucceeded,
3907
- resumeError
3908
- }, result);
3909
- sendNdjson(res, {
3910
- type: 'result',
3911
- exitCode: result.exitCode,
3912
- output: result.output,
3913
- contextMode,
3914
- resumeAttempted,
3915
- resumeSucceeded,
3916
- interrupted: result.interrupted === true
3917
- });
3918
- } catch (e) {
3919
- traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
3920
- appendWebAgentTraceMessage(state.webHistoryDir, sessionRef, traceLines.join('\n'), {
3921
- traceEvents,
3922
- contextMode,
3923
- resumeAttempted,
3924
- resumeSucceeded,
3925
- interrupted: true
3926
- });
3927
- sendNdjson(res, {
3928
- type: 'error',
3929
- error: e && e.message ? e.message : 'Agent 执行失败'
3930
- });
3931
- } finally {
3932
- res.end();
3933
- }
3934
- }
3935
- },
3936
- {
3937
- method: 'POST',
3938
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/stop$/),
3939
- handler: async match => {
3940
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3941
- if (!sessionRef) {
3942
- return;
3943
- }
3944
- const stopped = stopWebAgentRun(state, sessionRef.containerName);
3945
- if (!stopped) {
3946
- sendJson(res, 404, { error: '当前会话没有运行中的 agent 任务' });
3947
- return;
3948
- }
3949
- sendJson(res, 200, { ok: true, stopping: true });
3950
- }
3951
- },
3952
- {
3953
- method: 'POST',
3954
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/remove$/),
3955
- handler: async match => {
3956
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3957
- if (!sessionRef) {
3958
- return;
3959
- }
3960
-
3961
- if (ctx.containerExists(sessionRef.containerName)) {
3962
- ctx.removeContainer(sessionRef.containerName);
3963
- appendWebSessionMessage(state.webHistoryDir, sessionRef, 'system', `容器 ${sessionRef.containerName} 已删除。`);
3964
- }
3965
-
3966
- sendJson(res, 200, { removed: true, name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId) });
3967
- }
3968
- },
3969
- {
3970
- method: 'POST',
3971
- match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/remove-with-history$/),
3972
- handler: async match => {
3973
- const sessionRef = getValidSessionRef(ctx, res, match[1]);
3974
- if (!sessionRef) {
3975
- return;
3976
- }
3977
-
3978
- const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
3979
- if (history.agents && typeof history.agents === 'object') {
3980
- if (sessionRef.agentId === WEB_DEFAULT_AGENT_ID) {
3981
- delete history.agents[WEB_DEFAULT_AGENT_ID];
3982
- } else {
3983
- delete history.agents[sessionRef.agentId];
3984
- }
3985
- }
3986
- if (!Object.keys(history.agents || {}).length && !ctx.containerExists(sessionRef.containerName)) {
3987
- removeWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
3988
- } else {
3989
- saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
3990
- }
3991
- sendJson(res, 200, {
3992
- removedHistory: true,
3993
- name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
3994
- });
3995
- }
3996
- }
3997
- ];
3998
2000
 
3999
- for (const route of routes) {
4000
- if (route.method !== req.method) {
4001
- continue;
4002
- }
4003
- const matched = route.match(pathname);
4004
- if (!matched) {
4005
- continue;
4006
- }
4007
- await route.handler(matched);
4008
- return true;
4009
- }
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
+ });
4010
2017
 
4011
- return false;
2018
+ 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
+ })
2074
+ ];
2075
+ return await runMatchedRoute(routes, req.method, pathname);
4012
2076
  }
4013
2077
 
4014
2078
  async function startWebServer(options) {
4015
- const fallbackLogger = {
4016
- info: () => {},
4017
- warn: () => {},
4018
- error: () => {}
4019
- };
4020
- const ctx = {
4021
- serverHost: options.serverHost || '127.0.0.1',
4022
- serverPort: options.serverPort,
4023
- authUser: options.authUser,
4024
- authPass: options.authPass,
4025
- authPassAuto: options.authPassAuto,
4026
- dockerCmd: options.dockerCmd,
4027
- hostPath: options.hostPath,
4028
- containerPath: options.containerPath,
4029
- imageName: options.imageName,
4030
- imageVersion: options.imageVersion,
4031
- execCommandPrefix: options.execCommandPrefix,
4032
- execCommand: options.execCommand,
4033
- execCommandSuffix: options.execCommandSuffix,
4034
- contModeArgs: options.contModeArgs,
4035
- containerExtraArgs: options.containerExtraArgs,
4036
- containerEnvs: options.containerEnvs,
4037
- containerVolumes: options.containerVolumes,
4038
- containerPorts: options.containerPorts,
4039
- validateHostPath: options.validateHostPath,
4040
- formatDate: options.formatDate,
4041
- isValidContainerName: options.isValidContainerName,
4042
- containerExists: options.containerExists,
4043
- getContainerStatus: options.getContainerStatus,
4044
- waitForContainerReady: options.waitForContainerReady,
4045
- dockerExecArgs: options.dockerExecArgs,
4046
- showImagePullHint: options.showImagePullHint,
4047
- removeContainer: options.removeContainer,
4048
- logger: options.logger && typeof options.logger.info === 'function' ? options.logger : fallbackLogger,
4049
- colors: options.colors || {
4050
- GREEN: '',
4051
- CYAN: '',
4052
- YELLOW: '',
4053
- NC: ''
4054
- }
4055
- };
4056
-
4057
- if (!ctx.authUser || !ctx.authPass) {
4058
- throw new Error('Web 认证配置缺失,请设置 serve -U / serve -P');
4059
- }
4060
-
4061
- const state = {
4062
- webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
4063
- webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
4064
- authSessions: new Map(),
4065
- terminalSessions: new Map(),
4066
- agentRuns: new Map()
4067
- };
2079
+ const ctx = createWebServerContext(options);
2080
+ const state = createWebServerState(options);
4068
2081
 
4069
2082
  ensureWebHistoryDir(state.webHistoryDir);
4070
2083
 
4071
- const wsServer = new WebSocket.Server({
4072
- noServer: true,
4073
- maxPayload: 1024 * 1024
4074
- });
4075
- wsServer.on('error', err => {
4076
- ctx.logger.error('ws server error', err);
4077
- });
4078
-
4079
- wsServer.on('connection', (ws, req, meta = {}) => {
4080
- const containerName = meta.containerName;
4081
- if (!containerName || !ctx.isValidContainerName(containerName)) {
4082
- ws.close();
4083
- return;
4084
- }
4085
- const { cols, rows } = normalizeTerminalSize(meta.cols, meta.rows);
4086
- bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows);
4087
- });
4088
-
4089
- const server = http.createServer(async (req, res) => {
4090
- try {
4091
- const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
4092
- const url = new URL(req.url, `http://${req.headers.host || fallbackHost}`);
4093
- const pathname = url.pathname;
4094
-
4095
- // 全局认证入口:除登录路由外,默认全部请求都要求认证
4096
- if (await handleWebAuthRoutes(req, res, pathname, ctx, state)) {
4097
- return;
4098
- }
4099
-
4100
- const authSession = getWebAuthSession(state, req);
4101
- if (!authSession) {
4102
- sendWebUnauthorized(res, pathname);
4103
- return;
4104
- }
4105
-
4106
- if (req.method === 'GET' && pathname === '/') {
4107
- sendHtml(res, 200, loadTemplate('app.html'));
4108
- return;
4109
- }
4110
-
4111
- const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
4112
- if (req.method === 'GET' && appFrontendMatch) {
4113
- const assetName = appFrontendMatch[1];
4114
- if (!(assetName === 'app.css' || assetName === 'app.js' || assetName === 'markdown.css' || assetName === 'markdown-renderer.js')) {
4115
- sendHtml(res, 404, '<h1>404 Not Found</h1>');
4116
- return;
4117
- }
4118
- sendStaticAsset(res, assetName);
4119
- return;
4120
- }
4121
-
4122
- const appVendorMatch = pathname.match(/^\/app\/vendor\/([A-Za-z0-9._-]+)$/);
4123
- if (req.method === 'GET' && appVendorMatch) {
4124
- const assetName = appVendorMatch[1];
4125
- if (!(assetName === 'xterm.css' || assetName === 'xterm.js' || assetName === 'xterm-addon-fit.js' || assetName === 'marked.min.js')) {
4126
- sendHtml(res, 404, '<h1>404 Not Found</h1>');
4127
- return;
4128
- }
4129
- sendVendorAsset(res, assetName);
4130
- return;
4131
- }
4132
-
4133
- if (pathname === '/healthz') {
4134
- sendJson(res, 200, { ok: true });
4135
- return;
4136
- }
4137
-
4138
- if (pathname.startsWith('/api/')) {
4139
- const handled = await handleWebApi(req, res, pathname, ctx, state);
4140
- if (!handled) {
4141
- sendJson(res, 404, { error: 'Not Found' });
4142
- }
4143
- return;
4144
- }
4145
-
4146
- sendHtml(res, 404, '<h1>404 Not Found</h1>');
4147
- } catch (e) {
4148
- ctx.logger.error('http request error', {
4149
- method: req && req.method ? req.method : '',
4150
- url: req && req.url ? req.url : '',
4151
- message: e && e.message ? e.message : 'Server Error'
4152
- });
4153
- if ((req.url || '').startsWith('/api/')) {
4154
- sendJson(res, 500, { error: e.message || 'Server Error' });
4155
- } else {
4156
- sendHtml(res, 500, '<h1>500 Server Error</h1>');
4157
- }
4158
- }
4159
- });
4160
- server.on('error', err => {
4161
- ctx.logger.error('http server error', err);
4162
- });
4163
- server.on('close', () => {
4164
- ctx.logger.warn('http server closed');
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
4165
2104
  });
4166
2105
 
4167
- server.on('upgrade', (req, socket, head) => {
4168
- const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
4169
- let url;
4170
- try {
4171
- url = new URL(req.url || '/', `http://${req.headers.host || fallbackHost}`);
4172
- } catch (e) {
4173
- sendWebSocketUpgradeError(socket, 400, 'Invalid URL');
4174
- return;
4175
- }
4176
-
4177
- const terminalMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/terminal\/ws$/);
4178
- if (!terminalMatch) {
4179
- socket.destroy();
4180
- return;
4181
- }
4182
-
4183
- // [P1-03] Origin 校验,防止跨站 WebSocket 劫持(CSWSH)
4184
- // 浏览器发起的 WebSocket 请求必须携带 Origin 头,非浏览器客户端(如 curl)不携带则放行
4185
- const requestOrigin = req.headers.origin;
4186
- if (requestOrigin) {
4187
- const allowedOrigins = new Set();
4188
- // 始终以请求的 Host 头构造允许来源,兼容 nginx 等反向代理场景
4189
- const hostHeader = req.headers.host || '';
4190
- if (hostHeader) {
4191
- allowedOrigins.add(`http://${hostHeader}`);
4192
- allowedOrigins.add(`https://${hostHeader}`);
4193
- }
4194
- if (ctx.serverHost !== '0.0.0.0') {
4195
- allowedOrigins.add(`http://${formatUrlHost(ctx.serverHost)}:${listenPort}`);
4196
- if (ctx.serverHost === '127.0.0.1') {
4197
- allowedOrigins.add(`http://localhost:${listenPort}`);
4198
- }
4199
- }
4200
- if (allowedOrigins.size > 0 && !allowedOrigins.has(requestOrigin)) {
4201
- sendWebSocketUpgradeError(socket, 403, 'Forbidden');
4202
- return;
4203
- }
4204
- }
4205
-
4206
- const authSession = getWebAuthSession(state, req);
4207
- if (!authSession) {
4208
- sendWebSocketUpgradeError(socket, 401, 'UNAUTHORIZED');
4209
- return;
4210
- }
4211
-
4212
- const sessionRef = parseWebSessionKey(decodeSessionName(terminalMatch[1]));
4213
- if (!ctx.isValidContainerName(sessionRef.containerName)) {
4214
- sendWebSocketUpgradeError(socket, 400, `containerName 非法: ${sessionRef.containerName}`);
4215
- return;
4216
- }
4217
- if (!SAFE_CONTAINER_NAME_PATTERN.test(sessionRef.agentId)) {
4218
- sendWebSocketUpgradeError(socket, 400, `agentId 非法: ${sessionRef.agentId}`);
4219
- return;
4220
- }
4221
-
4222
- if (state.terminalSessions.size >= WEB_TERMINAL_MAX_SESSIONS) {
4223
- sendWebSocketUpgradeError(socket, 429, 'TERMINAL_LIMIT_REACHED');
4224
- return;
4225
- }
4226
-
4227
- const { cols, rows } = normalizeTerminalSize(
4228
- url.searchParams.get('cols'),
4229
- url.searchParams.get('rows')
4230
- );
4231
-
4232
- ensureWebContainer(ctx, state, sessionRef.containerName)
4233
- .then(() => {
4234
- wsServer.handleUpgrade(req, socket, head, ws => {
4235
- wsServer.emit('connection', ws, req, {
4236
- containerName: sessionRef.containerName,
4237
- cols,
4238
- rows
4239
- });
4240
- });
4241
- })
4242
- .catch(e => {
4243
- sendWebSocketUpgradeError(socket, 500, e && e.message ? e.message : '终端创建失败');
4244
- });
4245
- });
4246
-
4247
- let listenPort = ctx.serverPort;
4248
-
4249
- await new Promise((resolve, reject) => {
4250
- server.once('error', err => {
4251
- ctx.logger.error('http server listen failed', err);
4252
- reject(err);
4253
- });
4254
- server.listen(ctx.serverPort, ctx.serverHost, () => {
4255
- const address = server.address();
4256
- if (address && typeof address === 'object' && typeof address.port === 'number') {
4257
- listenPort = address.port;
4258
- }
4259
- const { GREEN, CYAN, YELLOW, NC } = ctx.colors;
4260
- const listenHost = formatUrlHost(ctx.serverHost);
4261
- console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${listenPort}${NC}`);
4262
- console.log(`${CYAN}提示: 左侧是 manyoyo 容器会话列表,中间是活动/终端/配置/检查工作台,右侧显示当前会话上下文。${NC}`);
4263
- if (ctx.serverHost === '0.0.0.0') {
4264
- console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 IP 访问。${NC}`);
4265
- }
4266
- console.log(`${CYAN}🔐 登录用户名: ${YELLOW}${ctx.authUser}${NC}`);
4267
- if (ctx.authPassAuto) {
4268
- console.log(`${CYAN}🔐 登录密码(本次随机): ${YELLOW}${ctx.authPass}${NC}`);
4269
- } else {
4270
- console.log(`${CYAN}🔐 登录密码: 使用你配置的 serve -P / serverPass / MANYOYO_SERVER_PASS${NC}`);
4271
- }
4272
- ctx.logger.info('web server started', {
4273
- host: ctx.serverHost,
4274
- port: listenPort,
4275
- authUser: ctx.authUser,
4276
- authPassAuto: Boolean(ctx.authPassAuto)
4277
- });
4278
- resolve();
4279
- });
4280
- });
2106
+ const server = createHttpServer(ctx, state, wsServer, handleWebHttpRequest);
2107
+ const listenPort = await listenWebServer(server, ctx);
4281
2108
 
4282
2109
  return {
4283
2110
  server,
4284
2111
  wsServer,
4285
2112
  host: ctx.serverHost,
4286
2113
  port: listenPort,
4287
- close: () => new Promise(resolve => {
4288
- ctx.logger.info('web server closing');
4289
- for (const session of state.terminalSessions.values()) {
4290
- const ptyProcess = session && session.ptyProcess;
4291
- if (ptyProcess && !ptyProcess.killed) {
4292
- try { ptyProcess.kill('SIGTERM'); } catch (e) {}
4293
- }
4294
- }
4295
- state.terminalSessions.clear();
4296
- for (const runState of state.agentRuns.values()) {
4297
- const child = runState && runState.process;
4298
- if (child && !child.killed) {
4299
- try { child.kill('SIGTERM'); } catch (e) {}
4300
- }
4301
- }
4302
- state.agentRuns.clear();
4303
-
4304
- const closeHttp = () => {
4305
- if (!server.listening) {
4306
- resolve();
4307
- return;
4308
- }
4309
- server.close(() => resolve());
4310
- };
4311
-
4312
- try {
4313
- wsServer.close(() => closeHttp());
4314
- } catch (e) {
4315
- closeHttp();
4316
- }
4317
- })
2114
+ close: () => closeWebServer(server, wsServer, ctx, state)
4318
2115
  };
4319
2116
  }
4320
2117