@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/README.md +1 -0
- package/bin/manyoyo.js +265 -174
- package/lib/global-config.js +1 -198
- package/lib/image-build.js +20 -4
- package/lib/init-config.js +22 -10
- package/lib/json5-text-edit.js +238 -0
- package/lib/plugin/playwright-bootstrap.js +116 -0
- package/lib/plugin/playwright-command-output.js +95 -0
- package/lib/plugin/playwright-container-runtime.js +94 -0
- package/lib/plugin/playwright-extension-manager.js +265 -0
- package/lib/plugin/playwright-extension-paths.js +98 -0
- package/lib/plugin/playwright-host-runtime.js +114 -0
- package/lib/plugin/playwright-scene-config.js +137 -0
- package/lib/plugin/playwright-scene-drivers.js +285 -0
- package/lib/plugin/playwright-scene-state.js +80 -0
- package/lib/plugin/playwright.js +169 -1049
- package/lib/runtime-normalizers.js +65 -0
- package/lib/runtime-resolver.js +195 -0
- package/lib/web/agent-command.js +153 -0
- package/lib/web/api-route-helpers.js +88 -0
- package/lib/web/container-exec.js +215 -0
- package/lib/web/http-handlers.js +163 -0
- package/lib/web/runtime-state.js +50 -0
- package/lib/web/server-context.js +71 -0
- package/lib/web/server-lifecycle.js +129 -0
- package/lib/web/server.js +293 -2496
- package/lib/web/session-api-routes.js +390 -0
- package/lib/web/structured-output.js +149 -0
- package/lib/web/structured-trace.js +603 -0
- package/lib/web/system-api-routes.js +114 -0
- package/lib/web/terminal-session.js +205 -0
- package/lib/web/upgrade-handler.js +94 -0
- package/package.json +1 -1
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
|
1640
|
-
|
|
1641
|
-
|
|
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
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
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 (
|
|
1690
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
1950
|
-
const currentRange =
|
|
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
|
|
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
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
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 =
|
|
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 =
|
|
2447
|
-
|
|
2448
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
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
|
-
|
|
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
|
|
4016
|
-
|
|
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 =
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
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
|
|
4168
|
-
|
|
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: () =>
|
|
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
|
|