@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 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.2.0-common`。更多命令见[命令速查](https://xcanwin.github.io/manyoyo/reference/cli-options)。
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.2.0-common` only if you need a custom image. More commands: [CLI reference](https://xcanwin.github.io/manyoyo/en/reference/cli-options).
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
- console.log(`${CYAN} ${label} ${outcome.info.subject}(构建于 ${outcome.info.builtAt || '未知时间'})${NC}`);
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.aliyun.com
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
- switchCurrent(appRoot, version);
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,
@@ -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
- * 返回 { status: 'same', info } 或 { status: 'installed', info, version, previous, manifest }。
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
- const url = asset ? `${tagBase(downloadBase, branch)}${asset.name}` : '';
121
- if (!asset) throw new UpdateError('NO_ASSET', `代码版 ${info.version} 里没有适合这台机器(${targetOs}-${targetArch})的升级包。`);
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 };
@@ -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.push('--entrypoint', 'cat', getImageRef(), '/etc/hosts', '/etc/resolv.conf');
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 run([
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
- getImageRef(),
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
 
@@ -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
- args.push('--entrypoint', 'node', options.imageRef(), '/app/egress-sidecar-main.js');
217
+ const tail = ['/app/egress-sidecar-main.js'];
218
+ const runWith = image => run([...args, '--entrypoint', 'node', image, ...tail], { timeout: 60000 });
217
219
  try {
218
- await run(args, { timeout: 60000 });
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 };
@@ -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 },
@@ -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 };
@@ -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 args = ['run', '--rm', '--init', '--name', name, '--shm-size=1g', ...integration.extraArgs];
939
- for (const volume of integration.volumes) args.push('--volume', volume);
940
- for (const entry of integration.env) args.push('--env', entry);
941
- args.push(image, 'sh', '-c', CONTAINER_PROBE_SCRIPT);
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
- const result = this.runtimeCall(args, { timeout: CONTAINER_PROBE_TIMEOUT_MS });
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)`);