@xcanwin/manyoyo 8.6.10 → 8.6.12
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 +2 -2
- package/bin/manyoyo.js +12 -4
- package/docker/manyoyo.Dockerfile +1 -1
- package/docker/res/claude/settings.json +20 -0
- package/lib/agent-adapters/index.js +18 -1
- package/lib/app-update.js +15 -5
- package/lib/code-update.js +54 -7
- package/lib/container-network.js +48 -10
- package/lib/egress-sidecar.js +19 -5
- package/lib/image-agents.js +45 -0
- package/lib/init-config.js +2 -1
- package/lib/network-endpoints.js +56 -1
- package/lib/plugin/playwright.js +12 -5
- package/lib/provider-presets.js +86 -0
- package/lib/setup-cli.js +18 -4
- package/lib/setup.js +7 -1
- package/lib/web/AGENTS.md +3 -0
- package/lib/web/agent-queue.js +5 -1
- package/lib/web/auth-store.js +167 -0
- package/lib/web/container-manage.js +2 -2
- package/lib/web/denied-notify.js +5 -0
- package/lib/web/index.html +86 -86
- package/lib/web/rewind.js +75 -0
- package/lib/web/server.js +794 -68
- package/lib/web/snapshots.js +245 -0
- package/lib/web/uploads.js +114 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ manyoyo init all # 把本机已有的 Agent 配置搬进来
|
|
|
54
54
|
manyoyo run -y c # 在沙箱里以 YOLO 模式启动 Claude Code
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
默认镜像 `ghcr.io/xcanwin/manyoyo` 首次使用时自动拉取;需要自定义镜像时再执行 `manyoyo build --iv 2.
|
|
57
|
+
默认镜像 `ghcr.io/xcanwin/manyoyo` 首次使用时自动拉取;需要自定义镜像时再执行 `manyoyo build --iv 2.3.0-common`。更多命令见[命令速查](https://xcanwin.github.io/manyoyo/reference/cli-options)。
|
|
58
58
|
|
|
59
59
|
## 它能做什么
|
|
60
60
|
|
|
@@ -110,7 +110,7 @@ manyoyo init all # import the agent configs already on this machine
|
|
|
110
110
|
manyoyo run -y c # start Claude Code in YOLO mode inside the sandbox
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
The default image `ghcr.io/xcanwin/manyoyo` is pulled automatically on first use; run `manyoyo build --iv 2.
|
|
113
|
+
The default image `ghcr.io/xcanwin/manyoyo` is pulled automatically on first use; run `manyoyo build --iv 2.3.0-common` only if you need a custom image. More commands: [CLI reference](https://xcanwin.github.io/manyoyo/en/reference/cli-options).
|
|
114
114
|
|
|
115
115
|
## What it does
|
|
116
116
|
|
package/bin/manyoyo.js
CHANGED
|
@@ -9,6 +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 { authSessionsFilePath } = require('../lib/web/auth-store');
|
|
12
13
|
const { isSidecarName } = require('../lib/egress-sidecar');
|
|
13
14
|
const { buildContainerRunArgs, buildContainerRunCommand, runWithEnvFile, hasNetworkArg } = require('../lib/container-run');
|
|
14
15
|
const { getManyoyoConfigPath, readManyoyoConfig, syncGlobalImageVersion } = require('../lib/global-config');
|
|
@@ -34,7 +35,7 @@ const { launchApp, getAppStatePath, openBrowser } = require('../lib/app-launcher
|
|
|
34
35
|
const { initAgentConfigs } = require('../lib/init-config');
|
|
35
36
|
const { buildImage } = require('../lib/image-build');
|
|
36
37
|
const { resolveAgentResumeArg, buildAgentResumeCommand } = require('../lib/agent-resume');
|
|
37
|
-
const { resolveYoloCommand } = require('../lib/agent-adapters');
|
|
38
|
+
const { resolveYoloCommand, withCodexBaseUrl } = require('../lib/agent-adapters');
|
|
38
39
|
const { runDoctorChecks, applyDoctorFixes } = require('../lib/doctor');
|
|
39
40
|
const { resolveContainerMode } = require('../lib/container-modes');
|
|
40
41
|
const { runPlaywrightCommand, buildContainerIntegration, mergeIntegration } = require('../lib/plugin');
|
|
@@ -871,14 +872,20 @@ async function updateOfflineInstall(mode, options, globalConfig) {
|
|
|
871
872
|
// MANYOYO_TEST_CODE_BASE 只给测试用(指向本地下载站替身)
|
|
872
873
|
const outcome = await codeUpdate.installCodeUpdate({
|
|
873
874
|
appRoot, branch: options.code, installed, fetchImpl: github.fetch, targetOs: appUpdate.platformOs(),
|
|
874
|
-
downloadBase: process.env.MANYOYO_TEST_CODE_BASE || undefined, log: line => console.log(` ${line}`)
|
|
875
|
+
downloadBase: process.env.MANYOYO_TEST_CODE_BASE || undefined, log: line => console.log(` ${line}`),
|
|
876
|
+
// 校验通过后、下载或切换之前先说清楚装的是哪个提交
|
|
877
|
+
onInfo: info => console.log(`${CYAN} ${options.code}@${info.commit.slice(0, 7)} ${info.subject}(构建于 ${codeUpdate.formatBuiltAt(info.builtAt)})${NC}`)
|
|
875
878
|
});
|
|
876
879
|
const label = `${options.code}@${outcome.info.commit.slice(0, 7)}`;
|
|
877
880
|
if (outcome.status === 'same') {
|
|
878
881
|
console.log(`${GREEN}✅ 已是 ${label},无需更新${NC}`);
|
|
879
882
|
return;
|
|
880
883
|
}
|
|
881
|
-
|
|
884
|
+
if (outcome.status === 'same-release') {
|
|
885
|
+
console.log(`${GREEN}✅ ${label} 就是当前正式版 ${installed} 的代码,无需更新${NC}`);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (outcome.status === 'reused') console.log(`${CYAN} 使用本地已有的 ${outcome.version},无需下载${NC}`);
|
|
882
889
|
result = outcome;
|
|
883
890
|
} catch (error) {
|
|
884
891
|
if (error instanceof appUpdate.UpdateError && error.code === 'NETWORK') {
|
|
@@ -2346,7 +2353,7 @@ async function executeInContainer(runtime, defaultCommand) {
|
|
|
2346
2353
|
const exec = buildExecArgs({ homeDir: os.homedir(), dockerExecArgs }, runtime.containerName, {
|
|
2347
2354
|
interactive: true,
|
|
2348
2355
|
tty: true,
|
|
2349
|
-
command: runtime.execCommand ? ['/bin/bash', '-c', runtime.execCommand] : ['/bin/bash']
|
|
2356
|
+
command: runtime.execCommand ? ['/bin/bash', '-c', withCodexBaseUrl(runtime.execCommand)] : ['/bin/bash']
|
|
2350
2357
|
});
|
|
2351
2358
|
try {
|
|
2352
2359
|
spawnSync(`${DOCKER_CMD}`, exec.args, { stdio: 'inherit', env: DOCKER_ENV });
|
|
@@ -2568,6 +2575,7 @@ async function runWebServerMode(runtime) {
|
|
|
2568
2575
|
importState: () => readImportState(),
|
|
2569
2576
|
updateInfo: () => updateChecker.getInfo(),
|
|
2570
2577
|
loginTokenDir: getLoginTokenDir(),
|
|
2578
|
+
authSessionsFile: authSessionsFilePath(os.homedir(), runtime.serverHost, runtime.serverPort),
|
|
2571
2579
|
doctorCheck: async () => runDoctorChecks({
|
|
2572
2580
|
selectRuntime: () => CONTAINER_RUNTIME,
|
|
2573
2581
|
runCommand: runRuntimeCommandAsync,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Stage 1: 缓存准备阶段 - 智能检测缓存或下载
|
|
3
3
|
# ==============================================================================
|
|
4
4
|
# 镜像源参数化(默认使用阿里云,可按需覆盖),两个阶段共享同一默认值
|
|
5
|
-
ARG APT_MIRROR=https://mirrors.
|
|
5
|
+
ARG APT_MIRROR=https://mirrors.tencent.com
|
|
6
6
|
ARG NODEJS_MIRROR=https://mirrors.tencent.com/nodejs-release/
|
|
7
7
|
|
|
8
8
|
FROM ubuntu:24.04 AS cache-stage
|
|
@@ -7,5 +7,25 @@
|
|
|
7
7
|
"statusLine": {
|
|
8
8
|
"type": "command",
|
|
9
9
|
"command": "bash /root/.claude/statusline.sh"
|
|
10
|
+
},
|
|
11
|
+
"hooks": {
|
|
12
|
+
"Stop": [
|
|
13
|
+
{
|
|
14
|
+
"hooks": [
|
|
15
|
+
{
|
|
16
|
+
"type": "command",
|
|
17
|
+
"command": "[ \"$CLAUDE_CODE_ENTRYPOINT\" = cli ] || exit 0; sleep 0; printf '%s' '{\"terminalSequence\":\"\\u0007\"}'"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"command": "[ \"$CLAUDE_CODE_ENTRYPOINT\" = cli ] || exit 0; sleep 0.3; printf '%s' '{\"terminalSequence\":\"\\u0007\"}'"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "command",
|
|
25
|
+
"command": "[ \"$CLAUDE_CODE_ENTRYPOINT\" = cli ] || exit 0; sleep 0.6; printf '%s' '{\"terminalSequence\":\"\\u0007\"}'"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
10
30
|
}
|
|
11
31
|
}
|
|
@@ -41,7 +41,24 @@ function resolveYoloCommand(yolo) {
|
|
|
41
41
|
return adapter.yoloCommand;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Codex 0.160 / 0.164 实测不认环境变量 OPENAI_BASE_URL,只认 `-c openai_base_url=...`。
|
|
45
|
+
// 向导给 Codex 配“兼容服务”写的是环境变量,所以执行 codex 的命令要在容器里由 shell 展开出 -c:
|
|
46
|
+
// 变量没设就一个参数都不多;地址只在容器里从环境变量读,不拼进命令文本(也就不会出现在日志和 ps 里)。
|
|
47
|
+
// 只处理以 codex 开头(可带前置的 KEY=值)的命令;命令里已经写了 openai_base_url 就以用户的为准。
|
|
48
|
+
const CODEX_BASE_URL_FLAGS = '${OPENAI_BASE_URL:+-c "openai_base_url=\\"$OPENAI_BASE_URL\\""}';
|
|
49
|
+
// 有 exec / exec resume 子命令时插在它们后面(codex exec -c … --json …),保持 `codex exec` 连在一起
|
|
50
|
+
const CODEX_COMMAND_START = /^((?:\s*[A-Za-z_][A-Za-z0-9_]*=(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]*)\s+)*\s*)codex(?:\s+exec(?:\s+resume)?)?(?=\s|$)/;
|
|
51
|
+
|
|
52
|
+
function withCodexBaseUrl(command) {
|
|
53
|
+
const text = String(command || '');
|
|
54
|
+
if (!text || /openai_base_url/.test(text)) return text;
|
|
55
|
+
const match = text.match(CODEX_COMMAND_START);
|
|
56
|
+
if (!match) return text;
|
|
57
|
+
return `${match[0]} ${CODEX_BASE_URL_FLAGS}${text.slice(match[0].length)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
module.exports = {
|
|
45
61
|
AGENT_ADAPTERS,
|
|
46
|
-
resolveYoloCommand
|
|
62
|
+
resolveYoloCommand,
|
|
63
|
+
withCodexBaseUrl
|
|
47
64
|
};
|
package/lib/app-update.js
CHANGED
|
@@ -206,6 +206,19 @@ function pruneApps(appRoot, keep) {
|
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* 切换 current 到 app/<version>/(目录必须已就位)、写 .update.json(记下上一版本)、清理旧版本。
|
|
211
|
+
* installFromArchive 与代码版的“本地已有就直接切换”共用;manifest 原样放进返回值,供调用方做收尾。
|
|
212
|
+
*/
|
|
213
|
+
function switchInstalled({ appRoot, version, manifest }) {
|
|
214
|
+
const previous = currentVersion(appRoot);
|
|
215
|
+
switchCurrent(appRoot, version);
|
|
216
|
+
writeUpdateRecord(appRoot, { previous: previous && previous !== version ? previous : readUpdateRecord(appRoot).previous || '', current: version, updatedAt: new Date().toISOString() });
|
|
217
|
+
const record = readUpdateRecord(appRoot);
|
|
218
|
+
pruneApps(appRoot, [version, record.previous].filter(Boolean));
|
|
219
|
+
return { version, previous: record.previous || '', manifest };
|
|
220
|
+
}
|
|
221
|
+
|
|
209
222
|
/**
|
|
210
223
|
* 解开已校验过的 -app.tar.gz 并切换 current(网络下载与 --file 共用)。包不完整/内容不符都在切换前中止。
|
|
211
224
|
*/
|
|
@@ -234,11 +247,7 @@ async function installFromArchive({ appRoot, archive, version, targetArch, targe
|
|
|
234
247
|
const target = path.join(appRoot, version);
|
|
235
248
|
if (version !== previous) fs.rmSync(target, { recursive: true, force: true });
|
|
236
249
|
if (version !== previous) fs.renameSync(staging, target);
|
|
237
|
-
|
|
238
|
-
writeUpdateRecord(appRoot, { previous: previous && previous !== version ? previous : readUpdateRecord(appRoot).previous || '', current: version, updatedAt: new Date().toISOString() });
|
|
239
|
-
const record = readUpdateRecord(appRoot);
|
|
240
|
-
pruneApps(appRoot, [version, record.previous].filter(Boolean));
|
|
241
|
-
return { version, previous: record.previous || '', manifest };
|
|
250
|
+
return switchInstalled({ appRoot, version, manifest });
|
|
242
251
|
} finally {
|
|
243
252
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
244
253
|
}
|
|
@@ -397,6 +406,7 @@ module.exports = {
|
|
|
397
406
|
compareVersions,
|
|
398
407
|
isAppVersion,
|
|
399
408
|
installFromArchive,
|
|
409
|
+
switchInstalled,
|
|
400
410
|
detectInstallMode,
|
|
401
411
|
fetchLatestRelease,
|
|
402
412
|
parseSha256Sums,
|
package/lib/code-update.js
CHANGED
|
@@ -9,9 +9,9 @@ const fs = require('fs');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
const appUpdate = require('./app-update');
|
|
12
|
-
const { downloadVerified } = require('./download-verified');
|
|
12
|
+
const { downloadVerified, sha256File } = require('./download-verified');
|
|
13
13
|
|
|
14
|
-
const { UpdateError, compareVersions, networkError, request, installFromArchive } = appUpdate;
|
|
14
|
+
const { UpdateError, compareVersions, networkError, request, installFromArchive, switchInstalled } = appUpdate;
|
|
15
15
|
|
|
16
16
|
const DOWNLOAD_BASE = 'https://github.com/xcanwin/manyoyo/releases/download/';
|
|
17
17
|
const BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,100}$/;
|
|
@@ -102,23 +102,70 @@ async function fetchCodeInfo({ branch, fetchImpl = fetch, downloadBase = DOWNLOA
|
|
|
102
102
|
return validateCodeInfo(parsed, { branch });
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// 当前版本的 manyoyo/package.json 里的 gitHead(正式包与代码版包构建时写入);读不到或格式不对当作没有
|
|
106
|
+
function readCurrentGitHead(appRoot) {
|
|
107
|
+
try {
|
|
108
|
+
const head = JSON.parse(fs.readFileSync(path.join(appRoot, 'current', 'manyoyo', 'package.json'), 'utf-8')).gitHead;
|
|
109
|
+
return typeof head === 'string' && /^[0-9a-f]{40}$/.test(head) ? head : '';
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 本地 app/<版本>/ 已经是这个代码版时返回它的 manifest,否则 null(调用方照常下载,不报错)。
|
|
117
|
+
* --rollback 直接切到本地目录,所以这里的检查不能比它宽松:目录结构完整,并额外核对 manifest 与 .installed、提交号。
|
|
118
|
+
*/
|
|
119
|
+
async function findLocalCopy({ appRoot, info, targetArch, targetOs }) {
|
|
120
|
+
const dir = path.join(appRoot, info.version);
|
|
121
|
+
try {
|
|
122
|
+
for (const required of ['node/bin/node', 'manyoyo/bin/manyoyo.js', 'manifest.json', '.installed']) {
|
|
123
|
+
if (!fs.existsSync(path.join(dir, required))) return null;
|
|
124
|
+
}
|
|
125
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf-8'));
|
|
126
|
+
if (!manifest || manifest.version !== info.version || manifest.kind !== 'app' || manifest.arch !== targetArch || manifest.os !== targetOs) return null;
|
|
127
|
+
if (!manifest.source || manifest.source.commit !== info.commit) return null;
|
|
128
|
+
// installFromArchive 安装时把 manifest.json 的哈希写进 .installed:对得上才说明目录没被动过
|
|
129
|
+
if (fs.readFileSync(path.join(dir, '.installed'), 'utf-8').trim() !== await sha256File(path.join(dir, 'manifest.json'))) return null;
|
|
130
|
+
return manifest;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 构建时间按本机时区显示成 YYYY-MM-DD HH:mm;timeZone 可注入(测试不依赖运行机器的时区)
|
|
137
|
+
function formatBuiltAt(builtAt, timeZone) {
|
|
138
|
+
const date = new Date(builtAt);
|
|
139
|
+
if (typeof builtAt !== 'string' || !builtAt || Number.isNaN(date.getTime())) return '未知时间';
|
|
140
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
|
141
|
+
.formatToParts(date).map(part => [part.type, part.value]));
|
|
142
|
+
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
105
145
|
/**
|
|
106
146
|
* 安装某个分支的代码版。installed 是当前版本号。
|
|
107
|
-
*
|
|
147
|
+
* 返回:{ status: 'same', info }(已是这个版本)、{ status: 'same-release', info }(与当前正式版同一个提交)、
|
|
148
|
+
* { status: 'reused' | 'installed', info, version, previous, manifest }(本地已有就直接切换 / 下载安装)。
|
|
108
149
|
* 校验失败 / 镜像未发布 / 版本更旧都在下载与切换之前中止,current 不变。
|
|
150
|
+
* onInfo(info):全部校验通过后、下载或切换之前调用,让调用方先告诉用户装的是哪个提交。
|
|
109
151
|
*/
|
|
110
|
-
async function installCodeUpdate({ appRoot, branch, installed, fetchImpl = fetch, downloadBase = DOWNLOAD_BASE, notFoundRetryMs, targetArch = appUpdate.arch(), targetOs = appUpdate.platformOs(), run, log = () => {}, tmpRoot = os.tmpdir() }) {
|
|
152
|
+
async function installCodeUpdate({ appRoot, branch, installed, fetchImpl = fetch, downloadBase = DOWNLOAD_BASE, notFoundRetryMs, onInfo, targetArch = appUpdate.arch(), targetOs = appUpdate.platformOs(), run, log = () => {}, tmpRoot = os.tmpdir() }) {
|
|
111
153
|
const info = await fetchCodeInfo({ branch, fetchImpl, downloadBase, notFoundRetryMs });
|
|
112
154
|
if (info.version === installed) return { status: 'same', info };
|
|
155
|
+
if (readCurrentGitHead(appRoot) === info.commit) return { status: 'same-release', info };
|
|
113
156
|
if (compareVersions(info.version, installed) < 0) {
|
|
114
157
|
throw new UpdateError('OLDER', `分支 ${branch} 的代码版是 ${info.version},比当前的 ${installed} 旧。${branch} 的最新提交可能还在构建,稍后再试;要换回正式版请用 manyoyo update --rollback 或重新运行安装命令。`);
|
|
115
158
|
}
|
|
116
159
|
if (!info.imageReady) {
|
|
117
160
|
throw new UpdateError('IMAGE_NOT_READY', `这个代码版需要镜像 ${info.imageVersion},但它还没有发布,现在升级会让容器拉不到镜像。请等镜像发布后再试,或换一个分支。`);
|
|
118
161
|
}
|
|
162
|
+
// 降级与镜像检查都排在本地复用之前,否则本地留着旧目录时会绕过它们
|
|
163
|
+
const local = await findLocalCopy({ appRoot, info, targetArch, targetOs });
|
|
119
164
|
const asset = info.assets[`${targetOs}-${targetArch}`];
|
|
120
|
-
|
|
121
|
-
if (
|
|
165
|
+
if (!local && !asset) throw new UpdateError('NO_ASSET', `代码版 ${info.version} 里没有适合这台机器(${targetOs}-${targetArch})的升级包。`);
|
|
166
|
+
if (typeof onInfo === 'function') onInfo(info);
|
|
167
|
+
if (local) return { status: 'reused', info, ...switchInstalled({ appRoot, version: info.version, manifest: local }) };
|
|
168
|
+
const url = `${tagBase(downloadBase, branch)}${asset.name}`;
|
|
122
169
|
// 包是按 CODE.json 里的固定命名拼出来的:先确认确实存在(404 就是没有)
|
|
123
170
|
const probe = await request(fetchImpl, url, { method: 'HEAD' });
|
|
124
171
|
if (probe.status === 404) throw new UpdateError('NO_ASSET', `代码版 ${info.version} 的升级包 ${asset.name} 还没有上传完整,请稍后再试。`);
|
|
@@ -162,4 +209,4 @@ function latestReleaseNotice(installed, latest) {
|
|
|
162
209
|
return `已是最新版本 ${installed}`;
|
|
163
210
|
}
|
|
164
211
|
|
|
165
|
-
module.exports = { isCodeVersion, latestReleaseNotice, DOWNLOAD_BASE, isValidBranch, codeTagForBranch, codeAssetName, validateCodeInfo, fetchCodeInfo, installCodeUpdate };
|
|
212
|
+
module.exports = { formatBuiltAt, isCodeVersion, latestReleaseNotice, DOWNLOAD_BASE, isValidBranch, codeTagForBranch, codeAssetName, validateCodeInfo, fetchCodeInfo, installCodeUpdate };
|
package/lib/container-network.js
CHANGED
|
@@ -113,6 +113,11 @@ function uniqueBy(items, keyFn) {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// 默认镜像本地没有时各运行时的报错(--pull=never):podman `image not known`,docker `Unable to find image … locally` / `No such image`
|
|
117
|
+
function isMissingImageError(error) {
|
|
118
|
+
return /image not known|no such image|unable to find image|image .* not found locally/i.test(String(error && error.message || error));
|
|
119
|
+
}
|
|
120
|
+
|
|
116
121
|
function createNetworkManager(options) {
|
|
117
122
|
const homeDir = options.homeDir || os.homedir();
|
|
118
123
|
const command = options.command;
|
|
@@ -153,8 +158,33 @@ function createNetworkManager(options) {
|
|
|
153
158
|
});
|
|
154
159
|
}
|
|
155
160
|
|
|
161
|
+
// 一次性 helper(改规则的 nft、读 /etc/hosts)和 sidecar 用哪个镜像:
|
|
162
|
+
// 优先用 serve 的默认镜像——它一定有 nft、node 和 sidecar 要的文件(2.1.x 及更早的镜像里没有 nft,
|
|
163
|
+
// 用户可能拿旧镜像建过容器,不能默认用容器自己的镜像)。只有默认镜像本地没有(--pull=never 下报“镜像不存在”)时,
|
|
164
|
+
// 才退回目标容器自己的镜像。判断靠运行失败的输出,不额外起进程:
|
|
165
|
+
// podman 报 `<镜像>: image not known`;docker 报 `Unable to find image '<镜像>' locally` 或 `No such image: <镜像>`
|
|
166
|
+
function ownImageOf(info) {
|
|
167
|
+
return info && info.Config && typeof info.Config.Image === 'string' ? info.Config.Image : '';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function runWithFallbackImage(info, makeArgs, runOptions) {
|
|
171
|
+
const primary = getImageRef();
|
|
172
|
+
const own = ownImageOf(info);
|
|
173
|
+
try {
|
|
174
|
+
return await run(makeArgs(primary), runOptions);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (!isMissingImageError(e) || !own || own === primary) throw e;
|
|
177
|
+
try {
|
|
178
|
+
return await run(makeArgs(own), runOptions);
|
|
179
|
+
} catch (e2) {
|
|
180
|
+
if (isMissingImageError(e2)) throw new Error(`默认镜像 ${primary} 和容器自己的镜像 ${own} 本地都没有: ${e2.message}`);
|
|
181
|
+
throw e2;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
156
186
|
// 域名规则的过滤代理:manyoyo 网络里的 sidecar 容器,按来源 IP 认容器(见 egress-sidecar.js)
|
|
157
|
-
const sidecar = createSidecarManager({ run, command, homeDir, imageRef: getImageRef, networkName: NETWORK_NAME, ensureNetwork: ensureBridgeNetwork, getUpstream });
|
|
187
|
+
const sidecar = createSidecarManager({ run, command, homeDir, imageRef: getImageRef, isMissingImageError, networkName: NETWORK_NAME, ensureNetwork: ensureBridgeNetwork, getUpstream });
|
|
158
188
|
|
|
159
189
|
// buildExecArgs 只需要同步 inspect 取 id;这里 withEnv:false 不会用到
|
|
160
190
|
const execCtx = { homeDir, dockerExecArgs: () => '' };
|
|
@@ -236,8 +266,7 @@ function createNetworkManager(options) {
|
|
|
236
266
|
const hostConfig = info.HostConfig || {};
|
|
237
267
|
(hostConfig.Dns || []).filter(ip => net.isIP(ip)).forEach(ip => args.push('--dns', ip));
|
|
238
268
|
(hostConfig.ExtraHosts || []).filter(entry => /^[A-Za-z0-9._-]+:(?:host-gateway|[0-9a-fA-F:.]+)$/.test(entry)).forEach(entry => args.push('--add-host', entry));
|
|
239
|
-
args
|
|
240
|
-
return await run(args, { timeout: 30000 });
|
|
269
|
+
return await runWithFallbackImage(info, image => [...args, '--entrypoint', 'cat', image, '/etc/hosts', '/etc/resolv.conf'], { timeout: 30000 });
|
|
241
270
|
}
|
|
242
271
|
|
|
243
272
|
// 容器间:别的容器入站规则里允许了本容器(来源写 @containers 或 @container:<本容器>),本容器才能出去到它(派生行)。
|
|
@@ -303,7 +332,7 @@ function createNetworkManager(options) {
|
|
|
303
332
|
if (proxied) {
|
|
304
333
|
const selfBridge = safeBridge(self.info);
|
|
305
334
|
if (!selfBridge) throw new Error('域名规则需要容器在 manyoyo 网络里,这个容器的网络不支持');
|
|
306
|
-
const proxy = await sidecar.ensure();
|
|
335
|
+
const proxy = await sidecar.ensure({ fallbackImage: ownImageOf(self.info) });
|
|
307
336
|
allow.push({ ip: proxy.ip, ports: String(proxy.port), proto: 'tcp' });
|
|
308
337
|
antiSpoof.push({ daddr: proxy.ip, saddr: selfBridge.ip });
|
|
309
338
|
const url = `http://${proxy.ip}:${proxy.port}`;
|
|
@@ -363,8 +392,8 @@ function createNetworkManager(options) {
|
|
|
363
392
|
sidecar.writeClients(clients);
|
|
364
393
|
}
|
|
365
394
|
|
|
366
|
-
async function sendRuleset(name, text) {
|
|
367
|
-
await
|
|
395
|
+
async function sendRuleset(name, text, info) {
|
|
396
|
+
await runWithFallbackImage(info, image => [
|
|
368
397
|
'run', '--rm', '-i',
|
|
369
398
|
'--network', `container:${name}`,
|
|
370
399
|
'--user', 'root',
|
|
@@ -372,7 +401,7 @@ function createNetworkManager(options) {
|
|
|
372
401
|
'--security-opt', 'no-new-privileges',
|
|
373
402
|
'--entrypoint', 'nft',
|
|
374
403
|
'--pull=never',
|
|
375
|
-
|
|
404
|
+
image,
|
|
376
405
|
'-f', '-'
|
|
377
406
|
], { input: text, timeout: 30000 });
|
|
378
407
|
}
|
|
@@ -419,7 +448,7 @@ function createNetworkManager(options) {
|
|
|
419
448
|
if (endpoints.proxyPolicy && selfBridge) clientCache.set(self.id, { ip: selfBridge.ip, entry: { id: self.id, ...endpoints.proxyPolicy } });
|
|
420
449
|
else clientCache.delete(self.id);
|
|
421
450
|
syncEgressClients(all); // 先让代理认得这个容器,再放行门闩
|
|
422
|
-
await sendRuleset(name, policyLib.buildNftRuleset(policy, endpoints));
|
|
451
|
+
await sendRuleset(name, policyLib.buildNftRuleset(policy, endpoints), self.info);
|
|
423
452
|
await execInContainer(name, ['/bin/sh', '-c', `mkdir -p ${path.posix.dirname(GATE_READY)} && : > ${GATE_READY}`]);
|
|
424
453
|
const privileged = Boolean(self.info.HostConfig && self.info.HostConfig.Privileged);
|
|
425
454
|
const status = {
|
|
@@ -479,7 +508,7 @@ function createNetworkManager(options) {
|
|
|
479
508
|
if (policyLib.usesProxy(policy)) {
|
|
480
509
|
try {
|
|
481
510
|
if (!await sidecar.isHealthy()) {
|
|
482
|
-
await sidecar.ensure();
|
|
511
|
+
await sidecar.ensure({ fallbackImage: ownImageOf(info) });
|
|
483
512
|
syncEgressClients(await listManaged());
|
|
484
513
|
}
|
|
485
514
|
} catch (e) { warn(`域名规则代理恢复失败: ${e.message}`); }
|
|
@@ -534,6 +563,15 @@ function createNetworkManager(options) {
|
|
|
534
563
|
});
|
|
535
564
|
}
|
|
536
565
|
|
|
566
|
+
// 没有“触发它的那个容器”时(定时自愈),用第一个要用代理的容器的镜像当退路
|
|
567
|
+
function fallbackImageOfProxied(all) {
|
|
568
|
+
const item = all.find(entry => {
|
|
569
|
+
if (!state.stateExists(homeDir, entry.id)) return false;
|
|
570
|
+
try { return policyLib.usesProxy(loadPolicy(entry.id)); } catch (e) { return false; }
|
|
571
|
+
});
|
|
572
|
+
return item ? ownImageOf(item.info) : '';
|
|
573
|
+
}
|
|
574
|
+
|
|
537
575
|
// 过滤代理容器自己退出 / 被删:有用到它的容器就拉起来,并重写映射
|
|
538
576
|
let lastHealAt = 0;
|
|
539
577
|
let healTimer = null;
|
|
@@ -554,7 +592,7 @@ function createNetworkManager(options) {
|
|
|
554
592
|
lastHealAt = Date.now();
|
|
555
593
|
const all = await listManaged();
|
|
556
594
|
if (!await needsSidecar(all)) return;
|
|
557
|
-
await sidecar.ensure();
|
|
595
|
+
await sidecar.ensure({ fallbackImage: fallbackImageOfProxied(all) });
|
|
558
596
|
syncEgressClients(all);
|
|
559
597
|
}
|
|
560
598
|
|
package/lib/egress-sidecar.js
CHANGED
|
@@ -111,6 +111,7 @@ function appFileContents() {
|
|
|
111
111
|
* @param {string} options.command 运行时命令名(docker / podman,用来判断宿主机别名)
|
|
112
112
|
* @param {string} options.homeDir
|
|
113
113
|
* @param {() => string} options.imageRef 当前镜像
|
|
114
|
+
* @param {(error: Error) => boolean} [options.isMissingImageError] 运行失败是不是“镜像本地不存在”(决定要不要换备用镜像重试)
|
|
114
115
|
* @param {string} options.networkName manyoyo 网络名
|
|
115
116
|
* @param {() => Promise<void>} options.ensureNetwork
|
|
116
117
|
* @param {() => string} [options.getUpstream] 上游代理(宿主机视角)
|
|
@@ -180,7 +181,7 @@ function createSidecarManager(options) {
|
|
|
180
181
|
throw new Error(`域名规则代理没有起来${tail ? `: ${String(tail).trim().split('\n').slice(-2).join(' ')}` : ''}`);
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
async function doEnsure() {
|
|
184
|
+
async function doEnsure(fallbackImage = '') {
|
|
184
185
|
await options.ensureNetwork();
|
|
185
186
|
const subnet = subnetOfNetwork(JSON.parse((await run(['network', 'inspect', networkName, '--format', '{{json .}}'])).trim().split('\n')[0] || 'null'));
|
|
186
187
|
const ip = chooseSidecarIp(subnet, homeDir);
|
|
@@ -213,9 +214,22 @@ function createSidecarManager(options) {
|
|
|
213
214
|
'--env', `EGRESS_PORT=${SIDECAR_PORT}`, '--env', `EGRESS_HOST_ALIAS=${hostAlias}`
|
|
214
215
|
];
|
|
215
216
|
if (isDocker) args.push('--add-host', 'host.docker.internal:host-gateway');
|
|
216
|
-
|
|
217
|
+
const tail = ['/app/egress-sidecar-main.js'];
|
|
218
|
+
const runWith = image => run([...args, '--entrypoint', 'node', image, ...tail], { timeout: 60000 });
|
|
217
219
|
try {
|
|
218
|
-
|
|
220
|
+
try {
|
|
221
|
+
await runWith(options.imageRef());
|
|
222
|
+
} catch (first) {
|
|
223
|
+
// 默认镜像本地没有(--pull=never):用触发它的那个容器的镜像(只要里面有 node 就行,程序文件是从宿主机只读挂进去的)
|
|
224
|
+
const isMissing = options.isMissingImageError || (() => false);
|
|
225
|
+
if (!fallbackImage || fallbackImage === options.imageRef() || !isMissing(first)) throw first;
|
|
226
|
+
try {
|
|
227
|
+
await runWith(fallbackImage);
|
|
228
|
+
} catch (second) {
|
|
229
|
+
if (isMissing(second)) throw new Error(`默认镜像 ${options.imageRef()} 和容器自己的镜像 ${fallbackImage} 本地都没有: ${second.message}`);
|
|
230
|
+
throw second;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
219
233
|
} catch (e) {
|
|
220
234
|
const message = String(e.message);
|
|
221
235
|
// 另一个进程(CLI / serve)刚好同时建好了它:直接用现成的
|
|
@@ -235,8 +249,8 @@ function createSidecarManager(options) {
|
|
|
235
249
|
}
|
|
236
250
|
|
|
237
251
|
/** 保证 sidecar 在运行,返回 {name, ip, port};多处同时调用合并成一次。 */
|
|
238
|
-
function ensure() {
|
|
239
|
-
if (!inflight) inflight = doEnsure().finally(() => { inflight = null; });
|
|
252
|
+
function ensure({ fallbackImage = '' } = {}) {
|
|
253
|
+
if (!inflight) inflight = doEnsure(fallbackImage).finally(() => { inflight = null; });
|
|
240
254
|
return inflight;
|
|
241
255
|
}
|
|
242
256
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PROBE_AGENTS = ['claude', 'codex', 'gemini', 'opencode'];
|
|
4
|
+
const PROBE_SCRIPT = `for c in ${PROBE_AGENTS.join(' ')}; do command -v "$c" >/dev/null 2>&1 && echo "$c"; done`;
|
|
5
|
+
const PROBE_TIMEOUT_MS = 30000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 探测镜像里装了哪些 Agent CLI。结果按镜像 ID 缓存,同一镜像只探测一次;
|
|
9
|
+
* 同时到来的请求共用一次探测。拿不到结果时返回 null(未知,不缓存)。
|
|
10
|
+
* exec(args, { timeout }) → Promise<stdout>,抛错表示失败。
|
|
11
|
+
*/
|
|
12
|
+
function createImageAgentProbe(exec) {
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
const inflight = new Map();
|
|
15
|
+
|
|
16
|
+
async function run(imageRef) {
|
|
17
|
+
let imageId = '';
|
|
18
|
+
try {
|
|
19
|
+
imageId = String(await exec(['image', 'inspect', '--format', '{{.Id}}', imageRef], { timeout: 10000 })).trim();
|
|
20
|
+
} catch (e) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (!imageId) return null;
|
|
24
|
+
if (cache.has(imageId)) return cache.get(imageId);
|
|
25
|
+
try {
|
|
26
|
+
const out = await exec([
|
|
27
|
+
'run', '--rm', '--network', 'none', '--entrypoint', '/bin/sh', imageRef, '-c', PROBE_SCRIPT
|
|
28
|
+
], { timeout: PROBE_TIMEOUT_MS });
|
|
29
|
+
const found = String(out).split('\n').map(line => line.trim()).filter(line => PROBE_AGENTS.includes(line));
|
|
30
|
+
cache.set(imageId, found);
|
|
31
|
+
return found;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return function probeImageAgents(imageRef) {
|
|
38
|
+
if (inflight.has(imageRef)) return inflight.get(imageRef);
|
|
39
|
+
const promise = run(imageRef).finally(() => inflight.delete(imageRef));
|
|
40
|
+
inflight.set(imageRef, promise);
|
|
41
|
+
return promise;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { createImageAgentProbe, PROBE_AGENTS };
|
package/lib/init-config.js
CHANGED
|
@@ -13,11 +13,12 @@ const AGENT_ENV_SCHEMA = {
|
|
|
13
13
|
label: 'Claude Code',
|
|
14
14
|
yolo: 'c',
|
|
15
15
|
modelKey: 'ANTHROPIC_MODEL',
|
|
16
|
-
requiredAnyOf: ['ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
|
16
|
+
requiredAnyOf: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'],
|
|
17
17
|
baseUrlKey: 'ANTHROPIC_BASE_URL',
|
|
18
18
|
baseUrlPresets: [{ label: 'Anthropic 官方', value: 'https://api.anthropic.com' }],
|
|
19
19
|
env: [
|
|
20
20
|
{ name: 'ANTHROPIC_AUTH_TOKEN', description: 'Anthropic API Key 或兼容服务的令牌', secret: true },
|
|
21
|
+
{ name: 'ANTHROPIC_API_KEY', description: '部分服务商(如 Kimi Code)要求用 API Key 方式', secret: true },
|
|
21
22
|
{ name: 'CLAUDE_CODE_OAUTH_TOKEN', description: 'Claude Code OAuth 令牌(订阅登录)', secret: true },
|
|
22
23
|
{ name: 'ANTHROPIC_BASE_URL', description: 'API 地址(使用官方服务可留空)', secret: false },
|
|
23
24
|
{ name: 'ANTHROPIC_MODEL', description: '默认模型', secret: false },
|
package/lib/network-endpoints.js
CHANGED
|
@@ -4,6 +4,7 @@ const dns = require('dns').promises;
|
|
|
4
4
|
const net = require('net');
|
|
5
5
|
const { normalizePolicy, normalizeDomain, isUnrestricted } = require('./network-policy');
|
|
6
6
|
const { isRestrictedAddress } = require('./egress-proxy');
|
|
7
|
+
const { AGENT_ENV_SCHEMA } = require('./init-config');
|
|
7
8
|
|
|
8
9
|
// 新建容器时从用户在创建时填写的 env 里推断“Agent 要访问的端点”(如 ANTHROPIC_BASE_URL、OPENAI_BASE_URL 指向宿主机上的
|
|
9
10
|
// Ollama 或局域网网关),把落在私有网段的写进网络策略,否则默认收紧会让 Agent 一上来就连不上自己配置的模型服务。
|
|
@@ -43,6 +44,60 @@ function envUrlDomains(envLines) {
|
|
|
43
44
|
return out;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Agent 的模型域名:env 里的 URL 域名,再加官方默认域名(取自 AGENT_ENV_SCHEMA 的 baseUrlPresets)。
|
|
49
|
+
* 官方域名只在该 Agent 没配 BASE_URL 时才算;给了 agentId 就只看这个 Agent,没给则只看配了 Key 的 Agent。
|
|
50
|
+
*/
|
|
51
|
+
function modelDomains(envLines, agentId) {
|
|
52
|
+
const out = envUrlDomains(envLines);
|
|
53
|
+
const env = {};
|
|
54
|
+
for (const line of envLines || []) {
|
|
55
|
+
const text = String(line);
|
|
56
|
+
const i = text.indexOf('=');
|
|
57
|
+
if (i > 0) env[text.slice(0, i)] = text.slice(i + 1);
|
|
58
|
+
}
|
|
59
|
+
const agents = agentId ? [agentId] : Object.keys(AGENT_ENV_SCHEMA);
|
|
60
|
+
for (const id of agents) {
|
|
61
|
+
const spec = AGENT_ENV_SCHEMA[id];
|
|
62
|
+
if (!spec || env[spec.baseUrlKey]) continue;
|
|
63
|
+
if (!agentId && !spec.requiredAnyOf.some(key => env[key])) continue;
|
|
64
|
+
for (const preset of spec.baseUrlPresets) {
|
|
65
|
+
const target = urlOf(preset.value);
|
|
66
|
+
if (!target) continue;
|
|
67
|
+
let domain;
|
|
68
|
+
try { domain = normalizeDomain(target.host); } catch (e) { continue; }
|
|
69
|
+
if (!out.includes(domain)) out.push(domain);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 判断“被拦的是不是这个 Agent 自己的模型地址”用的:只认该 Agent 的 BASE_URL 变量(配了)或官方默认域名(没配)。
|
|
77
|
+
* 比 modelDomains 窄:env 里别的 URL(npm 源、pip 源……)被拦不能当成模型地址去中止一轮。
|
|
78
|
+
*/
|
|
79
|
+
function agentModelDomains(envLines, agentId) {
|
|
80
|
+
const spec = AGENT_ENV_SCHEMA[agentId];
|
|
81
|
+
if (!spec) return [];
|
|
82
|
+
const out = [];
|
|
83
|
+
let configured = '';
|
|
84
|
+
for (const line of envLines || []) {
|
|
85
|
+
const text = String(line);
|
|
86
|
+
const i = text.indexOf('=');
|
|
87
|
+
if (i > 0 && text.slice(0, i) === spec.baseUrlKey) configured = text.slice(i + 1);
|
|
88
|
+
}
|
|
89
|
+
const urls = configured ? [configured] : spec.baseUrlPresets.map(item => item.value);
|
|
90
|
+
for (const value of urls) {
|
|
91
|
+
const target = urlOf(value);
|
|
92
|
+
if (!target || net.isIP(target.host) || HOST_ALIASES.has(target.host)) continue;
|
|
93
|
+
try {
|
|
94
|
+
const domain = normalizeDomain(target.host);
|
|
95
|
+
if (!out.includes(domain)) out.push(domain);
|
|
96
|
+
} catch (e) { /* 非法域名忽略 */ }
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
46
101
|
/**
|
|
47
102
|
* @param {string[]} envLines KEY=VALUE(创建时的用户 env)
|
|
48
103
|
* @param {(host: string) => Promise<string[]>} [lookup]
|
|
@@ -95,4 +150,4 @@ async function withEnvEndpoints(policy, envLines, lookup) {
|
|
|
95
150
|
return normalizePolicy({ ...policy, outbound: [...policy.outbound, ...added] });
|
|
96
151
|
}
|
|
97
152
|
|
|
98
|
-
module.exports = { inferEnvEndpoints, withEnvEndpoints, envUrlDomains };
|
|
153
|
+
module.exports = { inferEnvEndpoints, withEnvEndpoints, envUrlDomains, modelDomains, agentModelDomains };
|
package/lib/plugin/playwright.js
CHANGED
|
@@ -935,12 +935,19 @@ class PlaywrightPlugin {
|
|
|
935
935
|
const runtimeCommand = this.runtimeCommand();
|
|
936
936
|
const integration = await this.buildContainerIntegration({ runtimeCommand });
|
|
937
937
|
const name = `manyoyo-probe-${process.pid}`;
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
938
|
+
const buildArgs = withInit => {
|
|
939
|
+
const args = ['run', '--rm', ...(withInit ? ['--init'] : []), '--name', name, '--shm-size=1g', ...integration.extraArgs];
|
|
940
|
+
for (const volume of integration.volumes) args.push('--volume', volume);
|
|
941
|
+
for (const entry of integration.env) args.push('--env', entry);
|
|
942
|
+
args.push(image, 'sh', '-c', CONTAINER_PROBE_SCRIPT);
|
|
943
|
+
return args;
|
|
944
|
+
};
|
|
942
945
|
const started = Date.now();
|
|
943
|
-
|
|
946
|
+
let result = this.runtimeCall(buildArgs(true), { timeout: CONTAINER_PROBE_TIMEOUT_MS });
|
|
947
|
+
// podman 的 --init 要宿主机装了 catatonit(没装报 `lookup init binary`),不是浏览器的问题:不带 --init 再来一次
|
|
948
|
+
if (result.status !== 0 && /catatonit|init binary/i.test(`${result.stdout}${result.stderr}`)) {
|
|
949
|
+
result = this.runtimeCall(buildArgs(false), { timeout: CONTAINER_PROBE_TIMEOUT_MS });
|
|
950
|
+
}
|
|
944
951
|
if (result.status !== 0) this.runtimeCall(['rm', '-f', name]); // 超时时容器客户端被杀,容器本身可能还在
|
|
945
952
|
if (result.status === 0 && /manyoyo-ok/.test(result.stdout)) {
|
|
946
953
|
this.out(`真实探测: 通过(default,${Date.now() - started}ms)`);
|