agent-dual-proxy 0.0.0-stage → 0.7.1
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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +140 -2
- package/SECURITY.md +23 -0
- package/bin/agent-gateway.js +261 -0
- package/bin/dual-proxy.js +195 -0
- package/contrib/dual-proxy.service +17 -0
- package/docs/agents.md +94 -0
- package/package.json +54 -4
- package/src/agents.js +61 -0
- package/src/engines.js +25 -0
- package/src/gateways.js +163 -0
- package/src/paths.js +194 -0
- package/src/pref.js +40 -0
- package/src/retry-policy.js +43 -0
- package/src/server.js +532 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createDispatcher } from '../src/server.js';
|
|
6
|
+
import { collectGateways, isLoopbackHost, resolveUpstream } from '../src/gateways.js';
|
|
7
|
+
import { assertNode } from '../src/engines.js';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const pkg = require('../package.json');
|
|
11
|
+
|
|
12
|
+
/** 参数错误:打印一行并以退出码 2 结束。 */
|
|
13
|
+
export class UsageError extends Error {}
|
|
14
|
+
|
|
15
|
+
function usage() {
|
|
16
|
+
process.stdout.write([
|
|
17
|
+
'dual-proxy ' + pkg.version,
|
|
18
|
+
'',
|
|
19
|
+
'Usage: dual-proxy [options]',
|
|
20
|
+
'',
|
|
21
|
+
'Options:',
|
|
22
|
+
' --listen-host <host> default 127.0.0.1',
|
|
23
|
+
' --listen-port <port> default 7898',
|
|
24
|
+
' --upstream-host <host> default 127.0.0.1',
|
|
25
|
+
' --upstream-port <port> default 7897',
|
|
26
|
+
' --upstream-url <url> full upstream URL, may include user:pass',
|
|
27
|
+
' --gateway <host:port> extra candidate gateway, repeatable',
|
|
28
|
+
' --health-url <url> verify the channel with this URL, default off',
|
|
29
|
+
' --health-interval-ms <ms> default 60000, 0 disables',
|
|
30
|
+
' --fast-fail-ms <ms> timeout for the known path, default 2000',
|
|
31
|
+
' --cooldown-ms <ms> failed gateways rest this long, default 30000',
|
|
32
|
+
' --timeout-ms <ms> per-attempt timeout, default 5000',
|
|
33
|
+
' --response-timeout-ms <ms> header wait for plain HTTP, default 60000',
|
|
34
|
+
' --pref-ttl-ms <ms> per-host preference TTL, default 60000',
|
|
35
|
+
' --no-verify-tls skip the TLS reachability probe on 443',
|
|
36
|
+
' --retry-non-idempotent-after-send replay POST/PATCH after the body may have',
|
|
37
|
+
' been sent (dangerous; default off)',
|
|
38
|
+
' --retry-non-idempotent-with-idempotency-key',
|
|
39
|
+
' replay POST/PATCH after send when Idempotency-Key is present',
|
|
40
|
+
' -h, --help show this help',
|
|
41
|
+
' -v, --version print version',
|
|
42
|
+
'',
|
|
43
|
+
'Env fallbacks: LISTEN_HOST / LISTEN_PORT / UPSTREAM_HOST / UPSTREAM_PORT / UPSTREAM_URL /',
|
|
44
|
+
' GATEWAY / HEALTH_URL / HEALTH_INTERVAL_MS / FAST_FAIL_MS /',
|
|
45
|
+
' COOLDOWN_MS / ATTEMPT_TIMEOUT_MS / RESPONSE_TIMEOUT_MS /',
|
|
46
|
+
' PREF_TTL_MS / VERIFY_TLS=0 / RETRY_NON_IDEMPOTENT_AFTER_SEND=1 /',
|
|
47
|
+
' RETRY_NON_IDEMPOTENT_WITH_IDEMPOTENCY_KEY=1',
|
|
48
|
+
''
|
|
49
|
+
].join('\n'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function envValue(env, name) {
|
|
53
|
+
const value = env[name];
|
|
54
|
+
return value === undefined || value === '' ? undefined : value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 解析整数并校验范围;失败抛 UsageError。 */
|
|
58
|
+
function integer(label, raw, min, max) {
|
|
59
|
+
const value = Number(raw);
|
|
60
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
61
|
+
throw new UsageError('invalid value for ' + label + ': ' + raw + ' (expected integer ' + min + '..' + max + ')');
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 解析 CLI 参数;args 不含 node 与脚本路径。 */
|
|
67
|
+
export function parseArgs(args, env = {}) {
|
|
68
|
+
const opts = {
|
|
69
|
+
listenHost: envValue(env, 'LISTEN_HOST') || '127.0.0.1',
|
|
70
|
+
listenPort: integer('LISTEN_PORT', envValue(env, 'LISTEN_PORT') ?? 7898, 1, 65535),
|
|
71
|
+
upstreamHost: envValue(env, 'UPSTREAM_HOST') || '127.0.0.1',
|
|
72
|
+
upstreamPort: integer('UPSTREAM_PORT', envValue(env, 'UPSTREAM_PORT') ?? 7897, 1, 65535),
|
|
73
|
+
upstreamUrl: envValue(env, 'UPSTREAM_URL') || '',
|
|
74
|
+
attemptTimeoutMs: integer('ATTEMPT_TIMEOUT_MS', envValue(env, 'ATTEMPT_TIMEOUT_MS') ?? 5000, 100, 60000),
|
|
75
|
+
responseTimeoutMs: integer('RESPONSE_TIMEOUT_MS', envValue(env, 'RESPONSE_TIMEOUT_MS') ?? 60000, 100, 600000),
|
|
76
|
+
prefTtlMs: integer('PREF_TTL_MS', envValue(env, 'PREF_TTL_MS') ?? 60000, 0, 3600000),
|
|
77
|
+
gateways: envValue(env, 'GATEWAY') ? [envValue(env, 'GATEWAY')] : [],
|
|
78
|
+
healthUrl: env.HEALTH_URL ?? '',
|
|
79
|
+
healthIntervalMs: integer('HEALTH_INTERVAL_MS', envValue(env, 'HEALTH_INTERVAL_MS') ?? 60000, 0, 3600000),
|
|
80
|
+
fastFailTimeoutMs: integer('FAST_FAIL_MS', envValue(env, 'FAST_FAIL_MS') ?? 2000, 100, 60000),
|
|
81
|
+
cooldownMs: integer('COOLDOWN_MS', envValue(env, 'COOLDOWN_MS') ?? 30000, 0, 600000),
|
|
82
|
+
retryNonIdempotentAfterSend: env.RETRY_NON_IDEMPOTENT_AFTER_SEND === '1',
|
|
83
|
+
retryNonIdempotentWithIdempotencyKey: env.RETRY_NON_IDEMPOTENT_WITH_IDEMPOTENCY_KEY === '1',
|
|
84
|
+
verifyTls: env.VERIFY_TLS !== '0'
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
88
|
+
const arg = args[i];
|
|
89
|
+
const next = () => {
|
|
90
|
+
i += 1;
|
|
91
|
+
if (i >= args.length) throw new UsageError('missing value for ' + arg);
|
|
92
|
+
return args[i];
|
|
93
|
+
};
|
|
94
|
+
switch (arg) {
|
|
95
|
+
case '--listen-host': opts.listenHost = next(); break;
|
|
96
|
+
case '--listen-port': opts.listenPort = integer(arg, next(), 1, 65535); break;
|
|
97
|
+
case '--upstream-host': opts.upstreamHost = next(); break;
|
|
98
|
+
case '--upstream-port': opts.upstreamPort = integer(arg, next(), 1, 65535); break;
|
|
99
|
+
case '--upstream-url': opts.upstreamUrl = next(); break;
|
|
100
|
+
case '--gateway': opts.gateways.push(next()); break;
|
|
101
|
+
case '--health-url': opts.healthUrl = next(); break;
|
|
102
|
+
case '--health-interval-ms': opts.healthIntervalMs = integer(arg, next(), 0, 3600000); break;
|
|
103
|
+
case '--fast-fail-ms': opts.fastFailTimeoutMs = integer(arg, next(), 100, 60000); break;
|
|
104
|
+
case '--cooldown-ms': opts.cooldownMs = integer(arg, next(), 0, 600000); break;
|
|
105
|
+
case '--timeout-ms': opts.attemptTimeoutMs = integer(arg, next(), 100, 60000); break;
|
|
106
|
+
case '--response-timeout-ms': opts.responseTimeoutMs = integer(arg, next(), 100, 600000); break;
|
|
107
|
+
case '--pref-ttl-ms': opts.prefTtlMs = integer(arg, next(), 0, 3600000); break;
|
|
108
|
+
case '--no-verify-tls': opts.verifyTls = false; break;
|
|
109
|
+
case '--retry-non-idempotent-after-send': opts.retryNonIdempotentAfterSend = true; break;
|
|
110
|
+
case '--retry-non-idempotent-with-idempotency-key': opts.retryNonIdempotentWithIdempotencyKey = true; break;
|
|
111
|
+
case '-h':
|
|
112
|
+
case '--help': opts.help = true; break;
|
|
113
|
+
case '-v':
|
|
114
|
+
case '--version': opts.version = true; break;
|
|
115
|
+
default: throw new UsageError('unknown option: ' + arg);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (opts.upstreamUrl) {
|
|
120
|
+
// upstreamUrl 是显式的第一个候选:非法值 fail fast,由 resolveUpstream 给出脱敏原因。
|
|
121
|
+
try {
|
|
122
|
+
resolveUpstream(opts);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
throw new UsageError(err && err.message ? err.message : 'invalid upstreamUrl (userinfo redacted)');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// --gateway/GATEWAY 与 gateways 配置一样,交给 collectGateways 做 warning + skip;
|
|
128
|
+
// 非法额外候选不应阻止启动或悄悄回退。
|
|
129
|
+
return opts;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
try {
|
|
134
|
+
assertNode();
|
|
135
|
+
const opts = parseArgs(process.argv.slice(2), process.env);
|
|
136
|
+
if (opts.help) { usage(); return; }
|
|
137
|
+
if (opts.version) { process.stdout.write(pkg.version + '\n'); return; }
|
|
138
|
+
let upstream;
|
|
139
|
+
try {
|
|
140
|
+
upstream = resolveUpstream(opts);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
throw new UsageError(err && err.message ? err.message : 'invalid upstreamUrl (userinfo redacted)');
|
|
143
|
+
}
|
|
144
|
+
if (!isLoopbackHost(opts.listenHost)) {
|
|
145
|
+
process.stderr.write('warning: listening on ' + opts.listenHost + '; this proxy has no authentication\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const gateways = collectGateways({
|
|
149
|
+
upstream,
|
|
150
|
+
extra: opts.gateways,
|
|
151
|
+
env: process.env,
|
|
152
|
+
self: { host: opts.listenHost, port: opts.listenPort },
|
|
153
|
+
onInvalid: (subject, reason) => process.stderr.write('warning: ' + subject + ': ' + reason + '\n')
|
|
154
|
+
});
|
|
155
|
+
const dispatcher = createDispatcher(Object.assign({}, opts, {
|
|
156
|
+
upstreamHost: upstream.host,
|
|
157
|
+
upstreamPort: upstream.port,
|
|
158
|
+
upstreamAuth: upstream.auth,
|
|
159
|
+
gateways
|
|
160
|
+
}));
|
|
161
|
+
const addr = await dispatcher.listen();
|
|
162
|
+
dispatcher.startHealth();
|
|
163
|
+
process.stderr.write('[dual-proxy] listening on ' + addr.address + ':' + addr.port +
|
|
164
|
+
' gateways=' + gateways.map((g) => g.host + ':' + g.port).concat('direct').join(',') +
|
|
165
|
+
(opts.healthUrl ? ' health=' + opts.healthUrl : '') + '\n');
|
|
166
|
+
|
|
167
|
+
const shutdown = () => {
|
|
168
|
+
dispatcher.close().then(() => process.exit(0));
|
|
169
|
+
};
|
|
170
|
+
process.on('SIGINT', shutdown);
|
|
171
|
+
process.on('SIGTERM', shutdown);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err instanceof UsageError) {
|
|
174
|
+
process.stderr.write(err.message + '\n');
|
|
175
|
+
process.exitCode = 2;
|
|
176
|
+
} else {
|
|
177
|
+
process.stderr.write('error: ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
178
|
+
process.exitCode = 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 入口判断:npm 通过符号链接调用时,argv[1] 与真实路径不同,必须 realpath 比较。 */
|
|
184
|
+
function isMain() {
|
|
185
|
+
if (!process.argv[1]) return false;
|
|
186
|
+
try {
|
|
187
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (isMain()) {
|
|
194
|
+
main();
|
|
195
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=agent-dual-proxy (upstream proxy first, direct on failure)
|
|
3
|
+
After=network.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=simple
|
|
7
|
+
ExecStart=/usr/bin/env node %h/agent-dual-proxy/bin/dual-proxy.js
|
|
8
|
+
Restart=always
|
|
9
|
+
RestartSec=2
|
|
10
|
+
Environment=LISTEN_HOST=127.0.0.1
|
|
11
|
+
Environment=LISTEN_PORT=7898
|
|
12
|
+
Environment=UPSTREAM_HOST=127.0.0.1
|
|
13
|
+
Environment=UPSTREAM_PORT=7897
|
|
14
|
+
Environment=ATTEMPT_TIMEOUT_MS=5000
|
|
15
|
+
|
|
16
|
+
[Install]
|
|
17
|
+
WantedBy=default.target
|
package/docs/agents.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# 给主流 CLI agent 用
|
|
2
|
+
|
|
3
|
+
这些 agent 没有共用的插件 API,唯一通杀的是 `HTTP(S)_PROXY`。所以适配层就一件事:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
agent-gateway run <agent> → 起双路代理 + 把代理变量塞进子进程 + exec
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
端口已被占用时复用那一个;子进程退出后代理跟着关,不留孤儿进程。agent 的本地端口
|
|
10
|
+
(hook、LSP、语言服务器)走 `NO_PROXY` 直连,不会被代理吞掉。
|
|
11
|
+
|
|
12
|
+
注入时会替换启动环境里的 socks `ALL_PROXY`:不少客户端会优先读它,留着就绕过了
|
|
13
|
+
`HTTP(S)_PROXY`,双路选路白做。`NO_PROXY` 与用户原有的直连段合并,不覆盖。
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
node bin/agent-gateway.js run claude --resume
|
|
17
|
+
node bin/agent-gateway.js run codex
|
|
18
|
+
node bin/agent-gateway.js run --listen-port 12334 --health-url https://api.deepseek.com -- gemini
|
|
19
|
+
node bin/agent-gateway.js env # 打印 export 语句,给不支持启动器的场景用
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
内置短名只是「名字 → 可执行文件」的映射,真正的适配仍然是环境变量:
|
|
23
|
+
|
|
24
|
+
| 短名 | 可执行文件 | 说明 |
|
|
25
|
+
| :--- | :--- | :--- |
|
|
26
|
+
| `claude` | `claude` | Claude Code |
|
|
27
|
+
| `codex` | `codex` | OpenAI Codex CLI |
|
|
28
|
+
| `gemini` | `gemini` | Gemini CLI |
|
|
29
|
+
| `pi` | `pi` | pi |
|
|
30
|
+
| `aider` | `aider` | Aider |
|
|
31
|
+
| `opencode` | `opencode` | OpenCode |
|
|
32
|
+
| `qwen` | `qwen` | Qwen Code |
|
|
33
|
+
| `cursor` | `cursor-agent` | Cursor CLI |
|
|
34
|
+
| `droid` | `droid` | Factory Droid |
|
|
35
|
+
| `amp` | `amp` | Amp |
|
|
36
|
+
| `crush` | `crush` | Charm Crush |
|
|
37
|
+
| `goose` | `goose` | Goose |
|
|
38
|
+
| `copilot` | `copilot` | GitHub Copilot CLI |
|
|
39
|
+
|
|
40
|
+
不在表里的 CLI 用 `agent-gateway run -- <cmd> [args...]`,效果一样。客户端不读环境变量时,
|
|
41
|
+
这一层无能为力——见下面的排查提示。
|
|
42
|
+
|
|
43
|
+
## Claude Code
|
|
44
|
+
|
|
45
|
+
`~/.claude/settings.json` 支持 `env` 段,写进去就不用每次走启动器:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"env": {
|
|
50
|
+
"HTTPS_PROXY": "http://127.0.0.1:7898",
|
|
51
|
+
"HTTP_PROXY": "http://127.0.0.1:7898",
|
|
52
|
+
"ALL_PROXY": "http://127.0.0.1:7898",
|
|
53
|
+
"NO_PROXY": "localhost,127.0.0.1,::1,[::1]"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
先有代理在听(`agent-gateway run claude` 会自己起;或 `dual-proxy --listen-port 7898`)。
|
|
59
|
+
|
|
60
|
+
## Codex CLI
|
|
61
|
+
|
|
62
|
+
`~/.codex/config.toml` 没有代理字段,只能用环境变量,所以走启动器:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
agent-gateway run codex
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Gemini CLI
|
|
69
|
+
|
|
70
|
+
同样只有环境变量这一条路:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
agent-gateway run gemini
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
如果该版本的内置 HTTP 客户端不读环境变量(表现为请求完全没经过 7898,`ss -tnp | grep 7898` 为空),
|
|
77
|
+
就把它放到系统代理或 TUN 之下——那不是这一层能解决的,别在本插件里加特例。
|
|
78
|
+
|
|
79
|
+
## pi
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
agent-gateway run pi
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
pi 自己的长会话里若中途换了网络,重启会话比热改环境可靠:进程环境是启动快照,
|
|
86
|
+
`collectGateways` 也只读一次。
|
|
87
|
+
|
|
88
|
+
## 通用命令
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
agent-gateway run -- <任意命令> [args...]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
任何尊重 `HTTP(S)_PROXY` 的 CLI 都能这样套上,不限于上表。
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dual-proxy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Local dual-path proxy for CLI agents: auto-selects a gateway that reaches the model endpoint, verifies the channel, fails over to direct",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/server.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/server.js",
|
|
10
|
+
"./agents": "./src/agents.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/gambn/agent-dual-proxy.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/gambn/agent-dual-proxy#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/gambn/agent-dual-proxy/issues"
|
|
20
|
+
},
|
|
21
|
+
"author": "Gambn",
|
|
22
|
+
"bin": {
|
|
23
|
+
"agent-gateway": "bin/agent-gateway.js",
|
|
24
|
+
"dual-proxy": "bin/dual-proxy.js"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/*.test.js",
|
|
28
|
+
"start": "node bin/dual-proxy.js"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"undici": "8.12.3"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.19.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"bin",
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"README.en.md",
|
|
41
|
+
"CHANGELOG.md",
|
|
42
|
+
"SECURITY.md",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"docs",
|
|
45
|
+
"contrib"
|
|
46
|
+
],
|
|
47
|
+
"keywords": [
|
|
48
|
+
"proxy",
|
|
49
|
+
"failover",
|
|
50
|
+
"cli-agent",
|
|
51
|
+
"claude-code",
|
|
52
|
+
"codex",
|
|
53
|
+
"gemini",
|
|
54
|
+
"health-check"
|
|
55
|
+
]
|
|
56
|
+
}
|
package/src/agents.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主流 AGENT 的适配表与环境构造。
|
|
3
|
+
*
|
|
4
|
+
* 这些 CLI agent 没有共用的插件 API,唯一通杀的是 HTTP(S)_PROXY 环境变量,
|
|
5
|
+
* 所以适配层只做两件事:起一个双路代理,把变量塞进子进程环境里再 exec。
|
|
6
|
+
*/
|
|
7
|
+
export const AGENTS = {
|
|
8
|
+
claude: { bin: 'claude', note: 'Claude Code' },
|
|
9
|
+
codex: { bin: 'codex', note: 'OpenAI Codex CLI' },
|
|
10
|
+
gemini: { bin: 'gemini', note: 'Gemini CLI' },
|
|
11
|
+
pi: { bin: 'pi', note: 'pi' },
|
|
12
|
+
aider: { bin: 'aider', note: 'Aider' },
|
|
13
|
+
opencode: { bin: 'opencode', note: 'OpenCode' },
|
|
14
|
+
qwen: { bin: 'qwen', note: 'Qwen Code' },
|
|
15
|
+
cursor: { bin: 'cursor-agent', note: 'Cursor CLI' },
|
|
16
|
+
droid: { bin: 'droid', note: 'Factory Droid' },
|
|
17
|
+
amp: { bin: 'amp', note: 'Amp' },
|
|
18
|
+
crush: { bin: 'crush', note: 'Charm Crush' },
|
|
19
|
+
goose: { bin: 'goose', note: 'Goose' },
|
|
20
|
+
copilot: { bin: 'copilot', note: 'GitHub Copilot CLI' }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** 适配表里的短名,供用法/报错文本使用。 */
|
|
24
|
+
export const AGENT_NAMES = Object.keys(AGENTS);
|
|
25
|
+
|
|
26
|
+
/** 按名字取适配项;不认识的返回 null,由 CLI 负责报错。 */
|
|
27
|
+
export function resolveAgent(name) {
|
|
28
|
+
if (typeof name !== 'string') return null;
|
|
29
|
+
return AGENTS[name.trim().toLowerCase()] || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function proxyUrl({ listenHost = '127.0.0.1', listenPort }) {
|
|
33
|
+
const loopback = listenHost === '0.0.0.0' || listenHost === '::' ? '127.0.0.1' : listenHost;
|
|
34
|
+
const host = loopback.includes(':') && !loopback.startsWith('[') ? '[' + loopback + ']' : loopback;
|
|
35
|
+
return 'http://' + host + ':' + listenPort;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 回环地址一律直连:agent 自己的本地端口(hook、LSP、语言服务器)不能被代理吞掉。 */
|
|
39
|
+
const LOCAL_BYPASS = ['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 生成子进程环境:代理变量大小写各一份,其余原样继承。
|
|
43
|
+
*
|
|
44
|
+
* ALL_PROXY 也指向本代理:启动环境里的 socks ALL_PROXY 会让客户端绕过
|
|
45
|
+
* HTTP(S)_PROXY,双路选路就白做了。NO_PROXY 是合并而不是覆盖,避免丢掉
|
|
46
|
+
* 用户原有的内网直连段。
|
|
47
|
+
*/
|
|
48
|
+
export function buildProxyEnv(url, base = process.env) {
|
|
49
|
+
const env = { ...base };
|
|
50
|
+
for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy']) {
|
|
51
|
+
env[key] = url;
|
|
52
|
+
}
|
|
53
|
+
const noProxy = [...LOCAL_BYPASS];
|
|
54
|
+
for (const item of String(base.NO_PROXY ?? base.no_proxy ?? '').split(',')) {
|
|
55
|
+
const value = item.trim();
|
|
56
|
+
if (value && !noProxy.includes(value)) noProxy.push(value);
|
|
57
|
+
}
|
|
58
|
+
env.NO_PROXY = noProxy.join(',');
|
|
59
|
+
env.no_proxy = env.NO_PROXY;
|
|
60
|
+
return env;
|
|
61
|
+
}
|
package/src/engines.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Node 版本门槛:undici 8 要求 >= 22.19.0。 */
|
|
2
|
+
export const MIN_NODE = '22.19.0';
|
|
3
|
+
|
|
4
|
+
function parts(version) {
|
|
5
|
+
return String(version)
|
|
6
|
+
.split('-')[0]
|
|
7
|
+
.split('.')
|
|
8
|
+
.map((value) => Number.parseInt(value, 10) || 0);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 版本比较;只比较 major.minor.patch。 */
|
|
12
|
+
export function nodeAtLeast(version, min = MIN_NODE) {
|
|
13
|
+
const [a, b, c] = parts(version);
|
|
14
|
+
const [x, y, z] = parts(min);
|
|
15
|
+
if (a !== x) return a > x;
|
|
16
|
+
if (b !== y) return b > y;
|
|
17
|
+
return c >= z;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 低于门槛时抛出可读错误,避免在 undici 里报出看不懂的运行期错误。 */
|
|
21
|
+
export function assertNode(version = process.versions.node) {
|
|
22
|
+
if (!nodeAtLeast(version)) {
|
|
23
|
+
throw new Error('agent-dual-proxy requires Node >= ' + MIN_NODE + ' (current ' + version + ')');
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/gateways.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** 网关候选:把配置与启动环境里的代理地址归一化成 {host, port, auth?}。 */
|
|
2
|
+
|
|
3
|
+
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '0.0.0.0']);
|
|
4
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
5
|
+
const ENV_NAMES = ['https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY', 'http_proxy', 'HTTP_PROXY'];
|
|
6
|
+
|
|
7
|
+
/** upstreamUrl 解析失败:消息里不含原始 URL 或 userinfo。 */
|
|
8
|
+
export class UpstreamError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'UpstreamError';
|
|
12
|
+
this.code = 'EUPSTREAMURL';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 把 userinfo 编成 Proxy-Authorization 的 Basic 值;没有凭证回 null。 */
|
|
17
|
+
function basicAuth(url) {
|
|
18
|
+
if (!url.username && !url.password) return null;
|
|
19
|
+
try {
|
|
20
|
+
const user = decodeURIComponent(url.username);
|
|
21
|
+
const pass = decodeURIComponent(url.password);
|
|
22
|
+
return 'Basic ' + Buffer.from(user + ':' + pass).toString('base64');
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解析 `host:port`、`http://host:port` 或带 userinfo 的 HTTP 代理 URL,失败回 null。
|
|
30
|
+
* 0.7.0 只支持明文 HTTP CONNECT 代理;`https://` 一律拒绝,避免把 CONNECT 明文发给
|
|
31
|
+
* 实际期望 TLS 的端口。
|
|
32
|
+
*/
|
|
33
|
+
export function parseGateway(value) {
|
|
34
|
+
if (value === undefined || value === null) return null;
|
|
35
|
+
const text = String(value).trim();
|
|
36
|
+
if (!text) return null;
|
|
37
|
+
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(text.includes('://') ? text : 'http://' + text);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (url.protocol !== 'http:') return null;
|
|
45
|
+
|
|
46
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
47
|
+
const port = Number(url.port || 80);
|
|
48
|
+
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
49
|
+
|
|
50
|
+
const auth = basicAuth(url);
|
|
51
|
+
if ((url.username || url.password) && !auth) return null;
|
|
52
|
+
return auth ? { host, port, auth } : { host, port };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 去掉 userinfo 的展示形式;只用于警告/错误,不用于连接。 */
|
|
56
|
+
export function redactGateway(value) {
|
|
57
|
+
const text = String(value === undefined || value === null ? '' : value).trim();
|
|
58
|
+
if (!text) return '<empty>';
|
|
59
|
+
try {
|
|
60
|
+
const url = new URL(text.includes('://') ? text : 'http://' + text);
|
|
61
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
62
|
+
if (!host) return '<invalid>';
|
|
63
|
+
const shown = host.includes(':') ? '[' + host + ']' : host;
|
|
64
|
+
return url.protocol + '//' + shown + (url.port ? ':' + url.port : '');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return '<invalid>';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 拒绝原因:https 与普通非法值分开,便于调用方给出可操作的提示。 */
|
|
71
|
+
function rejectReason(value) {
|
|
72
|
+
return /^\s*https:\/\//i.test(String(value === undefined || value === null ? '' : value))
|
|
73
|
+
? 'https upstream proxy is not supported'
|
|
74
|
+
: 'invalid gateway';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 汇总候选网关:配置的上游、额外网关、启动环境里的代理,去重并排除自身监听端口。
|
|
79
|
+
* `direct` 不在这里,由调用方补在最后。
|
|
80
|
+
*
|
|
81
|
+
* 同一 host:port 只保留一条,但会合并凭证:无 auth + 有 auth → 补上;
|
|
82
|
+
* 两边都有且不同 → 保留第一条并回调 warning(subject 已脱敏)。
|
|
83
|
+
*/
|
|
84
|
+
export function collectGateways({ upstream, extra = [], env = {}, self, onInvalid } = {}) {
|
|
85
|
+
const list = [];
|
|
86
|
+
const warn = (subject, reason) => {
|
|
87
|
+
if (typeof onInvalid === 'function') onInvalid(subject, reason);
|
|
88
|
+
};
|
|
89
|
+
const add = (gateway, raw) => {
|
|
90
|
+
const fallback = gateway && gateway.host
|
|
91
|
+
? String(gateway.host) + ':' + String(gateway.port)
|
|
92
|
+
: raw;
|
|
93
|
+
if (!gateway) {
|
|
94
|
+
warn(redactGateway(fallback), rejectReason(fallback));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const host = String(gateway.host || '');
|
|
98
|
+
const port = Number(gateway.port);
|
|
99
|
+
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
100
|
+
warn(redactGateway(fallback), 'invalid gateway');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const isSelf = self !== undefined && self !== null && port === Number(self.port) &&
|
|
104
|
+
LOOPBACK.has(host) && LOOPBACK.has(String(self.host || ''));
|
|
105
|
+
if (isSelf) return;
|
|
106
|
+
|
|
107
|
+
const existing = list.find((item) => item.host === host && item.port === port);
|
|
108
|
+
if (existing) {
|
|
109
|
+
if (!existing.auth && gateway.auth) {
|
|
110
|
+
existing.auth = gateway.auth;
|
|
111
|
+
} else if (existing.auth && gateway.auth && existing.auth !== gateway.auth) {
|
|
112
|
+
warn(host + ':' + port, 'conflicting proxy credentials; keeping first');
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
list.push(gateway.auth ? { host, port, auth: gateway.auth } : { host, port });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (upstream) add(typeof upstream === 'string' ? parseGateway(upstream) : upstream, upstream);
|
|
120
|
+
for (const item of extra) {
|
|
121
|
+
add(typeof item === 'string' ? parseGateway(item) : item, item);
|
|
122
|
+
}
|
|
123
|
+
for (const name of ENV_NAMES) {
|
|
124
|
+
if (env[name] !== undefined && env[name] !== null && String(env[name]).trim()) {
|
|
125
|
+
add(parseGateway(env[name]), env[name]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return list;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 解析上游:upstreamUrl 非空时替换 host/port/auth;为空时沿用 host/port。
|
|
133
|
+
* 非法值抛 UpstreamError(消息已脱敏),由 index/CLI 分别转成日志或退出码 2。
|
|
134
|
+
*/
|
|
135
|
+
export function resolveUpstream({ upstreamUrl, upstreamHost = '127.0.0.1', upstreamPort = 7897 } = {}) {
|
|
136
|
+
const text = String(upstreamUrl === undefined || upstreamUrl === null ? '' : upstreamUrl).trim();
|
|
137
|
+
if (text) {
|
|
138
|
+
if (/^https:\/\//i.test(text)) {
|
|
139
|
+
throw new UpstreamError('invalid upstreamUrl: https upstream proxy is not supported (userinfo redacted)');
|
|
140
|
+
}
|
|
141
|
+
const parsed = parseGateway(text);
|
|
142
|
+
if (!parsed) throw new UpstreamError('invalid upstreamUrl (userinfo redacted)');
|
|
143
|
+
return parsed;
|
|
144
|
+
}
|
|
145
|
+
const host = String(upstreamHost || '127.0.0.1');
|
|
146
|
+
const port = Number(upstreamPort);
|
|
147
|
+
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
148
|
+
throw new UpstreamError('invalid upstreamHost/upstreamPort');
|
|
149
|
+
}
|
|
150
|
+
return { host, port };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 这个 host 是不是回环地址(用于“非回环绑定”告警)。 */
|
|
154
|
+
export function isLoopbackHost(host) {
|
|
155
|
+
return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 归一化成候选列表(最后一条 direct),供 dispatcher 使用。 */
|
|
159
|
+
export function buildCandidates(upstream, gateways = [], self, onInvalid) {
|
|
160
|
+
return collectGateways({ upstream, extra: gateways, self, onInvalid })
|
|
161
|
+
.map((gateway) => Object.assign({ id: gateway.host + ':' + gateway.port }, gateway))
|
|
162
|
+
.concat({ id: 'direct', direct: true });
|
|
163
|
+
}
|