@xcanwin/manyoyo 8.3.3 → 8.5.0

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.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/bin/manyoyo.js +111 -48
  3. package/docker/manyoyo.Dockerfile +41 -8
  4. package/docker/res/agent/update-agents.sh +1 -1
  5. package/docker/res/playwright/browser.json +9 -12
  6. package/docker/res/playwright/chrome-policy.json +3 -0
  7. package/docker/res/playwright/env +3 -0
  8. package/docker/res/playwright/playwright-cli.sh +5 -0
  9. package/docker/res/playwright/stealth.init.js +11 -9
  10. package/lib/browser-background.js +23 -0
  11. package/lib/container-env.sh +34 -0
  12. package/lib/container-exec.js +132 -0
  13. package/lib/container-id.js +11 -0
  14. package/lib/container-init.sh +40 -0
  15. package/lib/container-manage-options.js +35 -0
  16. package/lib/container-network.js +615 -0
  17. package/lib/container-run.js +27 -3
  18. package/lib/container-state.js +467 -0
  19. package/lib/egress-denied.js +128 -0
  20. package/lib/egress-proxy.js +315 -0
  21. package/lib/egress-sidecar-main.js +112 -0
  22. package/lib/egress-sidecar.js +276 -0
  23. package/lib/env-text.js +62 -0
  24. package/lib/json5-text-edit.js +5 -5
  25. package/lib/network-endpoints.js +99 -0
  26. package/lib/network-policy.js +269 -0
  27. package/lib/plugin/fingerprint.js +65 -50
  28. package/lib/plugin/playwright-extensions.js +0 -9
  29. package/lib/plugin/playwright-server.js +22 -2
  30. package/lib/plugin/playwright.js +55 -16
  31. package/lib/port-forward.js +137 -0
  32. package/lib/web/AGENTS.md +4 -0
  33. package/lib/web/container-manage.js +506 -0
  34. package/lib/web/denied-notify.js +52 -0
  35. package/lib/web/index.html +81 -77
  36. package/lib/web/server.js +352 -135
  37. package/manyoyo.example.json +20 -2
  38. package/package.json +13 -8
package/lib/web/server.js CHANGED
@@ -9,9 +9,23 @@ const { consumeLoginToken, isLoopbackHost } = require('../login-token');
9
9
  const http = require('http');
10
10
  const WebSocket = require('ws');
11
11
  const JSON5 = require('json5');
12
- const { buildContainerRunArgs } = require('../container-run');
12
+ const { buildContainerRunArgs, hasNetworkArg } = require('../container-run');
13
13
  const { buildContainerIntegration, mergeIntegration } = require('../plugin');
14
14
  const { applyAptMirror } = require('../mirrors');
15
+ const containerState = require('../container-state');
16
+ const { buildExecArgs, resolveContainerId } = require('../container-exec');
17
+ const { createNetworkManager, NETWORK_NAME } = require('../container-network');
18
+ const { normalizePolicy } = require('../network-policy');
19
+ const { withEnvEndpoints } = require('../network-endpoints');
20
+ const { egressPaths, isSidecarName } = require('../egress-sidecar');
21
+ const { read: readDenied } = require('../egress-denied');
22
+ const { isBrowserBackground } = require('../browser-background');
23
+ const { createDeniedNotifier } = require('./denied-notify');
24
+ const { domainAllowed } = require('../network-policy');
25
+ const { createPortForwarder } = require('../port-forward');
26
+ const { createContainerManageRoutes, pendingRisks } = require('./container-manage');
27
+ const { resolveManageOptions } = require('../container-manage-options');
28
+ const { defaultPolicy } = require('../network-policy');
15
29
  const { normalizeMirrors } = require('../runtime-normalizers');
16
30
  const { estimateContainerCapacity } = require('../capacity');
17
31
  const { extractAgentMessageFromCodexJsonl } = require('../codex-output');
@@ -1188,6 +1202,21 @@ function accumulateUsageTotal(baseline, turnUsage) {
1188
1202
  };
1189
1203
  }
1190
1204
 
1205
+ // 所有进用户容器的 exec 都经这里:env 从状态目录现读(见 lib/container-exec.js),子进程结束后删临时 env 文件
1206
+ function spawnContainerExec(ctx, containerName, execOptions, spawnOptions) {
1207
+ const exec = buildExecArgs({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, containerName, execOptions);
1208
+ let child;
1209
+ try {
1210
+ child = spawn(ctx.dockerCmd, exec.args, { ...spawnOptions, env: ctx.dockerEnv });
1211
+ } catch (e) {
1212
+ exec.cleanup();
1213
+ throw e;
1214
+ }
1215
+ child.once('close', exec.cleanup);
1216
+ child.once('error', exec.cleanup);
1217
+ return child;
1218
+ }
1219
+
1191
1220
  // 起一个短命的容器内子进程,通过对应协议问一次元信息(初始化握手 / model 列表),拿到结果就杀掉进程——
1192
1221
  // 不是长驻会话,不会产生真实对话轮次或费用。
1193
1222
  function runAgentProtocolProcess(ctx, containerName, command, options = {}) {
@@ -1200,7 +1229,7 @@ function runAgentProtocolProcess(ctx, containerName, command, options = {}) {
1200
1229
  let stdoutAll = '';
1201
1230
  let child;
1202
1231
  try {
1203
- child = spawn(ctx.dockerCmd, ['exec', '-i', containerName, '/bin/bash', '-lc', command], { stdio: ['pipe', 'pipe', 'pipe'], env: ctx.dockerEnv });
1232
+ child = spawnContainerExec(ctx, containerName, { interactive: true, command: ['/bin/bash', '-lc', command] }, { stdio: ['pipe', 'pipe', 'pipe'] });
1204
1233
  } catch (e) {
1205
1234
  resolve({ result: null, stdout: '' });
1206
1235
  return;
@@ -2493,40 +2522,16 @@ function normalizeStringArray(value, sourceLabel) {
2493
2522
  .filter(Boolean);
2494
2523
  }
2495
2524
 
2496
- function parseEnvFileToArgs(filePath) {
2525
+ // 环境变量文件:只解析路径并检查存在,内容每次 exec 现读(见 lib/container-state.js 的 readEnvFiles)
2526
+ function resolveEnvFilePath(filePath) {
2497
2527
  const resolvedPath = expandHomeAliasPath(filePath);
2498
2528
  if (!path.isAbsolute(resolvedPath)) {
2499
- throw new Error(`envFile 仅支持绝对路径: ${filePath}`);
2529
+ throw new Error(`环境变量文件仅支持绝对路径: ${filePath}`);
2500
2530
  }
2501
2531
  if (!fs.existsSync(resolvedPath)) {
2502
- throw new Error(`未找到环境文件: ${resolvedPath}`);
2532
+ throw new Error(`未找到环境变量文件: ${resolvedPath}`);
2503
2533
  }
2504
-
2505
- const content = fs.readFileSync(resolvedPath, 'utf-8');
2506
- const args = [];
2507
- const lines = content.split('\n');
2508
-
2509
- for (let line of lines) {
2510
- const match = line.match(/^(?:export\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/);
2511
- if (!match) {
2512
- continue;
2513
- }
2514
-
2515
- const key = match[1];
2516
- let value = match[2].trim();
2517
- if (/[\r\n\0]/.test(value)) continue;
2518
- if (/[\$\(\)\`\|\&\*\{\};<>]/.test(value)) continue;
2519
- if (/^\(/.test(value)) continue;
2520
-
2521
- if (/^"(.*)"$/.test(value)) {
2522
- value = value.slice(1, -1);
2523
- } else if (/^'(.*)'$/.test(value)) {
2524
- value = value.slice(1, -1);
2525
- }
2526
- args.push('--env', `${key}=${value}`);
2527
- }
2528
-
2529
- return args;
2534
+ return resolvedPath;
2530
2535
  }
2531
2536
 
2532
2537
  function resolveContainerModeArgs(mode) {
@@ -2693,7 +2698,7 @@ function buildConfigDefaults(ctx, config) {
2693
2698
  const defaults = {
2694
2699
  containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
2695
2700
  hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath, ctx.hostPath)),
2696
- containerPath: pickFirstString(parsed.containerPath, ctx.containerPath),
2701
+ containerPath: pickFirstString(parsed.containerPath),
2697
2702
  imageName: pickFirstString(parsed.imageName, ctx.imageName),
2698
2703
  imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
2699
2704
  containerMode: hasOwn(parsed, 'containerMode') ? String(parsed.containerMode || '') : '',
@@ -2747,6 +2752,7 @@ function buildStaticContainerRuntime(ctx, containerName) {
2747
2752
  contModeArgs: Array.isArray(ctx.contModeArgs) ? ctx.contModeArgs.slice() : [],
2748
2753
  containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
2749
2754
  containerEnvs: Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [],
2755
+ envFiles: Array.isArray(ctx.envFiles) ? ctx.envFiles.slice() : [],
2750
2756
  containerVolumes: Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [],
2751
2757
  containerPorts: Array.isArray(ctx.containerPorts) ? ctx.containerPorts.slice() : [],
2752
2758
  defaultCommand: buildDefaultCommand(ctx.execCommandPrefix, ctx.execCommand, ctx.execCommandSuffix) || '/bin/bash'
@@ -2797,7 +2803,9 @@ function buildCreateRuntime(ctx, state, payload) {
2797
2803
  defaults: {
2798
2804
  hostPath: ctx.hostPath,
2799
2805
  containerName: `my-${ctx.formatDate()}`,
2800
- containerPath: ctx.containerPath,
2806
+ // 不用 ctx.containerPath:那是 serve 进程的启动目录(CLI 的 cwd 语义),与用户要挂载的目录无关;
2807
+ // 没配置也没传时落到下面的 `|| hostPath`,容器内路径与宿主机路径一致
2808
+ containerPath: '',
2801
2809
  imageName: ctx.imageName,
2802
2810
  imageVersion: ctx.imageVersion
2803
2811
  },
@@ -2818,6 +2826,12 @@ function buildCreateRuntime(ctx, state, payload) {
2818
2826
  parseServerListen: () => ({ host: '', port: 0 })
2819
2827
  });
2820
2828
 
2829
+ // 自启动与网络:请求 > runs.<name> > 全局配置;请求里放开限制(open / 非本机绑定)要带 confirmRisk
2830
+ const manage = resolveManageOptions({ requestOptions, runConfig, globalConfig: config });
2831
+ if (manage.fromRequest && requestOptions.confirmRisk !== true && pendingRisks(defaultPolicy(), manage.network).length) {
2832
+ throw new Error('该网络设置会放开限制(open 预设或绑定非本机地址),需要确认(confirmRisk)');
2833
+ }
2834
+
2821
2835
  const containerName = resolvedBase.containerName;
2822
2836
  validateContainerNameStrict(containerName);
2823
2837
 
@@ -2886,6 +2900,7 @@ function buildCreateRuntime(ctx, state, payload) {
2886
2900
  const resumeSupported = Boolean(buildAgentResumeCommand(agentProgram));
2887
2901
 
2888
2902
  let containerEnvs = Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [];
2903
+ let envFiles = Array.isArray(ctx.envFiles) ? ctx.envFiles.slice() : [];
2889
2904
  const hasRunEnv = hasOwn(runConfig, 'env');
2890
2905
  const hasRunEnvFile = hasOwn(runConfig, 'envFile');
2891
2906
  if (hasRequestEnv || hasRequestEnvFile || hasRunEnv || hasRunEnvFile || hasConfigEnv || hasConfigEnvFile) {
@@ -2896,13 +2911,8 @@ function buildCreateRuntime(ctx, state, payload) {
2896
2911
  envArgs.push('--env', `${parsed.key}=${parsed.value}`);
2897
2912
  });
2898
2913
 
2899
- const envFileList = resolvedBase.envFile;
2900
- const envFileArgs = [];
2901
- envFileList.forEach(filePath => {
2902
- envFileArgs.push(...parseEnvFileToArgs(filePath));
2903
- });
2904
-
2905
- containerEnvs = [...envArgs, ...envFileArgs];
2914
+ containerEnvs = envArgs;
2915
+ envFiles = resolvedBase.envFile.map(resolveEnvFilePath);
2906
2916
  }
2907
2917
 
2908
2918
  let containerVolumes = Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [];
@@ -2934,8 +2944,11 @@ function buildCreateRuntime(ctx, state, payload) {
2934
2944
  contModeArgs,
2935
2945
  containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
2936
2946
  containerEnvs,
2947
+ envFiles,
2937
2948
  containerVolumes,
2938
2949
  containerPorts,
2950
+ autostart: manage.autostart,
2951
+ network: manage.network,
2939
2952
  agentPromptCommand,
2940
2953
  defaultCommand: buildDefaultCommand(shellPrefix, shell, shellSuffix) || '/bin/bash',
2941
2954
  // 仅供服务端内部复用(如"创建相同配置容器"/"复制容器"),绝不能透传给任何对外响应字段,
@@ -2954,7 +2967,10 @@ function buildCreateRuntime(ctx, state, payload) {
2954
2967
  env: resolvedBase.env || {},
2955
2968
  envFile: resolvedBase.envFile || [],
2956
2969
  volumes: resolvedBase.volumes || [],
2957
- ports: resolvedBase.ports || []
2970
+ ports: resolvedBase.ports || [],
2971
+ autostart: manage.autostart,
2972
+ // 复制容器时端口暴露(宿主机端口会冲突)与 peers(按 id 引用别的容器)不带过去
2973
+ network: { ...manage.network, expose: [], peers: { inbound: [] } }
2958
2974
  },
2959
2975
  applied: {
2960
2976
  containerName,
@@ -2972,6 +2988,8 @@ function buildCreateRuntime(ctx, state, payload) {
2972
2988
  resumeSupported,
2973
2989
  yolo: yolo || '',
2974
2990
  envCount: Math.floor(containerEnvs.length / 2),
2991
+ autostartEnabled: Boolean(manage.autostart.trim()),
2992
+ networkPreset: manage.network.preset,
2975
2993
  volumeCount: Math.floor(containerVolumes.length / 2),
2976
2994
  portCount: Math.floor(containerPorts.length / 2)
2977
2995
  }
@@ -3015,7 +3033,11 @@ function buildCloneCreateOptionsFromSnapshot(runtimeSnapshot, fallbackHistory) {
3015
3033
  env: runtimeSnapshot.env || {},
3016
3034
  envFile: runtimeSnapshot.envFile || [],
3017
3035
  volumes: runtimeSnapshot.volumes || [],
3018
- ports: runtimeSnapshot.ports || []
3036
+ ports: runtimeSnapshot.ports || [],
3037
+ autostart: runtimeSnapshot.autostart || '',
3038
+ network: runtimeSnapshot.network || undefined,
3039
+ // 快照来自服务端、原容器创建时已确认过,复制不必再确认
3040
+ confirmRisk: runtimeSnapshot.network ? true : undefined
3019
3041
  };
3020
3042
  }
3021
3043
 
@@ -3138,7 +3160,7 @@ function listWebManyoyoContainers(ctx) {
3138
3160
  const candidates = [];
3139
3161
  output.trim().split('\n').forEach(line => {
3140
3162
  const [name, status, image] = line.split('\t');
3141
- if (!ctx.isValidContainerName(name)) {
3163
+ if (!ctx.isValidContainerName(name) || isSidecarName(name)) {
3142
3164
  return;
3143
3165
  }
3144
3166
  const imageName = image || '';
@@ -3295,13 +3317,114 @@ async function cleanupStaleSetupTestContainers(ctx) {
3295
3317
  const names = String(listed || '').split('\n').map(line => line.trim())
3296
3318
  .filter(name => name.startsWith(SETUP_TEST_CONTAINER_PREFIX) && /^[A-Za-z0-9_.-]+$/.test(name));
3297
3319
  for (const name of names) {
3320
+ const stateId = String(await ctx.dockerExecAsync(
3321
+ ['inspect', '-f', '{{index .Config.Labels "manyoyo.id"}}', name], { timeout: 10000 }
3322
+ ).catch(() => '') || '').trim();
3298
3323
  await ctx.dockerExecAsync(['rm', '-f', name], { timeout: 20000 }).catch(() => {});
3324
+ if (stateId) containerState.removeState(ctx.homeDir || os.homedir(), stateId);
3299
3325
  }
3300
3326
  } catch (e) {
3301
3327
  ctx.logger.warn('cleanup stale setup test containers failed', { message: e && e.message });
3302
3328
  }
3303
3329
  }
3304
3330
 
3331
+ // Agent 一轮对话里,容器被过滤代理拦下的新域名(浏览器后台请求不算):只在 allowlist 容器上轮询
3332
+ function createWebDeniedNotifier(ctx, sessionRef, emit) {
3333
+ try {
3334
+ const homeDir = ctx.homeDir || os.homedir();
3335
+ const id = resolveContainerId(ctx.dockerExecArgs, sessionRef.containerName);
3336
+ if (!id || !containerState.stateExists(homeDir, id)) return null;
3337
+ const deniedDir = egressPaths(homeDir).denied;
3338
+ const policyOf = () => normalizePolicy(containerState.readNetworkRaw(homeDir, id) || undefined);
3339
+ if (policyOf().preset !== 'allowlist') return null;
3340
+ return createDeniedNotifier({
3341
+ read: () => readDenied(deniedDir, id),
3342
+ isIgnored: host => isBrowserBackground(host) || domainAllowed(policyOf().egress.domains, host),
3343
+ emit
3344
+ });
3345
+ } catch (e) {
3346
+ return null;
3347
+ }
3348
+ }
3349
+
3350
+ function getWebPortForwarder(ctx, state) {
3351
+ if (!state.portForwarder) {
3352
+ const homeDir = ctx.homeDir || os.homedir();
3353
+ state.portForwarder = createPortForwarder({
3354
+ command: ctx.dockerCmd,
3355
+ env: ctx.dockerEnv,
3356
+ homeDir,
3357
+ // id → 当前容器名:只信运行时里真实存在的(serve 启动时与接口调用时登记),meta.json 里的旧名字可能已被同名新容器占用
3358
+ resolveName: id => (state.containerNames && state.containerNames.get(id)) || '',
3359
+ warn: message => ctx.logger.warn(message)
3360
+ });
3361
+ }
3362
+ return state.portForwarder;
3363
+ }
3364
+
3365
+ // 运行时就绪后的容器服务:规则对账 + start 事件订阅(外部重启后补下发)、serve 启动时拉起标记了“自动拉起”的容器、
3366
+ // 恢复端口暴露。没有 watchStarts 的替身管理器(单测)不启动任何后台服务。
3367
+ async function startContainerServices(ctx, state) {
3368
+ const manager = getWebNetworkManager(ctx, state);
3369
+ const homeDir = ctx.homeDir || os.homedir();
3370
+ sweepStaleExecEnvFiles(homeDir);
3371
+ const reconcile = () => reconcileContainerServices(ctx, state, manager);
3372
+ state.networkWatch = manager.watchStarts({ onReconnect: reconcile });
3373
+ await reconcile();
3374
+ }
3375
+
3376
+ // 拉起 autostartOnServe 的容器并恢复端口暴露:serve 启动时做一次,运行时重启后(事件流重连)再做一次,幂等
3377
+ async function reconcileContainerServices(ctx, state, manager) {
3378
+ const homeDir = ctx.homeDir || os.homedir();
3379
+ const forwarder = getWebPortForwarder(ctx, state);
3380
+ // 以运行时里真实存在的容器为准(按 id 对应名字);状态目录里有、容器却没有的是孤儿,既不拉起也不转发
3381
+ state.containerNames = new Map((await manager.listManaged()).map(item => [item.id, item.name]));
3382
+ for (const id of containerState.listStateIds(homeDir)) {
3383
+ const name = state.containerNames.get(id);
3384
+ if (!name) continue;
3385
+ let policy;
3386
+ try { policy = normalizePolicy(containerState.readNetworkRaw(homeDir, id) || undefined); } catch (e) { continue; }
3387
+ if (policy.autostartOnServe) {
3388
+ try {
3389
+ if (ctx.getContainerStatus(name) !== 'running') await ctx.dockerExecAsync(['start', name], { timeout: 30000 });
3390
+ } catch (e) {
3391
+ ctx.logger.warn('autostart container failed', { name, message: e && e.message });
3392
+ }
3393
+ }
3394
+ if (policy.expose.length) await forwarder.sync(id, policy.expose);
3395
+ }
3396
+ }
3397
+
3398
+ // serve 异常退出时 exec 的临时 env 文件(明文)可能残留:启动时清掉超过 6 小时的(正在跑的长任务不会这么久还没读完它)
3399
+ function sweepStaleExecEnvFiles(homeDir) {
3400
+ const dir = path.join(homeDir, '.manyoyo', 'tmp');
3401
+ try {
3402
+ for (const entry of fs.readdirSync(dir)) {
3403
+ if (!/^(exec|env)-/.test(entry)) continue;
3404
+ const file = path.join(dir, entry);
3405
+ if (Date.now() - fs.statSync(file).mtimeMs > 6 * 3600 * 1000) fs.rmSync(file, { force: true });
3406
+ }
3407
+ } catch (e) { /* 没有目录 */ }
3408
+ }
3409
+
3410
+ // 运行时就绪后启动 startContainerServices
3411
+ function startContainerNetworkWatch(ctx, state) {
3412
+ const manager = getWebNetworkManager(ctx, state);
3413
+ if (typeof manager.watchStarts !== 'function' || state.networkWatch || state.containerServicesStarting) return;
3414
+ state.containerServicesStarting = true;
3415
+ const deadline = Date.now() + 10 * 60 * 1000;
3416
+ const tick = () => {
3417
+ const status = ctx.runtimeState.status;
3418
+ if (status === 'ready') {
3419
+ startContainerServices(ctx, state).catch(e => ctx.logger.warn('container services failed', { message: e && e.message }));
3420
+ return;
3421
+ }
3422
+ if (status === 'failed' || Date.now() > deadline) return;
3423
+ setTimeout(tick, 1000).unref();
3424
+ };
3425
+ tick();
3426
+ }
3427
+
3305
3428
  function startStaleSetupCleanup(ctx) {
3306
3429
  const deadline = Date.now() + 10 * 60 * 1000;
3307
3430
  const tick = () => {
@@ -3347,6 +3470,24 @@ async function buildWebPlaywrightIntegration(ctx, state, runtime) {
3347
3470
  return mergeIntegration(runtime, integration);
3348
3471
  }
3349
3472
 
3473
+ // serve 进程的网络规则下发器(一份,懒创建)
3474
+ function getWebNetworkManager(ctx, state) {
3475
+ if (ctx.networkManager) return ctx.networkManager;
3476
+ if (!state.networkManager) {
3477
+ state.networkManager = createNetworkManager({
3478
+ command: ctx.dockerCmd,
3479
+ env: ctx.dockerEnv,
3480
+ homeDir: ctx.homeDir || os.homedir(),
3481
+ imageRef: () => `${ctx.imageName}:${ctx.imageVersion}`,
3482
+ getMirrorUrls: () => Object.values(readWebMirrors(state)).filter(Boolean),
3483
+ warn: message => ctx.logger.warn(message)
3484
+ });
3485
+ }
3486
+ return state.networkManager;
3487
+ }
3488
+
3489
+ // 进入容器前一律检查网络规则(宿主机侧的“本次启动已下发”状态):文件浏览也会 exec /bin/bash -lc,
3490
+ // 里面的 .bashrc 等是 Agent 能改的代码,不能在规则缺失的窗口里跑
3350
3491
  async function ensureWebContainer(ctx, state, containerInput, messageSessionRef = null) {
3351
3492
  const runtime = typeof containerInput === 'string'
3352
3493
  ? buildStaticContainerRuntime(ctx, containerInput)
@@ -3358,8 +3499,27 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3358
3499
 
3359
3500
  if (!ctx.containerExists(runtime.containerName)) {
3360
3501
  const mirrors = readWebMirrors(state);
3502
+ // 用户 env 不进容器配置,写进状态目录的 box/env,每次 exec 现读(见 lib/container-exec.js)
3503
+ const userEnvLines = containerState.userEnvLines(runtime.containerEnvs);
3361
3504
  const playwright = await buildWebPlaywrightIntegration(ctx, state, runtime);
3505
+ const containerEnvs = containerState.stripEnvKeys(playwright.containerEnvs, new Set(userEnvLines.map(containerState.envLineKey)));
3506
+ const networkPolicy = await withEnvEndpoints(normalizePolicy(runtime.network), [...userEnvLines, ...containerState.readEnvFilePaths(runtime.envFiles || []).flatMap(file => file.entries.map(entry => `${entry.key}=${entry.value}`))]);
3507
+ const networkManager = getWebNetworkManager(ctx, state);
3508
+ if (!hasNetworkArg([...(runtime.contModeArgs || []), ...(playwright.containerExtraArgs || [])])) {
3509
+ await networkManager.ensureBridgeNetwork();
3510
+ }
3511
+ const containerStateInfo = containerState.createState({
3512
+ homeDir: ctx.homeDir || os.homedir(),
3513
+ envLines: userEnvLines,
3514
+ envFiles: runtime.envFiles,
3515
+ autostart: runtime.autostart,
3516
+ network: networkPolicy,
3517
+ netRequired: networkPolicy.preset !== 'open',
3518
+ meta: { name: runtime.containerName }
3519
+ });
3362
3520
  const args = buildContainerRunArgs({
3521
+ state: containerStateInfo,
3522
+ defaultNetwork: NETWORK_NAME,
3363
3523
  mirrors,
3364
3524
  containerName: runtime.containerName,
3365
3525
  hostPath: runtime.hostPath,
@@ -3368,22 +3528,31 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3368
3528
  imageVersion: runtime.imageVersion,
3369
3529
  contModeArgs: runtime.contModeArgs,
3370
3530
  containerExtraArgs: playwright.containerExtraArgs,
3371
- containerEnvs: playwright.containerEnvs,
3531
+ containerEnvs,
3372
3532
  containerVolumes: playwright.containerVolumes,
3373
3533
  containerPorts: runtime.containerPorts,
3374
3534
  defaultCommand: runtime.defaultCommand
3375
3535
  });
3376
3536
 
3377
- await ensureWebImage(ctx, state, webImageRef(ctx, runtime), { failFast: true });
3378
-
3379
3537
  try {
3538
+ await ensureWebImage(ctx, state, webImageRef(ctx, runtime), { failFast: true });
3380
3539
  ctx.dockerExecArgs(args, { stdio: 'pipe' });
3381
3540
  } catch (e) {
3541
+ containerState.removeState(ctx.homeDir || os.homedir(), containerStateInfo.id);
3382
3542
  ctx.showImagePullHint(e);
3383
3543
  throw e;
3384
3544
  }
3385
3545
 
3386
3546
  await ctx.waitForContainerReady(runtime.containerName, { throwOnFailure: true });
3547
+ // 先下发网络规则再做任何 exec;失败即关闭(抛错,容器留着,init 在等门闩)
3548
+ await networkManager.apply(runtime.containerName, { expectId: containerStateInfo.id });
3549
+ // 创建时填的端口暴露:容器已就绪、规则已下发后立即开始转发;端口被占等失败只警告,网络页里能看到没有生效
3550
+ if (networkPolicy.expose.length) {
3551
+ if (!state.containerNames) state.containerNames = new Map();
3552
+ state.containerNames.set(containerStateInfo.id, runtime.containerName);
3553
+ const failures = await getWebPortForwarder(ctx, state).sync(containerStateInfo.id, networkPolicy.expose);
3554
+ failures.forEach(message => console.warn(message));
3555
+ }
3387
3556
  applyAptMirror({
3388
3557
  dockerExecArgs: ctx.dockerExecArgs,
3389
3558
  containerName: runtime.containerName,
@@ -3409,16 +3578,18 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3409
3578
  `容器 ${runtime.containerName} 已启动。`
3410
3579
  );
3411
3580
  }
3581
+ await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
3412
3582
  }
3413
3583
 
3414
3584
  async function execCommandInWebContainer(ctx, containerName, command, options = {}) {
3415
3585
  const opts = options && typeof options === 'object' ? options : {};
3416
3586
  const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
3417
3587
  return await new Promise((resolve, reject) => {
3418
- const process = spawn(
3419
- ctx.dockerCmd,
3420
- ['exec', containerName, '/bin/bash', '-lc', command],
3421
- { stdio: ['ignore', 'pipe', 'pipe'], env: ctx.dockerEnv }
3588
+ const process = spawnContainerExec(
3589
+ ctx,
3590
+ containerName,
3591
+ { withEnv: opts.withEnv !== false, command: ['/bin/bash', '-lc', command] },
3592
+ { stdio: ['ignore', 'pipe', 'pipe'] }
3422
3593
  );
3423
3594
 
3424
3595
  const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
@@ -3483,10 +3654,11 @@ __MANYOYO_NODE__`;
3483
3654
  }
3484
3655
 
3485
3656
  function spawnContainerRawFileStream(ctx, containerName, filePath) {
3486
- return spawn(
3487
- ctx.dockerCmd,
3488
- ['exec', containerName, 'cat', '--', filePath],
3489
- { stdio: ['ignore', 'pipe', 'pipe'], env: ctx.dockerEnv }
3657
+ return spawnContainerExec(
3658
+ ctx,
3659
+ containerName,
3660
+ { withEnv: false, command: ['cat', '--', filePath] },
3661
+ { stdio: ['ignore', 'pipe', 'pipe'] }
3490
3662
  );
3491
3663
  }
3492
3664
 
@@ -3516,7 +3688,7 @@ function inferFileLanguage(filePath) {
3516
3688
  }
3517
3689
 
3518
3690
  async function execJsonCommandInWebContainer(ctx, containerName, command) {
3519
- const result = await execCommandInWebContainer(ctx, containerName, command);
3691
+ const result = await execCommandInWebContainer(ctx, containerName, command, { withEnv: false });
3520
3692
  if (result.exitCode !== 0) {
3521
3693
  throw new Error(result.output || '容器命令执行失败');
3522
3694
  }
@@ -3821,10 +3993,11 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
3821
3993
  : reserveWebAgentRun(state, sessionRef);
3822
3994
  let process;
3823
3995
  try {
3824
- process = spawn(
3825
- ctx.dockerCmd,
3826
- ['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
3827
- { stdio: ['ignore', 'pipe', 'pipe'], env: ctx.dockerEnv }
3996
+ process = spawnContainerExec(
3997
+ ctx,
3998
+ sessionRef.containerName,
3999
+ { command: ['/bin/bash', '-lc', command] },
4000
+ { stdio: ['ignore', 'pipe', 'pipe'] }
3828
4001
  );
3829
4002
  } catch (e) {
3830
4003
  // spawn 同步抛错(如 dockerCmd 不存在)时必须释放锁,否则这个容器永远发不出消息
@@ -4184,9 +4357,14 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
4184
4357
  const workpath = getSetupWorkpath(state);
4185
4358
  fs.mkdirSync(workpath, { recursive: true });
4186
4359
  const containerName = `manyoyo-setup-test-${crypto.randomBytes(4).toString('hex')}`;
4187
- const containerEnvs = [];
4188
- Object.entries(envMap).forEach(([key, value]) => containerEnvs.push('--env', `${key}=${value}`));
4360
+ const testHome = ctx.homeDir || os.homedir();
4361
+ const containerStateInfo = containerState.createState({
4362
+ homeDir: testHome,
4363
+ envLines: Object.entries(envMap).map(([key, value]) => `${key}=${value}`),
4364
+ meta: { name: containerName }
4365
+ });
4189
4366
  const args = buildContainerRunArgs({
4367
+ state: containerStateInfo,
4190
4368
  containerName,
4191
4369
  hostPath: workpath,
4192
4370
  containerPath: '/workspace',
@@ -4194,7 +4372,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
4194
4372
  imageVersion: ctx.imageVersion,
4195
4373
  contModeArgs: [],
4196
4374
  containerExtraArgs: [],
4197
- containerEnvs,
4375
+ containerEnvs: [],
4198
4376
  containerVolumes: [],
4199
4377
  containerPorts: [],
4200
4378
  defaultCommand: '/bin/bash'
@@ -4220,6 +4398,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
4220
4398
  } catch (e) {
4221
4399
  // 容器可能根本没创建成功
4222
4400
  }
4401
+ containerState.removeState(testHome, containerStateInfo.id);
4223
4402
  }
4224
4403
  }
4225
4404
 
@@ -4605,6 +4784,9 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4605
4784
  'if [ -f ~/.bashrc ]; then',
4606
4785
  ' . ~/.bashrc',
4607
4786
  'fi',
4787
+ 'if [ -f /run/manyoyo-sys/env.sh ]; then',
4788
+ ' . /run/manyoyo-sys/env.sh',
4789
+ 'fi',
4608
4790
  'if [ -n "${MANYOYO_TERM_COLS:-}" ] && [ -n "${MANYOYO_TERM_ROWS:-}" ]; then',
4609
4791
  ' COLUMNS="$MANYOYO_TERM_COLS"',
4610
4792
  ' LINES="$MANYOYO_TERM_ROWS"',
@@ -4628,20 +4810,16 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4628
4810
 
4629
4811
  const termValue = process.env.TERM && process.env.TERM !== 'dumb' ? process.env.TERM : 'xterm-256color';
4630
4812
  const colorTermValue = process.env.COLORTERM || 'truecolor';
4631
- const dockerExecArgs = [
4632
- 'exec',
4633
- '-i',
4634
- '-e', `TERM=${termValue}`,
4635
- '-e', `COLORTERM=${colorTermValue}`,
4636
- '-e', `MANYOYO_TERM_COLS=${String(cols)}`,
4637
- '-e', `MANYOYO_TERM_ROWS=${String(rows)}`,
4638
- containerName,
4639
- '/bin/bash',
4640
- '-lc',
4641
- terminalBootstrap
4642
- ];
4643
-
4644
- return spawn(ctx.dockerCmd, dockerExecArgs, { stdio: ['pipe', 'pipe', 'pipe'], env: ctx.dockerEnv });
4813
+ return spawnContainerExec(ctx, containerName, {
4814
+ interactive: true,
4815
+ envs: [
4816
+ `TERM=${termValue}`,
4817
+ `COLORTERM=${colorTermValue}`,
4818
+ `MANYOYO_TERM_COLS=${String(cols)}`,
4819
+ `MANYOYO_TERM_ROWS=${String(rows)}`
4820
+ ],
4821
+ command: ['/bin/bash', '-lc', terminalBootstrap]
4822
+ }, { stdio: ['pipe', 'pipe', 'pipe'] });
4645
4823
  }
4646
4824
 
4647
4825
  function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
@@ -5209,6 +5387,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5209
5387
  ctx.retryRuntimeHeal();
5210
5388
  if (ctx.autoPullImage) startWebImagePrepare(ctx, state);
5211
5389
  startStaleSetupCleanup(ctx);
5390
+ startContainerNetworkWatch(ctx, state);
5212
5391
  }
5213
5392
  // 镜像拉取失败的条目会一直挂着:清掉后重新拉
5214
5393
  if (ctx.runtimeState.status === 'ready' && ctx.autoPullImage) {
@@ -6096,69 +6275,79 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6096
6275
  });
6097
6276
 
6098
6277
  let lastPartialPersistAt = 0;
6099
- try {
6100
- const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
6101
- agentProgram: agentMeta.agentProgram,
6102
- // 复用路由入口占好的那把锁,而不是另起一份运行登记
6103
- runState,
6104
- onEvent: event => {
6105
- // token 级增量:只走网线,不进事件日志,历史落盘按
6106
- // AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
6107
- const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
6108
- if (event && event.type === 'trace' && event.text) {
6109
- traceLines.push(String(event.text));
6110
- if (event.traceEvent && typeof event.traceEvent === 'object') {
6111
- traceEvents.push(event.traceEvent);
6112
- }
6113
- patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
6114
- content: traceLines.join('\n'),
6115
- traceEvents: traceEvents.slice(),
6278
+ const handleStreamEvent = event => {
6279
+ // token 级增量:只走网线,不进事件日志,历史落盘按
6280
+ // AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
6281
+ const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
6282
+ if (event && event.type === 'trace' && event.text) {
6283
+ traceLines.push(String(event.text));
6284
+ if (event.traceEvent && typeof event.traceEvent === 'object') {
6285
+ traceEvents.push(event.traceEvent);
6286
+ }
6287
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
6288
+ content: traceLines.join('\n'),
6289
+ traceEvents: traceEvents.slice(),
6290
+ pending: true
6291
+ });
6292
+ }
6293
+ if (event && event.type === 'content_delta' && typeof event.content === 'string') {
6294
+ const now = Date.now();
6295
+ const shouldPersist = !isPartialDelta
6296
+ || now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
6297
+ if (!streamingReplyMessageId) {
6298
+ const streamingReplyMessage = appendWebSessionMessage(
6299
+ state.webHistoryDir,
6300
+ sessionRef,
6301
+ 'assistant',
6302
+ event.content,
6303
+ {
6304
+ mode: 'agent',
6305
+ streamingReply: true,
6116
6306
  pending: true
6117
- });
6118
- }
6119
- if (event && event.type === 'content_delta' && typeof event.content === 'string') {
6120
- const now = Date.now();
6121
- const shouldPersist = !isPartialDelta
6122
- || now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
6123
- if (!streamingReplyMessageId) {
6124
- const streamingReplyMessage = appendWebSessionMessage(
6125
- state.webHistoryDir,
6126
- sessionRef,
6127
- 'assistant',
6128
- event.content,
6129
- {
6130
- mode: 'agent',
6131
- streamingReply: true,
6132
- pending: true
6133
- }
6134
- );
6135
- streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
6136
- ? streamingReplyMessage.id
6137
- : '';
6138
- lastPartialPersistAt = now;
6139
- } else if (shouldPersist) {
6140
- patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
6141
- content: event.content,
6142
- pending: true
6143
- });
6144
- lastPartialPersistAt = now;
6145
6307
  }
6146
- }
6147
- if (isPartialDelta) {
6148
- lastStreamWriteAt = Date.now();
6149
- // 只发增量片段:前端在 content_chunk 上做追加,
6150
- // reset 表示换了一条 assistant 消息、从空白重新开始
6151
- sendNdjson(res, event.chunkReset === true
6152
- ? { type: 'content_chunk', text: '', reset: true }
6153
- : { type: 'content_chunk', text: event.chunk });
6154
- return;
6155
- }
6156
- emitStreamEvent(event, resolveWebStreamControlEventType(event), {
6157
- transportType: event && event.type ? event.type : '',
6158
- text: event && (event.text || event.content) ? String(event.text || event.content) : ''
6308
+ );
6309
+ streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
6310
+ ? streamingReplyMessage.id
6311
+ : '';
6312
+ lastPartialPersistAt = now;
6313
+ } else if (shouldPersist) {
6314
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
6315
+ content: event.content,
6316
+ pending: true
6159
6317
  });
6318
+ lastPartialPersistAt = now;
6160
6319
  }
6320
+ }
6321
+ if (isPartialDelta) {
6322
+ lastStreamWriteAt = Date.now();
6323
+ // 只发增量片段:前端在 content_chunk 上做追加,
6324
+ // reset 表示换了一条 assistant 消息、从空白重新开始
6325
+ sendNdjson(res, event.chunkReset === true
6326
+ ? { type: 'content_chunk', text: '', reset: true }
6327
+ : { type: 'content_chunk', text: event.chunk });
6328
+ return;
6329
+ }
6330
+ emitStreamEvent(event, resolveWebStreamControlEventType(event), {
6331
+ transportType: event && event.type ? event.type : '',
6332
+ text: event && (event.text || event.content) ? String(event.text || event.content) : ''
6161
6333
  });
6334
+
6335
+ };
6336
+ // 域名白名单下被过滤代理拦下的新域名:对话进行中立即提示一次
6337
+ const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }));
6338
+ try {
6339
+ if (deniedNotifier) deniedNotifier.start();
6340
+ let result;
6341
+ try {
6342
+ result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
6343
+ agentProgram: agentMeta.agentProgram,
6344
+ // 复用路由入口占好的那把锁,而不是另起一份运行登记
6345
+ runState,
6346
+ onEvent: handleStreamEvent
6347
+ });
6348
+ } finally {
6349
+ if (deniedNotifier) deniedNotifier.stop();
6350
+ }
6162
6351
  ctx.logger.info('agent turn finished', {
6163
6352
  session: turnSessionKey,
6164
6353
  durationMs: Date.now() - turnStartedAt,
@@ -6321,6 +6510,26 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6321
6510
  }
6322
6511
  ];
6323
6512
 
6513
+ routes.push(...createContainerManageRoutes({
6514
+ ctx,
6515
+ state,
6516
+ req,
6517
+ res,
6518
+ deps: {
6519
+ sendJson,
6520
+ readJsonBody,
6521
+ decodeSessionName,
6522
+ getNetworkManager: () => getWebNetworkManager(ctx, state),
6523
+ getPortForwarder: () => getWebPortForwarder(ctx, state),
6524
+ rememberContainerName: (id, name) => {
6525
+ if (!state.containerNames) state.containerNames = new Map();
6526
+ state.containerNames.set(id, name);
6527
+ },
6528
+ ensureWebContainer,
6529
+ execCommandInWebContainer
6530
+ }
6531
+ }));
6532
+
6324
6533
  for (const route of routes) {
6325
6534
  if (route.method !== req.method) {
6326
6535
  continue;
@@ -6370,6 +6579,8 @@ async function startWebServer(options) {
6370
6579
  contModeArgs: options.contModeArgs,
6371
6580
  containerExtraArgs: options.containerExtraArgs,
6372
6581
  containerEnvs: options.containerEnvs,
6582
+ networkManager: options.networkManager,
6583
+ envFiles: options.envFiles,
6373
6584
  containerVolumes: options.containerVolumes,
6374
6585
  containerPorts: options.containerPorts,
6375
6586
  validateHostPath: options.validateHostPath,
@@ -6598,7 +6809,7 @@ async function startWebServer(options) {
6598
6809
  console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${listenPort}${NC}`);
6599
6810
  console.log(`${CYAN}提示: 左侧是 manyoyo 容器与 AGENT 列表,右侧是聊天/终端/文件/详情/配置/检查工作台。${NC}`);
6600
6811
  if (ctx.serverHost === '0.0.0.0') {
6601
- console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 IP 访问。${NC}`);
6812
+ console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 / 公网 IP 访问。${NC}`);
6602
6813
  }
6603
6814
  console.log(`${CYAN}🔐 登录用户名: ${YELLOW}${ctx.authUser}${NC}`);
6604
6815
  if (ctx.authPassAuto) {
@@ -6617,6 +6828,7 @@ async function startWebServer(options) {
6617
6828
  });
6618
6829
 
6619
6830
  startStaleSetupCleanup(ctx);
6831
+ startContainerNetworkWatch(ctx, state);
6620
6832
  if (ctx.autoPullImage) {
6621
6833
  startWebImagePrepare(ctx, state);
6622
6834
  }
@@ -6628,6 +6840,11 @@ async function startWebServer(options) {
6628
6840
  port: listenPort,
6629
6841
  close: () => new Promise(resolve => {
6630
6842
  ctx.logger.info('web server closing');
6843
+ if (state.networkWatch) {
6844
+ state.networkWatch.stop();
6845
+ state.networkWatch = null;
6846
+ }
6847
+ if (state.portForwarder) state.portForwarder.closeAll();
6631
6848
  for (const session of state.terminalSessions.values()) {
6632
6849
  const ptyProcess = session && session.ptyProcess;
6633
6850
  if (ptyProcess && !ptyProcess.killed) {