@xcanwin/manyoyo 8.3.3 → 8.4.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.
package/lib/web/server.js CHANGED
@@ -9,9 +9,19 @@ 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 } = 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 { createEgressProxy, REALM: EGRESS_REALM } = require('../egress-proxy');
21
+ const { createPortForwarder } = require('../port-forward');
22
+ const { createContainerManageRoutes, pendingRisks } = require('./container-manage');
23
+ const { resolveManageOptions } = require('../container-manage-options');
24
+ const { defaultPolicy } = require('../network-policy');
15
25
  const { normalizeMirrors } = require('../runtime-normalizers');
16
26
  const { estimateContainerCapacity } = require('../capacity');
17
27
  const { extractAgentMessageFromCodexJsonl } = require('../codex-output');
@@ -1188,6 +1198,21 @@ function accumulateUsageTotal(baseline, turnUsage) {
1188
1198
  };
1189
1199
  }
1190
1200
 
1201
+ // 所有进用户容器的 exec 都经这里:env 从状态目录现读(见 lib/container-exec.js),子进程结束后删临时 env 文件
1202
+ function spawnContainerExec(ctx, containerName, execOptions, spawnOptions) {
1203
+ const exec = buildExecArgs({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, containerName, execOptions);
1204
+ let child;
1205
+ try {
1206
+ child = spawn(ctx.dockerCmd, exec.args, { ...spawnOptions, env: ctx.dockerEnv });
1207
+ } catch (e) {
1208
+ exec.cleanup();
1209
+ throw e;
1210
+ }
1211
+ child.once('close', exec.cleanup);
1212
+ child.once('error', exec.cleanup);
1213
+ return child;
1214
+ }
1215
+
1191
1216
  // 起一个短命的容器内子进程,通过对应协议问一次元信息(初始化握手 / model 列表),拿到结果就杀掉进程——
1192
1217
  // 不是长驻会话,不会产生真实对话轮次或费用。
1193
1218
  function runAgentProtocolProcess(ctx, containerName, command, options = {}) {
@@ -1200,7 +1225,7 @@ function runAgentProtocolProcess(ctx, containerName, command, options = {}) {
1200
1225
  let stdoutAll = '';
1201
1226
  let child;
1202
1227
  try {
1203
- child = spawn(ctx.dockerCmd, ['exec', '-i', containerName, '/bin/bash', '-lc', command], { stdio: ['pipe', 'pipe', 'pipe'], env: ctx.dockerEnv });
1228
+ child = spawnContainerExec(ctx, containerName, { interactive: true, command: ['/bin/bash', '-lc', command] }, { stdio: ['pipe', 'pipe', 'pipe'] });
1204
1229
  } catch (e) {
1205
1230
  resolve({ result: null, stdout: '' });
1206
1231
  return;
@@ -2493,40 +2518,16 @@ function normalizeStringArray(value, sourceLabel) {
2493
2518
  .filter(Boolean);
2494
2519
  }
2495
2520
 
2496
- function parseEnvFileToArgs(filePath) {
2521
+ // 环境变量文件:只解析路径并检查存在,内容每次 exec 现读(见 lib/container-state.js 的 readEnvFiles)
2522
+ function resolveEnvFilePath(filePath) {
2497
2523
  const resolvedPath = expandHomeAliasPath(filePath);
2498
2524
  if (!path.isAbsolute(resolvedPath)) {
2499
- throw new Error(`envFile 仅支持绝对路径: ${filePath}`);
2525
+ throw new Error(`环境变量文件仅支持绝对路径: ${filePath}`);
2500
2526
  }
2501
2527
  if (!fs.existsSync(resolvedPath)) {
2502
- throw new Error(`未找到环境文件: ${resolvedPath}`);
2528
+ throw new Error(`未找到环境变量文件: ${resolvedPath}`);
2503
2529
  }
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;
2530
+ return resolvedPath;
2530
2531
  }
2531
2532
 
2532
2533
  function resolveContainerModeArgs(mode) {
@@ -2693,7 +2694,7 @@ function buildConfigDefaults(ctx, config) {
2693
2694
  const defaults = {
2694
2695
  containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
2695
2696
  hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath, ctx.hostPath)),
2696
- containerPath: pickFirstString(parsed.containerPath, ctx.containerPath),
2697
+ containerPath: pickFirstString(parsed.containerPath),
2697
2698
  imageName: pickFirstString(parsed.imageName, ctx.imageName),
2698
2699
  imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
2699
2700
  containerMode: hasOwn(parsed, 'containerMode') ? String(parsed.containerMode || '') : '',
@@ -2747,6 +2748,7 @@ function buildStaticContainerRuntime(ctx, containerName) {
2747
2748
  contModeArgs: Array.isArray(ctx.contModeArgs) ? ctx.contModeArgs.slice() : [],
2748
2749
  containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
2749
2750
  containerEnvs: Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [],
2751
+ envFiles: Array.isArray(ctx.envFiles) ? ctx.envFiles.slice() : [],
2750
2752
  containerVolumes: Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [],
2751
2753
  containerPorts: Array.isArray(ctx.containerPorts) ? ctx.containerPorts.slice() : [],
2752
2754
  defaultCommand: buildDefaultCommand(ctx.execCommandPrefix, ctx.execCommand, ctx.execCommandSuffix) || '/bin/bash'
@@ -2797,7 +2799,9 @@ function buildCreateRuntime(ctx, state, payload) {
2797
2799
  defaults: {
2798
2800
  hostPath: ctx.hostPath,
2799
2801
  containerName: `my-${ctx.formatDate()}`,
2800
- containerPath: ctx.containerPath,
2802
+ // 不用 ctx.containerPath:那是 serve 进程的启动目录(CLI 的 cwd 语义),与用户要挂载的目录无关;
2803
+ // 没配置也没传时落到下面的 `|| hostPath`,容器内路径与宿主机路径一致
2804
+ containerPath: '',
2801
2805
  imageName: ctx.imageName,
2802
2806
  imageVersion: ctx.imageVersion
2803
2807
  },
@@ -2818,6 +2822,12 @@ function buildCreateRuntime(ctx, state, payload) {
2818
2822
  parseServerListen: () => ({ host: '', port: 0 })
2819
2823
  });
2820
2824
 
2825
+ // 自启动与网络:请求 > runs.<name> > 全局配置;请求里放开限制(open / 非本机绑定)要带 confirmRisk
2826
+ const manage = resolveManageOptions({ requestOptions, runConfig, globalConfig: config });
2827
+ if (manage.fromRequest && requestOptions.confirmRisk !== true && pendingRisks(defaultPolicy(), manage.network).length) {
2828
+ throw new Error('该网络设置会放开限制(open 预设或绑定非本机地址),需要确认(confirmRisk)');
2829
+ }
2830
+
2821
2831
  const containerName = resolvedBase.containerName;
2822
2832
  validateContainerNameStrict(containerName);
2823
2833
 
@@ -2886,6 +2896,7 @@ function buildCreateRuntime(ctx, state, payload) {
2886
2896
  const resumeSupported = Boolean(buildAgentResumeCommand(agentProgram));
2887
2897
 
2888
2898
  let containerEnvs = Array.isArray(ctx.containerEnvs) ? ctx.containerEnvs.slice() : [];
2899
+ let envFiles = Array.isArray(ctx.envFiles) ? ctx.envFiles.slice() : [];
2889
2900
  const hasRunEnv = hasOwn(runConfig, 'env');
2890
2901
  const hasRunEnvFile = hasOwn(runConfig, 'envFile');
2891
2902
  if (hasRequestEnv || hasRequestEnvFile || hasRunEnv || hasRunEnvFile || hasConfigEnv || hasConfigEnvFile) {
@@ -2896,13 +2907,8 @@ function buildCreateRuntime(ctx, state, payload) {
2896
2907
  envArgs.push('--env', `${parsed.key}=${parsed.value}`);
2897
2908
  });
2898
2909
 
2899
- const envFileList = resolvedBase.envFile;
2900
- const envFileArgs = [];
2901
- envFileList.forEach(filePath => {
2902
- envFileArgs.push(...parseEnvFileToArgs(filePath));
2903
- });
2904
-
2905
- containerEnvs = [...envArgs, ...envFileArgs];
2910
+ containerEnvs = envArgs;
2911
+ envFiles = resolvedBase.envFile.map(resolveEnvFilePath);
2906
2912
  }
2907
2913
 
2908
2914
  let containerVolumes = Array.isArray(ctx.containerVolumes) ? ctx.containerVolumes.slice() : [];
@@ -2934,8 +2940,11 @@ function buildCreateRuntime(ctx, state, payload) {
2934
2940
  contModeArgs,
2935
2941
  containerExtraArgs: Array.isArray(ctx.containerExtraArgs) ? ctx.containerExtraArgs.slice() : [],
2936
2942
  containerEnvs,
2943
+ envFiles,
2937
2944
  containerVolumes,
2938
2945
  containerPorts,
2946
+ autostart: manage.autostart,
2947
+ network: manage.network,
2939
2948
  agentPromptCommand,
2940
2949
  defaultCommand: buildDefaultCommand(shellPrefix, shell, shellSuffix) || '/bin/bash',
2941
2950
  // 仅供服务端内部复用(如"创建相同配置容器"/"复制容器"),绝不能透传给任何对外响应字段,
@@ -2954,7 +2963,10 @@ function buildCreateRuntime(ctx, state, payload) {
2954
2963
  env: resolvedBase.env || {},
2955
2964
  envFile: resolvedBase.envFile || [],
2956
2965
  volumes: resolvedBase.volumes || [],
2957
- ports: resolvedBase.ports || []
2966
+ ports: resolvedBase.ports || [],
2967
+ autostart: manage.autostart,
2968
+ // 复制容器时端口暴露(宿主机端口会冲突)与 peers(按 id 引用别的容器)不带过去
2969
+ network: { ...manage.network, expose: [], peers: { inbound: [] } }
2958
2970
  },
2959
2971
  applied: {
2960
2972
  containerName,
@@ -2972,6 +2984,8 @@ function buildCreateRuntime(ctx, state, payload) {
2972
2984
  resumeSupported,
2973
2985
  yolo: yolo || '',
2974
2986
  envCount: Math.floor(containerEnvs.length / 2),
2987
+ autostartEnabled: Boolean(manage.autostart.trim()),
2988
+ networkPreset: manage.network.preset,
2975
2989
  volumeCount: Math.floor(containerVolumes.length / 2),
2976
2990
  portCount: Math.floor(containerPorts.length / 2)
2977
2991
  }
@@ -3015,7 +3029,11 @@ function buildCloneCreateOptionsFromSnapshot(runtimeSnapshot, fallbackHistory) {
3015
3029
  env: runtimeSnapshot.env || {},
3016
3030
  envFile: runtimeSnapshot.envFile || [],
3017
3031
  volumes: runtimeSnapshot.volumes || [],
3018
- ports: runtimeSnapshot.ports || []
3032
+ ports: runtimeSnapshot.ports || [],
3033
+ autostart: runtimeSnapshot.autostart || '',
3034
+ network: runtimeSnapshot.network || undefined,
3035
+ // 快照来自服务端、原容器创建时已确认过,复制不必再确认
3036
+ confirmRisk: runtimeSnapshot.network ? true : undefined
3019
3037
  };
3020
3038
  }
3021
3039
 
@@ -3295,13 +3313,139 @@ async function cleanupStaleSetupTestContainers(ctx) {
3295
3313
  const names = String(listed || '').split('\n').map(line => line.trim())
3296
3314
  .filter(name => name.startsWith(SETUP_TEST_CONTAINER_PREFIX) && /^[A-Za-z0-9_.-]+$/.test(name));
3297
3315
  for (const name of names) {
3316
+ const stateId = String(await ctx.dockerExecAsync(
3317
+ ['inspect', '-f', '{{index .Config.Labels "manyoyo.id"}}', name], { timeout: 10000 }
3318
+ ).catch(() => '') || '').trim();
3298
3319
  await ctx.dockerExecAsync(['rm', '-f', name], { timeout: 20000 }).catch(() => {});
3320
+ if (stateId) containerState.removeState(ctx.homeDir || os.homedir(), stateId);
3299
3321
  }
3300
3322
  } catch (e) {
3301
3323
  ctx.logger.warn('cleanup stale setup test containers failed', { message: e && e.message });
3302
3324
  }
3303
3325
  }
3304
3326
 
3327
+ // 过滤代理固定端口:容器里的代理地址写在各自的 managed.env 里,serve 重启后端口不能变
3328
+ const EGRESS_PROXY_PORT = 8936;
3329
+
3330
+ function probeEgressProxy(port) {
3331
+ return new Promise(resolve => {
3332
+ const req = http.request({ host: '127.0.0.1', port, method: 'GET', path: 'http://manyoyo.invalid/', timeout: 3000 }, res => {
3333
+ res.resume();
3334
+ resolve(res.statusCode === 407 && String(res.headers['proxy-authenticate'] || '').includes(EGRESS_REALM));
3335
+ });
3336
+ req.on('error', () => resolve(false));
3337
+ req.on('timeout', () => { req.destroy(); resolve(false); });
3338
+ req.end();
3339
+ });
3340
+ }
3341
+
3342
+ // 过滤代理(域名白名单的强制点):有 allowlist 容器时才启动;同机另一个 serve 已经提供就复用它
3343
+ function ensureWebEgressProxy(ctx, state) {
3344
+ if (!state.egressProxyPromise) {
3345
+ const homeDir = ctx.homeDir || os.homedir();
3346
+ // 每个 CONNECT 都读策略 / token:加 2 秒缓存,保存策略后最多 2 秒生效
3347
+ const cache = new Map();
3348
+ const cached = (key, load) => {
3349
+ const hit = cache.get(key);
3350
+ if (hit && Date.now() - hit.at < 2000) return hit.value;
3351
+ const value = load();
3352
+ cache.set(key, { at: Date.now(), value });
3353
+ return value;
3354
+ };
3355
+ const proxy = createEgressProxy({
3356
+ upstream: process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || '',
3357
+ getToken: id => cached(`t:${id}`, () => containerState.readEgressToken(homeDir, id)),
3358
+ getPolicy: id => cached(`p:${id}`, () => {
3359
+ try {
3360
+ const policy = normalizePolicy(containerState.readNetworkRaw(homeDir, id) || undefined);
3361
+ return { preset: policy.preset, domains: policy.egress.domains, rules: policy.egress.rules };
3362
+ } catch (e) {
3363
+ return null;
3364
+ }
3365
+ })
3366
+ });
3367
+ state.egressProxyPromise = proxy.start({ host: '0.0.0.0', port: EGRESS_PROXY_PORT })
3368
+ .then(port => { state.egressProxy = proxy; return { port }; })
3369
+ .catch(async error => {
3370
+ if (error.code === 'EADDRINUSE' && await probeEgressProxy(EGRESS_PROXY_PORT)) return { port: EGRESS_PROXY_PORT };
3371
+ state.egressProxyPromise = null;
3372
+ throw new Error(error.code === 'EADDRINUSE' ? `过滤代理端口 ${EGRESS_PROXY_PORT} 被其他程序占用` : error.message);
3373
+ });
3374
+ }
3375
+ return state.egressProxyPromise;
3376
+ }
3377
+
3378
+ function getWebPortForwarder(ctx, state) {
3379
+ if (!state.portForwarder) {
3380
+ const homeDir = ctx.homeDir || os.homedir();
3381
+ state.portForwarder = createPortForwarder({
3382
+ command: ctx.dockerCmd,
3383
+ env: ctx.dockerEnv,
3384
+ homeDir,
3385
+ // id → 当前容器名:只信运行时里真实存在的(serve 启动时与接口调用时登记),meta.json 里的旧名字可能已被同名新容器占用
3386
+ resolveName: id => (state.containerNames && state.containerNames.get(id)) || '',
3387
+ warn: message => ctx.logger.warn(message)
3388
+ });
3389
+ }
3390
+ return state.portForwarder;
3391
+ }
3392
+
3393
+ // 运行时就绪后的容器服务:规则对账 + start 事件订阅(外部重启后补下发)、serve 启动时拉起标记了“自动拉起”的容器、
3394
+ // 恢复端口暴露。没有 watchStarts 的替身管理器(单测)不启动任何后台服务。
3395
+ async function startContainerServices(ctx, state) {
3396
+ const manager = getWebNetworkManager(ctx, state);
3397
+ const homeDir = ctx.homeDir || os.homedir();
3398
+ sweepStaleExecEnvFiles(homeDir);
3399
+ state.networkWatch = manager.watchStarts();
3400
+ const forwarder = getWebPortForwarder(ctx, state);
3401
+ // 以运行时里真实存在的容器为准(按 id 对应名字);状态目录里有、容器却没有的是孤儿,既不拉起也不转发
3402
+ state.containerNames = new Map((await manager.listManaged()).map(item => [item.id, item.name]));
3403
+ for (const id of containerState.listStateIds(homeDir)) {
3404
+ const name = state.containerNames.get(id);
3405
+ if (!name) continue;
3406
+ let policy;
3407
+ try { policy = normalizePolicy(containerState.readNetworkRaw(homeDir, id) || undefined); } catch (e) { continue; }
3408
+ if (policy.autostartOnServe) {
3409
+ try {
3410
+ if (ctx.getContainerStatus(name) !== 'running') await ctx.dockerExecAsync(['start', name], { timeout: 30000 });
3411
+ } catch (e) {
3412
+ ctx.logger.warn('autostart container failed', { name, message: e && e.message });
3413
+ }
3414
+ }
3415
+ if (policy.expose.length) await forwarder.sync(id, policy.expose);
3416
+ }
3417
+ }
3418
+
3419
+ // serve 异常退出时 exec 的临时 env 文件(明文)可能残留:启动时清掉超过 6 小时的(正在跑的长任务不会这么久还没读完它)
3420
+ function sweepStaleExecEnvFiles(homeDir) {
3421
+ const dir = path.join(homeDir, '.manyoyo', 'tmp');
3422
+ try {
3423
+ for (const entry of fs.readdirSync(dir)) {
3424
+ if (!/^(exec|env)-/.test(entry)) continue;
3425
+ const file = path.join(dir, entry);
3426
+ if (Date.now() - fs.statSync(file).mtimeMs > 6 * 3600 * 1000) fs.rmSync(file, { force: true });
3427
+ }
3428
+ } catch (e) { /* 没有目录 */ }
3429
+ }
3430
+
3431
+ // 运行时就绪后启动 startContainerServices
3432
+ function startContainerNetworkWatch(ctx, state) {
3433
+ const manager = getWebNetworkManager(ctx, state);
3434
+ if (typeof manager.watchStarts !== 'function' || state.networkWatch || state.containerServicesStarting) return;
3435
+ state.containerServicesStarting = true;
3436
+ const deadline = Date.now() + 10 * 60 * 1000;
3437
+ const tick = () => {
3438
+ const status = ctx.runtimeState.status;
3439
+ if (status === 'ready') {
3440
+ startContainerServices(ctx, state).catch(e => ctx.logger.warn('container services failed', { message: e && e.message }));
3441
+ return;
3442
+ }
3443
+ if (status === 'failed' || Date.now() > deadline) return;
3444
+ setTimeout(tick, 1000).unref();
3445
+ };
3446
+ tick();
3447
+ }
3448
+
3305
3449
  function startStaleSetupCleanup(ctx) {
3306
3450
  const deadline = Date.now() + 10 * 60 * 1000;
3307
3451
  const tick = () => {
@@ -3347,6 +3491,25 @@ async function buildWebPlaywrightIntegration(ctx, state, runtime) {
3347
3491
  return mergeIntegration(runtime, integration);
3348
3492
  }
3349
3493
 
3494
+ // serve 进程的网络规则下发器(一份,懒创建)
3495
+ function getWebNetworkManager(ctx, state) {
3496
+ if (ctx.networkManager) return ctx.networkManager;
3497
+ if (!state.networkManager) {
3498
+ state.networkManager = createNetworkManager({
3499
+ command: ctx.dockerCmd,
3500
+ env: ctx.dockerEnv,
3501
+ homeDir: ctx.homeDir || os.homedir(),
3502
+ imageRef: () => `${ctx.imageName}:${ctx.imageVersion}`,
3503
+ getMirrorUrls: () => Object.values(readWebMirrors(state)).filter(Boolean),
3504
+ getFilterProxy: () => ensureWebEgressProxy(ctx, state),
3505
+ warn: message => ctx.logger.warn(message)
3506
+ });
3507
+ }
3508
+ return state.networkManager;
3509
+ }
3510
+
3511
+ // 进入容器前一律检查网络规则(宿主机侧的“本次启动已下发”状态):文件浏览也会 exec /bin/bash -lc,
3512
+ // 里面的 .bashrc 等是 Agent 能改的代码,不能在规则缺失的窗口里跑
3350
3513
  async function ensureWebContainer(ctx, state, containerInput, messageSessionRef = null) {
3351
3514
  const runtime = typeof containerInput === 'string'
3352
3515
  ? buildStaticContainerRuntime(ctx, containerInput)
@@ -3358,8 +3521,27 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3358
3521
 
3359
3522
  if (!ctx.containerExists(runtime.containerName)) {
3360
3523
  const mirrors = readWebMirrors(state);
3524
+ // 用户 env 不进容器配置,写进状态目录的 box/env,每次 exec 现读(见 lib/container-exec.js)
3525
+ const userEnvLines = containerState.userEnvLines(runtime.containerEnvs);
3361
3526
  const playwright = await buildWebPlaywrightIntegration(ctx, state, runtime);
3527
+ const containerEnvs = containerState.stripEnvKeys(playwright.containerEnvs, new Set(userEnvLines.map(containerState.envLineKey)));
3528
+ const networkPolicy = await withEnvEndpoints(normalizePolicy(runtime.network), [...userEnvLines, ...containerState.readEnvFilePaths(runtime.envFiles || []).flatMap(file => file.entries.map(entry => `${entry.key}=${entry.value}`))]);
3529
+ const networkManager = getWebNetworkManager(ctx, state);
3530
+ if (!hasNetworkArg([...(runtime.contModeArgs || []), ...(playwright.containerExtraArgs || [])])) {
3531
+ await networkManager.ensureBridgeNetwork();
3532
+ }
3533
+ const containerStateInfo = containerState.createState({
3534
+ homeDir: ctx.homeDir || os.homedir(),
3535
+ envLines: userEnvLines,
3536
+ envFiles: runtime.envFiles,
3537
+ autostart: runtime.autostart,
3538
+ network: networkPolicy,
3539
+ netRequired: networkPolicy.preset !== 'open',
3540
+ meta: { name: runtime.containerName }
3541
+ });
3362
3542
  const args = buildContainerRunArgs({
3543
+ state: containerStateInfo,
3544
+ defaultNetwork: NETWORK_NAME,
3363
3545
  mirrors,
3364
3546
  containerName: runtime.containerName,
3365
3547
  hostPath: runtime.hostPath,
@@ -3368,22 +3550,31 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3368
3550
  imageVersion: runtime.imageVersion,
3369
3551
  contModeArgs: runtime.contModeArgs,
3370
3552
  containerExtraArgs: playwright.containerExtraArgs,
3371
- containerEnvs: playwright.containerEnvs,
3553
+ containerEnvs,
3372
3554
  containerVolumes: playwright.containerVolumes,
3373
3555
  containerPorts: runtime.containerPorts,
3374
3556
  defaultCommand: runtime.defaultCommand
3375
3557
  });
3376
3558
 
3377
- await ensureWebImage(ctx, state, webImageRef(ctx, runtime), { failFast: true });
3378
-
3379
3559
  try {
3560
+ await ensureWebImage(ctx, state, webImageRef(ctx, runtime), { failFast: true });
3380
3561
  ctx.dockerExecArgs(args, { stdio: 'pipe' });
3381
3562
  } catch (e) {
3563
+ containerState.removeState(ctx.homeDir || os.homedir(), containerStateInfo.id);
3382
3564
  ctx.showImagePullHint(e);
3383
3565
  throw e;
3384
3566
  }
3385
3567
 
3386
3568
  await ctx.waitForContainerReady(runtime.containerName, { throwOnFailure: true });
3569
+ // 先下发网络规则再做任何 exec;失败即关闭(抛错,容器留着,init 在等门闩)
3570
+ await networkManager.apply(runtime.containerName, { expectId: containerStateInfo.id });
3571
+ // 创建时填的端口暴露:容器已就绪、规则已下发后立即开始转发;端口被占等失败只警告,网络页里能看到没有生效
3572
+ if (networkPolicy.expose.length) {
3573
+ if (!state.containerNames) state.containerNames = new Map();
3574
+ state.containerNames.set(containerStateInfo.id, runtime.containerName);
3575
+ const failures = await getWebPortForwarder(ctx, state).sync(containerStateInfo.id, networkPolicy.expose);
3576
+ failures.forEach(message => console.warn(message));
3577
+ }
3387
3578
  applyAptMirror({
3388
3579
  dockerExecArgs: ctx.dockerExecArgs,
3389
3580
  containerName: runtime.containerName,
@@ -3409,16 +3600,18 @@ async function ensureWebContainer(ctx, state, containerInput, messageSessionRef
3409
3600
  `容器 ${runtime.containerName} 已启动。`
3410
3601
  );
3411
3602
  }
3603
+ await getWebNetworkManager(ctx, state).ensureReady(runtime.containerName);
3412
3604
  }
3413
3605
 
3414
3606
  async function execCommandInWebContainer(ctx, containerName, command, options = {}) {
3415
3607
  const opts = options && typeof options === 'object' ? options : {};
3416
3608
  const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
3417
3609
  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 }
3610
+ const process = spawnContainerExec(
3611
+ ctx,
3612
+ containerName,
3613
+ { withEnv: opts.withEnv !== false, command: ['/bin/bash', '-lc', command] },
3614
+ { stdio: ['ignore', 'pipe', 'pipe'] }
3422
3615
  );
3423
3616
 
3424
3617
  const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
@@ -3483,10 +3676,11 @@ __MANYOYO_NODE__`;
3483
3676
  }
3484
3677
 
3485
3678
  function spawnContainerRawFileStream(ctx, containerName, filePath) {
3486
- return spawn(
3487
- ctx.dockerCmd,
3488
- ['exec', containerName, 'cat', '--', filePath],
3489
- { stdio: ['ignore', 'pipe', 'pipe'], env: ctx.dockerEnv }
3679
+ return spawnContainerExec(
3680
+ ctx,
3681
+ containerName,
3682
+ { withEnv: false, command: ['cat', '--', filePath] },
3683
+ { stdio: ['ignore', 'pipe', 'pipe'] }
3490
3684
  );
3491
3685
  }
3492
3686
 
@@ -3516,7 +3710,7 @@ function inferFileLanguage(filePath) {
3516
3710
  }
3517
3711
 
3518
3712
  async function execJsonCommandInWebContainer(ctx, containerName, command) {
3519
- const result = await execCommandInWebContainer(ctx, containerName, command);
3713
+ const result = await execCommandInWebContainer(ctx, containerName, command, { withEnv: false });
3520
3714
  if (result.exitCode !== 0) {
3521
3715
  throw new Error(result.output || '容器命令执行失败');
3522
3716
  }
@@ -3821,10 +4015,11 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
3821
4015
  : reserveWebAgentRun(state, sessionRef);
3822
4016
  let process;
3823
4017
  try {
3824
- process = spawn(
3825
- ctx.dockerCmd,
3826
- ['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
3827
- { stdio: ['ignore', 'pipe', 'pipe'], env: ctx.dockerEnv }
4018
+ process = spawnContainerExec(
4019
+ ctx,
4020
+ sessionRef.containerName,
4021
+ { command: ['/bin/bash', '-lc', command] },
4022
+ { stdio: ['ignore', 'pipe', 'pipe'] }
3828
4023
  );
3829
4024
  } catch (e) {
3830
4025
  // spawn 同步抛错(如 dockerCmd 不存在)时必须释放锁,否则这个容器永远发不出消息
@@ -4184,9 +4379,14 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
4184
4379
  const workpath = getSetupWorkpath(state);
4185
4380
  fs.mkdirSync(workpath, { recursive: true });
4186
4381
  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}`));
4382
+ const testHome = ctx.homeDir || os.homedir();
4383
+ const containerStateInfo = containerState.createState({
4384
+ homeDir: testHome,
4385
+ envLines: Object.entries(envMap).map(([key, value]) => `${key}=${value}`),
4386
+ meta: { name: containerName }
4387
+ });
4189
4388
  const args = buildContainerRunArgs({
4389
+ state: containerStateInfo,
4190
4390
  containerName,
4191
4391
  hostPath: workpath,
4192
4392
  containerPath: '/workspace',
@@ -4194,7 +4394,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
4194
4394
  imageVersion: ctx.imageVersion,
4195
4395
  contModeArgs: [],
4196
4396
  containerExtraArgs: [],
4197
- containerEnvs,
4397
+ containerEnvs: [],
4198
4398
  containerVolumes: [],
4199
4399
  containerPorts: [],
4200
4400
  defaultCommand: '/bin/bash'
@@ -4220,6 +4420,7 @@ async function runSetupConnectionTest(ctx, state, agent, envMap) {
4220
4420
  } catch (e) {
4221
4421
  // 容器可能根本没创建成功
4222
4422
  }
4423
+ containerState.removeState(testHome, containerStateInfo.id);
4223
4424
  }
4224
4425
  }
4225
4426
 
@@ -4628,20 +4829,16 @@ function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
4628
4829
 
4629
4830
  const termValue = process.env.TERM && process.env.TERM !== 'dumb' ? process.env.TERM : 'xterm-256color';
4630
4831
  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 });
4832
+ return spawnContainerExec(ctx, containerName, {
4833
+ interactive: true,
4834
+ envs: [
4835
+ `TERM=${termValue}`,
4836
+ `COLORTERM=${colorTermValue}`,
4837
+ `MANYOYO_TERM_COLS=${String(cols)}`,
4838
+ `MANYOYO_TERM_ROWS=${String(rows)}`
4839
+ ],
4840
+ command: ['/bin/bash', '-lc', terminalBootstrap]
4841
+ }, { stdio: ['pipe', 'pipe', 'pipe'] });
4645
4842
  }
4646
4843
 
4647
4844
  function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
@@ -5209,6 +5406,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5209
5406
  ctx.retryRuntimeHeal();
5210
5407
  if (ctx.autoPullImage) startWebImagePrepare(ctx, state);
5211
5408
  startStaleSetupCleanup(ctx);
5409
+ startContainerNetworkWatch(ctx, state);
5212
5410
  }
5213
5411
  // 镜像拉取失败的条目会一直挂着:清掉后重新拉
5214
5412
  if (ctx.runtimeState.status === 'ready' && ctx.autoPullImage) {
@@ -6321,6 +6519,26 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6321
6519
  }
6322
6520
  ];
6323
6521
 
6522
+ routes.push(...createContainerManageRoutes({
6523
+ ctx,
6524
+ state,
6525
+ req,
6526
+ res,
6527
+ deps: {
6528
+ sendJson,
6529
+ readJsonBody,
6530
+ decodeSessionName,
6531
+ getNetworkManager: () => getWebNetworkManager(ctx, state),
6532
+ getPortForwarder: () => getWebPortForwarder(ctx, state),
6533
+ rememberContainerName: (id, name) => {
6534
+ if (!state.containerNames) state.containerNames = new Map();
6535
+ state.containerNames.set(id, name);
6536
+ },
6537
+ ensureWebContainer,
6538
+ execCommandInWebContainer
6539
+ }
6540
+ }));
6541
+
6324
6542
  for (const route of routes) {
6325
6543
  if (route.method !== req.method) {
6326
6544
  continue;
@@ -6370,6 +6588,8 @@ async function startWebServer(options) {
6370
6588
  contModeArgs: options.contModeArgs,
6371
6589
  containerExtraArgs: options.containerExtraArgs,
6372
6590
  containerEnvs: options.containerEnvs,
6591
+ networkManager: options.networkManager,
6592
+ envFiles: options.envFiles,
6373
6593
  containerVolumes: options.containerVolumes,
6374
6594
  containerPorts: options.containerPorts,
6375
6595
  validateHostPath: options.validateHostPath,
@@ -6598,7 +6818,7 @@ async function startWebServer(options) {
6598
6818
  console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${listenPort}${NC}`);
6599
6819
  console.log(`${CYAN}提示: 左侧是 manyoyo 容器与 AGENT 列表,右侧是聊天/终端/文件/详情/配置/检查工作台。${NC}`);
6600
6820
  if (ctx.serverHost === '0.0.0.0') {
6601
- console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 IP 访问。${NC}`);
6821
+ console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 / 公网 IP 访问。${NC}`);
6602
6822
  }
6603
6823
  console.log(`${CYAN}🔐 登录用户名: ${YELLOW}${ctx.authUser}${NC}`);
6604
6824
  if (ctx.authPassAuto) {
@@ -6617,6 +6837,7 @@ async function startWebServer(options) {
6617
6837
  });
6618
6838
 
6619
6839
  startStaleSetupCleanup(ctx);
6840
+ startContainerNetworkWatch(ctx, state);
6620
6841
  if (ctx.autoPullImage) {
6621
6842
  startWebImagePrepare(ctx, state);
6622
6843
  }
@@ -6628,6 +6849,12 @@ async function startWebServer(options) {
6628
6849
  port: listenPort,
6629
6850
  close: () => new Promise(resolve => {
6630
6851
  ctx.logger.info('web server closing');
6852
+ if (state.networkWatch) {
6853
+ state.networkWatch.stop();
6854
+ state.networkWatch = null;
6855
+ }
6856
+ if (state.portForwarder) state.portForwarder.closeAll();
6857
+ if (state.egressProxy) state.egressProxy.stop();
6631
6858
  for (const session of state.terminalSessions.values()) {
6632
6859
  const ptyProcess = session && session.ptyProcess;
6633
6860
  if (ptyProcess && !ptyProcess.killed) {
@@ -29,6 +29,24 @@
29
29
  "volumes": [],
30
30
  "ports": ["8888:8888"],
31
31
 
32
+ // 网络策略(新建容器时写入,Web「容器」页随时改,不需要重启容器)。不写 = 默认收紧:允许公网,
33
+ // 禁止宿主机其他端口、局域网 / 私有网段、云元数据与其他容器。
34
+ // preset:restricted(默认)/ allowlist(域名白名单,需要 serve 运行)/ open(不加规则)
35
+ "network": {
36
+ "preset": "restricted",
37
+ // 允许容器访问的宿主机端口(宿主机上监听 0.0.0.0 的服务)
38
+ "host": [],
39
+ // 直连放行的 IP 规则,例如局域网里的模型网关:{ "cidr": "192.168.1.50", "ports": "8000", "proto": "tcp" }
40
+ "egress": { "domains": [], "rules": [] },
41
+ // 端口暴露(需要 serve 运行):监听地址默认 127.0.0.1;绑 0.0.0.0 等于局域网 / 公网可见
42
+ "expose": []
43
+ },
44
+
45
+ // 容器每次启动(新建、重启、serve 拉起)都由容器内 init 执行一次的 bash 脚本;新建容器时写入,之后在 Web「容器」页随时改
46
+ "autostart": "",
47
+ // serve 启动时是否自动拉起此容器(配合 autostart 使用)
48
+ "autostartOnServe": false,
49
+
32
50
  // 仅首次创建容器时执行一次(创建后、常规 shell 前)
33
51
  "first": {
34
52
  // 仅支持绝对路径,如 /abs/path.env
@@ -81,8 +99,8 @@
81
99
  "timezoneId": "",
82
100
  // 强制 navigator.platform;留空则使用浏览器真实值(推荐,与 UA、字体、WebGL 自洽)
83
101
  "navigatorPlatform": "",
84
- // 是否禁用 WebRTC(默认 false)
85
- "disableWebRTC": false,
102
+ // 是否禁用 WebRTC(默认 true:否则 STUN 会暴露本机公网 IP;需要视频会议类网站时设为 false)
103
+ "disableWebRTC": true,
86
104
  // chrome 模式读取的 DevToolsActivePort 文件;留空时自动探测常见路径
87
105
  "devtoolsActivePortPath": "",
88
106
  // playwright ext-download 的 CRX prodversion 参数