claude-org 0.3.0 → 0.5.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 +17 -0
- package/README.md +60 -11
- package/bin/claude-org.js +13 -2
- package/lib/config.js +42 -4
- package/lib/launch.js +55 -7
- package/lib/proxy.js +571 -120
- package/lib/usage.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.0 — 2026-10-27
|
|
6
|
+
|
|
7
|
+
- `proxy`: claude.ai connector MCPs (Slack / Notion / Gmail …) now work in proxy sessions when you are signed in to claude.ai. The launch checks `claude auth status`; with a claude.ai login, `claude` keeps it and sends the per-session secret in an `x-claude-org-secret` header (`ANTHROPIC_CUSTOM_HEADERS`, your own lines kept) instead of `ANTHROPIC_AUTH_TOKEN`. The proxy discards the login token and the header and still sends only org tokens upstream. Without a login (or with `CLAUDE_ORG_NO_CONNECTORS=1`) the session starts as before. The launch line `-> claude.ai connectors: on/off (...)` and the `CLAUDE_ORG_CONNECTORS=1/0` env say which mode is in use (#35)
|
|
8
|
+
|
|
9
|
+
- `proxy`: each launch keeps its log dir (`claude-org-proxy-*` in the temp dir) for later investigation, but now removes all but the newest 20 at startup, so repeated and headless launches no longer pile up (#15)
|
|
10
|
+
|
|
11
|
+
## 0.4.0 — 2026-10-11
|
|
12
|
+
|
|
13
|
+
- `proxy`: hardened for unattended use under a supervisor (#22)
|
|
14
|
+
- 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`
|
|
15
|
+
- 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
|
|
16
|
+
- 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`
|
|
17
|
+
- 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`
|
|
18
|
+
- 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")
|
|
19
|
+
- 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
|
|
20
|
+
- README: unattended runs must use proxy mode; how to keep the token dir away from `claude`'s shell and Read tool
|
|
21
|
+
|
|
5
22
|
## 0.3.0 — 2026-10-10
|
|
6
23
|
|
|
7
24
|
- `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,14 @@ 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
|
-
|
|
87
|
-
|
|
85
|
+
2. `claude` launches with `ANTHROPIC_BASE_URL` pointing at the proxy and a **per-session secret** (generated with `crypto.randomBytes`; it exists only in that `claude`'s environment). How the secret is passed depends on your login (see [claude.ai connectors](#claudeai-connectors-slack--notion-) below):
|
|
86
|
+
- you are signed in to claude.ai (`claude auth status` shows `"authMethod": "claude.ai"`): in an `x-claude-org-secret` header via `ANTHROPIC_CUSTOM_HEADERS`, and `claude` keeps its claude.ai login, so connector MCPs load. Your other `ANTHROPIC_CUSTOM_HEADERS` lines are kept;
|
|
87
|
+
- otherwise (no login, an API key or token in your env, or `CLAUDE_ORG_NO_CONNECTORS=1`): as its bearer token (`ANTHROPIC_AUTH_TOKEN`), as in earlier versions.
|
|
88
|
+
|
|
89
|
+
The launch prints which one was used: `-> claude.ai connectors: on (...)` / `off (<reason>)`.
|
|
90
|
+
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.
|
|
91
|
+
4. The proxy replaces the `Authorization` header with a real org token (when `claude` keeps its claude.ai login, that login token arrives as `Authorization` and is discarded, never forwarded or logged), drops the `x-claude-org-secret` header and 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.
|
|
92
|
+
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
93
|
|
|
89
94
|
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
95
|
|
|
@@ -96,33 +101,73 @@ One log line per request (`via <org> model=<model> -> <status>`) goes to a temp
|
|
|
96
101
|
[ -n "$CLAUDE_ORG_STATE_FILE" ] && [ -f "$CLAUDE_ORG_STATE_FILE" ] && node -p "require(process.env.CLAUDE_ORG_STATE_FILE).org"
|
|
97
102
|
```
|
|
98
103
|
|
|
104
|
+
**claude.ai connectors (Slack / Notion / …)**
|
|
105
|
+
|
|
106
|
+
Claude Code loads claude.ai connector MCPs (Gmail / Drive / Calendar / Notion / Slack …, `mcp__claude_ai_*`) only while it uses a claude.ai login: when `ANTHROPIC_AUTH_TOKEN` or an API key takes precedence it skips them (its debug log says `[claudeai-mcp] Disabled: API-key auth precedence active`; `ANTHROPIC_BASE_URL` alone does not disable them). So when you are signed in to claude.ai, `claude-org proxy` leaves the login in place and passes its secret in a header instead, and connectors work in proxy sessions:
|
|
107
|
+
|
|
108
|
+
- Connectors are those of your **claude.ai login** (the default org), whichever org the proxy sends model requests to. Their calls go from Claude Code straight to Anthropic, not through the proxy.
|
|
109
|
+
- The launch asks `claude auth status` first (about one second). If you are not signed in, have an API key or token in your env, or the check fails, the session starts the earlier way (Bearer secret, no connectors) and the launch line says why.
|
|
110
|
+
- `CLAUDE_ORG_NO_CONNECTORS=1` forces the earlier way. The session gets `CLAUDE_ORG_CONNECTORS=1` or `0` so hooks can tell which one it is.
|
|
111
|
+
- Claude Code may also send custom headers to other Anthropic endpoints. The secret is accepted only by this session's loopback proxy and is worthless once it exits.
|
|
112
|
+
|
|
99
113
|
**Limitations of proxy mode**
|
|
100
114
|
|
|
101
|
-
- claude.ai
|
|
115
|
+
- Without a claude.ai login (see above), claude.ai connector MCPs and Remote Control are **unavailable**. Local/plugin MCPs work either way. Open connectors in a plain `claude --resume` session — history is shared.
|
|
102
116
|
- Each proxy serves one session and dies when `claude` exits.
|
|
103
117
|
|
|
104
118
|
**Subagents**
|
|
105
119
|
|
|
106
120
|
- Subagents started with Claude Code's Agent tool run in the same process as the parent session and share its `ANTHROPIC_BASE_URL`, so they go through the same proxy with no setup and use the same orgs' limits. Their requests appear in the proxy log with their own `model=` (e.g. a Haiku subagent).
|
|
107
121
|
- Not covered: a `claude` started as a separate process (it needs its own `claude-org proxy`), and agents that run in the cloud (e.g. `isolation: remote`).
|
|
108
|
-
-
|
|
122
|
+
- Subagents get the same connector MCPs as the parent session (none when it started without a claude.ai login).
|
|
109
123
|
- When an org's 5h or 7d window is rejected, the org is taken out for every model, so subagents also move to the next org until the window resets. A 429 for one model only (#8) moves that model only; other models, including those of subagents, stay on the org.
|
|
110
124
|
- Work routed to a Team / Enterprise org (e.g. your company's) counts toward that org's usage, which its admins can see.
|
|
111
125
|
|
|
112
126
|
### Prompt cache & tool search
|
|
113
127
|
|
|
114
|
-
A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat the session as API-key auth, which changes two defaults:
|
|
128
|
+
A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` (the mode without a claude.ai login) makes Claude Code treat the session as API-key auth, which changes two defaults:
|
|
115
129
|
|
|
116
130
|
- **Prompt cache TTL drops to 5 minutes** (subscription-direct sessions get 1 hour). The 1-hour TTL is requested via the `anthropic-beta` header, which the proxy forwards unchanged.
|
|
117
131
|
- **MCP tool search** (deferred tool-definition loading) is **disabled**.
|
|
118
132
|
|
|
119
|
-
`claude-org proxy` restores the subscription-equivalent behavior by launching `claude` with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` and `ENABLE_TOOL_SEARCH=true
|
|
133
|
+
`claude-org proxy` restores the subscription-equivalent behavior by launching `claude` with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` and `ENABLE_TOOL_SEARCH=true` (in both modes). If you have set either variable yourself, your value wins.
|
|
120
134
|
|
|
121
135
|
| Entry point | Mid-session switch | Connector MCPs | Best for |
|
|
122
136
|
|---|---|---|---|
|
|
123
137
|
| `claude` | no (default org) | yes | connector work |
|
|
124
138
|
| `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 |
|
|
139
|
+
| `claude-org proxy --resume` | **automatic** | yes with a claude.ai login (those of the login) / no otherwise | long-running work, agents, unattended runs |
|
|
140
|
+
|
|
141
|
+
### Unattended use (supervisors, `--dangerously-skip-permissions`)
|
|
142
|
+
|
|
143
|
+
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`:
|
|
144
|
+
|
|
145
|
+
- **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 org tokens stay in the launcher process; `claude` only holds the per-session proxy secret, which is worth nothing outside that session (and, when you are signed in to claude.ai, its own claude.ai login, which it has without claude-org too; set `CLAUDE_ORG_NO_CONNECTORS=1` if the unattended session should not use the login).
|
|
146
|
+
- **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).
|
|
147
|
+
- **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):
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"permissions": { "deny": ["Read(~/.claude/org-tokens/**)"] },
|
|
152
|
+
"sandbox": {
|
|
153
|
+
"enabled": true,
|
|
154
|
+
"credentials": { "files": [{ "path": "~/.claude/org-tokens", "mode": "deny" }] }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
- **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 `:
|
|
161
|
+
|
|
162
|
+
| Line | Meaning |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `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 |
|
|
165
|
+
| `ALERT no-org-available model=<model> reason=rate-limited status=429` | every org is over its window; the 429 went to `claude` |
|
|
166
|
+
| `ALERT no-org-available model=<model> reason=model-rejected status=429` | every org refuses this model; switch the model |
|
|
167
|
+
| `ALERT no-org-available model=<model> reason=unreachable status=503` | no org answered (network, timeouts, bad token files) |
|
|
168
|
+
| `ALERT no-org-available model=<model> reason=no-tokens status=503` | the token dir is missing or has no usable `.token` file |
|
|
169
|
+
|
|
170
|
+
The state file (`CLAUDE_ORG_STATE_FILE`) records the same outcome as `status` (`503` with `"org": null`, or `401` with the org).
|
|
126
171
|
|
|
127
172
|
## Environment variables
|
|
128
173
|
|
|
@@ -135,15 +180,19 @@ A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat t
|
|
|
135
180
|
| `CLAUDE_ORG_TOKEN_DIR` | `~/.claude/org-tokens` | Where `<name>.token` files live |
|
|
136
181
|
| `CLAUDE_ORG_NOTIFY_CMD` | (unset) | `watch` notification command, run as `<cmd> "<title>" "<message>"` via the OS shell |
|
|
137
182
|
| `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) |
|
|
139
|
-
| `
|
|
183
|
+
| `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 |
|
|
184
|
+
| `CLAUDE_ORG_UNSAFE_UPSTREAM` | (unset) | `1` lets `proxy` forward to a `CLAUDE_ORG_UPSTREAM` other than `api.anthropic.com` (tests only) |
|
|
185
|
+
| `CLAUDE_ORG_PROXY_SECRET` | (unset) | Standalone `node lib/proxy.js` only: the value clients must send (as `x-claude-org-secret` header or bearer token). `claude-org proxy` generates one per session itself |
|
|
186
|
+
| `CLAUDE_ORG_NO_CONNECTORS` | (unset) | `1`: `proxy` always passes the secret as `ANTHROPIC_AUTH_TOKEN` (no claude.ai login check, no connector MCPs) |
|
|
187
|
+
| `CLAUDE_ORG_PROXY` | `1` when launched via `proxy` | Marker env passed to the claude process so user hooks can detect proxy sessions |
|
|
188
|
+
| `CLAUDE_ORG_CONNECTORS` | `1` / `0` when launched via `proxy` | Marker env: `1` when the session kept its claude.ai login (connector MCPs can load), `0` otherwise |
|
|
140
189
|
| `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
190
|
| `CLAUDE_ORG_FORCE_PROBE` | (unset) | Test seam: `allowed` / `rejected[:<epoch>]` / `rejected_until:<epoch>` fakes `probe` results without API calls |
|
|
142
191
|
| `CLAUDE_ORG_REJECT_FILE` | (unset) | Test seam: orgs listed comma-separated in this file are treated as rate-limited by `proxy` |
|
|
143
192
|
|
|
144
193
|
## Notes
|
|
145
194
|
|
|
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.
|
|
195
|
+
- **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
196
|
- **probe cost**: each `probe` sends one `max_tokens=1` request (~11 tokens billed to that org), once per `auto` launch.
|
|
148
197
|
- **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
198
|
|
package/bin/claude-org.js
CHANGED
|
@@ -62,9 +62,20 @@ async function main() {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Exit by letting the event loop drain instead of process.exit(): on Windows
|
|
66
|
+
// (Node 24.10), process.exit() after two or more fetch() calls aborts with the
|
|
67
|
+
// libuv assertion "!(handle->flags & UV_HANDLE_CLOSING)" in src\win\async.c
|
|
68
|
+
// and exit code 127 instead of ours (#36). If something still holds the loop
|
|
69
|
+
// open, the unref'd timer exits the old way so the CLI never hangs.
|
|
70
|
+
const EXIT_GRACE_MS = 5000;
|
|
71
|
+
function finish(code) {
|
|
72
|
+
process.exitCode = code;
|
|
73
|
+
setTimeout(() => process.exit(code), EXIT_GRACE_MS).unref();
|
|
74
|
+
}
|
|
75
|
+
|
|
65
76
|
main()
|
|
66
|
-
.then(code =>
|
|
77
|
+
.then(code => finish(code))
|
|
67
78
|
.catch(e => {
|
|
68
79
|
console.error(`claude-org: ${e.message}`);
|
|
69
|
-
|
|
80
|
+
finish(1);
|
|
70
81
|
});
|
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,75 @@
|
|
|
2
2
|
const { spawnClaude } = require('./claude-spawn');
|
|
3
3
|
const { readToken } = require('./config');
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
+
|
|
10
|
+
// Spawn claude with inherited stdio and extra env (a key set to undefined is
|
|
11
|
+
// removed from the inherited env). Resolves to the child's exit code (128+n
|
|
12
|
+
// when killed by signal n, like a shell would report).
|
|
13
|
+
//
|
|
14
|
+
// Signals sent to this launcher (a supervisor's reaper or timeout kills the
|
|
15
|
+
// launcher pid, not claude) are forwarded to claude, and the launcher waits
|
|
16
|
+
// for claude to exit instead of dying first and leaving claude orphaned with
|
|
17
|
+
// its proxy gone. SIGINT from a terminal (Ctrl-C) reaches both processes at
|
|
18
|
+
// once; forwarding it would give claude a second Ctrl-C, which it reads as
|
|
19
|
+
// "exit". So with a terminal on any of stdin/stdout/stderr, SIGINT is left to
|
|
20
|
+
// claude; with none (headless, under a supervisor) it is forwarded like the
|
|
21
|
+
// others.
|
|
22
|
+
// Windows env names are case-insensitive, so a removed key is matched that way
|
|
23
|
+
// there (an inherited "Anthropic_Auth_Token" must not survive).
|
|
24
|
+
function childEnv(envExtra = {}) {
|
|
25
|
+
const env = { ...process.env, ...envExtra };
|
|
26
|
+
const same = process.platform === 'win32' ? (a, b) => a.toUpperCase() === b.toUpperCase() : (a, b) => a === b;
|
|
27
|
+
for (const [k, v] of Object.entries(envExtra)) {
|
|
28
|
+
if (v !== undefined) continue;
|
|
29
|
+
for (const name of Object.keys(env)) if (same(name, k)) delete env[name];
|
|
30
|
+
}
|
|
31
|
+
return env;
|
|
32
|
+
}
|
|
33
|
+
|
|
7
34
|
function runClaude(args, envExtra = {}) {
|
|
8
35
|
return new Promise(resolve => {
|
|
9
36
|
const child = spawnClaude(args, {
|
|
10
37
|
stdio: 'inherit',
|
|
11
|
-
env:
|
|
38
|
+
env: childEnv(envExtra),
|
|
12
39
|
});
|
|
40
|
+
let killTimer = null;
|
|
41
|
+
const handlers = new Map();
|
|
42
|
+
for (const sig of FORWARDED_SIGNALS) {
|
|
43
|
+
const h = () => {
|
|
44
|
+
if (sig === 'SIGINT' && (process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY)) return;
|
|
45
|
+
try { child.kill(sig); } catch { /* already exited */ }
|
|
46
|
+
if (!killTimer) {
|
|
47
|
+
killTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already exited */ } }, KILL_GRACE_MS);
|
|
48
|
+
killTimer.unref();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
handlers.set(sig, h);
|
|
52
|
+
process.on(sig, h);
|
|
53
|
+
}
|
|
54
|
+
const done = code => {
|
|
55
|
+
for (const [sig, h] of handlers) process.off(sig, h);
|
|
56
|
+
if (killTimer) clearTimeout(killTimer);
|
|
57
|
+
resolve(code);
|
|
58
|
+
};
|
|
13
59
|
child.on('error', e => {
|
|
14
60
|
console.error(`claude-org: failed to launch claude: ${e.message}`);
|
|
15
|
-
|
|
61
|
+
done(1);
|
|
16
62
|
});
|
|
17
63
|
child.on('exit', (code, signal) => {
|
|
18
|
-
if (code !== null) return
|
|
19
|
-
|
|
64
|
+
if (code !== null) return done(code);
|
|
65
|
+
done(signal ? 128 + ({ SIGINT: 2, SIGTERM: 15, SIGKILL: 9, SIGHUP: 1 }[signal] || 1) : 1);
|
|
20
66
|
});
|
|
21
67
|
});
|
|
22
68
|
}
|
|
23
69
|
|
|
24
70
|
// launch(name, args): 'default' launches a plain `claude`; a token org launches
|
|
25
71
|
// with CLAUDE_CODE_OAUTH_TOKEN set. Returns the process exit code.
|
|
72
|
+
// The token goes into claude's environment, where a prompt-injected claude can
|
|
73
|
+
// read it; unattended runs should use `claude-org proxy` instead (README).
|
|
26
74
|
async function launch(name, args) {
|
|
27
75
|
if (name === 'default') return runClaude(args);
|
|
28
76
|
const tok = readToken(name);
|
|
@@ -30,4 +78,4 @@ async function launch(name, args) {
|
|
|
30
78
|
return runClaude(args, { CLAUDE_CODE_OAUTH_TOKEN: tok });
|
|
31
79
|
}
|
|
32
80
|
|
|
33
|
-
module.exports = { runClaude, launch };
|
|
81
|
+
module.exports = { runClaude, launch, childEnv };
|
package/lib/proxy.js
CHANGED
|
@@ -16,25 +16,38 @@
|
|
|
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, name the proxy itself in
|
|
21
|
+
// Host (no DNS rebinding from a browser), and be a GET/POST/DELETE under /v1/.
|
|
22
|
+
// The secret comes either in the x-claude-org-secret header (what claude sends
|
|
23
|
+
// for ANTHROPIC_CUSTOM_HEADERS; the Authorization header then holds claude's
|
|
24
|
+
// own claude.ai login token, which is discarded) or as Authorization: Bearer
|
|
25
|
+
// (what claude sends for ANTHROPIC_AUTH_TOKEN). Neither reaches the upstream.
|
|
26
|
+
//
|
|
19
27
|
// Env (standalone mode):
|
|
20
|
-
// PORT
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
28
|
+
// PORT listen port (default 0 = OS picks; reported on the
|
|
29
|
+
// "listening on" stderr line)
|
|
30
|
+
// CLAUDE_ORG_PROXY_SECRET the Bearer value clients must send (required)
|
|
31
|
+
// CLAUDE_ORG_TOKEN_DIR where <name>.token files live (~/.claude/org-tokens)
|
|
32
|
+
// CLAUDE_ORG_ORDER org priority, comma-separated (default: alphabetical)
|
|
33
|
+
// CLAUDE_ORG_STRATEGY "least-utilized": prefer the usable org with the
|
|
34
|
+
// lowest 5h utilization seen so far (default: ORDER)
|
|
35
|
+
// CLAUDE_ORG_UPSTREAM forward target (default https://api.anthropic.com;
|
|
36
|
+
// anything else needs CLAUDE_ORG_UNSAFE_UPSTREAM=1)
|
|
37
|
+
// CLAUDE_ORG_REJECT_FILE test seam: orgs listed comma-separated in this file
|
|
38
|
+
// are treated as rate-limited
|
|
39
|
+
// CLAUDE_ORG_STATE_FILE where the org and model of the last /v1/messages
|
|
40
|
+
// response are written as JSON (unset: not written)
|
|
31
41
|
const http = require('http');
|
|
32
42
|
const https = require('https');
|
|
33
43
|
const fs = require('fs');
|
|
34
44
|
const path = require('path');
|
|
35
45
|
const os = require('os');
|
|
36
46
|
const zlib = require('zlib');
|
|
47
|
+
const crypto = require('crypto');
|
|
48
|
+
const { validOrgName, tokenFileProblem } = require('./config');
|
|
37
49
|
|
|
50
|
+
const DEFAULT_UPSTREAM = 'https://api.anthropic.com';
|
|
38
51
|
// How long an org stays out when it is org-wide limited but sent no reset time,
|
|
39
52
|
// and the longest a model-specific refusal keeps an (org, model) pair out.
|
|
40
53
|
const ORG_REJECT_FALLBACK_MS = 10 * 60 * 1000;
|
|
@@ -43,6 +56,38 @@ const MODEL_REJECT_MAX_MS = 10 * 60 * 1000;
|
|
|
43
56
|
// from exhausting memory: the raw body read, and the decompressed copy.
|
|
44
57
|
const ERROR_BODY_MAX_BYTES = 1024 * 1024;
|
|
45
58
|
const ERROR_DECODED_MAX_BYTES = 64 * 1024;
|
|
59
|
+
// The request body is buffered whole (it may be resent to another org). The
|
|
60
|
+
// API's own limit is 32 MB; this cap only keeps a runaway client from filling
|
|
61
|
+
// memory.
|
|
62
|
+
const REQUEST_BODY_MAX_BYTES = 64 * 1024 * 1024;
|
|
63
|
+
// Idle time on the upstream connection (no bytes in either direction) after
|
|
64
|
+
// which the request is given up and the next org tried. Claude Code's own
|
|
65
|
+
// API_TIMEOUT_MS defaults to the same 10 minutes.
|
|
66
|
+
const UPSTREAM_TIMEOUT_MS = 10 * 60 * 1000;
|
|
67
|
+
const ALLOWED_METHODS = new Set(['GET', 'POST', 'DELETE']);
|
|
68
|
+
const SECRET_HEADER = 'x-claude-org-secret';
|
|
69
|
+
// Hop-by-hop headers (RFC 9110 §7.6.1) describe one connection and must not be
|
|
70
|
+
// forwarded; the body is re-framed by this proxy anyway.
|
|
71
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
72
|
+
'te', 'trailer', 'trailers', 'transfer-encoding', 'upgrade']);
|
|
73
|
+
|
|
74
|
+
// A copy of `headers` without the hop-by-hop ones and those the Connection
|
|
75
|
+
// header names.
|
|
76
|
+
function stripHopByHop(headers) {
|
|
77
|
+
const named = new Set(String(headers.connection || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
80
|
+
const lk = k.toLowerCase();
|
|
81
|
+
if (!HOP_BY_HOP.has(lk) && !named.has(lk)) out[k] = v;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Constant-time comparison that does not leak the secret's length either.
|
|
87
|
+
function secretMatches(given, secret) {
|
|
88
|
+
const h = s => crypto.createHash('sha256').update(String(s)).digest();
|
|
89
|
+
return crypto.timingSafeEqual(h(given), h(secret));
|
|
90
|
+
}
|
|
46
91
|
|
|
47
92
|
// The "model" field of a /v1/messages request body, or '?' when the body is
|
|
48
93
|
// not JSON (the proxy must still forward it). Used as is as the quarantine
|
|
@@ -56,11 +101,38 @@ function requestModel(body) {
|
|
|
56
101
|
}
|
|
57
102
|
}
|
|
58
103
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
104
|
+
// Claude Code's startup quota check, sent by an interactive session that uses
|
|
105
|
+
// a claude.ai login (so in the connectors mode; captured from 2.1.310 on
|
|
106
|
+
// 2026-10-26): POST /v1/messages, max_tokens 1, no system prompt, one user
|
|
107
|
+
// message "quota". The API answers it with 429 rate_limit_error "Error" and no
|
|
108
|
+
// rate-limit headers for every OAuth token, the claude.ai login's own included
|
|
109
|
+
// (measured on all three), while real requests from the same tokens get 200.
|
|
110
|
+
// So its 429 says nothing about the org or the model: it is passed through
|
|
111
|
+
// from the first org without failover, quarantine or ALERT.
|
|
112
|
+
function isQuotaCheck(body) {
|
|
113
|
+
try {
|
|
114
|
+
const j = JSON.parse(body.toString('utf8'));
|
|
115
|
+
const m = Array.isArray(j.messages) && j.messages.length === 1 ? j.messages[0] : null;
|
|
116
|
+
if (j.max_tokens !== 1 || j.system !== undefined || !m || m.role !== 'user') return false;
|
|
117
|
+
const text = typeof m.content === 'string' ? m.content
|
|
118
|
+
: Array.isArray(m.content) && m.content.length === 1 && m.content[0].type === 'text' ? m.content[0].text : null;
|
|
119
|
+
return text === 'quota';
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Client input for the log: limited to safe characters and cut short.
|
|
61
126
|
function logModel(model) {
|
|
62
127
|
return model.replace(/[^\w.:@/-]/g, '_').slice(0, 80);
|
|
63
128
|
}
|
|
129
|
+
// The query string is dropped: a client may put secrets there, and the log
|
|
130
|
+
// only needs to show which path was refused.
|
|
131
|
+
function logPath(url) {
|
|
132
|
+
const s = String(url);
|
|
133
|
+
const q = s.indexOf('?');
|
|
134
|
+
return (q === -1 ? s : s.slice(0, q) + '?...').replace(/[^\w.:@/?-]/g, '_').slice(0, 120);
|
|
135
|
+
}
|
|
64
136
|
|
|
65
137
|
// Milliseconds from a Retry-After header, which is either delay-seconds or an
|
|
66
138
|
// HTTP-date. NaN when absent or unparsable.
|
|
@@ -87,6 +159,8 @@ function readBody(res, maxBytes) {
|
|
|
87
159
|
});
|
|
88
160
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
89
161
|
res.on('error', reject);
|
|
162
|
+
// destroy() without an error (the client went away) emits neither
|
|
163
|
+
res.on('close', () => reject(new Error('upstream closed before the 429 body ended')));
|
|
90
164
|
});
|
|
91
165
|
}
|
|
92
166
|
|
|
@@ -121,27 +195,54 @@ function createProxy(opts = {}) {
|
|
|
121
195
|
|| path.join(os.homedir(), '.claude', 'org-tokens');
|
|
122
196
|
const ORDER = opts.order
|
|
123
197
|
|| (process.env.CLAUDE_ORG_ORDER || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
124
|
-
const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM ||
|
|
198
|
+
const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM || DEFAULT_UPSTREAM);
|
|
125
199
|
const REJECT_FILE = 'rejectFile' in opts ? opts.rejectFile : (process.env.CLAUDE_ORG_REJECT_FILE || '');
|
|
126
200
|
const STATE_FILE = 'stateFile' in opts ? opts.stateFile : (process.env.CLAUDE_ORG_STATE_FILE || '');
|
|
127
201
|
const STRATEGY = 'strategy' in opts ? opts.strategy : (process.env.CLAUDE_ORG_STRATEGY || '');
|
|
202
|
+
const SECRET = 'secret' in opts ? opts.secret : (process.env.CLAUDE_ORG_PROXY_SECRET || '');
|
|
203
|
+
const ALLOW_ANY_UPSTREAM = 'allowAnyUpstream' in opts ? opts.allowAnyUpstream : process.env.CLAUDE_ORG_UNSAFE_UPSTREAM === '1';
|
|
204
|
+
const MAX_BODY = opts.maxBodyBytes || REQUEST_BODY_MAX_BYTES;
|
|
205
|
+
const UPSTREAM_TIMEOUT = opts.upstreamTimeoutMs || UPSTREAM_TIMEOUT_MS;
|
|
128
206
|
const out = opts.log || process.stderr;
|
|
129
207
|
const transport = UPSTREAM.protocol === 'http:' ? http : https;
|
|
130
208
|
|
|
209
|
+
// Org tokens go to the upstream as Bearer, so the upstream is pinned to the
|
|
210
|
+
// real API unless a test says otherwise.
|
|
211
|
+
if (UPSTREAM.href !== `${DEFAULT_UPSTREAM}/` && !ALLOW_ANY_UPSTREAM) {
|
|
212
|
+
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)`);
|
|
213
|
+
}
|
|
214
|
+
if (!SECRET) {
|
|
215
|
+
throw new Error('proxy secret missing: set CLAUDE_ORG_PROXY_SECRET (claude-org proxy generates one per session)');
|
|
216
|
+
}
|
|
217
|
+
|
|
131
218
|
function log(...a) {
|
|
132
219
|
out.write(`[claude-org-proxy ${new Date().toISOString().slice(11, 19)}] ${a.join(' ')}\n`);
|
|
133
220
|
}
|
|
134
221
|
const leastUtilized = STRATEGY === 'least-utilized';
|
|
135
222
|
if (STRATEGY && !leastUtilized) log(`unknown CLAUDE_ORG_STRATEGY "${STRATEGY}" -> using the static order`);
|
|
136
223
|
|
|
224
|
+
const reportedBadNames = new Set();
|
|
137
225
|
function listOrgs() {
|
|
138
|
-
const
|
|
226
|
+
const names = fs.readdirSync(TOKEN_DIR).filter(f => f.endsWith('.token')).map(f => f.slice(0, -6));
|
|
227
|
+
for (const n of names) {
|
|
228
|
+
if (validOrgName(n) || reportedBadNames.has(n)) continue;
|
|
229
|
+
reportedBadNames.add(n);
|
|
230
|
+
log(`ignoring ${TOKEN_DIR}/${logModel(n)}.token: not an org name (letters, digits, _ and - only)`);
|
|
231
|
+
}
|
|
232
|
+
const files = names.filter(validOrgName).sort();
|
|
139
233
|
if (ORDER.length === 0) return files;
|
|
140
234
|
const known = new Set(files);
|
|
141
235
|
return ORDER.filter(o => known.has(o)).concat(files.filter(f => !ORDER.includes(f)));
|
|
142
236
|
}
|
|
237
|
+
// Throws when the file is missing or not private to the user (S5): the
|
|
238
|
+
// caller skips that org for this request.
|
|
143
239
|
function readToken(name) {
|
|
144
|
-
|
|
240
|
+
const f = path.join(TOKEN_DIR, `${name}.token`);
|
|
241
|
+
const problem = tokenFileProblem(f);
|
|
242
|
+
if (problem) throw new Error(`token file ${f} ${problem}`);
|
|
243
|
+
const tok = fs.readFileSync(f, 'utf8').replace(/[\r\n]/g, '');
|
|
244
|
+
if (!tok) throw new Error(`token file ${f} is empty`);
|
|
245
|
+
return tok;
|
|
145
246
|
}
|
|
146
247
|
|
|
147
248
|
// Per-org "rate-limited" memory: name -> recovery epoch (ms). Expired entries
|
|
@@ -230,123 +331,264 @@ function createProxy(opts = {}) {
|
|
|
230
331
|
}
|
|
231
332
|
}
|
|
232
333
|
|
|
233
|
-
|
|
334
|
+
// One upstream request. Resolves with the response once its headers are in;
|
|
335
|
+
// rejects on connection errors, on idle timeout, and when `signal` aborts
|
|
336
|
+
// (the client went away). The response stream can still fail later; the
|
|
337
|
+
// caller listens for that.
|
|
338
|
+
function forward(token, method, urlPath, headers, body, signal) {
|
|
234
339
|
return new Promise((resolve, reject) => {
|
|
235
|
-
const h =
|
|
340
|
+
const h = stripHopByHop(headers);
|
|
236
341
|
for (const k of Object.keys(h)) {
|
|
237
342
|
const lk = k.toLowerCase();
|
|
238
|
-
if (lk === 'x-api-key' || lk === 'host' || lk === 'content-length' || lk === 'authorization'
|
|
343
|
+
if (lk === 'x-api-key' || lk === 'host' || lk === 'content-length' || lk === 'authorization'
|
|
344
|
+
|| lk === SECRET_HEADER) delete h[k];
|
|
239
345
|
}
|
|
240
|
-
h.authorization = `Bearer ${
|
|
346
|
+
h.authorization = `Bearer ${token}`;
|
|
241
347
|
h.host = UPSTREAM.host;
|
|
242
348
|
if (body.length) h['content-length'] = String(body.length);
|
|
243
349
|
const req = transport.request(
|
|
244
|
-
{
|
|
350
|
+
{
|
|
351
|
+
hostname: UPSTREAM.hostname,
|
|
352
|
+
port: UPSTREAM.port || (transport === https ? 443 : 80),
|
|
353
|
+
path: urlPath,
|
|
354
|
+
method,
|
|
355
|
+
headers: h,
|
|
356
|
+
signal,
|
|
357
|
+
timeout: UPSTREAM_TIMEOUT,
|
|
358
|
+
},
|
|
245
359
|
res => resolve(res),
|
|
246
360
|
);
|
|
361
|
+
req.on('timeout', () => req.destroy(new Error(`no data from upstream for ${UPSTREAM_TIMEOUT} ms`)));
|
|
247
362
|
req.on('error', reject);
|
|
248
363
|
if (body.length) req.write(body);
|
|
249
364
|
req.end();
|
|
250
365
|
});
|
|
251
366
|
}
|
|
252
367
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
368
|
+
function refuse(clientRes, status, message) {
|
|
369
|
+
clientRes.writeHead(status, { 'content-type': 'application/json', connection: 'close' });
|
|
370
|
+
clientRes.end(JSON.stringify({ type: 'error', error: { type: 'claude_org_proxy', message } }));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Checks done before any body is read or anything is forwarded. Returns
|
|
374
|
+
// [status, message] for a request to refuse, or null.
|
|
375
|
+
function gate(clientReq) {
|
|
376
|
+
// When the header is present it alone decides: the Authorization header is
|
|
377
|
+
// then claude's claude.ai login token, not the secret.
|
|
378
|
+
const viaHeader = clientReq.headers[SECRET_HEADER];
|
|
379
|
+
const auth = clientReq.headers.authorization || '';
|
|
380
|
+
const authorized = viaHeader !== undefined
|
|
381
|
+
? secretMatches(viaHeader, SECRET)
|
|
382
|
+
: auth.startsWith('Bearer ') && secretMatches(auth.slice(7), SECRET);
|
|
383
|
+
if (!authorized) {
|
|
384
|
+
return [401, `missing or wrong proxy secret (the ${SECRET_HEADER} header or Bearer token claude-org proxy gave this session)`];
|
|
385
|
+
}
|
|
386
|
+
const addr = server.address(); // null once the server is closing
|
|
387
|
+
if (!addr) return [503, 'proxy is shutting down'];
|
|
388
|
+
const expectedHost = `127.0.0.1:${addr.port}`;
|
|
389
|
+
if (clientReq.headers.host !== expectedHost) return [403, `Host must be ${expectedHost}`];
|
|
390
|
+
if (!ALLOWED_METHODS.has(clientReq.method)) return [405, `method ${clientReq.method} is not forwarded`];
|
|
391
|
+
// The path is forwarded as is, so nothing that an upstream could
|
|
392
|
+
// normalise out of /v1/ ("..", "//", "\\", %2e) is accepted.
|
|
393
|
+
const pathOnly = clientReq.url.split('?')[0];
|
|
394
|
+
if (!pathOnly.startsWith('/v1/') || /\.\.|\/\/|\\|%2e/i.test(pathOnly)) return [404, 'only /v1/* is forwarded'];
|
|
395
|
+
const announced = Number(clientReq.headers['content-length']);
|
|
396
|
+
if (announced > MAX_BODY) return [413, `request body larger than ${MAX_BODY} bytes`];
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function handle(clientReq, clientRes, body) {
|
|
401
|
+
// The client going away (Claude Code cancelled, or exited) cancels the
|
|
402
|
+
// upstream request in flight and stops a response being piped, so no org
|
|
403
|
+
// keeps generating for nobody.
|
|
404
|
+
const abort = new AbortController();
|
|
405
|
+
let clientGone = false;
|
|
406
|
+
let up = null;
|
|
407
|
+
clientRes.on('close', () => {
|
|
408
|
+
if (clientRes.writableFinished) return;
|
|
409
|
+
clientGone = true;
|
|
410
|
+
abort.abort();
|
|
411
|
+
if (up) up.destroy();
|
|
412
|
+
});
|
|
413
|
+
const quotaCheck = clientReq.url.startsWith('/v1/messages?') || clientReq.url === '/v1/messages'
|
|
414
|
+
? isQuotaCheck(body) : false;
|
|
415
|
+
// A quota check is forwarded like a non-model request: first usable org,
|
|
416
|
+
// response passed through, no limit bookkeeping.
|
|
417
|
+
const isModelReq = clientReq.url.startsWith('/v1/messages') && !quotaCheck;
|
|
418
|
+
const model = isModelReq ? requestModel(body) : '';
|
|
419
|
+
let order;
|
|
420
|
+
try {
|
|
421
|
+
order = pickOrder(model);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
log(`cannot list orgs in ${TOKEN_DIR}: ${e.message}`);
|
|
424
|
+
order = [];
|
|
425
|
+
}
|
|
426
|
+
let attempt = 0;
|
|
427
|
+
let modelRefusals = 0;
|
|
428
|
+
for (const org of order) {
|
|
429
|
+
attempt++;
|
|
430
|
+
if (clientGone) return;
|
|
431
|
+
let token;
|
|
432
|
+
try {
|
|
433
|
+
token = readToken(org);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
log(`skipping ${org}: ${e.message}`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
up = await forward(token, clientReq.method, clientReq.url, clientReq.headers, body, abort.signal);
|
|
440
|
+
} catch (e) {
|
|
441
|
+
if (clientGone) return;
|
|
442
|
+
log(`upstream error via ${org}: ${e.message}`);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
// A response that fails after its headers (idle timeout, reset) is only
|
|
446
|
+
// logged here; whether the client is cut off is decided where it is piped.
|
|
447
|
+
up.on('error', e => log(`upstream error via ${org} while reading the response: ${e.message}`));
|
|
448
|
+
const hdr = name => up.headers[`anthropic-ratelimit-unified-${name}`];
|
|
449
|
+
const status5h = hdr('5h-status');
|
|
450
|
+
const status7d = hdr('7d-status');
|
|
451
|
+
const claim = hdr('representative-claim');
|
|
452
|
+
const util5h = Number(hdr('5h-utilization'));
|
|
453
|
+
if (isModelReq && hdr('5h-utilization') !== undefined && Number.isFinite(util5h)) {
|
|
454
|
+
recordUtil(org, util5h, Number(hdr('5h-reset')));
|
|
455
|
+
} else if (isModelReq) {
|
|
456
|
+
answeredUnmeasured.add(org);
|
|
457
|
+
}
|
|
458
|
+
const isLast = attempt >= order.length;
|
|
459
|
+
// A 401 means the org's token is expired or revoked (they last about a
|
|
460
|
+
// year), not that the request is bad: the org is kept out for a while
|
|
461
|
+
// and the next one tried. The ALERT line is the fixed format a
|
|
462
|
+
// supervisor can watch for (README, "Unattended use").
|
|
463
|
+
if (isModelReq && up.statusCode === 401) {
|
|
464
|
+
log(`ALERT token-rejected org=${org} status=401 -> run: claude setup-token (org ${org}) and overwrite ${path.join(TOKEN_DIR, `${org}.token`)}`);
|
|
465
|
+
rejectedUntil.set(org, Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
466
|
+
if (!isLast) {
|
|
467
|
+
up.resume();
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// The 5h and 7d windows cover every model of the org, so only those make
|
|
472
|
+
// the org as a whole unusable. Other refusals are per model: a used-up
|
|
473
|
+
// per-model weekly allowance (claim=seven_day_overage_included) or a model
|
|
474
|
+
// that needs usage credits (429 with no window headers). The overall
|
|
475
|
+
// "unified-status" header is not used: it also reads "rejected" for those.
|
|
476
|
+
const orgWide = status5h === 'rejected' || status7d === 'rejected';
|
|
477
|
+
const limited = up.statusCode === 429 || orgWide;
|
|
478
|
+
const windows = `5h=${status5h ?? '?'}${claim ? `, claim=${claim}` : ''}`;
|
|
479
|
+
// A 429 body is a small JSON error; buffer it to log the reason and,
|
|
480
|
+
// if no org is left, to hand it to the client unchanged.
|
|
481
|
+
let errBody = null;
|
|
482
|
+
let reason = '';
|
|
483
|
+
if (isModelReq && up.statusCode === 429) {
|
|
266
484
|
try {
|
|
267
|
-
|
|
485
|
+
errBody = await readBody(up, ERROR_BODY_MAX_BYTES);
|
|
268
486
|
} catch (e) {
|
|
269
|
-
log(`upstream error via ${org}: ${e.message}`);
|
|
487
|
+
log(`upstream error via ${org} while reading 429 body: ${e.message}`);
|
|
270
488
|
continue;
|
|
271
489
|
}
|
|
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;
|
|
490
|
+
reason = describeError(errBody, up.headers['content-encoding']);
|
|
491
|
+
}
|
|
492
|
+
const modelRefused = isModelReq && up.statusCode === 429 && !orgWide;
|
|
493
|
+
if (modelRefused) {
|
|
494
|
+
// retry-after is honoured when shorter (a transient per-minute limit
|
|
495
|
+
// must not keep the preferred org out for long); a long one (a weekly
|
|
496
|
+
// per-model allowance) is capped so the org is re-checked now and then.
|
|
497
|
+
const ra = retryAfterMs(up.headers['retry-after']);
|
|
498
|
+
const ms = Number.isFinite(ra) ? Math.min(ra, MODEL_REJECT_MAX_MS) : MODEL_REJECT_MAX_MS;
|
|
499
|
+
modelRejectedUntil.set(`${org}|${model}`, Date.now() + ms);
|
|
500
|
+
}
|
|
501
|
+
if (limited && isModelReq && !isLast) {
|
|
502
|
+
if (orgWide) {
|
|
503
|
+
// When both windows are rejected the org is back only after the later reset.
|
|
504
|
+
const resets = [['5h', status5h], ['7d', status7d]]
|
|
505
|
+
.filter(([, s]) => s === 'rejected')
|
|
506
|
+
.map(([w]) => Number(hdr(`${w}-reset`)))
|
|
507
|
+
.filter(Number.isFinite);
|
|
508
|
+
rejectedUntil.set(org, resets.length ? Math.max(...resets) * 1000 : Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
509
|
+
log(`${org} is rate-limited (HTTP ${up.statusCode}, ${windows}) -> retrying with next org${reason ? ` (${reason})` : ''}`);
|
|
510
|
+
} else {
|
|
511
|
+
modelRefusals++;
|
|
512
|
+
log(`${org} rejected model ${logModel(model)} (HTTP ${up.statusCode}, ${windows}, ${reason}) -> retrying with next org; ${org} stays in use for other models`);
|
|
328
513
|
}
|
|
329
|
-
if (
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
514
|
+
if (!errBody) up.resume(); // discard the response body and try the next org
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (isModelReq) {
|
|
518
|
+
const util = hdr('5h-utilization');
|
|
519
|
+
log(`${clientReq.method} ${clientReq.url} via ${org} model=${logModel(model)} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
|
|
520
|
+
writeState(org, model, up.statusCode);
|
|
521
|
+
if (modelRefused && modelRefusals === attempt - 1) {
|
|
522
|
+
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
523
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
clientRes.end(errBody);
|
|
340
|
-
} else {
|
|
341
|
-
up.pipe(clientRes); // SSE streaming is piped through untouched
|
|
524
|
+
if (limited) {
|
|
525
|
+
log(`ALERT no-org-available model=${logModel(model)} reason=${modelRefused ? 'model-rejected' : 'rate-limited'} status=${up.statusCode}`);
|
|
342
526
|
}
|
|
527
|
+
}
|
|
528
|
+
if (quotaCheck) log(`quota check via ${org} -> ${up.statusCode} (passed through, not counted as a limit)`);
|
|
529
|
+
if (clientGone) { up.destroy(); return; }
|
|
530
|
+
clientRes.writeHead(up.statusCode, { ...stripHopByHop(up.headers), 'x-claude-org': org });
|
|
531
|
+
if (errBody) {
|
|
532
|
+
clientRes.end(errBody);
|
|
533
|
+
} else {
|
|
534
|
+
up.once('error', () => clientRes.destroy()); // the client sees a reset, not a truncated OK
|
|
535
|
+
up.pipe(clientRes); // SSE streaming is piped through untouched
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (clientGone) return;
|
|
540
|
+
if (isModelReq) {
|
|
541
|
+
writeState(null, model, 503); // no org served it
|
|
542
|
+
log(`ALERT no-org-available model=${logModel(model)} reason=${order.length ? 'unreachable' : 'no-tokens'} status=503`);
|
|
543
|
+
}
|
|
544
|
+
refuse(clientRes, 503, 'all orgs rate-limited or unreachable');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Nothing in here may take the process down: with no proxy, every claude
|
|
548
|
+
// behind it is stranded.
|
|
549
|
+
const server = http.createServer((clientReq, clientRes) => {
|
|
550
|
+
try {
|
|
551
|
+
accept(clientReq, clientRes);
|
|
552
|
+
} catch (e) {
|
|
553
|
+
log(`internal error on ${clientReq.method} ${logPath(clientReq.url)}: ${e.stack || e.message}`);
|
|
554
|
+
if (!clientRes.headersSent) refuse(clientRes, 500, 'proxy internal error (see the proxy log)');
|
|
555
|
+
else clientRes.destroy();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
function accept(clientReq, clientRes) {
|
|
560
|
+
clientReq.on('error', e => log(`client request error: ${e.message}`));
|
|
561
|
+
clientRes.on('error', e => log(`client response error: ${e.message}`));
|
|
562
|
+
const denied = gate(clientReq);
|
|
563
|
+
if (denied) {
|
|
564
|
+
log(`refused ${clientReq.method} ${logPath(clientReq.url)}: ${denied[1]} (HTTP ${denied[0]})`);
|
|
565
|
+
refuse(clientRes, denied[0], denied[1]);
|
|
566
|
+
return; // the rest of the request body is discarded by node
|
|
567
|
+
}
|
|
568
|
+
const chunks = [];
|
|
569
|
+
let size = 0;
|
|
570
|
+
let tooLarge = false;
|
|
571
|
+
clientReq.on('data', c => {
|
|
572
|
+
if (tooLarge) return;
|
|
573
|
+
size += c.length;
|
|
574
|
+
if (size > MAX_BODY) {
|
|
575
|
+
tooLarge = true;
|
|
576
|
+
chunks.length = 0;
|
|
577
|
+
log(`refused ${clientReq.method} ${logPath(clientReq.url)}: request body larger than ${MAX_BODY} bytes (HTTP 413)`);
|
|
578
|
+
refuse(clientRes, 413, `request body larger than ${MAX_BODY} bytes`);
|
|
343
579
|
return;
|
|
344
580
|
}
|
|
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' } }));
|
|
581
|
+
chunks.push(c);
|
|
348
582
|
});
|
|
349
|
-
|
|
583
|
+
clientReq.on('end', () => {
|
|
584
|
+
if (tooLarge) return;
|
|
585
|
+
handle(clientReq, clientRes, Buffer.concat(chunks)).catch(e => {
|
|
586
|
+
log(`internal error on ${clientReq.method} ${logPath(clientReq.url)}: ${e.stack || e.message}`);
|
|
587
|
+
if (!clientRes.headersSent) refuse(clientRes, 500, 'proxy internal error (see the proxy log)');
|
|
588
|
+
else clientRes.destroy();
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
350
592
|
|
|
351
593
|
return { server, listOrgs, log, upstream: UPSTREAM, strategy: leastUtilized ? 'least-utilized' : 'order' };
|
|
352
594
|
}
|
|
@@ -354,23 +596,206 @@ function createProxy(opts = {}) {
|
|
|
354
596
|
// `claude-org proxy [claude args...]`: start an in-process proxy on a random
|
|
355
597
|
// port, point ANTHROPIC_BASE_URL at it, launch claude, and shut the proxy down
|
|
356
598
|
// when claude exits. One proxy per session, so parallel sessions never collide.
|
|
599
|
+
// A log that cannot be written (disk full, dir removed) must not take the
|
|
600
|
+
// proxy down with an unhandled 'error'. Once it fails, ALERT lines would vanish
|
|
601
|
+
// for an unattended session that only watches the log, so they are mirrored to
|
|
602
|
+
// stderr from then on.
|
|
603
|
+
// Each launch leaves a claude-org-proxy-XXXXXX dir in tmpdir (the log is kept
|
|
604
|
+
// for later investigation, so it is not removed on exit). Keep only the newest
|
|
605
|
+
// `keep` finished ones so repeated and headless launches do not pile up.
|
|
606
|
+
// - Names are matched exactly, and only real directories owned by this user
|
|
607
|
+
// are removed (tmpdir is shared: someone else could plant a lookalike).
|
|
608
|
+
// process.getuid does not exist on Windows, where tmpdir is per-user.
|
|
609
|
+
// - A dir whose launcher is still alive (pid file) is never removed, however
|
|
610
|
+
// many there are: it holds the open log and the state file.
|
|
611
|
+
// - "Newest" is the launch time: the pid file is written once at launch and
|
|
612
|
+
// never touched again, whereas the dir mtime moves whenever state.json is
|
|
613
|
+
// renamed into it. Dirs from before the pid file existed fall back to mtime.
|
|
614
|
+
const RUN_DIR_PREFIX = 'claude-org-proxy-';
|
|
615
|
+
const RUN_DIR_KEEP = 20;
|
|
616
|
+
const RUN_DIR_RE = new RegExp(`^${RUN_DIR_PREFIX}[A-Za-z0-9]{6}$`);
|
|
617
|
+
|
|
618
|
+
function launcherAlive(dir) {
|
|
619
|
+
let pid;
|
|
620
|
+
try { pid = Number(fs.readFileSync(path.join(dir, 'pid'), 'utf8').trim()); } catch { return false; }
|
|
621
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
622
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function pruneRunDirs(root, keep = RUN_DIR_KEEP) {
|
|
626
|
+
let names;
|
|
627
|
+
try { names = fs.readdirSync(root); } catch { return 0; }
|
|
628
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
629
|
+
const dirs = [];
|
|
630
|
+
for (const name of names) {
|
|
631
|
+
if (!RUN_DIR_RE.test(name)) continue;
|
|
632
|
+
const full = path.join(root, name);
|
|
633
|
+
try {
|
|
634
|
+
const st = fs.lstatSync(full);
|
|
635
|
+
if (!st.isDirectory() || (uid !== null && st.uid !== uid)) continue;
|
|
636
|
+
if (launcherAlive(full)) continue;
|
|
637
|
+
let launched = st.mtimeMs;
|
|
638
|
+
try { launched = fs.statSync(path.join(full, 'pid')).mtimeMs; } catch { /* pre-pid-file dir */ }
|
|
639
|
+
dirs.push({ full, launched });
|
|
640
|
+
} catch { /* removed by a concurrent launch */ }
|
|
641
|
+
}
|
|
642
|
+
dirs.sort((a, b) => b.launched - a.launched);
|
|
643
|
+
let removed = 0;
|
|
644
|
+
for (const d of dirs.slice(keep)) {
|
|
645
|
+
try {
|
|
646
|
+
fs.rmSync(d.full, { recursive: true, force: true });
|
|
647
|
+
removed++;
|
|
648
|
+
} catch (e) {
|
|
649
|
+
console.error(`claude-org: cannot remove old proxy log dir ${d.full}: ${e.message}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return removed;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function guardLogStream(logStream, logf) {
|
|
656
|
+
let logFailed = false;
|
|
657
|
+
logStream.on('error', e => {
|
|
658
|
+
if (!logFailed) console.error(`claude-org: cannot write the proxy log ${logf}: ${e.message}`);
|
|
659
|
+
logFailed = true;
|
|
660
|
+
});
|
|
661
|
+
return {
|
|
662
|
+
write(line) {
|
|
663
|
+
if (!line.includes('ALERT ')) return logStream.write(line);
|
|
664
|
+
// After a confirmed failure, mirror directly (the stream is destroyed and
|
|
665
|
+
// would only call back with an error again).
|
|
666
|
+
if (logFailed) { process.stderr.write(line); return false; }
|
|
667
|
+
// The write that first hits the failure only reports it asynchronously;
|
|
668
|
+
// recover that line from the callback so a one-off ALERT is not lost.
|
|
669
|
+
return logStream.write(line, err => { if (err) process.stderr.write(line); });
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// claude.ai connector MCPs (Slack, Notion, ...) load only while claude uses
|
|
675
|
+
// its claude.ai login: Claude Code 2.1.310 skips them when ANTHROPIC_AUTH_TOKEN
|
|
676
|
+
// or an API key takes precedence ("[claudeai-mcp] Disabled: API-key auth
|
|
677
|
+
// precedence active"). ANTHROPIC_BASE_URL alone does not disable them. So when
|
|
678
|
+
// claude, started with the env it will get, reports a claude.ai login, the
|
|
679
|
+
// secret goes in a custom header and the login stays active; otherwise (no
|
|
680
|
+
// login, an API key or token in the env, the check failed) the secret is the
|
|
681
|
+
// Bearer token, as before. The check costs about one second at launch.
|
|
682
|
+
const AUTH_STATUS_TIMEOUT_MS = 15 * 1000;
|
|
683
|
+
const AUTH_STATUS_MAX_BYTES = 64 * 1024;
|
|
684
|
+
const AUTH_STATUS_KILL_WAIT_MS = 5 * 1000;
|
|
685
|
+
|
|
686
|
+
function claudeAiLogin(env) {
|
|
687
|
+
const { spawnClaude } = require('./claude-spawn');
|
|
688
|
+
return new Promise(resolve => {
|
|
689
|
+
let out = '';
|
|
690
|
+
let settled = false;
|
|
691
|
+
let timer = null;
|
|
692
|
+
const done = (ok, why) => {
|
|
693
|
+
if (settled) return;
|
|
694
|
+
settled = true;
|
|
695
|
+
clearTimeout(timer);
|
|
696
|
+
resolve({ ok, why });
|
|
697
|
+
};
|
|
698
|
+
let child;
|
|
699
|
+
try {
|
|
700
|
+
child = spawnClaude(['auth', 'status'], { env, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
701
|
+
} catch (e) {
|
|
702
|
+
done(false, `claude auth status could not start: ${e.message}`);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
let timedOut = null;
|
|
706
|
+
timer = setTimeout(() => {
|
|
707
|
+
const why = `claude auth status did not answer within ${AUTH_STATUS_TIMEOUT_MS / 1000} s`;
|
|
708
|
+
timedOut = why;
|
|
709
|
+
if (process.platform !== 'win32') {
|
|
710
|
+
try { child.kill(); } catch { /* already exited */ }
|
|
711
|
+
done(false, why);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
// On Windows claude runs under cmd.exe (shell: true); killing only the
|
|
715
|
+
// shell would leave claude running, so the whole tree is ended. The
|
|
716
|
+
// launch waits for taskkill (bounded): measured on Windows 11, a
|
|
717
|
+
// taskkill not waited for left the hung claude running.
|
|
718
|
+
let killer;
|
|
719
|
+
try {
|
|
720
|
+
killer = require('child_process').spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
721
|
+
} catch {
|
|
722
|
+
done(false, why);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const giveUp = setTimeout(() => done(false, why), AUTH_STATUS_KILL_WAIT_MS);
|
|
726
|
+
const finish = () => { clearTimeout(giveUp); done(false, why); };
|
|
727
|
+
killer.on('exit', finish);
|
|
728
|
+
killer.on('error', finish);
|
|
729
|
+
}, AUTH_STATUS_TIMEOUT_MS);
|
|
730
|
+
child.stdout.setEncoding('utf8');
|
|
731
|
+
child.stdout.on('data', c => { if (out.length < AUTH_STATUS_MAX_BYTES) out += c; });
|
|
732
|
+
child.on('error', e => done(false, `claude auth status could not start: ${e.message}`));
|
|
733
|
+
child.on('close', () => {
|
|
734
|
+
if (timedOut) return done(false, timedOut); // closed because it was killed
|
|
735
|
+
let st;
|
|
736
|
+
try { st = JSON.parse(out); } catch { return done(false, 'claude auth status gave no JSON'); }
|
|
737
|
+
if (st && st.loggedIn === true && st.authMethod === 'claude.ai') return done(true, 'claude.ai login');
|
|
738
|
+
done(false, `claude auth status: loggedIn=${st && st.loggedIn} authMethod=${String(st && st.authMethod).slice(0, 40)}`);
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ANTHROPIC_CUSTOM_HEADERS ("Name: value" lines) with the secret header added.
|
|
744
|
+
// The user's other headers are kept; a secret header inherited from an outer
|
|
745
|
+
// claude-org proxy session is replaced.
|
|
746
|
+
function withSecretHeader(existing, secret) {
|
|
747
|
+
const lines = String(existing || '').split(/\r?\n/)
|
|
748
|
+
.filter(l => l.trim() && l.split(':')[0].trim().toLowerCase() !== SECRET_HEADER);
|
|
749
|
+
return lines.concat(`${SECRET_HEADER}: ${secret}`).join('\n');
|
|
750
|
+
}
|
|
751
|
+
|
|
357
752
|
async function proxyLaunch(args) {
|
|
358
753
|
const { listOrgs, tokenDir, resolvePrefer } = require('./config');
|
|
359
|
-
const { runClaude } = require('./launch');
|
|
754
|
+
const { runClaude, childEnv } = require('./launch');
|
|
360
755
|
|
|
361
|
-
|
|
756
|
+
// An org whose token file the proxy would refuse (S5) is reported here, at
|
|
757
|
+
// launch, not only in the proxy log after every request has failed.
|
|
758
|
+
const orgs = listOrgs().filter(name => {
|
|
759
|
+
const f = path.join(tokenDir(), `${name}.token`);
|
|
760
|
+
const problem = tokenFileProblem(f);
|
|
761
|
+
if (problem) console.error(`claude-org: skipping org ${name}: token file ${f} ${problem}`);
|
|
762
|
+
return !problem;
|
|
763
|
+
});
|
|
362
764
|
if (orgs.length < 2) {
|
|
363
765
|
console.error(`claude-org: proxy には org トークンが 2 つ以上必要です(${tokenDir()} に ${orgs.join(' ')})`);
|
|
364
766
|
console.error(` 取得: claude setup-token でもう片方の org を選び、${tokenDir()}/<name>.token に保存`);
|
|
365
767
|
return 1;
|
|
366
768
|
}
|
|
367
769
|
|
|
770
|
+
// An ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL in this env (e.g. from an
|
|
771
|
+
// outer claude-org proxy session) is replaced below either way, so the check
|
|
772
|
+
// runs without them.
|
|
773
|
+
const unsetForCheck = { ANTHROPIC_AUTH_TOKEN: undefined, ANTHROPIC_BASE_URL: undefined };
|
|
774
|
+
const login = process.env.CLAUDE_ORG_NO_CONNECTORS === '1'
|
|
775
|
+
? { ok: false, why: 'CLAUDE_ORG_NO_CONNECTORS=1' }
|
|
776
|
+
: await claudeAiLogin(childEnv(unsetForCheck));
|
|
777
|
+
|
|
368
778
|
const order = process.env.CLAUDE_ORG_ORDER || resolvePrefer();
|
|
369
|
-
|
|
779
|
+
pruneRunDirs(os.tmpdir());
|
|
780
|
+
const runDir = fs.mkdtempSync(path.join(os.tmpdir(), RUN_DIR_PREFIX));
|
|
781
|
+
fs.writeFileSync(path.join(runDir, 'pid'), String(process.pid));
|
|
370
782
|
const logf = path.join(runDir, 'proxy.log');
|
|
371
783
|
const stateFile = path.join(runDir, 'state.json');
|
|
372
784
|
const logStream = fs.createWriteStream(logf);
|
|
373
|
-
const
|
|
785
|
+
const logOut = guardLogStream(logStream, logf);
|
|
786
|
+
// Only this claude may use this proxy (and so the org tokens): it must send
|
|
787
|
+
// this per-session secret, in the x-claude-org-secret header or as its
|
|
788
|
+
// Bearer token. It goes into the child's env and nowhere else.
|
|
789
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
790
|
+
let proxy;
|
|
791
|
+
try {
|
|
792
|
+
proxy = createProxy({ order: order.split(','), log: logOut, stateFile, secret });
|
|
793
|
+
} catch (e) {
|
|
794
|
+
console.error(`claude-org: ${e.message}`);
|
|
795
|
+
logStream.end();
|
|
796
|
+
return 1;
|
|
797
|
+
}
|
|
798
|
+
const { server, log, upstream, strategy } = proxy;
|
|
374
799
|
|
|
375
800
|
try {
|
|
376
801
|
await new Promise((resolve, reject) => {
|
|
@@ -385,17 +810,30 @@ async function proxyLaunch(args) {
|
|
|
385
810
|
return 1;
|
|
386
811
|
}
|
|
387
812
|
const port = server.address().port;
|
|
813
|
+
const connectors = login.ok ? 'on (claude.ai login kept)' : `off (${login.why})`;
|
|
814
|
+
log(`claude.ai connectors: ${connectors}`);
|
|
815
|
+
console.error(`-> claude.ai connectors: ${connectors}`);
|
|
388
816
|
console.error(`-> proxy 127.0.0.1:${port} (order: ${order}, strategy: ${strategy}, log: ${logf})`);
|
|
389
817
|
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
818
|
+
// With ANTHROPIC_AUTH_TOKEN, Claude Code treats the session as API-key auth:
|
|
819
|
+
// prompt cache TTL drops to 5min and MCP tool search is disabled. Restore
|
|
820
|
+
// subscription-equivalent defaults in both modes; user-set values win.
|
|
393
821
|
const envExtra = {
|
|
394
822
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
395
|
-
|
|
396
|
-
|
|
823
|
+
CLAUDE_ORG_PROXY: '1', // marker: session goes through claude-org proxy
|
|
824
|
+
CLAUDE_ORG_CONNECTORS: login.ok ? '1' : '0', // marker: claude.ai connector MCPs can load
|
|
397
825
|
CLAUDE_ORG_STATE_FILE: stateFile, // last org/model, for statusline and hooks
|
|
398
826
|
};
|
|
827
|
+
if (login.ok) {
|
|
828
|
+
// claude keeps its claude.ai login (and sends that token as Bearer, which
|
|
829
|
+
// the proxy discards); the secret travels in the custom header. Claude
|
|
830
|
+
// Code may also send custom headers to other Anthropic endpoints; the
|
|
831
|
+
// secret is only accepted by this loopback proxy and dies with it.
|
|
832
|
+
envExtra.ANTHROPIC_AUTH_TOKEN = undefined;
|
|
833
|
+
envExtra.ANTHROPIC_CUSTOM_HEADERS = withSecretHeader(process.env.ANTHROPIC_CUSTOM_HEADERS, secret);
|
|
834
|
+
} else {
|
|
835
|
+
envExtra.ANTHROPIC_AUTH_TOKEN = secret; // sent as Bearer; the proxy checks it, then swaps in an org token
|
|
836
|
+
}
|
|
399
837
|
if (!process.env.CLAUDE_CODE_PROMPT_CACHE_TTL) envExtra.CLAUDE_CODE_PROMPT_CACHE_TTL = '1h';
|
|
400
838
|
if (!process.env.ENABLE_TOOL_SEARCH) envExtra.ENABLE_TOOL_SEARCH = 'true';
|
|
401
839
|
|
|
@@ -409,10 +847,23 @@ async function proxyLaunch(args) {
|
|
|
409
847
|
// `node claude-org-proxy.js`).
|
|
410
848
|
if (require.main === module) {
|
|
411
849
|
const PORT = Number(process.env.PORT || 0);
|
|
412
|
-
|
|
850
|
+
let proxy;
|
|
851
|
+
try {
|
|
852
|
+
proxy = createProxy();
|
|
853
|
+
} catch (e) {
|
|
854
|
+
console.error(`claude-org-proxy: ${e.message}`);
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
const { server, listOrgs, log, upstream, strategy } = proxy;
|
|
858
|
+
server.once('error', e => {
|
|
859
|
+
console.error(`claude-org-proxy: cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
860
|
+
process.exit(1);
|
|
861
|
+
});
|
|
413
862
|
server.listen(PORT, '127.0.0.1', () => {
|
|
414
|
-
|
|
863
|
+
let orgs;
|
|
864
|
+
try { orgs = listOrgs().join(','); } catch (e) { orgs = `<cannot list: ${e.message}>`; }
|
|
865
|
+
log(`listening on http://127.0.0.1:${server.address().port} orgs=${orgs} upstream=${upstream.host} strategy=${strategy}`);
|
|
415
866
|
});
|
|
416
867
|
}
|
|
417
868
|
|
|
418
|
-
module.exports = { createProxy, proxyLaunch };
|
|
869
|
+
module.exports = { createProxy, proxyLaunch, guardLogStream, pruneRunDirs, withSecretHeader };
|
package/lib/usage.js
CHANGED
|
@@ -20,7 +20,7 @@ token org alphabetically):
|
|
|
20
20
|
claude-org proxy [claude args...] launch via a local proxy; a running session
|
|
21
21
|
fails over to another org the moment the 5h
|
|
22
22
|
limit hits (needs 2+ org tokens; claude.ai
|
|
23
|
-
connector MCPs
|
|
23
|
+
connector MCPs need a claude.ai login)
|
|
24
24
|
claude-org auto [claude args...] launch on <prefer> if usable, else default
|
|
25
25
|
(decided once at launch)
|
|
26
26
|
claude-org <name> [claude args...] e.g. claude-org work --resume
|
package/package.json
CHANGED