@xcanwin/manyoyo 5.1.0 → 5.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/manyoyo.js CHANGED
@@ -14,7 +14,7 @@ const { buildContainerRunArgs, buildContainerRunCommand } = require('../lib/cont
14
14
  const { initAgentConfigs } = require('../lib/init-config');
15
15
  const { buildImage } = require('../lib/image-build');
16
16
  const { resolveAgentResumeArg, buildAgentResumeCommand } = require('../lib/agent-resume');
17
- const { runPluginCommand } = require('../lib/services');
17
+ const { runPluginCommand } = require('../lib/plugin');
18
18
  const { version: BIN_VERSION, imageVersion: IMAGE_VERSION_DEFAULT } = require('../package.json');
19
19
  const IMAGE_VERSION_BASE = String(IMAGE_VERSION_DEFAULT || '1.0.0').split('-')[0];
20
20
  const IMAGE_VERSION_HELP_EXAMPLE = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
@@ -873,7 +873,10 @@ async function setupCommander() {
873
873
  pluginAction: params.action || 'ls',
874
874
  pluginName: params.pluginName || 'playwright',
875
875
  pluginScene: params.scene || 'host-headless',
876
- pluginHost: params.host || ''
876
+ pluginHost: params.host || '',
877
+ pluginExtensionPaths: Array.isArray(params.extensionPaths) ? params.extensionPaths : [],
878
+ pluginExtensionNames: Array.isArray(params.extensionNames) ? params.extensionNames : [],
879
+ pluginProdversion: params.prodversion || ''
877
880
  });
878
881
  };
879
882
 
@@ -889,14 +892,22 @@ async function setupCommander() {
889
892
 
890
893
  const actions = ['up', 'down', 'status', 'health', 'logs'];
891
894
  actions.forEach(action => {
892
- command.command(`${action} [scene]`)
895
+ const sceneCommand = command.command(`${action} [scene]`)
893
896
  .description(`${action} playwright 场景,scene 默认 host-headless`)
894
- .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)')
895
- .action((scene, options) => selectPluginAction({
896
- action,
897
- pluginName: 'playwright',
898
- scene: scene || 'host-headless'
899
- }, options));
897
+ .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)');
898
+
899
+ if (action === 'up') {
900
+ appendArrayOption(sceneCommand, '--ext-path <path>', '追加浏览器扩展目录(可多次传入;目录需包含 manifest.json)');
901
+ appendArrayOption(sceneCommand, '--ext-name <name>', '追加 ~/.manyoyo/plugin/playwright/extensions/ 下的扩展目录名(可多次传入)');
902
+ }
903
+
904
+ sceneCommand.action((scene, options) => selectPluginAction({
905
+ action,
906
+ pluginName: 'playwright',
907
+ scene: scene || 'host-headless',
908
+ extensionPaths: action === 'up' ? (options.extPath || []) : [],
909
+ extensionNames: action === 'up' ? (options.extName || []) : []
910
+ }, options));
900
911
  });
901
912
 
902
913
  command.command('mcp-add')
@@ -909,6 +920,16 @@ async function setupCommander() {
909
920
  scene: 'all',
910
921
  host: options.host || ''
911
922
  }, options));
923
+
924
+ command.command('ext-download')
925
+ .description('下载并解压 Playwright 扩展到 ~/.manyoyo/plugin/playwright/extensions/')
926
+ .option('--prodversion <ver>', 'CRX 下载使用的 Chrome 版本号 (默认 132.0.0.0)')
927
+ .action(options => selectPluginAction({
928
+ action: 'ext-download',
929
+ pluginName: 'playwright',
930
+ scene: 'all',
931
+ prodversion: options.prodversion || ''
932
+ }, options));
912
933
  };
913
934
 
914
935
  program
@@ -1095,7 +1116,10 @@ async function setupCommander() {
1095
1116
  action: options.pluginAction,
1096
1117
  pluginName: options.pluginName,
1097
1118
  scene: options.pluginScene || 'host-headless',
1098
- host: options.pluginHost || ''
1119
+ host: options.pluginHost || '',
1120
+ extensionPaths: Array.isArray(options.pluginExtensionPaths) ? options.pluginExtensionPaths : [],
1121
+ extensionNames: Array.isArray(options.pluginExtensionNames) ? options.pluginExtensionNames : [],
1122
+ prodversion: options.pluginProdversion || ''
1099
1123
  },
1100
1124
  pluginGlobalConfig: config,
1101
1125
  pluginRunConfig: runConfig
@@ -36,6 +36,8 @@
36
36
  "mcpDefaultHost": "host.docker.internal",
37
37
  // cont-headed 场景读取的密码环境变量名(默认 VNC_PASSWORD)
38
38
  "vncPasswordEnvKey": "VNC_PASSWORD",
39
+ // playwright ext-download 的 CRX prodversion 参数
40
+ "extensionProdversion": "132.0.0.0",
39
41
  "ports": {
40
42
  "contHeadless": 8931,
41
43
  "contHeaded": 8932,
@@ -52,7 +52,10 @@ async function runPluginCommand(request, options = {}) {
52
52
  return await plugin.run({
53
53
  action,
54
54
  scene: request.scene,
55
- host: request.host
55
+ host: request.host,
56
+ extensionPaths: request.extensionPaths,
57
+ extensionNames: request.extensionNames,
58
+ prodversion: request.prodversion
56
59
  });
57
60
  }
58
61
 
@@ -6,6 +6,14 @@ const os = require('os');
6
6
  const path = require('path');
7
7
  const { spawn, spawnSync } = require('child_process');
8
8
 
9
+ const EXTENSIONS = [
10
+ ['ublock-origin-lite', 'ddkjiahejlhfcafbddmgiahcphecmpfh'],
11
+ ['adguard', 'bgnkhhnnamicmpeenaelnjfhikgbkllg'],
12
+ ['privacy-badger', 'pkehgijcmpdhfbdbbnkijodmdjhbjlgp'],
13
+ ['webrtc-leak-shield', 'bppamachkoflopbagkdoflbgfjflfnfl'],
14
+ ['webgl-fingerprint-defender', 'olnbjpaejebpnokblkepbphhembdicik']
15
+ ];
16
+
9
17
  const SCENE_ORDER = ['cont-headless', 'cont-headed', 'host-headless', 'host-headed'];
10
18
 
11
19
  const SCENE_DEFS = {
@@ -47,6 +55,7 @@ const SCENE_DEFS = {
47
55
 
48
56
  const VALID_RUNTIME = new Set(['container', 'host', 'mixed']);
49
57
  const VALID_ACTIONS = new Set(['up', 'down', 'status', 'health', 'logs']);
58
+ const CONTAINER_EXTENSION_ROOT = '/app/extensions';
50
59
 
51
60
  function sleep(ms) {
52
61
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -76,6 +85,129 @@ function asStringArray(value, fallback) {
76
85
  .filter(Boolean);
77
86
  }
78
87
 
88
+ function isHostPermission(value) {
89
+ if (value === '<all_urls>') {
90
+ return true;
91
+ }
92
+ return /^(?:\*|http|https|file|ftp):\/\//.test(value);
93
+ }
94
+
95
+ function scriptSourcesFromHtml(htmlFile) {
96
+ const content = fs.readFileSync(htmlFile, { encoding: 'utf8' });
97
+ const scripts = [...content.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)].map(m => m[1]);
98
+ return scripts.filter(src => !/^(?:https?:)?\/\//.test(src));
99
+ }
100
+
101
+ function convertManifestV2ToV3(extDir) {
102
+ const manifestFile = path.join(extDir, 'manifest.json');
103
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
104
+ if (manifest.manifest_version !== 2) {
105
+ return false;
106
+ }
107
+
108
+ manifest.manifest_version = 3;
109
+
110
+ if (manifest.browser_action && !manifest.action) {
111
+ manifest.action = manifest.browser_action;
112
+ delete manifest.browser_action;
113
+ }
114
+ if (manifest.page_action && !manifest.action) {
115
+ manifest.action = manifest.page_action;
116
+ delete manifest.page_action;
117
+ }
118
+
119
+ const background = manifest.background;
120
+ if (background && typeof background === 'object' && !Array.isArray(background)) {
121
+ let scripts = [];
122
+ if (Array.isArray(background.scripts)) {
123
+ scripts = background.scripts.filter(s => typeof s === 'string');
124
+ } else if (typeof background.page === 'string') {
125
+ const pagePath = path.join(extDir, background.page);
126
+ if (fs.existsSync(pagePath)) {
127
+ scripts = scriptSourcesFromHtml(pagePath);
128
+ }
129
+ }
130
+
131
+ if (scripts.length > 0) {
132
+ const swName = 'generated_background_sw.js';
133
+ const swFile = path.join(extDir, swName);
134
+ const swLines = [
135
+ '// Auto-generated by manyoyo playwright ext-download for MV3.',
136
+ `importScripts(${scripts.map(s => JSON.stringify(s)).join(', ')});`,
137
+ ''
138
+ ];
139
+ fs.writeFileSync(swFile, swLines.join('\n'), 'utf8');
140
+ manifest.background = { service_worker: swName };
141
+ } else {
142
+ delete manifest.background;
143
+ }
144
+ }
145
+
146
+ if (typeof manifest.content_security_policy === 'string') {
147
+ manifest.content_security_policy = { extension_pages: manifest.content_security_policy };
148
+ }
149
+
150
+ if (Array.isArray(manifest.permissions)) {
151
+ const hostPermissions = Array.isArray(manifest.host_permissions) ? [...manifest.host_permissions] : [];
152
+ const keptPermissions = [];
153
+
154
+ for (const perm of manifest.permissions) {
155
+ if (typeof perm === 'string' && isHostPermission(perm)) {
156
+ if (!hostPermissions.includes(perm)) {
157
+ hostPermissions.push(perm);
158
+ }
159
+ } else {
160
+ keptPermissions.push(perm);
161
+ }
162
+ }
163
+
164
+ manifest.permissions = keptPermissions;
165
+ if (hostPermissions.length > 0) {
166
+ manifest.host_permissions = hostPermissions;
167
+ }
168
+ }
169
+
170
+ const war = manifest.web_accessible_resources;
171
+ if (Array.isArray(war) && war.length > 0 && war.every(v => typeof v === 'string')) {
172
+ manifest.web_accessible_resources = [
173
+ {
174
+ resources: war,
175
+ matches: ['<all_urls>']
176
+ }
177
+ ];
178
+ }
179
+
180
+ fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
181
+ return true;
182
+ }
183
+
184
+ function buildCrxUrl(extId, prodversion) {
185
+ return (
186
+ 'https://clients2.google.com/service/update2/crx' +
187
+ `?response=redirect&prodversion=${prodversion}` +
188
+ '&acceptformat=crx2,crx3' +
189
+ `&x=id%3D${extId}%26installsource%3Dondemand%26uc`
190
+ );
191
+ }
192
+
193
+ function crxZipOffset(data) {
194
+ if (data.subarray(0, 4).toString('ascii') !== 'Cr24') {
195
+ throw new Error('not a CRX file');
196
+ }
197
+
198
+ const version = data.readUInt32LE(4);
199
+ if (version === 2) {
200
+ const pubLen = data.readUInt32LE(8);
201
+ const sigLen = data.readUInt32LE(12);
202
+ return 16 + pubLen + sigLen;
203
+ }
204
+ if (version === 3) {
205
+ const headerLen = data.readUInt32LE(8);
206
+ return 12 + headerLen;
207
+ }
208
+ throw new Error(`unsupported CRX version: ${version}`);
209
+ }
210
+
79
211
  class PlaywrightPlugin {
80
212
  constructor(options = {}) {
81
213
  this.projectRoot = options.projectRoot || path.join(__dirname, '..', '..');
@@ -88,6 +220,7 @@ class PlaywrightPlugin {
88
220
 
89
221
  resolveConfig() {
90
222
  const homeDir = os.homedir();
223
+ const pluginRootDir = path.join(homeDir, '.manyoyo', 'plugin', 'playwright');
91
224
  const defaultConfig = {
92
225
  runtime: 'mixed',
93
226
  enabledScenes: [...SCENE_ORDER],
@@ -98,8 +231,9 @@ class PlaywrightPlugin {
98
231
  containerRuntime: 'podman',
99
232
  vncPasswordEnvKey: 'VNC_PASSWORD',
100
233
  headedImage: 'localhost/xcanwin/manyoyo-playwright-headed',
101
- configDir: path.join(homeDir, '.manyoyo', 'services', 'playwright', 'config'),
102
- runDir: path.join(homeDir, '.manyoyo', 'services', 'playwright', 'run'),
234
+ configDir: path.join(pluginRootDir, 'config'),
235
+ runDir: path.join(pluginRootDir, 'run'),
236
+ extensionProdversion: '132.0.0.0',
103
237
  composeDir: path.join(__dirname, 'playwright-assets'),
104
238
  ports: {
105
239
  contHeadless: 8931,
@@ -184,8 +318,15 @@ class PlaywrightPlugin {
184
318
  }
185
319
 
186
320
  ensureCommandAvailable(command) {
187
- const check = this.runCmd([command, '--version'], { captureOutput: true, check: false });
188
- return check.returncode === 0;
321
+ const name = String(command || '').trim();
322
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
323
+ return false;
324
+ }
325
+ const result = spawnSync('sh', ['-c', `command -v ${name}`], {
326
+ encoding: 'utf8',
327
+ stdio: ['ignore', 'ignore', 'ignore']
328
+ });
329
+ return result.status === 0;
189
330
  }
190
331
 
191
332
  scenePort(sceneName) {
@@ -198,6 +339,32 @@ class PlaywrightPlugin {
198
339
  return path.join(this.config.configDir, def.configFile);
199
340
  }
200
341
 
342
+ sceneConfigMissing(sceneName) {
343
+ return !fs.existsSync(this.sceneConfigPath(sceneName));
344
+ }
345
+
346
+ defaultBrowserName(sceneName) {
347
+ const cfg = this.buildSceneConfig(sceneName);
348
+ const browserName = cfg && cfg.browser && cfg.browser.browserName;
349
+ return String(browserName || 'chromium');
350
+ }
351
+
352
+ ensureContainerScenePrerequisites(sceneName) {
353
+ if (!this.sceneConfigMissing(sceneName)) {
354
+ return;
355
+ }
356
+ const tag = String(this.config.dockerTag || 'latest').trim() || 'latest';
357
+ const image = `mcr.microsoft.com/playwright/mcp:${tag}`;
358
+ this.runCmd([this.config.containerRuntime, 'pull', image], { check: true });
359
+ }
360
+
361
+ ensureHostScenePrerequisites(sceneName) {
362
+ if (!this.sceneConfigMissing(sceneName)) {
363
+ return;
364
+ }
365
+ this.runCmd(['npx', '-y', 'playwright-core', 'install', this.defaultBrowserName(sceneName)], { check: true });
366
+ }
367
+
201
368
  scenePidFile(sceneName) {
202
369
  return path.join(this.config.runDir, `${sceneName}.pid`);
203
370
  }
@@ -206,6 +373,14 @@ class PlaywrightPlugin {
206
373
  return path.join(this.config.runDir, `${sceneName}.log`);
207
374
  }
208
375
 
376
+ extensionDirPath() {
377
+ return path.join(os.homedir(), '.manyoyo', 'plugin', 'playwright', 'extensions');
378
+ }
379
+
380
+ extensionTmpDirPath() {
381
+ return path.join(os.homedir(), '.manyoyo', 'plugin', 'playwright', 'tmp-crx');
382
+ }
383
+
209
384
  resolveTargets(sceneName = 'all') {
210
385
  const requested = String(sceneName || 'all').trim();
211
386
  const enabledSet = new Set(this.config.enabledScenes);
@@ -237,9 +412,112 @@ class PlaywrightPlugin {
237
412
  .filter(scene => isAllowedByRuntime(scene));
238
413
  }
239
414
 
240
- buildSceneConfig(sceneName) {
415
+ resolveExtensionPaths(extensionArgs = []) {
416
+ const inputs = asStringArray(extensionArgs, []);
417
+ const uniquePaths = [];
418
+ const seen = new Set();
419
+
420
+ for (const item of inputs) {
421
+ const absPath = path.resolve(item);
422
+ if (!fs.existsSync(absPath)) {
423
+ throw new Error(`扩展路径不存在: ${absPath}`);
424
+ }
425
+ const stat = fs.statSync(absPath);
426
+ if (!stat.isDirectory()) {
427
+ throw new Error(`扩展路径必须是目录: ${absPath}`);
428
+ }
429
+
430
+ const manifestPath = path.join(absPath, 'manifest.json');
431
+ if (fs.existsSync(manifestPath)) {
432
+ if (!seen.has(absPath)) {
433
+ seen.add(absPath);
434
+ uniquePaths.push(absPath);
435
+ }
436
+ continue;
437
+ }
438
+
439
+ const children = fs.readdirSync(absPath, { withFileTypes: true })
440
+ .filter(dirent => dirent.isDirectory())
441
+ .map(dirent => path.join(absPath, dirent.name))
442
+ .filter(child => fs.existsSync(path.join(child, 'manifest.json')));
443
+
444
+ if (children.length === 0) {
445
+ throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
446
+ }
447
+
448
+ for (const childPath of children) {
449
+ if (!seen.has(childPath)) {
450
+ seen.add(childPath);
451
+ uniquePaths.push(childPath);
452
+ }
453
+ }
454
+ }
455
+
456
+ return uniquePaths;
457
+ }
458
+
459
+ resolveNamedExtensionPaths(extensionNames = []) {
460
+ const names = asStringArray(extensionNames, []);
461
+ const extensionRoot = path.resolve(this.extensionDirPath());
462
+
463
+ return names.map(name => {
464
+ if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
465
+ throw new Error(`扩展名称无效: ${name}`);
466
+ }
467
+ return path.join(extensionRoot, name);
468
+ });
469
+ }
470
+
471
+ resolveExtensionInputs(options = {}) {
472
+ const extensionPaths = asStringArray(options.extensionPaths, []);
473
+ const namedPaths = this.resolveNamedExtensionPaths(options.extensionNames || []);
474
+ return this.resolveExtensionPaths([...extensionPaths, ...namedPaths]);
475
+ }
476
+
477
+ buildExtensionLaunchArgs(extensionPaths) {
478
+ const joined = extensionPaths.join(',');
479
+ return [
480
+ `--disable-extensions-except=${joined}`,
481
+ `--load-extension=${joined}`
482
+ ];
483
+ }
484
+
485
+ sanitizeExtensionMountName(value) {
486
+ const sanitized = String(value || '')
487
+ .trim()
488
+ .replace(/[^A-Za-z0-9._-]/g, '-')
489
+ .replace(/-+/g, '-')
490
+ .replace(/^-|-$/g, '');
491
+ return sanitized || 'ext';
492
+ }
493
+
494
+ buildContainerExtensionMounts(extensionPaths = []) {
495
+ const hostPaths = asStringArray(extensionPaths, []);
496
+ const containerPaths = [];
497
+ const volumeMounts = [];
498
+
499
+ hostPaths.forEach((hostPath, idx) => {
500
+ const safeName = this.sanitizeExtensionMountName(path.basename(hostPath));
501
+ const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
502
+ containerPaths.push(containerPath);
503
+ volumeMounts.push(`${hostPath}:${containerPath}:ro`);
504
+ });
505
+
506
+ return { containerPaths, volumeMounts };
507
+ }
508
+
509
+ buildSceneConfig(sceneName, options = {}) {
241
510
  const def = SCENE_DEFS[sceneName];
242
511
  const port = this.scenePort(sceneName);
512
+ const extensionPaths = asStringArray(options.extensionPaths, []);
513
+ const launchOptions = {
514
+ channel: 'chromium',
515
+ headless: def.headless
516
+ };
517
+
518
+ if (extensionPaths.length > 0) {
519
+ launchOptions.args = this.buildExtensionLaunchArgs(extensionPaths);
520
+ }
243
521
 
244
522
  return {
245
523
  server: {
@@ -255,10 +533,7 @@ class PlaywrightPlugin {
255
533
  browser: {
256
534
  chromiumSandbox: true,
257
535
  browserName: 'chromium',
258
- launchOptions: {
259
- channel: 'chromium',
260
- headless: def.headless
261
- },
536
+ launchOptions,
262
537
  contextOptions: {
263
538
  userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36'
264
539
  }
@@ -266,9 +541,9 @@ class PlaywrightPlugin {
266
541
  };
267
542
  }
268
543
 
269
- ensureSceneConfig(sceneName) {
544
+ ensureSceneConfig(sceneName, options = {}) {
270
545
  fs.mkdirSync(this.config.configDir, { recursive: true });
271
- const payload = this.buildSceneConfig(sceneName);
546
+ const payload = this.buildSceneConfig(sceneName, options);
272
547
  const filePath = this.sceneConfigPath(sceneName);
273
548
  fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
274
549
  return filePath;
@@ -339,28 +614,74 @@ class PlaywrightPlugin {
339
614
  return path.join(this.config.composeDir, def.composeFile);
340
615
  }
341
616
 
342
- async startContainer(sceneName) {
617
+ sceneComposeOverridePath(sceneName) {
618
+ return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
619
+ }
620
+
621
+ ensureContainerComposeOverride(sceneName, volumeMounts = []) {
622
+ const overridePath = this.sceneComposeOverridePath(sceneName);
623
+ if (!Array.isArray(volumeMounts) || volumeMounts.length === 0) {
624
+ fs.rmSync(overridePath, { force: true });
625
+ return '';
626
+ }
627
+
628
+ fs.mkdirSync(this.config.runDir, { recursive: true });
629
+ const lines = [
630
+ 'services:',
631
+ ' playwright:',
632
+ ' volumes:'
633
+ ];
634
+ volumeMounts.forEach(item => {
635
+ lines.push(` - ${JSON.stringify(String(item))}`);
636
+ });
637
+ fs.writeFileSync(overridePath, `${lines.join('\n')}\n`, 'utf8');
638
+ return overridePath;
639
+ }
640
+
641
+ async startContainer(sceneName, options = {}) {
343
642
  const runtime = this.config.containerRuntime;
344
643
  if (!this.ensureCommandAvailable(runtime)) {
345
644
  this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
346
645
  return 1;
347
646
  }
348
647
 
349
- const cfgPath = this.ensureSceneConfig(sceneName);
648
+ try {
649
+ this.ensureContainerScenePrerequisites(sceneName);
650
+ } catch (error) {
651
+ return error.returncode || 1;
652
+ }
653
+
654
+ const incomingExtensionPaths = asStringArray(options.extensionPaths, []);
655
+ let configOptions = { ...options, extensionPaths: incomingExtensionPaths };
656
+ const composeFiles = [this.containerComposePath(sceneName)];
657
+
658
+ if (incomingExtensionPaths.length > 0) {
659
+ const mapped = this.buildContainerExtensionMounts(incomingExtensionPaths);
660
+ const overridePath = this.ensureContainerComposeOverride(sceneName, mapped.volumeMounts);
661
+ if (overridePath) {
662
+ composeFiles.push(overridePath);
663
+ }
664
+ configOptions = { ...options, extensionPaths: mapped.containerPaths };
665
+ } else {
666
+ this.ensureContainerComposeOverride(sceneName, []);
667
+ }
668
+
669
+ const cfgPath = this.ensureSceneConfig(sceneName, configOptions);
350
670
  const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
351
671
  const def = SCENE_DEFS[sceneName];
352
672
 
353
673
  try {
354
- this.runCmd([
674
+ const args = [
355
675
  runtime,
356
676
  'compose',
357
677
  '-p',
358
- def.projectName,
359
- '-f',
360
- this.containerComposePath(sceneName),
361
- 'up',
362
- '-d'
363
- ], { env, check: true });
678
+ def.projectName
679
+ ];
680
+ composeFiles.forEach(filePath => {
681
+ args.push('-f', filePath);
682
+ });
683
+ args.push('up', '-d');
684
+ this.runCmd(args, { env, check: true });
364
685
  } catch (error) {
365
686
  return error.returncode || 1;
366
687
  }
@@ -382,6 +703,7 @@ class PlaywrightPlugin {
382
703
  return 1;
383
704
  }
384
705
 
706
+ this.ensureContainerComposeOverride(sceneName, []);
385
707
  const cfgPath = this.sceneConfigPath(sceneName);
386
708
  const env = this.containerEnv(sceneName, cfgPath);
387
709
  const def = SCENE_DEFS[sceneName];
@@ -520,14 +842,20 @@ class PlaywrightPlugin {
520
842
  return [];
521
843
  }
522
844
 
523
- async startHost(sceneName) {
845
+ async startHost(sceneName, options = {}) {
524
846
  if (!this.ensureCommandAvailable('npx')) {
525
847
  this.writeStderr(`[up] ${sceneName} failed: npx command not found.`);
526
848
  return 1;
527
849
  }
528
850
 
851
+ try {
852
+ this.ensureHostScenePrerequisites(sceneName);
853
+ } catch (error) {
854
+ return error.returncode || 1;
855
+ }
856
+
529
857
  fs.mkdirSync(this.config.runDir, { recursive: true });
530
- const cfgPath = this.ensureSceneConfig(sceneName);
858
+ const cfgPath = this.ensureSceneConfig(sceneName, options);
531
859
  const pidFile = this.scenePidFile(sceneName);
532
860
  const logFile = this.sceneLogFile(sceneName);
533
861
  const port = this.scenePort(sceneName);
@@ -665,6 +993,118 @@ class PlaywrightPlugin {
665
993
  return 0;
666
994
  }
667
995
 
996
+ async downloadFile(url, output, retries = 3, timeoutMs = 60_000) {
997
+ const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
998
+ if (!this.ensureCommandAvailable('curl')) {
999
+ throw new Error('curl command not found');
1000
+ }
1001
+
1002
+ let lastError = null;
1003
+ for (let i = 1; i <= retries; i += 1) {
1004
+ try {
1005
+ const result = this.runCmd([
1006
+ 'curl',
1007
+ '--fail',
1008
+ '--location',
1009
+ '--silent',
1010
+ '--show-error',
1011
+ '--connect-timeout',
1012
+ String(timeoutSec),
1013
+ '--max-time',
1014
+ String(timeoutSec),
1015
+ '--output',
1016
+ output,
1017
+ url
1018
+ ], { captureOutput: true, check: false });
1019
+ if (result.returncode !== 0) {
1020
+ throw new Error(result.stderr || `curl failed with exit code ${result.returncode}`);
1021
+ }
1022
+ return;
1023
+ } catch (error) {
1024
+ lastError = error;
1025
+ if (i < retries) {
1026
+ // eslint-disable-next-line no-await-in-loop
1027
+ await sleep(1000);
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
1033
+ }
1034
+
1035
+ extractZipBuffer(zipBuffer, outDir) {
1036
+ fs.mkdirSync(outDir, { recursive: true });
1037
+ const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
1038
+ fs.writeFileSync(tempZip, zipBuffer);
1039
+
1040
+ const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
1041
+ fs.rmSync(tempZip, { force: true });
1042
+
1043
+ if (result.error) {
1044
+ throw result.error;
1045
+ }
1046
+ if (result.status !== 0) {
1047
+ throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
1048
+ }
1049
+ }
1050
+
1051
+ extractCrx(crxFile, outDir) {
1052
+ const data = fs.readFileSync(crxFile);
1053
+ const offset = crxZipOffset(data);
1054
+ const zipBuffer = data.subarray(offset);
1055
+
1056
+ this.extractZipBuffer(zipBuffer, outDir);
1057
+
1058
+ const manifest = path.join(outDir, 'manifest.json');
1059
+ if (!fs.existsSync(manifest)) {
1060
+ throw new Error(`${crxFile} extracted but manifest.json missing`);
1061
+ }
1062
+
1063
+ if (convertManifestV2ToV3(outDir)) {
1064
+ this.writeStdout(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
1065
+ }
1066
+ }
1067
+
1068
+ async downloadExtensions(options = {}) {
1069
+ if (!this.ensureCommandAvailable('unzip')) {
1070
+ this.writeStderr('[ext-download] failed: unzip command not found.');
1071
+ return 1;
1072
+ }
1073
+
1074
+ const prodversion = String(options.prodversion || this.config.extensionProdversion || '132.0.0.0').trim();
1075
+ const extDir = path.resolve(this.extensionDirPath());
1076
+ const tmpDir = path.resolve(this.extensionTmpDirPath());
1077
+
1078
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1079
+ fs.mkdirSync(extDir, { recursive: true });
1080
+ fs.mkdirSync(tmpDir, { recursive: true });
1081
+
1082
+ try {
1083
+ this.writeStdout(`[info] ext dir: ${extDir}`);
1084
+ this.writeStdout(`[info] tmp dir: ${tmpDir}`);
1085
+
1086
+ for (const [name, extId] of EXTENSIONS) {
1087
+ const url = buildCrxUrl(extId, prodversion);
1088
+ const crxFile = path.join(tmpDir, `${name}.crx`);
1089
+ const outDir = path.join(extDir, name);
1090
+
1091
+ this.writeStdout(`[download] ${name}`);
1092
+ // eslint-disable-next-line no-await-in-loop
1093
+ await this.downloadFile(url, crxFile);
1094
+
1095
+ this.writeStdout(`[extract] ${name}`);
1096
+ fs.rmSync(outDir, { recursive: true, force: true });
1097
+ this.extractCrx(crxFile, outDir);
1098
+ }
1099
+ } finally {
1100
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1101
+ this.writeStdout(`[cleanup] removed ${tmpDir}`);
1102
+ }
1103
+
1104
+ this.writeStdout(`[done] all extensions are ready: ${extDir}`);
1105
+ return 0;
1106
+ }
1107
+
668
1108
  detectCurrentIPv4() {
669
1109
  const interfaces = os.networkInterfaces();
670
1110
  for (const values of Object.values(interfaces)) {
@@ -724,12 +1164,12 @@ class PlaywrightPlugin {
724
1164
  return 0;
725
1165
  }
726
1166
 
727
- async runOnScene(action, sceneName) {
1167
+ async runOnScene(action, sceneName, options = {}) {
728
1168
  const def = SCENE_DEFS[sceneName];
729
1169
  if (action === 'up') {
730
1170
  return def.type === 'container'
731
- ? await this.startContainer(sceneName)
732
- : await this.startHost(sceneName);
1171
+ ? await this.startContainer(sceneName, options)
1172
+ : await this.startHost(sceneName, options);
733
1173
  }
734
1174
  if (action === 'down') {
735
1175
  return def.type === 'container'
@@ -753,7 +1193,7 @@ class PlaywrightPlugin {
753
1193
  return 1;
754
1194
  }
755
1195
 
756
- async run({ action, scene = 'host-headless', host = '' }) {
1196
+ async run({ action, scene = 'host-headless', host = '', extensionPaths = [], extensionNames = [], prodversion = '' }) {
757
1197
  if (action === 'ls') {
758
1198
  return this.printSummary();
759
1199
  }
@@ -762,6 +1202,10 @@ class PlaywrightPlugin {
762
1202
  return this.printMcpAdd(host);
763
1203
  }
764
1204
 
1205
+ if (action === 'ext-download') {
1206
+ return await this.downloadExtensions({ prodversion });
1207
+ }
1208
+
765
1209
  if (!VALID_ACTIONS.has(action)) {
766
1210
  throw new Error(`未知 plugin 动作: ${action}`);
767
1211
  }
@@ -772,10 +1216,14 @@ class PlaywrightPlugin {
772
1216
  return 1;
773
1217
  }
774
1218
 
1219
+ const resolvedExtensionPaths = action === 'up'
1220
+ ? this.resolveExtensionInputs({ extensionPaths, extensionNames })
1221
+ : [];
1222
+
775
1223
  let rc = 0;
776
1224
  for (const sceneName of targets) {
777
1225
  // eslint-disable-next-line no-await-in-loop
778
- const code = await this.runOnScene(action, sceneName);
1226
+ const code = await this.runOnScene(action, sceneName, { extensionPaths: resolvedExtensionPaths });
779
1227
  if (code !== 0) {
780
1228
  rc = 1;
781
1229
  }
@@ -786,6 +1234,7 @@ class PlaywrightPlugin {
786
1234
  }
787
1235
 
788
1236
  module.exports = {
1237
+ EXTENSIONS,
789
1238
  SCENE_ORDER,
790
1239
  SCENE_DEFS,
791
1240
  PlaywrightPlugin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "5.1.0",
3
+ "version": "5.1.4",
4
4
  "imageVersion": "1.8.1-common",
5
5
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",
6
6
  "keywords": [