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 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.19.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...]`.
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 is already in use, the launcher reuses the existing listener; 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.
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 reuse.
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.19.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...]`。
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、语言服务器)不会被代理吞掉。端口已被占用时复用现有监听;子进程退出后代理跟着关,不留孤儿进程。各家细节见 [docs/agents.md](docs/agents.md)。
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
 
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import net from 'node:net';
2
+ import http from 'node:http';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { createRequire } from 'node:module';
5
- import { createDispatcher } from '../src/server.js';
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
- /** 解析整数并校验范围;失败抛 Error,由顶层统一报一行。 */
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 Error('invalid value for ' + label + ': ' + raw + ' (expected integer ' + min + '..' + max + ')');
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 Error('missing value for ' + arg);
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
- // ponytail: 只判「有人在听」就复用,不验证它是不是本代理;跑错的监听会静默被当上游用。
146
- function portInUse(host, port) {
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
- const server = net.createServer();
149
- server.once('error', (err) => resolve(err.code === 'EADDRINUSE'));
150
- server.listen(port, host, () => server.close(() => resolve(false)));
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
- if (await portInUse(opts.listenHost, opts.listenPort)) {
161
- process.stderr.write('[agent-gateway] reusing existing listener on ' + url + '\n');
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
- main();
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
- 端口已被占用时复用那一个;子进程退出后代理跟着关,不留孤儿进程。agent 的本地端口
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.1",
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.19.0"
31
+ "node": ">=22.0.0"
35
32
  },
36
33
  "files": [
37
34
  "bin",
package/src/engines.js CHANGED
@@ -1,5 +1,5 @@
1
- /** Node 版本门槛:undici 8 要求 >= 22.19.0。 */
2
- export const MIN_NODE = '22.19.0';
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
- /** 低于门槛时抛出可读错误,避免在 undici 里报出看不懂的运行期错误。 */
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);