@xcanwin/manyoyo 4.1.4 → 4.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/web/server.js CHANGED
@@ -1,12 +1,13 @@
1
1
  'use strict';
2
2
 
3
- const { spawnSync, spawn } = require('child_process');
3
+ const { spawn } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
7
  const crypto = require('crypto');
8
8
  const http = require('http');
9
9
  const WebSocket = require('ws');
10
+ const JSON5 = require('json5');
10
11
  const { buildContainerRunArgs } = require('../container-run');
11
12
 
12
13
  const WEB_HISTORY_MAX_MESSAGES = 500;
@@ -20,6 +21,39 @@ const WEB_TERMINAL_MIN_ROWS = 12;
20
21
  const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
21
22
  const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
22
23
  const FRONTEND_DIR = path.join(__dirname, 'frontend');
24
+ const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
25
+ const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
26
+
27
+ const YOLO_COMMAND_MAP = {
28
+ claude: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
29
+ cc: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
30
+ c: 'IS_SANDBOX=1 claude --dangerously-skip-permissions',
31
+ gemini: 'gemini --yolo',
32
+ gm: 'gemini --yolo',
33
+ g: 'gemini --yolo',
34
+ codex: 'codex --dangerously-bypass-approvals-and-sandbox',
35
+ cx: 'codex --dangerously-bypass-approvals-and-sandbox',
36
+ opencode: 'OPENCODE_PERMISSION=\'{"*":"allow"}\' opencode',
37
+ oc: 'OPENCODE_PERMISSION=\'{"*":"allow"}\' opencode'
38
+ };
39
+
40
+ const DEFAULT_WEB_CONFIG_TEMPLATE = `{
41
+ // MANYOYO 全局配置文件(JSON5)
42
+ "containerName": "my-dev",
43
+ "hostPath": "/path/to/your/project",
44
+ "containerPath": "/path/to/your/project",
45
+ "imageName": "localhost/xcanwin/manyoyo",
46
+ "imageVersion": "1.7.4-common",
47
+ "containerMode": "common",
48
+ "shellPrefix": "",
49
+ "shell": "",
50
+ "shellSuffix": "",
51
+ "yolo": "",
52
+ "env": {},
53
+ "envFile": [],
54
+ "volumes": []
55
+ }
56
+ `;
23
57
 
24
58
  let XTERM_JS_FILE = null;
25
59
  let XTERM_CSS_FILE = null;
@@ -218,6 +252,464 @@ function getWebAuthClearCookie() {
218
252
  return `${WEB_AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
219
253
  }
220
254
 
255
+ function getDefaultWebConfigPath() {
256
+ return path.join(os.homedir(), '.manyoyo', 'manyoyo.json');
257
+ }
258
+
259
+ function hasOwn(obj, key) {
260
+ return Boolean(obj) && Object.prototype.hasOwnProperty.call(obj, key);
261
+ }
262
+
263
+ function toPlainObject(value) {
264
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
265
+ return {};
266
+ }
267
+ return value;
268
+ }
269
+
270
+ function pickFirstString() {
271
+ for (let i = 0; i < arguments.length; i += 1) {
272
+ const value = arguments[i];
273
+ if (typeof value === 'string') {
274
+ const trimmed = value.trim();
275
+ if (trimmed) {
276
+ return trimmed;
277
+ }
278
+ }
279
+ }
280
+ return '';
281
+ }
282
+
283
+ function resolveNowTemplate(value, formatDate) {
284
+ if (typeof value !== 'string') {
285
+ return value;
286
+ }
287
+ const nowText = typeof formatDate === 'function' ? formatDate() : '';
288
+ return value.replace(/\{now\}|\$\{now\}/g, nowText);
289
+ }
290
+
291
+ function validateContainerNameStrict(containerName) {
292
+ if (!SAFE_CONTAINER_NAME_PATTERN.test(containerName)) {
293
+ throw new Error(`containerName 非法: ${containerName}`);
294
+ }
295
+ }
296
+
297
+ function validateImageVersionStrict(imageVersion) {
298
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(imageVersion)) {
299
+ throw new Error(`imageVersion 非法: ${imageVersion}`);
300
+ }
301
+ if (!IMAGE_VERSION_TAG_PATTERN.test(imageVersion)) {
302
+ throw new Error(`imageVersion 格式必须为 <x.y.z-后缀>,例如 1.7.4-common。当前值: ${imageVersion}`);
303
+ }
304
+ }
305
+
306
+ function validateWebHostPath(hostPath) {
307
+ if (typeof hostPath !== 'string' || !hostPath.trim()) {
308
+ throw new Error('hostPath 不能为空');
309
+ }
310
+ if (!fs.existsSync(hostPath)) {
311
+ throw new Error(`宿主机路径不存在: ${hostPath}`);
312
+ }
313
+ const realHostPath = fs.realpathSync(hostPath);
314
+ const homeDir = process.env.HOME || os.homedir() || '/home';
315
+ if (realHostPath === '/' || realHostPath === '/home' || realHostPath === homeDir) {
316
+ throw new Error('不允许挂载根目录或home目录。');
317
+ }
318
+ }
319
+
320
+ function parseEnvEntry(entryText) {
321
+ const text = String(entryText || '');
322
+ const idx = text.indexOf('=');
323
+ if (idx <= 0) {
324
+ throw new Error(`env 格式应为 KEY=VALUE: ${text}`);
325
+ }
326
+ const key = text.slice(0, idx);
327
+ const value = text.slice(idx + 1);
328
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
329
+ throw new Error(`env key 非法: ${key}`);
330
+ }
331
+ if (/[\r\n\0]/.test(value) || /[;&|`$<>]/.test(value)) {
332
+ throw new Error(`env value 含非法字符: ${key}`);
333
+ }
334
+ return { key, value };
335
+ }
336
+
337
+ function normalizeEnvMap(envConfig, sourceLabel) {
338
+ if (envConfig === undefined || envConfig === null) {
339
+ return {};
340
+ }
341
+ if (typeof envConfig !== 'object' || Array.isArray(envConfig)) {
342
+ throw new Error(`${sourceLabel} 必须是对象(map)`);
343
+ }
344
+ const result = {};
345
+ for (const [key, rawValue] of Object.entries(envConfig)) {
346
+ if (rawValue !== null && !['string', 'number', 'boolean'].includes(typeof rawValue)) {
347
+ throw new Error(`${sourceLabel}.${key} 必须是 string/number/boolean/null`);
348
+ }
349
+ const parsed = parseEnvEntry(`${key}=${rawValue === null ? '' : String(rawValue)}`);
350
+ result[parsed.key] = parsed.value;
351
+ }
352
+ return result;
353
+ }
354
+
355
+ function normalizeStringArray(value, sourceLabel) {
356
+ if (value === undefined || value === null) {
357
+ return [];
358
+ }
359
+ if (!Array.isArray(value)) {
360
+ throw new Error(`${sourceLabel} 必须是数组`);
361
+ }
362
+ return value
363
+ .map(item => String(item || '').trim())
364
+ .filter(Boolean);
365
+ }
366
+
367
+ function parseEnvFileToArgs(filePath) {
368
+ if (!path.isAbsolute(filePath)) {
369
+ throw new Error(`envFile 仅支持绝对路径: ${filePath}`);
370
+ }
371
+ if (!fs.existsSync(filePath)) {
372
+ throw new Error(`未找到环境文件: ${filePath}`);
373
+ }
374
+
375
+ const content = fs.readFileSync(filePath, 'utf-8');
376
+ const args = [];
377
+ const lines = content.split('\n');
378
+
379
+ for (let line of lines) {
380
+ const match = line.match(/^(?:export\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/);
381
+ if (!match) {
382
+ continue;
383
+ }
384
+
385
+ const key = match[1];
386
+ let value = match[2].trim();
387
+ if (/[\r\n\0]/.test(value)) continue;
388
+ if (/[\$\(\)\`\|\&\*\{\};<>]/.test(value)) continue;
389
+ if (/^\(/.test(value)) continue;
390
+
391
+ if (/^"(.*)"$/.test(value)) {
392
+ value = value.slice(1, -1);
393
+ } else if (/^'(.*)'$/.test(value)) {
394
+ value = value.slice(1, -1);
395
+ }
396
+ args.push('--env', `${key}=${value}`);
397
+ }
398
+
399
+ return args;
400
+ }
401
+
402
+ function resolveContainerModeArgs(mode) {
403
+ const modeAliasMap = {
404
+ common: 'common',
405
+ 'docker-in-docker': 'dind',
406
+ dind: 'dind',
407
+ d: 'dind',
408
+ 'mount-docker-socket': 'sock',
409
+ sock: 'sock',
410
+ s: 'sock'
411
+ };
412
+ const normalized = modeAliasMap[String(mode || '').trim().toLowerCase()];
413
+ if (!normalized) {
414
+ throw new Error(`未知 containerMode: ${mode}`);
415
+ }
416
+ if (normalized === 'common') {
417
+ return { mode: 'common', args: [] };
418
+ }
419
+ if (normalized === 'dind') {
420
+ return { mode: 'dind', args: ['--privileged'] };
421
+ }
422
+ return {
423
+ mode: 'sock',
424
+ args: [
425
+ '--privileged',
426
+ '--volume', '/var/run/docker.sock:/var/run/docker.sock',
427
+ '--env', 'DOCKER_HOST=unix:///var/run/docker.sock',
428
+ '--env', 'CONTAINER_HOST=unix:///var/run/docker.sock'
429
+ ]
430
+ };
431
+ }
432
+
433
+ function resolveYoloCommand(yolo) {
434
+ const key = String(yolo || '').trim().toLowerCase();
435
+ if (!key) {
436
+ return '';
437
+ }
438
+ const mapped = YOLO_COMMAND_MAP[key];
439
+ if (!mapped) {
440
+ throw new Error(`未知 yolo 值: ${yolo}`);
441
+ }
442
+ return mapped;
443
+ }
444
+
445
+ function buildDefaultCommand(shellPrefix, shell, shellSuffix) {
446
+ const parts = [];
447
+ if (shellPrefix && String(shellPrefix).trim()) {
448
+ parts.push(String(shellPrefix).trim());
449
+ }
450
+ if (shell && String(shell).trim()) {
451
+ parts.push(String(shell).trim());
452
+ }
453
+ if (shellSuffix && String(shellSuffix).trim()) {
454
+ parts.push(String(shellSuffix).trim());
455
+ }
456
+ return parts.join(' ').trim();
457
+ }
458
+
459
+ function validateWebConfigShape(configObject) {
460
+ const config = toPlainObject(configObject);
461
+
462
+ if (hasOwn(config, 'containerName') && String(config.containerName || '').trim()) {
463
+ validateContainerNameStrict(resolveNowTemplate(String(config.containerName), () => '0101-0000'));
464
+ }
465
+ if (hasOwn(config, 'imageVersion') && String(config.imageVersion || '').trim()) {
466
+ validateImageVersionStrict(String(config.imageVersion).trim());
467
+ }
468
+ if (hasOwn(config, 'env')) {
469
+ normalizeEnvMap(config.env, 'env');
470
+ }
471
+ if (hasOwn(config, 'envFile')) {
472
+ normalizeStringArray(config.envFile, 'envFile');
473
+ }
474
+ if (hasOwn(config, 'volumes')) {
475
+ normalizeStringArray(config.volumes, 'volumes');
476
+ }
477
+ if (hasOwn(config, 'runs')) {
478
+ const runs = config.runs;
479
+ if (runs !== undefined && (typeof runs !== 'object' || runs === null || Array.isArray(runs))) {
480
+ throw new Error('runs 必须是对象(map)');
481
+ }
482
+ }
483
+ }
484
+
485
+ function readWebConfigSnapshot(configPath) {
486
+ const resolvedPath = path.resolve(configPath || getDefaultWebConfigPath());
487
+ if (!fs.existsSync(resolvedPath)) {
488
+ return {
489
+ path: resolvedPath,
490
+ raw: DEFAULT_WEB_CONFIG_TEMPLATE,
491
+ parsed: {},
492
+ parseError: null,
493
+ exists: false
494
+ };
495
+ }
496
+
497
+ const raw = fs.readFileSync(resolvedPath, 'utf-8');
498
+ if (!String(raw || '').trim()) {
499
+ return {
500
+ path: resolvedPath,
501
+ raw,
502
+ parsed: {},
503
+ parseError: null,
504
+ exists: true
505
+ };
506
+ }
507
+
508
+ try {
509
+ const parsed = JSON5.parse(raw);
510
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
511
+ throw new Error('配置根节点必须是对象(map)');
512
+ }
513
+ const config = toPlainObject(parsed);
514
+ validateWebConfigShape(config);
515
+ return {
516
+ path: resolvedPath,
517
+ raw,
518
+ parsed: config,
519
+ parseError: null,
520
+ exists: true
521
+ };
522
+ } catch (e) {
523
+ return {
524
+ path: resolvedPath,
525
+ raw,
526
+ parsed: {},
527
+ parseError: e && e.message ? e.message : '配置解析失败',
528
+ exists: true
529
+ };
530
+ }
531
+ }
532
+
533
+ function parseAndValidateConfigRaw(raw) {
534
+ const parsed = JSON5.parse(String(raw || ''));
535
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
536
+ throw new Error('配置根节点必须是对象(map)');
537
+ }
538
+ const config = toPlainObject(parsed);
539
+ validateWebConfigShape(config);
540
+ return config;
541
+ }
542
+
543
+ function buildConfigDefaults(ctx, config) {
544
+ const parsed = toPlainObject(config);
545
+ const defaults = {
546
+ containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
547
+ hostPath: pickFirstString(parsed.hostPath, ctx.hostPath),
548
+ containerPath: pickFirstString(parsed.containerPath, ctx.containerPath),
549
+ imageName: pickFirstString(parsed.imageName, ctx.imageName),
550
+ imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
551
+ containerMode: hasOwn(parsed, 'containerMode') ? String(parsed.containerMode || '') : '',
552
+ shellPrefix: hasOwn(parsed, 'shellPrefix') ? String(parsed.shellPrefix || '') : '',
553
+ shell: hasOwn(parsed, 'shell') ? String(parsed.shell || '') : '',
554
+ shellSuffix: hasOwn(parsed, 'shellSuffix') ? String(parsed.shellSuffix || '') : '',
555
+ yolo: hasOwn(parsed, 'yolo') ? String(parsed.yolo || '') : '',
556
+ env: {},
557
+ envFile: [],
558
+ volumes: []
559
+ };
560
+
561
+ try {
562
+ defaults.env = normalizeEnvMap(parsed.env, 'env');
563
+ } catch (e) {
564
+ defaults.env = {};
565
+ }
566
+ try {
567
+ defaults.envFile = normalizeStringArray(parsed.envFile, 'envFile');
568
+ } catch (e) {
569
+ defaults.envFile = [];
570
+ }
571
+ try {
572
+ defaults.volumes = normalizeStringArray(parsed.volumes, 'volumes');
573
+ } catch (e) {
574
+ defaults.volumes = [];
575
+ }
576
+
577
+ return defaults;
578
+ }
579
+
580
+ function buildStaticContainerRuntime(ctx, containerName) {
581
+ return {
582
+ containerName,
583
+ hostPath: ctx.hostPath,
584
+ containerPath: ctx.containerPath,
585
+ imageName: ctx.imageName,
586
+ imageVersion: ctx.imageVersion,
587
+ contModeArgs: Array.isArray(ctx.contModeArgs) ? ctx.contModeArgs.slice() : [],
588
+ containerEnvs: Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [],
589
+ containerVolumes: Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [],
590
+ defaultCommand: buildDefaultCommand(ctx.execCommandPrefix, ctx.execCommand, ctx.execCommandSuffix) || '/bin/bash'
591
+ };
592
+ }
593
+
594
+ function buildCreateRuntime(ctx, state, payload) {
595
+ const body = toPlainObject(payload);
596
+ const requestOptions = toPlainObject(body.createOptions);
597
+ const snapshot = readWebConfigSnapshot(state.webConfigPath);
598
+ const config = snapshot.parseError ? {} : snapshot.parsed;
599
+
600
+ const hasRequestEnv = hasOwn(requestOptions, 'env');
601
+ const hasRequestEnvFile = hasOwn(requestOptions, 'envFile');
602
+ const hasRequestVolumes = hasOwn(requestOptions, 'volumes');
603
+ const hasConfigEnv = hasOwn(config, 'env');
604
+ const hasConfigEnvFile = hasOwn(config, 'envFile');
605
+ const hasConfigVolumes = hasOwn(config, 'volumes');
606
+
607
+ const requestName = pickFirstString(requestOptions.containerName, body.name);
608
+ let containerName = pickFirstString(requestName, config.containerName);
609
+ if (!containerName) {
610
+ containerName = `my-${ctx.formatDate()}`;
611
+ }
612
+ containerName = resolveNowTemplate(containerName, ctx.formatDate);
613
+ validateContainerNameStrict(containerName);
614
+
615
+ const hostPath = pickFirstString(requestOptions.hostPath, config.hostPath, ctx.hostPath);
616
+ validateWebHostPath(hostPath);
617
+
618
+ const containerPath = pickFirstString(requestOptions.containerPath, config.containerPath, ctx.containerPath, hostPath) || hostPath;
619
+ const imageName = pickFirstString(requestOptions.imageName, config.imageName, ctx.imageName);
620
+ const imageVersion = pickFirstString(requestOptions.imageVersion, config.imageVersion, ctx.imageVersion);
621
+
622
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/:-]*$/.test(imageName)) {
623
+ throw new Error(`imageName 非法: ${imageName}`);
624
+ }
625
+ validateImageVersionStrict(imageVersion);
626
+
627
+ let contModeArgs = Array.isArray(ctx.contModeArgs) ? ctx.contModeArgs.slice() : [];
628
+ let containerMode = '';
629
+ const modeValue = pickFirstString(requestOptions.containerMode, config.containerMode);
630
+ if (modeValue) {
631
+ const mode = resolveContainerModeArgs(modeValue);
632
+ containerMode = mode.mode;
633
+ contModeArgs = mode.args;
634
+ }
635
+
636
+ const shellPrefix = hasOwn(requestOptions, 'shellPrefix')
637
+ ? String(requestOptions.shellPrefix || '')
638
+ : (hasOwn(config, 'shellPrefix') ? String(config.shellPrefix || '') : String(ctx.execCommandPrefix || ''));
639
+ let shell = hasOwn(requestOptions, 'shell')
640
+ ? String(requestOptions.shell || '')
641
+ : (hasOwn(config, 'shell') ? String(config.shell || '') : String(ctx.execCommand || ''));
642
+ const shellSuffix = hasOwn(requestOptions, 'shellSuffix')
643
+ ? String(requestOptions.shellSuffix || '')
644
+ : (hasOwn(config, 'shellSuffix') ? String(config.shellSuffix || '') : String(ctx.execCommandSuffix || ''));
645
+ const yolo = hasOwn(requestOptions, 'yolo')
646
+ ? String(requestOptions.yolo || '')
647
+ : (hasOwn(config, 'yolo') ? String(config.yolo || '') : '');
648
+ const yoloCommand = resolveYoloCommand(yolo);
649
+ if (yoloCommand) {
650
+ shell = yoloCommand;
651
+ }
652
+
653
+ let containerEnvs = Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [];
654
+ if (hasRequestEnv || hasRequestEnvFile || hasConfigEnv || hasConfigEnvFile) {
655
+ const configEnv = normalizeEnvMap(config.env, 'config.env');
656
+ const requestEnv = hasRequestEnv ? normalizeEnvMap(requestOptions.env, 'createOptions.env') : {};
657
+ const mergedEnv = { ...configEnv, ...requestEnv };
658
+ const envArgs = [];
659
+ Object.entries(mergedEnv).forEach(([key, value]) => {
660
+ const parsed = parseEnvEntry(`${key}=${value}`);
661
+ envArgs.push('--env', `${parsed.key}=${parsed.value}`);
662
+ });
663
+
664
+ const envFileList = hasRequestEnvFile
665
+ ? normalizeStringArray(requestOptions.envFile, 'createOptions.envFile')
666
+ : normalizeStringArray(config.envFile, 'config.envFile');
667
+ const envFileArgs = [];
668
+ envFileList.forEach(filePath => {
669
+ envFileArgs.push(...parseEnvFileToArgs(filePath));
670
+ });
671
+
672
+ containerEnvs = [...envArgs, ...envFileArgs];
673
+ }
674
+
675
+ let containerVolumes = Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [];
676
+ if (hasRequestVolumes || hasConfigVolumes) {
677
+ const volumeList = hasRequestVolumes
678
+ ? normalizeStringArray(requestOptions.volumes, 'createOptions.volumes')
679
+ : normalizeStringArray(config.volumes, 'config.volumes');
680
+ containerVolumes = [];
681
+ volumeList.forEach(volume => {
682
+ containerVolumes.push('--volume', volume);
683
+ });
684
+ }
685
+
686
+ return {
687
+ containerName,
688
+ hostPath,
689
+ containerPath,
690
+ imageName,
691
+ imageVersion,
692
+ contModeArgs,
693
+ containerEnvs,
694
+ containerVolumes,
695
+ defaultCommand: buildDefaultCommand(shellPrefix, shell, shellSuffix) || '/bin/bash',
696
+ applied: {
697
+ containerName,
698
+ hostPath,
699
+ containerPath,
700
+ imageName,
701
+ imageVersion,
702
+ containerMode,
703
+ shellPrefix: shellPrefix || '',
704
+ shell: shell || '',
705
+ shellSuffix: shellSuffix || '',
706
+ yolo: yolo || '',
707
+ envCount: Math.floor(containerEnvs.length / 2),
708
+ volumeCount: Math.floor(containerVolumes.length / 2)
709
+ }
710
+ };
711
+ }
712
+
221
713
  function listWebManyoyoContainers(ctx) {
222
714
  const output = ctx.dockerExecArgs(
223
715
  ['ps', '-a', '--filter', 'label=manyoyo.default_cmd', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
@@ -244,19 +736,26 @@ function listWebManyoyoContainers(ctx) {
244
736
  return map;
245
737
  }
246
738
 
247
- async function ensureWebContainer(ctx, state, containerName) {
248
- if (!ctx.containerExists(containerName)) {
249
- const webDefaultCommand = `${ctx.execCommandPrefix}${ctx.execCommand}${ctx.execCommandSuffix}`.trim() || '/bin/bash';
739
+ async function ensureWebContainer(ctx, state, containerInput) {
740
+ const runtime = typeof containerInput === 'string'
741
+ ? buildStaticContainerRuntime(ctx, containerInput)
742
+ : containerInput;
743
+
744
+ if (!runtime || !runtime.containerName) {
745
+ throw new Error('containerName 不能为空');
746
+ }
747
+
748
+ if (!ctx.containerExists(runtime.containerName)) {
250
749
  const args = buildContainerRunArgs({
251
- containerName,
252
- hostPath: ctx.hostPath,
253
- containerPath: ctx.containerPath,
254
- imageName: ctx.imageName,
255
- imageVersion: ctx.imageVersion,
256
- contModeArgs: ctx.contModeArgs,
257
- containerEnvs: ctx.containerEnvs,
258
- containerVolumes: ctx.containerVolumes,
259
- defaultCommand: webDefaultCommand
750
+ containerName: runtime.containerName,
751
+ hostPath: runtime.hostPath,
752
+ containerPath: runtime.containerPath,
753
+ imageName: runtime.imageName,
754
+ imageVersion: runtime.imageVersion,
755
+ contModeArgs: runtime.contModeArgs,
756
+ containerEnvs: runtime.containerEnvs,
757
+ containerVolumes: runtime.containerVolumes,
758
+ defaultCommand: runtime.defaultCommand
260
759
  });
261
760
 
262
761
  try {
@@ -266,33 +765,58 @@ async function ensureWebContainer(ctx, state, containerName) {
266
765
  throw e;
267
766
  }
268
767
 
269
- await ctx.waitForContainerReady(containerName);
270
- appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已创建并启动。`);
768
+ await ctx.waitForContainerReady(runtime.containerName);
769
+ appendWebSessionMessage(state.webHistoryDir, runtime.containerName, 'system', `容器 ${runtime.containerName} 已创建并启动。`);
271
770
  return;
272
771
  }
273
772
 
274
- const status = ctx.getContainerStatus(containerName);
773
+ const status = ctx.getContainerStatus(runtime.containerName);
275
774
  if (status !== 'running') {
276
- ctx.dockerExecArgs(['start', containerName], { stdio: 'pipe' });
277
- appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已启动。`);
775
+ ctx.dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
776
+ appendWebSessionMessage(state.webHistoryDir, runtime.containerName, 'system', `容器 ${runtime.containerName} 已启动。`);
278
777
  }
279
778
  }
280
779
 
281
- function execCommandInWebContainer(ctx, containerName, command) {
282
- const result = spawnSync(ctx.dockerCmd, ['exec', containerName, '/bin/bash', '-lc', command], {
283
- encoding: 'utf-8',
284
- maxBuffer: 32 * 1024 * 1024
285
- });
780
+ async function execCommandInWebContainer(ctx, containerName, command) {
781
+ return await new Promise((resolve, reject) => {
782
+ const process = spawn(
783
+ ctx.dockerCmd,
784
+ ['exec', containerName, '/bin/bash', '-lc', command],
785
+ { stdio: ['ignore', 'pipe', 'pipe'] }
786
+ );
286
787
 
287
- if (result.error) {
288
- throw result.error;
289
- }
788
+ const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
789
+ let rawOutput = '';
790
+ let outputTruncated = false;
791
+
792
+ function appendChunk(chunk) {
793
+ if (!chunk) return;
794
+ const text = chunk.toString('utf-8');
795
+ if (!text) return;
796
+ if (rawOutput.length >= MAX_RAW_OUTPUT_CHARS) {
797
+ outputTruncated = true;
798
+ return;
799
+ }
800
+ const remain = MAX_RAW_OUTPUT_CHARS - rawOutput.length;
801
+ if (text.length > remain) {
802
+ rawOutput += text.slice(0, remain);
803
+ outputTruncated = true;
804
+ return;
805
+ }
806
+ rawOutput += text;
807
+ }
290
808
 
291
- const exitCode = typeof result.status === 'number' ? result.status : 1;
292
- const rawOutput = `${result.stdout || ''}${result.stderr || ''}`;
293
- const output = clipText(stripAnsi(rawOutput).trim() || '(无输出)');
809
+ process.stdout.on('data', appendChunk);
810
+ process.stderr.on('data', appendChunk);
294
811
 
295
- return { exitCode, output };
812
+ process.on('error', reject);
813
+ process.on('close', code => {
814
+ const exitCode = typeof code === 'number' ? code : 1;
815
+ const clippedRaw = outputTruncated ? `${rawOutput}\n...[raw-truncated]` : rawOutput;
816
+ const output = clipText(stripAnsi(clippedRaw).trim() || '(无输出)');
817
+ resolve({ exitCode, output });
818
+ });
819
+ });
296
820
  }
297
821
 
298
822
  function readRequestBody(req) {
@@ -694,6 +1218,51 @@ function sendWebUnauthorized(res, pathname) {
694
1218
 
695
1219
  async function handleWebApi(req, res, pathname, ctx, state) {
696
1220
  const routes = [
1221
+ {
1222
+ method: 'GET',
1223
+ match: currentPath => currentPath === '/api/config' ? [] : null,
1224
+ handler: async () => {
1225
+ const snapshot = readWebConfigSnapshot(state.webConfigPath);
1226
+ const defaults = buildConfigDefaults(ctx, snapshot.parseError ? {} : snapshot.parsed);
1227
+ sendJson(res, 200, {
1228
+ path: snapshot.path,
1229
+ raw: snapshot.raw,
1230
+ parsed: snapshot.parseError ? null : snapshot.parsed,
1231
+ parseError: snapshot.parseError,
1232
+ defaults
1233
+ });
1234
+ }
1235
+ },
1236
+ {
1237
+ method: 'PUT',
1238
+ match: currentPath => currentPath === '/api/config' ? [] : null,
1239
+ handler: async () => {
1240
+ const payload = await readJsonBody(req);
1241
+ const raw = typeof payload.raw === 'string' ? payload.raw : '';
1242
+ if (!raw.trim()) {
1243
+ sendJson(res, 400, { error: '配置内容不能为空' });
1244
+ return;
1245
+ }
1246
+
1247
+ let parsed = null;
1248
+ try {
1249
+ parsed = parseAndValidateConfigRaw(raw);
1250
+ } catch (e) {
1251
+ sendJson(res, 400, { error: '配置格式错误', detail: e.message || '解析失败' });
1252
+ return;
1253
+ }
1254
+
1255
+ const savePath = path.resolve(state.webConfigPath);
1256
+ fs.mkdirSync(path.dirname(savePath), { recursive: true });
1257
+ fs.writeFileSync(savePath, raw, 'utf-8');
1258
+
1259
+ sendJson(res, 200, {
1260
+ saved: true,
1261
+ path: savePath,
1262
+ defaults: buildConfigDefaults(ctx, parsed)
1263
+ });
1264
+ }
1265
+ },
697
1266
  {
698
1267
  method: 'GET',
699
1268
  match: currentPath => currentPath === '/api/sessions' ? [] : null,
@@ -720,17 +1289,16 @@ async function handleWebApi(req, res, pathname, ctx, state) {
720
1289
  match: currentPath => currentPath === '/api/sessions' ? [] : null,
721
1290
  handler: async () => {
722
1291
  const payload = await readJsonBody(req);
723
- let containerName = (payload.name || '').trim();
724
- if (!containerName) {
725
- containerName = `my-${ctx.formatDate()}`;
726
- }
727
- if (!ctx.isValidContainerName(containerName)) {
728
- sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
1292
+ let runtime = null;
1293
+ try {
1294
+ runtime = buildCreateRuntime(ctx, state, payload);
1295
+ } catch (e) {
1296
+ sendJson(res, 400, { error: e.message || '创建参数错误' });
729
1297
  return;
730
1298
  }
731
1299
 
732
- await ensureWebContainer(ctx, state, containerName);
733
- sendJson(res, 200, { name: containerName });
1300
+ await ensureWebContainer(ctx, state, runtime);
1301
+ sendJson(res, 200, { name: runtime.containerName, applied: runtime.applied });
734
1302
  }
735
1303
  },
736
1304
  {
@@ -763,12 +1331,12 @@ async function handleWebApi(req, res, pathname, ctx, state) {
763
1331
 
764
1332
  await ensureWebContainer(ctx, state, containerName);
765
1333
  appendWebSessionMessage(state.webHistoryDir, containerName, 'user', command);
766
- const result = execCommandInWebContainer(ctx, containerName, command);
1334
+ const result = await execCommandInWebContainer(ctx, containerName, command);
767
1335
  appendWebSessionMessage(
768
1336
  state.webHistoryDir,
769
1337
  containerName,
770
1338
  'assistant',
771
- `${result.output}\n\n[exit ${result.exitCode}]`,
1339
+ result.output,
772
1340
  { exitCode: result.exitCode }
773
1341
  );
774
1342
  sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
@@ -862,6 +1430,7 @@ async function startWebServer(options) {
862
1430
 
863
1431
  const state = {
864
1432
  webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
1433
+ webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
865
1434
  authSessions: new Map(),
866
1435
  terminalSessions: new Map()
867
1436
  };