@xcanwin/manyoyo 5.1.0 → 5.1.3

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,9 @@ 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
+ pluginExtensions: Array.isArray(params.extensions) ? params.extensions : [],
878
+ pluginProdversion: params.prodversion || ''
877
879
  });
878
880
  };
879
881
 
@@ -889,14 +891,20 @@ async function setupCommander() {
889
891
 
890
892
  const actions = ['up', 'down', 'status', 'health', 'logs'];
891
893
  actions.forEach(action => {
892
- command.command(`${action} [scene]`)
894
+ const sceneCommand = command.command(`${action} [scene]`)
893
895
  .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));
896
+ .option('-r, --run <name>', '加载运行配置 (从 ~/.manyoyo/manyoyo.json 的 runs.<name> 读取)');
897
+
898
+ if (action === 'up') {
899
+ appendArrayOption(sceneCommand, '--ext <path>', '追加浏览器扩展目录(可多次传入;目录需包含 manifest.json)');
900
+ }
901
+
902
+ sceneCommand.action((scene, options) => selectPluginAction({
903
+ action,
904
+ pluginName: 'playwright',
905
+ scene: scene || 'host-headless',
906
+ extensions: action === 'up' ? (options.ext || []) : []
907
+ }, options));
900
908
  });
901
909
 
902
910
  command.command('mcp-add')
@@ -909,6 +917,16 @@ async function setupCommander() {
909
917
  scene: 'all',
910
918
  host: options.host || ''
911
919
  }, options));
920
+
921
+ command.command('ext-download')
922
+ .description('下载并解压 Playwright 扩展到 ~/.manyoyo/plugin/playwright/extensions/')
923
+ .option('--prodversion <ver>', 'CRX 下载使用的 Chrome 版本号 (默认 132.0.0.0)')
924
+ .action(options => selectPluginAction({
925
+ action: 'ext-download',
926
+ pluginName: 'playwright',
927
+ scene: 'all',
928
+ prodversion: options.prodversion || ''
929
+ }, options));
912
930
  };
913
931
 
914
932
  program
@@ -1095,7 +1113,9 @@ async function setupCommander() {
1095
1113
  action: options.pluginAction,
1096
1114
  pluginName: options.pluginName,
1097
1115
  scene: options.pluginScene || 'host-headless',
1098
- host: options.pluginHost || ''
1116
+ host: options.pluginHost || '',
1117
+ extensions: Array.isArray(options.pluginExtensions) ? options.pluginExtensions : [],
1118
+ prodversion: options.pluginProdversion || ''
1099
1119
  },
1100
1120
  pluginGlobalConfig: config,
1101
1121
  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,9 @@ 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
+ extensions: request.extensions,
57
+ prodversion: request.prodversion
56
58
  });
57
59
  }
58
60
 
@@ -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) {
@@ -206,6 +347,14 @@ class PlaywrightPlugin {
206
347
  return path.join(this.config.runDir, `${sceneName}.log`);
207
348
  }
208
349
 
350
+ extensionDirPath() {
351
+ return path.join(os.homedir(), '.manyoyo', 'plugin', 'playwright', 'extensions');
352
+ }
353
+
354
+ extensionTmpDirPath() {
355
+ return path.join(os.homedir(), '.manyoyo', 'plugin', 'playwright', 'tmp-crx');
356
+ }
357
+
209
358
  resolveTargets(sceneName = 'all') {
210
359
  const requested = String(sceneName || 'all').trim();
211
360
  const enabledSet = new Set(this.config.enabledScenes);
@@ -237,9 +386,94 @@ class PlaywrightPlugin {
237
386
  .filter(scene => isAllowedByRuntime(scene));
238
387
  }
239
388
 
240
- buildSceneConfig(sceneName) {
389
+ resolveExtensionPaths(extensionArgs = []) {
390
+ const inputs = asStringArray(extensionArgs, []);
391
+ const uniquePaths = [];
392
+ const seen = new Set();
393
+
394
+ for (const item of inputs) {
395
+ const absPath = path.resolve(item);
396
+ if (!fs.existsSync(absPath)) {
397
+ throw new Error(`扩展路径不存在: ${absPath}`);
398
+ }
399
+ const stat = fs.statSync(absPath);
400
+ if (!stat.isDirectory()) {
401
+ throw new Error(`扩展路径必须是目录: ${absPath}`);
402
+ }
403
+
404
+ const manifestPath = path.join(absPath, 'manifest.json');
405
+ if (fs.existsSync(manifestPath)) {
406
+ if (!seen.has(absPath)) {
407
+ seen.add(absPath);
408
+ uniquePaths.push(absPath);
409
+ }
410
+ continue;
411
+ }
412
+
413
+ const children = fs.readdirSync(absPath, { withFileTypes: true })
414
+ .filter(dirent => dirent.isDirectory())
415
+ .map(dirent => path.join(absPath, dirent.name))
416
+ .filter(child => fs.existsSync(path.join(child, 'manifest.json')));
417
+
418
+ if (children.length === 0) {
419
+ throw new Error(`目录下未找到扩展(manifest.json): ${absPath}`);
420
+ }
421
+
422
+ for (const childPath of children) {
423
+ if (!seen.has(childPath)) {
424
+ seen.add(childPath);
425
+ uniquePaths.push(childPath);
426
+ }
427
+ }
428
+ }
429
+
430
+ return uniquePaths;
431
+ }
432
+
433
+ buildExtensionLaunchArgs(extensionPaths) {
434
+ const joined = extensionPaths.join(',');
435
+ return [
436
+ `--disable-extensions-except=${joined}`,
437
+ `--load-extension=${joined}`
438
+ ];
439
+ }
440
+
441
+ sanitizeExtensionMountName(value) {
442
+ const sanitized = String(value || '')
443
+ .trim()
444
+ .replace(/[^A-Za-z0-9._-]/g, '-')
445
+ .replace(/-+/g, '-')
446
+ .replace(/^-|-$/g, '');
447
+ return sanitized || 'ext';
448
+ }
449
+
450
+ buildContainerExtensionMounts(extensionPaths = []) {
451
+ const hostPaths = asStringArray(extensionPaths, []);
452
+ const containerPaths = [];
453
+ const volumeMounts = [];
454
+
455
+ hostPaths.forEach((hostPath, idx) => {
456
+ const safeName = this.sanitizeExtensionMountName(path.basename(hostPath));
457
+ const containerPath = path.posix.join(CONTAINER_EXTENSION_ROOT, `ext-${idx + 1}-${safeName}`);
458
+ containerPaths.push(containerPath);
459
+ volumeMounts.push(`${hostPath}:${containerPath}:ro`);
460
+ });
461
+
462
+ return { containerPaths, volumeMounts };
463
+ }
464
+
465
+ buildSceneConfig(sceneName, options = {}) {
241
466
  const def = SCENE_DEFS[sceneName];
242
467
  const port = this.scenePort(sceneName);
468
+ const extensionPaths = asStringArray(options.extensionPaths, []);
469
+ const launchOptions = {
470
+ channel: 'chromium',
471
+ headless: def.headless
472
+ };
473
+
474
+ if (extensionPaths.length > 0) {
475
+ launchOptions.args = this.buildExtensionLaunchArgs(extensionPaths);
476
+ }
243
477
 
244
478
  return {
245
479
  server: {
@@ -255,10 +489,7 @@ class PlaywrightPlugin {
255
489
  browser: {
256
490
  chromiumSandbox: true,
257
491
  browserName: 'chromium',
258
- launchOptions: {
259
- channel: 'chromium',
260
- headless: def.headless
261
- },
492
+ launchOptions,
262
493
  contextOptions: {
263
494
  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
495
  }
@@ -266,9 +497,9 @@ class PlaywrightPlugin {
266
497
  };
267
498
  }
268
499
 
269
- ensureSceneConfig(sceneName) {
500
+ ensureSceneConfig(sceneName, options = {}) {
270
501
  fs.mkdirSync(this.config.configDir, { recursive: true });
271
- const payload = this.buildSceneConfig(sceneName);
502
+ const payload = this.buildSceneConfig(sceneName, options);
272
503
  const filePath = this.sceneConfigPath(sceneName);
273
504
  fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 4)}\n`, 'utf8');
274
505
  return filePath;
@@ -339,28 +570,68 @@ class PlaywrightPlugin {
339
570
  return path.join(this.config.composeDir, def.composeFile);
340
571
  }
341
572
 
342
- async startContainer(sceneName) {
573
+ sceneComposeOverridePath(sceneName) {
574
+ return path.join(this.config.runDir, `${sceneName}.compose.override.yaml`);
575
+ }
576
+
577
+ ensureContainerComposeOverride(sceneName, volumeMounts = []) {
578
+ const overridePath = this.sceneComposeOverridePath(sceneName);
579
+ if (!Array.isArray(volumeMounts) || volumeMounts.length === 0) {
580
+ fs.rmSync(overridePath, { force: true });
581
+ return '';
582
+ }
583
+
584
+ fs.mkdirSync(this.config.runDir, { recursive: true });
585
+ const lines = [
586
+ 'services:',
587
+ ' playwright:',
588
+ ' volumes:'
589
+ ];
590
+ volumeMounts.forEach(item => {
591
+ lines.push(` - ${JSON.stringify(String(item))}`);
592
+ });
593
+ fs.writeFileSync(overridePath, `${lines.join('\n')}\n`, 'utf8');
594
+ return overridePath;
595
+ }
596
+
597
+ async startContainer(sceneName, options = {}) {
343
598
  const runtime = this.config.containerRuntime;
344
599
  if (!this.ensureCommandAvailable(runtime)) {
345
600
  this.writeStderr(`[up] ${sceneName} failed: ${runtime} command not found.`);
346
601
  return 1;
347
602
  }
348
603
 
349
- const cfgPath = this.ensureSceneConfig(sceneName);
604
+ const incomingExtensionPaths = asStringArray(options.extensionPaths, []);
605
+ let configOptions = { ...options, extensionPaths: incomingExtensionPaths };
606
+ const composeFiles = [this.containerComposePath(sceneName)];
607
+
608
+ if (incomingExtensionPaths.length > 0) {
609
+ const mapped = this.buildContainerExtensionMounts(incomingExtensionPaths);
610
+ const overridePath = this.ensureContainerComposeOverride(sceneName, mapped.volumeMounts);
611
+ if (overridePath) {
612
+ composeFiles.push(overridePath);
613
+ }
614
+ configOptions = { ...options, extensionPaths: mapped.containerPaths };
615
+ } else {
616
+ this.ensureContainerComposeOverride(sceneName, []);
617
+ }
618
+
619
+ const cfgPath = this.ensureSceneConfig(sceneName, configOptions);
350
620
  const env = this.containerEnv(sceneName, cfgPath, { requireVncPassword: true });
351
621
  const def = SCENE_DEFS[sceneName];
352
622
 
353
623
  try {
354
- this.runCmd([
624
+ const args = [
355
625
  runtime,
356
626
  'compose',
357
627
  '-p',
358
- def.projectName,
359
- '-f',
360
- this.containerComposePath(sceneName),
361
- 'up',
362
- '-d'
363
- ], { env, check: true });
628
+ def.projectName
629
+ ];
630
+ composeFiles.forEach(filePath => {
631
+ args.push('-f', filePath);
632
+ });
633
+ args.push('up', '-d');
634
+ this.runCmd(args, { env, check: true });
364
635
  } catch (error) {
365
636
  return error.returncode || 1;
366
637
  }
@@ -382,6 +653,7 @@ class PlaywrightPlugin {
382
653
  return 1;
383
654
  }
384
655
 
656
+ this.ensureContainerComposeOverride(sceneName, []);
385
657
  const cfgPath = this.sceneConfigPath(sceneName);
386
658
  const env = this.containerEnv(sceneName, cfgPath);
387
659
  const def = SCENE_DEFS[sceneName];
@@ -520,14 +792,14 @@ class PlaywrightPlugin {
520
792
  return [];
521
793
  }
522
794
 
523
- async startHost(sceneName) {
795
+ async startHost(sceneName, options = {}) {
524
796
  if (!this.ensureCommandAvailable('npx')) {
525
797
  this.writeStderr(`[up] ${sceneName} failed: npx command not found.`);
526
798
  return 1;
527
799
  }
528
800
 
529
801
  fs.mkdirSync(this.config.runDir, { recursive: true });
530
- const cfgPath = this.ensureSceneConfig(sceneName);
802
+ const cfgPath = this.ensureSceneConfig(sceneName, options);
531
803
  const pidFile = this.scenePidFile(sceneName);
532
804
  const logFile = this.sceneLogFile(sceneName);
533
805
  const port = this.scenePort(sceneName);
@@ -665,6 +937,118 @@ class PlaywrightPlugin {
665
937
  return 0;
666
938
  }
667
939
 
940
+ async downloadFile(url, output, retries = 3, timeoutMs = 60_000) {
941
+ const timeoutSec = Math.max(1, Math.ceil(timeoutMs / 1000));
942
+ if (!this.ensureCommandAvailable('curl')) {
943
+ throw new Error('curl command not found');
944
+ }
945
+
946
+ let lastError = null;
947
+ for (let i = 1; i <= retries; i += 1) {
948
+ try {
949
+ const result = this.runCmd([
950
+ 'curl',
951
+ '--fail',
952
+ '--location',
953
+ '--silent',
954
+ '--show-error',
955
+ '--connect-timeout',
956
+ String(timeoutSec),
957
+ '--max-time',
958
+ String(timeoutSec),
959
+ '--output',
960
+ output,
961
+ url
962
+ ], { captureOutput: true, check: false });
963
+ if (result.returncode !== 0) {
964
+ throw new Error(result.stderr || `curl failed with exit code ${result.returncode}`);
965
+ }
966
+ return;
967
+ } catch (error) {
968
+ lastError = error;
969
+ if (i < retries) {
970
+ // eslint-disable-next-line no-await-in-loop
971
+ await sleep(1000);
972
+ }
973
+ }
974
+ }
975
+
976
+ throw new Error(`download failed after ${retries} attempts: ${url}; ${String(lastError)}`);
977
+ }
978
+
979
+ extractZipBuffer(zipBuffer, outDir) {
980
+ fs.mkdirSync(outDir, { recursive: true });
981
+ const tempZip = path.join(os.tmpdir(), `manyoyo-playwright-ext-${process.pid}-${Date.now()}.zip`);
982
+ fs.writeFileSync(tempZip, zipBuffer);
983
+
984
+ const result = spawnSync('unzip', ['-oq', tempZip, '-d', outDir], { encoding: 'utf8' });
985
+ fs.rmSync(tempZip, { force: true });
986
+
987
+ if (result.error) {
988
+ throw result.error;
989
+ }
990
+ if (result.status !== 0) {
991
+ throw new Error(result.stderr || `unzip failed with exit code ${result.status}`);
992
+ }
993
+ }
994
+
995
+ extractCrx(crxFile, outDir) {
996
+ const data = fs.readFileSync(crxFile);
997
+ const offset = crxZipOffset(data);
998
+ const zipBuffer = data.subarray(offset);
999
+
1000
+ this.extractZipBuffer(zipBuffer, outDir);
1001
+
1002
+ const manifest = path.join(outDir, 'manifest.json');
1003
+ if (!fs.existsSync(manifest)) {
1004
+ throw new Error(`${crxFile} extracted but manifest.json missing`);
1005
+ }
1006
+
1007
+ if (convertManifestV2ToV3(outDir)) {
1008
+ this.writeStdout(`[manifest] upgraded to MV3: ${path.basename(outDir)}`);
1009
+ }
1010
+ }
1011
+
1012
+ async downloadExtensions(options = {}) {
1013
+ if (!this.ensureCommandAvailable('unzip')) {
1014
+ this.writeStderr('[ext-download] failed: unzip command not found.');
1015
+ return 1;
1016
+ }
1017
+
1018
+ const prodversion = String(options.prodversion || this.config.extensionProdversion || '132.0.0.0').trim();
1019
+ const extDir = path.resolve(this.extensionDirPath());
1020
+ const tmpDir = path.resolve(this.extensionTmpDirPath());
1021
+
1022
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1023
+ fs.mkdirSync(extDir, { recursive: true });
1024
+ fs.mkdirSync(tmpDir, { recursive: true });
1025
+
1026
+ try {
1027
+ this.writeStdout(`[info] ext dir: ${extDir}`);
1028
+ this.writeStdout(`[info] tmp dir: ${tmpDir}`);
1029
+
1030
+ for (const [name, extId] of EXTENSIONS) {
1031
+ const url = buildCrxUrl(extId, prodversion);
1032
+ const crxFile = path.join(tmpDir, `${name}.crx`);
1033
+ const outDir = path.join(extDir, name);
1034
+
1035
+ this.writeStdout(`[download] ${name}`);
1036
+ // eslint-disable-next-line no-await-in-loop
1037
+ await this.downloadFile(url, crxFile);
1038
+
1039
+ this.writeStdout(`[extract] ${name}`);
1040
+ fs.rmSync(outDir, { recursive: true, force: true });
1041
+ this.extractCrx(crxFile, outDir);
1042
+ }
1043
+ } finally {
1044
+ fs.rmSync(tmpDir, { recursive: true, force: true });
1045
+ this.writeStdout(`[cleanup] removed ${tmpDir}`);
1046
+ }
1047
+
1048
+ this.writeStdout(`[done] all extensions are ready: ${extDir}`);
1049
+ return 0;
1050
+ }
1051
+
668
1052
  detectCurrentIPv4() {
669
1053
  const interfaces = os.networkInterfaces();
670
1054
  for (const values of Object.values(interfaces)) {
@@ -724,12 +1108,12 @@ class PlaywrightPlugin {
724
1108
  return 0;
725
1109
  }
726
1110
 
727
- async runOnScene(action, sceneName) {
1111
+ async runOnScene(action, sceneName, options = {}) {
728
1112
  const def = SCENE_DEFS[sceneName];
729
1113
  if (action === 'up') {
730
1114
  return def.type === 'container'
731
- ? await this.startContainer(sceneName)
732
- : await this.startHost(sceneName);
1115
+ ? await this.startContainer(sceneName, options)
1116
+ : await this.startHost(sceneName, options);
733
1117
  }
734
1118
  if (action === 'down') {
735
1119
  return def.type === 'container'
@@ -753,7 +1137,7 @@ class PlaywrightPlugin {
753
1137
  return 1;
754
1138
  }
755
1139
 
756
- async run({ action, scene = 'host-headless', host = '' }) {
1140
+ async run({ action, scene = 'host-headless', host = '', extensions = [], prodversion = '' }) {
757
1141
  if (action === 'ls') {
758
1142
  return this.printSummary();
759
1143
  }
@@ -762,6 +1146,10 @@ class PlaywrightPlugin {
762
1146
  return this.printMcpAdd(host);
763
1147
  }
764
1148
 
1149
+ if (action === 'ext-download') {
1150
+ return await this.downloadExtensions({ prodversion });
1151
+ }
1152
+
765
1153
  if (!VALID_ACTIONS.has(action)) {
766
1154
  throw new Error(`未知 plugin 动作: ${action}`);
767
1155
  }
@@ -772,10 +1160,14 @@ class PlaywrightPlugin {
772
1160
  return 1;
773
1161
  }
774
1162
 
1163
+ const extensionPaths = action === 'up'
1164
+ ? this.resolveExtensionPaths(extensions)
1165
+ : [];
1166
+
775
1167
  let rc = 0;
776
1168
  for (const sceneName of targets) {
777
1169
  // eslint-disable-next-line no-await-in-loop
778
- const code = await this.runOnScene(action, sceneName);
1170
+ const code = await this.runOnScene(action, sceneName, { extensionPaths });
779
1171
  if (code !== 0) {
780
1172
  rc = 1;
781
1173
  }
@@ -786,6 +1178,7 @@ class PlaywrightPlugin {
786
1178
  }
787
1179
 
788
1180
  module.exports = {
1181
+ EXTENSIONS,
789
1182
  SCENE_ORDER,
790
1183
  SCENE_DEFS,
791
1184
  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.3",
4
4
  "imageVersion": "1.8.1-common",
5
5
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",
6
6
  "keywords": [