@xcanwin/manyoyo 8.2.1 → 8.3.1

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.
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ (function () {
3
+ try {
4
+ const toolbar = 87;
5
+ const patch = (name, fallback, getter) => {
6
+ const original = Object.getOwnPropertyDescriptor(window, name);
7
+ if (!original || !original.get) return;
8
+ const real = original.get;
9
+ Object.defineProperty(window, name, { ...original, get: getter(function () { const value = real.call(this); return value === 0 ? fallback() : value; }) });
10
+ };
11
+ patch('outerWidth', () => window.innerWidth, fn => Object.getOwnPropertyDescriptor({ get outerWidth() { return fn.call(this); } }, 'outerWidth').get);
12
+ patch('outerHeight', () => window.innerHeight + toolbar, fn => Object.getOwnPropertyDescriptor({ get outerHeight() { return fn.call(this); } }, 'outerHeight').get);
13
+ } catch (_) {}
14
+ })();
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
3
6
  const { buildMirrorEnvArgs } = require('./mirrors');
4
7
 
5
8
  function sanitizeDefaultCommand(defaultCommand) {
@@ -26,6 +29,38 @@ function buildContainerRunArgs(options) {
26
29
  return args;
27
30
  }
28
31
 
32
+ /**
33
+ * 把命令行里的 `--env KEY=值` 挪进 0600 的临时 env 文件,改用 `--env-file` 传入,
34
+ * 避免密钥出现在 ps / /proc 与错误信息里;exec 返回(或抛错)后立即删除文件。
35
+ * 没有 KEY=值 形式的 env 时原样执行。
36
+ */
37
+ function runWithEnvFile(args, dir, exec) {
38
+ const rest = [];
39
+ const lines = [];
40
+ let envFileIndex = -1;
41
+ for (let i = 0; i < args.length; i++) {
42
+ if (args[i] === '--env' && /^[^=]+=/.test(String(args[i + 1] ?? ''))) {
43
+ if (envFileIndex < 0) envFileIndex = rest.length;
44
+ lines.push(args[i + 1]);
45
+ i += 1;
46
+ } else {
47
+ rest.push(args[i]);
48
+ }
49
+ }
50
+ if (!lines.length) return exec(args);
51
+
52
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
53
+ fs.chmodSync(dir, 0o700);
54
+ const file = path.join(dir, `env-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
55
+ fs.writeFileSync(file, `${lines.join('\n')}\n`, { mode: 0o600, flag: 'wx' });
56
+ rest.splice(envFileIndex, 0, '--env-file', file);
57
+ try {
58
+ return exec(rest);
59
+ } finally {
60
+ fs.rmSync(file, { force: true });
61
+ }
62
+ }
63
+
29
64
  function quoteShellArg(value) {
30
65
  const text = String(value);
31
66
  if (text.includes(' ') || text.includes('"') || text.includes('=')) {
@@ -40,5 +75,6 @@ function buildContainerRunCommand(dockerCmd, args) {
40
75
 
41
76
  module.exports = {
42
77
  buildContainerRunArgs,
78
+ runWithEnvFile,
43
79
  buildContainerRunCommand
44
80
  };
package/lib/doctor.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  const { resolveContainerMode } = require('./container-modes');
4
4
  const { resolveAgentProgram } = require('./agent-resume');
5
+ const { describeError } = require('./error-hints');
6
+ const { checkRootlessNetwork } = require('./rootless-network');
5
7
 
6
8
  function createCheck(code, status, summary, action = '', detail = '') {
7
9
  return { code, status, summary, action, detail };
@@ -9,6 +11,8 @@ function createCheck(code, status, summary, action = '', detail = '') {
9
11
 
10
12
  // 卡死的 daemon(如无响应的 Docker Desktop)不能让诊断无限挂起
11
13
  const COMMAND_TIMEOUT_MS = 10000;
14
+ // 真实启动一次容器(含网络配置),比 info 慢
15
+ const START_TEST_TIMEOUT_MS = 60000;
12
16
 
13
17
  // runCommand 可以是同步或异步执行器;serve 里传异步的,避免阻塞事件循环
14
18
  async function runDoctorChecks(options = {}) {
@@ -45,13 +49,37 @@ async function runDoctorChecks(options = {}) {
45
49
  } catch (error) {
46
50
  checks.push(createCheck('DAEMON_UNAVAILABLE', 'error', `${runtimeCommand} daemon 不可用`, `启动 ${runtimeCommand} daemon 后重试。`, error.message || ''));
47
51
  }
52
+ const network = await checkRootlessNetwork(runCommand, { command: runtimeCommand, env: runtimeEnv, timeout: COMMAND_TIMEOUT_MS, platform: options.platform });
53
+ if (network && network.missing) {
54
+ checks.push(createCheck('ROOTLESS_NETWORK_MISSING', 'error', network.reason, network.action));
55
+ } else if (network) {
56
+ checks.push(createCheck('ROOTLESS_NETWORK_OK', 'ok', `rootless 网络组件可用: ${network.component}`));
57
+ }
58
+ const image = `${options.imageName || ''}:${options.imageVersion || ''}`;
59
+ let imageAvailable = false;
48
60
  try {
49
- const image = `${options.imageName || ''}:${options.imageVersion || ''}`;
50
61
  const detail = String(await runCommand(runtimeCommand, ['image', 'inspect', image], { env: runtimeEnv, timeout: COMMAND_TIMEOUT_MS }) || '').trim();
51
62
  checks.push(createCheck('IMAGE_AVAILABLE', 'ok', `镜像可用: ${image}`, '', detail));
63
+ imageAvailable = true;
52
64
  } catch (error) {
53
65
  checks.push(createCheck('IMAGE_MISSING', 'warning', '目标镜像尚不可用', '执行 manyoyo build,或拉取匹配镜像。', error.message || ''));
54
66
  }
67
+ // 镜像在、网络组件也没查出问题时,真实启动一次:info 正常不代表容器能起来
68
+ if (imageAvailable && !(network && network.missing)) {
69
+ try {
70
+ await runCommand(runtimeCommand, ['run', '--rm', '--entrypoint', '', image, 'true'], { env: runtimeEnv, timeout: START_TEST_TIMEOUT_MS });
71
+ checks.push(createCheck('CONTAINER_START_OK', 'ok', '容器可以正常启动'));
72
+ } catch (error) {
73
+ const raw = `${error.message || ''}\n${error.stderr || ''}`;
74
+ const info = describeError(raw, { command: runtimeCommand, imageRef: image });
75
+ checks.push(createCheck(
76
+ 'CONTAINER_START_FAILED', 'error',
77
+ info ? info.reason : '容器无法启动',
78
+ info ? info.action : `手动执行 ${runtimeCommand} run --rm ${image} true 查看原因。`,
79
+ String(error.stderr || error.message || '').trim().split('\n').slice(-5).join('\n')
80
+ ));
81
+ }
82
+ }
55
83
  }
56
84
 
57
85
  checks.push(options.configExists === true
@@ -71,9 +99,13 @@ async function runDoctorChecks(options = {}) {
71
99
  }
72
100
 
73
101
  const pluginConfig = options.pluginConfig;
74
- checks.push(pluginConfig && typeof pluginConfig === 'object' && !Array.isArray(pluginConfig)
75
- ? createCheck('PLUGIN_CONFIG_VALID', 'ok', '插件配置有效')
76
- : createCheck('PLUGIN_CONFIG_INVALID', 'warning', '插件配置不存在或格式无效', '将 plugins 配置为对象(map)。'));
102
+ if (pluginConfig === undefined || pluginConfig === null) {
103
+ checks.push(createCheck('PLUGIN_CONFIG_VALID', 'ok', '未配置插件'));
104
+ } else {
105
+ checks.push(typeof pluginConfig === 'object' && !Array.isArray(pluginConfig)
106
+ ? createCheck('PLUGIN_CONFIG_VALID', 'ok', '插件配置有效')
107
+ : createCheck('PLUGIN_CONFIG_INVALID', 'warning', '插件配置格式无效', '将 plugins 配置为对象(map)。'));
108
+ }
77
109
 
78
110
  if (options.portStatus === 'available') {
79
111
  checks.push(createCheck('PORT_AVAILABLE', 'ok', '监听端口可用'));
@@ -1,7 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const { describeMissing } = require('./rootless-network');
4
+
3
5
  // 原始运行时/系统错误 -> “原因 + 下一步”。新增映射只改这里。
4
6
  const RULES = [
7
+ {
8
+ code: 'ROOTLESS_NETWORK_MISSING',
9
+ pattern: /could not find (slirp4netns|pasta)/i,
10
+ describe: (ctx, match) => describeMissing(match[1].toLowerCase(), ctx)
11
+ },
5
12
  {
6
13
  code: 'PODMAN_MACHINE_UNAVAILABLE',
7
14
  pattern: /Cannot connect to Podman|unable to connect to Podman socket|podman machine start|podman system connection/i,
@@ -78,8 +85,9 @@ function describeError(text, context = {}) {
78
85
  const raw = String(text || '');
79
86
  const ctx = { command: 'docker', imageRef: '', port: '', ...context };
80
87
  for (const rule of RULES) {
81
- if (rule.pattern.test(raw)) {
82
- return { code: rule.code, ...rule.describe(ctx) };
88
+ const match = rule.pattern.exec(raw);
89
+ if (match) {
90
+ return { code: rule.code, ...rule.describe(ctx, match) };
83
91
  }
84
92
  }
85
93
  return null;
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const JSON5 = require('json5');
7
- const { findTopLevelPropertyValueRange } = require('./json5-text-edit');
7
+ const { upsertValueByPath } = require('./json5-text-edit');
8
8
  const { writeConfigFileSecure } = require('./secure-file');
9
9
 
10
10
  function getManyoyoConfigPath(homeDir = os.homedir()) {
@@ -38,23 +38,8 @@ function readManyoyoConfig(homeDir = os.homedir()) {
38
38
  }
39
39
  }
40
40
 
41
- function insertTopLevelImageVersion(text, imageVersion) {
42
- const openBraceIndex = text.indexOf('{');
43
- if (openBraceIndex === -1) {
44
- return null;
45
- }
46
-
47
- const newlineIndex = text.indexOf('\n', openBraceIndex);
48
- const insertIndex = newlineIndex === -1 ? openBraceIndex + 1 : newlineIndex + 1;
49
- return `${text.slice(0, insertIndex)} imageVersion: ${JSON.stringify(imageVersion)},\n${text.slice(insertIndex)}`;
50
- }
51
-
52
41
  function updateImageVersionText(text, imageVersion) {
53
- const range = findTopLevelPropertyValueRange(text, 'imageVersion');
54
- if (range) {
55
- return `${text.slice(0, range.start)}${JSON.stringify(imageVersion)}${text.slice(range.end)}`;
56
- }
57
- return insertTopLevelImageVersion(text, imageVersion);
42
+ return upsertValueByPath(text, ['imageVersion'], JSON.stringify(imageVersion));
58
43
  }
59
44
 
60
45
  function syncGlobalImageVersion(imageVersion, options = {}) {
@@ -87,22 +72,12 @@ function syncGlobalImageVersion(imageVersion, options = {}) {
87
72
  };
88
73
  }
89
74
 
90
- const nextConfig = {
91
- ...currentConfig,
92
- imageVersion
93
- };
94
-
95
- if (result.exists) {
96
- const currentText = fs.readFileSync(configPath, 'utf-8');
97
- const updatedText = updateImageVersionText(currentText, imageVersion);
98
- if (updatedText) {
99
- writeConfigFileSecure(configPath, updatedText.endsWith('\n') ? updatedText : `${updatedText}\n`);
100
- } else {
101
- writeConfigFileSecure(configPath, `${JSON.stringify(nextConfig, null, 4)}\n`);
102
- }
103
- } else {
104
- writeConfigFileSecure(configPath, `${JSON.stringify(nextConfig, null, 4)}\n`);
105
- }
75
+ // 只在原文上局部替换,保留注释与格式;文件不存在或为空时写标准 JSON
76
+ const currentText = result.exists ? fs.readFileSync(configPath, 'utf-8') : '';
77
+ const updatedText = currentText.trim()
78
+ ? updateImageVersionText(currentText, imageVersion)
79
+ : `${JSON.stringify({ imageVersion }, null, 4)}\n`;
80
+ writeConfigFileSecure(configPath, updatedText.endsWith('\n') ? updatedText : `${updatedText}\n`);
106
81
 
107
82
  return {
108
83
  updated: true,
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { writeConfigFileSecure } = require('./secure-file');
7
+ const { upsertValueByPath } = require('./json5-text-edit');
7
8
 
8
9
  // 每个 Agent 需要哪些环境变量:CLI init 与 Web 首次向导共用的单一数据源。
9
10
  // requiredAnyOf:其中任意一个非空即视为该 Agent 可用。
@@ -414,6 +415,9 @@ async function initAgentConfigs(rawAgents, options = {}) {
414
415
  }
415
416
  runsMap = { ...manyoyoConfig.runs };
416
417
  }
418
+ // 只在原文上局部写入各 runs.<agent>,保留用户的注释与格式
419
+ const originalRaw = fs.existsSync(manyoyoConfigPath) ? fs.readFileSync(manyoyoConfigPath, 'utf-8') : '';
420
+ let nextRaw = originalRaw;
417
421
 
418
422
  let hasConfigChanged = false;
419
423
  ctx.log(`${CYAN}🧭 正在初始化 MANYOYO 配置: ${agents.join(', ')}${NC}`);
@@ -427,6 +431,7 @@ async function initAgentConfigs(rawAgents, options = {}) {
427
431
  if (shouldWriteRun) {
428
432
  const buildResult = buildInitRunProfile(agent, spec.yolo, data.volumes, data.keys, data.values);
429
433
  runsMap[agent] = buildResult.runProfile;
434
+ if (originalRaw.trim()) nextRaw = upsertValueByPath(nextRaw, ['runs', agent], JSON.stringify(buildResult.runProfile, null, 4));
430
435
  writeResult = { missingKeys: buildResult.missingKeys, unsafeKeys: buildResult.unsafeKeys };
431
436
  hasConfigChanged = true;
432
437
  }
@@ -450,8 +455,12 @@ async function initAgentConfigs(rawAgents, options = {}) {
450
455
  }
451
456
 
452
457
  if (hasConfigChanged || !fs.existsSync(manyoyoConfigPath)) {
453
- manyoyoConfig.runs = runsMap;
454
- writeConfigFileSecure(manyoyoConfigPath, `${JSON.stringify(manyoyoConfig, null, 4)}\n`);
458
+ if (originalRaw.trim()) {
459
+ writeConfigFileSecure(manyoyoConfigPath, nextRaw);
460
+ } else {
461
+ // 新文件没有可保留的注释,写标准 JSON
462
+ writeConfigFileSecure(manyoyoConfigPath, `${JSON.stringify({ ...manyoyoConfig, runs: runsMap }, null, 4)}\n`);
463
+ }
455
464
  }
456
465
  }
457
466
 
@@ -235,12 +235,30 @@ function formatPropertyKey(key) {
235
235
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
236
236
  }
237
237
 
238
+ // 某个位置所在行的缩进
239
+ function lineIndentAt(text, index) {
240
+ const lineStart = text.lastIndexOf('\n', index - 1) + 1;
241
+ return /^[ \t]*/.exec(text.slice(lineStart))[0];
242
+ }
243
+
244
+ // 多行值(JSON.stringify 的结果)的后续行补上插入位置的缩进,否则内层会顶格
245
+ function reindent(valueText, indent) {
246
+ return String(valueText).replace(/\n/g, `\n${indent}`);
247
+ }
248
+
238
249
  // 永远只在对象的 '{' 后面插入,不管这个对象原来是空的还是已经有属性——
239
250
  // 不用去判断"是不是最后一个属性、要不要补逗号",语法上总是安全的
240
251
  function insertObjectProperty(text, objectStartIndex, key, valueText) {
241
- const prefix = `\n ${formatPropertyKey(key)}: `;
242
- const insertion = `${prefix}${valueText},`;
243
- const insertPos = objectStartIndex + 1;
252
+ const objectIndent = lineIndentAt(text, objectStartIndex);
253
+ const childIndent = `${objectIndent} `;
254
+ const prefix = `\n${childIndent}${formatPropertyKey(key)}: `;
255
+ // '{' 同一行后面只有空白或 // 注释时,插到该行末尾,注释留在 '{' 那一行,不会被挪到新属性后面
256
+ const lineEnd = text.indexOf('\n', objectStartIndex + 1);
257
+ const tailIsTrivia = lineEnd !== -1 && /^\s*(\/\/.*)?$/.test(text.slice(objectStartIndex + 1, lineEnd));
258
+ // 空对象且 } 紧跟在同一行:闭合的 } 另起一行;} 已在后面的行上就不再补,避免多出空行
259
+ const closesRightAfter = !tailIsTrivia && text[skipTrivia(text, objectStartIndex + 1)] === '}';
260
+ const insertion = `${prefix}${reindent(valueText, childIndent)},${closesRightAfter ? `\n${objectIndent}` : ''}`;
261
+ const insertPos = tailIsTrivia ? lineEnd : objectStartIndex + 1;
244
262
  const nextText = `${text.slice(0, insertPos)}${insertion}${text.slice(insertPos)}`;
245
263
  return { text: nextText, valueStart: insertPos + prefix.length };
246
264
  }
@@ -269,7 +287,7 @@ function upsertValueByPath(text, pathParts, valueText) {
269
287
  if (existingRange) {
270
288
  if (isLast) {
271
289
  return applyTextReplacements(currentText, [
272
- { start: existingRange.start, end: existingRange.end, text: valueText }
290
+ { start: existingRange.start, end: existingRange.end, text: reindent(valueText, lineIndentAt(currentText, existingRange.start)) }
273
291
  ]);
274
292
  }
275
293
  const nextObjectStart = skipTrivia(currentText, existingRange.start);
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ // 浏览器指纹的唯一数据源:宿主机生成的配置与镜像内的 docker/res/playwright/* 都从这里生成。
4
+ // 原则是各项信号自洽:UA、Client Hints、平台、字体、WebGL 一律用浏览器真实值,不做自相矛盾的伪装;
5
+ // 只固定语言、时区、窗口形态与自动化痕迹。
6
+
7
+ const CONTAINER_CONFIG_DIR = '/run/manyoyo-playwright';
8
+ const CONTAINER_CONFIG_PATH = `${CONTAINER_CONFIG_DIR}/config.json`;
9
+ const CONTAINER_INIT_SCRIPT_PATH = `${CONTAINER_CONFIG_DIR}/stealth.init.js`;
10
+ const CONTAINER_OUTPUT_DIR = '/tmp/.playwright-cli';
11
+
12
+ const DEFAULT_PROFILE = {
13
+ locale: 'en-US',
14
+ timezoneId: 'UTC',
15
+ navigatorPlatform: '',
16
+ disableWebRTC: false
17
+ };
18
+
19
+ function resolveProfile(options = {}) {
20
+ const text = (value, fallback) => String(value || '').trim() || fallback;
21
+ return {
22
+ locale: text(options.locale, DEFAULT_PROFILE.locale),
23
+ timezoneId: text(options.timezoneId, DEFAULT_PROFILE.timezoneId),
24
+ navigatorPlatform: text(options.navigatorPlatform, DEFAULT_PROFILE.navigatorPlatform),
25
+ disableWebRTC: options.disableWebRTC === true
26
+ };
27
+ }
28
+
29
+ function acceptLanguage(locale) {
30
+ const language = String(locale).split('-')[0];
31
+ return language === locale ? `${locale};q=0.9` : `${locale},${language};q=0.9`;
32
+ }
33
+
34
+ // 宿主机的时区与系统语言,作为默认值
35
+ function detectHostProfile() {
36
+ const resolved = Intl.DateTimeFormat().resolvedOptions();
37
+ return { locale: resolved.locale, timezoneId: resolved.timeZone };
38
+ }
39
+
40
+ // window: 'virtual' 为容器内 1920x1080 虚拟屏;'native' 为真实桌面,最大化即可
41
+ function buildLaunchArgs(options = {}, window = 'native') {
42
+ const profile = resolveProfile(options);
43
+ const args = [
44
+ `--lang=${profile.locale}`,
45
+ window === 'virtual' ? '--window-size=1920,1080' : '--start-maximized',
46
+ '--disable-blink-features=AutomationControlled',
47
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
48
+ ];
49
+ if (profile.disableWebRTC) {
50
+ args.push('--disable-webrtc');
51
+ }
52
+ return args;
53
+ }
54
+
55
+ // 有头浏览器不设置 viewport / screen,让窗口尺寸与屏幕尺寸像真实窗口一样不同
56
+ function buildContextOptions(options = {}) {
57
+ const profile = resolveProfile(options);
58
+ return {
59
+ locale: profile.locale,
60
+ timezoneId: profile.timezoneId,
61
+ viewport: null,
62
+ extraHTTPHeaders: {
63
+ 'Accept-Language': acceptLanguage(profile.locale)
64
+ }
65
+ };
66
+ }
67
+
68
+ function buildInitScript(options = {}) {
69
+ const profile = resolveProfile(options);
70
+ // 首个页面加载时窗口尚未显示,outerWidth/outerHeight 会读到 0(无头浏览器的典型特征);
71
+ // 只在读到 0 时用 inner 尺寸加上工具栏高度补上,真实窗口的值原样返回
72
+ const lines = [
73
+ "'use strict';",
74
+ '(function () {',
75
+ ' try {',
76
+ ' const toolbar = 87;',
77
+ ' const patch = (name, fallback, getter) => {',
78
+ ' const original = Object.getOwnPropertyDescriptor(window, name);',
79
+ ' if (!original || !original.get) return;',
80
+ ' const real = original.get;',
81
+ ' Object.defineProperty(window, name, { ...original, get: getter(function () { const value = real.call(this); return value === 0 ? fallback() : value; }) });',
82
+ ' };',
83
+ " patch('outerWidth', () => window.innerWidth, fn => Object.getOwnPropertyDescriptor({ get outerWidth() { return fn.call(this); } }, 'outerWidth').get);",
84
+ " patch('outerHeight', () => window.innerHeight + toolbar, fn => Object.getOwnPropertyDescriptor({ get outerHeight() { return fn.call(this); } }, 'outerHeight').get);",
85
+ ' } catch (_) {}'
86
+ ];
87
+ if (profile.navigatorPlatform) {
88
+ lines.push(
89
+ ' try {',
90
+ ' const navProto = Object.getPrototypeOf(navigator);',
91
+ " Object.defineProperty(navProto, 'platform', {",
92
+ ' configurable: true,',
93
+ ` get: () => ${JSON.stringify(profile.navigatorPlatform)}`,
94
+ ' });',
95
+ ' } catch (_) {}'
96
+ );
97
+ }
98
+ if (profile.disableWebRTC) {
99
+ lines.push(
100
+ ' try {',
101
+ " const blocked = ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCIceCandidate', 'RTCRtpSender', 'RTCRtpReceiver', 'RTCRtpTransceiver', 'RTCDataChannel'];",
102
+ ' for (const name of blocked) {',
103
+ ' Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: undefined });',
104
+ ' }',
105
+ ' if (navigator.mediaDevices) {',
106
+ " Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {",
107
+ ' configurable: true,',
108
+ ' writable: true,',
109
+ " value: async () => { throw new DOMException('WebRTC is disabled', 'NotAllowedError'); }",
110
+ ' });',
111
+ ' }',
112
+ ' } catch (_) {}'
113
+ );
114
+ }
115
+ lines.push('})();', '');
116
+ return lines.join('\n');
117
+ }
118
+
119
+ // 容器内 playwright-cli 读取的配置。
120
+ // default: 容器内 Xvfb 里的有头浏览器;ws: 附着宿主机 launch-server;cdp: 附着用户自己的 Chrome(不注入指纹)
121
+ function buildContainerConfig(kind, options = {}) {
122
+ const base = { outputDir: CONTAINER_OUTPUT_DIR };
123
+ if (kind === 'cdp') {
124
+ return { ...base, browser: { cdpEndpoint: options.endpoint, cdpTimeout: 60000 } };
125
+ }
126
+ const fingerprint = {
127
+ initScript: [CONTAINER_INIT_SCRIPT_PATH],
128
+ contextOptions: buildContextOptions(options)
129
+ };
130
+ if (kind === 'ws') {
131
+ // launch-server 不预创建 context,必须 isolated 才会自建
132
+ return { ...base, browser: { remoteEndpoint: options.endpoint, isolated: true, ...fingerprint } };
133
+ }
134
+ return {
135
+ ...base,
136
+ browser: {
137
+ // 容器内是 root,Chromium 不允许 root 开沙箱
138
+ chromiumSandbox: false,
139
+ browserName: 'chromium',
140
+ ...fingerprint,
141
+ launchOptions: {
142
+ channel: 'chromium',
143
+ headless: false,
144
+ args: buildLaunchArgs(options, 'virtual')
145
+ }
146
+ }
147
+ };
148
+ }
149
+
150
+ // 宿主机 playwright launch-server 的配置
151
+ function buildServerConfig({ host, port, wsPath, headless = false, window = 'native', chromiumSandbox = true, extensionArgs = [], ...profileOptions }) {
152
+ return {
153
+ host,
154
+ port,
155
+ wsPath,
156
+ headless,
157
+ channel: 'chromium',
158
+ chromiumSandbox,
159
+ args: [...buildLaunchArgs(profileOptions, window), ...extensionArgs]
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ CONTAINER_CONFIG_DIR,
165
+ CONTAINER_CONFIG_PATH,
166
+ CONTAINER_INIT_SCRIPT_PATH,
167
+ DEFAULT_PROFILE,
168
+ resolveProfile,
169
+ detectHostProfile,
170
+ acceptLanguage,
171
+ buildLaunchArgs,
172
+ buildContextOptions,
173
+ buildInitScript,
174
+ buildContainerConfig,
175
+ buildServerConfig
176
+ };
@@ -2,67 +2,82 @@
2
2
 
3
3
  const { PlaywrightPlugin } = require('./playwright');
4
4
 
5
- const AVAILABLE_PLUGINS = ['playwright'];
6
-
7
5
  function asObject(value) {
8
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
9
- return {};
10
- }
11
- return value;
12
- }
13
-
14
- function getPluginConfigs(pluginName, globalConfig, runConfig) {
15
- const globalPlugins = asObject(asObject(globalConfig).plugins);
16
- const runPlugins = asObject(asObject(runConfig).plugins);
17
- return {
18
- globalPluginConfig: asObject(globalPlugins[pluginName]),
19
- runPluginConfig: asObject(runPlugins[pluginName])
20
- };
6
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
21
7
  }
22
8
 
23
- function createPlugin(pluginName, options = {}) {
24
- if (pluginName !== 'playwright') {
25
- throw new Error(`未知插件: ${pluginName}`);
26
- }
27
-
28
- const cfg = getPluginConfigs(pluginName, options.globalConfig, options.runConfig);
9
+ // 全局配置与 runs.<name> 里的 plugins.playwright 分别传入,后者覆盖前者
10
+ function createPlaywrightPlugin(options = {}) {
11
+ const pluginConfig = config => asObject(asObject(asObject(config).plugins).playwright);
29
12
  return new PlaywrightPlugin({
30
- projectRoot: options.projectRoot,
31
13
  stdout: options.stdout,
32
14
  stderr: options.stderr,
33
- globalConfig: cfg.globalPluginConfig,
34
- runConfig: cfg.runPluginConfig,
15
+ runtime: options.runtime,
16
+ homeDir: options.homeDir,
17
+ globalConfig: pluginConfig(options.globalConfig),
18
+ runConfig: pluginConfig(options.runConfig),
35
19
  rootGlobalConfig: asObject(options.globalConfig),
36
20
  rootRunConfig: asObject(options.runConfig)
37
21
  });
38
22
  }
39
23
 
40
- async function runPluginCommand(request, options = {}) {
41
- const action = String(request && request.action || '').trim();
42
-
43
- if (action === 'ls') {
44
- const plugin = createPlugin('playwright', options);
45
- return await plugin.run({ action: 'ls' });
46
- }
47
-
48
- const pluginName = String(request && request.pluginName || '').trim();
49
- if (!pluginName) {
50
- throw new Error('plugin 名称不能为空');
51
- }
24
+ async function runPlaywrightCommand(request, options = {}) {
25
+ return await createPlaywrightPlugin(options).run(request);
26
+ }
52
27
 
53
- const plugin = createPlugin(pluginName, options);
54
- return await plugin.run({
55
- action,
56
- scene: request.scene,
57
- host: request.host,
58
- extensionPaths: request.extensionPaths,
59
- extensionNames: request.extensionNames,
60
- prodversion: request.prodversion
28
+ // 新建会话容器时要加的参数:CLI 的 run 与 Web 建会话共用这一个入口。
29
+ // 返回扁平的 run 参数数组(--env / --volume / 其余),以及要给用户看的警告;不会抛错。
30
+ async function buildContainerIntegration(options = {}) {
31
+ const plugin = createPlaywrightPlugin(options);
32
+ const integration = await plugin.buildContainerIntegration({
33
+ runtimeCommand: options.runtimeCommand,
34
+ envEntries: options.envEntries || [],
35
+ dryRun: options.dryRun === true
61
36
  });
37
+ return {
38
+ envArgs: integration.env.flatMap(entry => ['--env', entry]),
39
+ volumeArgs: integration.volumes.flatMap(volume => ['--volume', volume]),
40
+ extraArgs: integration.extraArgs,
41
+ warning: integration.warning
42
+ };
43
+ }
44
+
45
+ // 把集成参数并进已有的扁平参数数组:已有的 env 不覆盖(NO_PROXY 由集成在原值上追加,故替换),volume 与其余参数去重
46
+ function mergeIntegration(runtime, integration) {
47
+ const keyOf = entry => String(entry).split('=')[0];
48
+ const proxyKeys = new Set(['NO_PROXY', 'no_proxy']);
49
+ const pairs = args => {
50
+ const out = [];
51
+ for (let i = 0; i + 1 < args.length; i += 2) {
52
+ out.push([args[i], args[i + 1]]);
53
+ }
54
+ return out;
55
+ };
56
+ const existingEnv = pairs(runtime.containerEnvs || []);
57
+ const kept = existingEnv.filter(([head, value]) => !(head === '--env' && proxyKeys.has(keyOf(value))));
58
+ const present = new Set(kept.filter(([head]) => head === '--env').map(([, value]) => keyOf(value)));
59
+ const added = pairs(integration.envArgs).filter(([, value]) => proxyKeys.has(keyOf(value)) || !present.has(keyOf(value)));
60
+ const append = (base, extra) => {
61
+ const seen = new Set(pairs(base).map(pair => pair.join('\u0000')));
62
+ const out = [...base];
63
+ for (const pair of pairs(extra)) {
64
+ if (!seen.has(pair.join('\u0000'))) {
65
+ seen.add(pair.join('\u0000'));
66
+ out.push(...pair);
67
+ }
68
+ }
69
+ return out;
70
+ };
71
+ return {
72
+ containerEnvs: [...kept, ...added].flat(),
73
+ containerVolumes: append(runtime.containerVolumes || [], integration.volumeArgs),
74
+ containerExtraArgs: append(runtime.containerExtraArgs || [], integration.extraArgs)
75
+ };
62
76
  }
63
77
 
64
78
  module.exports = {
65
- AVAILABLE_PLUGINS,
66
- createPlugin,
67
- runPluginCommand
79
+ buildContainerIntegration,
80
+ mergeIntegration,
81
+ createPlaywrightPlugin,
82
+ runPlaywrightCommand
68
83
  };
@@ -0,0 +1,21 @@
1
+ #!/bin/sh
2
+ # 在 Xvfb 里跑有头浏览器服务,并用 x11vnc + noVNC 让用户在网页上观看
3
+ set -eu
4
+
5
+ export DISPLAY=:99
6
+ : "${VNC_PASSWORD:?VNC_PASSWORD is required}"
7
+
8
+ rm -f /tmp/.X99-lock
9
+ Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
10
+ i=0
11
+ while [ ! -S /tmp/.X11-unix/X99 ] && [ "$i" -lt 50 ]; do
12
+ sleep 0.1
13
+ i=$((i + 1))
14
+ done
15
+ fluxbox >/tmp/fluxbox.log 2>&1 &
16
+ x11vnc -display :99 -forever -shared -rfbport 5900 -passwd "$VNC_PASSWORD" >/tmp/x11vnc.log 2>&1 &
17
+ websockify --web=/usr/share/novnc/ 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
18
+
19
+ exec node /run/manyoyo-playwright-vnc/playwright-server.js \
20
+ /usr/local/lib/node_modules/@playwright/cli/node_modules/playwright-core \
21
+ /run/manyoyo-playwright-vnc/server.json