@xcanwin/manyoyo 8.3.3 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +1 -1
  2. package/bin/manyoyo.js +111 -48
  3. package/docker/manyoyo.Dockerfile +41 -8
  4. package/docker/res/agent/update-agents.sh +1 -1
  5. package/docker/res/playwright/browser.json +9 -12
  6. package/docker/res/playwright/chrome-policy.json +3 -0
  7. package/docker/res/playwright/env +3 -0
  8. package/docker/res/playwright/playwright-cli.sh +5 -0
  9. package/docker/res/playwright/stealth.init.js +11 -9
  10. package/lib/browser-background.js +23 -0
  11. package/lib/container-env.sh +34 -0
  12. package/lib/container-exec.js +132 -0
  13. package/lib/container-id.js +11 -0
  14. package/lib/container-init.sh +40 -0
  15. package/lib/container-manage-options.js +35 -0
  16. package/lib/container-network.js +615 -0
  17. package/lib/container-run.js +27 -3
  18. package/lib/container-state.js +467 -0
  19. package/lib/egress-denied.js +128 -0
  20. package/lib/egress-proxy.js +315 -0
  21. package/lib/egress-sidecar-main.js +112 -0
  22. package/lib/egress-sidecar.js +276 -0
  23. package/lib/env-text.js +62 -0
  24. package/lib/json5-text-edit.js +5 -5
  25. package/lib/network-endpoints.js +99 -0
  26. package/lib/network-policy.js +269 -0
  27. package/lib/plugin/fingerprint.js +65 -50
  28. package/lib/plugin/playwright-extensions.js +0 -9
  29. package/lib/plugin/playwright-server.js +22 -2
  30. package/lib/plugin/playwright.js +55 -16
  31. package/lib/port-forward.js +137 -0
  32. package/lib/web/AGENTS.md +4 -0
  33. package/lib/web/container-manage.js +506 -0
  34. package/lib/web/denied-notify.js +52 -0
  35. package/lib/web/index.html +81 -77
  36. package/lib/web/server.js +352 -135
  37. package/manyoyo.example.json +20 -2
  38. package/package.json +13 -8
package/README.md CHANGED
@@ -53,7 +53,7 @@ manyoyo init all # 把本机已有的 Agent 配置搬进来
53
53
  manyoyo run -y c # 在沙箱里以 YOLO 模式启动 Claude Code
54
54
  ```
55
55
 
56
- 默认镜像 `ghcr.io/xcanwin/manyoyo` 首次使用时自动拉取;需要自定义镜像时再执行 `manyoyo build --iv 2.1.1-common`。更多命令见[命令速查](https://xcanwin.github.io/manyoyo/reference/cli-options)。
56
+ 默认镜像 `ghcr.io/xcanwin/manyoyo` 首次使用时自动拉取;需要自定义镜像时再执行 `manyoyo build --iv 2.2.0-common`。更多命令见[命令速查](https://xcanwin.github.io/manyoyo/reference/cli-options)。
57
57
 
58
58
  ## 它能做什么
59
59
 
package/bin/manyoyo.js CHANGED
@@ -9,7 +9,8 @@ const net = require('net');
9
9
  const readline = require('readline');
10
10
  const { Command, Help } = require('commander');
11
11
  const { startWebServer } = require('../lib/web/server');
12
- const { buildContainerRunArgs, buildContainerRunCommand, runWithEnvFile } = require('../lib/container-run');
12
+ const { isSidecarName } = require('../lib/egress-sidecar');
13
+ const { buildContainerRunArgs, buildContainerRunCommand, runWithEnvFile, hasNetworkArg } = require('../lib/container-run');
13
14
  const { getManyoyoConfigPath, readManyoyoConfig, syncGlobalImageVersion } = require('../lib/global-config');
14
15
  const { resolveUpdateImageVersion } = require('../lib/image-version-policy');
15
16
  const { selectContainerRuntime, mergeRuntimeEnv } = require('../lib/container-runtime');
@@ -44,6 +45,12 @@ const {
44
45
  normalizeMirrors
45
46
  } = require('../lib/runtime-normalizers');
46
47
  const { applyAptMirror } = require('../lib/mirrors');
48
+ const containerState = require('../lib/container-state');
49
+ const { buildExecArgs, resolveContainerId } = require('../lib/container-exec');
50
+ const { createNetworkManager, NETWORK_NAME } = require('../lib/container-network');
51
+ const { normalizePolicy } = require('../lib/network-policy');
52
+ const { withEnvEndpoints } = require('../lib/network-endpoints');
53
+ const { resolveManageOptions } = require('../lib/container-manage-options');
47
54
  const {
48
55
  sanitizeSensitiveData,
49
56
  sanitizeServeLogText,
@@ -131,6 +138,8 @@ let CONTAINER_RUNTIME = null;
131
138
  let UPDATE_CHECK_ENABLED = true;
132
139
  // 全局配置 mirrors(apt/npm/pip 软件源,空 = 官方默认):容器创建时在容器层生效,镜像不变
133
140
  let MIRRORS = { apt: '', npm: '', pip: '' };
141
+ let MANAGE_OPTIONS = { autostart: '', network: null };
142
+ let ENV_FILES = [];
134
143
  // serve 的容器环境状态,供 GET /api/system/runtime 读取
135
144
  const RUNTIME_STATE = { status: 'ready', message: '' };
136
145
  const DOCKER_DAEMON_ERROR_CODES = new Set(['PODMAN_MACHINE_UNAVAILABLE', 'DOCKER_DAEMON_UNAVAILABLE', 'PORT_IN_USE']);
@@ -342,6 +351,9 @@ function installServeProcessDiagnostics(logger) {
342
351
  * @property {string} [yolo] - YOLO 模式
343
352
  * @property {string} [containerMode] - 容器模式
344
353
  * @property {string} [containerRuntime] - 容器运行时(auto/docker/podman,默认 auto;仅全局配置生效)
354
+ * @property {string} [autostart] - 容器每次启动时由容器内 init 执行的 bash 脚本(新建容器时写入状态目录)
355
+ * @property {boolean} [autostartOnServe] - serve 启动时自动拉起此容器
356
+ * @property {object} [network] - 网络策略(preset / host / egress / peers,见 lib/network-policy.js),不写为默认收紧
345
357
  * @property {{apt?: string, npm?: string, pip?: string}} [mirrors] - 容器内 apt/npm/pip 软件源(http/https URL,空/缺省为官方默认;仅全局配置生效)
346
358
  * @property {boolean} [updateCheck] - serve 是否每天检查一次新版本(默认 true;仅全局配置生效,请求不附带任何本机信息)
347
359
  * @property {number} [cacheTTL] - 缓存过期天数
@@ -567,43 +579,16 @@ function addEnvFileTo(targetEnvs, envFile) {
567
579
  }
568
580
 
569
581
  if (fs.existsSync(filePath)) {
570
- const content = fs.readFileSync(filePath, 'utf-8');
571
- const lines = content.split('\n');
572
-
573
- for (let line of lines) {
574
- // Match pattern: (export )?(KEY)=(VALUE)
575
- const match = line.match(/^(?:export\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.*)$/);
576
- if (match) {
577
- let key = match[1];
578
- let value = match[2].trim();
579
-
580
- // Filter malicious characters
581
- if (/[\r\n\0]/.test(value)) continue;
582
- if (/[\$\(\)\`\|\&\*\{\};<>]/.test(value)) continue;
583
- if (/^\(/.test(value)) continue;
584
-
585
- // Remove quotes
586
- if (/^"(.*)"$/.test(value)) {
587
- value = value.slice(1, -1);
588
- } else if (/^'(.*)'$/.test(value)) {
589
- value = value.slice(1, -1);
590
- }
591
-
592
- if (key) {
593
- targetEnvs.push("--env", `${key}=${value}`);
594
- }
595
- }
596
- }
582
+ // 与「环境变量」文本同一套语法(见 lib/env-text.js);值里有被禁止的字符的行会被跳过并提示
583
+ const { entries, invalid } = containerState.parseEnvFileContent(fs.readFileSync(filePath, 'utf-8'));
584
+ entries.forEach(entry => targetEnvs.push('--env', `${entry.key}=${entry.value}`));
585
+ invalid.forEach(item => console.error(`${YELLOW}⚠️ ${filePath} 第 ${item.line} 行已跳过: ${item.reason}${NC}`));
597
586
  return {};
598
587
  }
599
588
  console.error(`${RED}⚠️ 未找到环境文件: ${envFile}${NC}`);
600
589
  return {};
601
590
  }
602
591
 
603
- function addEnvFile(envFile) {
604
- return addEnvFileTo(CONTAINER_ENVS, envFile);
605
- }
606
-
607
592
  // Playwright 浏览器模式的容器参数;在容器创建时(运行时已确定)才计算,失败只警告,不影响 run
608
593
  async function applyPlaywrightIntegration(runtime) {
609
594
  const integration = await buildContainerIntegration({
@@ -768,7 +753,10 @@ function getContainerStatus(name) {
768
753
 
769
754
  function removeContainer(name) {
770
755
  if ( !(QUIET.crm || QUIET.full) ) console.log(`${YELLOW}🗑️ 正在删除容器: ${name}...${NC}`);
756
+ // 先读 id:容器删掉后就查不到了;同时删状态目录,同名重建不会继承旧 env / 规则
757
+ const stateId = resolveContainerId(dockerExecArgs, name);
771
758
  dockerExecArgs(['rm', '-f', name], { stdio: 'pipe' });
759
+ if (stateId) containerState.removeState(os.homedir(), stateId);
772
760
  if ( !(QUIET.crm || QUIET.full) ) console.log(`${GREEN}✅ 已彻底删除。${NC}`);
773
761
  }
774
762
 
@@ -1007,6 +995,7 @@ function getContList() {
1007
995
  const image = cols[4] || '';
1008
996
  // include manyoyo runtime containers (image match)
1009
997
  // and plugin containers (both legacy manyoyo-* and new my-* prefixes)
998
+ if (isSidecarName(name)) return false; // 域名白名单的过滤代理容器不是用户的会话容器
1010
999
  return image.includes('manyoyo') || name.startsWith('manyoyo-') || name.startsWith('my-');
1011
1000
  });
1012
1001
 
@@ -1503,6 +1492,9 @@ https://github.com/xcanwin/manyoyo
1503
1492
  const runConfig = runNameToLoad ? loadRunConfig(runNameToLoad, config) : {};
1504
1493
  const globalFirstConfig = normalizeFirstConfig(config.first, '全局配置');
1505
1494
  const runFirstConfig = normalizeFirstConfig(runConfig.first, '运行配置');
1495
+ // 自启动脚本与网络策略:runs.<name> > 全局配置(新建容器时写进状态目录,Web 上可随时改)
1496
+ const manageOptions = resolveManageOptions({ runConfig, globalConfig: config });
1497
+ MANAGE_OPTIONS = manageOptions;
1506
1498
 
1507
1499
  const resolvedRuntime = resolveRuntimeConfig({
1508
1500
  cliOptions: options,
@@ -1551,7 +1543,17 @@ https://github.com/xcanwin/manyoyo
1551
1543
 
1552
1544
  // Merge mode (array values): concatenate all sources
1553
1545
  const envFileList = resolvedRuntime.envFile;
1554
- envFileList.forEach(ef => addEnvFile(ef));
1546
+ // 环境变量文件不再在创建时展开成 --env:登记路径,每次 exec 现读(改了文件下一条命令就生效)
1547
+ ENV_FILES = [];
1548
+ envFileList.forEach(ef => {
1549
+ const filePath = String(ef || '').trim();
1550
+ if (!path.isAbsolute(filePath)) {
1551
+ console.error(`${RED}⚠️ 错误: --env-file 仅支持绝对路径: ${ef}${NC}`);
1552
+ process.exit(1);
1553
+ }
1554
+ if (!fs.existsSync(filePath)) console.error(`${YELLOW}⚠️ 未找到环境文件(创建后放进去也会生效): ${ef}${NC}`);
1555
+ ENV_FILES.push(filePath);
1556
+ });
1555
1557
 
1556
1558
  const envMap = resolvedRuntime.env;
1557
1559
  Object.entries(envMap).forEach(([key, value]) => addEnv(`${key}=${value}`));
@@ -1612,6 +1614,8 @@ https://github.com/xcanwin/manyoyo
1612
1614
  containerMode: contModeValue || "",
1613
1615
  containerRuntime: config.containerRuntime || "auto",
1614
1616
  mirrors: MIRRORS,
1617
+ autostart: manageOptions.autostart,
1618
+ network: manageOptions.network,
1615
1619
  shellPrefix: EXEC_COMMAND_PREFIX.trim(),
1616
1620
  shell: EXEC_COMMAND || "",
1617
1621
  shellSuffix: EXEC_COMMAND_SUFFIX || "",
@@ -1753,6 +1757,9 @@ function createRuntimeContext(modeState = {}) {
1753
1757
  containerExtraArgs: CONTAINER_EXTRA_ARGS,
1754
1758
  containerEnvs: CONTAINER_ENVS,
1755
1759
  mirrors: MIRRORS,
1760
+ autostart: MANAGE_OPTIONS.autostart,
1761
+ network: MANAGE_OPTIONS.network,
1762
+ envFiles: ENV_FILES,
1756
1763
  firstContainerEnvs: FIRST_CONTAINER_ENVS,
1757
1764
  containerVolumes: CONTAINER_VOLUMES,
1758
1765
  containerPorts: CONTAINER_PORTS,
@@ -2047,15 +2054,16 @@ function executeFirstCommand(runtime) {
2047
2054
  console.log(`⚙️ 首次预执行命令: ${YELLOW}${firstCommand}${NC}`);
2048
2055
  }
2049
2056
 
2050
- const firstExecArgs = [
2051
- 'exec',
2052
- ...(runtime.firstContainerEnvs || []),
2053
- runtime.containerName,
2054
- '/bin/bash',
2055
- '-c',
2056
- firstCommand
2057
- ];
2058
- const firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExecArgs, { stdio: 'inherit', env: DOCKER_ENV });
2057
+ const firstExec = buildExecArgs({ homeDir: os.homedir(), dockerExecArgs }, runtime.containerName, {
2058
+ extraEnv: containerState.envArgsToLines(runtime.firstContainerEnvs),
2059
+ command: ['/bin/bash', '-c', firstCommand]
2060
+ });
2061
+ let firstExecResult;
2062
+ try {
2063
+ firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExec.args, { stdio: 'inherit', env: DOCKER_ENV });
2064
+ } finally {
2065
+ firstExec.cleanup();
2066
+ }
2059
2067
  if (firstExecResult.error) {
2060
2068
  throw firstExecResult.error;
2061
2069
  }
@@ -2083,15 +2091,38 @@ async function createNewContainer(runtime) {
2083
2091
  );
2084
2092
  const defaultCommand = runtime.execCommand;
2085
2093
 
2094
+ // 用户 env 不进容器配置(podman inspect 看不到明文),改写进状态目录的 box/env,每次 exec 现读
2095
+ const userEnvLines = containerState.userEnvLines(runtime.containerEnvs);
2086
2096
  await applyPlaywrightIntegration(runtime);
2097
+ runtime.containerEnvs = containerState.stripEnvKeys(runtime.containerEnvs, new Set(userEnvLines.map(containerState.envLineKey)));
2087
2098
 
2088
2099
  if (runtime.showCommand) {
2100
+ runtime.state = previewContainerState();
2089
2101
  console.log(buildDockerRunCmd(runtime));
2090
2102
  process.exit(0);
2091
2103
  }
2092
2104
 
2093
2105
  await ensureRunImage(runtime);
2094
2106
 
2107
+ const networkPolicy = await withEnvEndpoints(normalizePolicy(runtime.network), [...userEnvLines, ...containerState.readEnvFilePaths(runtime.envFiles || []).flatMap(file => file.entries.map(entry => `${entry.key}=${entry.value}`))]);
2108
+ const networkManager = createCliNetworkManager(runtime);
2109
+ if (!hasNetworkArg([...(runtime.contModeArgs || []), ...(runtime.containerExtraArgs || [])])) {
2110
+ try {
2111
+ await networkManager.ensureBridgeNetwork();
2112
+ } catch (e) {
2113
+ throw new Error(`无法创建 ${NETWORK_NAME} 网络: ${e.message}`);
2114
+ }
2115
+ }
2116
+ runtime.state = containerState.createState({
2117
+ homeDir: os.homedir(),
2118
+ envLines: userEnvLines,
2119
+ envFiles: runtime.envFiles,
2120
+ autostart: runtime.autostart,
2121
+ network: networkPolicy,
2122
+ netRequired: networkPolicy.preset !== 'open',
2123
+ meta: { name: runtime.containerName }
2124
+ });
2125
+
2095
2126
  // 使用数组参数执行命令(安全方式)
2096
2127
  try {
2097
2128
  const args = buildDockerRunArgs(runtime);
@@ -2100,12 +2131,16 @@ async function createNewContainer(runtime) {
2100
2131
  showImagePullHint(e);
2101
2132
  // 失败的 run 可能留下 Created 状态的容器;这个名字创建前不存在,所以只清理本次留下的这一个
2102
2133
  try { dockerExecArgs(['rm', '-f', runtime.containerName], { stdio: 'pipe' }); } catch (cleanupError) { /* 尽力清理 */ }
2134
+ containerState.removeState(os.homedir(), runtime.state.id);
2103
2135
  throw explainCreateFailure(e);
2104
2136
  }
2105
2137
 
2106
2138
  // Wait for container to be ready
2107
2139
  await waitForContainerReady(runtime.containerName);
2108
2140
 
2141
+ // 先下发网络规则再做任何 exec;失败即关闭(容器留着,init 在等门闩,不会跑自启动)
2142
+ await networkManager.apply(runtime.containerName, { expectId: runtime.state.id });
2143
+
2109
2144
  applyAptMirror({
2110
2145
  dockerExecArgs,
2111
2146
  containerName: runtime.containerName,
@@ -2123,8 +2158,26 @@ async function createNewContainer(runtime) {
2123
2158
  * 构建 Docker run 命令参数数组(安全方式,避免命令注入)
2124
2159
  * @returns {string[]} 命令参数数组
2125
2160
  */
2161
+ // 容器网络规则的下发器:CLI 与 serve 各建一份,helper 容器用当前 imageVersion 的官方镜像
2162
+ function createCliNetworkManager(runtime) {
2163
+ return createNetworkManager({
2164
+ command: DOCKER_CMD,
2165
+ env: DOCKER_ENV,
2166
+ homeDir: os.homedir(),
2167
+ imageRef: () => `${runtime.imageName}:${runtime.imageVersion}`,
2168
+ getMirrorUrls: () => Object.values(runtime.mirrors || {}).filter(Boolean)
2169
+ });
2170
+ }
2171
+
2172
+ function previewContainerState() {
2173
+ const dir = path.join(os.homedir(), '.manyoyo', 'containers', '<id>');
2174
+ return { id: '<id>', box: path.join(dir, 'box'), sys: path.join(dir, 'sys') };
2175
+ }
2176
+
2126
2177
  function buildDockerRunArgs(runtime) {
2127
2178
  return buildContainerRunArgs({
2179
+ state: runtime.state,
2180
+ defaultNetwork: NETWORK_NAME,
2128
2181
  containerName: runtime.containerName,
2129
2182
  hostPath: runtime.hostPath,
2130
2183
  containerPath: runtime.containerPath,
@@ -2159,6 +2212,7 @@ async function connectExistingContainer(runtime) {
2159
2212
  if (status !== 'running') {
2160
2213
  dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
2161
2214
  }
2215
+ await createCliNetworkManager(runtime).ensureReady(runtime.containerName);
2162
2216
 
2163
2217
  // Get default command from label
2164
2218
  const defaultCommand = dockerExecArgs(['inspect', '-f', '{{index .Config.Labels "manyoyo.default_cmd"}}', runtime.containerName]).trim();
@@ -2183,6 +2237,8 @@ async function setupContainer(runtime) {
2183
2237
  process.exit(0);
2184
2238
  }
2185
2239
  runtime.execCommand = joinExecCommand(runtime.execCommandPrefix, runtime.execCommand, runtime.execCommandSuffix);
2240
+ runtime.containerEnvs = containerState.stripEnvKeys(runtime.containerEnvs, new Set(containerState.userEnvLines(runtime.containerEnvs).map(containerState.envLineKey)));
2241
+ runtime.state = previewContainerState();
2186
2242
  console.log(buildDockerRunCmd(runtime));
2187
2243
  process.exit(0);
2188
2244
  }
@@ -2193,7 +2249,7 @@ async function setupContainer(runtime) {
2193
2249
  }
2194
2250
  }
2195
2251
 
2196
- function executeInContainer(runtime, defaultCommand) {
2252
+ async function executeInContainer(runtime, defaultCommand) {
2197
2253
  if (!containerExists(runtime.containerName)) {
2198
2254
  throw new Error(`未找到容器: ${runtime.containerName}`);
2199
2255
  }
@@ -2202,6 +2258,7 @@ function executeInContainer(runtime, defaultCommand) {
2202
2258
  if (status !== 'running') {
2203
2259
  dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
2204
2260
  }
2261
+ await createCliNetworkManager(runtime).ensureReady(runtime.containerName);
2205
2262
 
2206
2263
  getHelloTip(runtime.containerName, defaultCommand, runtime.execCommand);
2207
2264
  if (!(runtime.quiet.cmd || runtime.quiet.full)) {
@@ -2210,10 +2267,15 @@ function executeInContainer(runtime, defaultCommand) {
2210
2267
  }
2211
2268
 
2212
2269
  // Execute command in container
2213
- if (runtime.execCommand) {
2214
- spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash', '-c', runtime.execCommand], { stdio: 'inherit', env: DOCKER_ENV });
2215
- } else {
2216
- spawnSync(`${DOCKER_CMD}`, ['exec', '-it', runtime.containerName, '/bin/bash'], { stdio: 'inherit', env: DOCKER_ENV });
2270
+ const exec = buildExecArgs({ homeDir: os.homedir(), dockerExecArgs }, runtime.containerName, {
2271
+ interactive: true,
2272
+ tty: true,
2273
+ command: runtime.execCommand ? ['/bin/bash', '-c', runtime.execCommand] : ['/bin/bash']
2274
+ });
2275
+ try {
2276
+ spawnSync(`${DOCKER_CMD}`, exec.args, { stdio: 'inherit', env: DOCKER_ENV });
2277
+ } finally {
2278
+ exec.cleanup();
2217
2279
  }
2218
2280
  }
2219
2281
 
@@ -2458,6 +2520,7 @@ async function runWebServerMode(runtime) {
2458
2520
  contModeArgs: runtime.contModeArgs,
2459
2521
  containerExtraArgs: runtime.containerExtraArgs,
2460
2522
  containerEnvs: runtime.containerEnvs,
2523
+ envFiles: runtime.envFiles,
2461
2524
  containerVolumes: runtime.containerVolumes,
2462
2525
  containerPorts: runtime.containerPorts,
2463
2526
  validateHostPath: value => validateHostPathOrThrow(value),
@@ -2574,7 +2637,7 @@ async function main() {
2574
2637
  // 7-8. Execute command and handle post-exit interactions
2575
2638
  let shouldContinue = true;
2576
2639
  while (shouldContinue) {
2577
- executeInContainer(runtime, defaultCommand);
2640
+ await executeInContainer(runtime, defaultCommand);
2578
2641
  shouldContinue = await handlePostExit(runtime, defaultCommand);
2579
2642
  }
2580
2643
 
@@ -114,7 +114,7 @@ RUN <<EOX
114
114
  # Python
115
115
  apt-get -o Acquire::https::Verify-Peer=false update -y
116
116
  apt-get -o Acquire::https::Verify-Peer=false install -y --no-install-recommends \
117
- ca-certificates openssl curl wget net-tools iputils-ping dnsutils socat ncat ssh \
117
+ ca-certificates openssl curl wget net-tools iputils-ping dnsutils socat ncat ssh nftables \
118
118
  git gh g++ make sqlite3 \
119
119
  procps psmisc lsof supervisor \
120
120
  nano jq file tree ripgrep less bc xxd tar zip unzip gzip \
@@ -230,18 +230,47 @@ RUN <<EOX
230
230
  cp /tmp/docker-res/opencode/opencode.json ~/.config/opencode/opencode.json
231
231
  ;; esac
232
232
 
233
- # 安装 Playwright CLI skills 与 chromium 浏览器
233
+ # 安装 Playwright CLI skills、patchright-core 与 Google Chrome
234
234
  PLAYWRIGHT_CLI_INSTALL_DIR=/tmp/playwright-cli-install
235
235
  mkdir -p "$PLAYWRIGHT_CLI_INSTALL_DIR/.playwright"
236
236
  cd "$PLAYWRIGHT_CLI_INSTALL_DIR"
237
- PLAYWRIGHT_CLI_VERSION=$(node -p "const pkg = require('/tmp/manyoyo-package.json'); const value = String(pkg.playwrightCliVersion || '').trim(); if (!value) { throw new Error('package.json.playwrightCliVersion is required'); } value")
237
+ pkg_field() { node -p "const pkg = require('/tmp/manyoyo-package.json'); const value = String(pkg.$1 || '').trim(); if (!value) { throw new Error('package.json.$1 is required'); } value"; }
238
+ PLAYWRIGHT_CLI_VERSION=$(pkg_field playwrightCliVersion)
239
+ PATCHRIGHT_CORE_VERSION=$(pkg_field patchrightCoreVersion)
240
+ PATCHRIGHT_CORE_INTEGRITY=$(pkg_field patchrightCoreIntegrity)
238
241
  npm install -g "@playwright/cli@${PLAYWRIGHT_CLI_VERSION}"
239
- # 浏览器与系统依赖都用 @playwright/cli 自带的 playwright-core 安装;
242
+ # 把 @playwright/cli 自带的 playwright-core 换成 patchright-core(开源补丁版,可从源码逐字节复现):
243
+ # 官方版会在页面与 Worker 里启用 CDP Runtime 域,被检测站点识别;patchright 去掉了这一痕迹。
244
+ # 两者必须同 minor(容器内 client 与宿主机 launchServer 要求 minor 完全一致),并以 package.json 里的 integrity 校验,不一致就中止构建
245
+ CLI_DIR="$(npm root -g)/@playwright/cli"
246
+ CLI_CORE_VERSION=$(node -p "require('$CLI_DIR/node_modules/playwright-core/package.json').version")
247
+ [ "${CLI_CORE_VERSION%.*}" = "${PATCHRIGHT_CORE_VERSION%.*}" ] || { echo "playwright-core ${CLI_CORE_VERSION} 与 patchright-core ${PATCHRIGHT_CORE_VERSION} minor 不一致" >&2; exit 1; }
248
+ npm pack "patchright-core@${PATCHRIGHT_CORE_VERSION}" --silent >/dev/null
249
+ PATCHRIGHT_TARBALL="patchright-core-${PATCHRIGHT_CORE_VERSION}.tgz"
250
+ [ "sha512-$(openssl dgst -sha512 -binary "$PATCHRIGHT_TARBALL" | openssl base64 -A)" = "$PATCHRIGHT_CORE_INTEGRITY" ] || { echo "patchright-core 完整性校验失败" >&2; exit 1; }
251
+ for core in $(find "$CLI_DIR" -type d -name playwright-core -path '*/node_modules/playwright-core'); do
252
+ rm -rf "$core"
253
+ mkdir -p "$core"
254
+ tar -xzf "$PATCHRIGHT_TARBALL" -C "$core" --strip-components=1
255
+ # patchright 的客户端默认在隔离世界执行 evaluate(页面读不到 Agent 的变量,Agent 也读不到页面自己定义的全局变量),
256
+ # 对 Agent 毫无用处;把客户端 evaluate / $eval 等 12 处默认值改回主世界(与官方 Playwright 行为一致)。
257
+ # 在 integrity 校验之后做,次数对不上就中止(升级 patchright 时必须人工复核);实测不影响 dabi / rebrowser 等检测
258
+ ISOLATED_DEFAULTS=12
259
+ [ "$(grep -o 'isolatedContext = true' "$core/lib/coreBundle.js" | wc -l)" = "$ISOLATED_DEFAULTS" ] || { echo "patchright-core 里 isolatedContext 默认值数量不是 ${ISOLATED_DEFAULTS},请人工复核" >&2; exit 1; }
260
+ sed -i 's/isolatedContext = true/isolatedContext = false/g' "$core/lib/coreBundle.js"
261
+ done
240
262
  # 默认在 Xvfb 虚拟屏(配轻量窗口管理器)里跑有头浏览器,所以装 xvfb、fluxbox 与常见字体;x11vnc / noVNC 供 playwright vnc 模式使用
241
- PLAYWRIGHT_CORE_CLI="$(npm root -g)/@playwright/cli/node_modules/playwright-core/cli.js"
242
- node "$PLAYWRIGHT_CORE_CLI" install-deps chromium
243
- apt-get install -y --no-install-recommends xvfb fluxbox x11vnc novnc websockify fonts-noto-cjk fonts-noto-color-emoji fonts-liberation
244
- node "$PLAYWRIGHT_CORE_CLI" install --no-shell chromium
263
+ apt-get update
264
+ apt-get install -y --no-install-recommends xvfb fluxbox x11vnc novnc websockify fonts-noto-cjk fonts-noto-color-emoji fonts-liberation gpg
265
+ # Google Chrome stable:官方 apt 源(amd64 / arm64 都有),固定签名密钥指纹,apt 再校验 Release 签名
266
+ curl -fsSL https://dl.google.com/linux/linux_signing_key.pub -o /tmp/google-linux-signing-key.pub
267
+ [ "$(gpg --show-keys --with-colons /tmp/google-linux-signing-key.pub | awk -F: '/^fpr/{print $10; exit}')" = "EB4C1BFD4F042F6DDDCCEC917721F63BD38B4796" ] || { echo "Google 签名密钥指纹不符" >&2; exit 1; }
268
+ gpg --dearmor < /tmp/google-linux-signing-key.pub > /usr/share/keyrings/google-chrome.gpg
269
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list
270
+ apt-get update
271
+ apt-get install -y --no-install-recommends google-chrome-stable
272
+ rm -f /etc/apt/sources.list.d/google-chrome.list /etc/cron.daily/google-chrome
273
+ google-chrome --version
245
274
  playwright-cli install --skills
246
275
  PLAYWRIGHT_CLI_SKILL_SOURCE="$PLAYWRIGHT_CLI_INSTALL_DIR/.claude/skills/playwright-cli"
247
276
  for target in ~/.claude/skills/playwright-cli ~/.codex/skills/playwright-cli ~/.gemini/skills/playwright-cli; do
@@ -261,8 +290,12 @@ EOX
261
290
  # Playwright 默认配置(挂载宿主机目录时会被覆盖,路径固定)与按需启动 Xvfb 的 playwright-cli 包装
262
291
  COPY ./docker/res/playwright/browser.json /run/manyoyo-playwright/config.json
263
292
  COPY ./docker/res/playwright/stealth.init.js /run/manyoyo-playwright/stealth.init.js
293
+ COPY ./docker/res/playwright/env /run/manyoyo-playwright/env
264
294
  COPY --chmod=755 ./docker/res/playwright/playwright-cli.sh /usr/local/sbin/playwright-cli
265
295
  RUN ln -s playwright-cli /usr/local/sbin/playwright-mcp
296
+ # Chrome 154 起,不安全页面(http://host.containers.internal 这类宿主机别名)不能向 local 地址空间发请求(含同源 fetch),
297
+ # 会让 Agent 测试宿主机上的本地网页时 fetch 失败(旧的 Chromium 没有这个限制);这条企业策略关闭该检查,只在容器内的 Chrome 生效
298
+ COPY ./docker/res/playwright/chrome-policy.json /etc/opt/chrome/policies/managed/local-network.json
266
299
 
267
300
  # 从 cache-stage 复制 JDT LSP 到最终位置,避免中转层残留
268
301
  COPY --from=cache-stage /opt/jdtls /root/.local/share/jdtls
@@ -32,7 +32,7 @@ echo "[manyoyo] Agent CLI versions before update:"
32
32
  print_agent_versions
33
33
 
34
34
  if [ "${#update_packages[@]}" -gt 0 ]; then
35
- npm_config_update_notifier=false npm install -g npm@latest "${update_packages[@]}" @playwright/cli@latest
35
+ npm_config_update_notifier=false npm install -g npm@latest "${update_packages[@]}"
36
36
  npm config set allow-scripts=@anthropic-ai/claude-code,@openai/codex,@google/gemini-cli,opencode-ai,@playwright/cli,pyright,typescript-language-server,typescript --location=user
37
37
  else
38
38
  echo "[manyoyo] No existing Agent CLI found; skip npm install."
@@ -1,27 +1,24 @@
1
1
  {
2
2
  "outputDir": "/tmp/.playwright-cli",
3
3
  "browser": {
4
- "chromiumSandbox": false,
5
4
  "browserName": "chromium",
5
+ "contextOptions": {
6
+ "viewport": null
7
+ },
6
8
  "initScript": [
7
9
  "/run/manyoyo-playwright/stealth.init.js"
8
10
  ],
9
- "contextOptions": {
10
- "locale": "en-US",
11
- "timezoneId": "UTC",
12
- "viewport": null,
13
- "extraHTTPHeaders": {
14
- "Accept-Language": "en-US,en;q=0.9"
15
- }
16
- },
17
11
  "launchOptions": {
18
- "channel": "chromium",
12
+ "channel": "chrome",
13
+ "chromiumSandbox": false,
19
14
  "headless": false,
20
15
  "args": [
21
16
  "--lang=en-US",
22
- "--window-size=1920,1080",
17
+ "--window-size=1920,1058",
18
+ "--window-position=0,0",
23
19
  "--disable-blink-features=AutomationControlled",
24
- "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"
20
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
21
+ "--disable-webrtc"
25
22
  ]
26
23
  }
27
24
  }
@@ -0,0 +1,3 @@
1
+ {
2
+ "LocalNetworkAccessRestrictionsTemporaryOptOut": true
3
+ }
@@ -0,0 +1,3 @@
1
+ export TZ='UTC'
2
+ export LANG='en_US.UTF-8'
3
+ export LANGUAGE='en_US:en'
@@ -3,6 +3,11 @@
3
3
  # 以 playwright-cli 名字调用时交给 @playwright/cli;以 playwright-mcp 名字调用时启动 stdio MCP(读同一份 $PLAYWRIGHT_MCP_CONFIG)。
4
4
  export DISPLAY=:99
5
5
  CONFIG="${PLAYWRIGHT_MCP_CONFIG:-/run/manyoyo-playwright/config.json}"
6
+ # 语言与时区用进程环境变量(TZ / LANG / LANGUAGE)原生提供,页面与 Worker 才一致;由宿主机生成,与 config.json 同目录
7
+ ENV_FILE="$(dirname "$CONFIG")/env"
8
+ if [ -r "$ENV_FILE" ]; then
9
+ . "$ENV_FILE"
10
+ fi
6
11
  # 浏览器在容器外(headed / chrome / vnc)时不需要虚拟屏
7
12
  if ! grep -qE '"(remoteEndpoint|cdpEndpoint)"' "$CONFIG" 2>/dev/null; then
8
13
  # 以锁文件里的进程是否活着为准;容器被 kill 后遗留的 socket 与锁文件要清掉
@@ -1,14 +1,16 @@
1
1
  'use strict';
2
2
  (function () {
3
3
  try {
4
- const toolbar = 87;
5
- const patch = (name, fallback, getter) => {
6
- const original = Object.getOwnPropertyDescriptor(window, name);
7
- if (!original || !original.get) return;
8
- const real = original.get;
9
- Object.defineProperty(window, name, { ...original, get: getter(function () { const value = real.call(this); return value === 0 ? fallback() : value; }) });
10
- };
11
- patch('outerWidth', () => window.innerWidth, fn => Object.getOwnPropertyDescriptor({ get outerWidth() { return fn.call(this); } }, 'outerWidth').get);
12
- patch('outerHeight', () => window.innerHeight + toolbar, fn => Object.getOwnPropertyDescriptor({ get outerHeight() { return fn.call(this); } }, 'outerHeight').get);
4
+ const blocked = ['RTCPeerConnection', 'webkitRTCPeerConnection', 'RTCIceCandidate', 'RTCRtpSender', 'RTCRtpReceiver', 'RTCRtpTransceiver', 'RTCDataChannel'];
5
+ for (const name of blocked) {
6
+ Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: undefined });
7
+ }
8
+ if (navigator.mediaDevices) {
9
+ Object.defineProperty(navigator.mediaDevices, 'getUserMedia', {
10
+ configurable: true,
11
+ writable: true,
12
+ value: async () => { throw new DOMException('WebRTC is disabled', 'NotAllowedError'); }
13
+ });
14
+ }
13
15
  } catch (_) {}
14
16
  })();
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ const { domainAllowed } = require('./network-policy');
4
+
5
+ // 容器里的 Chrome 启动后会自己连一些谷歌的后台服务(更新、账号同步、字体 / 资源)。白名单下它们必然被拦,
6
+ // 对用户没有意义:网络分区的“最近被拦截”把它们折叠成“浏览器后台请求”,对话里也不为它们提示。
7
+ // 数据只写在这一处。
8
+ const BROWSER_BACKGROUND_DOMAINS = [
9
+ 'accounts.google.com',
10
+ 'update.googleapis.com',
11
+ 'www.google.com',
12
+ 'www.gstatic.com',
13
+ 'dl.google.com',
14
+ 'clients2.google.com',
15
+ 'android.clients.google.com',
16
+ 'content-autofill.googleapis.com'
17
+ ];
18
+
19
+ function isBrowserBackground(host) {
20
+ return domainAllowed(BROWSER_BACKGROUND_DOMAINS, host);
21
+ }
22
+
23
+ module.exports = { BROWSER_BACKGROUND_DOMAINS, isBrowserBackground };
@@ -0,0 +1,34 @@
1
+ # manyoyo 容器内的 env 加载:init.sh 启动自启动前加载,终端里的 `reload-env` 也用它。
2
+ # 由宿主机以只读目录挂到 /run/manyoyo-sys/env.sh,容器内不可改。
3
+ # 与宿主机侧(lib/env-text.js)同一语法:一行 KEY=VALUE,# 注释与空行忽略,可选 `export ` 前缀与 `=` 两侧空白,
4
+ # 值两端成对的引号去掉;不展开变量、不执行命令;key 必须合法
5
+ load_env() {
6
+ local file=$1 line key val
7
+ [ -f "$file" ] || return 0
8
+ while IFS= read -r line || [ -n "$line" ]; do
9
+ line=${line%$'\r'}
10
+ line="${line#"${line%%[![:space:]]*}"}"
11
+ line="${line%"${line##*[![:space:]]}"}"
12
+ case $line in ''|'#'*) continue ;; esac
13
+ if [[ $line =~ ^export[[:space:]]+ ]]; then line=${line#"${BASH_REMATCH[0]}"}; fi
14
+ [[ $line =~ ^([^=[:space:]]+)[[:space:]]*=[[:space:]]*(.*)$ ]] || continue
15
+ key=${BASH_REMATCH[1]}
16
+ val=${BASH_REMATCH[2]}
17
+ [[ $key =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
18
+ if [ "${#val}" -ge 2 ]; then
19
+ case $val in
20
+ \"*\") val=${val:1:${#val}-2} ;;
21
+ \'*\') val=${val:1:${#val}-2} ;;
22
+ esac
23
+ fi
24
+ export "$key=$val"
25
+ done < "$file"
26
+ }
27
+
28
+ # 已打开的终端里让最新的环境变量生效(优先级与新开终端一致:managed < 环境变量文件 < 用户 env)
29
+ reload-env() {
30
+ local sys=${MANYOYO_SYS_DIR:-/run/manyoyo-sys} box=${MANYOYO_BOX_DIR:-/run/manyoyo}
31
+ load_env "$sys/managed.env"
32
+ load_env "$sys/files.env"
33
+ load_env "$box/env"
34
+ }