claude-org 0.3.0 → 0.4.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 +11 -0
- package/README.md +40 -6
- package/lib/config.js +42 -4
- package/lib/launch.js +38 -3
- package/lib/proxy.js +356 -112
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.0 — 2026-10-11
|
|
6
|
+
|
|
7
|
+
- `proxy`: hardened for unattended use under a supervisor (#22)
|
|
8
|
+
- only the `claude` it launched can use it: a per-session secret is checked (constant time) on every request, `Host` must be `127.0.0.1:<port>`, only `GET` / `POST` / `DELETE` under `/v1/` are forwarded, and bodies over 64 MB get 413. Standalone `node lib/proxy.js` needs `CLAUDE_ORG_PROXY_SECRET`
|
|
9
|
+
- it no longer crashes or hangs: an unreadable token dir gives 503, every request handler error is caught, hop-by-hop headers are dropped, an upstream that sends nothing for 10 min is given up and the next org tried, and a `claude` that cancels or exits mid-response cancels the upstream request
|
|
10
|
+
- the launcher forwards `SIGTERM` / `SIGHUP` (and `SIGINT` without a terminal) to `claude`, waits for it, and sends `SIGKILL` after 10 s, so killing the launcher pid no longer orphans `claude`; with a terminal, Ctrl-C no longer kills the proxy under a running `claude`
|
|
11
|
+
- token files must be mode `0600` and owned by the user, org names are limited to `[A-Za-z0-9_-]`, and `CLAUDE_ORG_UPSTREAM` other than `https://api.anthropic.com` is refused unless `CLAUDE_ORG_UNSAFE_UPSTREAM=1`
|
|
12
|
+
- a 401 from an org (expired token) fails over to the next org, and "no org works" events are logged on fixed-format `ALERT ...` lines a supervisor can grep (README, "Unattended use")
|
|
13
|
+
- the proxy log never records a request's query string (a refused `/v1/messages?...` is logged as such), so a secret put there cannot leak into the log
|
|
14
|
+
- README: unattended runs must use proxy mode; how to keep the token dir away from `claude`'s shell and Read tool
|
|
15
|
+
|
|
5
16
|
## 0.3.0 — 2026-10-10
|
|
6
17
|
|
|
7
18
|
- `proxy`: each log line now names the model (`via <org> model=<model> -> <status>`), and the org and model of the last request are written to a JSON state file whose path is passed to `claude` as `CLAUDE_ORG_STATE_FILE`, for statusline and hooks (#12)
|
package/README.md
CHANGED
|
@@ -82,9 +82,10 @@ claude-org proxy --resume
|
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
1. A session-scoped proxy starts on `127.0.0.1:<random port>` (one per session; no port collisions).
|
|
85
|
-
2. `claude` launches with `ANTHROPIC_BASE_URL` pointing at the proxy and a **
|
|
86
|
-
3. The proxy
|
|
87
|
-
4.
|
|
85
|
+
2. `claude` launches with `ANTHROPIC_BASE_URL` pointing at the proxy and a **per-session secret** as its bearer token (`ANTHROPIC_AUTH_TOKEN`, generated with `crypto.randomBytes`; it exists only in that `claude`'s environment).
|
|
86
|
+
3. The proxy serves only requests that carry that secret, name it in `Host` (`127.0.0.1:<port>`, so a web page cannot reach it by DNS rebinding) and are `GET` / `POST` / `DELETE` under `/v1/`; anything else is refused with 401 / 403 / 404 / 405 and never forwarded. Request bodies over 64 MB get 413.
|
|
87
|
+
4. The proxy replaces the `Authorization` header with a real org token, drops hop-by-hop headers, and forwards to `api.anthropic.com` (any other upstream is refused unless `CLAUDE_ORG_UNSAFE_UPSTREAM=1`). On a 429 or `anthropic-ratelimit-unified-5h-status: rejected` it marks that org limited until `5h-reset` and **resends the same request** with the next org's token. An org whose token is rejected (401: expired or revoked) is skipped the same way for 10 minutes.
|
|
88
|
+
5. Claude Code only ever sees the successful response — the org switched mid-session. SSE streaming is piped through untouched. When Claude Code cancels or exits mid-response, the upstream request is cancelled too; an upstream that sends nothing for 10 minutes is given up and the next org tried.
|
|
88
89
|
|
|
89
90
|
One log line per request (`via <org> model=<model> -> <status>`) goes to a temp file printed at launch (`-> proxy 127.0.0.1:<port> (order: ..., strategy: ..., log: ...)`). The prompt and the tokens are never logged.
|
|
90
91
|
|
|
@@ -122,7 +123,38 @@ A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat t
|
|
|
122
123
|
|---|---|---|---|
|
|
123
124
|
| `claude` | no (default org) | yes | connector work |
|
|
124
125
|
| `claude-org auto --resume` | at launch only | no on preferred org / yes on default | short tasks |
|
|
125
|
-
| `claude-org proxy --resume` | **automatic** | no | long-running work, agents |
|
|
126
|
+
| `claude-org proxy --resume` | **automatic** | no | long-running work, agents, unattended runs |
|
|
127
|
+
|
|
128
|
+
### Unattended use (supervisors, `--dangerously-skip-permissions`)
|
|
129
|
+
|
|
130
|
+
When a supervisor starts `claude` without a person watching (e.g. `claude -p` with `--dangerously-skip-permissions`), launch it through `claude-org proxy`, not `claude-org <org>` / `auto`:
|
|
131
|
+
|
|
132
|
+
- **Use proxy mode only.** `claude-org <org>` and `auto` put the org token into `claude`'s environment as `CLAUDE_CODE_OAUTH_TOKEN`, where a prompt-injected `claude` can read it. In proxy mode the tokens stay in the launcher process; `claude` only holds the per-session proxy secret, which is worth nothing outside that session.
|
|
133
|
+
- **Signals.** The launcher forwards `SIGTERM` / `SIGHUP` (and `SIGINT` when it has no terminal) to `claude`, waits for it to exit, and reports its exit code (`128+signal` when killed); after 10 s it sends `SIGKILL`. So a supervisor that kills the launcher pid does not leave an orphaned `claude`, and the proxy lives exactly as long as `claude` does. With a terminal, Ctrl-C goes to `claude` only (the launcher stays until `claude` exits).
|
|
134
|
+
- **Keep the token files away from `claude`'s shell.** A `claude` running with permissions skipped can `cat ~/.claude/org-tokens/*.token` from Bash. The proxy refuses token files that are not mode `0600` and owned by you, but that does not stop your own `claude`. Deny the directory to sandboxed commands and to the Read tool in the settings of the project the unattended session runs in (both are needed: the sandbox covers shell commands only, the Read tool follows permission rules):
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"permissions": { "deny": ["Read(~/.claude/org-tokens/**)"] },
|
|
139
|
+
"sandbox": {
|
|
140
|
+
"enabled": true,
|
|
141
|
+
"credentials": { "files": [{ "path": "~/.claude/org-tokens", "mode": "deny" }] }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Source: Claude Code docs, [Configure the sandboxed Bash tool](https://code.claude.com/docs/en/sandboxing) (`sandbox.credentials.files` with `"mode": "deny"` applies the same read block as `sandbox.filesystem.denyRead`; the sandbox runs on macOS, Linux and WSL2, not native Windows). Running the proxy as a separate daemon under another user would close the gap fully; it is not implemented, and would build on the shared proxy discussed in #14 / #18.
|
|
147
|
+
- **Detecting "no org works" from outside.** The proxy log (path on the `-> proxy` launch line) has one fixed-format line per such event, so a supervisor or a notifier can grep for `ALERT `:
|
|
148
|
+
|
|
149
|
+
| Line | Meaning |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `ALERT token-rejected org=<org> status=401 -> run: claude setup-token ...` | that org's token is expired or revoked; the org is skipped for 10 min |
|
|
152
|
+
| `ALERT no-org-available model=<model> reason=rate-limited status=429` | every org is over its window; the 429 went to `claude` |
|
|
153
|
+
| `ALERT no-org-available model=<model> reason=model-rejected status=429` | every org refuses this model; switch the model |
|
|
154
|
+
| `ALERT no-org-available model=<model> reason=unreachable status=503` | no org answered (network, timeouts, bad token files) |
|
|
155
|
+
| `ALERT no-org-available model=<model> reason=no-tokens status=503` | the token dir is missing or has no usable `.token` file |
|
|
156
|
+
|
|
157
|
+
The state file (`CLAUDE_ORG_STATE_FILE`) records the same outcome as `status` (`503` with `"org": null`, or `401` with the org).
|
|
126
158
|
|
|
127
159
|
## Environment variables
|
|
128
160
|
|
|
@@ -135,7 +167,9 @@ A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat t
|
|
|
135
167
|
| `CLAUDE_ORG_TOKEN_DIR` | `~/.claude/org-tokens` | Where `<name>.token` files live |
|
|
136
168
|
| `CLAUDE_ORG_NOTIFY_CMD` | (unset) | `watch` notification command, run as `<cmd> "<title>" "<message>"` via the OS shell |
|
|
137
169
|
| `CLAUDE_ORG_WATCH_MAX_SLEEP` | `18000` | Max seconds between `watch` re-checks (values < 60 also lower the 60s minimum — for tests) |
|
|
138
|
-
| `CLAUDE_ORG_UPSTREAM` | `https://api.anthropic.com` | API base URL for `probe` and `proxy` (stub upstream in tests) |
|
|
170
|
+
| `CLAUDE_ORG_UPSTREAM` | `https://api.anthropic.com` | API base URL for `probe` and `proxy` (stub upstream in tests). `proxy` refuses any other value unless `CLAUDE_ORG_UNSAFE_UPSTREAM=1`, since org tokens are sent there |
|
|
171
|
+
| `CLAUDE_ORG_UNSAFE_UPSTREAM` | (unset) | `1` lets `proxy` forward to a `CLAUDE_ORG_UPSTREAM` other than `api.anthropic.com` (tests only) |
|
|
172
|
+
| `CLAUDE_ORG_PROXY_SECRET` | (unset) | Standalone `node lib/proxy.js` only: the bearer value clients must send. `claude-org proxy` generates one per session itself |
|
|
139
173
|
| `CLAUDE_ORG_PROXY` | `1` when launched via `proxy` | Marker env passed to the claude process so user hooks can detect connector-less sessions |
|
|
140
174
|
| `CLAUDE_ORG_STATE_FILE` | set when launched via `proxy` | Path of the JSON file holding the org and model of the last request (read it from a statusline or hook) |
|
|
141
175
|
| `CLAUDE_ORG_FORCE_PROBE` | (unset) | Test seam: `allowed` / `rejected[:<epoch>]` / `rejected_until:<epoch>` fakes `probe` results without API calls |
|
|
@@ -143,7 +177,7 @@ A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat t
|
|
|
143
177
|
|
|
144
178
|
## Notes
|
|
145
179
|
|
|
146
|
-
- **Token safety**: tokens are read from files only and never printed to logs, errors, or test snapshots. The proxy binds to `127.0.0.1` only.
|
|
180
|
+
- **Token safety**: tokens are read from files only and never printed to logs, errors, or test snapshots. A token file must be mode `0600` and owned by you (`chmod 600 ~/.claude/org-tokens/*.token`); one that other users can read is refused. Org names are limited to letters, digits, `_` and `-`. The proxy binds to `127.0.0.1` only and serves only the `claude` it launched (see [Unattended use](#unattended-use-supervisors---dangerously-skip-permissions)).
|
|
147
181
|
- **probe cost**: each `probe` sends one `max_tokens=1` request (~11 tokens billed to that org), once per `auto` launch.
|
|
148
182
|
- **Token expiry**: tokens last ~1 year. If `probe` returns `status=unknown` or `status` shows `(auth status failed)`, rerun `claude setup-token` and overwrite the file.
|
|
149
183
|
|
package/lib/config.js
CHANGED
|
@@ -7,7 +7,16 @@ function tokenDir() {
|
|
|
7
7
|
return process.env.CLAUDE_ORG_TOKEN_DIR || path.join(os.homedir(), '.claude', 'org-tokens');
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
//
|
|
10
|
+
// An org name is used as a file name under the token dir, so anything that
|
|
11
|
+
// could leave the dir ("../x", "a/b") or confuse a shell is refused.
|
|
12
|
+
const ORG_NAME_RE = /^[\w-]+$/;
|
|
13
|
+
function validOrgName(name) {
|
|
14
|
+
return typeof name === 'string' && ORG_NAME_RE.test(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Saved org names: *.token files, extension stripped, sorted. A file whose
|
|
18
|
+
// name is not a valid org name is left out, and said so once.
|
|
19
|
+
const reportedBadNames = new Set();
|
|
11
20
|
function listOrgs(dir = tokenDir()) {
|
|
12
21
|
let files;
|
|
13
22
|
try {
|
|
@@ -15,12 +24,36 @@ function listOrgs(dir = tokenDir()) {
|
|
|
15
24
|
} catch {
|
|
16
25
|
return [];
|
|
17
26
|
}
|
|
18
|
-
|
|
27
|
+
const names = files.filter(f => f.endsWith('.token')).map(f => f.slice(0, -6));
|
|
28
|
+
for (const n of names) {
|
|
29
|
+
if (validOrgName(n) || reportedBadNames.has(n)) continue;
|
|
30
|
+
reportedBadNames.add(n);
|
|
31
|
+
console.error(`claude-org: ignoring ${path.join(dir, `${n}.token`)}: not an org name (letters, digits, _ and - only)`);
|
|
32
|
+
}
|
|
33
|
+
return names.filter(validOrgName).sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Why a token file must be private to the user: the proxy sends its content
|
|
37
|
+
// as a Bearer token, so a file another local user can read hands them that
|
|
38
|
+
// org. Returns '' when the file is fine, else the reason it is refused.
|
|
39
|
+
// Not checked on Windows (no POSIX mode bits there).
|
|
40
|
+
function tokenFileProblem(file) {
|
|
41
|
+
if (process.platform === 'win32' || typeof process.getuid !== 'function') return '';
|
|
42
|
+
const st = fs.statSync(file);
|
|
43
|
+
const me = process.getuid();
|
|
44
|
+
if (st.uid !== me) return `is owned by uid ${st.uid}, not by you (uid ${me})`;
|
|
45
|
+
if (st.mode & 0o077) return `is readable by other users (mode ${(st.mode & 0o777).toString(8)}); run: chmod 600 ${file}`;
|
|
46
|
+
return '';
|
|
19
47
|
}
|
|
20
48
|
|
|
21
49
|
// Read a token file, stripping CR/LF. Returns null (after printing guidance)
|
|
22
|
-
// when the file is missing or empty
|
|
50
|
+
// when the name is invalid, the file is missing or empty, or the file is not
|
|
51
|
+
// private to the user.
|
|
23
52
|
function readToken(name, dir = tokenDir()) {
|
|
53
|
+
if (!validOrgName(name)) {
|
|
54
|
+
console.error(`claude-org: invalid org name "${name}" (letters, digits, _ and - only)`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
24
57
|
const f = path.join(dir, `${name}.token`);
|
|
25
58
|
let raw = '';
|
|
26
59
|
try {
|
|
@@ -33,6 +66,11 @@ function readToken(name, dir = tokenDir()) {
|
|
|
33
66
|
console.error(` 取得: claude setup-token でブラウザから org '${name}' を選び、出力トークンを ${f} に保存(改行なし)`);
|
|
34
67
|
return null;
|
|
35
68
|
}
|
|
69
|
+
const problem = tokenFileProblem(f);
|
|
70
|
+
if (problem) {
|
|
71
|
+
console.error(`claude-org: token file ${f} ${problem}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
36
74
|
return raw.replace(/[\r\n]/g, '');
|
|
37
75
|
}
|
|
38
76
|
|
|
@@ -52,4 +90,4 @@ function resolvePrefer() {
|
|
|
52
90
|
process.exit(1);
|
|
53
91
|
}
|
|
54
92
|
|
|
55
|
-
module.exports = { tokenDir, listOrgs, readToken, resolvePrefer };
|
|
93
|
+
module.exports = { tokenDir, listOrgs, readToken, resolvePrefer, validOrgName, tokenFileProblem };
|
package/lib/launch.js
CHANGED
|
@@ -2,27 +2,62 @@
|
|
|
2
2
|
const { spawnClaude } = require('./claude-spawn');
|
|
3
3
|
const { readToken } = require('./config');
|
|
4
4
|
|
|
5
|
+
// After a forwarded SIGTERM / SIGHUP, how long claude gets to exit on its own
|
|
6
|
+
// before it is killed. Keeps a hung claude from outliving a supervisor's kill.
|
|
7
|
+
const KILL_GRACE_MS = 10 * 1000;
|
|
8
|
+
const FORWARDED_SIGNALS = ['SIGTERM', 'SIGHUP', 'SIGINT'];
|
|
9
|
+
|
|
5
10
|
// Spawn claude with inherited stdio and extra env. Resolves to the child's
|
|
6
11
|
// exit code (128+n when killed by signal n, like a shell would report).
|
|
12
|
+
//
|
|
13
|
+
// Signals sent to this launcher (a supervisor's reaper or timeout kills the
|
|
14
|
+
// launcher pid, not claude) are forwarded to claude, and the launcher waits
|
|
15
|
+
// for claude to exit instead of dying first and leaving claude orphaned with
|
|
16
|
+
// its proxy gone. SIGINT from a terminal (Ctrl-C) reaches both processes at
|
|
17
|
+
// once; forwarding it would give claude a second Ctrl-C, which it reads as
|
|
18
|
+
// "exit". So with a terminal on any of stdin/stdout/stderr, SIGINT is left to
|
|
19
|
+
// claude; with none (headless, under a supervisor) it is forwarded like the
|
|
20
|
+
// others.
|
|
7
21
|
function runClaude(args, envExtra = {}) {
|
|
8
22
|
return new Promise(resolve => {
|
|
9
23
|
const child = spawnClaude(args, {
|
|
10
24
|
stdio: 'inherit',
|
|
11
25
|
env: { ...process.env, ...envExtra },
|
|
12
26
|
});
|
|
27
|
+
let killTimer = null;
|
|
28
|
+
const handlers = new Map();
|
|
29
|
+
for (const sig of FORWARDED_SIGNALS) {
|
|
30
|
+
const h = () => {
|
|
31
|
+
if (sig === 'SIGINT' && (process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY)) return;
|
|
32
|
+
try { child.kill(sig); } catch { /* already exited */ }
|
|
33
|
+
if (!killTimer) {
|
|
34
|
+
killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already exited */ } }, KILL_GRACE_MS);
|
|
35
|
+
killTimer.unref();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
handlers.set(sig, h);
|
|
39
|
+
process.on(sig, h);
|
|
40
|
+
}
|
|
41
|
+
const done = code => {
|
|
42
|
+
for (const [sig, h] of handlers) process.off(sig, h);
|
|
43
|
+
if (killTimer) clearTimeout(killTimer);
|
|
44
|
+
resolve(code);
|
|
45
|
+
};
|
|
13
46
|
child.on('error', e => {
|
|
14
47
|
console.error(`claude-org: failed to launch claude: ${e.message}`);
|
|
15
|
-
|
|
48
|
+
done(1);
|
|
16
49
|
});
|
|
17
50
|
child.on('exit', (code, signal) => {
|
|
18
|
-
if (code !== null) return
|
|
19
|
-
|
|
51
|
+
if (code !== null) return done(code);
|
|
52
|
+
done(signal ? 128 + ({ SIGINT: 2, SIGTERM: 15, SIGKILL: 9, SIGHUP: 1 }[signal] || 1) : 1);
|
|
20
53
|
});
|
|
21
54
|
});
|
|
22
55
|
}
|
|
23
56
|
|
|
24
57
|
// launch(name, args): 'default' launches a plain `claude`; a token org launches
|
|
25
58
|
// with CLAUDE_CODE_OAUTH_TOKEN set. Returns the process exit code.
|
|
59
|
+
// The token goes into claude's environment, where a prompt-injected claude can
|
|
60
|
+
// read it; unattended runs should use `claude-org proxy` instead (README).
|
|
26
61
|
async function launch(name, args) {
|
|
27
62
|
if (name === 'default') return runClaude(args);
|
|
28
63
|
const tok = readToken(name);
|
package/lib/proxy.js
CHANGED
|
@@ -16,25 +16,35 @@
|
|
|
16
16
|
// `Authorization: Bearer` alone, but if `x-api-key` is also present it is checked
|
|
17
17
|
// first and returns 401. The proxy drops x-api-key and sends Bearer only.
|
|
18
18
|
//
|
|
19
|
+
// The proxy holds org tokens, so it only serves the one claude it was started
|
|
20
|
+
// for: each request must carry a per-session secret (Authorization: Bearer,
|
|
21
|
+
// what claude sends for ANTHROPIC_AUTH_TOKEN), name the proxy itself in Host
|
|
22
|
+
// (no DNS rebinding from a browser), and be a GET/POST/DELETE under /v1/.
|
|
23
|
+
//
|
|
19
24
|
// Env (standalone mode):
|
|
20
|
-
// PORT
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
25
|
+
// PORT listen port (default 0 = OS picks; reported on the
|
|
26
|
+
// "listening on" stderr line)
|
|
27
|
+
// CLAUDE_ORG_PROXY_SECRET the Bearer value clients must send (required)
|
|
28
|
+
// CLAUDE_ORG_TOKEN_DIR where <name>.token files live (~/.claude/org-tokens)
|
|
29
|
+
// CLAUDE_ORG_ORDER org priority, comma-separated (default: alphabetical)
|
|
30
|
+
// CLAUDE_ORG_STRATEGY "least-utilized": prefer the usable org with the
|
|
31
|
+
// lowest 5h utilization seen so far (default: ORDER)
|
|
32
|
+
// CLAUDE_ORG_UPSTREAM forward target (default https://api.anthropic.com;
|
|
33
|
+
// anything else needs CLAUDE_ORG_UNSAFE_UPSTREAM=1)
|
|
34
|
+
// CLAUDE_ORG_REJECT_FILE test seam: orgs listed comma-separated in this file
|
|
35
|
+
// are treated as rate-limited
|
|
36
|
+
// CLAUDE_ORG_STATE_FILE where the org and model of the last /v1/messages
|
|
37
|
+
// response are written as JSON (unset: not written)
|
|
31
38
|
const http = require('http');
|
|
32
39
|
const https = require('https');
|
|
33
40
|
const fs = require('fs');
|
|
34
41
|
const path = require('path');
|
|
35
42
|
const os = require('os');
|
|
36
43
|
const zlib = require('zlib');
|
|
44
|
+
const crypto = require('crypto');
|
|
45
|
+
const { validOrgName, tokenFileProblem } = require('./config');
|
|
37
46
|
|
|
47
|
+
const DEFAULT_UPSTREAM = 'https://api.anthropic.com';
|
|
38
48
|
// How long an org stays out when it is org-wide limited but sent no reset time,
|
|
39
49
|
// and the longest a model-specific refusal keeps an (org, model) pair out.
|
|
40
50
|
const ORG_REJECT_FALLBACK_MS = 10 * 60 * 1000;
|
|
@@ -43,6 +53,37 @@ const MODEL_REJECT_MAX_MS = 10 * 60 * 1000;
|
|
|
43
53
|
// from exhausting memory: the raw body read, and the decompressed copy.
|
|
44
54
|
const ERROR_BODY_MAX_BYTES = 1024 * 1024;
|
|
45
55
|
const ERROR_DECODED_MAX_BYTES = 64 * 1024;
|
|
56
|
+
// The request body is buffered whole (it may be resent to another org). The
|
|
57
|
+
// API's own limit is 32 MB; this cap only keeps a runaway client from filling
|
|
58
|
+
// memory.
|
|
59
|
+
const REQUEST_BODY_MAX_BYTES = 64 * 1024 * 1024;
|
|
60
|
+
// Idle time on the upstream connection (no bytes in either direction) after
|
|
61
|
+
// which the request is given up and the next org tried. Claude Code's own
|
|
62
|
+
// API_TIMEOUT_MS defaults to the same 10 minutes.
|
|
63
|
+
const UPSTREAM_TIMEOUT_MS = 10 * 60 * 1000;
|
|
64
|
+
const ALLOWED_METHODS = new Set(['GET', 'POST', 'DELETE']);
|
|
65
|
+
// Hop-by-hop headers (RFC 9110 §7.6.1) describe one connection and must not be
|
|
66
|
+
// forwarded; the body is re-framed by this proxy anyway.
|
|
67
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
68
|
+
'te', 'trailer', 'trailers', 'transfer-encoding', 'upgrade']);
|
|
69
|
+
|
|
70
|
+
// A copy of `headers` without the hop-by-hop ones and those the Connection
|
|
71
|
+
// header names.
|
|
72
|
+
function stripHopByHop(headers) {
|
|
73
|
+
const named = new Set(String(headers.connection || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
76
|
+
const lk = k.toLowerCase();
|
|
77
|
+
if (!HOP_BY_HOP.has(lk) && !named.has(lk)) out[k] = v;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Constant-time comparison that does not leak the secret's length either.
|
|
83
|
+
function secretMatches(given, secret) {
|
|
84
|
+
const h = s => crypto.createHash('sha256').update(String(s)).digest();
|
|
85
|
+
return crypto.timingSafeEqual(h(given), h(secret));
|
|
86
|
+
}
|
|
46
87
|
|
|
47
88
|
// The "model" field of a /v1/messages request body, or '?' when the body is
|
|
48
89
|
// not JSON (the proxy must still forward it). Used as is as the quarantine
|
|
@@ -56,11 +97,17 @@ function requestModel(body) {
|
|
|
56
97
|
}
|
|
57
98
|
}
|
|
58
99
|
|
|
59
|
-
//
|
|
60
|
-
// cut short.
|
|
100
|
+
// Client input for the log: limited to safe characters and cut short.
|
|
61
101
|
function logModel(model) {
|
|
62
102
|
return model.replace(/[^\w.:@/-]/g, '_').slice(0, 80);
|
|
63
103
|
}
|
|
104
|
+
// The query string is dropped: a client may put secrets there, and the log
|
|
105
|
+
// only needs to show which path was refused.
|
|
106
|
+
function logPath(url) {
|
|
107
|
+
const s = String(url);
|
|
108
|
+
const q = s.indexOf('?');
|
|
109
|
+
return (q === -1 ? s : s.slice(0, q) + '?...').replace(/[^\w.:@/?-]/g, '_').slice(0, 120);
|
|
110
|
+
}
|
|
64
111
|
|
|
65
112
|
// Milliseconds from a Retry-After header, which is either delay-seconds or an
|
|
66
113
|
// HTTP-date. NaN when absent or unparsable.
|
|
@@ -87,6 +134,8 @@ function readBody(res, maxBytes) {
|
|
|
87
134
|
});
|
|
88
135
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
89
136
|
res.on('error', reject);
|
|
137
|
+
// destroy() without an error (the client went away) emits neither
|
|
138
|
+
res.on('close', () => reject(new Error('upstream closed before the 429 body ended')));
|
|
90
139
|
});
|
|
91
140
|
}
|
|
92
141
|
|
|
@@ -121,27 +170,54 @@ function createProxy(opts = {}) {
|
|
|
121
170
|
|| path.join(os.homedir(), '.claude', 'org-tokens');
|
|
122
171
|
const ORDER = opts.order
|
|
123
172
|
|| (process.env.CLAUDE_ORG_ORDER || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
124
|
-
const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM ||
|
|
173
|
+
const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM || DEFAULT_UPSTREAM);
|
|
125
174
|
const REJECT_FILE = 'rejectFile' in opts ? opts.rejectFile : (process.env.CLAUDE_ORG_REJECT_FILE || '');
|
|
126
175
|
const STATE_FILE = 'stateFile' in opts ? opts.stateFile : (process.env.CLAUDE_ORG_STATE_FILE || '');
|
|
127
176
|
const STRATEGY = 'strategy' in opts ? opts.strategy : (process.env.CLAUDE_ORG_STRATEGY || '');
|
|
177
|
+
const SECRET = 'secret' in opts ? opts.secret : (process.env.CLAUDE_ORG_PROXY_SECRET || '');
|
|
178
|
+
const ALLOW_ANY_UPSTREAM = 'allowAnyUpstream' in opts ? opts.allowAnyUpstream : process.env.CLAUDE_ORG_UNSAFE_UPSTREAM === '1';
|
|
179
|
+
const MAX_BODY = opts.maxBodyBytes || REQUEST_BODY_MAX_BYTES;
|
|
180
|
+
const UPSTREAM_TIMEOUT = opts.upstreamTimeoutMs || UPSTREAM_TIMEOUT_MS;
|
|
128
181
|
const out = opts.log || process.stderr;
|
|
129
182
|
const transport = UPSTREAM.protocol === 'http:' ? http : https;
|
|
130
183
|
|
|
184
|
+
// Org tokens go to the upstream as Bearer, so the upstream is pinned to the
|
|
185
|
+
// real API unless a test says otherwise.
|
|
186
|
+
if (UPSTREAM.href !== `${DEFAULT_UPSTREAM}/` && !ALLOW_ANY_UPSTREAM) {
|
|
187
|
+
throw new Error(`CLAUDE_ORG_UPSTREAM=${UPSTREAM.href} is not ${DEFAULT_UPSTREAM}; org tokens are only sent there (set CLAUDE_ORG_UNSAFE_UPSTREAM=1 for a test upstream)`);
|
|
188
|
+
}
|
|
189
|
+
if (!SECRET) {
|
|
190
|
+
throw new Error('proxy secret missing: set CLAUDE_ORG_PROXY_SECRET (claude-org proxy generates one per session)');
|
|
191
|
+
}
|
|
192
|
+
|
|
131
193
|
function log(...a) {
|
|
132
194
|
out.write(`[claude-org-proxy ${new Date().toISOString().slice(11, 19)}] ${a.join(' ')}\n`);
|
|
133
195
|
}
|
|
134
196
|
const leastUtilized = STRATEGY === 'least-utilized';
|
|
135
197
|
if (STRATEGY && !leastUtilized) log(`unknown CLAUDE_ORG_STRATEGY "${STRATEGY}" -> using the static order`);
|
|
136
198
|
|
|
199
|
+
const reportedBadNames = new Set();
|
|
137
200
|
function listOrgs() {
|
|
138
|
-
const
|
|
201
|
+
const names = fs.readdirSync(TOKEN_DIR).filter(f => f.endsWith('.token')).map(f => f.slice(0, -6));
|
|
202
|
+
for (const n of names) {
|
|
203
|
+
if (validOrgName(n) || reportedBadNames.has(n)) continue;
|
|
204
|
+
reportedBadNames.add(n);
|
|
205
|
+
log(`ignoring ${TOKEN_DIR}/${logModel(n)}.token: not an org name (letters, digits, _ and - only)`);
|
|
206
|
+
}
|
|
207
|
+
const files = names.filter(validOrgName).sort();
|
|
139
208
|
if (ORDER.length === 0) return files;
|
|
140
209
|
const known = new Set(files);
|
|
141
210
|
return ORDER.filter(o => known.has(o)).concat(files.filter(f => !ORDER.includes(f)));
|
|
142
211
|
}
|
|
212
|
+
// Throws when the file is missing or not private to the user (S5): the
|
|
213
|
+
// caller skips that org for this request.
|
|
143
214
|
function readToken(name) {
|
|
144
|
-
|
|
215
|
+
const f = path.join(TOKEN_DIR, `${name}.token`);
|
|
216
|
+
const problem = tokenFileProblem(f);
|
|
217
|
+
if (problem) throw new Error(`token file ${f} ${problem}`);
|
|
218
|
+
const tok = fs.readFileSync(f, 'utf8').replace(/[\r\n]/g, '');
|
|
219
|
+
if (!tok) throw new Error(`token file ${f} is empty`);
|
|
220
|
+
return tok;
|
|
145
221
|
}
|
|
146
222
|
|
|
147
223
|
// Per-org "rate-limited" memory: name -> recovery epoch (ms). Expired entries
|
|
@@ -230,123 +306,252 @@ function createProxy(opts = {}) {
|
|
|
230
306
|
}
|
|
231
307
|
}
|
|
232
308
|
|
|
233
|
-
|
|
309
|
+
// One upstream request. Resolves with the response once its headers are in;
|
|
310
|
+
// rejects on connection errors, on idle timeout, and when `signal` aborts
|
|
311
|
+
// (the client went away). The response stream can still fail later; the
|
|
312
|
+
// caller listens for that.
|
|
313
|
+
function forward(token, method, urlPath, headers, body, signal) {
|
|
234
314
|
return new Promise((resolve, reject) => {
|
|
235
|
-
const h =
|
|
315
|
+
const h = stripHopByHop(headers);
|
|
236
316
|
for (const k of Object.keys(h)) {
|
|
237
317
|
const lk = k.toLowerCase();
|
|
238
318
|
if (lk === 'x-api-key' || lk === 'host' || lk === 'content-length' || lk === 'authorization') delete h[k];
|
|
239
319
|
}
|
|
240
|
-
h.authorization = `Bearer ${
|
|
320
|
+
h.authorization = `Bearer ${token}`;
|
|
241
321
|
h.host = UPSTREAM.host;
|
|
242
322
|
if (body.length) h['content-length'] = String(body.length);
|
|
243
323
|
const req = transport.request(
|
|
244
|
-
{
|
|
324
|
+
{
|
|
325
|
+
hostname: UPSTREAM.hostname,
|
|
326
|
+
port: UPSTREAM.port || (transport === https ? 443 : 80),
|
|
327
|
+
path: urlPath,
|
|
328
|
+
method,
|
|
329
|
+
headers: h,
|
|
330
|
+
signal,
|
|
331
|
+
timeout: UPSTREAM_TIMEOUT,
|
|
332
|
+
},
|
|
245
333
|
res => resolve(res),
|
|
246
334
|
);
|
|
335
|
+
req.on('timeout', () => req.destroy(new Error(`no data from upstream for ${UPSTREAM_TIMEOUT} ms`)));
|
|
247
336
|
req.on('error', reject);
|
|
248
337
|
if (body.length) req.write(body);
|
|
249
338
|
req.end();
|
|
250
339
|
});
|
|
251
340
|
}
|
|
252
341
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
342
|
+
function refuse(clientRes, status, message) {
|
|
343
|
+
clientRes.writeHead(status, { 'content-type': 'application/json', connection: 'close' });
|
|
344
|
+
clientRes.end(JSON.stringify({ type: 'error', error: { type: 'claude_org_proxy', message } }));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Checks done before any body is read or anything is forwarded. Returns
|
|
348
|
+
// [status, message] for a request to refuse, or null.
|
|
349
|
+
function gate(clientReq) {
|
|
350
|
+
const auth = clientReq.headers.authorization || '';
|
|
351
|
+
if (!auth.startsWith('Bearer ') || !secretMatches(auth.slice(7), SECRET)) {
|
|
352
|
+
return [401, 'missing or wrong proxy secret (the Bearer token claude-org proxy gave this session)'];
|
|
353
|
+
}
|
|
354
|
+
const addr = server.address(); // null once the server is closing
|
|
355
|
+
if (!addr) return [503, 'proxy is shutting down'];
|
|
356
|
+
const expectedHost = `127.0.0.1:${addr.port}`;
|
|
357
|
+
if (clientReq.headers.host !== expectedHost) return [403, `Host must be ${expectedHost}`];
|
|
358
|
+
if (!ALLOWED_METHODS.has(clientReq.method)) return [405, `method ${clientReq.method} is not forwarded`];
|
|
359
|
+
// The path is forwarded as is, so nothing that an upstream could
|
|
360
|
+
// normalise out of /v1/ ("..", "//", "\\", %2e) is accepted.
|
|
361
|
+
const pathOnly = clientReq.url.split('?')[0];
|
|
362
|
+
if (!pathOnly.startsWith('/v1/') || /\.\.|\/\/|\\|%2e/i.test(pathOnly)) return [404, 'only /v1/* is forwarded'];
|
|
363
|
+
const announced = Number(clientReq.headers['content-length']);
|
|
364
|
+
if (announced > MAX_BODY) return [413, `request body larger than ${MAX_BODY} bytes`];
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function handle(clientReq, clientRes, body) {
|
|
369
|
+
// The client going away (Claude Code cancelled, or exited) cancels the
|
|
370
|
+
// upstream request in flight and stops a response being piped, so no org
|
|
371
|
+
// keeps generating for nobody.
|
|
372
|
+
const abort = new AbortController();
|
|
373
|
+
let clientGone = false;
|
|
374
|
+
let up = null;
|
|
375
|
+
clientRes.on('close', () => {
|
|
376
|
+
if (clientRes.writableFinished) return;
|
|
377
|
+
clientGone = true;
|
|
378
|
+
abort.abort();
|
|
379
|
+
if (up) up.destroy();
|
|
380
|
+
});
|
|
381
|
+
const isModelReq = clientReq.url.startsWith('/v1/messages');
|
|
382
|
+
const model = isModelReq ? requestModel(body) : '';
|
|
383
|
+
let order;
|
|
384
|
+
try {
|
|
385
|
+
order = pickOrder(model);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
log(`cannot list orgs in ${TOKEN_DIR}: ${e.message}`);
|
|
388
|
+
order = [];
|
|
389
|
+
}
|
|
390
|
+
let attempt = 0;
|
|
391
|
+
let modelRefusals = 0;
|
|
392
|
+
for (const org of order) {
|
|
393
|
+
attempt++;
|
|
394
|
+
if (clientGone) return;
|
|
395
|
+
let token;
|
|
396
|
+
try {
|
|
397
|
+
token = readToken(org);
|
|
398
|
+
} catch (e) {
|
|
399
|
+
log(`skipping ${org}: ${e.message}`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
up = await forward(token, clientReq.method, clientReq.url, clientReq.headers, body, abort.signal);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
if (clientGone) return;
|
|
406
|
+
log(`upstream error via ${org}: ${e.message}`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
// A response that fails after its headers (idle timeout, reset) is only
|
|
410
|
+
// logged here; whether the client is cut off is decided where it is piped.
|
|
411
|
+
up.on('error', e => log(`upstream error via ${org} while reading the response: ${e.message}`));
|
|
412
|
+
const hdr = name => up.headers[`anthropic-ratelimit-unified-${name}`];
|
|
413
|
+
const status5h = hdr('5h-status');
|
|
414
|
+
const status7d = hdr('7d-status');
|
|
415
|
+
const claim = hdr('representative-claim');
|
|
416
|
+
const util5h = Number(hdr('5h-utilization'));
|
|
417
|
+
if (isModelReq && hdr('5h-utilization') !== undefined && Number.isFinite(util5h)) {
|
|
418
|
+
recordUtil(org, util5h, Number(hdr('5h-reset')));
|
|
419
|
+
} else if (isModelReq) {
|
|
420
|
+
answeredUnmeasured.add(org);
|
|
421
|
+
}
|
|
422
|
+
const isLast = attempt >= order.length;
|
|
423
|
+
// A 401 means the org's token is expired or revoked (they last about a
|
|
424
|
+
// year), not that the request is bad: the org is kept out for a while
|
|
425
|
+
// and the next one tried. The ALERT line is the fixed format a
|
|
426
|
+
// supervisor can watch for (README, "Unattended use").
|
|
427
|
+
if (isModelReq && up.statusCode === 401) {
|
|
428
|
+
log(`ALERT token-rejected org=${org} status=401 -> run: claude setup-token (org ${org}) and overwrite ${path.join(TOKEN_DIR, `${org}.token`)}`);
|
|
429
|
+
rejectedUntil.set(org, Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
430
|
+
if (!isLast) {
|
|
431
|
+
up.resume();
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// The 5h and 7d windows cover every model of the org, so only those make
|
|
436
|
+
// the org as a whole unusable. Other refusals are per model: a used-up
|
|
437
|
+
// per-model weekly allowance (claim=seven_day_overage_included) or a model
|
|
438
|
+
// that needs usage credits (429 with no window headers). The overall
|
|
439
|
+
// "unified-status" header is not used: it also reads "rejected" for those.
|
|
440
|
+
const orgWide = status5h === 'rejected' || status7d === 'rejected';
|
|
441
|
+
const limited = up.statusCode === 429 || orgWide;
|
|
442
|
+
const windows = `5h=${status5h ?? '?'}${claim ? `, claim=${claim}` : ''}`;
|
|
443
|
+
// A 429 body is a small JSON error; buffer it to log the reason and,
|
|
444
|
+
// if no org is left, to hand it to the client unchanged.
|
|
445
|
+
let errBody = null;
|
|
446
|
+
let reason = '';
|
|
447
|
+
if (isModelReq && up.statusCode === 429) {
|
|
266
448
|
try {
|
|
267
|
-
|
|
449
|
+
errBody = await readBody(up, ERROR_BODY_MAX_BYTES);
|
|
268
450
|
} catch (e) {
|
|
269
|
-
log(`upstream error via ${org}: ${e.message}`);
|
|
451
|
+
log(`upstream error via ${org} while reading 429 body: ${e.message}`);
|
|
270
452
|
continue;
|
|
271
453
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
try {
|
|
296
|
-
errBody = await readBody(up, ERROR_BODY_MAX_BYTES);
|
|
297
|
-
} catch (e) {
|
|
298
|
-
log(`upstream error via ${org} while reading 429 body: ${e.message}`);
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
reason = describeError(errBody, up.headers['content-encoding']);
|
|
302
|
-
}
|
|
303
|
-
const modelRefused = isModelReq && up.statusCode === 429 && !orgWide;
|
|
304
|
-
if (modelRefused) {
|
|
305
|
-
// retry-after is honoured when shorter (a transient per-minute limit
|
|
306
|
-
// must not keep the preferred org out for long); a long one (a weekly
|
|
307
|
-
// per-model allowance) is capped so the org is re-checked now and then.
|
|
308
|
-
const ra = retryAfterMs(up.headers['retry-after']);
|
|
309
|
-
const ms = Number.isFinite(ra) ? Math.min(ra, MODEL_REJECT_MAX_MS) : MODEL_REJECT_MAX_MS;
|
|
310
|
-
modelRejectedUntil.set(`${org}|${model}`, Date.now() + ms);
|
|
311
|
-
}
|
|
312
|
-
const isLast = attempt >= order.length;
|
|
313
|
-
if (limited && isModelReq && !isLast) {
|
|
314
|
-
if (orgWide) {
|
|
315
|
-
// When both windows are rejected the org is back only after the later reset.
|
|
316
|
-
const resets = [['5h', status5h], ['7d', status7d]]
|
|
317
|
-
.filter(([, s]) => s === 'rejected')
|
|
318
|
-
.map(([w]) => Number(hdr(`${w}-reset`)))
|
|
319
|
-
.filter(Number.isFinite);
|
|
320
|
-
rejectedUntil.set(org, resets.length ? Math.max(...resets) * 1000 : Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
321
|
-
log(`${org} is rate-limited (HTTP ${up.statusCode}, ${windows}) -> retrying with next org${reason ? ` (${reason})` : ''}`);
|
|
322
|
-
} else {
|
|
323
|
-
modelRefusals++;
|
|
324
|
-
log(`${org} rejected model ${logModel(model)} (HTTP ${up.statusCode}, ${windows}, ${reason}) -> retrying with next org; ${org} stays in use for other models`);
|
|
325
|
-
}
|
|
326
|
-
if (!errBody) up.resume(); // discard the response body and try the next org
|
|
327
|
-
continue;
|
|
454
|
+
reason = describeError(errBody, up.headers['content-encoding']);
|
|
455
|
+
}
|
|
456
|
+
const modelRefused = isModelReq && up.statusCode === 429 && !orgWide;
|
|
457
|
+
if (modelRefused) {
|
|
458
|
+
// retry-after is honoured when shorter (a transient per-minute limit
|
|
459
|
+
// must not keep the preferred org out for long); a long one (a weekly
|
|
460
|
+
// per-model allowance) is capped so the org is re-checked now and then.
|
|
461
|
+
const ra = retryAfterMs(up.headers['retry-after']);
|
|
462
|
+
const ms = Number.isFinite(ra) ? Math.min(ra, MODEL_REJECT_MAX_MS) : MODEL_REJECT_MAX_MS;
|
|
463
|
+
modelRejectedUntil.set(`${org}|${model}`, Date.now() + ms);
|
|
464
|
+
}
|
|
465
|
+
if (limited && isModelReq && !isLast) {
|
|
466
|
+
if (orgWide) {
|
|
467
|
+
// When both windows are rejected the org is back only after the later reset.
|
|
468
|
+
const resets = [['5h', status5h], ['7d', status7d]]
|
|
469
|
+
.filter(([, s]) => s === 'rejected')
|
|
470
|
+
.map(([w]) => Number(hdr(`${w}-reset`)))
|
|
471
|
+
.filter(Number.isFinite);
|
|
472
|
+
rejectedUntil.set(org, resets.length ? Math.max(...resets) * 1000 : Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
473
|
+
log(`${org} is rate-limited (HTTP ${up.statusCode}, ${windows}) -> retrying with next org${reason ? ` (${reason})` : ''}`);
|
|
474
|
+
} else {
|
|
475
|
+
modelRefusals++;
|
|
476
|
+
log(`${org} rejected model ${logModel(model)} (HTTP ${up.statusCode}, ${windows}, ${reason}) -> retrying with next org; ${org} stays in use for other models`);
|
|
328
477
|
}
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
478
|
+
if (!errBody) up.resume(); // discard the response body and try the next org
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (isModelReq) {
|
|
482
|
+
const util = hdr('5h-utilization');
|
|
483
|
+
log(`${clientReq.method} ${clientReq.url} via ${org} model=${logModel(model)} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
|
|
484
|
+
writeState(org, model, up.statusCode);
|
|
485
|
+
if (modelRefused && modelRefusals === attempt - 1) {
|
|
486
|
+
log(`all orgs rejected model ${logModel(model)} -> switch the model (e.g. /model) or wait for that model's limit to reset; the org tokens are not the cause`);
|
|
336
487
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
clientRes.end(errBody);
|
|
340
|
-
} else {
|
|
341
|
-
up.pipe(clientRes); // SSE streaming is piped through untouched
|
|
488
|
+
if (limited) {
|
|
489
|
+
log(`ALERT no-org-available model=${logModel(model)} reason=${modelRefused ? 'model-rejected' : 'rate-limited'} status=${up.statusCode}`);
|
|
342
490
|
}
|
|
491
|
+
}
|
|
492
|
+
if (clientGone) { up.destroy(); return; }
|
|
493
|
+
clientRes.writeHead(up.statusCode, { ...stripHopByHop(up.headers), 'x-claude-org': org });
|
|
494
|
+
if (errBody) {
|
|
495
|
+
clientRes.end(errBody);
|
|
496
|
+
} else {
|
|
497
|
+
up.once('error', () => clientRes.destroy()); // the client sees a reset, not a truncated OK
|
|
498
|
+
up.pipe(clientRes); // SSE streaming is piped through untouched
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (clientGone) return;
|
|
503
|
+
if (isModelReq) {
|
|
504
|
+
writeState(null, model, 503); // no org served it
|
|
505
|
+
log(`ALERT no-org-available model=${logModel(model)} reason=${order.length ? 'unreachable' : 'no-tokens'} status=503`);
|
|
506
|
+
}
|
|
507
|
+
refuse(clientRes, 503, 'all orgs rate-limited or unreachable');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Nothing in here may take the process down: with no proxy, every claude
|
|
511
|
+
// behind it is stranded.
|
|
512
|
+
const server = http.createServer((clientReq, clientRes) => {
|
|
513
|
+
try {
|
|
514
|
+
accept(clientReq, clientRes);
|
|
515
|
+
} catch (e) {
|
|
516
|
+
log(`internal error on ${clientReq.method} ${logPath(clientReq.url)}: ${e.stack || e.message}`);
|
|
517
|
+
if (!clientRes.headersSent) refuse(clientRes, 500, 'proxy internal error (see the proxy log)');
|
|
518
|
+
else clientRes.destroy();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
function accept(clientReq, clientRes) {
|
|
523
|
+
clientReq.on('error', e => log(`client request error: ${e.message}`));
|
|
524
|
+
clientRes.on('error', e => log(`client response error: ${e.message}`));
|
|
525
|
+
const denied = gate(clientReq);
|
|
526
|
+
if (denied) {
|
|
527
|
+
log(`refused ${clientReq.method} ${logPath(clientReq.url)}: ${denied[1]} (HTTP ${denied[0]})`);
|
|
528
|
+
refuse(clientRes, denied[0], denied[1]);
|
|
529
|
+
return; // the rest of the request body is discarded by node
|
|
530
|
+
}
|
|
531
|
+
const chunks = [];
|
|
532
|
+
let size = 0;
|
|
533
|
+
let tooLarge = false;
|
|
534
|
+
clientReq.on('data', c => {
|
|
535
|
+
if (tooLarge) return;
|
|
536
|
+
size += c.length;
|
|
537
|
+
if (size > MAX_BODY) {
|
|
538
|
+
tooLarge = true;
|
|
539
|
+
chunks.length = 0;
|
|
540
|
+
log(`refused ${clientReq.method} ${logPath(clientReq.url)}: request body larger than ${MAX_BODY} bytes (HTTP 413)`);
|
|
541
|
+
refuse(clientRes, 413, `request body larger than ${MAX_BODY} bytes`);
|
|
343
542
|
return;
|
|
344
543
|
}
|
|
345
|
-
|
|
346
|
-
clientRes.writeHead(503, { 'content-type': 'application/json' });
|
|
347
|
-
clientRes.end(JSON.stringify({ type: 'error', error: { type: 'claude_org_proxy', message: 'all orgs rate-limited or unreachable' } }));
|
|
544
|
+
chunks.push(c);
|
|
348
545
|
});
|
|
349
|
-
|
|
546
|
+
clientReq.on('end', () => {
|
|
547
|
+
if (tooLarge) return;
|
|
548
|
+
handle(clientReq, clientRes, Buffer.concat(chunks)).catch(e => {
|
|
549
|
+
log(`internal error on ${clientReq.method} ${logPath(clientReq.url)}: ${e.stack || e.message}`);
|
|
550
|
+
if (!clientRes.headersSent) refuse(clientRes, 500, 'proxy internal error (see the proxy log)');
|
|
551
|
+
else clientRes.destroy();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
}
|
|
350
555
|
|
|
351
556
|
return { server, listOrgs, log, upstream: UPSTREAM, strategy: leastUtilized ? 'least-utilized' : 'order' };
|
|
352
557
|
}
|
|
@@ -358,7 +563,14 @@ async function proxyLaunch(args) {
|
|
|
358
563
|
const { listOrgs, tokenDir, resolvePrefer } = require('./config');
|
|
359
564
|
const { runClaude } = require('./launch');
|
|
360
565
|
|
|
361
|
-
|
|
566
|
+
// An org whose token file the proxy would refuse (S5) is reported here, at
|
|
567
|
+
// launch, not only in the proxy log after every request has failed.
|
|
568
|
+
const orgs = listOrgs().filter(name => {
|
|
569
|
+
const f = path.join(tokenDir(), `${name}.token`);
|
|
570
|
+
const problem = tokenFileProblem(f);
|
|
571
|
+
if (problem) console.error(`claude-org: skipping org ${name}: token file ${f} ${problem}`);
|
|
572
|
+
return !problem;
|
|
573
|
+
});
|
|
362
574
|
if (orgs.length < 2) {
|
|
363
575
|
console.error(`claude-org: proxy には org トークンが 2 つ以上必要です(${tokenDir()} に ${orgs.join(' ')})`);
|
|
364
576
|
console.error(` 取得: claude setup-token でもう片方の org を選び、${tokenDir()}/<name>.token に保存`);
|
|
@@ -370,7 +582,26 @@ async function proxyLaunch(args) {
|
|
|
370
582
|
const logf = path.join(runDir, 'proxy.log');
|
|
371
583
|
const stateFile = path.join(runDir, 'state.json');
|
|
372
584
|
const logStream = fs.createWriteStream(logf);
|
|
373
|
-
|
|
585
|
+
// A log that cannot be written (disk full, dir removed) must not take the
|
|
586
|
+
// proxy down with an unhandled 'error'.
|
|
587
|
+
let logFailed = false;
|
|
588
|
+
logStream.on('error', e => {
|
|
589
|
+
if (!logFailed) console.error(`claude-org: cannot write the proxy log ${logf}: ${e.message}`);
|
|
590
|
+
logFailed = true;
|
|
591
|
+
});
|
|
592
|
+
// Only this claude may use this proxy (and so the org tokens): it must send
|
|
593
|
+
// this per-session secret as its Bearer token. It goes into the child's env
|
|
594
|
+
// and nowhere else.
|
|
595
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
596
|
+
let proxy;
|
|
597
|
+
try {
|
|
598
|
+
proxy = createProxy({ order: order.split(','), log: logStream, stateFile, secret });
|
|
599
|
+
} catch (e) {
|
|
600
|
+
console.error(`claude-org: ${e.message}`);
|
|
601
|
+
logStream.end();
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
604
|
+
const { server, log, upstream, strategy } = proxy;
|
|
374
605
|
|
|
375
606
|
try {
|
|
376
607
|
await new Promise((resolve, reject) => {
|
|
@@ -392,7 +623,7 @@ async function proxyLaunch(args) {
|
|
|
392
623
|
// disabled. Restore subscription-equivalent defaults; user-set values win.
|
|
393
624
|
const envExtra = {
|
|
394
625
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
395
|
-
ANTHROPIC_AUTH_TOKEN:
|
|
626
|
+
ANTHROPIC_AUTH_TOKEN: secret, // sent as Bearer; the proxy checks it, then swaps in an org token
|
|
396
627
|
CLAUDE_ORG_PROXY: '1', // marker: "connector MCPs unavailable" session
|
|
397
628
|
CLAUDE_ORG_STATE_FILE: stateFile, // last org/model, for statusline and hooks
|
|
398
629
|
};
|
|
@@ -409,9 +640,22 @@ async function proxyLaunch(args) {
|
|
|
409
640
|
// `node claude-org-proxy.js`).
|
|
410
641
|
if (require.main === module) {
|
|
411
642
|
const PORT = Number(process.env.PORT || 0);
|
|
412
|
-
|
|
643
|
+
let proxy;
|
|
644
|
+
try {
|
|
645
|
+
proxy = createProxy();
|
|
646
|
+
} catch (e) {
|
|
647
|
+
console.error(`claude-org-proxy: ${e.message}`);
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
const { server, listOrgs, log, upstream, strategy } = proxy;
|
|
651
|
+
server.once('error', e => {
|
|
652
|
+
console.error(`claude-org-proxy: cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
653
|
+
process.exit(1);
|
|
654
|
+
});
|
|
413
655
|
server.listen(PORT, '127.0.0.1', () => {
|
|
414
|
-
|
|
656
|
+
let orgs;
|
|
657
|
+
try { orgs = listOrgs().join(','); } catch (e) { orgs = `<cannot list: ${e.message}>`; }
|
|
658
|
+
log(`listening on http://127.0.0.1:${server.address().port} orgs=${orgs} upstream=${upstream.host} strategy=${strategy}`);
|
|
415
659
|
});
|
|
416
660
|
}
|
|
417
661
|
|
package/package.json
CHANGED