@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/README.md +1 -1
- package/bin/manyoyo.js +109 -48
- package/docker/manyoyo.Dockerfile +41 -8
- package/docker/res/agent/update-agents.sh +1 -1
- package/docker/res/playwright/browser.json +9 -12
- package/docker/res/playwright/chrome-policy.json +3 -0
- package/docker/res/playwright/env +3 -0
- package/docker/res/playwright/playwright-cli.sh +5 -0
- package/docker/res/playwright/stealth.init.js +11 -9
- package/lib/container-exec.js +132 -0
- package/lib/container-init.sh +59 -0
- package/lib/container-manage-options.js +35 -0
- package/lib/container-network.js +526 -0
- package/lib/container-run.js +24 -3
- package/lib/container-state.js +488 -0
- package/lib/egress-proxy.js +298 -0
- package/lib/env-text.js +62 -0
- package/lib/network-endpoints.js +99 -0
- package/lib/network-policy.js +265 -0
- package/lib/plugin/fingerprint.js +65 -50
- package/lib/plugin/playwright-extensions.js +0 -9
- package/lib/plugin/playwright-server.js +22 -2
- package/lib/plugin/playwright.js +55 -16
- package/lib/port-forward.js +135 -0
- package/lib/web/AGENTS.md +4 -0
- package/lib/web/container-manage.js +450 -0
- package/lib/web/index.html +81 -77
- package/lib/web/server.js +303 -76
- package/manyoyo.example.json +20 -2
- package/package.json +13 -8
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 =
|
|
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
|
-
|
|
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(
|
|
2525
|
+
throw new Error(`环境变量文件仅支持绝对路径: ${filePath}`);
|
|
2500
2526
|
}
|
|
2501
2527
|
if (!fs.existsSync(resolvedPath)) {
|
|
2502
|
-
throw new Error(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2900
|
-
|
|
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
|
|
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 =
|
|
3419
|
-
ctx
|
|
3420
|
-
|
|
3421
|
-
{
|
|
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
|
|
3487
|
-
ctx
|
|
3488
|
-
|
|
3489
|
-
{
|
|
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 =
|
|
3825
|
-
ctx
|
|
3826
|
-
|
|
3827
|
-
{
|
|
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
|
|
4188
|
-
|
|
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
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
'/bin/bash',
|
|
4640
|
-
|
|
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) {
|
package/manyoyo.example.json
CHANGED
|
@@ -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":
|
|
102
|
+
// 是否禁用 WebRTC(默认 true:否则 STUN 会暴露本机公网 IP;需要视频会议类网站时设为 false)
|
|
103
|
+
"disableWebRTC": true,
|
|
86
104
|
// chrome 模式读取的 DevToolsActivePort 文件;留空时自动探测常见路径
|
|
87
105
|
"devtoolsActivePortPath": "",
|
|
88
106
|
// playwright ext-download 的 CRX prodversion 参数
|