@xcanwin/manyoyo 8.3.3 → 8.4.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.
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,7 @@ 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 { buildContainerRunArgs, buildContainerRunCommand, runWithEnvFile, hasNetworkArg } = require('../lib/container-run');
13
13
  const { getManyoyoConfigPath, readManyoyoConfig, syncGlobalImageVersion } = require('../lib/global-config');
14
14
  const { resolveUpdateImageVersion } = require('../lib/image-version-policy');
15
15
  const { selectContainerRuntime, mergeRuntimeEnv } = require('../lib/container-runtime');
@@ -44,6 +44,12 @@ const {
44
44
  normalizeMirrors
45
45
  } = require('../lib/runtime-normalizers');
46
46
  const { applyAptMirror } = require('../lib/mirrors');
47
+ const containerState = require('../lib/container-state');
48
+ const { buildExecArgs, resolveContainerId } = require('../lib/container-exec');
49
+ const { createNetworkManager, NETWORK_NAME } = require('../lib/container-network');
50
+ const { normalizePolicy } = require('../lib/network-policy');
51
+ const { withEnvEndpoints } = require('../lib/network-endpoints');
52
+ const { resolveManageOptions } = require('../lib/container-manage-options');
47
53
  const {
48
54
  sanitizeSensitiveData,
49
55
  sanitizeServeLogText,
@@ -131,6 +137,8 @@ let CONTAINER_RUNTIME = null;
131
137
  let UPDATE_CHECK_ENABLED = true;
132
138
  // 全局配置 mirrors(apt/npm/pip 软件源,空 = 官方默认):容器创建时在容器层生效,镜像不变
133
139
  let MIRRORS = { apt: '', npm: '', pip: '' };
140
+ let MANAGE_OPTIONS = { autostart: '', network: null };
141
+ let ENV_FILES = [];
134
142
  // serve 的容器环境状态,供 GET /api/system/runtime 读取
135
143
  const RUNTIME_STATE = { status: 'ready', message: '' };
136
144
  const DOCKER_DAEMON_ERROR_CODES = new Set(['PODMAN_MACHINE_UNAVAILABLE', 'DOCKER_DAEMON_UNAVAILABLE', 'PORT_IN_USE']);
@@ -342,6 +350,9 @@ function installServeProcessDiagnostics(logger) {
342
350
  * @property {string} [yolo] - YOLO 模式
343
351
  * @property {string} [containerMode] - 容器模式
344
352
  * @property {string} [containerRuntime] - 容器运行时(auto/docker/podman,默认 auto;仅全局配置生效)
353
+ * @property {string} [autostart] - 容器每次启动时由容器内 init 执行的 bash 脚本(新建容器时写入状态目录)
354
+ * @property {boolean} [autostartOnServe] - serve 启动时自动拉起此容器
355
+ * @property {object} [network] - 网络策略(preset / host / egress / peers,见 lib/network-policy.js),不写为默认收紧
345
356
  * @property {{apt?: string, npm?: string, pip?: string}} [mirrors] - 容器内 apt/npm/pip 软件源(http/https URL,空/缺省为官方默认;仅全局配置生效)
346
357
  * @property {boolean} [updateCheck] - serve 是否每天检查一次新版本(默认 true;仅全局配置生效,请求不附带任何本机信息)
347
358
  * @property {number} [cacheTTL] - 缓存过期天数
@@ -567,43 +578,16 @@ function addEnvFileTo(targetEnvs, envFile) {
567
578
  }
568
579
 
569
580
  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
- }
581
+ // 与「环境变量」文本同一套语法(见 lib/env-text.js);值里有被禁止的字符的行会被跳过并提示
582
+ const { entries, invalid } = containerState.parseEnvFileContent(fs.readFileSync(filePath, 'utf-8'));
583
+ entries.forEach(entry => targetEnvs.push('--env', `${entry.key}=${entry.value}`));
584
+ invalid.forEach(item => console.error(`${YELLOW}⚠️ ${filePath} 第 ${item.line} 行已跳过: ${item.reason}${NC}`));
597
585
  return {};
598
586
  }
599
587
  console.error(`${RED}⚠️ 未找到环境文件: ${envFile}${NC}`);
600
588
  return {};
601
589
  }
602
590
 
603
- function addEnvFile(envFile) {
604
- return addEnvFileTo(CONTAINER_ENVS, envFile);
605
- }
606
-
607
591
  // Playwright 浏览器模式的容器参数;在容器创建时(运行时已确定)才计算,失败只警告,不影响 run
608
592
  async function applyPlaywrightIntegration(runtime) {
609
593
  const integration = await buildContainerIntegration({
@@ -768,7 +752,10 @@ function getContainerStatus(name) {
768
752
 
769
753
  function removeContainer(name) {
770
754
  if ( !(QUIET.crm || QUIET.full) ) console.log(`${YELLOW}🗑️ 正在删除容器: ${name}...${NC}`);
755
+ // 先读 id:容器删掉后就查不到了;同时删状态目录,同名重建不会继承旧 env / 规则
756
+ const stateId = resolveContainerId(dockerExecArgs, name);
771
757
  dockerExecArgs(['rm', '-f', name], { stdio: 'pipe' });
758
+ if (stateId) containerState.removeState(os.homedir(), stateId);
772
759
  if ( !(QUIET.crm || QUIET.full) ) console.log(`${GREEN}✅ 已彻底删除。${NC}`);
773
760
  }
774
761
 
@@ -1503,6 +1490,9 @@ https://github.com/xcanwin/manyoyo
1503
1490
  const runConfig = runNameToLoad ? loadRunConfig(runNameToLoad, config) : {};
1504
1491
  const globalFirstConfig = normalizeFirstConfig(config.first, '全局配置');
1505
1492
  const runFirstConfig = normalizeFirstConfig(runConfig.first, '运行配置');
1493
+ // 自启动脚本与网络策略:runs.<name> > 全局配置(新建容器时写进状态目录,Web 上可随时改)
1494
+ const manageOptions = resolveManageOptions({ runConfig, globalConfig: config });
1495
+ MANAGE_OPTIONS = manageOptions;
1506
1496
 
1507
1497
  const resolvedRuntime = resolveRuntimeConfig({
1508
1498
  cliOptions: options,
@@ -1551,7 +1541,17 @@ https://github.com/xcanwin/manyoyo
1551
1541
 
1552
1542
  // Merge mode (array values): concatenate all sources
1553
1543
  const envFileList = resolvedRuntime.envFile;
1554
- envFileList.forEach(ef => addEnvFile(ef));
1544
+ // 环境变量文件不再在创建时展开成 --env:登记路径,每次 exec 现读(改了文件下一条命令就生效)
1545
+ ENV_FILES = [];
1546
+ envFileList.forEach(ef => {
1547
+ const filePath = String(ef || '').trim();
1548
+ if (!path.isAbsolute(filePath)) {
1549
+ console.error(`${RED}⚠️ 错误: --env-file 仅支持绝对路径: ${ef}${NC}`);
1550
+ process.exit(1);
1551
+ }
1552
+ if (!fs.existsSync(filePath)) console.error(`${YELLOW}⚠️ 未找到环境文件(创建后放进去也会生效): ${ef}${NC}`);
1553
+ ENV_FILES.push(filePath);
1554
+ });
1555
1555
 
1556
1556
  const envMap = resolvedRuntime.env;
1557
1557
  Object.entries(envMap).forEach(([key, value]) => addEnv(`${key}=${value}`));
@@ -1612,6 +1612,8 @@ https://github.com/xcanwin/manyoyo
1612
1612
  containerMode: contModeValue || "",
1613
1613
  containerRuntime: config.containerRuntime || "auto",
1614
1614
  mirrors: MIRRORS,
1615
+ autostart: manageOptions.autostart,
1616
+ network: manageOptions.network,
1615
1617
  shellPrefix: EXEC_COMMAND_PREFIX.trim(),
1616
1618
  shell: EXEC_COMMAND || "",
1617
1619
  shellSuffix: EXEC_COMMAND_SUFFIX || "",
@@ -1753,6 +1755,9 @@ function createRuntimeContext(modeState = {}) {
1753
1755
  containerExtraArgs: CONTAINER_EXTRA_ARGS,
1754
1756
  containerEnvs: CONTAINER_ENVS,
1755
1757
  mirrors: MIRRORS,
1758
+ autostart: MANAGE_OPTIONS.autostart,
1759
+ network: MANAGE_OPTIONS.network,
1760
+ envFiles: ENV_FILES,
1756
1761
  firstContainerEnvs: FIRST_CONTAINER_ENVS,
1757
1762
  containerVolumes: CONTAINER_VOLUMES,
1758
1763
  containerPorts: CONTAINER_PORTS,
@@ -2047,15 +2052,16 @@ function executeFirstCommand(runtime) {
2047
2052
  console.log(`⚙️ 首次预执行命令: ${YELLOW}${firstCommand}${NC}`);
2048
2053
  }
2049
2054
 
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 });
2055
+ const firstExec = buildExecArgs({ homeDir: os.homedir(), dockerExecArgs }, runtime.containerName, {
2056
+ extraEnv: containerState.envArgsToLines(runtime.firstContainerEnvs),
2057
+ command: ['/bin/bash', '-c', firstCommand]
2058
+ });
2059
+ let firstExecResult;
2060
+ try {
2061
+ firstExecResult = spawnSync(`${DOCKER_CMD}`, firstExec.args, { stdio: 'inherit', env: DOCKER_ENV });
2062
+ } finally {
2063
+ firstExec.cleanup();
2064
+ }
2059
2065
  if (firstExecResult.error) {
2060
2066
  throw firstExecResult.error;
2061
2067
  }
@@ -2083,15 +2089,38 @@ async function createNewContainer(runtime) {
2083
2089
  );
2084
2090
  const defaultCommand = runtime.execCommand;
2085
2091
 
2092
+ // 用户 env 不进容器配置(podman inspect 看不到明文),改写进状态目录的 box/env,每次 exec 现读
2093
+ const userEnvLines = containerState.userEnvLines(runtime.containerEnvs);
2086
2094
  await applyPlaywrightIntegration(runtime);
2095
+ runtime.containerEnvs = containerState.stripEnvKeys(runtime.containerEnvs, new Set(userEnvLines.map(containerState.envLineKey)));
2087
2096
 
2088
2097
  if (runtime.showCommand) {
2098
+ runtime.state = previewContainerState();
2089
2099
  console.log(buildDockerRunCmd(runtime));
2090
2100
  process.exit(0);
2091
2101
  }
2092
2102
 
2093
2103
  await ensureRunImage(runtime);
2094
2104
 
2105
+ const networkPolicy = await withEnvEndpoints(normalizePolicy(runtime.network), [...userEnvLines, ...containerState.readEnvFilePaths(runtime.envFiles || []).flatMap(file => file.entries.map(entry => `${entry.key}=${entry.value}`))]);
2106
+ const networkManager = createCliNetworkManager(runtime);
2107
+ if (!hasNetworkArg([...(runtime.contModeArgs || []), ...(runtime.containerExtraArgs || [])])) {
2108
+ try {
2109
+ await networkManager.ensureBridgeNetwork();
2110
+ } catch (e) {
2111
+ throw new Error(`无法创建 ${NETWORK_NAME} 网络: ${e.message}`);
2112
+ }
2113
+ }
2114
+ runtime.state = containerState.createState({
2115
+ homeDir: os.homedir(),
2116
+ envLines: userEnvLines,
2117
+ envFiles: runtime.envFiles,
2118
+ autostart: runtime.autostart,
2119
+ network: networkPolicy,
2120
+ netRequired: networkPolicy.preset !== 'open',
2121
+ meta: { name: runtime.containerName }
2122
+ });
2123
+
2095
2124
  // 使用数组参数执行命令(安全方式)
2096
2125
  try {
2097
2126
  const args = buildDockerRunArgs(runtime);
@@ -2100,12 +2129,16 @@ async function createNewContainer(runtime) {
2100
2129
  showImagePullHint(e);
2101
2130
  // 失败的 run 可能留下 Created 状态的容器;这个名字创建前不存在,所以只清理本次留下的这一个
2102
2131
  try { dockerExecArgs(['rm', '-f', runtime.containerName], { stdio: 'pipe' }); } catch (cleanupError) { /* 尽力清理 */ }
2132
+ containerState.removeState(os.homedir(), runtime.state.id);
2103
2133
  throw explainCreateFailure(e);
2104
2134
  }
2105
2135
 
2106
2136
  // Wait for container to be ready
2107
2137
  await waitForContainerReady(runtime.containerName);
2108
2138
 
2139
+ // 先下发网络规则再做任何 exec;失败即关闭(容器留着,init 在等门闩,不会跑自启动)
2140
+ await networkManager.apply(runtime.containerName, { expectId: runtime.state.id });
2141
+
2109
2142
  applyAptMirror({
2110
2143
  dockerExecArgs,
2111
2144
  containerName: runtime.containerName,
@@ -2123,8 +2156,26 @@ async function createNewContainer(runtime) {
2123
2156
  * 构建 Docker run 命令参数数组(安全方式,避免命令注入)
2124
2157
  * @returns {string[]} 命令参数数组
2125
2158
  */
2159
+ // 容器网络规则的下发器:CLI 与 serve 各建一份,helper 容器用当前 imageVersion 的官方镜像
2160
+ function createCliNetworkManager(runtime) {
2161
+ return createNetworkManager({
2162
+ command: DOCKER_CMD,
2163
+ env: DOCKER_ENV,
2164
+ homeDir: os.homedir(),
2165
+ imageRef: () => `${runtime.imageName}:${runtime.imageVersion}`,
2166
+ getMirrorUrls: () => Object.values(runtime.mirrors || {}).filter(Boolean)
2167
+ });
2168
+ }
2169
+
2170
+ function previewContainerState() {
2171
+ const dir = path.join(os.homedir(), '.manyoyo', 'containers', '<id>');
2172
+ return { id: '<id>', box: path.join(dir, 'box'), sys: path.join(dir, 'sys') };
2173
+ }
2174
+
2126
2175
  function buildDockerRunArgs(runtime) {
2127
2176
  return buildContainerRunArgs({
2177
+ state: runtime.state,
2178
+ defaultNetwork: NETWORK_NAME,
2128
2179
  containerName: runtime.containerName,
2129
2180
  hostPath: runtime.hostPath,
2130
2181
  containerPath: runtime.containerPath,
@@ -2159,6 +2210,7 @@ async function connectExistingContainer(runtime) {
2159
2210
  if (status !== 'running') {
2160
2211
  dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
2161
2212
  }
2213
+ await createCliNetworkManager(runtime).ensureReady(runtime.containerName);
2162
2214
 
2163
2215
  // Get default command from label
2164
2216
  const defaultCommand = dockerExecArgs(['inspect', '-f', '{{index .Config.Labels "manyoyo.default_cmd"}}', runtime.containerName]).trim();
@@ -2183,6 +2235,8 @@ async function setupContainer(runtime) {
2183
2235
  process.exit(0);
2184
2236
  }
2185
2237
  runtime.execCommand = joinExecCommand(runtime.execCommandPrefix, runtime.execCommand, runtime.execCommandSuffix);
2238
+ runtime.containerEnvs = containerState.stripEnvKeys(runtime.containerEnvs, new Set(containerState.userEnvLines(runtime.containerEnvs).map(containerState.envLineKey)));
2239
+ runtime.state = previewContainerState();
2186
2240
  console.log(buildDockerRunCmd(runtime));
2187
2241
  process.exit(0);
2188
2242
  }
@@ -2193,7 +2247,7 @@ async function setupContainer(runtime) {
2193
2247
  }
2194
2248
  }
2195
2249
 
2196
- function executeInContainer(runtime, defaultCommand) {
2250
+ async function executeInContainer(runtime, defaultCommand) {
2197
2251
  if (!containerExists(runtime.containerName)) {
2198
2252
  throw new Error(`未找到容器: ${runtime.containerName}`);
2199
2253
  }
@@ -2202,6 +2256,7 @@ function executeInContainer(runtime, defaultCommand) {
2202
2256
  if (status !== 'running') {
2203
2257
  dockerExecArgs(['start', runtime.containerName], { stdio: 'pipe' });
2204
2258
  }
2259
+ await createCliNetworkManager(runtime).ensureReady(runtime.containerName);
2205
2260
 
2206
2261
  getHelloTip(runtime.containerName, defaultCommand, runtime.execCommand);
2207
2262
  if (!(runtime.quiet.cmd || runtime.quiet.full)) {
@@ -2210,10 +2265,15 @@ function executeInContainer(runtime, defaultCommand) {
2210
2265
  }
2211
2266
 
2212
2267
  // 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 });
2268
+ const exec = buildExecArgs({ homeDir: os.homedir(), dockerExecArgs }, runtime.containerName, {
2269
+ interactive: true,
2270
+ tty: true,
2271
+ command: runtime.execCommand ? ['/bin/bash', '-c', runtime.execCommand] : ['/bin/bash']
2272
+ });
2273
+ try {
2274
+ spawnSync(`${DOCKER_CMD}`, exec.args, { stdio: 'inherit', env: DOCKER_ENV });
2275
+ } finally {
2276
+ exec.cleanup();
2217
2277
  }
2218
2278
  }
2219
2279
 
@@ -2458,6 +2518,7 @@ async function runWebServerMode(runtime) {
2458
2518
  contModeArgs: runtime.contModeArgs,
2459
2519
  containerExtraArgs: runtime.containerExtraArgs,
2460
2520
  containerEnvs: runtime.containerEnvs,
2521
+ envFiles: runtime.envFiles,
2461
2522
  containerVolumes: runtime.containerVolumes,
2462
2523
  containerPorts: runtime.containerPorts,
2463
2524
  validateHostPath: value => validateHostPathOrThrow(value),
@@ -2574,7 +2635,7 @@ async function main() {
2574
2635
  // 7-8. Execute command and handle post-exit interactions
2575
2636
  let shouldContinue = true;
2576
2637
  while (shouldContinue) {
2577
- executeInContainer(runtime, defaultCommand);
2638
+ await executeInContainer(runtime, defaultCommand);
2578
2639
  shouldContinue = await handlePostExit(runtime, defaultCommand);
2579
2640
  }
2580
2641
 
@@ -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,132 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const state = require('./container-state');
8
+
9
+ const ID_LABEL = 'manyoyo.id';
10
+
11
+ /**
12
+ * 读容器的 manyoyo.id 标签;没有标签(旧容器)或容器不存在返回 ''。
13
+ * @param {(args: string[], options?: object) => string} dockerExecArgs
14
+ */
15
+ function resolveContainerId(dockerExecArgs, containerName) {
16
+ try {
17
+ const out = String(dockerExecArgs(
18
+ ['inspect', '-f', `{{index .Config.Labels "${ID_LABEL}"}}`, containerName],
19
+ { stdio: 'pipe' }
20
+ ) || '').trim();
21
+ return state.isValidId(out) ? out : '';
22
+ } catch (e) {
23
+ return '';
24
+ }
25
+ }
26
+
27
+ // managed.env 在只读挂载的 sys/ 里(容器改不了);box/env 是容器可写的,按不可信输入读取(见 container-state.js)
28
+ function readTextOrEmpty(file, untrusted = false) {
29
+ if (untrusted) return state.readBoxText(file, state.MAX_ENV_BYTES).text;
30
+ try {
31
+ return fs.readFileSync(file, 'utf-8');
32
+ } catch (e) {
33
+ return '';
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 合成一次 exec 的 env 行:managed → 用户 env(box/env)→ extra,后者覆盖前者,非法行一律丢弃。
39
+ * podman 对非法行(1BAD=x、含空格的 key)会原样传进容器,所以必须由宿主机先过滤。
40
+ * @returns {{lines: string[], invalid: object[]}}
41
+ */
42
+ function composeEnvLines(managedText, boxText, extraLines = [], fileEntries = []) {
43
+ const merged = new Map();
44
+ const invalid = [];
45
+ const take = (text, source) => {
46
+ const parsed = state.parseEnvText(text);
47
+ parsed.entries.forEach(entry => merged.set(entry.key, entry.value));
48
+ parsed.invalid.forEach(item => invalid.push({ ...item, source }));
49
+ };
50
+ take(managedText, 'managed');
51
+ // 环境变量文件(已解析、已校验的条目)排在 managed 之后、box/env 之前:表格里的变量优先于文件
52
+ (fileEntries || []).forEach(entry => merged.set(entry.key, entry.value));
53
+ take(boxText, 'box');
54
+ // extra 是命令行 / 配置里已经成形的 KEY=VALUE(值是字面量,不再做去引号)
55
+ (extraLines || []).forEach(line => {
56
+ const i = String(line).indexOf('=');
57
+ if (i > 0 && state.ENV_KEY_RE.test(line.slice(0, i))) merged.set(line.slice(0, i), line.slice(i + 1));
58
+ else invalid.push({ line: 0, text: String(line), reason: '缺少 KEY=VALUE', source: 'extra' });
59
+ });
60
+ // 写给 docker / podman 的 --env-file:值是字面量(它们不处理引号),所以这里一律原样输出
61
+ return { lines: [...merged].map(([key, value]) => `${key}=${value}`), invalid };
62
+ }
63
+
64
+ function writeTempEnvFile(homeDir, lines) {
65
+ const dir = path.join(homeDir, '.manyoyo', 'tmp');
66
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ fs.chmodSync(dir, 0o700);
68
+ const file = path.join(dir, `exec-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
69
+ fs.writeFileSync(file, `${lines.join('\n')}\n`, { mode: 0o600, flag: 'wx' });
70
+ return file;
71
+ }
72
+
73
+ /**
74
+ * 所有进入用户容器的 `exec` 都经这里构造参数(Agent、终端、CLI 命令、first 预执行)。
75
+ * 用户 env 与 manyoyo 注入的 env 放在宿主机文件里,每次 exec 现读,所以改了下一条命令就生效;
76
+ * 值通过 0600 临时 env 文件传入,不出现在 ps 里;调用方必须在子进程启动后(或结束时)调 cleanup()。
77
+ *
78
+ * @param {{homeDir?: string, dockerExecArgs: Function}} ctx
79
+ * @param {string} containerName
80
+ * @param {object} options
81
+ * @param {boolean} [options.interactive] -i
82
+ * @param {boolean} [options.tty] -t
83
+ * @param {string} [options.user]
84
+ * @param {string[]} [options.envs] 非敏感的 KEY=VALUE(TERM 等),以 --env 传入
85
+ * @param {string[]} [options.extraEnv] 敏感/用户 KEY=VALUE(first.env),进临时文件,优先级最高
86
+ * @param {boolean} [options.withEnv=true] false 时不加载用户/managed env(读文件等与环境无关的 exec)
87
+ * @param {string[]} options.command 容器内要运行的命令与参数
88
+ * @returns {{args: string[], cleanup: () => void, invalid: object[], containerId: string}}
89
+ */
90
+ function buildExecArgs(ctx, containerName, options = {}) {
91
+ const homeDir = ctx.homeDir || os.homedir();
92
+ const withEnv = options.withEnv !== false;
93
+ const args = ['exec'];
94
+ if (options.interactive) args.push('-i');
95
+ if (options.tty) args.push('-t');
96
+ if (options.user) args.push('--user', options.user);
97
+
98
+ let containerId = '';
99
+ let lines = [];
100
+ let invalid = [];
101
+ const extra = options.extraEnv || [];
102
+ if (withEnv) {
103
+ containerId = resolveContainerId(ctx.dockerExecArgs, containerName);
104
+ if (containerId && state.stateExists(homeDir, containerId)) {
105
+ const p = state.paths(homeDir, containerId);
106
+ ({ lines, invalid } = composeEnvLines(readTextOrEmpty(p.managedEnv), readTextOrEmpty(p.env, true), extra, state.envFileEntries(homeDir, containerId)));
107
+ } else {
108
+ containerId = '';
109
+ ({ lines, invalid } = composeEnvLines('', '', extra));
110
+ }
111
+ } else if (extra.length) {
112
+ ({ lines, invalid } = composeEnvLines('', '', extra));
113
+ }
114
+
115
+ let envFile = '';
116
+ if (lines.length) {
117
+ envFile = writeTempEnvFile(homeDir, lines);
118
+ args.push('--env-file', envFile);
119
+ }
120
+ (options.envs || []).forEach(entry => args.push('--env', entry));
121
+ args.push(containerName, ...(options.command || []));
122
+
123
+ let cleaned = false;
124
+ const cleanup = () => {
125
+ if (cleaned || !envFile) return;
126
+ cleaned = true;
127
+ try { fs.rmSync(envFile, { force: true }); } catch (e) { /* 尽力清理 */ }
128
+ };
129
+ return { args, cleanup, invalid, containerId };
130
+ }
131
+
132
+ module.exports = { ID_LABEL, resolveContainerId, composeEnvLines, buildExecArgs };