@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.
@@ -1,128 +1,41 @@
1
1
  'use strict';
2
2
 
3
+ // Playwright 插件:容器内的浏览器怎么来,只有 4 种模式、同一时间只有一个。
4
+ // default 容器内 Xvfb 里的有头浏览器(无需宿主机进程)
5
+ // headed 宿主机有窗口的浏览器
6
+ // chrome 用户正在使用的 Chrome(经本机中继)
7
+ // vnc 独立容器里的浏览器,用 noVNC 网页观看
8
+ // 宿主机目录 current/ 以目录方式只读挂进容器的 /run/manyoyo-playwright/,里面的 config.json 一律原子替换,
9
+ // 因此切换模式后已运行的容器自动跟随,不需要重建。
10
+
3
11
  const fs = require('fs');
4
12
  const net = require('net');
5
13
  const os = require('os');
6
14
  const path = require('path');
7
15
  const crypto = require('crypto');
8
16
  const { spawn, spawnSync } = require('child_process');
9
- const { playwrightCliVersion: PLAYWRIGHT_CLI_VERSION } = require('../../package.json');
10
- const { resolveForcedRuntime } = require('../container-runtime');
11
-
12
- const EXTENSIONS = [
13
- ['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
14
- ['adguard', 'bgnkhhnnamicmpeenaelnjfhikgbkllg'],
15
- ['privacy-badger', 'pkehgijcmpdhfbdbbnkijodmdjhbjlgp'],
16
- ['webrtc-leak-shield', 'bppamachkoflopbagkdoflbgfjflfnfl'],
17
- ['webgl-fingerprint-defender', 'olnbjpaejebpnokblkepbphhembdicik']
17
+ const { writeConfigFileSecure } = require('../secure-file');
18
+ const fingerprint = require('./fingerprint');
19
+ const extensions = require('./playwright-extensions');
20
+ const { findDevToolsActivePort } = require('./playwright-relay');
21
+
22
+ const MODES = ['headed', 'chrome', 'vnc'];
23
+ const MODE_HELP = [
24
+ ['(默认)', '容器内虚拟屏里的有头浏览器,无需任何命令'],
25
+ ['headed', '宿主机上有窗口的浏览器,亲眼看着 Agent 操作'],
26
+ ['chrome', '你正在用的 Chrome,沿用登录状态(有风险)'],
27
+ ['vnc', '独立容器里的浏览器,通过 noVNC 网页观看(无桌面的机器)']
18
28
  ];
29
+ const VNC_CONTAINER = 'my-playwright-vnc';
30
+ const VNC_ASSET_DIR = path.join(__dirname, 'playwright-assets');
31
+ const VNC_MOUNT_DIR = '/run/manyoyo-playwright-vnc';
32
+ const DEFAULT_PORTS = { port: 8935, vncPort: 5900, novncPort: 6080 };
33
+ const PROXY_BYPASS_HOSTS = ['host.docker.internal', 'host.containers.internal'];
34
+ const SERVER_START_TIMEOUT_MS = 30000;
35
+ const PROBE_TIMEOUT_MS = 5000;
19
36
 
20
- const SCENE_ORDER = ['mcp-cont-headless', 'mcp-cont-headed', 'mcp-host-headless', 'mcp-host-headed', 'cli-host-headless', 'cli-host-headed', 'dev-host-headed'];
21
-
22
- const SCENE_DEFS = {
23
- 'mcp-cont-headless': {
24
- type: 'container',
25
- engine: 'mcp',
26
- configFile: 'mcp-cont-headless.json',
27
- composeFile: 'compose-headless.yaml',
28
- projectName: 'my-playwright-mcp-cont-headless',
29
- containerName: 'my-playwright-mcp-cont-headless',
30
- portKey: 'mcpContHeadless',
31
- headless: true,
32
- listenHost: '0.0.0.0'
33
- },
34
- 'mcp-cont-headed': {
35
- type: 'container',
36
- engine: 'mcp',
37
- configFile: 'mcp-cont-headed.json',
38
- composeFile: 'compose-headed.yaml',
39
- projectName: 'my-playwright-mcp-cont-headed',
40
- containerName: 'my-playwright-mcp-cont-headed',
41
- portKey: 'mcpContHeaded',
42
- headless: false,
43
- listenHost: '0.0.0.0'
44
- },
45
- 'mcp-host-headless': {
46
- type: 'host',
47
- engine: 'mcp',
48
- configFile: 'mcp-host-headless.json',
49
- portKey: 'mcpHostHeadless',
50
- headless: true,
51
- listenHost: '127.0.0.1'
52
- },
53
- 'mcp-host-headed': {
54
- type: 'host',
55
- engine: 'mcp',
56
- configFile: 'mcp-host-headed.json',
57
- portKey: 'mcpHostHeaded',
58
- headless: false,
59
- listenHost: '127.0.0.1'
60
- },
61
- 'cli-host-headless': {
62
- type: 'host',
63
- engine: 'cli',
64
- configFile: 'cli-host-headless.json',
65
- portKey: 'cliHostHeadless',
66
- headless: true,
67
- listenHost: '0.0.0.0'
68
- },
69
- 'cli-host-headed': {
70
- type: 'host',
71
- engine: 'cli',
72
- configFile: 'cli-host-headed.json',
73
- portKey: 'cliHostHeaded',
74
- headless: false,
75
- listenHost: '0.0.0.0'
76
- },
77
- 'dev-host-headed': {
78
- type: 'host',
79
- engine: 'dev',
80
- configFile: 'dev-host-headed.json',
81
- headless: false
82
- }
83
- };
84
-
85
- const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
86
- const VALID_ACTIONS = new Set(['up', 'down', 'status', 'health', 'logs']);
87
- const CONTAINER_EXTENSION_ROOT = '/app/extensions';
88
- const DEFAULT_FINGERPRINT_PROFILE = {
89
- userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
90
- locale: 'zh-CN',
91
- acceptLanguage: 'zh-CN,zh;q=0.9',
92
- timezoneId: 'Asia/Shanghai',
93
- width: 1366,
94
- height: 768
95
- };
96
- const DISABLE_WEBRTC_LAUNCH_ARGS = ['--disable-webrtc'];
97
-
98
- function isMcpScene(sceneName) {
99
- return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'mcp');
100
- }
101
-
102
- function isCliScene(sceneName) {
103
- return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'cli');
104
- }
105
-
106
- function isDevScene(sceneName) {
107
- return Boolean(SCENE_DEFS[sceneName] && SCENE_DEFS[sceneName].engine === 'dev');
108
- }
109
-
110
- function isCliSessionScene(sceneName) {
111
- return isCliScene(sceneName) || isDevScene(sceneName);
112
- }
113
-
114
- function platformFromUserAgent(userAgent) {
115
- const ua = String(userAgent || '').toLowerCase();
116
- if (ua.includes('macintosh') || ua.includes('mac os x')) {
117
- return 'MacIntel';
118
- }
119
- if (ua.includes('windows')) {
120
- return 'Win32';
121
- }
122
- if (ua.includes('android') || ua.includes('linux')) {
123
- return 'Linux x86_64';
124
- }
125
- return 'MacIntel';
37
+ function asObject(value) {
38
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
126
39
  }
127
40
 
128
41
  function sleep(ms) {
@@ -130,1913 +43,854 @@ function sleep(ms) {
130
43
  }
131
44
 
132
45
  function tailText(filePath, lineCount) {
133
- if (!fs.existsSync(filePath)) {
46
+ try {
47
+ return fs.readFileSync(filePath, 'utf8').split(/\r?\n/).slice(-lineCount).join('\n');
48
+ } catch {
134
49
  return '';
135
50
  }
136
- const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
137
- return lines.slice(-lineCount).join('\n');
138
51
  }
139
52
 
140
- function asObject(value) {
141
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
142
- return {};
53
+ function randomAlnum(length) {
54
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
55
+ let out = '';
56
+ for (let i = 0; i < length; i += 1) {
57
+ out += chars[crypto.randomInt(0, chars.length)];
143
58
  }
144
- return value;
59
+ return out;
145
60
  }
146
61
 
147
- function asStringArray(value, fallback) {
148
- if (!Array.isArray(value)) {
149
- return fallback;
150
- }
151
- return value
152
- .map(item => String(item || '').trim())
153
- .filter(Boolean);
62
+ function shellValue(value) {
63
+ const text = String(value);
64
+ return /^[A-Za-z0-9_@%+=:,./~-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
154
65
  }
155
66
 
156
- function asBoolean(value, fallback = false) {
157
- if (typeof value === 'boolean') {
158
- return value;
159
- }
160
- if (typeof value === 'string') {
161
- const normalized = value.trim().toLowerCase();
162
- if (normalized === 'true') {
163
- return true;
164
- }
165
- if (normalized === 'false') {
166
- return false;
167
- }
168
- }
169
- return fallback;
67
+ function portReady(port, host = '127.0.0.1', timeout = 300) {
68
+ return new Promise(resolve => {
69
+ const socket = net.createConnection({ host, port });
70
+ const finish = value => {
71
+ socket.destroy();
72
+ resolve(value);
73
+ };
74
+ socket.setTimeout(timeout);
75
+ socket.once('connect', () => finish(true));
76
+ socket.once('timeout', () => finish(false));
77
+ socket.once('error', () => finish(false));
78
+ });
170
79
  }
171
80
 
172
- function uniqueStrings(values) {
173
- const out = [];
174
- const seen = new Set();
175
- for (const value of values) {
176
- const text = String(value || '').trim();
177
- if (!text || seen.has(text)) {
178
- continue;
179
- }
180
- seen.add(text);
181
- out.push(text);
81
+ function pidAlive(pid) {
82
+ if (!Number.isInteger(pid) || pid <= 0) {
83
+ return false;
182
84
  }
183
- return out;
184
- }
185
-
186
- function isHostPermission(value) {
187
- if (value === '<all_urls>') {
85
+ try {
86
+ process.kill(pid, 0);
188
87
  return true;
88
+ } catch (error) {
89
+ return error.code === 'EPERM';
189
90
  }
190
- return /^(?:\*|http|https|file|ftp):\/\//.test(value);
191
- }
192
-
193
- function scriptSourcesFromHtml(htmlFile) {
194
- const content = fs.readFileSync(htmlFile, { encoding: 'utf8' });
195
- const scripts = [...content.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map(m => m[1]);
196
- return scripts.filter(src => !/^(?:https?:)?\/\//.test(src));
197
91
  }
198
92
 
199
- function convertManifestV2ToV3(extDir) {
200
- const manifestFile = path.join(extDir, 'manifest.json');
201
- const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
202
- if (manifest.manifest_version !== 2) {
93
+ // pid 可能在重启后被不相干的进程复用:确认它的命令行还是我们起的服务脚本
94
+ function pidIsOurs(pid) {
95
+ if (!pidAlive(pid)) {
203
96
  return false;
204
97
  }
98
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8' });
99
+ return /playwright-(server|relay)\.js/.test(result.stdout || '');
100
+ }
205
101
 
206
- manifest.manifest_version = 3;
207
-
208
- if (manifest.browser_action && !manifest.action) {
209
- manifest.action = manifest.browser_action;
210
- delete manifest.browser_action;
211
- }
212
- if (manifest.page_action && !manifest.action) {
213
- manifest.action = manifest.page_action;
214
- delete manifest.page_action;
215
- }
216
-
217
- const background = manifest.background;
218
- if (background && typeof background === 'object' && !Array.isArray(background)) {
219
- let scripts = [];
220
- if (Array.isArray(background.scripts)) {
221
- scripts = background.scripts.filter(s => typeof s === 'string');
222
- } else if (typeof background.page === 'string') {
223
- const pagePath = path.join(extDir, background.page);
224
- if (fs.existsSync(pagePath)) {
225
- scripts = scriptSourcesFromHtml(pagePath);
226
- }
227
- }
228
-
229
- if (scripts.length > 0) {
230
- const swName = 'generated_background_sw.js';
231
- const swFile = path.join(extDir, swName);
232
- const swLines = [
233
- '// Auto-generated by manyoyo playwright ext-download for MV3.',
234
- `importScripts(${scripts.map(s => JSON.stringify(s)).join(', ')});`,
235
- ''
236
- ];
237
- fs.writeFileSync(swFile, swLines.join('\n'), 'utf8');
238
- manifest.background = { service_worker: swName };
239
- } else {
240
- delete manifest.background;
241
- }
242
- }
243
-
244
- if (typeof manifest.content_security_policy === 'string') {
245
- manifest.content_security_policy = { extension_pages: manifest.content_security_policy };
246
- }
247
-
248
- if (Array.isArray(manifest.permissions)) {
249
- const hostPermissions = Array.isArray(manifest.host_permissions) ? [...manifest.host_permissions] : [];
250
- const keptPermissions = [];
251
-
252
- for (const perm of manifest.permissions) {
253
- if (typeof perm === 'string' && isHostPermission(perm)) {
254
- if (!hostPermissions.includes(perm)) {
255
- hostPermissions.push(perm);
256
- }
257
- } else {
258
- keptPermissions.push(perm);
259
- }
260
- }
261
-
262
- manifest.permissions = keptPermissions;
263
- if (hostPermissions.length > 0) {
264
- manifest.host_permissions = hostPermissions;
265
- }
266
- }
267
-
268
- const war = manifest.web_accessible_resources;
269
- if (Array.isArray(war) && war.length > 0 && war.every(v => typeof v === 'string')) {
270
- manifest.web_accessible_resources = [
271
- {
272
- resources: war,
273
- matches: ['<all_urls>']
274
- }
275
- ];
276
- }
102
+ // 宿主机监听地址:macOS 的容器能访问宿主机 loopback;Linux 容器只能访问 0.0.0.0
103
+ function defaultListenHost(platform = process.platform) {
104
+ return platform === 'darwin' ? '127.0.0.1' : '0.0.0.0';
105
+ }
277
106
 
278
- fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
279
- return true;
107
+ function runtimeKind(runtimeCommand) {
108
+ return path.basename(String(runtimeCommand || '').trim()).toLowerCase() === 'podman' ? 'podman' : 'docker';
280
109
  }
281
110
 
282
- function buildCrxUrl(extId, prodversion) {
283
- return (
284
- 'https://clients2.google.com/service/update2/crx' +
285
- `?response=redirect&prodversion=${prodversion}` +
286
- '&acceptformat=crx2,crx3' +
287
- `&x=id%3D${extId}%26installsource%3Dondemand%26uc`
288
- );
111
+ function connectHostFor(runtimeCommand) {
112
+ return runtimeKind(runtimeCommand) === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
289
113
  }
290
114
 
291
- function crxZipOffset(data) {
292
- if (data.subarray(0, 4).toString('ascii') !== 'Cr24') {
293
- throw new Error('not a CRX file');
115
+ function appendNoProxy(existing) {
116
+ const parts = String(existing || '').split(',').map(item => item.trim()).filter(Boolean);
117
+ for (const host of PROXY_BYPASS_HOSTS) {
118
+ if (!parts.includes(host)) {
119
+ parts.push(host);
120
+ }
294
121
  }
122
+ return parts.join(',');
123
+ }
295
124
 
296
- const version = data.readUInt32LE(4);
297
- if (version === 2) {
298
- const pubLen = data.readUInt32LE(8);
299
- const sigLen = data.readUInt32LE(12);
300
- return 16 + pubLen + sigLen;
301
- }
302
- if (version === 3) {
303
- const headerLen = data.readUInt32LE(8);
304
- return 12 + headerLen;
125
+ function envEntryValue(entries, key) {
126
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
127
+ const text = String(entries[i]);
128
+ if (text.startsWith(`${key}=`)) {
129
+ return text.slice(key.length + 1);
130
+ }
305
131
  }
306
- throw new Error(`unsupported CRX version: ${version}`);
132
+ return undefined;
307
133
  }
308
134
 
309
135
  class PlaywrightPlugin {
310
136
  constructor(options = {}) {
311
- this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
312
137
  this.stdout = options.stdout || process.stdout;
313
138
  this.stderr = options.stderr || process.stderr;
314
- this.globalConfig = asObject(options.globalConfig);
315
- this.runConfig = asObject(options.runConfig);
316
139
  this.rootGlobalConfig = asObject(options.rootGlobalConfig);
317
140
  this.rootRunConfig = asObject(options.rootRunConfig);
318
- this.runtimeEnv = {};
319
- this.config = this.resolveConfig();
320
- }
321
-
322
- resolveConfig() {
323
- const homeDir = os.homedir();
324
- const pluginRootDir = path.join(homeDir, '.manyoyo', 'plugin', 'playwright');
325
- const defaultConfig = {
326
- homeDir,
327
- runtime: 'mixed',
328
- enabledScenes: [...SCENE_ORDER],
329
- cliSessionScene: 'cli-host-headless',
330
- mcpDefaultHost: 'host.docker.internal',
331
- dockerTag: process.env.PLAYWRIGHT_MCP_DOCKER_TAG || 'latest',
332
- containerRuntime: '',
333
- vncPasswordEnvKey: 'VNC_PASSWORD',
334
- headedImage: 'localhost/xcanwin/manyoyo-playwright-headed',
335
- configDir: path.join(pluginRootDir, 'config'),
336
- runDir: path.join(pluginRootDir, 'run'),
337
- extensionProdversion: '132.0.0.0',
338
- navigatorPlatform: platformFromUserAgent(DEFAULT_FINGERPRINT_PROFILE.userAgent),
339
- disableWebRTC: false,
340
- devtoolsActivePortPath: '',
341
- devtoolsCdpTimeout: 60000,
342
- composeDir: path.join(__dirname, 'playwright-assets'),
343
- ports: {
344
- mcpContHeadless: 8931,
345
- mcpContHeaded: 8932,
346
- mcpHostHeadless: 8933,
347
- mcpHostHeaded: 8934,
348
- cliHostHeadless: 8935,
349
- cliHostHeaded: 8936,
350
- mcpContHeadedNoVnc: 6080
351
- }
141
+ this.runtime = options.runtime || null;
142
+ this.homeDir = options.homeDir || os.homedir();
143
+ this.root = path.join(this.homeDir, '.manyoyo', 'plugin', 'playwright');
144
+ this.currentDir = path.join(this.root, 'current');
145
+ this.runDir = path.join(this.root, 'run');
146
+ this.config = this.resolveConfig(asObject(options.globalConfig), asObject(options.runConfig));
147
+ }
148
+
149
+ resolveConfig(globalConfig, runConfig) {
150
+ const merged = { ...globalConfig, ...runConfig };
151
+ const host = fingerprint.detectHostProfile();
152
+ const port = name => {
153
+ const value = Number(merged[name] || DEFAULT_PORTS[name]);
154
+ if (!Number.isInteger(value) || value <= 0 || value > 65535) {
155
+ throw new Error(`plugins.playwright.${name} 无效: ${merged[name]}`);
156
+ }
157
+ return value;
352
158
  };
353
-
354
- const merged = {
355
- ...defaultConfig,
356
- ...this.globalConfig,
357
- ...this.runConfig,
358
- ports: {
359
- ...defaultConfig.ports,
360
- ...asObject(this.globalConfig.ports),
361
- ...asObject(this.runConfig.ports)
362
- }
159
+ return {
160
+ port: port('port'),
161
+ vncPort: port('vncPort'),
162
+ novncPort: port('novncPort'),
163
+ locale: String(merged.locale || '').trim() || host.locale,
164
+ timezoneId: String(merged.timezoneId || '').trim() || host.timezoneId,
165
+ navigatorPlatform: String(merged.navigatorPlatform || '').trim(),
166
+ disableWebRTC: merged.disableWebRTC === true || String(merged.disableWebRTC).toLowerCase() === 'true',
167
+ devtoolsActivePortPath: String(merged.devtoolsActivePortPath || '').trim(),
168
+ extensionProdversion: String(merged.extensionProdversion || '').trim() || '132.0.0.0'
363
169
  };
364
-
365
- merged.runtime = String(merged.runtime || defaultConfig.runtime).trim().toLowerCase();
366
- if (!VALID_RUNTIME.has(merged.runtime)) {
367
- throw new Error(`playwright.runtime 无效: ${merged.runtime}`);
368
- }
369
-
370
- merged.enabledScenes = asStringArray(
371
- this.runConfig.enabledScenes,
372
- asStringArray(this.globalConfig.enabledScenes, [...defaultConfig.enabledScenes])
373
- );
374
- merged.containerRuntime = this.resolveContainerRuntime(merged.containerRuntime);
375
- merged.cliSessionScene = String(merged.cliSessionScene || defaultConfig.cliSessionScene).trim();
376
- merged.navigatorPlatform = String(merged.navigatorPlatform || defaultConfig.navigatorPlatform).trim() || defaultConfig.navigatorPlatform;
377
- merged.disableWebRTC = asBoolean(merged.disableWebRTC, defaultConfig.disableWebRTC);
378
-
379
- if (merged.enabledScenes.length === 0) {
380
- throw new Error('playwright.enabledScenes 不能为空');
381
- }
382
-
383
- const invalidScene = merged.enabledScenes.find(scene => !SCENE_DEFS[scene]);
384
- if (invalidScene) {
385
- throw new Error(`playwright.enabledScenes 包含未知场景: ${invalidScene}`);
386
- }
387
- merged.devtoolsActivePortPath = String(merged.devtoolsActivePortPath || '').trim();
388
- merged.devtoolsCdpTimeout = Number(merged.devtoolsCdpTimeout) || defaultConfig.devtoolsCdpTimeout;
389
-
390
- if (merged.cliSessionScene && !isCliSessionScene(merged.cliSessionScene)) {
391
- throw new Error(`playwright.cliSessionScene 无效: ${merged.cliSessionScene}`);
392
- }
393
-
394
- return merged;
395
170
  }
396
171
 
397
- resolveContainerRuntime(configuredRuntime) {
398
- const configured = String(configuredRuntime || '').trim().toLowerCase();
399
- if (configured) {
400
- return configured;
401
- }
402
-
403
- const forced = resolveForcedRuntime({ configured: this.rootGlobalConfig.containerRuntime });
404
- if (forced) {
405
- this.runtimeEnv = forced.env;
406
- return forced.command;
407
- }
408
-
409
- const candidates = ['docker', 'podman'];
410
- for (const cmd of candidates) {
411
- if (this.ensureCommandAvailable(cmd)) {
412
- return cmd;
413
- }
414
- }
415
-
416
- return 'docker';
172
+ profile() {
173
+ const { locale, timezoneId, navigatorPlatform, disableWebRTC } = this.config;
174
+ return { locale, timezoneId, navigatorPlatform, disableWebRTC };
417
175
  }
418
176
 
419
- writeStdout(line = '') {
177
+ out(line = '') {
420
178
  this.stdout.write(`${line}\n`);
421
179
  }
422
180
 
423
- writeStderr(line = '') {
181
+ err(line = '') {
424
182
  this.stderr.write(`${line}\n`);
425
183
  }
426
184
 
427
- remindCliSessionScene(sceneName) {
428
- if (!isCliScene(sceneName)) {
429
- return;
430
- }
431
- if (sceneName !== 'cli-host-headed') {
432
- return;
433
- }
434
- if (this.cliHostContainerAttachConfigReady()) {
435
- return;
185
+ // ---------- 路径与状态 ----------
186
+
187
+ statePath() { return path.join(this.runDir, 'state.json'); }
188
+ tokenPath() { return path.join(this.runDir, 'token'); }
189
+ logPath() { return path.join(this.runDir, 'server.log'); }
190
+ hostConfigPath() { return path.join(this.root, 'host.json'); }
191
+
192
+ ensureDirs() {
193
+ for (const dir of [this.root, this.currentDir, this.runDir]) {
194
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
436
195
  }
437
- this.writeStdout('[tip] 如果希望容器内 manyoyo run 自动附着到当前 CLI 宿主场景,请在 ~/.manyoyo/manyoyo.json 中设置:');
438
- this.writeStdout('{');
439
- this.writeStdout(' "volumes": [');
440
- this.writeStdout(' "~/.manyoyo/.cache/ms-playwright:/root/.cache/ms-playwright"');
441
- this.writeStdout(' ],');
442
- this.writeStdout(' "plugins": {');
443
- this.writeStdout(' "playwright": {');
444
- this.writeStdout(' "cliSessionScene": "cli-host-headed"');
445
- this.writeStdout(' }');
446
- this.writeStdout(' }');
447
- this.writeStdout('}');
448
196
  }
449
197
 
450
- remoteDebuggingPageUrl() {
451
- return 'chrome://inspect/#remote-debugging';
198
+ readState() {
199
+ try {
200
+ const state = JSON.parse(fs.readFileSync(this.statePath(), 'utf8'));
201
+ return state && MODES.includes(state.mode) ? state : { mode: 'default' };
202
+ } catch {
203
+ return { mode: 'default' };
204
+ }
452
205
  }
453
206
 
454
- openChromeRemoteDebuggingPage() {
455
- const url = this.remoteDebuggingPageUrl();
456
- const platform = process.platform;
457
- let commands = [];
458
-
459
- if (platform === 'darwin') {
460
- commands = [
461
- ['open', '-a', 'Google Chrome', url],
462
- ['open', url]
463
- ];
464
- } else if (platform === 'win32') {
465
- commands = [
466
- ['cmd', '/c', 'start', '', 'chrome', url]
467
- ];
468
- } else {
469
- commands = [
470
- ['google-chrome', url],
471
- ['google-chrome-stable', url],
472
- ['chromium', url],
473
- ['chromium-browser', url]
474
- ];
475
- }
207
+ writeState(state) {
208
+ this.ensureDirs();
209
+ writeConfigFileSecure(this.statePath(), `${JSON.stringify(state, null, 4)}\n`);
210
+ }
476
211
 
477
- for (const args of commands) {
478
- const result = this.runCmd(args, { check: false, captureOutput: true });
479
- if (result.returncode === 0) {
480
- return true;
212
+ // token 首次生成后持久化,重启服务不换;只有 down 才会让它失效
213
+ ensureToken() {
214
+ this.ensureDirs();
215
+ try {
216
+ const token = fs.readFileSync(this.tokenPath(), 'utf8').trim();
217
+ if (/^[0-9a-f]{64}$/.test(token)) {
218
+ return token;
481
219
  }
220
+ } catch {
221
+ // 还没有,下面生成
482
222
  }
483
- return false;
223
+ const token = crypto.randomBytes(32).toString('hex');
224
+ writeConfigFileSecure(this.tokenPath(), `${token}\n`);
225
+ return token;
484
226
  }
485
227
 
486
- writeDevHostUsage(configPath) {
487
- this.writeStdout('');
488
- this.writeStdout('若用容器 Agent 操作宿主机浏览器,在宿主机执行:');
489
- ['claude', 'codex', 'gemini'].forEach(runName => {
490
- this.writeStdout(`my run -r ${runName}`);
491
- });
492
- this.writeStdout('');
493
- this.writeStdout('若用宿主机 Agent 操作宿主机浏览器,在宿主机执行:');
494
- ['claude', 'codex', 'gemini'].forEach(command => {
495
- this.writeStdout(this.devHostAttachCommand(configPath, command));
496
- });
228
+ readPersisted(name) {
229
+ try {
230
+ return fs.readFileSync(path.join(this.runDir, name), 'utf8').trim();
231
+ } catch {
232
+ return '';
233
+ }
497
234
  }
498
235
 
499
- randomAlnum(length = 16) {
500
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
501
- let out = '';
502
- for (let i = 0; i < length; i += 1) {
503
- out += chars[crypto.randomInt(0, chars.length)];
236
+ ensureVncPassword() {
237
+ const existing = this.readPersisted('vnc-password');
238
+ if (existing) {
239
+ return existing;
504
240
  }
505
- return out;
241
+ const password = randomAlnum(8);
242
+ writeConfigFileSecure(path.join(this.runDir, 'vnc-password'), `${password}\n`);
243
+ return password;
506
244
  }
507
245
 
508
- runCmd(args, { env = null, captureOutput = false, check = true } = {}) {
509
- const isRuntimeCommand = args[0] === this.config.containerRuntime;
510
- const result = spawnSync(args[0], args.slice(1), {
511
- encoding: 'utf8',
512
- env: isRuntimeCommand ? { ...(env || process.env), ...this.runtimeEnv } : (env || process.env),
513
- stdio: captureOutput ? ['ignore', 'pipe', 'pipe'] : 'inherit'
514
- });
515
-
516
- const completed = {
517
- returncode: result.status === null ? 1 : result.status,
518
- stdout: typeof result.stdout === 'string' ? result.stdout : '',
519
- stderr: typeof result.stderr === 'string' ? result.stderr : ''
520
- };
246
+ // ---------- 容器侧配置(挂载目录里的 config.json) ----------
521
247
 
522
- if (result.error) {
523
- if (check) {
524
- throw result.error;
525
- }
526
- completed.returncode = 1;
527
- return completed;
248
+ containerConfigFor(state, runtimeCommand) {
249
+ const profile = this.profile();
250
+ const host = connectHostFor(runtimeCommand);
251
+ if (state.mode === 'chrome') {
252
+ return fingerprint.buildContainerConfig('cdp', {
253
+ ...profile,
254
+ endpoint: `ws://${host}:${state.port}/${this.ensureToken()}`
255
+ });
528
256
  }
529
-
530
- if (check && completed.returncode !== 0) {
531
- const error = new Error(`command failed with exit code ${completed.returncode}`);
532
- error.returncode = completed.returncode;
533
- error.stdout = completed.stdout;
534
- error.stderr = completed.stderr;
535
- throw error;
257
+ if (state.mode === 'headed' || state.mode === 'vnc') {
258
+ return fingerprint.buildContainerConfig('ws', {
259
+ ...profile,
260
+ endpoint: `ws://${host}:${state.port}/${this.ensureToken()}`
261
+ });
536
262
  }
537
-
538
- return completed;
263
+ return fingerprint.buildContainerConfig('default', profile);
539
264
  }
540
265
 
541
- ensureCommandAvailable(command) {
542
- const name = String(command || '').trim();
543
- if (path.isAbsolute(name)) {
544
- return fs.existsSync(name);
266
+ // 原子替换:目录挂载能看到 rename 之后的新文件
267
+ writeCurrentConfig(state, runtimeCommand) {
268
+ this.ensureDirs();
269
+ const config = `${JSON.stringify(this.containerConfigFor(state, runtimeCommand), null, 4)}\n`;
270
+ const initScript = fingerprint.buildInitScript(this.profile());
271
+ const configPath = path.join(this.currentDir, 'config.json');
272
+ const initPath = path.join(this.currentDir, 'stealth.init.js');
273
+ const same = (file, content) => {
274
+ try {
275
+ return fs.readFileSync(file, 'utf8') === content;
276
+ } catch {
277
+ return false;
278
+ }
279
+ };
280
+ if (!same(initPath, initScript)) {
281
+ writeConfigFileSecure(initPath, initScript);
545
282
  }
546
- if (!/^[A-Za-z0-9._-]+$/.test(name)) {
547
- return false;
283
+ if (!same(configPath, config)) {
284
+ writeConfigFileSecure(configPath, config);
548
285
  }
549
- const result = spawnSync('sh', ['-c', `command -v ${name}`], {
550
- encoding: 'utf8',
551
- stdio: ['ignore', 'ignore', 'ignore']
552
- });
553
- return result.status === 0;
286
+ return configPath;
554
287
  }
555
288
 
556
- scenePort(sceneName) {
557
- const def = SCENE_DEFS[sceneName];
558
- return Number(this.config.ports[def.portKey]);
559
- }
560
-
561
- sceneConfigPath(sceneName) {
562
- const def = SCENE_DEFS[sceneName];
563
- return path.join(this.config.configDir, def.configFile);
289
+ // 宿主机上的 Agent 用的配置(路径与端点都是宿主机视角)
290
+ writeHostConfig(state) {
291
+ const profile = this.profile();
292
+ const endpoint = `ws://127.0.0.1:${state.port}/${this.ensureToken()}`;
293
+ const initPath = path.join(this.root, 'host.init.js');
294
+ writeConfigFileSecure(initPath, fingerprint.buildInitScript(profile));
295
+ const config = state.mode === 'chrome'
296
+ ? fingerprint.buildContainerConfig('cdp', { endpoint })
297
+ : fingerprint.buildContainerConfig('ws', { ...profile, endpoint });
298
+ config.outputDir = path.join(os.tmpdir(), '.playwright-cli');
299
+ if (config.browser.initScript) {
300
+ config.browser.initScript = [initPath];
301
+ }
302
+ writeConfigFileSecure(this.hostConfigPath(), `${JSON.stringify(config, null, 4)}\n`);
303
+ return this.hostConfigPath();
564
304
  }
565
305
 
566
- sceneConfigMissing(sceneName) {
567
- return !fs.existsSync(this.sceneConfigPath(sceneName));
306
+ userDisplayPath(filePath) {
307
+ return filePath.startsWith(`${this.homeDir}${path.sep}`) ? `~${filePath.slice(this.homeDir.length)}` : filePath;
568
308
  }
569
309
 
570
- sceneEndpointPath(sceneName) {
571
- return path.join(this.config.runDir, `${sceneName}.endpoint.json`);
572
- }
310
+ // ---------- 容器集成(CLI 与 Web 共用) ----------
573
311
 
574
- readSceneEndpoint(sceneName) {
575
- const filePath = this.sceneEndpointPath(sceneName);
576
- if (!fs.existsSync(filePath)) {
577
- return null;
312
+ // 模式是否真的还活着(只看进程与端口,足够快;真实探测见 probe)
313
+ async modeAlive(state) {
314
+ if (state.mode === 'default') {
315
+ return true;
578
316
  }
579
- try {
580
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
581
- } catch {
582
- return null;
317
+ if (state.mode === 'vnc') {
318
+ // 发布端口由运行时转发,容器没了端口也可能还能连上,所以看容器状态
319
+ try {
320
+ const inspect = this.runtimeCall(['inspect', '--format', '{{.State.Running}}', VNC_CONTAINER]);
321
+ return inspect.status === 0 && inspect.stdout.trim() === 'true' && await portReady(state.port);
322
+ } catch {
323
+ return false;
324
+ }
583
325
  }
326
+ return pidIsOurs(state.pid) && await portReady(state.port);
584
327
  }
585
328
 
586
- writeSceneEndpoint(sceneName, payload) {
587
- fs.mkdirSync(this.config.runDir, { recursive: true });
588
- fs.writeFileSync(this.sceneEndpointPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
589
- }
590
-
591
- removeSceneEndpoint(sceneName) {
592
- fs.rmSync(this.sceneEndpointPath(sceneName), { force: true });
593
- }
594
-
595
- sceneCliAttachConfigPath(sceneName) {
596
- return path.join(this.config.runDir, `${sceneName}.cli-attach.json`);
329
+ // 返回 { env, volumes, extraArgs, warning };永远不抛错,不因 playwright 让 run 退出
330
+ async buildContainerIntegration({ runtimeCommand, envEntries = [], dryRun = false } = {}) {
331
+ let warning = '';
332
+ try {
333
+ // 预览命令(dryRun)不写任何文件、不改状态
334
+ if (!dryRun) {
335
+ this.ensureDirs();
336
+ }
337
+ let state = this.readState();
338
+ if (dryRun) {
339
+ state = { mode: 'default' };
340
+ } else if (!(await this.modeAlive(state))) {
341
+ warning = `Playwright ${state.mode} 模式已失效,容器内浏览器已回退到默认模式。恢复: manyoyo playwright up ${state.mode}`;
342
+ state = { mode: 'default' };
343
+ this.writeState(state);
344
+ }
345
+ if (!dryRun) {
346
+ this.writeCurrentConfig(state, runtimeCommand);
347
+ }
348
+ } catch (error) {
349
+ return { env: [], volumes: [], extraArgs: [], warning: `Playwright 集成不可用: ${error.message || String(error)}` };
350
+ }
351
+ const noProxy = appendNoProxy(envEntryValue(envEntries, 'NO_PROXY') ?? envEntryValue(envEntries, 'no_proxy')
352
+ ?? process.env.NO_PROXY ?? process.env.no_proxy);
353
+ return {
354
+ env: [
355
+ `PLAYWRIGHT_MCP_CONFIG=${fingerprint.CONTAINER_CONFIG_PATH}`,
356
+ 'NO_UPDATE_NOTIFIER=1',
357
+ `NO_PROXY=${noProxy}`,
358
+ `no_proxy=${noProxy}`
359
+ ],
360
+ volumes: [`${this.currentDir}:${fingerprint.CONTAINER_CONFIG_DIR}:ro`],
361
+ extraArgs: runtimeKind(runtimeCommand) === 'docker' ? ['--add-host', 'host.docker.internal:host-gateway'] : [],
362
+ warning
363
+ };
597
364
  }
598
365
 
599
- writeSceneCliAttachConfig(sceneName, payload) {
600
- fs.mkdirSync(this.config.runDir, { recursive: true });
601
- fs.writeFileSync(this.sceneCliAttachConfigPath(sceneName), `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
602
- }
366
+ // ---------- 运行时与命令 ----------
603
367
 
604
- removeSceneCliAttachConfig(sceneName) {
605
- fs.rmSync(this.sceneCliAttachConfigPath(sceneName), { force: true });
368
+ resolveRuntime() {
369
+ if (!this.runtime) {
370
+ const { selectContainerRuntime } = require('../container-runtime');
371
+ this.runtime = selectContainerRuntime({ configured: this.rootGlobalConfig.containerRuntime });
372
+ }
373
+ return this.runtime;
606
374
  }
607
375
 
608
- buildDevHostAttachConfig(endpoint, cdpHost) {
376
+ runtimeCall(args, { capture = true } = {}) {
377
+ const runtime = this.resolveRuntime();
378
+ const result = spawnSync(runtime.command, args, {
379
+ encoding: 'utf8',
380
+ env: { ...process.env, ...runtime.env },
381
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
382
+ });
609
383
  return {
610
- outputDir: '/tmp/.playwright-cli',
611
- browser: {
612
- cdpEndpoint: `ws://${cdpHost}:${endpoint.port}${endpoint.wsPath}`,
613
- cdpHeaders: {
614
- Host: `127.0.0.1:${endpoint.port}`
615
- },
616
- cdpTimeout: this.config.devtoolsCdpTimeout
617
- }
384
+ status: result.error ? 1 : (result.status === null ? 1 : result.status),
385
+ stdout: result.stdout || '',
386
+ stderr: result.stderr || (result.error ? String(result.error.message) : '')
618
387
  };
619
388
  }
620
389
 
621
- writeDevHostAttachConfig(sceneName, endpoint) {
622
- fs.mkdirSync(this.config.configDir, { recursive: true });
623
- const filePath = this.sceneConfigPath(sceneName);
624
- fs.writeFileSync(filePath, `${JSON.stringify(this.buildDevHostAttachConfig(endpoint, '127.0.0.1'), null, 4)}\n`, 'utf8');
625
- return filePath;
390
+ corePath() {
391
+ const cliPackage = require.resolve('@playwright/cli/package.json');
392
+ return path.dirname(require.resolve('playwright-core/package.json', { paths: [path.dirname(cliPackage)] }));
626
393
  }
627
394
 
628
- removeDevHostAttachConfig(sceneName) {
629
- fs.rmSync(this.sceneConfigPath(sceneName), { force: true });
395
+ coreCli() {
396
+ return path.join(this.corePath(), 'cli.js');
630
397
  }
631
398
 
632
- userDisplayPath(filePath) {
633
- const normalizedPath = path.resolve(filePath);
634
- const normalizedHome = path.resolve(this.config.homeDir);
635
- if (normalizedPath === normalizedHome) {
636
- return '~';
637
- }
638
- if (normalizedPath.startsWith(`${normalizedHome}${path.sep}`)) {
639
- return `~${normalizedPath.slice(normalizedHome.length)}`;
640
- }
641
- return filePath;
399
+ // 浏览器与探测都用 @playwright/cli 自带的 playwright-core,保证与容器内版本一致
400
+ installCommand(args) {
401
+ return `node ${shellValue(this.coreCli())} ${args}`;
642
402
  }
643
403
 
644
- shellValue(value) {
645
- const text = String(value);
646
- if (/^[A-Za-z0-9_@%+=:,./~-]+$/.test(text)) {
647
- return text;
404
+ ensureBrowserInstalled() {
405
+ const result = spawnSync(process.execPath, [this.coreCli(), 'install', '--no-shell', 'chromium'], { stdio: 'inherit' });
406
+ if (result.status !== 0) {
407
+ throw new Error(`浏览器安装失败,请手动执行: ${this.installCommand('install --no-shell chromium')}`);
648
408
  }
649
- return `'${text.replace(/'/g, "'\\''")}'`;
650
409
  }
651
410
 
652
- devHostAttachEnvEntries(configPath) {
653
- return [
654
- `PLAYWRIGHT_MCP_CONFIG=${this.shellValue(this.userDisplayPath(configPath))}`
655
- ];
656
- }
411
+ // ---------- 探测 ----------
657
412
 
658
- devHostAttachCommand(configPath, command) {
659
- return `${this.devHostAttachEnvEntries(configPath).join(' ')} ${command}`;
413
+ probe(kind, endpoint) {
414
+ return new Promise(resolve => {
415
+ const child = spawn(process.execPath, [path.join(__dirname, 'playwright-probe.js')], {
416
+ env: {
417
+ ...process.env,
418
+ MANYOYO_PROBE: JSON.stringify({ core: this.corePath(), kind, endpoint, timeout: PROBE_TIMEOUT_MS })
419
+ },
420
+ stdio: ['ignore', 'pipe', 'ignore']
421
+ });
422
+ let output = '';
423
+ const timer = setTimeout(() => child.kill('SIGKILL'), PROBE_TIMEOUT_MS + 3000);
424
+ child.stdout.on('data', chunk => { output += chunk; });
425
+ child.on('error', () => {
426
+ clearTimeout(timer);
427
+ resolve({ ok: false, error: '无法启动探测进程' });
428
+ });
429
+ child.on('close', () => {
430
+ clearTimeout(timer);
431
+ try {
432
+ resolve(JSON.parse(output.trim().split('\n').pop()));
433
+ } catch {
434
+ resolve({ ok: false, error: '探测超时或无输出' });
435
+ }
436
+ });
437
+ });
660
438
  }
661
439
 
662
- expandHomePath(value) {
663
- const text = String(value || '').trim();
664
- if (text === '~') {
665
- return this.config.homeDir;
666
- }
667
- if (text.startsWith('~/')) {
668
- return path.join(this.config.homeDir, text.slice(2));
669
- }
670
- return text;
440
+ async probeState(state) {
441
+ const endpoint = `ws://127.0.0.1:${state.port}/${this.ensureToken()}`;
442
+ return await this.probe(state.mode === 'chrome' ? 'cdp' : 'ws', endpoint);
671
443
  }
672
444
 
673
- parseVolumeEntry(value) {
674
- const parts = String(value || '').split(':');
675
- if (parts.length < 2) {
676
- return null;
677
- }
678
- return {
679
- hostPath: path.resolve(this.expandHomePath(parts[0])),
680
- containerPath: parts[1]
681
- };
682
- }
445
+ // ---------- 进程管理 ----------
683
446
 
684
- mergedRootVolumes() {
685
- return [
686
- ...asStringArray(this.rootGlobalConfig.volumes, []),
687
- ...asStringArray(this.rootRunConfig.volumes, []),
688
- ...asStringArray(this.globalConfig.volumes, []),
689
- ...asStringArray(this.runConfig.volumes, [])
690
- ];
447
+ spawnDetached(command, args, logPath) {
448
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
449
+ const logFd = fs.openSync(logPath, 'a', 0o600);
450
+ const child = spawn(command, args, { detached: true, stdio: ['ignore', logFd, logFd] });
451
+ fs.closeSync(logFd);
452
+ child.unref();
453
+ return child;
691
454
  }
692
455
 
693
- hasRootVolume(required) {
694
- const parsedRequired = this.parseVolumeEntry(required);
695
- if (!parsedRequired) {
696
- return false;
456
+ async killGroup(pid, { verify = true } = {}) {
457
+ if (verify ? !pidIsOurs(pid) : !pidAlive(pid)) {
458
+ return;
459
+ }
460
+ for (const signal of ['SIGTERM', 'SIGKILL']) {
461
+ try {
462
+ process.kill(-pid, signal);
463
+ } catch {
464
+ try {
465
+ process.kill(pid, signal);
466
+ } catch {
467
+ return;
468
+ }
469
+ }
470
+ for (let i = 0; i < 20; i += 1) {
471
+ if (!pidAlive(pid)) {
472
+ return;
473
+ }
474
+ // eslint-disable-next-line no-await-in-loop
475
+ await sleep(100);
476
+ }
697
477
  }
698
- return this.mergedRootVolumes().some(item => {
699
- const parsed = this.parseVolumeEntry(item);
700
- return parsed
701
- && parsed.hostPath === parsedRequired.hostPath
702
- && parsed.containerPath === parsedRequired.containerPath;
703
- });
704
478
  }
705
479
 
706
- cliHostContainerAttachConfigReady() {
707
- return this.config.cliSessionScene === 'cli-host-headed'
708
- && this.hasRootVolume('~/.manyoyo/.cache/ms-playwright:/root/.cache/ms-playwright');
480
+ async waitForPort(port, child) {
481
+ const deadline = Date.now() + SERVER_START_TIMEOUT_MS;
482
+ while (Date.now() < deadline) {
483
+ if (child && child.exitCode !== null) {
484
+ return false;
485
+ }
486
+ // eslint-disable-next-line no-await-in-loop
487
+ if (await portReady(port)) {
488
+ return true;
489
+ }
490
+ // eslint-disable-next-line no-await-in-loop
491
+ await sleep(300);
492
+ }
493
+ return false;
709
494
  }
710
495
 
711
- devtoolsActivePortCandidates() {
712
- const homeDir = this.config.homeDir;
713
- const configured = this.config.devtoolsActivePortPath;
714
- return uniqueStrings([
715
- configured,
716
- path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'),
717
- path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome Canary', 'DevToolsActivePort'),
718
- path.join(homeDir, 'Library', 'Application Support', 'Chromium', 'DevToolsActivePort'),
719
- path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
720
- path.join(homeDir, '.config', 'google-chrome', 'DevToolsActivePort'),
721
- path.join(homeDir, '.config', 'chromium', 'DevToolsActivePort'),
722
- path.join(homeDir, '.config', 'google-chrome-beta', 'DevToolsActivePort'),
723
- path.join(homeDir, '.config', 'google-chrome-unstable', 'DevToolsActivePort'),
724
- path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
725
- path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'),
726
- path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome Beta', 'User Data', 'DevToolsActivePort'),
727
- path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome SxS', 'User Data', 'DevToolsActivePort'),
728
- path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data', 'DevToolsActivePort'),
729
- path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data', 'DevToolsActivePort')
730
- ]);
496
+ async stopCurrent(state) {
497
+ if (state.mode === 'vnc') {
498
+ this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
499
+ } else if (state.mode === 'headed' || state.mode === 'chrome') {
500
+ await this.killGroup(state.pid);
501
+ }
731
502
  }
732
503
 
733
- parseDevToolsActivePort(content, filePath = 'DevToolsActivePort') {
734
- const lines = String(content || '')
735
- .split(/\r?\n/)
736
- .map(line => line.trim())
737
- .filter(Boolean);
738
- const port = Number(lines[0]);
739
- const wsPath = lines[1] || '';
504
+ // ---------- 各模式启动 ----------
740
505
 
741
- if (!Number.isInteger(port) || port <= 0 || port > 65535) {
742
- throw new Error(`${filePath} 内容无效: 第一行必须是端口号`);
743
- }
744
- if (!wsPath.startsWith('/devtools/browser/')) {
745
- throw new Error(`${filePath} 内容无效: 第二行必须是 /devtools/browser/...`);
506
+ startFailure(mode, message, extra = '') {
507
+ this.err(`[up] ${mode} 失败: ${message}`);
508
+ if (extra) {
509
+ this.err(extra);
746
510
  }
747
-
748
- return { port, wsPath };
511
+ return 1;
749
512
  }
750
513
 
751
- readDevToolsActivePort() {
752
- const candidates = this.devtoolsActivePortCandidates();
753
- for (const candidate of candidates) {
754
- if (!fs.existsSync(candidate)) {
755
- continue;
756
- }
757
- const content = fs.readFileSync(candidate, 'utf8');
758
- return {
759
- ...this.parseDevToolsActivePort(content, candidate),
760
- filePath: candidate
761
- };
514
+ async portPreflight(mode, port) {
515
+ if (await portReady(port)) {
516
+ this.startFailure(mode, `端口 ${port} 已被占用。`,
517
+ '换一个端口: 在 ~/.manyoyo/manyoyo.json 设置 "plugins": { "playwright": { "port": 9335 } },或先停掉占用它的进程。');
518
+ return false;
762
519
  }
763
-
764
- throw new Error(`未找到 DevToolsActivePort,请先在 Chrome 启用 remote debugging,并挂载或配置 devtoolsActivePortPath。已检查: ${candidates.join(', ')}`);
520
+ return true;
765
521
  }
766
522
 
767
- sceneInitScriptPath(sceneName) {
768
- const configFile = path.basename(this.sceneConfigPath(sceneName), '.json');
769
- return path.join(this.config.configDir, `${configFile}.init.js`);
523
+ async startHeaded(extensionPaths) {
524
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
525
+ return this.startFailure('headed', '当前没有图形界面(DISPLAY / WAYLAND_DISPLAY 为空)。', '没有桌面的机器请改用: manyoyo playwright up vnc');
526
+ }
527
+ const { port } = this.config;
528
+ if (!(await this.portPreflight('headed', port))) {
529
+ return 1;
530
+ }
531
+ try {
532
+ this.ensureBrowserInstalled();
533
+ } catch (error) {
534
+ return this.startFailure('headed', error.message);
535
+ }
536
+ const token = this.ensureToken();
537
+ const serverConfigPath = path.join(this.runDir, 'server.json');
538
+ // 优先开沙箱;系统禁止非特权用户命名空间(如 Ubuntu 23.10+ 的 AppArmor 限制)时退回无沙箱
539
+ let child = null;
540
+ for (const chromiumSandbox of [true, false]) {
541
+ writeConfigFileSecure(serverConfigPath, `${JSON.stringify(fingerprint.buildServerConfig({
542
+ host: defaultListenHost(),
543
+ port,
544
+ wsPath: `/${token}`,
545
+ chromiumSandbox,
546
+ extensionArgs: extensions.buildExtensionLaunchArgs(extensionPaths),
547
+ ...this.profile()
548
+ }), null, 4)}\n`);
549
+ fs.rmSync(this.logPath(), { force: true });
550
+ child = this.spawnDetached(process.execPath,
551
+ [path.join(__dirname, 'playwright-server.js'), this.corePath(), serverConfigPath], this.logPath());
552
+ if (await this.waitForPort(port, child)) {
553
+ break;
554
+ }
555
+ await this.killGroup(child.pid, { verify: false });
556
+ if (!chromiumSandbox || !tailText(this.logPath(), 200).includes('No usable sandbox')) {
557
+ return this.startFailure('headed', '浏览器服务没有启动成功。',
558
+ `${tailText(this.logPath(), 20)}\n缺系统依赖时执行: sudo ${this.installCommand('install-deps chromium')}`);
559
+ }
560
+ this.out('[up] 系统不允许非特权用户命名空间,Chromium 沙箱不可用,改为无沙箱启动。');
561
+ }
562
+ return { mode: 'headed', port, pid: child.pid, listenHost: defaultListenHost(), extensionPaths };
563
+ }
564
+
565
+ async startChrome() {
566
+ const { port } = this.config;
567
+ const candidates = this.devtoolsCandidates();
568
+ if (!findDevToolsActivePort(candidates)) {
569
+ return this.startFailure('chrome', '没有找到正在运行且开启了远程调试的 Chrome。',
570
+ '在 Chrome 地址栏打开 chrome://inspect/#remote-debugging 并开启后重试;\n'
571
+ + '自定义用户目录时,在 plugins.playwright.devtoolsActivePortPath 指定 DevToolsActivePort 文件。');
572
+ }
573
+ if (!(await this.portPreflight('chrome', port))) {
574
+ return 1;
575
+ }
576
+ const relayConfigPath = path.join(this.runDir, 'relay.json');
577
+ writeConfigFileSecure(relayConfigPath, `${JSON.stringify({
578
+ host: defaultListenHost(), port, token: this.ensureToken(), candidates
579
+ }, null, 4)}\n`);
580
+ const child = this.spawnDetached(process.execPath,
581
+ [path.join(__dirname, 'playwright-relay.js'), relayConfigPath], this.logPath());
582
+ if (!(await this.waitForPort(port, child))) {
583
+ await this.killGroup(child.pid, { verify: false });
584
+ return this.startFailure('chrome', '中继没有启动成功。', tailText(this.logPath(), 20));
585
+ }
586
+ return { mode: 'chrome', port, pid: child.pid, listenHost: defaultListenHost() };
770
587
  }
771
588
 
772
- buildInitScriptContent() {
773
- const lines = [
774
- "'use strict';",
775
- '(function () {',
776
- ` const platformValue = ${JSON.stringify(this.config.navigatorPlatform)};`,
777
- ' try {',
778
- ' const navProto = Object.getPrototypeOf(navigator);',
779
- " Object.defineProperty(navProto, 'platform', {",
780
- ' configurable: true,',
781
- ' get: () => platformValue',
782
- ' });',
783
- ' } catch (_) {}'
589
+ devtoolsCandidates() {
590
+ const h = this.homeDir;
591
+ const local = path.join(h, 'AppData', 'Local');
592
+ return [
593
+ this.config.devtoolsActivePortPath,
594
+ path.join(h, 'Library', 'Application Support', 'Google', 'Chrome', 'DevToolsActivePort'),
595
+ path.join(h, 'Library', 'Application Support', 'Google', 'Chrome Canary', 'DevToolsActivePort'),
596
+ path.join(h, 'Library', 'Application Support', 'Chromium', 'DevToolsActivePort'),
597
+ path.join(h, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
598
+ path.join(h, '.config', 'google-chrome', 'DevToolsActivePort'),
599
+ path.join(h, '.config', 'google-chrome-beta', 'DevToolsActivePort'),
600
+ path.join(h, '.config', 'google-chrome-unstable', 'DevToolsActivePort'),
601
+ path.join(h, '.config', 'chromium', 'DevToolsActivePort'),
602
+ path.join(h, '.config', 'BraveSoftware', 'Brave-Browser', 'DevToolsActivePort'),
603
+ path.join(local, 'Google', 'Chrome', 'User Data', 'DevToolsActivePort'),
604
+ path.join(local, 'Google', 'Chrome Beta', 'User Data', 'DevToolsActivePort'),
605
+ path.join(local, 'Chromium', 'User Data', 'DevToolsActivePort'),
606
+ path.join(local, 'BraveSoftware', 'Brave-Browser', 'User Data', 'DevToolsActivePort')
607
+ ].filter(Boolean);
608
+ }
609
+
610
+ baseImageRef() {
611
+ const global = this.rootGlobalConfig;
612
+ const run = this.rootRunConfig;
613
+ const name = String(run.imageName || global.imageName || 'ghcr.io/xcanwin/manyoyo').trim();
614
+ const version = String(run.imageVersion || global.imageVersion || require('../../package.json').imageVersion).trim();
615
+ return { name, version, ref: `${name}:${version}` };
616
+ }
617
+
618
+ // vnc 容器直接用当前 manyoyo 镜像(已带 xvfb / x11vnc / noVNC),不需要额外构建
619
+ ensureBaseImage() {
620
+ const { ref } = this.baseImageRef();
621
+ if (this.runtimeCall(['image', 'inspect', '--format', '{{.Id}}', ref]).status !== 0) {
622
+ throw new Error(`镜像 ${ref} 不存在,请先运行一次 manyoyo run 拉取或构建镜像。`);
623
+ }
624
+ return ref;
625
+ }
626
+
627
+ async startVnc(extensionPaths) {
628
+ const { port, vncPort, novncPort } = this.config;
629
+ for (const item of [port, vncPort, novncPort]) {
630
+ if (await portReady(item)) {
631
+ return this.startFailure('vnc', `端口 ${item} 已被占用。`,
632
+ '在 ~/.manyoyo/manyoyo.json 的 plugins.playwright 里改 port / vncPort / novncPort,或先停掉占用它的进程。');
633
+ }
634
+ }
635
+ let image;
636
+ try {
637
+ image = this.ensureBaseImage();
638
+ } catch (error) {
639
+ return this.startFailure('vnc', error.message);
640
+ }
641
+ const token = this.ensureToken();
642
+ const password = this.ensureVncPassword();
643
+ const mountDir = path.join(this.runDir, 'vnc');
644
+ fs.mkdirSync(mountDir, { recursive: true, mode: 0o700 });
645
+ const mounts = extensions.buildContainerExtensionMounts(extensionPaths);
646
+ for (const name of ['vnc-entry.sh', 'playwright-server.js']) {
647
+ const source = path.join(name === 'vnc-entry.sh' ? VNC_ASSET_DIR : __dirname, name);
648
+ writeConfigFileSecure(path.join(mountDir, name), fs.readFileSync(source, 'utf8'));
649
+ }
650
+ writeConfigFileSecure(path.join(mountDir, 'server.json'), `${JSON.stringify(fingerprint.buildServerConfig({
651
+ host: '0.0.0.0',
652
+ port,
653
+ wsPath: `/${token}`,
654
+ window: 'virtual',
655
+ chromiumSandbox: false,
656
+ extensionArgs: extensions.buildExtensionLaunchArgs(mounts.containerPaths),
657
+ ...this.profile()
658
+ }), null, 4)}\n`);
659
+ const envFile = path.join(mountDir, 'env');
660
+ const noProxy = appendNoProxy(process.env.NO_PROXY ?? process.env.no_proxy);
661
+ writeConfigFileSecure(envFile, `VNC_PASSWORD=${password}\nNO_PROXY=${noProxy}\nno_proxy=${noProxy}\n`);
662
+ this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
663
+ const listenHost = defaultListenHost();
664
+ const args = [
665
+ 'run', '-d', '--name', VNC_CONTAINER,
666
+ '--env-file', envFile,
667
+ '--publish', `${listenHost}:${port}:${port}`,
668
+ '--publish', `127.0.0.1:${vncPort}:5900`,
669
+ '--publish', `127.0.0.1:${novncPort}:6080`,
670
+ '--volume', `${mountDir}:${VNC_MOUNT_DIR}:ro`,
671
+ ...mounts.volumes.flatMap(volume => ['--volume', volume]),
672
+ '--entrypoint', 'sh',
673
+ image, `${VNC_MOUNT_DIR}/vnc-entry.sh`
784
674
  ];
785
-
786
- if (this.config.disableWebRTC) {
787
- lines.push(
788
- ' try {',
789
- ' const scope = globalThis;',
790
- " const blocked = ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCIceCandidate', 'RTCRtpSender', 'RTCRtpReceiver', 'RTCRtpTransceiver', 'RTCDataChannel'];",
791
- ' for (const name of blocked) {',
792
- " Object.defineProperty(scope, name, { configurable: true, writable: true, value: undefined });",
793
- ' }',
794
- ' if (navigator.mediaDevices) {',
795
- ' const errorFactory = () => {',
796
- ' try {',
797
- " return new DOMException('WebRTC is disabled', 'NotAllowedError');",
798
- ' } catch (_) {',
799
- " const error = new Error('WebRTC is disabled');",
800
- " error.name = 'NotAllowedError';",
801
- ' return error;',
802
- ' }',
803
- ' };',
804
- " Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {",
805
- ' configurable: true,',
806
- ' writable: true,',
807
- ' value: async () => { throw errorFactory(); }',
808
- ' });',
809
- ' }',
810
- ' } catch (_) {}'
811
- );
675
+ const run = this.runtimeCall(args);
676
+ if (run.status !== 0) {
677
+ return this.startFailure('vnc', '容器启动失败。', run.stderr.trim());
678
+ }
679
+ // 发布端口由运行时转发,容器没起来时 TCP 也能连上,所以以浏览器服务打印的 ready 为准
680
+ const isReady = () => /^ready$/m.test(this.runtimeCall(['logs', '--tail', '200', VNC_CONTAINER]).stdout);
681
+ let started = false;
682
+ for (let i = 0; i < 100 && !started; i += 1) {
683
+ started = isReady();
684
+ if (!started) {
685
+ // eslint-disable-next-line no-await-in-loop
686
+ await sleep(300);
687
+ }
812
688
  }
813
-
814
- lines.push('})();', '');
815
- return lines.join('\n');
689
+ if (!started) {
690
+ const logs = this.runtimeCall(['logs', '--tail', '30', VNC_CONTAINER]);
691
+ this.runtimeCall(['rm', '-f', VNC_CONTAINER]);
692
+ return this.startFailure('vnc', '浏览器服务没有启动成功。', `${logs.stdout}${logs.stderr}`.trim());
693
+ }
694
+ return { mode: 'vnc', port, container: VNC_CONTAINER, listenHost, novncPort, vncPort, extensionPaths };
816
695
  }
817
696
 
818
- ensureSceneInitScript(sceneName) {
819
- const filePath = this.sceneInitScriptPath(sceneName);
820
- const content = this.buildInitScriptContent();
821
- fs.writeFileSync(filePath, content, 'utf8');
822
- return filePath;
697
+ novncUrl() {
698
+ return `http://127.0.0.1:${this.config.novncPort}/vnc.html?autoconnect=1&resize=scale&password=${this.readPersisted('vnc-password')}`;
823
699
  }
824
700
 
825
- defaultBrowserName(sceneName) {
826
- if (isCliScene(sceneName)) {
827
- return 'chromium';
701
+ // ---------- 命令 ----------
702
+
703
+ printOverview() {
704
+ this.out('Playwright 浏览器模式(同一时间只有一个):');
705
+ for (const [name, desc] of MODE_HELP) {
706
+ this.out(` ${name.padEnd(8)}${desc}`);
828
707
  }
829
- const cfg = this.buildSceneConfig(sceneName);
830
- const browserName = cfg && cfg.browser && cfg.browser.browserName;
831
- return String(browserName || 'chromium');
708
+ this.out('');
709
+ this.out('用法: manyoyo playwright up <headed|chrome|vnc> 切换模式');
710
+ this.out(' manyoyo playwright down 回到默认模式');
711
+ this.out(' manyoyo playwright status 查看当前模式与真实可用性');
832
712
  }
833
713
 
834
- ensureContainerScenePrerequisites(sceneName) {
835
- if (!this.sceneConfigMissing(sceneName)) {
714
+ printUsage(state) {
715
+ this.out('');
716
+ this.out('容器内 Agent: 自动生效,直接 manyoyo run;已打开浏览器的会话先执行 playwright-cli close 再重新 open。');
717
+ if (state.mode === 'default') {
836
718
  return;
837
719
  }
838
- const tag = String(this.config.dockerTag || 'latest').trim() || 'latest';
839
- const image = `mcr.microsoft.com/playwright/mcp:${tag}`;
840
- this.runCmd([this.config.containerRuntime, 'pull', image], { check: true });
720
+ const configPath = this.userDisplayPath(this.writeHostConfig(state));
721
+ this.out(`宿主机上的 Agent: PLAYWRIGHT_MCP_CONFIG=${shellValue(configPath)} claude`);
722
+ if (state.mode === 'vnc') {
723
+ this.out(`noVNC 观看: ${this.novncUrl()}`);
724
+ this.out(`原生 VNC: 127.0.0.1:${state.vncPort}(密码见 manyoyo playwright status)`);
725
+ }
726
+ if (state.mode === 'chrome') {
727
+ this.out('⚠️ 风险: 容器内 Agent 能操作你 Chrome 里已登录的所有网站。Chrome 可能弹出“允许远程调试”确认框,请点允许。');
728
+ }
729
+ if (process.platform !== 'darwin' && state.mode !== 'default') {
730
+ this.out(`安全: 端口 ${state.port} 监听 0.0.0.0,仅靠 token 保护;建议用防火墙限制来源。`);
731
+ }
841
732
  }
842
733
 
843
- ensureHostScenePrerequisites(sceneName) {
844
- if (!isCliScene(sceneName) && !this.sceneConfigMissing(sceneName)) {
845
- return;
734
+ async up(mode, { extensionPaths = [], extensionNames = [] } = {}) {
735
+ if (!MODES.includes(mode)) {
736
+ this.printOverview();
737
+ if (mode) {
738
+ this.err('');
739
+ this.err(`未知模式: ${mode},可用: ${MODES.join(', ')}`);
740
+ }
741
+ return 1;
846
742
  }
847
- this.runCmd([this.playwrightBinPath(sceneName), 'install', '--with-deps', this.defaultBrowserName(sceneName)], { check: true });
743
+ const extPaths = extensions.resolveExtensionInputs(this.root, { extensionPaths, extensionNames });
744
+ if (extPaths.length > 0 && mode === 'chrome') {
745
+ this.err('chrome 模式控制的是你自己的 Chrome,不支持 --ext-path / --ext-name;扩展只支持 headed、vnc。');
746
+ return 1;
747
+ }
748
+ this.ensureDirs();
749
+ await this.stopCurrent(this.readState());
750
+ this.writeState({ mode: 'default' });
751
+
752
+ const started = mode === 'headed' ? await this.startHeaded(extPaths)
753
+ : mode === 'chrome' ? await this.startChrome()
754
+ : await this.startVnc(extPaths);
755
+ if (typeof started === 'number') {
756
+ this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
757
+ return started;
758
+ }
759
+ const state = { ...started, startedAt: new Date().toISOString() };
760
+ this.writeState(state);
761
+ this.writeCurrentConfig(state, this.runtimeCommand());
762
+
763
+ const result = await this.probeState(state);
764
+ if (!result.ok) {
765
+ this.err(`[up] ${mode} 已启动,但真实探测失败: ${result.error}`);
766
+ this.err(tailText(this.logPath(), 10));
767
+ // 回滚:不让坏掉的模式留在配置里
768
+ await this.stopCurrent(state);
769
+ this.writeState({ mode: 'default' });
770
+ this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
771
+ this.err('已回滚到默认模式。');
772
+ return 1;
773
+ }
774
+ this.out(`✅ ${mode} 模式已就绪(真实探测通过,${result.ms}ms)`);
775
+ this.printUsage(state);
776
+ return 0;
848
777
  }
849
778
 
850
- scenePidFile(sceneName) {
851
- return path.join(this.config.runDir, `${sceneName}.pid`);
779
+ runtimeCommand() {
780
+ try {
781
+ return this.resolveRuntime().command;
782
+ } catch {
783
+ return 'docker';
784
+ }
852
785
  }
853
786
 
854
- sceneLogFile(sceneName) {
855
- return path.join(this.config.runDir, `${sceneName}.log`);
787
+ async down() {
788
+ const state = this.readState();
789
+ await this.stopCurrent(state);
790
+ this.ensureDirs();
791
+ fs.rmSync(this.tokenPath(), { force: true });
792
+ fs.rmSync(this.hostConfigPath(), { force: true });
793
+ this.writeState({ mode: 'default' });
794
+ this.writeCurrentConfig({ mode: 'default' }, this.runtimeCommand());
795
+ this.out(state.mode === 'default'
796
+ ? '当前已是默认模式(容器内虚拟屏里的有头浏览器)。'
797
+ : `已停止 ${state.mode} 模式,回到默认模式;已打开浏览器的容器会话先 playwright-cli close 再重新 open。`);
798
+ return 0;
856
799
  }
857
800
 
858
- localBinPath(binName) {
859
- const filename = process.platform === 'win32' ? `${binName}.cmd` : binName;
860
- const binPath = path.join(this.projectRoot, 'node_modules', '.bin', filename);
861
- if (!fs.existsSync(binPath)) {
862
- throw new Error(`local binary not found: ${binPath}. Run npm install first.`);
801
+ // 配置文件被删(或整个目录被删)时重建;返回是否重建过
802
+ ensureCurrentFiles(state) {
803
+ const missing = ['config.json', 'stealth.init.js'].some(name => !fs.existsSync(path.join(this.currentDir, name)));
804
+ if (missing) {
805
+ this.writeCurrentConfig(state, this.runtimeCommand());
863
806
  }
864
- return binPath;
807
+ return missing;
865
808
  }
866
809
 
867
- playwrightBinPath(sceneName) {
868
- if (!isCliScene(sceneName)) {
869
- return this.localBinPath('playwright');
810
+ async status() {
811
+ let state = this.readState();
812
+ if (this.ensureCurrentFiles(state)) {
813
+ this.out('[status] 容器内浏览器的配置文件缺失,已重建;运行中的容器会自动恢复(整个 ~/.manyoyo/plugin/playwright 目录被删过的容器需要重建)。');
870
814
  }
871
-
872
- const filename = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
873
- const candidates = [
874
- path.join(this.projectRoot, 'node_modules', '@playwright', 'mcp', 'node_modules', '.bin', filename),
875
- path.join(this.projectRoot, 'node_modules', '.bin', filename)
876
- ];
877
-
878
- for (const candidate of candidates) {
879
- if (fs.existsSync(candidate)) {
880
- return candidate;
815
+ if (state.mode === 'default') {
816
+ this.out('当前模式: default(容器内虚拟屏里的有头浏览器,无需宿主机进程)');
817
+ this.out('想换模式: manyoyo playwright 查看全部模式');
818
+ return 0;
819
+ }
820
+ this.out(`当前模式: ${state.mode}(端口 ${state.port}${state.pid ? `,pid ${state.pid}` : ''})`);
821
+ const alive = await this.modeAlive(state);
822
+ const result = alive ? await this.probeState(state) : { ok: false, error: state.mode === 'vnc' ? 'vnc 容器没有在运行' : '浏览器服务进程已不在' };
823
+ if (result.ok) {
824
+ this.out(`真实探测: 通过(${result.ms}ms)`);
825
+ if (state.mode === 'vnc') {
826
+ this.out(`noVNC: ${this.novncUrl()}`);
881
827
  }
828
+ return 0;
882
829
  }
883
-
884
- throw new Error(`local binary not found for ${sceneName}. Run npm install first.`);
885
- }
886
-
887
- extensionDirPath() {
888
- return path.join(this.config.homeDir, '.manyoyo', 'plugin', 'playwright', 'extensions');
830
+ this.err(`真实探测: 失败(${result.error})`);
831
+ state = { mode: 'default' };
832
+ this.writeState(state);
833
+ this.writeCurrentConfig(state, this.runtimeCommand());
834
+ this.err('已把容器内浏览器回退到默认模式。恢复: manyoyo playwright up <headed|chrome|vnc>;排查: manyoyo playwright logs');
835
+ return 1;
889
836
  }
890
837
 
891
- extensionTmpDirPath() {
892
- return path.join(this.config.homeDir, '.manyoyo', 'plugin', 'playwright', 'tmp-crx');
838
+ logs() {
839
+ const state = this.readState();
840
+ if (state.mode === 'vnc') {
841
+ const logs = this.runtimeCall(['logs', '--tail', '80', VNC_CONTAINER]);
842
+ this.out(`${logs.stdout}${logs.stderr}`.trimEnd() || '[logs] 没有日志');
843
+ return logs.status === 0 ? 0 : 1;
844
+ }
845
+ this.out(tailText(this.logPath(), 80).trimEnd() || '[logs] 没有日志');
846
+ return 0;
893
847
  }
894
848
 
895
- cliBrowserCacheDirPath() {
896
- return path.join(this.config.homeDir, '.manyoyo', '.cache', 'ms-playwright');
849
+ // 想用 MCP 的 Agent:在容器内以 stdio 注册,读同一个 $PLAYWRIGHT_MCP_CONFIG,自动跟随当前模式
850
+ mcpAdd() {
851
+ this.out('# 在容器内执行(playwright-mcp 随当前模式自动切换,无需重新注册)');
852
+ this.out('claude mcp add -s user playwright -- playwright-mcp');
853
+ this.out('codex mcp add playwright -- playwright-mcp');
854
+ this.out('gemini mcp add -s user playwright playwright-mcp');
855
+ return 0;
897
856
  }
898
857
 
899
- ensureCliHostHeadedCacheDir(sceneName) {
900
- if (sceneName !== 'cli-host-headed') {
901
- return;
902
- }
903
- fs.mkdirSync(this.cliBrowserCacheDirPath(), { recursive: true });
904
- }
905
-
906
- resolveTargets(sceneName = 'all') {
907
- const requested = String(sceneName || 'all').trim();
908
- const enabledSet = new Set(this.config.enabledScenes);
909
- const runtime = this.config.runtime;
910
-
911
- const isAllowedByRuntime = (scene) => {
912
- const type = SCENE_DEFS[scene].type;
913
- if (runtime === 'mixed') {
914
- return true;
915
- }
916
- return runtime === type;
917
- };
918
-
919
- if (requested !== 'all') {
920
- if (!SCENE_DEFS[requested]) {
921
- throw new Error(`未知场景: ${requested}`);
922
- }
923
- if (!enabledSet.has(requested)) {
924
- throw new Error(`场景未启用: ${requested}`);
925
- }
926
- if (!isAllowedByRuntime(requested)) {
927
- throw new Error(`当前 runtime=${runtime},不允许场景: ${requested}`);
928
- }
929
- return [requested];
930
- }
931
-
932
- return SCENE_ORDER
933
- .filter(scene => enabledSet.has(scene))
934
- .filter(scene => isAllowedByRuntime(scene));
935
- }
936
-
937
- resolveExtensionPaths(extensionArgs = []) {
938
- const inputs = asStringArray(extensionArgs, []);
939
- const uniquePaths = [];
940
- const seen = new Set();
941
-
942
- for (const item of inputs) {
943
- const absPath = path.resolve(item);
944
- if (!fs.existsSync(absPath)) {
945
- throw new Error(`扩展路径不存在: ${absPath}`);
946
- }
947
- const stat = fs.statSync(absPath);
948
- if (!stat.isDirectory()) {
949
- throw new Error(`扩展路径必须是目录: ${absPath}`);
950
- }
951
-
952
- const manifestPath = path.join(absPath, 'manifest.json');
953
- if (fs.existsSync(manifestPath)) {
954
- if (!seen.has(absPath)) {
955
- seen.add(absPath);
956
- uniquePaths.push(absPath);
957
- }
958
- continue;
959
- }
960
-
961
- const children = fs.readdirSync(absPath, { withFileTypes: true })
962
- .filter(dirent => dirent.isDirectory())
963
- .map(dirent => path.join(absPath, dirent.name))
964
- .filter(child => fs.existsSync(path.join(child, 'manifest.json')));
965
-
966
- if (children.length === 0) {
967
- throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
968
- }
969
-
970
- for (const childPath of children) {
971
- if (!seen.has(childPath)) {
972
- seen.add(childPath);
973
- uniquePaths.push(childPath);
974
- }
975
- }
976
- }
977
-
978
- return uniquePaths;
979
- }
980
-
981
- resolveNamedExtensionPaths(extensionNames = []) {
982
- const names = asStringArray(extensionNames, []);
983
- const extensionRoot = path.resolve(this.extensionDirPath());
984
-
985
- return names.map(name => {
986
- if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
987
- throw new Error(`扩展名称无效: ${name}`);
988
- }
989
- return path.join(extensionRoot, name);
990
- });
991
- }
992
-
993
- resolveExtensionInputs(options = {}) {
994
- const extensionPaths = asStringArray(options.extensionPaths, []);
995
- const namedPaths = this.resolveNamedExtensionPaths(options.extensionNames || []);
996
- return this.resolveExtensionPaths([...extensionPaths, ...namedPaths]);
997
- }
998
-
999
- buildExtensionLaunchArgs(extensionPaths) {
1000
- const joined = extensionPaths.join(',');
1001
- return [
1002
- `--disable-extensions-except=${joined}`,
1003
- `--load-extension=${joined}`
1004
- ];
1005
- }
1006
-
1007
- sanitizeExtensionMountName(value) {
1008
- const sanitized = String(value || '')
1009
- .trim()
1010
- .replace(/[^A-Za-z0-9._-]/g, '-')
1011
- .replace(/-+/g, '-')
1012
- .replace(/^-|-$/g, '');
1013
- return sanitized || 'ext';
1014
- }
1015
-
1016
- buildContainerExtensionMounts(extensionPaths = []) {
1017
- const hostPaths = asStringArray(extensionPaths, []);
1018
- const containerPaths = [];
1019
- const volumeMounts = [];
1020
-
1021
- hostPaths.forEach((hostPath, idx) => {
1022
- const safeName = this.sanitizeExtensionMountName(path.basename(hostPath));
1023
- const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
1024
- containerPaths.push(containerPath);
1025
- volumeMounts.push(`${hostPath}:${containerPath}:ro`);
1026
- });
1027
-
1028
- return { containerPaths, volumeMounts };
1029
- }
1030
-
1031
- baseLaunchArgs() {
1032
- return [
1033
- `--user-agent=${DEFAULT_FINGERPRINT_PROFILE.userAgent}`,
1034
- `--lang=${DEFAULT_FINGERPRINT_PROFILE.locale}`,
1035
- `--window-size=${DEFAULT_FINGERPRINT_PROFILE.width},${DEFAULT_FINGERPRINT_PROFILE.height}`,
1036
- '--disable-blink-features=AutomationControlled',
1037
- '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'
1038
- ];
1039
- }
1040
-
1041
- buildMcpSceneConfig(sceneName, options = {}) {
1042
- const def = SCENE_DEFS[sceneName];
1043
- const port = this.scenePort(sceneName);
1044
- const extensionPaths = asStringArray(options.extensionPaths, []);
1045
- const initScript = asStringArray(options.initScript, []);
1046
- const launchOptions = {
1047
- channel: 'chromium',
1048
- headless: def.headless,
1049
- args: [...this.baseLaunchArgs()]
1050
- };
1051
-
1052
- if (extensionPaths.length > 0) {
1053
- launchOptions.args.push(...this.buildExtensionLaunchArgs(extensionPaths));
1054
- }
1055
- if (this.config.disableWebRTC) {
1056
- launchOptions.args.push(...DISABLE_WEBRTC_LAUNCH_ARGS);
1057
- }
1058
-
1059
- const contextOptions = {
1060
- userAgent: DEFAULT_FINGERPRINT_PROFILE.userAgent,
1061
- locale: DEFAULT_FINGERPRINT_PROFILE.locale,
1062
- timezoneId: DEFAULT_FINGERPRINT_PROFILE.timezoneId,
1063
- extraHTTPHeaders: {
1064
- 'Accept-Language': DEFAULT_FINGERPRINT_PROFILE.acceptLanguage
1065
- }
1066
- };
1067
- if (sceneName !== 'mcp-host-headed') {
1068
- contextOptions.viewport = {
1069
- width: DEFAULT_FINGERPRINT_PROFILE.width,
1070
- height: DEFAULT_FINGERPRINT_PROFILE.height
1071
- };
1072
- contextOptions.screen = {
1073
- width: DEFAULT_FINGERPRINT_PROFILE.width,
1074
- height: DEFAULT_FINGERPRINT_PROFILE.height
1075
- };
1076
- }
1077
-
1078
- return {
1079
- outputDir: '/tmp/.playwright-mcp',
1080
- server: {
1081
- host: def.listenHost,
1082
- port,
1083
- allowedHosts: [
1084
- `localhost:${port}`,
1085
- `127.0.0.1:${port}`,
1086
- `host.docker.internal:${port}`,
1087
- `host.containers.internal:${port}`
1088
- ]
1089
- },
1090
- browser: {
1091
- chromiumSandbox: true,
1092
- browserName: 'chromium',
1093
- initScript,
1094
- launchOptions,
1095
- contextOptions
1096
- }
1097
- };
1098
- }
1099
-
1100
- buildCliSceneConfig(sceneName, options = {}) {
1101
- const def = SCENE_DEFS[sceneName];
1102
- const extensionPaths = asStringArray(options.extensionPaths, []);
1103
- const payload = {
1104
- host: def.listenHost,
1105
- port: this.scenePort(sceneName),
1106
- wsPath: `/${sceneName}-${crypto.randomBytes(8).toString('hex')}`,
1107
- headless: def.headless,
1108
- channel: 'chromium',
1109
- chromiumSandbox: true,
1110
- args: [...this.baseLaunchArgs()]
1111
- };
1112
-
1113
- if (extensionPaths.length > 0) {
1114
- payload.args.push(...this.buildExtensionLaunchArgs(extensionPaths));
1115
- }
1116
- if (this.config.disableWebRTC) {
1117
- payload.args.push(...DISABLE_WEBRTC_LAUNCH_ARGS);
1118
- }
1119
-
1120
- return payload;
1121
- }
1122
-
1123
- buildSceneConfig(sceneName, options = {}) {
1124
- if (isCliScene(sceneName)) {
1125
- return this.buildCliSceneConfig(sceneName, options);
1126
- }
1127
- return this.buildMcpSceneConfig(sceneName, options);
1128
- }
1129
-
1130
- ensureSceneConfig(sceneName, options = {}) {
1131
- fs.mkdirSync(this.config.configDir, { recursive: true });
1132
- if (isCliScene(sceneName)) {
1133
- const payload = this.buildCliSceneConfig(sceneName, options);
1134
- const filePath = this.sceneConfigPath(sceneName);
1135
- fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
1136
- return filePath;
1137
- }
1138
- const initScriptPath = this.ensureSceneInitScript(sceneName);
1139
- const configuredInitScript = asStringArray(options.initScript, []);
1140
- const initScript = configuredInitScript.length > 0 ? configuredInitScript : [initScriptPath];
1141
- const payload = this.buildSceneConfig(sceneName, {
1142
- ...options,
1143
- initScript
1144
- });
1145
- const filePath = this.sceneConfigPath(sceneName);
1146
- fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
1147
- return filePath;
1148
- }
1149
-
1150
- async portReady(port) {
1151
- return await new Promise((resolve) => {
1152
- let settled = false;
1153
- const socket = net.createConnection({ host: '127.0.0.1', port });
1154
-
1155
- const finish = (value) => {
1156
- if (settled) {
1157
- return;
1158
- }
1159
- settled = true;
1160
- socket.destroy();
1161
- resolve(value);
1162
- };
1163
-
1164
- socket.setTimeout(300);
1165
- socket.once('connect', () => finish(true));
1166
- socket.once('timeout', () => finish(false));
1167
- socket.once('error', () => finish(false));
1168
- });
1169
- }
1170
-
1171
- async waitForPort(port) {
1172
- for (let i = 0; i < 60; i += 1) {
1173
- // eslint-disable-next-line no-await-in-loop
1174
- if (await this.portReady(port)) {
1175
- return true;
1176
- }
1177
- // eslint-disable-next-line no-await-in-loop
1178
- await sleep(500);
1179
- }
1180
- return false;
1181
- }
1182
-
1183
- containerEnv(sceneName, cfgPath, options = {}) {
1184
- const def = SCENE_DEFS[sceneName];
1185
- const requireVncPassword = options.requireVncPassword === true;
1186
- const env = {
1187
- ...process.env,
1188
- PLAYWRIGHT_MCP_DOCKER_TAG: this.config.dockerTag,
1189
- PLAYWRIGHT_MCP_PORT: String(this.scenePort(sceneName)),
1190
- PLAYWRIGHT_MCP_CONFIG_PATH: cfgPath,
1191
- PLAYWRIGHT_MCP_CONTAINER_NAME: def.containerName,
1192
- PLAYWRIGHT_MCP_IMAGE: this.config.headedImage,
1193
- PLAYWRIGHT_MCP_NOVNC_PORT: String(this.config.ports.mcpContHeadedNoVnc)
1194
- };
1195
-
1196
- if (sceneName === 'mcp-cont-headed') {
1197
- const envKey = this.config.vncPasswordEnvKey;
1198
- let password = process.env[envKey];
1199
- if (!password) {
1200
- password = this.randomAlnum(16);
1201
- if (requireVncPassword) {
1202
- this.writeStdout(`[up] mcp-cont-headed ${envKey} not set; generated random 16-char password: ${password}`);
1203
- }
1204
- }
1205
- env.VNC_PASSWORD = password;
1206
- }
1207
-
1208
- return env;
1209
- }
1210
-
1211
- containerComposePath(sceneName) {
1212
- const def = SCENE_DEFS[sceneName];
1213
- return path.join(this.config.composeDir, def.composeFile);
1214
- }
1215
-
1216
- sceneComposeOverridePath(sceneName) {
1217
- return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
1218
- }
1219
-
1220
- ensureContainerComposeOverride(sceneName, volumeMounts = []) {
1221
- const overridePath = this.sceneComposeOverridePath(sceneName);
1222
- if (!Array.isArray(volumeMounts) || volumeMounts.length === 0) {
1223
- fs.rmSync(overridePath, { force: true });
1224
- return '';
1225
- }
1226
-
1227
- fs.mkdirSync(this.config.runDir, { recursive: true });
1228
- const lines = [
1229
- 'services:',
1230
- ' playwright:',
1231
- ' volumes:'
1232
- ];
1233
- volumeMounts.forEach(item => {
1234
- lines.push(` - ${JSON.stringify(String(item))}`);
1235
- });
1236
- fs.writeFileSync(overridePath, `${lines.join('\n')}\n`, 'utf8');
1237
- return overridePath;
1238
- }
1239
-
1240
- buildCliSessionIntegration(dockerCmd) {
1241
- const sceneName = this.config.cliSessionScene;
1242
- if (!sceneName) {
1243
- return { envEntries: [], extraArgs: [], volumeEntries: [] };
1244
- }
1245
-
1246
- if (isDevScene(sceneName)) {
1247
- return this.buildDevHostCliSessionIntegration(dockerCmd, sceneName);
1248
- }
1249
-
1250
- const endpoint = this.readSceneEndpoint(sceneName);
1251
- if (!endpoint || !Number.isInteger(endpoint.port) || endpoint.port <= 0 || typeof endpoint.wsPath !== 'string' || !endpoint.wsPath) {
1252
- return { envEntries: [], extraArgs: [], volumeEntries: [] };
1253
- }
1254
-
1255
- const normalizedDockerCmd = path.basename(String(dockerCmd || '').trim()).toLowerCase();
1256
- const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
1257
- const remoteEndpoint = `ws://${connectHost}:${endpoint.port}${endpoint.wsPath}`;
1258
- const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
1259
- const containerConfigPath = `/tmp/manyoyo-playwright/${sceneName}.cli-attach.json`;
1260
- this.writeSceneCliAttachConfig(sceneName, {
1261
- outputDir: '/tmp/.playwright-cli',
1262
- browser: {
1263
- // launch-server(非 shared)不预创建 context;remoteEndpoint 存在时
1264
- // playwright-cli 默认 isolated=false 会走 contexts()[0] 并报
1265
- // "unable to connect to a browser that does not have any contexts",
1266
- // 因此容器内会话必须以 isolated 模式自建 context。
1267
- remoteEndpoint,
1268
- isolated: true
1269
- }
1270
- });
1271
- const envEntries = [
1272
- `PLAYWRIGHT_MCP_CONFIG=${containerConfigPath}`
1273
- ];
1274
- const extraArgs = normalizedDockerCmd === 'docker'
1275
- ? ['--add-host', 'host.docker.internal:host-gateway']
1276
- : [];
1277
- const volumeEntries = ['--volume', `${hostConfigPath}:${containerConfigPath}:ro`];
1278
- return { envEntries, extraArgs, volumeEntries };
1279
- }
1280
-
1281
- buildDevHostCliSessionIntegration(dockerCmd, sceneName) {
1282
- const endpoint = this.readDevToolsActivePort();
1283
- const normalizedDockerCmd = path.basename(String(dockerCmd || '').trim()).toLowerCase();
1284
- const connectHost = normalizedDockerCmd === 'podman' ? 'host.containers.internal' : 'host.docker.internal';
1285
- const hostConfigPath = this.sceneCliAttachConfigPath(sceneName);
1286
- const containerConfigPath = `/tmp/manyoyo-playwright/${sceneName}.cli-attach.json`;
1287
-
1288
- this.writeSceneCliAttachConfig(sceneName, this.buildDevHostAttachConfig(endpoint, connectHost));
1289
-
1290
- const envEntries = [
1291
- `PLAYWRIGHT_MCP_CONFIG=${containerConfigPath}`
1292
- ];
1293
- const extraArgs = normalizedDockerCmd === 'docker'
1294
- ? ['--add-host', 'host.docker.internal:host-gateway']
1295
- : [];
1296
- const volumeEntries = ['--volume', `${hostConfigPath}:${containerConfigPath}:ro`];
1297
- return { envEntries, extraArgs, volumeEntries };
1298
- }
1299
-
1300
- async startContainer(sceneName, options = {}) {
1301
- const runtime = this.config.containerRuntime;
1302
- if (!this.ensureCommandAvailable(runtime)) {
1303
- this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
1304
- return 1;
1305
- }
1306
-
1307
- try {
1308
- this.ensureContainerScenePrerequisites(sceneName);
1309
- } catch (error) {
1310
- return error.returncode || 1;
1311
- }
1312
-
1313
- const incomingExtensionPaths = asStringArray(options.extensionPaths, []);
1314
- const hostInitScriptPath = this.sceneInitScriptPath(sceneName);
1315
- const containerInitScriptPath = path.posix.join('/app/config', path.basename(hostInitScriptPath));
1316
- let configOptions = {
1317
- ...options,
1318
- extensionPaths: incomingExtensionPaths,
1319
- initScript: [containerInitScriptPath]
1320
- };
1321
- const composeFiles = [this.containerComposePath(sceneName)];
1322
- const volumeMounts = [`${hostInitScriptPath}:${containerInitScriptPath}:ro`];
1323
-
1324
- if (incomingExtensionPaths.length > 0) {
1325
- const mapped = this.buildContainerExtensionMounts(incomingExtensionPaths);
1326
- volumeMounts.push(...mapped.volumeMounts);
1327
- configOptions = {
1328
- ...options,
1329
- extensionPaths: mapped.containerPaths,
1330
- initScript: [containerInitScriptPath]
1331
- };
1332
- }
1333
- const cfgPath = this.ensureSceneConfig(sceneName, configOptions);
1334
- const overridePath = this.ensureContainerComposeOverride(sceneName, volumeMounts);
1335
- if (overridePath) {
1336
- composeFiles.push(overridePath);
1337
- }
1338
-
1339
- const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
1340
- const def = SCENE_DEFS[sceneName];
1341
-
1342
- try {
1343
- const args = [
1344
- runtime,
1345
- 'compose',
1346
- '-p',
1347
- def.projectName
1348
- ];
1349
- composeFiles.forEach(filePath => {
1350
- args.push('-f', filePath);
1351
- });
1352
- args.push('up', '-d');
1353
- this.runCmd(args, { env, check: true });
1354
- } catch (error) {
1355
- return error.returncode || 1;
1356
- }
1357
-
1358
- const port = this.scenePort(sceneName);
1359
- if (await this.waitForPort(port)) {
1360
- this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port}`);
1361
- return 0;
1362
- }
1363
-
1364
- this.writeStderr(`[up] ${sceneName} did not become ready on 127.0.0.1:${port}`);
1365
- return 1;
1366
- }
1367
-
1368
- stopContainer(sceneName) {
1369
- const runtime = this.config.containerRuntime;
1370
- if (!this.ensureCommandAvailable(runtime)) {
1371
- this.writeStderr(`[down] ${sceneName} failed: ${runtime} command not found.`);
1372
- return 1;
1373
- }
1374
-
1375
- this.ensureContainerComposeOverride(sceneName, []);
1376
- const cfgPath = this.sceneConfigPath(sceneName);
1377
- const env = this.containerEnv(sceneName, cfgPath);
1378
- const def = SCENE_DEFS[sceneName];
1379
-
1380
- try {
1381
- this.runCmd([
1382
- runtime,
1383
- 'compose',
1384
- '-p',
1385
- def.projectName,
1386
- '-f',
1387
- this.containerComposePath(sceneName),
1388
- 'down'
1389
- ], { env, check: true });
1390
- } catch (error) {
1391
- return error.returncode || 1;
1392
- }
1393
-
1394
- this.writeStdout(`[down] ${sceneName}`);
1395
- return 0;
1396
- }
1397
-
1398
- statusContainer(sceneName) {
1399
- const runtime = this.config.containerRuntime;
1400
- if (!this.ensureCommandAvailable(runtime)) {
1401
- this.writeStderr(`[status] ${sceneName} failed: ${runtime} command not found.`);
1402
- return 1;
1403
- }
1404
-
1405
- const def = SCENE_DEFS[sceneName];
1406
- const cp = this.runCmd([
1407
- runtime,
1408
- 'ps',
1409
- '--filter',
1410
- `name=${def.containerName}`,
1411
- '--format',
1412
- '{{.Names}}'
1413
- ], { captureOutput: true, check: false });
1414
-
1415
- const names = new Set(
1416
- cp.stdout
1417
- .split(/\r?\n/)
1418
- .map(line => line.trim())
1419
- .filter(Boolean)
1420
- );
1421
-
1422
- if (names.has(def.containerName)) {
1423
- this.writeStdout(`[status] ${sceneName} running`);
1424
- } else {
1425
- this.writeStdout(`[status] ${sceneName} stopped`);
1426
- }
1427
- return 0;
1428
- }
1429
-
1430
- logsContainer(sceneName) {
1431
- const runtime = this.config.containerRuntime;
1432
- if (!this.ensureCommandAvailable(runtime)) {
1433
- this.writeStderr(`[logs] ${sceneName} failed: ${runtime} command not found.`);
1434
- return 1;
1435
- }
1436
-
1437
- const def = SCENE_DEFS[sceneName];
1438
- const cp = this.runCmd([
1439
- runtime,
1440
- 'logs',
1441
- '--tail',
1442
- '80',
1443
- def.containerName
1444
- ], { captureOutput: true, check: false });
1445
-
1446
- const output = cp.stdout || cp.stderr;
1447
- if (output.trim()) {
1448
- this.writeStdout(output.trimEnd());
1449
- } else {
1450
- this.writeStdout(`[logs] ${sceneName} no logs`);
1451
- }
1452
-
1453
- return cp.returncode === 0 ? 0 : 1;
1454
- }
1455
-
1456
- hostLaunchCommand(sceneName, cfgPath) {
1457
- if (isCliScene(sceneName)) {
1458
- return {
1459
- command: this.playwrightBinPath(sceneName),
1460
- args: ['launch-server', '--browser', this.defaultBrowserName(sceneName), '--config', String(cfgPath)]
1461
- };
1462
- }
1463
- return {
1464
- command: this.localBinPath('playwright-mcp'),
1465
- args: ['--config', String(cfgPath)]
1466
- };
1467
- }
1468
-
1469
- spawnHostProcess(command, args, logFd) {
1470
- return spawn(command, args, {
1471
- detached: true,
1472
- stdio: ['ignore', logFd, logFd]
1473
- });
1474
- }
1475
-
1476
- stopHostStarter(pid) {
1477
- if (!Number.isInteger(pid) || pid <= 0) {
1478
- return;
1479
- }
1480
- try {
1481
- process.kill(-pid, 'SIGTERM');
1482
- return;
1483
- } catch {
1484
- // no-op
1485
- }
1486
- try {
1487
- process.kill(pid, 'SIGTERM');
1488
- } catch {
1489
- // no-op
1490
- }
1491
- }
1492
-
1493
- hostScenePids(sceneName) {
1494
- const cfgPath = this.sceneConfigPath(sceneName);
1495
- const pattern = isCliScene(sceneName)
1496
- ? `playwright.*launch-server.*--config ${cfgPath}`
1497
- : `playwright-mcp.*--config ${cfgPath}`;
1498
- const cp = this.runCmd(['pgrep', '-f', pattern], { captureOutput: true, check: false });
1499
-
1500
- if (cp.returncode !== 0 || !cp.stdout.trim()) {
1501
- return [];
1502
- }
1503
-
1504
- const pids = [];
1505
- for (const line of cp.stdout.split(/\r?\n/)) {
1506
- const text = line.trim();
1507
- if (/^\d+$/.test(text)) {
1508
- pids.push(Number(text));
1509
- }
1510
- }
1511
- return pids;
1512
- }
1513
-
1514
- async waitForHostPids(sceneName, fallbackPid) {
1515
- for (let i = 0; i < 5; i += 1) {
1516
- const pids = this.hostScenePids(sceneName);
1517
- if (pids.length > 0) {
1518
- return pids;
1519
- }
1520
- // eslint-disable-next-line no-await-in-loop
1521
- await sleep(100);
1522
- }
1523
- if (Number.isInteger(fallbackPid) && fallbackPid > 0) {
1524
- return [fallbackPid];
1525
- }
1526
- return [];
1527
- }
1528
-
1529
- async startHost(sceneName, options = {}) {
1530
- try {
1531
- this.ensureCliHostHeadedCacheDir(sceneName);
1532
- this.ensureHostScenePrerequisites(sceneName);
1533
- } catch (error) {
1534
- this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
1535
- return error.returncode || 1;
1536
- }
1537
-
1538
- fs.mkdirSync(this.config.runDir, { recursive: true });
1539
- const cfgPath = this.ensureSceneConfig(sceneName, options);
1540
- const pidFile = this.scenePidFile(sceneName);
1541
- const logFile = this.sceneLogFile(sceneName);
1542
- const port = this.scenePort(sceneName);
1543
- this.removeSceneEndpoint(sceneName);
1544
- this.removeSceneCliAttachConfig(sceneName);
1545
-
1546
- let managedPids = this.hostScenePids(sceneName);
1547
- if (managedPids.length > 0 && (await this.portReady(port))) {
1548
- this.writeStdout(`[up] ${sceneName} already running (pid(s) ${managedPids.join(' ')})`);
1549
- return 0;
1550
- }
1551
-
1552
- if (await this.portReady(port)) {
1553
- this.writeStderr(`[up] ${sceneName} failed: port ${port} is already in use by another process.`);
1554
- this.writeStderr('Stop the conflicting process first, then retry.');
1555
- return 1;
1556
- }
1557
-
1558
- fs.rmSync(pidFile, { force: true });
1559
- const logFd = fs.openSync(logFile, 'a');
1560
- let launchCommand = null;
1561
- try {
1562
- launchCommand = this.hostLaunchCommand(sceneName, cfgPath);
1563
- } catch (error) {
1564
- fs.closeSync(logFd);
1565
- this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
1566
- return 1;
1567
- }
1568
-
1569
- const starter = this.spawnHostProcess(launchCommand.command, launchCommand.args, logFd);
1570
- fs.closeSync(logFd);
1571
- if (typeof starter.unref === 'function') {
1572
- starter.unref();
1573
- }
1574
-
1575
- if (await this.waitForPort(port)) {
1576
- if (isCliScene(sceneName)) {
1577
- const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
1578
- this.writeSceneEndpoint(sceneName, {
1579
- port,
1580
- wsPath: String(cfg.wsPath || '')
1581
- });
1582
- }
1583
- managedPids = await this.waitForHostPids(sceneName, starter.pid);
1584
- if (managedPids.length > 0) {
1585
- fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
1586
- this.writeStdout(`[up] ${sceneName} ready on 127.0.0.1:${port} (pid(s) ${managedPids.join(' ')})`);
1587
- this.remindCliSessionScene(sceneName);
1588
- return 0;
1589
- }
1590
- }
1591
-
1592
- this.writeStderr(`[up] ${sceneName} failed to start. tail ${logFile}:`);
1593
- const tail = tailText(logFile, 30);
1594
- if (tail) {
1595
- this.writeStderr(tail);
1596
- }
1597
-
1598
- if (starter.exitCode === null && !starter.killed) {
1599
- this.stopHostStarter(starter.pid);
1600
- }
1601
-
1602
- return 1;
1603
- }
1604
-
1605
- async startDevHost(sceneName) {
1606
- this.openChromeRemoteDebuggingPage();
1607
- try {
1608
- const endpoint = this.readDevToolsActivePort();
1609
- if (!(await this.portReady(endpoint.port))) {
1610
- throw new Error(`DevToolsActivePort 指向 127.0.0.1:${endpoint.port},但该端口不可连接;请确认 Chrome remote debugging 已启用,或重新打开 Chrome 后再试。`);
1611
- }
1612
- const cfgPath = this.writeDevHostAttachConfig(sceneName, endpoint);
1613
- this.writeStdout(`[up] ${sceneName} ready via DevToolsActivePort (${endpoint.filePath}, 127.0.0.1:${endpoint.port})`);
1614
- this.writeStdout(`[tip] 已尝试打开 ${this.remoteDebuggingPageUrl()},请确认 remote debugging 已启用。`);
1615
- this.writeDevHostUsage(cfgPath);
1616
- return 0;
1617
- } catch (error) {
1618
- this.writeStderr(`[up] ${sceneName} failed: ${error.message || String(error)}`);
1619
- return 1;
1620
- }
1621
- }
1622
-
1623
- async stopHost(sceneName) {
1624
- const pidFile = this.scenePidFile(sceneName);
1625
- const port = this.scenePort(sceneName);
1626
- const managedPids = this.hostScenePids(sceneName);
1627
- this.removeSceneEndpoint(sceneName);
1628
- this.removeSceneCliAttachConfig(sceneName);
1629
-
1630
- for (const pid of managedPids) {
1631
- try {
1632
- process.kill(pid, 'SIGTERM');
1633
- } catch {
1634
- // no-op
1635
- }
1636
- }
1637
-
1638
- if (managedPids.length > 0) {
1639
- await sleep(300);
1640
- }
1641
-
1642
- if (fs.existsSync(pidFile)) {
1643
- const text = fs.readFileSync(pidFile, 'utf8').trim();
1644
- if (/^\d+$/.test(text)) {
1645
- try {
1646
- process.kill(Number(text), 'SIGTERM');
1647
- } catch {
1648
- // no-op
1649
- }
1650
- }
1651
- fs.rmSync(pidFile, { force: true });
1652
- }
1653
-
1654
- if (await this.portReady(port)) {
1655
- this.writeStderr(`[down] ${sceneName} warning: port ${port} is still in use (possibly unmanaged process)`);
1656
- return 1;
1657
- }
1658
-
1659
- this.writeStdout(`[down] ${sceneName}`);
1660
- return 0;
1661
- }
1662
-
1663
- stopDevHost(sceneName) {
1664
- this.removeSceneEndpoint(sceneName);
1665
- this.removeSceneCliAttachConfig(sceneName);
1666
- this.removeDevHostAttachConfig(sceneName);
1667
- this.writeStdout(`[down] ${sceneName} no managed process`);
1668
- return 0;
1669
- }
1670
-
1671
- async statusHost(sceneName) {
1672
- const pidFile = this.scenePidFile(sceneName);
1673
- const port = this.scenePort(sceneName);
1674
- const managedPids = this.hostScenePids(sceneName);
1675
-
1676
- if (managedPids.length > 0 && (await this.portReady(port))) {
1677
- this.writeStdout(`[status] ${sceneName} running (pid(s) ${managedPids.join(' ')})`);
1678
- const pidfileValid = fs.existsSync(pidFile) && /^\d+$/.test(fs.readFileSync(pidFile, 'utf8').trim());
1679
- if (!pidfileValid) {
1680
- fs.mkdirSync(path.dirname(pidFile), { recursive: true });
1681
- fs.writeFileSync(pidFile, `${managedPids[0]}`, 'utf8');
1682
- }
1683
- return 0;
1684
- }
1685
-
1686
- if (managedPids.length > 0 && !(await this.portReady(port))) {
1687
- this.writeStdout(`[status] ${sceneName} degraded (pid(s) ${managedPids.join(' ')}, port ${port} not reachable)`);
1688
- return 0;
1689
- }
1690
-
1691
- fs.rmSync(pidFile, { force: true });
1692
- if (await this.portReady(port)) {
1693
- this.writeStdout(`[status] ${sceneName} conflict (port ${port} in use by unmanaged process)`);
1694
- } else {
1695
- this.writeStdout(`[status] ${sceneName} stopped`);
1696
- }
1697
- return 0;
1698
- }
1699
-
1700
- statusDevHost(sceneName) {
1701
- try {
1702
- const endpoint = this.readDevToolsActivePort();
1703
- this.writeStdout(`[status] ${sceneName} configured (${endpoint.filePath}, 127.0.0.1:${endpoint.port})`);
1704
- } catch {
1705
- this.writeStdout(`[status] ${sceneName} not configured`);
1706
- }
1707
- return 0;
1708
- }
1709
-
1710
- async healthScene(sceneName) {
1711
- if (isDevScene(sceneName)) {
1712
- try {
1713
- const endpoint = this.readDevToolsActivePort();
1714
- this.writeStdout(`[health] ${sceneName} ok (DevToolsActivePort 127.0.0.1:${endpoint.port})`);
1715
- return 0;
1716
- } catch (error) {
1717
- this.writeStdout(`[health] ${sceneName} fail (${error.message || String(error)})`);
1718
- return 1;
1719
- }
1720
- }
1721
-
1722
- const port = this.scenePort(sceneName);
1723
- if (await this.portReady(port)) {
1724
- this.writeStdout(`[health] ${sceneName} ok (127.0.0.1:${port})`);
1725
- return 0;
1726
- }
1727
- this.writeStdout(`[health] ${sceneName} fail (127.0.0.1:${port})`);
1728
- return 1;
1729
- }
1730
-
1731
- logsHost(sceneName) {
1732
- if (isDevScene(sceneName)) {
1733
- this.writeStdout(`[logs] ${sceneName} no managed process`);
1734
- return 0;
1735
- }
1736
-
1737
- const logFile = this.sceneLogFile(sceneName);
1738
- if (!fs.existsSync(logFile)) {
1739
- this.writeStdout(`[logs] ${sceneName} no log file: ${logFile}`);
1740
- return 0;
1741
- }
1742
-
1743
- const tail = tailText(logFile, 80);
1744
- if (tail) {
1745
- this.writeStdout(tail);
1746
- }
1747
- return 0;
1748
- }
1749
-
1750
- async downloadFile(url, output, retries = 3, timeoutMs = 60_000) {
1751
- const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
1752
- if (!this.ensureCommandAvailable('curl')) {
1753
- throw new Error('curl command not found');
1754
- }
1755
-
1756
- let lastError = null;
1757
- for (let i = 1; i <= retries; i += 1) {
1758
- try {
1759
- const result = this.runCmd([
1760
- 'curl',
1761
- '--fail',
1762
- '--location',
1763
- '--silent',
1764
- '--show-error',
1765
- '--connect-timeout',
1766
- String(timeoutSec),
1767
- '--max-time',
1768
- String(timeoutSec),
1769
- '--output',
1770
- output,
1771
- url
1772
- ], { captureOutput: true, check: false });
1773
- if (result.returncode !== 0) {
1774
- throw new Error(result.stderr || `curl failed with exit code ${result.returncode}`);
1775
- }
1776
- return;
1777
- } catch (error) {
1778
- lastError = error;
1779
- if (i < retries) {
1780
- // eslint-disable-next-line no-await-in-loop
1781
- await sleep(1000);
1782
- }
1783
- }
1784
- }
1785
-
1786
- throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
1787
- }
1788
-
1789
- extractZipBuffer(zipBuffer, outDir) {
1790
- fs.mkdirSync(outDir, { recursive: true });
1791
- const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
1792
- fs.writeFileSync(tempZip, zipBuffer);
1793
-
1794
- const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
1795
- fs.rmSync(tempZip, { force: true });
1796
-
1797
- if (result.error) {
1798
- throw result.error;
1799
- }
1800
- if (result.status !== 0) {
1801
- throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
1802
- }
1803
- }
1804
-
1805
- extractCrx(crxFile, outDir) {
1806
- const data = fs.readFileSync(crxFile);
1807
- const offset = crxZipOffset(data);
1808
- const zipBuffer = data.subarray(offset);
1809
-
1810
- this.extractZipBuffer(zipBuffer, outDir);
1811
-
1812
- const manifest = path.join(outDir, 'manifest.json');
1813
- if (!fs.existsSync(manifest)) {
1814
- throw new Error(`${crxFile} extracted but manifest.json missing`);
1815
- }
1816
-
1817
- if (convertManifestV2ToV3(outDir)) {
1818
- this.writeStdout(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
1819
- }
1820
- }
1821
-
1822
- async downloadExtensions(options = {}) {
1823
- if (!this.ensureCommandAvailable('unzip')) {
1824
- this.writeStderr('[ext-download] failed: unzip command not found.');
1825
- return 1;
1826
- }
1827
-
1828
- const prodversion = String(options.prodversion || this.config.extensionProdversion || '132.0.0.0').trim();
1829
- const extDir = path.resolve(this.extensionDirPath());
1830
- const tmpDir = path.resolve(this.extensionTmpDirPath());
1831
-
1832
- fs.rmSync(tmpDir, { recursive: true, force: true });
1833
- fs.mkdirSync(extDir, { recursive: true });
1834
- fs.mkdirSync(tmpDir, { recursive: true });
1835
-
1836
- try {
1837
- this.writeStdout(`[info] ext dir: ${extDir}`);
1838
- this.writeStdout(`[info] tmp dir: ${tmpDir}`);
1839
-
1840
- for (const [name, extId] of EXTENSIONS) {
1841
- const url = buildCrxUrl(extId, prodversion);
1842
- const crxFile = path.join(tmpDir, `${name}.crx`);
1843
- const outDir = path.join(extDir, name);
1844
-
1845
- this.writeStdout(`[download] ${name}`);
1846
- // eslint-disable-next-line no-await-in-loop
1847
- await this.downloadFile(url, crxFile);
1848
-
1849
- this.writeStdout(`[extract] ${name}`);
1850
- fs.rmSync(outDir, { recursive: true, force: true });
1851
- this.extractCrx(crxFile, outDir);
1852
- }
1853
- } finally {
1854
- fs.rmSync(tmpDir, { recursive: true, force: true });
1855
- this.writeStdout(`[cleanup] removed ${tmpDir}`);
1856
- }
1857
-
1858
- this.writeStdout(`[done] all extensions are ready: ${extDir}`);
1859
- return 0;
1860
- }
1861
-
1862
- detectCurrentIPv4() {
1863
- const interfaces = os.networkInterfaces();
1864
- for (const values of Object.values(interfaces)) {
1865
- if (!Array.isArray(values)) {
1866
- continue;
1867
- }
1868
- for (const item of values) {
1869
- if (!item || item.internal) {
1870
- continue;
1871
- }
1872
- if (item.family === 'IPv4') {
1873
- return item.address;
1874
- }
1875
- }
1876
- }
1877
- return '';
1878
- }
1879
-
1880
- resolveMcpAddHost(hostArg) {
1881
- if (!hostArg) {
1882
- return this.config.mcpDefaultHost;
1883
- }
1884
- const value = String(hostArg).trim();
1885
- if (!value) {
1886
- return '';
1887
- }
1888
- if (value === 'current-ip') {
1889
- return this.detectCurrentIPv4();
1890
- }
1891
- return value;
1892
- }
1893
-
1894
- printMcpAdd(hostArg) {
1895
- const host = this.resolveMcpAddHost(hostArg);
1896
- if (!host) {
1897
- this.writeStderr('[mcp-add] failed: cannot determine host. Use --host <host> to set one explicitly.');
1898
- return 1;
1899
- }
1900
-
1901
- const scenes = this.resolveTargets('all').filter(sceneName => isMcpScene(sceneName));
1902
- this.writeStdout('# 在容器中执行');
1903
- for (const sceneName of scenes) {
1904
- const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
1905
- this.writeStdout(`claude mcp add -t http -s user playwright-${sceneName} ${url}`);
1906
- }
1907
- this.writeStdout('');
1908
- for (const sceneName of scenes) {
1909
- const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
1910
- this.writeStdout(`codex mcp add playwright-${sceneName} --url ${url}`);
1911
- }
1912
- this.writeStdout('');
1913
- for (const sceneName of scenes) {
1914
- const url = `http://${host}:${this.scenePort(sceneName)}/mcp`;
1915
- this.writeStdout(`gemini mcp add -t http -s user playwright-${sceneName} ${url}`);
1916
- }
1917
-
1918
- return 0;
1919
- }
1920
-
1921
- printCliAdd() {
1922
- const lines = [
1923
- '# 在宿主机中执行',
1924
- 'PLAYWRIGHT_CLI_INSTALL_DIR="${TMPDIR:-/tmp}/manyoyo-playwright-cli-install-$$"',
1925
- 'mkdir -p "$PLAYWRIGHT_CLI_INSTALL_DIR/.playwright"',
1926
- 'cd "$PLAYWRIGHT_CLI_INSTALL_DIR"',
1927
- `npm install -g @playwright/cli@${PLAYWRIGHT_CLI_VERSION}`,
1928
- 'playwright-cli install --skills',
1929
- 'PLAYWRIGHT_CLI_SKILL_SOURCE="$PLAYWRIGHT_CLI_INSTALL_DIR/.claude/skills/playwright-cli"',
1930
- 'for target in ~/.claude/skills/playwright-cli ~/.codex/skills/playwright-cli ~/.gemini/skills/playwright-cli; do',
1931
- ' mkdir -p "$target"',
1932
- ' cp -R "$PLAYWRIGHT_CLI_SKILL_SOURCE/." "$target/"',
1933
- 'done',
1934
- 'cd "$OLDPWD"',
1935
- 'rm -rf "$PLAYWRIGHT_CLI_INSTALL_DIR"'
1936
- ];
1937
- this.writeStdout(lines.join('\n'));
1938
- return 0;
1939
- }
1940
-
1941
- printSummary() {
1942
- const scenes = this.resolveTargets('all');
1943
- this.writeStdout(`playwright\truntime=${this.config.runtime}\tscenes=${scenes.join(',')}`);
1944
- return 0;
1945
- }
1946
-
1947
- async runOnScene(action, sceneName, options = {}) {
1948
- const def = SCENE_DEFS[sceneName];
1949
- if (isDevScene(sceneName)) {
1950
- if (action === 'up') {
1951
- return await this.startDevHost(sceneName);
1952
- }
1953
- if (action === 'down') {
1954
- return this.stopDevHost(sceneName);
1955
- }
1956
- if (action === 'status') {
1957
- return this.statusDevHost(sceneName);
1958
- }
1959
- if (action === 'health') {
1960
- return await this.healthScene(sceneName);
1961
- }
1962
- if (action === 'logs') {
1963
- return this.logsHost(sceneName);
1964
- }
858
+ async run({ action, mode = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
859
+ if (action === 'overview') {
860
+ this.printOverview();
861
+ return await this.status();
1965
862
  }
1966
863
  if (action === 'up') {
1967
- return def.type === 'container'
1968
- ? await this.startContainer(sceneName, options)
1969
- : await this.startHost(sceneName, options);
864
+ return await this.up(mode, { extensionPaths, extensionNames });
1970
865
  }
1971
866
  if (action === 'down') {
1972
- return def.type === 'container'
1973
- ? this.stopContainer(sceneName)
1974
- : await this.stopHost(sceneName);
867
+ return await this.down();
1975
868
  }
1976
869
  if (action === 'status') {
1977
- return def.type === 'container'
1978
- ? this.statusContainer(sceneName)
1979
- : await this.statusHost(sceneName);
1980
- }
1981
- if (action === 'health') {
1982
- return await this.healthScene(sceneName);
870
+ return await this.status();
1983
871
  }
1984
872
  if (action === 'logs') {
1985
- return def.type === 'container'
1986
- ? this.logsContainer(sceneName)
1987
- : this.logsHost(sceneName);
873
+ return this.logs();
1988
874
  }
1989
- this.writeStderr(`unknown action: ${action}`);
1990
- return 1;
1991
- }
1992
-
1993
- async run({ action, scene = 'mcp-host-headless', host = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
1994
- if (action === 'ls') {
1995
- return this.printSummary();
1996
- }
1997
-
1998
875
  if (action === 'mcp-add') {
1999
- return this.printMcpAdd(host);
876
+ return this.mcpAdd();
2000
877
  }
2001
-
2002
- if (action === 'cli-add') {
2003
- return this.printCliAdd();
2004
- }
2005
-
2006
878
  if (action === 'ext-download') {
2007
- return await this.downloadExtensions({ prodversion });
2008
- }
2009
-
2010
- if (!VALID_ACTIONS.has(action)) {
2011
- throw new Error(`未知 plugin 动作: ${action}`);
2012
- }
2013
-
2014
- const targets = this.resolveTargets(scene);
2015
- if (targets.length === 0) {
2016
- this.writeStderr('没有可执行场景,请检查 runtime 与 enabledScenes 配置');
2017
- return 1;
2018
- }
2019
-
2020
- const resolvedExtensionPaths = action === 'up'
2021
- ? this.resolveExtensionInputs({ extensionPaths, extensionNames })
2022
- : [];
2023
-
2024
- let rc = 0;
2025
- for (const sceneName of targets) {
2026
- // eslint-disable-next-line no-await-in-loop
2027
- const code = await this.runOnScene(action, sceneName, { extensionPaths: resolvedExtensionPaths });
2028
- if (code !== 0) {
2029
- rc = 1;
2030
- }
879
+ return await extensions.downloadExtensions({
880
+ pluginRoot: this.root,
881
+ prodversion: prodversion || this.config.extensionProdversion,
882
+ log: line => this.out(line),
883
+ logError: line => this.err(line)
884
+ });
2031
885
  }
2032
-
2033
- return rc;
886
+ throw new Error(`未知 playwright 动作: ${action}`);
2034
887
  }
2035
888
  }
2036
889
 
2037
890
  module.exports = {
2038
- EXTENSIONS,
2039
- SCENE_ORDER,
2040
- SCENE_DEFS,
2041
- PlaywrightPlugin
891
+ MODES,
892
+ PlaywrightPlugin,
893
+ appendNoProxy,
894
+ connectHostFor,
895
+ defaultListenHost
2042
896
  };