agent-dual-proxy 0.7.1 → 0.7.2
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 +12 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/bin/agent-gateway.js +57 -15
- package/docs/agents.md +2 -1
- package/package.json +2 -5
- package/src/engines.js +3 -3
- package/src/server.js +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- 移除未使用的 `undici` 依赖(本项目只用 Node 内置 `http`/`net`/`tls`);Node 门槛从 `>=22.19.0` 降到 `>=22.0.0`。
|
|
8
|
+
- `agent-gateway` 复用监听端口前先探测身份:只有本代理才复用;被其他程序占用时报错退出(退出码 1),不再把 agent 指向非代理端口。
|
|
9
|
+
- 代理新增本地身份端点 `GET /__agent_dual_proxy__`,返回 `x-agent-dual-proxy: 1`,供端口复用探测与运维检查使用。
|
|
10
|
+
|
|
11
|
+
### Tests
|
|
12
|
+
|
|
13
|
+
- 新增身份端点、端口复用身份探测用例;更新 Node 门槛用例。
|
|
14
|
+
|
|
3
15
|
## 0.7.1
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/README.en.md
CHANGED
|
@@ -18,7 +18,7 @@ npm install -g agent-dual-proxy
|
|
|
18
18
|
agent-gateway run claude --resume
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Requires Node.js ≥ 22.
|
|
21
|
+
Requires Node.js ≥ 22.0.0. Built-in short names cover Claude Code, Codex CLI, Gemini CLI, Aider, OpenCode, Qwen Code, Cursor CLI, Droid, Amp, Crush, Goose, GitHub Copilot CLI, and pi; see [docs/agents.md](docs/agents.md) for the full table. For anything not listed, use `agent-gateway run -- <cmd> [args...]`.
|
|
22
22
|
|
|
23
23
|
Install from source (optional):
|
|
24
24
|
|
|
@@ -109,7 +109,7 @@ agent-gateway run -- <any command> # beyond the built-in table
|
|
|
109
109
|
agent-gateway env # print export statements to source
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
Injection replaces a socks `ALL_PROXY` inherited from the launch environment: many clients prefer it, and leaving it in place bypasses `HTTP(S)_PROXY`, defeating the whole point. `NO_PROXY` is merged with the user's existing bypasses rather than overwritten; loopback stays direct, so an agent's own local ports (hooks, LSP, language servers) are never swallowed by the proxy. If the port
|
|
112
|
+
Injection replaces a socks `ALL_PROXY` inherited from the launch environment: many clients prefer it, and leaving it in place bypasses `HTTP(S)_PROXY`, defeating the whole point. `NO_PROXY` is merged with the user's existing bypasses rather than overwritten; loopback stays direct, so an agent's own local ports (hooks, LSP, language servers) are never swallowed by the proxy. If the port already hosts agent-dual-proxy, the launcher reuses that listener; if another program owns it, the launcher exits with an error instead of pointing the agent at a non-proxy port. When the child exits, the proxy is closed and no orphan process is left behind. See [docs/agents.md](docs/agents.md) for per-agent details.
|
|
113
113
|
|
|
114
114
|
## Tests
|
|
115
115
|
|
|
@@ -119,7 +119,7 @@ npm test
|
|
|
119
119
|
|
|
120
120
|
The suite runs from the repository or the source archive; run `npm install` once in the source directory before `npm test`. The npm package does not include `test/`, so it cannot be run from an installed package.
|
|
121
121
|
|
|
122
|
-
The suite covers: HTTP direct fallback, forwarding through a fake proxy, skipping a dead gateway among several, 502 when every path fails, timeout-based failover from a black-hole gateway, hop-by-hop header filtering in both directions, upstream disconnects mid-response without crashing, CONNECT direct tunnels and fake-proxy tunnels, early data arriving with the CONNECT 200, IPv6 literals, fast-fail when a cached gateway stalls, cooldown after a failure, forced tunnel teardown on shutdown, gateway parsing/deduplication (including self-exclusion and self-loop protection), health-check selection and failure, the health loop remembering the favourite, failure kicking a health check, the health-check reentrancy lock, the preference-cache cap, CLI validation and exit codes, self-signed TLS probing, the non-idempotent replay policy (send boundary, method classes, switch, logs and cooldown), upstream proxy authentication (CONNECT/HTTP/health, 407), the idempotency-key replay switch (presence checks and logs), and the agent launcher's environment injection, exit-code passthrough, and port
|
|
122
|
+
The suite covers: HTTP direct fallback, forwarding through a fake proxy, skipping a dead gateway among several, 502 when every path fails, timeout-based failover from a black-hole gateway, hop-by-hop header filtering in both directions, upstream disconnects mid-response without crashing, CONNECT direct tunnels and fake-proxy tunnels, early data arriving with the CONNECT 200, IPv6 literals, fast-fail when a cached gateway stalls, cooldown after a failure, forced tunnel teardown on shutdown, gateway parsing/deduplication (including self-exclusion and self-loop protection), health-check selection and failure, the health loop remembering the favourite, failure kicking a health check, the health-check reentrancy lock, the preference-cache cap, CLI validation and exit codes, self-signed TLS probing, the non-idempotent replay policy (send boundary, method classes, switch, logs and cooldown), upstream proxy authentication (CONNECT/HTTP/health, 407), the idempotency-key replay switch (presence checks and logs), and the agent launcher's environment injection, exit-code passthrough, and port-reuse identity probing.
|
|
123
123
|
|
|
124
124
|
## Boundaries
|
|
125
125
|
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ npm install -g agent-dual-proxy
|
|
|
18
18
|
agent-gateway run claude --resume
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
要求 Node.js ≥ 22.
|
|
21
|
+
要求 Node.js ≥ 22.0.0。内置短名覆盖 Claude Code、Codex CLI、Gemini CLI、Aider、OpenCode、Qwen Code、Cursor CLI、Droid、Amp、Crush、Goose、GitHub Copilot CLI 和 pi;完整对照见 [docs/agents.md](docs/agents.md)。不在表里的命令用 `agent-gateway run -- <cmd> [args...]`。
|
|
22
22
|
|
|
23
23
|
从源码安装(可选):
|
|
24
24
|
|
|
@@ -109,7 +109,7 @@ agent-gateway run -- <任意命令> # 不限内置表
|
|
|
109
109
|
agent-gateway env # 打印可 source 的 export 语句
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
注入时会替换启动环境里的 socks `ALL_PROXY`:不少客户端会优先读它,留着就绕过了 `HTTP(S)_PROXY`,双路选路白做。`NO_PROXY` 与用户原有的直连段合并而不是覆盖;回环地址默认直连,agent 自己的本地端口(hook、LSP
|
|
112
|
+
注入时会替换启动环境里的 socks `ALL_PROXY`:不少客户端会优先读它,留着就绕过了 `HTTP(S)_PROXY`,双路选路白做。`NO_PROXY` 与用户原有的直连段合并而不是覆盖;回环地址默认直连,agent 自己的本地端口(hook、LSP、语言服务器)不会被代理吞掉。端口上已经有本代理时复用现有监听;被其他程序占用则报错退出,不会把 agent 指向一个非代理端口。子进程退出后代理跟着关,不留孤儿进程。各家细节见 [docs/agents.md](docs/agents.md)。
|
|
113
113
|
|
|
114
114
|
## 测试
|
|
115
115
|
|
|
@@ -119,7 +119,7 @@ npm test
|
|
|
119
119
|
|
|
120
120
|
测试面向仓库或源码快照;首次先在源码目录 `npm install`,再 `npm test`。npm 包不含 `test/`,安装后不能直接跑这套用例。
|
|
121
121
|
|
|
122
|
-
用例覆盖:HTTP 直连回退、经假代理转发、多个网关时跳过不通的、两条都不通返回 502、黑洞网关按超时换路、请求与响应两个方向的逐跳标头过滤、上游中途断开不带崩进程、CONNECT 直连隧道与经假代理隧道、CONNECT 200 同包早期数据、IPv6 字面量、缓存网关卡住时快失败换路、失败网关冷却后不再先撞、关闭时强制断开在途隧道、网关解析与去重(含自身端口排除与自环防护)、健康检查的选择与失败、健康循环记住首选、失败踢一次健康检查、健康检查重入锁、偏好缓存上限、CLI 参数校验与退出码、自签证书的 TLS 探测、非幂等重放策略(send 前后边界、方法分类、开关、日志与冷却)、上游代理认证(CONNECT/HTTP/健康检查、407)、带幂等键的发送后重放(开关/键存在性/日志),以及 AGENT
|
|
122
|
+
用例覆盖:HTTP 直连回退、经假代理转发、多个网关时跳过不通的、两条都不通返回 502、黑洞网关按超时换路、请求与响应两个方向的逐跳标头过滤、上游中途断开不带崩进程、CONNECT 直连隧道与经假代理隧道、CONNECT 200 同包早期数据、IPv6 字面量、缓存网关卡住时快失败换路、失败网关冷却后不再先撞、关闭时强制断开在途隧道、网关解析与去重(含自身端口排除与自环防护)、健康检查的选择与失败、健康循环记住首选、失败踢一次健康检查、健康检查重入锁、偏好缓存上限、CLI 参数校验与退出码、自签证书的 TLS 探测、非幂等重放策略(send 前后边界、方法分类、开关、日志与冷却)、上游代理认证(CONNECT/HTTP/健康检查、407)、带幂等键的发送后重放(开关/键存在性/日志),以及 AGENT 启动器的环境注入、退出码透传与端口复用的身份探测。
|
|
123
123
|
|
|
124
124
|
## 边界
|
|
125
125
|
|
package/bin/agent-gateway.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
2
|
+
import http from 'node:http';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
|
-
import {
|
|
5
|
+
import { realpathSync } from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { createDispatcher, IDENTITY_HEADER, IDENTITY_PATH } from '../src/server.js';
|
|
6
8
|
import { collectGateways, isLoopbackHost, resolveUpstream } from '../src/gateways.js';
|
|
7
9
|
import { assertNode } from '../src/engines.js';
|
|
8
10
|
import { AGENT_NAMES, resolveAgent, buildProxyEnv, proxyUrl } from '../src/agents.js';
|
|
@@ -56,11 +58,14 @@ function usage() {
|
|
|
56
58
|
].join('\n'));
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
/**
|
|
61
|
+
/** 参数错误:由顶层以退出码 2 结束。 */
|
|
62
|
+
export class UsageError extends Error {}
|
|
63
|
+
|
|
64
|
+
/** 解析整数并校验范围;失败抛 UsageError。 */
|
|
60
65
|
function integer(label, raw, min, max) {
|
|
61
66
|
const value = Number(raw);
|
|
62
67
|
if (!Number.isInteger(value) || value < min || value > max) {
|
|
63
|
-
throw new
|
|
68
|
+
throw new UsageError('invalid value for ' + label + ': ' + raw + ' (expected integer ' + min + '..' + max + ')');
|
|
64
69
|
}
|
|
65
70
|
return value;
|
|
66
71
|
}
|
|
@@ -100,7 +105,7 @@ function parseArgs(argv, env = process.env) {
|
|
|
100
105
|
}
|
|
101
106
|
const next = () => {
|
|
102
107
|
i += 1;
|
|
103
|
-
if (i >= argv.length) throw new
|
|
108
|
+
if (i >= argv.length) throw new UsageError('missing value for ' + arg);
|
|
104
109
|
return argv[i];
|
|
105
110
|
};
|
|
106
111
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -141,13 +146,33 @@ function parseArgs(argv, env = process.env) {
|
|
|
141
146
|
return { opts, rest, explicit };
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
/**
|
|
150
|
+
* 监听端口上是谁:free(没人听)、ours(本代理)、other(别的程序)。
|
|
151
|
+
*
|
|
152
|
+
* 只有 ours 才复用。别的程序占着端口时直接报错,不能把 agent 指向
|
|
153
|
+
* 一个根本不是代理的端口。
|
|
154
|
+
*/
|
|
155
|
+
export function probeListener(host, port, timeoutMs = 1500) {
|
|
156
|
+
const probeHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
147
157
|
return new Promise((resolve) => {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
let settled = false;
|
|
159
|
+
const done = (value) => {
|
|
160
|
+
if (settled) return;
|
|
161
|
+
settled = true;
|
|
162
|
+
resolve(value);
|
|
163
|
+
};
|
|
164
|
+
const req = http.get({
|
|
165
|
+
host: probeHost,
|
|
166
|
+
port,
|
|
167
|
+
path: IDENTITY_PATH,
|
|
168
|
+
timeout: timeoutMs,
|
|
169
|
+
agent: false
|
|
170
|
+
}, (res) => {
|
|
171
|
+
res.resume();
|
|
172
|
+
done(res.statusCode === 200 && res.headers[IDENTITY_HEADER] === '1' ? 'ours' : 'other');
|
|
173
|
+
});
|
|
174
|
+
req.on('timeout', () => req.destroy(new Error('probe timeout')));
|
|
175
|
+
req.on('error', (err) => done(err && err.code === 'ECONNREFUSED' ? 'free' : 'other'));
|
|
151
176
|
});
|
|
152
177
|
}
|
|
153
178
|
|
|
@@ -157,10 +182,15 @@ async function serve(opts) {
|
|
|
157
182
|
process.stderr.write('warning: listening on ' + opts.listenHost + '; this proxy has no authentication\n');
|
|
158
183
|
}
|
|
159
184
|
const url = proxyUrl(opts);
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
const existing = await probeListener(opts.listenHost, opts.listenPort);
|
|
186
|
+
if (existing === 'ours') {
|
|
187
|
+
process.stderr.write('[agent-gateway] reusing agent-dual-proxy on ' + url + '\n');
|
|
162
188
|
return { url, close: async () => {} };
|
|
163
189
|
}
|
|
190
|
+
if (existing === 'other') {
|
|
191
|
+
throw new Error('listen port ' + opts.listenPort + ' is already in use by something that is not '
|
|
192
|
+
+ 'agent-dual-proxy; stop it or choose another --listen-port');
|
|
193
|
+
}
|
|
164
194
|
// 启动环境里的 HTTP(S)_PROXY/ALL_PROXY 也是候选;self 排除自己。
|
|
165
195
|
const gateways = collectGateways({
|
|
166
196
|
upstream,
|
|
@@ -254,8 +284,20 @@ async function main() {
|
|
|
254
284
|
}
|
|
255
285
|
} catch (err) {
|
|
256
286
|
process.stderr.write((err && err.message ? err.message : String(err)) + '\n');
|
|
257
|
-
process.exitCode = 2;
|
|
287
|
+
process.exitCode = err instanceof UsageError ? 2 : 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 入口判断:npm 通过符号链接调用时,argv[1] 与真实路径不同,必须 realpath 比较。 */
|
|
292
|
+
function isMain() {
|
|
293
|
+
if (!process.argv[1]) return false;
|
|
294
|
+
try {
|
|
295
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
296
|
+
} catch (err) {
|
|
297
|
+
return false;
|
|
258
298
|
}
|
|
259
299
|
}
|
|
260
300
|
|
|
261
|
-
|
|
301
|
+
if (isMain()) {
|
|
302
|
+
main();
|
|
303
|
+
}
|
package/docs/agents.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
agent-gateway run <agent> → 起双路代理 + 把代理变量塞进子进程 + exec
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
端口上已有本代理时复用它;被其他程序占用则报错退出,不会把 agent 指向非代理端口。
|
|
10
|
+
子进程退出后代理跟着关,不留孤儿进程。agent 的本地端口
|
|
10
11
|
(hook、LSP、语言服务器)走 `NO_PROXY` 直连,不会被代理吞掉。
|
|
11
12
|
|
|
12
13
|
注入时会替换启动环境里的 socks `ALL_PROXY`:不少客户端会优先读它,留着就绕过了
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dual-proxy",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,11 +27,8 @@
|
|
|
27
27
|
"test": "node --test test/*.test.js",
|
|
28
28
|
"start": "node bin/dual-proxy.js"
|
|
29
29
|
},
|
|
30
|
-
"dependencies": {
|
|
31
|
-
"undici": "8.12.3"
|
|
32
|
-
},
|
|
33
30
|
"engines": {
|
|
34
|
-
"node": ">=22.
|
|
31
|
+
"node": ">=22.0.0"
|
|
35
32
|
},
|
|
36
33
|
"files": [
|
|
37
34
|
"bin",
|
package/src/engines.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/** Node
|
|
2
|
-
export const MIN_NODE = '22.
|
|
1
|
+
/** Node 版本门槛:只用到内置 http/net/tls,跟随当前维护中的 LTS 主版本。 */
|
|
2
|
+
export const MIN_NODE = '22.0.0';
|
|
3
3
|
|
|
4
4
|
function parts(version) {
|
|
5
5
|
return String(version)
|
|
@@ -17,7 +17,7 @@ export function nodeAtLeast(version, min = MIN_NODE) {
|
|
|
17
17
|
return c >= z;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/** 低于门槛时抛出可读错误,避免在运行期报出看不懂的语法/API 错误。 */
|
|
21
21
|
export function assertNode(version = process.versions.node) {
|
|
22
22
|
if (!nodeAtLeast(version)) {
|
|
23
23
|
throw new Error('agent-dual-proxy requires Node >= ' + MIN_NODE + ' (current ' + version + ')');
|
package/src/server.js
CHANGED
|
@@ -6,6 +6,10 @@ import { hasIdempotencyKey, isIdempotent, shouldRetryAfterSend } from './retry-p
|
|
|
6
6
|
|
|
7
7
|
const DEFAULT_LOG = (msg) => process.stderr.write('[dual-proxy] ' + msg + '\n');
|
|
8
8
|
|
|
9
|
+
/** 本地身份端点:端口复用前用来确认监听者确实是本代理。 */
|
|
10
|
+
export const IDENTITY_PATH = '/__agent_dual_proxy__';
|
|
11
|
+
export const IDENTITY_HEADER = 'x-agent-dual-proxy';
|
|
12
|
+
|
|
9
13
|
/** 合并配置:显式传入的 undefined 不应覆盖默认值。 */
|
|
10
14
|
export function mergeOptions(defaults, options) {
|
|
11
15
|
const merged = Object.assign({}, defaults);
|
|
@@ -224,6 +228,11 @@ export function createDispatcher(options) {
|
|
|
224
228
|
};
|
|
225
229
|
|
|
226
230
|
const server = http.createServer((req, res) => {
|
|
231
|
+
if (req.method === 'GET' && req.url === IDENTITY_PATH) {
|
|
232
|
+
res.writeHead(200, { 'content-type': 'text/plain', [IDENTITY_HEADER]: '1' });
|
|
233
|
+
res.end('agent-dual-proxy\n');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
227
236
|
let target;
|
|
228
237
|
try {
|
|
229
238
|
target = new URL(req.url);
|