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 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 **dummy** bearer token.
86
- 3. The proxy replaces the `Authorization` header with a real org token and forwards to `api.anthropic.com`. 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.
87
- 4. Claude Code only ever sees the successful response — the org switched mid-session. SSE streaming is piped through untouched.
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 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.
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
- - 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).
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`. 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.
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
- | `CLAUDE_ORG_PROXY` | `1` when launched via `proxy` | Marker env passed to the claude process so user hooks can detect connector-less sessions |
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 => 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/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
- // Saved org names: *.token files, extension stripped, sorted.
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
- return files.filter(f => f.endsWith('.token')).map(f => f.slice(0, -6)).sort();
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
- // Spawn claude with inherited stdio and extra env. Resolves to the child's
6
- // exit code (128+n when killed by signal n, like a shell would report).
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: { ...process.env, ...envExtra },
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
- resolve(1);
61
+ done(1);
16
62
  });
17
63
  child.on('exit', (code, signal) => {
18
- if (code !== null) return resolve(code);
19
- resolve(signal ? 128 + ({ SIGINT: 2, SIGTERM: 15 }[signal] || 1) : 1);
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 listen port (default 0 = OS picks; reported on the
21
- // "listening on" stderr line)
22
- // CLAUDE_ORG_TOKEN_DIR where <name>.token files live (~/.claude/org-tokens)
23
- // CLAUDE_ORG_ORDER org priority, comma-separated (default: alphabetical)
24
- // CLAUDE_ORG_STRATEGY "least-utilized": prefer the usable org with the
25
- // lowest 5h utilization seen so far (default: ORDER)
26
- // CLAUDE_ORG_UPSTREAM forward target (default https://api.anthropic.com)
27
- // CLAUDE_ORG_REJECT_FILE test seam: orgs listed comma-separated in this file
28
- // are treated as rate-limited
29
- // CLAUDE_ORG_STATE_FILE where the org and model of the last /v1/messages
30
- // response are written as JSON (unset: not written)
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
- // The model name for the log: client input, so limited to safe characters and
60
- // cut short.
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 || 'https://api.anthropic.com');
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 files = fs.readdirSync(TOKEN_DIR).filter(f => f.endsWith('.token')).map(f => f.slice(0, -6)).sort();
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
- return fs.readFileSync(path.join(TOKEN_DIR, `${name}.token`), 'utf8').replace(/[\r\n]/g, '');
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
- function forward(org, method, urlPath, headers, body) {
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 = { ...headers };
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') delete h[k];
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 ${readToken(org)}`;
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
- { hostname: UPSTREAM.hostname, port: UPSTREAM.port || (transport === https ? 443 : 80), path: urlPath, method, headers: h },
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
- const server = http.createServer((clientReq, clientRes) => {
254
- const chunks = [];
255
- clientReq.on('data', c => chunks.push(c));
256
- clientReq.on('end', async () => {
257
- const body = Buffer.concat(chunks);
258
- const isModelReq = clientReq.url.startsWith('/v1/messages');
259
- const model = isModelReq ? requestModel(body) : '';
260
- const order = pickOrder(model);
261
- let attempt = 0;
262
- let modelRefusals = 0;
263
- for (const org of order) {
264
- attempt++;
265
- let up;
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
- up = await forward(org, clientReq.method, clientReq.url, clientReq.headers, body);
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
- const hdr = name => up.headers[`anthropic-ratelimit-unified-${name}`];
273
- const status5h = hdr('5h-status');
274
- const status7d = hdr('7d-status');
275
- const claim = hdr('representative-claim');
276
- const util5h = Number(hdr('5h-utilization'));
277
- if (isModelReq && hdr('5h-utilization') !== undefined && Number.isFinite(util5h)) {
278
- recordUtil(org, util5h, Number(hdr('5h-reset')));
279
- } else if (isModelReq) {
280
- answeredUnmeasured.add(org);
281
- }
282
- // The 5h and 7d windows cover every model of the org, so only those make
283
- // the org as a whole unusable. Other refusals are per model: a used-up
284
- // per-model weekly allowance (claim=seven_day_overage_included) or a model
285
- // that needs usage credits (429 with no window headers). The overall
286
- // "unified-status" header is not used: it also reads "rejected" for those.
287
- const orgWide = status5h === 'rejected' || status7d === 'rejected';
288
- const limited = up.statusCode === 429 || orgWide;
289
- const windows = `5h=${status5h ?? '?'}${claim ? `, claim=${claim}` : ''}`;
290
- // A 429 body is a small JSON error; buffer it to log the reason and,
291
- // if no org is left, to hand it to the client unchanged.
292
- let errBody = null;
293
- let reason = '';
294
- if (isModelReq && up.statusCode === 429) {
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 (isModelReq) {
330
- const util = hdr('5h-utilization');
331
- log(`${clientReq.method} ${clientReq.url} via ${org} model=${logModel(model)} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
332
- writeState(org, model, up.statusCode);
333
- if (modelRefused && modelRefusals === attempt - 1) {
334
- 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`);
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
- clientRes.writeHead(up.statusCode, { ...up.headers, 'x-claude-org': org });
338
- if (errBody) {
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
- if (isModelReq) writeState(null, model, 503); // no org served it
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
- const orgs = listOrgs();
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
- 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));
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 { server, log, upstream, strategy } = createProxy({ order: order.split(','), log: logStream, stateFile });
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
- // A custom ANTHROPIC_BASE_URL + AUTH_TOKEN makes Claude Code treat the session
391
- // as API-key auth: prompt cache TTL drops to 5min and MCP tool search is
392
- // 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.
393
821
  const envExtra = {
394
822
  ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
395
- ANTHROPIC_AUTH_TOKEN: 'claude-org-proxy', // dummy; the proxy replaces it
396
- 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
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
- const { server, listOrgs, log, upstream, strategy } = createProxy();
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
- log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host} strategy=${strategy}`);
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 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.3.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",