claude-org 0.4.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 +6 -0
- package/README.md +25 -10
- package/bin/claude-org.js +13 -2
- package/lib/launch.js +17 -4
- package/lib/proxy.js +232 -25
- package/lib/usage.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
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
|
+
|
|
5
11
|
## 0.4.0 — 2026-10-11
|
|
6
12
|
|
|
7
13
|
- `proxy`: hardened for unattended use under a supervisor (#22)
|
package/README.md
CHANGED
|
@@ -82,9 +82,13 @@ 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 **per-session secret**
|
|
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>)`.
|
|
86
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.
|
|
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.
|
|
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.
|
|
88
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.
|
|
89
93
|
|
|
90
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.
|
|
@@ -97,39 +101,48 @@ One log line per request (`via <org> model=<model> -> <status>`) goes to a temp
|
|
|
97
101
|
[ -n "$CLAUDE_ORG_STATE_FILE" ] && [ -f "$CLAUDE_ORG_STATE_FILE" ] && node -p "require(process.env.CLAUDE_ORG_STATE_FILE).org"
|
|
98
102
|
```
|
|
99
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
|
+
|
|
100
113
|
**Limitations of proxy mode**
|
|
101
114
|
|
|
102
|
-
- 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.
|
|
103
116
|
- Each proxy serves one session and dies when `claude` exits.
|
|
104
117
|
|
|
105
118
|
**Subagents**
|
|
106
119
|
|
|
107
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).
|
|
108
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`).
|
|
109
|
-
-
|
|
122
|
+
- Subagents get the same connector MCPs as the parent session (none when it started without a claude.ai login).
|
|
110
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.
|
|
111
124
|
- Work routed to a Team / Enterprise org (e.g. your company's) counts toward that org's usage, which its admins can see.
|
|
112
125
|
|
|
113
126
|
### Prompt cache & tool search
|
|
114
127
|
|
|
115
|
-
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:
|
|
116
129
|
|
|
117
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.
|
|
118
131
|
- **MCP tool search** (deferred tool-definition loading) is **disabled**.
|
|
119
132
|
|
|
120
|
-
`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.
|
|
121
134
|
|
|
122
135
|
| Entry point | Mid-session switch | Connector MCPs | Best for |
|
|
123
136
|
|---|---|---|---|
|
|
124
137
|
| `claude` | no (default org) | yes | connector work |
|
|
125
138
|
| `claude-org auto --resume` | at launch only | no on preferred org / yes on default | short tasks |
|
|
126
|
-
| `claude-org proxy --resume` | **automatic** | no | long-running work, agents, unattended runs |
|
|
139
|
+
| `claude-org proxy --resume` | **automatic** | yes with a claude.ai login (those of the login) / no otherwise | long-running work, agents, unattended runs |
|
|
127
140
|
|
|
128
141
|
### Unattended use (supervisors, `--dangerously-skip-permissions`)
|
|
129
142
|
|
|
130
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`:
|
|
131
144
|
|
|
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.
|
|
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).
|
|
133
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).
|
|
134
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):
|
|
135
148
|
|
|
@@ -169,8 +182,10 @@ When a supervisor starts `claude` without a person watching (e.g. `claude -p` wi
|
|
|
169
182
|
| `CLAUDE_ORG_WATCH_MAX_SLEEP` | `18000` | Max seconds between `watch` re-checks (values < 60 also lower the 60s minimum — for tests) |
|
|
170
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 |
|
|
171
184
|
| `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
|
|
173
|
-
| `
|
|
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 |
|
|
174
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) |
|
|
175
190
|
| `CLAUDE_ORG_FORCE_PROBE` | (unset) | Test seam: `allowed` / `rejected[:<epoch>]` / `rejected_until:<epoch>` fakes `probe` results without API calls |
|
|
176
191
|
| `CLAUDE_ORG_REJECT_FILE` | (unset) | Test seam: orgs listed comma-separated in this file are treated as rate-limited by `proxy` |
|
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/launch.js
CHANGED
|
@@ -7,8 +7,9 @@ const { readToken } = require('./config');
|
|
|
7
7
|
const KILL_GRACE_MS = 10 * 1000;
|
|
8
8
|
const FORWARDED_SIGNALS = ['SIGTERM', 'SIGHUP', 'SIGINT'];
|
|
9
9
|
|
|
10
|
-
// Spawn claude with inherited stdio and extra env
|
|
11
|
-
//
|
|
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).
|
|
12
13
|
//
|
|
13
14
|
// Signals sent to this launcher (a supervisor's reaper or timeout kills the
|
|
14
15
|
// launcher pid, not claude) are forwarded to claude, and the launcher waits
|
|
@@ -18,11 +19,23 @@ const FORWARDED_SIGNALS = ['SIGTERM', 'SIGHUP', 'SIGINT'];
|
|
|
18
19
|
// "exit". So with a terminal on any of stdin/stdout/stderr, SIGINT is left to
|
|
19
20
|
// claude; with none (headless, under a supervisor) it is forwarded like the
|
|
20
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
|
+
|
|
21
34
|
function runClaude(args, envExtra = {}) {
|
|
22
35
|
return new Promise(resolve => {
|
|
23
36
|
const child = spawnClaude(args, {
|
|
24
37
|
stdio: 'inherit',
|
|
25
|
-
env:
|
|
38
|
+
env: childEnv(envExtra),
|
|
26
39
|
});
|
|
27
40
|
let killTimer = null;
|
|
28
41
|
const handlers = new Map();
|
|
@@ -65,4 +78,4 @@ async function launch(name, args) {
|
|
|
65
78
|
return runClaude(args, { CLAUDE_CODE_OAUTH_TOKEN: tok });
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
module.exports = { runClaude, launch };
|
|
81
|
+
module.exports = { runClaude, launch, childEnv };
|
package/lib/proxy.js
CHANGED
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
// first and returns 401. The proxy drops x-api-key and sends Bearer only.
|
|
18
18
|
//
|
|
19
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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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.
|
|
23
26
|
//
|
|
24
27
|
// Env (standalone mode):
|
|
25
28
|
// PORT listen port (default 0 = OS picks; reported on the
|
|
@@ -62,6 +65,7 @@ const REQUEST_BODY_MAX_BYTES = 64 * 1024 * 1024;
|
|
|
62
65
|
// API_TIMEOUT_MS defaults to the same 10 minutes.
|
|
63
66
|
const UPSTREAM_TIMEOUT_MS = 10 * 60 * 1000;
|
|
64
67
|
const ALLOWED_METHODS = new Set(['GET', 'POST', 'DELETE']);
|
|
68
|
+
const SECRET_HEADER = 'x-claude-org-secret';
|
|
65
69
|
// Hop-by-hop headers (RFC 9110 §7.6.1) describe one connection and must not be
|
|
66
70
|
// forwarded; the body is re-framed by this proxy anyway.
|
|
67
71
|
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
@@ -97,6 +101,27 @@ function requestModel(body) {
|
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
|
|
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
|
+
|
|
100
125
|
// Client input for the log: limited to safe characters and cut short.
|
|
101
126
|
function logModel(model) {
|
|
102
127
|
return model.replace(/[^\w.:@/-]/g, '_').slice(0, 80);
|
|
@@ -315,7 +340,8 @@ function createProxy(opts = {}) {
|
|
|
315
340
|
const h = stripHopByHop(headers);
|
|
316
341
|
for (const k of Object.keys(h)) {
|
|
317
342
|
const lk = k.toLowerCase();
|
|
318
|
-
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];
|
|
319
345
|
}
|
|
320
346
|
h.authorization = `Bearer ${token}`;
|
|
321
347
|
h.host = UPSTREAM.host;
|
|
@@ -347,9 +373,15 @@ function createProxy(opts = {}) {
|
|
|
347
373
|
// Checks done before any body is read or anything is forwarded. Returns
|
|
348
374
|
// [status, message] for a request to refuse, or null.
|
|
349
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];
|
|
350
379
|
const auth = clientReq.headers.authorization || '';
|
|
351
|
-
|
|
352
|
-
|
|
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)`];
|
|
353
385
|
}
|
|
354
386
|
const addr = server.address(); // null once the server is closing
|
|
355
387
|
if (!addr) return [503, 'proxy is shutting down'];
|
|
@@ -378,7 +410,11 @@ function createProxy(opts = {}) {
|
|
|
378
410
|
abort.abort();
|
|
379
411
|
if (up) up.destroy();
|
|
380
412
|
});
|
|
381
|
-
const
|
|
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;
|
|
382
418
|
const model = isModelReq ? requestModel(body) : '';
|
|
383
419
|
let order;
|
|
384
420
|
try {
|
|
@@ -489,6 +525,7 @@ function createProxy(opts = {}) {
|
|
|
489
525
|
log(`ALERT no-org-available model=${logModel(model)} reason=${modelRefused ? 'model-rejected' : 'rate-limited'} status=${up.statusCode}`);
|
|
490
526
|
}
|
|
491
527
|
}
|
|
528
|
+
if (quotaCheck) log(`quota check via ${org} -> ${up.statusCode} (passed through, not counted as a limit)`);
|
|
492
529
|
if (clientGone) { up.destroy(); return; }
|
|
493
530
|
clientRes.writeHead(up.statusCode, { ...stripHopByHop(up.headers), 'x-claude-org': org });
|
|
494
531
|
if (errBody) {
|
|
@@ -559,9 +596,162 @@ function createProxy(opts = {}) {
|
|
|
559
596
|
// `claude-org proxy [claude args...]`: start an in-process proxy on a random
|
|
560
597
|
// port, point ANTHROPIC_BASE_URL at it, launch claude, and shut the proxy down
|
|
561
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
|
+
|
|
562
752
|
async function proxyLaunch(args) {
|
|
563
753
|
const { listOrgs, tokenDir, resolvePrefer } = require('./config');
|
|
564
|
-
const { runClaude } = require('./launch');
|
|
754
|
+
const { runClaude, childEnv } = require('./launch');
|
|
565
755
|
|
|
566
756
|
// An org whose token file the proxy would refuse (S5) is reported here, at
|
|
567
757
|
// launch, not only in the proxy log after every request has failed.
|
|
@@ -577,25 +767,29 @@ async function proxyLaunch(args) {
|
|
|
577
767
|
return 1;
|
|
578
768
|
}
|
|
579
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
|
+
|
|
580
778
|
const order = process.env.CLAUDE_ORG_ORDER || resolvePrefer();
|
|
581
|
-
|
|
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));
|
|
582
782
|
const logf = path.join(runDir, 'proxy.log');
|
|
583
783
|
const stateFile = path.join(runDir, 'state.json');
|
|
584
784
|
const logStream = fs.createWriteStream(logf);
|
|
585
|
-
|
|
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
|
-
});
|
|
785
|
+
const logOut = guardLogStream(logStream, logf);
|
|
592
786
|
// Only this claude may use this proxy (and so the org tokens): it must send
|
|
593
|
-
// this per-session secret
|
|
594
|
-
// and nowhere else.
|
|
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.
|
|
595
789
|
const secret = crypto.randomBytes(32).toString('hex');
|
|
596
790
|
let proxy;
|
|
597
791
|
try {
|
|
598
|
-
proxy = createProxy({ order: order.split(','), log:
|
|
792
|
+
proxy = createProxy({ order: order.split(','), log: logOut, stateFile, secret });
|
|
599
793
|
} catch (e) {
|
|
600
794
|
console.error(`claude-org: ${e.message}`);
|
|
601
795
|
logStream.end();
|
|
@@ -616,17 +810,30 @@ async function proxyLaunch(args) {
|
|
|
616
810
|
return 1;
|
|
617
811
|
}
|
|
618
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}`);
|
|
619
816
|
console.error(`-> proxy 127.0.0.1:${port} (order: ${order}, strategy: ${strategy}, log: ${logf})`);
|
|
620
817
|
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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.
|
|
624
821
|
const envExtra = {
|
|
625
822
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
626
|
-
|
|
627
|
-
|
|
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
|
|
628
825
|
CLAUDE_ORG_STATE_FILE: stateFile, // last org/model, for statusline and hooks
|
|
629
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
|
+
}
|
|
630
837
|
if (!process.env.CLAUDE_CODE_PROMPT_CACHE_TTL) envExtra.CLAUDE_CODE_PROMPT_CACHE_TTL = '1h';
|
|
631
838
|
if (!process.env.ENABLE_TOOL_SEARCH) envExtra.ENABLE_TOOL_SEARCH = 'true';
|
|
632
839
|
|
|
@@ -659,4 +866,4 @@ if (require.main === module) {
|
|
|
659
866
|
});
|
|
660
867
|
}
|
|
661
868
|
|
|
662
|
-
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