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 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** as its bearer token (`ANTHROPIC_AUTH_TOKEN`, generated with `crypto.randomBytes`; it exists only in that `claude`'s environment).
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 connector MCPs (Gmail / Drive / Calendar / Notion / Slack …) and Remote Control are **unavailable**: `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat the session as external-token auth, so `mcp__claude_ai_*` tools do not appear. Local/plugin MCPs work. Open connectors in a plain `claude --resume` session — history is shared.
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
- - The connector MCP limitation above applies to subagents too.
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`. If you have set either variable yourself, your value wins.
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 bearer value clients must send. `claude-org proxy` generates one per session itself |
173
- | `CLAUDE_ORG_PROXY` | `1` when launched via `proxy` | Marker env passed to the claude process so user hooks can detect connector-less sessions |
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 => process.exit(code))
77
+ .then(code => finish(code))
67
78
  .catch(e => {
68
79
  console.error(`claude-org: ${e.message}`);
69
- process.exit(1);
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. Resolves to the child's
11
- // exit code (128+n when killed by signal n, like a shell would report).
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: { ...process.env, ...envExtra },
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 (Authorization: Bearer,
21
- // what claude sends for ANTHROPIC_AUTH_TOKEN), name the proxy itself in Host
22
- // (no DNS rebinding from a browser), and be a GET/POST/DELETE under /v1/.
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') delete h[k];
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
- if (!auth.startsWith('Bearer ') || !secretMatches(auth.slice(7), SECRET)) {
352
- return [401, 'missing or wrong proxy secret (the Bearer token claude-org proxy gave this session)'];
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 isModelReq = clientReq.url.startsWith('/v1/messages');
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
- const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-org-proxy-'));
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
- // A log that cannot be written (disk full, dir removed) must not take the
586
- // proxy down with an unhandled 'error'.
587
- let logFailed = false;
588
- logStream.on('error', e => {
589
- if (!logFailed) console.error(`claude-org: cannot write the proxy log ${logf}: ${e.message}`);
590
- logFailed = true;
591
- });
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 as its Bearer token. It goes into the child's env
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: logStream, stateFile, secret });
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
- // A custom ANTHROPIC_BASE_URL + AUTH_TOKEN makes Claude Code treat the session
622
- // as API-key auth: prompt cache TTL drops to 5min and MCP tool search is
623
- // disabled. Restore subscription-equivalent defaults; user-set values win.
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
- ANTHROPIC_AUTH_TOKEN: secret, // sent as Bearer; the proxy checks it, then swaps in an org token
627
- CLAUDE_ORG_PROXY: '1', // marker: "connector MCPs unavailable" session
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 are unavailable)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-org",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Switch Claude Code between multiple Anthropic organizations, with automatic failover on the 5-hour rate limit",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",