@xcanwin/manyoyo 4.1.1 → 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,14 @@
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');
11
+ const { buildContainerRunArgs } = require('../container-run');
10
12
 
11
13
  const WEB_HISTORY_MAX_MESSAGES = 500;
12
14
  const WEB_OUTPUT_MAX_CHARS = 16000;
@@ -19,6 +21,39 @@ const WEB_TERMINAL_MIN_ROWS = 12;
19
21
  const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
20
22
  const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
21
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
+ `;
22
57
 
23
58
  let XTERM_JS_FILE = null;
24
59
  let XTERM_CSS_FILE = null;
@@ -217,6 +252,464 @@ function getWebAuthClearCookie() {
217
252
  return `${WEB_AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
218
253
  }
219
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
+
220
713
  function listWebManyoyoContainers(ctx) {
221
714
  const output = ctx.dockerExecArgs(
222
715
  ['ps', '-a', '--filter', 'label=manyoyo.default_cmd', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
@@ -243,23 +736,27 @@ function listWebManyoyoContainers(ctx) {
243
736
  return map;
244
737
  }
245
738
 
246
- async function ensureWebContainer(ctx, state, containerName) {
247
- if (!ctx.containerExists(containerName)) {
248
- const webDefaultCommand = `${ctx.execCommandPrefix}${ctx.execCommand}${ctx.execCommandSuffix}`.trim() || '/bin/bash';
249
- const safeLabelCmd = webDefaultCommand.replace(/[\r\n]/g, ' ');
250
- const args = [
251
- 'run', '-d',
252
- '--name', containerName,
253
- '--entrypoint', '',
254
- ...ctx.contModeArgs,
255
- ...ctx.containerEnvs,
256
- ...ctx.containerVolumes,
257
- '--volume', `${ctx.hostPath}:${ctx.containerPath}`,
258
- '--workdir', ctx.containerPath,
259
- '--label', `manyoyo.default_cmd=${safeLabelCmd}`,
260
- `${ctx.imageName}:${ctx.imageVersion}`,
261
- 'tail', '-f', '/dev/null'
262
- ];
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)) {
749
+ const args = buildContainerRunArgs({
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
759
+ });
263
760
 
264
761
  try {
265
762
  ctx.dockerExecArgs(args, { stdio: 'pipe' });
@@ -268,33 +765,58 @@ async function ensureWebContainer(ctx, state, containerName) {
268
765
  throw e;
269
766
  }
270
767
 
271
- await ctx.waitForContainerReady(containerName);
272
- appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已创建并启动。`);
768
+ await ctx.waitForContainerReady(runtime.containerName);
769
+ appendWebSessionMessage(state.webHistoryDir, runtime.containerName, 'system', `容器 ${runtime.containerName} 已创建并启动。`);
273
770
  return;
274
771
  }
275
772
 
276
- const status = ctx.getContainerStatus(containerName);
773
+ const status = ctx.getContainerStatus(runtime.containerName);
277
774
  if (status !== 'running') {
278
- ctx.dockerExecArgs(['start', containerName], { stdio: 'pipe' });
279
- appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已启动。`);
775
+ ctx.dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
776
+ appendWebSessionMessage(state.webHistoryDir, runtime.containerName, 'system', `容器 ${runtime.containerName} 已启动。`);
280
777
  }
281
778
  }
282
779
 
283
- function execCommandInWebContainer(ctx, containerName, command) {
284
- const result = spawnSync(ctx.dockerCmd, ['exec', containerName, '/bin/bash', '-lc', command], {
285
- encoding: 'utf-8',
286
- maxBuffer: 32 * 1024 * 1024
287
- });
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
+ );
288
787
 
289
- if (result.error) {
290
- throw result.error;
291
- }
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
+ }
292
808
 
293
- const exitCode = typeof result.status === 'number' ? result.status : 1;
294
- const rawOutput = `${result.stdout || ''}${result.stderr || ''}`;
295
- const output = clipText(stripAnsi(rawOutput).trim() || '(无输出)');
809
+ process.stdout.on('data', appendChunk);
810
+ process.stderr.on('data', appendChunk);
296
811
 
297
- 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
+ });
298
820
  }
299
821
 
300
822
  function readRequestBody(req) {
@@ -350,6 +872,15 @@ function decodeSessionName(encoded) {
350
872
  }
351
873
  }
352
874
 
875
+ function getValidSessionName(ctx, res, encodedName) {
876
+ const containerName = decodeSessionName(encodedName);
877
+ if (!ctx.isValidContainerName(containerName)) {
878
+ sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
879
+ return null;
880
+ }
881
+ return containerName;
882
+ }
883
+
353
884
  function buildSessionSummary(ctx, state, containerMap, name) {
354
885
  const history = loadWebSessionHistory(state.webHistoryDir, name);
355
886
  const latestMessage = history.messages.length ? history.messages[history.messages.length - 1] : null;
@@ -686,111 +1217,172 @@ function sendWebUnauthorized(res, pathname) {
686
1217
  }
687
1218
 
688
1219
  async function handleWebApi(req, res, pathname, ctx, state) {
689
- if (req.method === 'GET' && pathname === '/api/sessions') {
690
- const containerMap = listWebManyoyoContainers(ctx);
691
- const names = new Set([
692
- ...Object.keys(containerMap),
693
- ...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
694
- ]);
695
-
696
- const sessions = Array.from(names)
697
- .map(name => buildSessionSummary(ctx, state, containerMap, name))
698
- .sort((a, b) => {
699
- const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
700
- const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
701
- return timeB - timeA;
702
- });
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
+ }
703
1246
 
704
- sendJson(res, 200, { sessions });
705
- return true;
706
- }
1247
+ let parsed = null;
1248
+ try {
1249
+ parsed = parseAndValidateConfigRaw(raw);
1250
+ } catch (e) {
1251
+ sendJson(res, 400, { error: '配置格式错误', detail: e.message || '解析失败' });
1252
+ return;
1253
+ }
707
1254
 
708
- if (req.method === 'POST' && pathname === '/api/sessions') {
709
- const payload = await readJsonBody(req);
710
- let containerName = (payload.name || '').trim();
711
- if (!containerName) {
712
- containerName = `my-${ctx.formatDate()}`;
713
- }
714
- if (!ctx.isValidContainerName(containerName)) {
715
- sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
716
- return true;
717
- }
1255
+ const savePath = path.resolve(state.webConfigPath);
1256
+ fs.mkdirSync(path.dirname(savePath), { recursive: true });
1257
+ fs.writeFileSync(savePath, raw, 'utf-8');
718
1258
 
719
- await ensureWebContainer(ctx, state, containerName);
720
- sendJson(res, 200, { name: containerName });
721
- return true;
722
- }
1259
+ sendJson(res, 200, {
1260
+ saved: true,
1261
+ path: savePath,
1262
+ defaults: buildConfigDefaults(ctx, parsed)
1263
+ });
1264
+ }
1265
+ },
1266
+ {
1267
+ method: 'GET',
1268
+ match: currentPath => currentPath === '/api/sessions' ? [] : null,
1269
+ handler: async () => {
1270
+ const containerMap = listWebManyoyoContainers(ctx);
1271
+ const names = new Set([
1272
+ ...Object.keys(containerMap),
1273
+ ...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
1274
+ ]);
1275
+
1276
+ const sessions = Array.from(names)
1277
+ .map(name => buildSessionSummary(ctx, state, containerMap, name))
1278
+ .sort((a, b) => {
1279
+ const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
1280
+ const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
1281
+ return timeB - timeA;
1282
+ });
723
1283
 
724
- const messagesMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/);
725
- if (req.method === 'GET' && messagesMatch) {
726
- const containerName = decodeSessionName(messagesMatch[1]);
727
- if (!ctx.isValidContainerName(containerName)) {
728
- sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
729
- return true;
730
- }
1284
+ sendJson(res, 200, { sessions });
1285
+ }
1286
+ },
1287
+ {
1288
+ method: 'POST',
1289
+ match: currentPath => currentPath === '/api/sessions' ? [] : null,
1290
+ handler: async () => {
1291
+ const payload = await readJsonBody(req);
1292
+ let runtime = null;
1293
+ try {
1294
+ runtime = buildCreateRuntime(ctx, state, payload);
1295
+ } catch (e) {
1296
+ sendJson(res, 400, { error: e.message || '创建参数错误' });
1297
+ return;
1298
+ }
731
1299
 
732
- const history = loadWebSessionHistory(state.webHistoryDir, containerName);
733
- sendJson(res, 200, { name: containerName, messages: history.messages });
734
- return true;
735
- }
1300
+ await ensureWebContainer(ctx, state, runtime);
1301
+ sendJson(res, 200, { name: runtime.containerName, applied: runtime.applied });
1302
+ }
1303
+ },
1304
+ {
1305
+ method: 'GET',
1306
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/messages$/),
1307
+ handler: async match => {
1308
+ const containerName = getValidSessionName(ctx, res, match[1]);
1309
+ if (!containerName) {
1310
+ return;
1311
+ }
1312
+ const history = loadWebSessionHistory(state.webHistoryDir, containerName);
1313
+ sendJson(res, 200, { name: containerName, messages: history.messages });
1314
+ }
1315
+ },
1316
+ {
1317
+ method: 'POST',
1318
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/run$/),
1319
+ handler: async match => {
1320
+ const containerName = getValidSessionName(ctx, res, match[1]);
1321
+ if (!containerName) {
1322
+ return;
1323
+ }
736
1324
 
737
- const runMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/run$/);
738
- if (req.method === 'POST' && runMatch) {
739
- const containerName = decodeSessionName(runMatch[1]);
740
- if (!ctx.isValidContainerName(containerName)) {
741
- sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
742
- return true;
743
- }
1325
+ const payload = await readJsonBody(req);
1326
+ const command = (payload.command || '').trim();
1327
+ if (!command) {
1328
+ sendJson(res, 400, { error: 'command 不能为空' });
1329
+ return;
1330
+ }
744
1331
 
745
- const payload = await readJsonBody(req);
746
- const command = (payload.command || '').trim();
747
- if (!command) {
748
- sendJson(res, 400, { error: 'command 不能为空' });
749
- return true;
750
- }
1332
+ await ensureWebContainer(ctx, state, containerName);
1333
+ appendWebSessionMessage(state.webHistoryDir, containerName, 'user', command);
1334
+ const result = await execCommandInWebContainer(ctx, containerName, command);
1335
+ appendWebSessionMessage(
1336
+ state.webHistoryDir,
1337
+ containerName,
1338
+ 'assistant',
1339
+ result.output,
1340
+ { exitCode: result.exitCode }
1341
+ );
1342
+ sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
1343
+ }
1344
+ },
1345
+ {
1346
+ method: 'POST',
1347
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/remove$/),
1348
+ handler: async match => {
1349
+ const containerName = getValidSessionName(ctx, res, match[1]);
1350
+ if (!containerName) {
1351
+ return;
1352
+ }
751
1353
 
752
- await ensureWebContainer(ctx, state, containerName);
753
- appendWebSessionMessage(state.webHistoryDir, containerName, 'user', command);
754
- const result = execCommandInWebContainer(ctx, containerName, command);
755
- appendWebSessionMessage(
756
- state.webHistoryDir,
757
- containerName,
758
- 'assistant',
759
- `${result.output}\n\n[exit ${result.exitCode}]`,
760
- { exitCode: result.exitCode }
761
- );
762
- sendJson(res, 200, { exitCode: result.exitCode, output: result.output });
763
- return true;
764
- }
1354
+ if (ctx.containerExists(containerName)) {
1355
+ ctx.removeContainer(containerName);
1356
+ appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已删除。`);
1357
+ }
765
1358
 
766
- const removeMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/remove$/);
767
- if (req.method === 'POST' && removeMatch) {
768
- const containerName = decodeSessionName(removeMatch[1]);
769
- if (!ctx.isValidContainerName(containerName)) {
770
- sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
771
- return true;
772
- }
1359
+ sendJson(res, 200, { removed: true, name: containerName });
1360
+ }
1361
+ },
1362
+ {
1363
+ method: 'POST',
1364
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/remove-with-history$/),
1365
+ handler: async match => {
1366
+ const containerName = getValidSessionName(ctx, res, match[1]);
1367
+ if (!containerName) {
1368
+ return;
1369
+ }
773
1370
 
774
- if (ctx.containerExists(containerName)) {
775
- ctx.removeContainer(containerName);
776
- appendWebSessionMessage(state.webHistoryDir, containerName, 'system', `容器 ${containerName} 已删除。`);
1371
+ removeWebSessionHistory(state.webHistoryDir, containerName);
1372
+ sendJson(res, 200, { removedHistory: true, name: containerName });
1373
+ }
777
1374
  }
1375
+ ];
778
1376
 
779
- sendJson(res, 200, { removed: true, name: containerName });
780
- return true;
781
- }
782
-
783
- const removeHistoryMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/remove-with-history$/);
784
- if (req.method === 'POST' && removeHistoryMatch) {
785
- const containerName = decodeSessionName(removeHistoryMatch[1]);
786
- if (!ctx.isValidContainerName(containerName)) {
787
- sendJson(res, 400, { error: `containerName 非法: ${containerName}` });
788
- return true;
1377
+ for (const route of routes) {
1378
+ if (route.method !== req.method) {
1379
+ continue;
789
1380
  }
790
-
791
- removeWebSessionHistory(state.webHistoryDir, containerName);
792
-
793
- sendJson(res, 200, { removedHistory: true, name: containerName });
1381
+ const matched = route.match(pathname);
1382
+ if (!matched) {
1383
+ continue;
1384
+ }
1385
+ await route.handler(matched);
794
1386
  return true;
795
1387
  }
796
1388
 
@@ -838,6 +1430,7 @@ async function startWebServer(options) {
838
1430
 
839
1431
  const state = {
840
1432
  webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
1433
+ webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
841
1434
  authSessions: new Map(),
842
1435
  terminalSessions: new Map()
843
1436
  };
@@ -980,12 +1573,18 @@ async function startWebServer(options) {
980
1573
  });
981
1574
  });
982
1575
 
1576
+ let listenPort = ctx.serverPort;
1577
+
983
1578
  await new Promise((resolve, reject) => {
984
1579
  server.once('error', reject);
985
1580
  server.listen(ctx.serverPort, ctx.serverHost, () => {
1581
+ const address = server.address();
1582
+ if (address && typeof address === 'object' && typeof address.port === 'number') {
1583
+ listenPort = address.port;
1584
+ }
986
1585
  const { GREEN, CYAN, YELLOW, NC } = ctx.colors;
987
1586
  const listenHost = formatUrlHost(ctx.serverHost);
988
- console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${ctx.serverPort}${NC}`);
1587
+ console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${listenPort}${NC}`);
989
1588
  console.log(`${CYAN}提示: 左侧是 manyoyo 容器会话列表,右侧支持命令模式与交互式终端模式。${NC}`);
990
1589
  if (ctx.serverHost === '0.0.0.0') {
991
1590
  console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 IP 访问。${NC}`);
@@ -999,6 +1598,36 @@ async function startWebServer(options) {
999
1598
  resolve();
1000
1599
  });
1001
1600
  });
1601
+
1602
+ return {
1603
+ server,
1604
+ wsServer,
1605
+ host: ctx.serverHost,
1606
+ port: listenPort,
1607
+ close: () => new Promise(resolve => {
1608
+ for (const session of state.terminalSessions.values()) {
1609
+ const ptyProcess = session && session.ptyProcess;
1610
+ if (ptyProcess && !ptyProcess.killed) {
1611
+ try { ptyProcess.kill('SIGTERM'); } catch (e) {}
1612
+ }
1613
+ }
1614
+ state.terminalSessions.clear();
1615
+
1616
+ const closeHttp = () => {
1617
+ if (!server.listening) {
1618
+ resolve();
1619
+ return;
1620
+ }
1621
+ server.close(() => resolve());
1622
+ };
1623
+
1624
+ try {
1625
+ wsServer.close(() => closeHttp());
1626
+ } catch (e) {
1627
+ closeHttp();
1628
+ }
1629
+ })
1630
+ };
1002
1631
  }
1003
1632
 
1004
1633
  module.exports = {