@xcanwin/manyoyo 8.6.11 → 8.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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');
@@ -60,18 +61,14 @@ const {
60
61
  formatServeLogValue,
61
62
  getServeProcessSnapshot
62
63
  } = require('../lib/serve-log');
64
+ const { formatNow, resolveQuickChatPath, expandTemplate } = require('../lib/name-template');
63
65
  const { version: BIN_VERSION, imageVersion: IMAGE_VERSION_DEFAULT } = require('../package.json');
64
66
  const IMAGE_VERSION_BASE = String(IMAGE_VERSION_DEFAULT || '1.0.0').split('-')[0];
65
67
  const IMAGE_VERSION_HELP_EXAMPLE = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
66
68
 
67
- // Helper function to format date like bash $(date +%m%d-%H%M)
69
+ // {now} 与默认容器名用的时间:年月日-时分秒(命令行与网页共用 lib/name-template.js)
68
70
  function formatDate() {
69
- const now = new Date();
70
- const month = String(now.getMonth() + 1).padStart(2, '0');
71
- const day = String(now.getDate()).padStart(2, '0');
72
- const hour = String(now.getHours()).padStart(2, '0');
73
- const minute = String(now.getMinutes()).padStart(2, '0');
74
- return `${month}${day}-${hour}${minute}`;
71
+ return formatNow();
75
72
  }
76
73
 
77
74
  function detectCommandName() {
@@ -94,6 +91,7 @@ const CONFIG = {
94
91
  // Default configuration
95
92
  let CONTAINER_NAME = `my-${formatDate()}`;
96
93
  let HOST_PATH = process.cwd();
94
+ let HOST_PATH_TEMPLATED = false;
97
95
  let CONTAINER_PATH = HOST_PATH;
98
96
  let IMAGE_NAME = "ghcr.io/xcanwin/manyoyo";
99
97
  let IMAGE_VERSION = IMAGE_VERSION_DEFAULT || `${IMAGE_VERSION_BASE}-common`;
@@ -158,11 +156,7 @@ function normalizeCommandSuffix(suffix) {
158
156
  }
159
157
 
160
158
  function resolveContainerNameTemplate(name) {
161
- if (typeof name !== 'string') {
162
- return name;
163
- }
164
- const nowValue = formatDate();
165
- return name.replace(/\{now\}|\$\{now\}/g, nowValue);
159
+ return expandTemplate(name, { now: formatDate() }).value;
166
160
  }
167
161
 
168
162
  function pickConfigValue(...values) {
@@ -1597,6 +1591,7 @@ https://github.com/xcanwin/manyoyo
1597
1591
  isServerStopMode,
1598
1592
  pickConfigValue,
1599
1593
  resolveContainerNameTemplate,
1594
+ templateVars: { now: formatDate(), quickChatPath: resolveQuickChatPath(config) },
1600
1595
  normalizeCommandSuffix,
1601
1596
  normalizeJsonEnvMap,
1602
1597
  normalizeCliEnvMap,
@@ -1607,6 +1602,7 @@ https://github.com/xcanwin/manyoyo
1607
1602
  });
1608
1603
 
1609
1604
  HOST_PATH = resolvedRuntime.hostPath;
1605
+ HOST_PATH_TEMPLATED = resolvedRuntime.hostPathTemplated === true;
1610
1606
  CONTAINER_NAME = resolvedRuntime.containerName;
1611
1607
  CONTAINER_PATH = resolvedRuntime.containerPath;
1612
1608
  IMAGE_NAME = resolvedRuntime.imageName;
@@ -1826,6 +1822,7 @@ function createRuntimeContext(modeState = {}) {
1826
1822
  return {
1827
1823
  containerName: CONTAINER_NAME,
1828
1824
  hostPath: HOST_PATH,
1825
+ hostPathTemplated: HOST_PATH_TEMPLATED,
1829
1826
  containerPath: CONTAINER_PATH,
1830
1827
  imageName: IMAGE_NAME,
1831
1828
  imageVersion: IMAGE_VERSION,
@@ -1876,6 +1873,10 @@ function handleRemoveContainer(runtime) {
1876
1873
  }
1877
1874
 
1878
1875
  function validateHostPath(runtime) {
1876
+ // 目录是按模板({now} / {quickChatPath})生成的:此刻才建,只在真的要用它的时候建
1877
+ if (runtime.hostPathTemplated && !fs.existsSync(runtime.hostPath)) {
1878
+ fs.mkdirSync(runtime.hostPath, { recursive: true });
1879
+ }
1879
1880
  if (!fs.existsSync(runtime.hostPath)) {
1880
1881
  console.log(`${RED}⚠️ 错误: 宿主机路径不存在: ${runtime.hostPath}${NC}`);
1881
1882
  process.exit(1);
@@ -2352,7 +2353,7 @@ async function executeInContainer(runtime, defaultCommand) {
2352
2353
  const exec = buildExecArgs({ homeDir: os.homedir(), dockerExecArgs }, runtime.containerName, {
2353
2354
  interactive: true,
2354
2355
  tty: true,
2355
- command: runtime.execCommand ? ['/bin/bash', '-c', runtime.execCommand] : ['/bin/bash']
2356
+ command: runtime.execCommand ? ['/bin/bash', '-c', withCodexBaseUrl(runtime.execCommand)] : ['/bin/bash']
2356
2357
  });
2357
2358
  try {
2358
2359
  spawnSync(`${DOCKER_CMD}`, exec.args, { stdio: 'inherit', env: DOCKER_ENV });
@@ -2574,6 +2575,7 @@ async function runWebServerMode(runtime) {
2574
2575
  importState: () => readImportState(),
2575
2576
  updateInfo: () => updateChecker.getInfo(),
2576
2577
  loginTokenDir: getLoginTokenDir(),
2578
+ authSessionsFile: authSessionsFilePath(os.homedir(), runtime.serverHost, runtime.serverPort),
2577
2579
  doctorCheck: async () => runDoctorChecks({
2578
2580
  selectRuntime: () => CONTAINER_RUNTIME,
2579
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
  };
@@ -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
 
@@ -663,5 +701,6 @@ module.exports = {
663
701
  parseHostsAndResolv,
664
702
  parseUrlHostPort,
665
703
  isHostTarget,
704
+ isMissingImageError,
666
705
  createNetworkManager
667
706
  };
@@ -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 },
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ // 配置里 containerName / hostPath / containerPath 的占位符,命令行与网页共用这一个展开函数:
7
+ // {now}(也认 ${now})→ 年月日-时分秒,如 20261024-230915
8
+ // {quickChatPath} → 快捷对话目录(serve.quickChat.path),没配置就是默认工作目录 ~/.manyoyo/work
9
+ const NOW_TOKEN = /\{now\}|\$\{now\}/g;
10
+ const QUICK_CHAT_PATH_TOKEN = /\{quickChatPath\}/g;
11
+
12
+ function formatNow(date = new Date()) {
13
+ const pad = value => String(value).padStart(2, '0');
14
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
15
+ }
16
+
17
+ function expandHome(value, homeDir = os.homedir()) {
18
+ const text = String(value || '').trim();
19
+ if (text === '~') return homeDir;
20
+ if (text.startsWith('~/')) return path.join(homeDir, text.slice(2));
21
+ return text;
22
+ }
23
+
24
+ /** 快捷对话目录的绝对路径(去掉末尾斜杠);没配置返回 defaultDir */
25
+ function resolveQuickChatPath(config, { homeDir = os.homedir(), defaultDir = path.join(os.homedir(), '.manyoyo', 'work') } = {}) {
26
+ const quickChat = config && config.serve && typeof config.serve === 'object' ? config.serve.quickChat : null;
27
+ const raw = quickChat && typeof quickChat.path === 'string' ? quickChat.path.trim() : '';
28
+ if (!raw) return defaultDir;
29
+ const resolved = path.resolve(expandHome(raw, homeDir));
30
+ return resolved;
31
+ }
32
+
33
+ /**
34
+ * 展开占位符。返回 { value, templated }:templated 表示原文里确实有占位符
35
+ * (调用方据此决定要不要替用户建目录:只有按模板生成的目录才自动建)。
36
+ */
37
+ function expandTemplate(value, vars = {}) {
38
+ if (typeof value !== 'string') return { value, templated: false };
39
+ const now = vars.now === undefined ? formatNow() : vars.now;
40
+ let templated = false;
41
+ let result = value.replace(NOW_TOKEN, () => {
42
+ templated = true;
43
+ return now;
44
+ });
45
+ result = result.replace(QUICK_CHAT_PATH_TOKEN, () => {
46
+ templated = true;
47
+ return String(vars.quickChatPath || '').replace(/\/+$/, '');
48
+ });
49
+ return { value: result, templated };
50
+ }
51
+
52
+ module.exports = { formatNow, expandHome, resolveQuickChatPath, expandTemplate };
@@ -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)`);