agent-dual-proxy 0.0.0-stage → 0.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/CHANGELOG.md +29 -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 +45 -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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
独立项目的首个正式版本:从 0.5.0 拆出版本回补 0.5.1–0.7.0 的核心演进,并把 agent 适配面扩展到主流 CLI。
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 上游代理认证:`upstreamUrl`、`gateways`、启动环境代理 URL 可带 `user:pass`,CONNECT/HTTP/TLS 探测/健康检查带 `Proxy-Authorization: Basic`;凭证不进日志、不转发给目标。
|
|
10
|
+
- `upstreamUrl` 配置/CLI/env 字段:非空时覆盖 `upstreamHost`/`upstreamPort`,非法值 fail fast 且错误信息脱敏。
|
|
11
|
+
- 幂等键重放:`retryNonIdempotentWithIdempotencyKey`,仅在请求带非空 `Idempotency-Key`/`X-Idempotency-Key` 时允许发送后重放非幂等请求。
|
|
12
|
+
- `responseTimeoutMs`:普通 HTTP 等响应头的上限,响应体开始流之后不再计时。
|
|
13
|
+
- 407 语义:上游代理拒绝认证时视为该网关失败并换路,不转发给客户端。
|
|
14
|
+
- 逐跳标头过滤(RFC 9110),请求方向重写 `Host`。
|
|
15
|
+
- 偏好缓存 LRU 上限(默认 1000 条);关闭时最多等 `shutdownTimeoutMs` 再强制断开在途 socket。
|
|
16
|
+
- 健康检查重入锁,周期检查与失败踢动不会并发探测。
|
|
17
|
+
- CONNECT 200 同包早期数据转交客户端;IPv6 字面量不做无效的 `servername`。
|
|
18
|
+
- CLI 参数校验统一为一行错误、退出码 2;帮助文本补齐新选项。
|
|
19
|
+
- `agent-gateway` 内置短名扩展到 13 个:claude、codex、gemini、pi、aider、opencode、qwen、cursor(`cursor-agent`)、droid、amp、crush、goose、copilot;不在表里的用 `run -- <cmd>`。
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `agent-gateway` 注入子进程环境时替换启动环境的 socks `ALL_PROXY`,`NO_PROXY` 与用户原有直连段合并而不是覆盖。
|
|
24
|
+
- `agent-gateway` 的启动环境 `HTTP(S)_PROXY`/`ALL_PROXY` 会作为候选网关,自身监听端口自动排除;`env` 子命令输出增加 `ALL_PROXY`/`all_proxy`。
|
|
25
|
+
- Node 要求提升到 `>=22.19.0`(与 undici 8 一致),启动时给出可读错误。
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
|
|
29
|
+
- 回补上游认证、407、幂等键重放、两段超时、逐跳标头、IPv6、CONNECT 早期数据、偏好 LRU、健康重入锁、CLI 校验等用例;测试总数 109。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gambn and dsh-dual-proxy Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# agent-dual-proxy
|
|
2
|
+
|
|
3
|
+
[English](README.en.md) · [中文](README.md)
|
|
4
|
+
|
|
5
|
+
[](https://github.com/gambn/agent-dual-proxy/actions/workflows/ci.yml) [](https://www.npmjs.com/package/agent-dual-proxy)
|
|
6
|
+
|
|
7
|
+
**A gateway manager for agents: switch between multiple gateways online, so model requests are never stuck on one flaky path.**
|
|
8
|
+
|
|
9
|
+
It treats the network path from an agent to the model endpoint as a managed resource: it picks whichever configured upstream, extra proxy, launch-environment proxy, or direct connection can reach the target, keeps verifying that channel with real requests, and switches on failure — without restarting the agent or touching client configuration.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Standalone CLI
|
|
15
|
+
npm install -g agent-dual-proxy
|
|
16
|
+
|
|
17
|
+
# Wrap a CLI agent such as Claude Code
|
|
18
|
+
agent-gateway run claude --resume
|
|
19
|
+
```
|
|
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...]`.
|
|
22
|
+
|
|
23
|
+
Install from source (optional):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/gambn/agent-dual-proxy.git
|
|
27
|
+
cd agent-dual-proxy
|
|
28
|
+
npm install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Two shapes, one core:
|
|
32
|
+
|
|
33
|
+
- **Agent launcher** (recommended): `agent-gateway run <agent>` starts the proxy, injects the proxy environment, and execs the agent; the proxy stops when the agent exits.
|
|
34
|
+
- **Standalone process**: run `dual-proxy` as a long-lived proxy, or install it as a systemd user service.
|
|
35
|
+
|
|
36
|
+
## What it solves
|
|
37
|
+
|
|
38
|
+
Upstream proxies are not reliable: the same proxy may reach site A but not site B, or be down for a while entirely. When two proxies are installed on one machine, which one works can change from hour to hour. A client that only knows one path fails in bursts, and retrying on a dead path only adds delay.
|
|
39
|
+
|
|
40
|
+
This layer keeps all candidate paths in hand and chooses per host:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
client → 127.0.0.1:7898
|
|
44
|
+
├── gateway A (TLS probe passes) → upstream proxy
|
|
45
|
+
├── gateway B (TLS probe passes) → upstream proxy
|
|
46
|
+
└── all probes fail → direct
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The health check runs the same candidate list with a real request; the first one that returns a response below 500 becomes the global favourite.
|
|
50
|
+
|
|
51
|
+
## Behaviour
|
|
52
|
+
|
|
53
|
+
- Candidate gateways = `upstreamHost:upstreamPort` (or `upstreamUrl`) + `gateways` + `HTTP(S)_PROXY`/`ALL_PROXY` from the launch environment, deduplicated, excluding the proxy's own listen port, with `direct` appended last.
|
|
54
|
+
- **Upstream proxy authentication**: `upstreamUrl`, `gateways`, or a proxy URL in the launch environment may carry `user:pass`; proxy connections then send `Proxy-Authorization: Basic`. 0.7.0 supports only `http://` upstream proxies: `upstreamUrl` with `https://` is an error; extra candidates (`gateways`, `--gateway`, `GATEWAY`, launch-environment proxies) with `https://` or invalid values are skipped with a redacted warning. Basic is base64, not encryption; use it only on loopback or trusted networks.
|
|
55
|
+
- **CONNECT (HTTPS)**: on 443, each candidate gets a TLS handshake probe; only a candidate that passes is used to open the tunnel. On failure, the next one is tried.
|
|
56
|
+
- **Plain HTTP**: the request body is buffered first (8 MB limit), and the request is replayed along the same candidate order. Connect uses `attemptTimeoutMs` (`fastFailTimeoutMs` for a known path), waiting for response headers uses `responseTimeoutMs`; the timer stops once the response body starts streaming.
|
|
57
|
+
- **Idempotency-key replay**: with `retryNonIdempotentWithIdempotencyKey` on, a non-idempotent request is replayed after send only when it carries a non-empty `Idempotency-Key`/`X-Idempotency-Key`; the proxy does not verify that the upstream actually deduplicates.
|
|
58
|
+
- **Replay policy (plain HTTP only; CONNECT tunnels are never replayed)**: if the TCP connection to the gateway/target is not established yet, any method is replayed. Once the connection is established, only idempotent methods are replayed (`GET`/`HEAD`/`OPTIONS`/`TRACE`/`PUT`/`DELETE`). `POST`/`PATCH` and other methods stop by default with 502 and the body `non-idempotent request may have been delivered; not replayed`; enable `retryNonIdempotentAfterSend` for the old behaviour.
|
|
59
|
+
- **Fast-fail covers the connect phase only**: if the cached or health-preferred gateway cannot connect over TCP, it is skipped within 2 seconds; if it connects but never returns response headers, the request still waits the full `responseTimeoutMs` (60 seconds by default).
|
|
60
|
+
- **Health check**: every `healthIntervalMs`, a `GET healthUrl` validates the channel; anything below 500 counts as reachable (401/403 means the endpoint is alive). The winner becomes the global favourite; if all candidates fail, the favourite is cleared and the next request re-scans. Set to `0` to disable.
|
|
61
|
+
- **Per-host preference cache** (60 seconds by default, up to 1000 entries, LRU eviction): a cache hit skips probing; a failure switches immediately.
|
|
62
|
+
- **Gateway cooldown** (30 seconds by default): after a failure, that candidate moves to the end of the order so other hosts do not hit it first; a successful health check clears the cooldown.
|
|
63
|
+
- **Failure triggers a health check**: any failed path kicks a health re-check (at most once per second), instead of waiting for the next 60-second cycle.
|
|
64
|
+
- **Hop-by-hop headers** are stripped per RFC 9110 (`Connection` and the fields it names, `Proxy-Connection`, `Transfer-Encoding`, etc.), and `Host` is rewritten to the target; response hop-by-hop headers are stripped too.
|
|
65
|
+
- **IPv6**: CONNECT accepts literals such as `[::1]:443`; hosts are normalised by removing brackets, lowercasing, and dropping a trailing dot.
|
|
66
|
+
- **Shutdown** waits up to `shutdownTimeoutMs` (1.5 seconds by default; an internal option) for in-flight requests, then force-destroys all sockets.
|
|
67
|
+
- A 502 is returned when every candidate fails, and also immediately when a non-idempotent request fails after the connection is established with `retryNonIdempotentAfterSend` off and no usable idempotency-key switch (remaining candidates are not tried).
|
|
68
|
+
- Logs contain the host, gateway and reason only; request contents are never logged.
|
|
69
|
+
|
|
70
|
+
## As a standalone process
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
dual-proxy --listen-port 7898 --upstream-port 7897 \
|
|
74
|
+
--gateway 127.0.0.1:12334 --health-url https://api.deepseek.com
|
|
75
|
+
|
|
76
|
+
node bin/dual-proxy.js --listen-port 7898 # if it is not on PATH
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Flag | Default | Description |
|
|
80
|
+
| :--- | :--- | :--- |
|
|
81
|
+
| `--listen-host` | `127.0.0.1` | Listen address |
|
|
82
|
+
| `--listen-port` | `7898` | Listen port |
|
|
83
|
+
| `--upstream-host` | `127.0.0.1` | First candidate gateway |
|
|
84
|
+
| `--upstream-port` | `7897` | Port of the first candidate |
|
|
85
|
+
| `--upstream-url` | — | Full URL of the first candidate, may include `user:pass`; overrides host/port |
|
|
86
|
+
| `--gateway` | — | Extra candidate, repeatable |
|
|
87
|
+
| `--health-url` | empty | Channel check URL |
|
|
88
|
+
| `--health-interval-ms` | `60000` | Health-check interval, `0` disables |
|
|
89
|
+
| `--fast-fail-ms` | `2000` | Connect timeout for a known path |
|
|
90
|
+
| `--cooldown-ms` | `30000` | Cooldown for a failed gateway |
|
|
91
|
+
| `--timeout-ms` | `5000` | Per-attempt/per-probe timeout |
|
|
92
|
+
| `--response-timeout-ms` | `60000` | Upper bound for response headers |
|
|
93
|
+
| `--pref-ttl-ms` | `60000` | Per-host preference TTL |
|
|
94
|
+
| `--no-verify-tls` | off | Skip the TLS probe on 443 |
|
|
95
|
+
| `--retry-non-idempotent-after-send` | off | Also replay `POST`/`PATCH` after the connection was established |
|
|
96
|
+
| `--retry-non-idempotent-with-idempotency-key` | off | Allow post-send replay when `Idempotency-Key` is present |
|
|
97
|
+
|
|
98
|
+
Environment variables use the same names (`LISTEN_HOST`, `LISTEN_PORT`, `UPSTREAM_HOST`, `UPSTREAM_PORT`, `UPSTREAM_URL`, `GATEWAY`, `HEALTH_URL`, `HEALTH_INTERVAL_MS`, `FAST_FAIL_MS`, `COOLDOWN_MS`, `ATTEMPT_TIMEOUT_MS`, `RESPONSE_TIMEOUT_MS`, `PREF_TTL_MS`, `VERIFY_TLS=0`, `RETRY_NON_IDEMPOTENT_AFTER_SEND=1`, `RETRY_NON_IDEMPOTENT_WITH_IDEMPOTENCY_KEY=1`).
|
|
99
|
+
|
|
100
|
+
A systemd user-service template lives in `contrib/`.
|
|
101
|
+
|
|
102
|
+
## For mainstream CLI agents
|
|
103
|
+
|
|
104
|
+
Agents such as Claude Code, Codex CLI, and Gemini CLI share no plugin API; the only common ground is `HTTP(S)_PROXY`. The launcher does three things: start the dual proxy, inject the proxy environment into the child, and exec the agent.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
agent-gateway run claude --resume # start proxy → inject → exec
|
|
108
|
+
agent-gateway run -- <any command> # beyond the built-in table
|
|
109
|
+
agent-gateway env # print export statements to source
|
|
110
|
+
```
|
|
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.
|
|
113
|
+
|
|
114
|
+
## Tests
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm test
|
|
118
|
+
```
|
|
119
|
+
|
|
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
|
+
|
|
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.
|
|
123
|
+
|
|
124
|
+
## Boundaries
|
|
125
|
+
|
|
126
|
+
- Only HTTP/1.1 `CONNECT` and plain HTTP; no transparent proxying and no UDP.
|
|
127
|
+
- A plain HTTP request waits up to `responseTimeoutMs` (60 seconds by default) for response headers after the gateway connection is established; 0.7.0 does not shorten that wait, and the idempotency key only changes the replay decision after the timeout.
|
|
128
|
+
- Once a CONNECT tunnel is established the proxy only forwards bytes and TLS is end-to-end; an in-tunnel failure cannot be failed over. Covering it would require MITM decryption, which conflicts with this project's no-interception design.
|
|
129
|
+
- Certificates are not verified, contents are not cached, and there is no authentication. It binds to loopback by default; changing `listenHost` to a non-loopback address logs a warning at startup — that turns it into an unauthenticated proxy, at your own risk.
|
|
130
|
+
- Upstream proxy authentication is HTTP Basic only; `https://` upstream proxies (TLS-to-proxy) are not supported. Basic is suitable only on loopback or trusted networks; protecting credentials to a remote proxy is outside this project's scope.
|
|
131
|
+
- It only answers "can I get there", not "can I trust it" — certificate validation remains the client's responsibility.
|
|
132
|
+
- The health check sends one unauthenticated `GET`; the criterion is "a response below 500", not whether credentials or model permissions are valid.
|
|
133
|
+
- **At-most-once is not guaranteed**: with the default `retryNonIdempotentAfterSend: false`, `POST`/`PATCH` return 502 immediately after a failure once the connection is established (possibly delivered, not replayed), while idempotent methods are still replayed; enabling the switch restores replay-after-send at the cost of possible duplicate execution. Client SDK retries on 5xx are outside the proxy's control; for billing- or write-sensitive calls, use idempotency keys upstream.
|
|
134
|
+
|
|
135
|
+
## Feedback
|
|
136
|
+
|
|
137
|
+
Use GitHub Issues for questions, bugs, and suggestions: <https://github.com/gambn/agent-dual-proxy/issues>. Do not paste tokens, keys, or full request contents into an issue; see [SECURITY.md](SECURITY.md) for security reports.
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
package/README.md
CHANGED
|
@@ -1,3 +1,141 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agent-dual-proxy
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[中文](README.md) · [English](README.en.md)
|
|
4
|
+
|
|
5
|
+
[](https://github.com/gambn/agent-dual-proxy/actions/workflows/ci.yml) [](https://www.npmjs.com/package/agent-dual-proxy)
|
|
6
|
+
|
|
7
|
+
**Agent 的网关管理器:在多个网关之间在线自动切换,让模型请求不再被一条不稳的路卡死。**
|
|
8
|
+
|
|
9
|
+
它把「agent 到模型端点的网络路径」当成可管理的资源:在配置的上游、额外指定的代理、启动环境里的代理和直连之间自动选一条能通的;运行中用真实请求持续验证,失败立刻切到下一条——不需要重启 agent,也不需要改客户端配置。
|
|
10
|
+
|
|
11
|
+
## 快速开始
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 独立 CLI
|
|
15
|
+
npm install -g agent-dual-proxy
|
|
16
|
+
|
|
17
|
+
# 给 Claude Code 等 CLI agent 套上
|
|
18
|
+
agent-gateway run claude --resume
|
|
19
|
+
```
|
|
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...]`。
|
|
22
|
+
|
|
23
|
+
从源码安装(可选):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/gambn/agent-dual-proxy.git
|
|
27
|
+
cd agent-dual-proxy
|
|
28
|
+
npm install
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
两种用法,同一份核心:
|
|
32
|
+
|
|
33
|
+
- **AGENT 启动器**(推荐):`agent-gateway run <agent>`,起代理 + 注入代理变量 + exec,agent 退出时代理跟着关;
|
|
34
|
+
- **独立进程**:`dual-proxy` 跑一个常驻代理,或挂 systemd 用户服务。
|
|
35
|
+
|
|
36
|
+
## 它解决什么
|
|
37
|
+
|
|
38
|
+
上游代理并不稳定:同一个代理可能对 A 站通、对 B 站不通,也可能整段时间全挂;本机同时装着两个代理时,哪个能用还会变。只认一条路的客户端在这种时候会成片失败,而在死路上重试只是拖延。
|
|
39
|
+
|
|
40
|
+
这一层把候选路都握在手里,按 host 选:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
客户端 → 127.0.0.1:7898
|
|
44
|
+
├── 网关 A(TLS 探测通过)→ 上游代理
|
|
45
|
+
├── 网关 B(TLS 探测通过)→ 上游代理
|
|
46
|
+
└── 都探测失败 → 直连
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
健康检查用一个真实请求把同一组候选走一遍,谁先给出 <500 的响应,谁就成为全局首选。
|
|
50
|
+
|
|
51
|
+
## 行为
|
|
52
|
+
|
|
53
|
+
- 候选网关 = `upstreamHost:upstreamPort`(或 `upstreamUrl`)+ `gateways` + 启动环境的 `HTTP(S)_PROXY`/`ALL_PROXY`,去重并排除自身监听端口,最后补 `direct`。
|
|
54
|
+
- **上游代理认证**:`upstreamUrl`、`gateways` 或环境变量里的 HTTP 代理 URL 可带 `user:pass`;连接代理时会带 `Proxy-Authorization: Basic`。0.7.0 只支持 `http://` 上游代理:`upstreamUrl` 传 `https://` 会报错;额外候选(`gateways`、`--gateway`、`GATEWAY`、启动环境代理)里的 `https://` 或非法值会给脱敏 warning 后跳过。Basic 只是 base64,不是加密,只建议回环或受信任网络。
|
|
55
|
+
- **CONNECT(HTTPS)**:443 上先对候选做 TLS 握手探测,通过的才用来开隧道;失败换下一条。
|
|
56
|
+
- **普通 HTTP**:请求体先缓冲(上限 8 MB),按同一顺序换路重放;建连用 `attemptTimeoutMs`(已知路径用 `fastFailTimeoutMs`),等响应头用 `responseTimeoutMs`,响应体开始流之后不再计时。
|
|
57
|
+
- **幂等键重放**:打开 `retryNonIdempotentWithIdempotencyKey` 后,非幂等请求只有在带非空 `Idempotency-Key`/`X-Idempotency-Key` 时才允许发送后重放;代理不验证上游是否真的去重。
|
|
58
|
+
- **重放策略(仅普通 HTTP,CONNECT 隧道不重放)**:到网关/目标的 TCP 连接还没建立就失败时,任何方法都重放;连接建立后失败时,只重放幂等方法(`GET`/`HEAD`/`OPTIONS`/`TRACE`/`PUT`/`DELETE`)。`POST`/`PATCH` 等默认停止并回 502,响应体是 `non-idempotent request may have been delivered; not replayed`;需要旧行为时打开 `retryNonIdempotentAfterSend`。
|
|
59
|
+
- **已知路径的快失败只覆盖建连阶段**:缓存或健康首选的那条网关,TCP 连不上时 2 秒内换路;如果它连上了却迟迟不回响应头,仍要等满 `responseTimeoutMs`(默认 60 秒)。
|
|
60
|
+
- **健康检查**:每 `healthIntervalMs` 用 `GET healthUrl` 验证一次通道,`<500` 就算通(401/403 说明端点活着);通过者设为全局首选,全部失败则清掉首选、下次请求重新扫描。设为 `0` 关闭。
|
|
61
|
+
- **每 host 偏好缓存**(默认 60 秒,最多 1000 条,LRU 淘汰):缓存命中跳过探测;失败即切换。
|
|
62
|
+
- **失败网关冷却**(默认 30 秒):某条路失败后进入冷却,期间排到最后,别的 host 不会再先撞它;健康检查证明它恢复时自动解除。
|
|
63
|
+
- **失败即复检**:任何一条路刚失败就踢一次健康检查(1 秒内只踢一次),不用等下一个 60 秒周期。
|
|
64
|
+
- **逐跳标头**按 RFC 9110 过滤(`Connection` 及其点名的字段、`Proxy-Connection`、`Transfer-Encoding` 等),`Host` 重写为目标主机;响应方向的逐跳标头同样清掉。
|
|
65
|
+
- **IPv6**:CONNECT 支持 `[::1]:443` 这类字面量,host 统一去掉方括号、转小写、去掉 FQDN 末尾的点。
|
|
66
|
+
- **关闭**最多等 `shutdownTimeoutMs`(默认 1.5 秒,内部选项)让在途请求自然结束,超时后强制断开所有 socket。
|
|
67
|
+
- 全部候选都失败时回 502;非幂等请求在连接建立后失败且未开 `retryNonIdempotentAfterSend`、也没有可用的幂等键开关时,也会立即回 502,不再尝试剩余候选。
|
|
68
|
+
- 日志只写主机、网关与原因,不记录任何请求内容。
|
|
69
|
+
|
|
70
|
+
## 作为独立进程
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
dual-proxy --listen-port 7898 --upstream-port 7897 \
|
|
74
|
+
--gateway 127.0.0.1:12334 --health-url https://api.deepseek.com
|
|
75
|
+
|
|
76
|
+
node bin/dual-proxy.js --listen-port 7898 # 没装到 PATH 时直接跑
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| 参数 | 默认 | 说明 |
|
|
80
|
+
| :--- | :--- | :--- |
|
|
81
|
+
| `--listen-host` | `127.0.0.1` | 监听地址 |
|
|
82
|
+
| `--listen-port` | `7898` | 监听端口 |
|
|
83
|
+
| `--upstream-host` | `127.0.0.1` | 第一个候选网关 |
|
|
84
|
+
| `--upstream-port` | `7897` | 第一个候选网关的端口 |
|
|
85
|
+
| `--upstream-url` | — | 第一个候选的完整 URL,可含 `user:pass`;非空时覆盖 host/port |
|
|
86
|
+
| `--gateway` | — | 额外候选网关,可重复 |
|
|
87
|
+
| `--health-url` | 空 | 通道验证地址 |
|
|
88
|
+
| `--health-interval-ms` | `60000` | 健康检查间隔,`0` 关闭 |
|
|
89
|
+
| `--fast-fail-ms` | `2000` | 已知路径的建连超时 |
|
|
90
|
+
| `--cooldown-ms` | `30000` | 失败网关的冷却时长 |
|
|
91
|
+
| `--timeout-ms` | `5000` | 每次尝试/探测超时 |
|
|
92
|
+
| `--response-timeout-ms` | `60000` | 普通 HTTP 等响应头的上限 |
|
|
93
|
+
| `--pref-ttl-ms` | `60000` | 每 host 偏好缓存 TTL |
|
|
94
|
+
| `--no-verify-tls` | 关 | 跳过 443 的 TLS 探测 |
|
|
95
|
+
| `--retry-non-idempotent-after-send` | 关 | 连接建立后失败时也重放 `POST`/`PATCH` |
|
|
96
|
+
| `--retry-non-idempotent-with-idempotency-key` | 关 | 带 `Idempotency-Key` 时允许发送后重放 |
|
|
97
|
+
|
|
98
|
+
环境变量同名(`LISTEN_HOST`、`LISTEN_PORT`、`UPSTREAM_HOST`、`UPSTREAM_PORT`、`UPSTREAM_URL`、`GATEWAY`、`HEALTH_URL`、`HEALTH_INTERVAL_MS`、`FAST_FAIL_MS`、`COOLDOWN_MS`、`ATTEMPT_TIMEOUT_MS`、`RESPONSE_TIMEOUT_MS`、`PREF_TTL_MS`、`VERIFY_TLS=0`、`RETRY_NON_IDEMPOTENT_AFTER_SEND=1`、`RETRY_NON_IDEMPOTENT_WITH_IDEMPOTENCY_KEY=1`)。
|
|
99
|
+
|
|
100
|
+
systemd 用户服务模板在 `contrib/`。
|
|
101
|
+
|
|
102
|
+
## 给主流 CLI agent 用
|
|
103
|
+
|
|
104
|
+
Claude Code / Codex CLI / Gemini CLI 等 agent 没有共用的插件 API,唯一通杀的是 `HTTP(S)_PROXY`。启动器做三件事:起双路代理、把代理变量塞进子进程、exec。
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
agent-gateway run claude --resume # 起代理 → 注入变量 → exec
|
|
108
|
+
agent-gateway run -- <任意命令> # 不限内置表
|
|
109
|
+
agent-gateway env # 打印可 source 的 export 语句
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
注入时会替换启动环境里的 socks `ALL_PROXY`:不少客户端会优先读它,留着就绕过了 `HTTP(S)_PROXY`,双路选路白做。`NO_PROXY` 与用户原有的直连段合并而不是覆盖;回环地址默认直连,agent 自己的本地端口(hook、LSP、语言服务器)不会被代理吞掉。端口已被占用时复用现有监听;子进程退出后代理跟着关,不留孤儿进程。各家细节见 [docs/agents.md](docs/agents.md)。
|
|
113
|
+
|
|
114
|
+
## 测试
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm test
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
测试面向仓库或源码快照;首次先在源码目录 `npm install`,再 `npm test`。npm 包不含 `test/`,安装后不能直接跑这套用例。
|
|
121
|
+
|
|
122
|
+
用例覆盖:HTTP 直连回退、经假代理转发、多个网关时跳过不通的、两条都不通返回 502、黑洞网关按超时换路、请求与响应两个方向的逐跳标头过滤、上游中途断开不带崩进程、CONNECT 直连隧道与经假代理隧道、CONNECT 200 同包早期数据、IPv6 字面量、缓存网关卡住时快失败换路、失败网关冷却后不再先撞、关闭时强制断开在途隧道、网关解析与去重(含自身端口排除与自环防护)、健康检查的选择与失败、健康循环记住首选、失败踢一次健康检查、健康检查重入锁、偏好缓存上限、CLI 参数校验与退出码、自签证书的 TLS 探测、非幂等重放策略(send 前后边界、方法分类、开关、日志与冷却)、上游代理认证(CONNECT/HTTP/健康检查、407)、带幂等键的发送后重放(开关/键存在性/日志),以及 AGENT 启动器的环境注入、退出码透传与端口复用。
|
|
123
|
+
|
|
124
|
+
## 边界
|
|
125
|
+
|
|
126
|
+
- 只处理 HTTP/1.1 的 `CONNECT` 与普通 HTTP;不做透明代理、不接管 UDP。
|
|
127
|
+
- 普通 HTTP 请求在网关连上后要等满 `responseTimeoutMs`(默认 60 秒)才判定响应头超时;0.7.0 不缩短这个等待,幂等键只改变超时后的重放决策。
|
|
128
|
+
- CONNECT 隧道建立后代理只转发字节,TLS 端到端;隧道内失败无法换路,要覆盖只能做 MITM 解密,和本项目“不拦截”的定位冲突。
|
|
129
|
+
- 不校验证书,不缓存内容,不做鉴权。默认只绑回环;把 `listenHost` 改成非回环地址会在启动时告警——那等于开放一个无鉴权代理,风险自负。
|
|
130
|
+
- 上游代理认证只支持 `http://` 的 Basic;不支持 `https://` 上游代理(TLS-to-proxy)。Basic 只适合回环或受信任网络,远程代理的凭证保护不在本项目范围内。
|
|
131
|
+
- 只负责「到不到得了」,不负责「信不信得过」——证书校验仍是客户端的责任。
|
|
132
|
+
- 健康检查只发一个无凭证 `GET`,判据是「有没有 <500 的响应」,不代表凭证或模型权限有效。
|
|
133
|
+
- **不保证「至多一次」**:默认 `retryNonIdempotentAfterSend: false` 时,`POST`/`PATCH` 在连接建立后失败会直接 502(可能已送达、未重放),幂等方法仍会重放;打开该开关会恢复发送后重放,代价是可能重复执行。客户端 SDK 对 5xx 的自动重试不受代理控制,对计费或写入敏感的调用仍应在上游使用幂等键。
|
|
134
|
+
|
|
135
|
+
## 反馈
|
|
136
|
+
|
|
137
|
+
问题、建议与使用疑问走 GitHub Issues:<https://github.com/gambn/agent-dual-proxy/issues>。请勿在 Issue 里粘贴 token、密钥或完整请求内容;安全问题见 [SECURITY.md](SECURITY.md)。
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
0.7.x receives security fixes. Older versions are not supported.
|
|
6
|
+
|
|
7
|
+
## Reporting a problem
|
|
8
|
+
|
|
9
|
+
Use GitHub Issues: <https://github.com/gambn/agent-dual-proxy/issues>
|
|
10
|
+
|
|
11
|
+
This project does not use an email address for support or security reports. Do not include tokens, API keys, proxy credentials, request bodies, or logs containing secrets in an issue. If a report needs sensitive details, open an issue with a short, non-sensitive summary and ask for a private channel first; do not post the sensitive material publicly.
|
|
12
|
+
|
|
13
|
+
## Release verification
|
|
14
|
+
|
|
15
|
+
Release tags and commits are SSH-signed; verify with `git tag -v v0.7.0` and compare the downloaded artifacts against `SHA256SUMS` and `BUILD-INFO.txt`.
|
|
16
|
+
|
|
17
|
+
## Scope
|
|
18
|
+
|
|
19
|
+
- The proxy runs locally and is unauthenticated by default. Exposing it on a non-loopback address is outside the supported configuration and is warned about at startup.
|
|
20
|
+
- The proxy does not validate upstream certificates, does not cache content, and does not authenticate clients.
|
|
21
|
+
- HTTP upstream proxy authentication is supported via `Proxy-Authorization: Basic`; credentials are never logged and are not forwarded to the target. Basic is base64, not encryption, so use it only on loopback or trusted networks. `https://` upstream proxies (TLS-to-proxy) are not supported and are rejected explicitly.
|
|
22
|
+
- The proxy does not authenticate its own clients; keep it on loopback unless you accept the risk.
|
|
23
|
+
- Reports about this project's own code, dependency handling, or the release artifacts are in scope. Reports about third-party gateways or the model provider are not.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { createDispatcher } from '../src/server.js';
|
|
6
|
+
import { collectGateways, isLoopbackHost, resolveUpstream } from '../src/gateways.js';
|
|
7
|
+
import { assertNode } from '../src/engines.js';
|
|
8
|
+
import { AGENT_NAMES, resolveAgent, buildProxyEnv, proxyUrl } from '../src/agents.js';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const pkg = require('../package.json');
|
|
12
|
+
|
|
13
|
+
const LAUNCHER_OPTS = [
|
|
14
|
+
'--listen-host', '--listen-port', '--upstream-host', '--upstream-port', '--upstream-url',
|
|
15
|
+
'--gateway', '--health-url', '--health-interval-ms', '--timeout-ms', '--response-timeout-ms',
|
|
16
|
+
'--fast-fail-ms', '--cooldown-ms', '--pref-ttl-ms'
|
|
17
|
+
];
|
|
18
|
+
const FLAGS = [
|
|
19
|
+
'--no-verify-tls', '--retry-non-idempotent-after-send',
|
|
20
|
+
'--retry-non-idempotent-with-idempotency-key'
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
process.stdout.write([
|
|
25
|
+
'agent-gateway ' + pkg.version + ' —— 给主流 CLI agent 套一层双路代理',
|
|
26
|
+
'',
|
|
27
|
+
'Usage:',
|
|
28
|
+
' agent-gateway run [options] <agent> [agent-args...]',
|
|
29
|
+
' agent-gateway run [options] -- <cmd> [args...]',
|
|
30
|
+
' agent-gateway env [options] # 打印可 source 的导出语句',
|
|
31
|
+
'',
|
|
32
|
+
'Agents: ' + AGENT_NAMES.join(', '),
|
|
33
|
+
'',
|
|
34
|
+
'Options:',
|
|
35
|
+
' --listen-host <host> default 127.0.0.1',
|
|
36
|
+
' --listen-port <port> default 7898',
|
|
37
|
+
' --upstream-host <host> default 127.0.0.1',
|
|
38
|
+
' --upstream-port <port> default 7897',
|
|
39
|
+
' --upstream-url <url> full upstream URL, may include user:pass',
|
|
40
|
+
' --gateway <host:port> extra candidate gateway, repeatable',
|
|
41
|
+
' --health-url <url> verify the channel with this URL, default off',
|
|
42
|
+
' --health-interval-ms <ms> default 60000, 0 disables',
|
|
43
|
+
' --timeout-ms <ms> per-attempt timeout, default 5000',
|
|
44
|
+
' --response-timeout-ms <ms> header wait for plain HTTP, default 60000',
|
|
45
|
+
' --fast-fail-ms <ms> timeout for the known path, default 2000',
|
|
46
|
+
' --cooldown-ms <ms> failed gateway rest, default 30000',
|
|
47
|
+
' --pref-ttl-ms <ms> per-host preference TTL, default 60000',
|
|
48
|
+
' --no-verify-tls skip the TLS reachability probe on 443',
|
|
49
|
+
' --retry-non-idempotent-after-send replay POST/PATCH after the body may have',
|
|
50
|
+
' been sent (dangerous; default off)',
|
|
51
|
+
' --retry-non-idempotent-with-idempotency-key',
|
|
52
|
+
' replay POST/PATCH after send when Idempotency-Key is present',
|
|
53
|
+
' -h, --help',
|
|
54
|
+
' -v, --version',
|
|
55
|
+
''
|
|
56
|
+
].join('\n'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 解析整数并校验范围;失败抛 Error,由顶层统一报一行。 */
|
|
60
|
+
function integer(label, raw, min, max) {
|
|
61
|
+
const value = Number(raw);
|
|
62
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
63
|
+
throw new Error('invalid value for ' + label + ': ' + raw + ' (expected integer ' + min + '..' + max + ')');
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseArgs(argv, env = process.env) {
|
|
69
|
+
const opts = {
|
|
70
|
+
listenHost: env.LISTEN_HOST || '127.0.0.1',
|
|
71
|
+
listenPort: integer('LISTEN_PORT', env.LISTEN_PORT ?? 7898, 1, 65535),
|
|
72
|
+
upstreamHost: env.UPSTREAM_HOST || '127.0.0.1',
|
|
73
|
+
upstreamPort: integer('UPSTREAM_PORT', env.UPSTREAM_PORT ?? 7897, 1, 65535),
|
|
74
|
+
upstreamUrl: env.UPSTREAM_URL || '',
|
|
75
|
+
attemptTimeoutMs: integer('ATTEMPT_TIMEOUT_MS', env.ATTEMPT_TIMEOUT_MS ?? 5000, 100, 60000),
|
|
76
|
+
responseTimeoutMs: integer('RESPONSE_TIMEOUT_MS', env.RESPONSE_TIMEOUT_MS ?? 60000, 100, 600000),
|
|
77
|
+
prefTtlMs: integer('PREF_TTL_MS', env.PREF_TTL_MS ?? 60000, 0, 3600000),
|
|
78
|
+
gateways: env.GATEWAY ? [env.GATEWAY] : [],
|
|
79
|
+
healthUrl: env.HEALTH_URL || '',
|
|
80
|
+
healthIntervalMs: integer('HEALTH_INTERVAL_MS', env.HEALTH_INTERVAL_MS ?? 60000, 0, 3600000),
|
|
81
|
+
fastFailTimeoutMs: integer('FAST_FAIL_MS', env.FAST_FAIL_MS ?? 2000, 100, 60000),
|
|
82
|
+
cooldownMs: integer('COOLDOWN_MS', env.COOLDOWN_MS ?? 30000, 0, 600000),
|
|
83
|
+
retryNonIdempotentAfterSend: env.RETRY_NON_IDEMPOTENT_AFTER_SEND === '1',
|
|
84
|
+
retryNonIdempotentWithIdempotencyKey: env.RETRY_NON_IDEMPOTENT_WITH_IDEMPOTENCY_KEY === '1',
|
|
85
|
+
verifyTls: env.VERIFY_TLS !== '0'
|
|
86
|
+
};
|
|
87
|
+
const rest = [];
|
|
88
|
+
let explicit = false;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
91
|
+
const arg = argv[i];
|
|
92
|
+
if (arg === '--') {
|
|
93
|
+
rest.push(...argv.slice(i + 1));
|
|
94
|
+
explicit = true;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (!arg.startsWith('-')) {
|
|
98
|
+
rest.push(...argv.slice(i));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
const next = () => {
|
|
102
|
+
i += 1;
|
|
103
|
+
if (i >= argv.length) throw new Error('missing value for ' + arg);
|
|
104
|
+
return argv[i];
|
|
105
|
+
};
|
|
106
|
+
if (arg === '--help' || arg === '-h') {
|
|
107
|
+
usage();
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
if (arg === '--version' || arg === '-v') {
|
|
111
|
+
process.stdout.write(pkg.version + '\n');
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
if (FLAGS.includes(arg)) {
|
|
115
|
+
if (arg === '--no-verify-tls') opts.verifyTls = false;
|
|
116
|
+
else if (arg === '--retry-non-idempotent-after-send') opts.retryNonIdempotentAfterSend = true;
|
|
117
|
+
else if (arg === '--retry-non-idempotent-with-idempotency-key') opts.retryNonIdempotentWithIdempotencyKey = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!LAUNCHER_OPTS.includes(arg)) {
|
|
121
|
+
// 不认识的开关当作目标 agent 的参数(例如 claude 自己的 --resume)
|
|
122
|
+
rest.push(...argv.slice(i));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
const value = next();
|
|
126
|
+
if (arg === '--listen-host') opts.listenHost = value;
|
|
127
|
+
else if (arg === '--listen-port') opts.listenPort = integer(arg, value, 1, 65535);
|
|
128
|
+
else if (arg === '--upstream-host') opts.upstreamHost = value;
|
|
129
|
+
else if (arg === '--upstream-port') opts.upstreamPort = integer(arg, value, 1, 65535);
|
|
130
|
+
else if (arg === '--upstream-url') opts.upstreamUrl = value;
|
|
131
|
+
else if (arg === '--gateway') opts.gateways.push(value);
|
|
132
|
+
else if (arg === '--health-url') opts.healthUrl = value;
|
|
133
|
+
else if (arg === '--health-interval-ms') opts.healthIntervalMs = integer(arg, value, 0, 3600000);
|
|
134
|
+
else if (arg === '--timeout-ms') opts.attemptTimeoutMs = integer(arg, value, 100, 60000);
|
|
135
|
+
else if (arg === '--response-timeout-ms') opts.responseTimeoutMs = integer(arg, value, 100, 600000);
|
|
136
|
+
else if (arg === '--fast-fail-ms') opts.fastFailTimeoutMs = integer(arg, value, 100, 60000);
|
|
137
|
+
else if (arg === '--cooldown-ms') opts.cooldownMs = integer(arg, value, 0, 600000);
|
|
138
|
+
else if (arg === '--pref-ttl-ms') opts.prefTtlMs = integer(arg, value, 0, 3600000);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { opts, rest, explicit };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 端口上已经有东西在听就复用,别抢端口。 */
|
|
145
|
+
// ponytail: 只判「有人在听」就复用,不验证它是不是本代理;跑错的监听会静默被当上游用。
|
|
146
|
+
function portInUse(host, port) {
|
|
147
|
+
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)));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function serve(opts) {
|
|
155
|
+
const upstream = resolveUpstream(opts);
|
|
156
|
+
if (!isLoopbackHost(opts.listenHost)) {
|
|
157
|
+
process.stderr.write('warning: listening on ' + opts.listenHost + '; this proxy has no authentication\n');
|
|
158
|
+
}
|
|
159
|
+
const url = proxyUrl(opts);
|
|
160
|
+
if (await portInUse(opts.listenHost, opts.listenPort)) {
|
|
161
|
+
process.stderr.write('[agent-gateway] reusing existing listener on ' + url + '\n');
|
|
162
|
+
return { url, close: async () => {} };
|
|
163
|
+
}
|
|
164
|
+
// 启动环境里的 HTTP(S)_PROXY/ALL_PROXY 也是候选;self 排除自己。
|
|
165
|
+
const gateways = collectGateways({
|
|
166
|
+
upstream,
|
|
167
|
+
extra: opts.gateways,
|
|
168
|
+
env: process.env,
|
|
169
|
+
self: { host: opts.listenHost, port: opts.listenPort },
|
|
170
|
+
onInvalid: (subject, reason) => process.stderr.write('warning: ' + subject + ': ' + reason + '\n')
|
|
171
|
+
});
|
|
172
|
+
const dispatcher = createDispatcher(Object.assign({}, opts, {
|
|
173
|
+
upstreamHost: upstream.host,
|
|
174
|
+
upstreamPort: upstream.port,
|
|
175
|
+
upstreamAuth: upstream.auth,
|
|
176
|
+
gateways,
|
|
177
|
+
log: (msg) => process.stderr.write('[agent-gateway] ' + msg + '\n')
|
|
178
|
+
}));
|
|
179
|
+
const addr = await dispatcher.listen();
|
|
180
|
+
dispatcher.startHealth();
|
|
181
|
+
const bound = proxyUrl({ listenHost: addr.address, listenPort: addr.port });
|
|
182
|
+
process.stderr.write('[agent-gateway] listening on ' + bound + '\n');
|
|
183
|
+
return { url: bound, close: () => dispatcher.stopHealth() || dispatcher.close() };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function run(argv) {
|
|
187
|
+
const { opts, rest, explicit } = parseArgs(argv);
|
|
188
|
+
let target;
|
|
189
|
+
if (rest.length === 0) {
|
|
190
|
+
usage();
|
|
191
|
+
process.exit(2);
|
|
192
|
+
}
|
|
193
|
+
// `run claude ...` 走适配表;`run -- cmd ...` 走裸命令
|
|
194
|
+
const adapter = explicit ? null : resolveAgent(rest[0]);
|
|
195
|
+
if (adapter) {
|
|
196
|
+
target = { bin: adapter.bin, args: rest.slice(1) };
|
|
197
|
+
} else if (explicit) {
|
|
198
|
+
target = { bin: rest[0], args: rest.slice(1) };
|
|
199
|
+
} else {
|
|
200
|
+
process.stderr.write('[agent-gateway] unknown agent: ' + rest[0]
|
|
201
|
+
+ '\n known: ' + AGENT_NAMES.join(', ')
|
|
202
|
+
+ '\n or: agent-gateway run -- <cmd> [args...]\n');
|
|
203
|
+
process.exit(2);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const bridge = await serve(opts);
|
|
207
|
+
const child = spawn(target.bin, target.args, {
|
|
208
|
+
stdio: 'inherit',
|
|
209
|
+
env: buildProxyEnv(bridge.url)
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const stop = async (code) => {
|
|
213
|
+
await bridge.close();
|
|
214
|
+
process.exit(code);
|
|
215
|
+
};
|
|
216
|
+
child.on('error', (err) => {
|
|
217
|
+
process.stderr.write('[agent-gateway] failed to start ' + target.bin + ': ' + err.message + '\n');
|
|
218
|
+
stop(127);
|
|
219
|
+
});
|
|
220
|
+
child.on('exit', (code, signal) => stop(code === null ? 128 + (signal ? 1 : 0) : code));
|
|
221
|
+
|
|
222
|
+
// 让 agent 自己收尾,等它的 exit 事件再关代理,避免留下孤儿进程
|
|
223
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
224
|
+
process.on(signal, () => {
|
|
225
|
+
try { child.kill(signal); } catch (e) { /* ignore */ }
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function printEnv(argv) {
|
|
231
|
+
const { opts } = parseArgs(argv);
|
|
232
|
+
// 以当前 shell 环境为底:NO_PROXY 会把用户原有的直连段一起带出去
|
|
233
|
+
const env = buildProxyEnv(proxyUrl(opts));
|
|
234
|
+
const keys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy', 'NO_PROXY', 'no_proxy'];
|
|
235
|
+
process.stdout.write(keys.map((key) => 'export ' + key + '=' + env[key]).join('\n') + '\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function main() {
|
|
239
|
+
try {
|
|
240
|
+
assertNode();
|
|
241
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
242
|
+
if (command === '-h' || command === '--help' || command === undefined) {
|
|
243
|
+
usage();
|
|
244
|
+
} else if (command === '-v' || command === '--version') {
|
|
245
|
+
process.stdout.write(pkg.version + '\n');
|
|
246
|
+
} else if (command === 'run') {
|
|
247
|
+
await run(rest);
|
|
248
|
+
} else if (command === 'env') {
|
|
249
|
+
await printEnv(rest);
|
|
250
|
+
} else {
|
|
251
|
+
process.stderr.write('[agent-gateway] unknown command: ' + command + '\n');
|
|
252
|
+
usage();
|
|
253
|
+
process.exit(2);
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
process.stderr.write((err && err.message ? err.message : String(err)) + '\n');
|
|
257
|
+
process.exitCode = 2;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
main();
|