claude-org 0.3.0 → 0.4.0

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