@xcanwin/manyoyo 8.3.2 → 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 +1 -1
- package/bin/manyoyo.js +109 -48
- package/docker/manyoyo.Dockerfile +41 -8
- package/docker/res/agent/update-agents.sh +1 -1
- package/docker/res/playwright/browser.json +9 -12
- package/docker/res/playwright/chrome-policy.json +3 -0
- package/docker/res/playwright/env +3 -0
- package/docker/res/playwright/playwright-cli.sh +5 -0
- package/docker/res/playwright/stealth.init.js +11 -9
- package/lib/container-exec.js +132 -0
- package/lib/container-init.sh +59 -0
- package/lib/container-manage-options.js +35 -0
- package/lib/container-network.js +526 -0
- package/lib/container-run.js +24 -3
- package/lib/container-state.js +488 -0
- package/lib/egress-proxy.js +298 -0
- package/lib/env-text.js +62 -0
- package/lib/network-endpoints.js +99 -0
- package/lib/network-policy.js +265 -0
- package/lib/plugin/fingerprint.js +65 -50
- package/lib/plugin/playwright-extensions.js +0 -9
- package/lib/plugin/playwright-server.js +22 -2
- package/lib/plugin/playwright.js +55 -16
- package/lib/port-forward.js +135 -0
- package/lib/web/AGENTS.md +4 -0
- package/lib/web/container-manage.js +450 -0
- package/lib/web/index.html +80 -76
- package/lib/web/server.js +303 -76
- package/manyoyo.example.json +20 -2
- 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.
|
|
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
|
-
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
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
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
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 与
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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[@]}"
|
|
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": "
|
|
12
|
+
"channel": "chrome",
|
|
13
|
+
"chromiumSandbox": false,
|
|
19
14
|
"headless": false,
|
|
20
15
|
"args": [
|
|
21
16
|
"--lang=en-US",
|
|
22
|
-
"--window-size=1920,
|
|
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
|
}
|
|
@@ -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
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Object.defineProperty(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 };
|