@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 +2 -2
- package/bin/manyoyo.js +16 -14
- package/docker/manyoyo.Dockerfile +1 -1
- package/docker/res/claude/settings.json +20 -0
- package/lib/agent-adapters/index.js +18 -1
- package/lib/container-network.js +49 -10
- package/lib/egress-sidecar.js +19 -5
- package/lib/image-agents.js +45 -0
- package/lib/init-config.js +2 -1
- package/lib/name-template.js +52 -0
- package/lib/network-endpoints.js +56 -1
- package/lib/plugin/playwright.js +12 -5
- package/lib/provider-presets.js +86 -0
- package/lib/runtime-resolver.js +13 -1
- 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 +94 -94
- package/lib/web/rewind.js +75 -0
- package/lib/web/server.js +971 -75
- package/lib/web/snapshots.js +245 -0
- package/lib/web/storage.js +444 -0
- package/lib/web/uploads.js +114 -0
- package/package.json +8 -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');
|
|
@@ -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
|
-
//
|
|
69
|
+
// {now} 与默认容器名用的时间:年月日-时分秒(命令行与网页共用 lib/name-template.js)
|
|
68
70
|
function formatDate() {
|
|
69
|
-
|
|
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
|
-
|
|
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.
|
|
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/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
|
|
|
@@ -663,5 +701,6 @@ module.exports = {
|
|
|
663
701
|
parseHostsAndResolv,
|
|
664
702
|
parseUrlHostPort,
|
|
665
703
|
isHostTarget,
|
|
704
|
+
isMissingImageError,
|
|
666
705
|
createNetworkManager
|
|
667
706
|
};
|
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 },
|
|
@@ -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 };
|
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)`);
|