@askalf/dario 3.4.1 → 3.4.4

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/README.md CHANGED
@@ -408,13 +408,13 @@ Anthropic periodically rotates the OAuth `client_id`, authorize URL, token URL,
408
408
 
409
409
  Dario scans the installed CC binary at startup and extracts the current config directly:
410
410
 
411
- - **Anchor**: `OAUTH_FILE_SUFFIX:"-local-oauth"` — the config block CC uses for clients that run their own localhost callback.
412
- - **Extracted**: `CLIENT_ID`, `CLAUDE_AI_AUTHORIZE_URL`, `TOKEN_URL`, and the full `user:*` scope string.
413
- - **Cached**: Results stored at `~/.dario/cc-oauth-cache.json` keyed by binary fingerprint (first 64KB sha256 + size + mtime). Cold scan ~500ms, cache hit ~5ms. Re-scans only when CC is upgraded.
414
- - **Fallback**: If CC is not installed or scanning fails, dario uses known-good hardcoded values. No user action needed.
411
+ - **Anchor**: `BASE_API_URL:"https://api.anthropic.com"` — this literal appears only inside CC's live prod OAuth config block, so the scanner reliably lands in the right object even when the minifier reorders fields across CC releases.
412
+ - **Extracted**: `CLIENT_ID`, `CLAUDE_AI_AUTHORIZE_URL`, `TOKEN_URL`, and the full `user:*` scope string. A defensive check rejects any scan result that matches a known-dead internal client_id.
413
+ - **Cached**: Results stored at `~/.dario/cc-oauth-cache-v2.json` keyed by binary fingerprint (first 64KB sha256 + size + mtime). Cold scan ~500ms, cache hit ~5ms. Re-scans only when CC is upgraded.
414
+ - **Fallback**: If CC is not installed or scanning fails, dario uses the CC 2.1.104 prod config values hardcoded in-tree. No user action needed.
415
415
  - **Override**: Set `DARIO_CC_PATH=/path/to/claude` to point dario at a non-standard CC binary location.
416
416
 
417
- CC ships **two** OAuth client configurations in one binary a `-local-oauth` flow (localhost callback) and a platform-hosted flow (`platform.claude.com/oauth/code/callback`). Dario must use the former. The scanner anchors specifically on the local block.
417
+ CC ships three OAuth config factories (`local`, `staging`, `prod`) in one binary, selected at runtime by a function that is hardcoded to `prod` in shipped builds. Only the prod block is live; the other two are dead code paths CC uses when pointing at internal dev/staging infrastructure. The scanner targets the prod block specifically.
418
418
 
419
419
  End-to-end verification lives at [`test/oauth-detector.mjs`](test/oauth-detector.mjs).
420
420
 
@@ -439,8 +439,10 @@ End-to-end verification lives at [`test/oauth-detector.mjs`](test/oauth-detector
439
439
  | `--preserve-tools` / `--keep-tools` | Keep client tool schemas instead of remapping to CC tools | off |
440
440
  | `--model=MODEL` | Force a model (`opus`, `sonnet`, `haiku`, or full ID) | passthrough |
441
441
  | `--port=PORT` | Port to listen on | `3456` |
442
+ | `--host=ADDRESS` / `DARIO_HOST` | Bind address. Use `0.0.0.0` to accept LAN connections, or a specific IP to bind selectively (e.g. a Tailscale interface). When non-loopback, also set `DARIO_API_KEY`. | `127.0.0.1` |
442
443
  | `--verbose` / `-v` | Log every request | off |
443
- | `DARIO_API_KEY` | If set, all endpoints (except `/health`) require matching `x-api-key` or `Authorization: Bearer` | unset (open) |
444
+ | `DARIO_API_KEY` | If set, all endpoints (except `/health`) require matching `x-api-key` or `Authorization: Bearer`. **Required** when `--host` binds to anything other than loopback. | unset (open) |
445
+ | `DARIO_CORS_ORIGIN` | Override the browser CORS `Access-Control-Allow-Origin`. Useful for browser-based clients (open-webui, librechat) connecting over a mesh network. | `http://localhost:${port}` |
444
446
  | `DARIO_NO_BUN` | Disable automatic Bun relaunch (stay on Node.js) | unset |
445
447
  | `DARIO_MIN_INTERVAL_MS` | Minimum ms between requests (rate governor) | `500` |
446
448
  | `DARIO_CC_PATH` | Override path to Claude Code binary for OAuth detection | auto-detect |
@@ -506,9 +508,9 @@ curl http://localhost:3456/health
506
508
  |---------|---------------------|
507
509
  | Credential storage | Reads from Claude Code (`~/.claude/.credentials.json`) or its own store (`~/.dario/credentials.json`) with `0600` permissions |
508
510
  | OAuth flow | PKCE (Proof Key for Code Exchange) — no client secret needed |
509
- | OAuth config source | Auto-detected from local CC binary at runtime; cached at `~/.dario/cc-oauth-cache.json`. Detector reads binary in read-only mode, never modifies it. |
511
+ | OAuth config source | Auto-detected from local CC binary at runtime; cached at `~/.dario/cc-oauth-cache-v2.json`. Detector reads binary in read-only mode, never modifies it. |
510
512
  | Token exposure | Tokens never logged; redacted from all error messages. |
511
- | Network binding | Binds exclusively to `127.0.0.1`. Upstream traffic goes only to `api.anthropic.com` over HTTPS. |
513
+ | Network binding | Binds to `127.0.0.1` by default. Override with `--host` / `DARIO_HOST` for mesh/LAN use; dario refuses to treat non-loopback binds as safe and requires you to set `DARIO_API_KEY` to avoid an unauthenticated LAN-reachable proxy. Upstream traffic goes only to `api.anthropic.com` over HTTPS. |
512
514
  | Auth timing | `timingSafeEqual` used for `DARIO_API_KEY` comparison. |
513
515
  | SSRF protection | Only `/v1/messages` and `/v1/complete` are proxied upstream — hardcoded allowlist. |
514
516
  | Body size | 10MB hard cap per request. 30s read timeout prevents slow-loris. |
@@ -571,7 +573,7 @@ Optional but recommended. If [Bun](https://bun.sh) is installed, dario auto-rela
571
573
  Dario auto-refreshes tokens 30 minutes before expiry. You should never see an auth error in normal use. If something goes wrong, `dario refresh` forces an immediate refresh.
572
574
 
573
575
  **What happens when Anthropic rotates the OAuth client_id or URL?**
574
- Dario auto-detects OAuth config from your installed Claude Code binary. When CC ships a new version with rotated values, dario picks them up on the next startup — no dario release needed. The detector is cached at `~/.dario/cc-oauth-cache.json` and only re-scans when the binary fingerprint changes. If CC isn't installed, dario falls back to known-good hardcoded values.
576
+ Dario auto-detects OAuth config from your installed Claude Code binary. When CC ships a new version with rotated values, dario picks them up on the next startup — no dario release needed. The detector is cached at `~/.dario/cc-oauth-cache-v2.json` and only re-scans when the binary fingerprint changes. If CC isn't installed, dario falls back to hardcoded CC 2.1.104 prod values.
575
577
 
576
578
  **I'm hitting rate limits. What do I do?**
577
579
  Claude subscriptions have rolling 5-hour and 7-day usage windows. Check your utilization with Claude Code's `/usage` command or the [statusline](https://code.claude.com/docs/en/statusline). Rate limit errors from dario include utilization percentages and reset times so you can see exactly when capacity returns.
@@ -582,7 +584,7 @@ If you're running a multi-agent workload and consistently hitting limits, [askal
582
584
  Claude subscriptions have rolling 5-hour and 7-day usage windows shared across claude.ai and Claude Code. See [Anthropic's docs](https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work) for details.
583
585
 
584
586
  **Can I run this on a server?**
585
- Dario binds to localhost by default. For server use, handle the initial login on a machine with a browser, then copy `~/.claude/.credentials.json` (or `~/.dario/credentials.json`) to your server. Auto-refresh will keep it alive from there.
587
+ Dario binds to localhost by default. For server use, handle the initial login on a machine with a browser, then copy `~/.claude/.credentials.json` (or `~/.dario/credentials.json`) to your server. Auto-refresh will keep it alive from there. If you want dario reachable from other machines on the same LAN or a Tailscale mesh, pass `--host=0.0.0.0` (or a specific interface IP) and set `DARIO_API_KEY` to gate access.
586
588
 
587
589
  **Why "dario"?**
588
590
  Named after [Dario Amodei](https://en.wikipedia.org/wiki/Dario_Amodei), CEO of Anthropic.
@@ -622,7 +624,7 @@ Dario handles your OAuth tokens. Here's why you can trust it:
622
624
  | **npm provenance** | Every release is [SLSA attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions |
623
625
  | **Security scanning** | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) runs on every push and weekly |
624
626
  | **Credential handling** | Tokens never logged, redacted from errors, stored with 0600 permissions |
625
- | **Network scope** | Binds to 127.0.0.1 only. Upstream traffic goes exclusively to `api.anthropic.com` over HTTPS |
627
+ | **Network scope** | Binds to 127.0.0.1 by default; `--host` / `DARIO_HOST` allows LAN/mesh exposure with `DARIO_API_KEY` gating. Upstream traffic goes exclusively to `api.anthropic.com` over HTTPS |
626
628
  | **No telemetry** | Zero analytics, tracking, or data collection of any kind |
627
629
  | **Audit trail** | [CHANGELOG.md](CHANGELOG.md) documents every release |
628
630
  | **Branch protection** | CI must pass before merge. CODEOWNERS enforces review |
@@ -5,20 +5,32 @@
5
5
  * (client_id, authorize URL, token URL, scopes). Eliminates the need to
6
6
  * hardcode values that Anthropic rotates between CC releases.
7
7
  *
8
- * CC ships two OAuth client configurations in one binary:
8
+ * CC ships three OAuth config factories in one binary (dev/staging/prod),
9
+ * selected at runtime by an environment switch that is hardcoded to "prod"
10
+ * in shipped builds. Only the PROD block is live; "local" and "staging"
11
+ * are dead code paths.
9
12
  *
10
- * 1. LOCAL flow — used when the OAuth client owns the callback
11
- * (i.e. runs an HTTP server on localhost). This is what dario does.
12
- * Identified by OAUTH_FILE_SUFFIX:"-local-oauth" next to the CLIENT_ID.
13
+ * PROD block (the one we want):
14
+ * BASE_API_URL: "https://api.anthropic.com"
15
+ * CLAUDE_AI_AUTHORIZE_URL: "https://claude.com/cai/oauth/authorize"
16
+ * TOKEN_URL: "https://platform.claude.com/v1/oauth/token"
17
+ * CLIENT_ID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
18
+ * OAUTH_FILE_SUFFIX: ""
13
19
  *
14
- * 2. PLATFORM flow used when the callback is hosted at
15
- * platform.claude.com/oauth/code/callback. Different CLIENT_ID.
16
- * Not applicable to dario.
20
+ * LOCAL block (dead code in shipped builds CC pointing at localhost:8000
21
+ * etc. as its own dev stack, NOT about "client uses a localhost callback"):
22
+ * BASE_API_URL: "http://localhost:8000"
23
+ * CLIENT_ID: "22422756-60c9-4084-8eb7-27705fd5cf9a"
24
+ * OAUTH_FILE_SUFFIX: "-local-oauth"
17
25
  *
18
- * We scan for the LOCAL block and extract its config.
26
+ * Dario uses CC's own automatic OAuth flow the prod client is registered
27
+ * with `http://localhost:${port}/callback` exactly as dario sends. (The
28
+ * "MANUAL_REDIRECT_URL" on platform.claude.com is only used when dario's
29
+ * local HTTP server can't bind a port; dario never hits that path.)
19
30
  *
20
- * Results are cached per-binary-hash at ~/.dario/cc-oauth-cache.json so
21
- * startup only re-scans when the user upgrades Claude Code.
31
+ * Results are cached per-binary-hash at ~/.dario/cc-oauth-cache-v2.json so
32
+ * startup only re-scans when the user upgrades Claude Code. The -v2 suffix
33
+ * invalidates the v3.4.0-v3.4.2 caches that held the wrong (dev) client_id.
22
34
  */
23
35
  export interface DetectedOAuthConfig {
24
36
  clientId: string;
@@ -30,10 +42,14 @@ export interface DetectedOAuthConfig {
30
42
  ccHash?: string;
31
43
  }
32
44
  /**
33
- * Scan binary bytes for the LOCAL-oauth OAuth block.
34
- * Uses Buffer.indexOf to locate anchor strings, then slices a small
35
- * window of context to run regexes on. This avoids converting the
36
- * whole binary to a JS string.
45
+ * Scan binary bytes for the PROD OAuth config block.
46
+ *
47
+ * Anchors on `BASE_API_URL:"https://api.anthropic.com"` this literal
48
+ * only appears inside the prod config object (`nh$`). The LOCAL-dev block
49
+ * uses `http://localhost:8000` for the same key, and there's no staging
50
+ * block present in shipped builds. Once we find the anchor, the CLIENT_ID,
51
+ * CLAUDE_AI_AUTHORIZE_URL, TOKEN_URL, and scopes all live within a ~1.5KB
52
+ * window after it.
37
53
  */
38
54
  export declare function scanBinaryForOAuthConfig(buf: Buffer): Omit<DetectedOAuthConfig, 'source' | 'ccPath' | 'ccHash'> | null;
39
55
  /**
@@ -5,20 +5,32 @@
5
5
  * (client_id, authorize URL, token URL, scopes). Eliminates the need to
6
6
  * hardcode values that Anthropic rotates between CC releases.
7
7
  *
8
- * CC ships two OAuth client configurations in one binary:
8
+ * CC ships three OAuth config factories in one binary (dev/staging/prod),
9
+ * selected at runtime by an environment switch that is hardcoded to "prod"
10
+ * in shipped builds. Only the PROD block is live; "local" and "staging"
11
+ * are dead code paths.
9
12
  *
10
- * 1. LOCAL flow — used when the OAuth client owns the callback
11
- * (i.e. runs an HTTP server on localhost). This is what dario does.
12
- * Identified by OAUTH_FILE_SUFFIX:"-local-oauth" next to the CLIENT_ID.
13
+ * PROD block (the one we want):
14
+ * BASE_API_URL: "https://api.anthropic.com"
15
+ * CLAUDE_AI_AUTHORIZE_URL: "https://claude.com/cai/oauth/authorize"
16
+ * TOKEN_URL: "https://platform.claude.com/v1/oauth/token"
17
+ * CLIENT_ID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
18
+ * OAUTH_FILE_SUFFIX: ""
13
19
  *
14
- * 2. PLATFORM flow used when the callback is hosted at
15
- * platform.claude.com/oauth/code/callback. Different CLIENT_ID.
16
- * Not applicable to dario.
20
+ * LOCAL block (dead code in shipped builds CC pointing at localhost:8000
21
+ * etc. as its own dev stack, NOT about "client uses a localhost callback"):
22
+ * BASE_API_URL: "http://localhost:8000"
23
+ * CLIENT_ID: "22422756-60c9-4084-8eb7-27705fd5cf9a"
24
+ * OAUTH_FILE_SUFFIX: "-local-oauth"
17
25
  *
18
- * We scan for the LOCAL block and extract its config.
26
+ * Dario uses CC's own automatic OAuth flow the prod client is registered
27
+ * with `http://localhost:${port}/callback` exactly as dario sends. (The
28
+ * "MANUAL_REDIRECT_URL" on platform.claude.com is only used when dario's
29
+ * local HTTP server can't bind a port; dario never hits that path.)
19
30
  *
20
- * Results are cached per-binary-hash at ~/.dario/cc-oauth-cache.json so
21
- * startup only re-scans when the user upgrades Claude Code.
31
+ * Results are cached per-binary-hash at ~/.dario/cc-oauth-cache-v2.json so
32
+ * startup only re-scans when the user upgrades Claude Code. The -v2 suffix
33
+ * invalidates the v3.4.0-v3.4.2 caches that held the wrong (dev) client_id.
22
34
  */
23
35
  import { readFile, writeFile, mkdir, stat, open as openFile } from 'node:fs/promises';
24
36
  import { existsSync } from 'node:fs';
@@ -26,15 +38,29 @@ import { homedir, platform } from 'node:os';
26
38
  import { join, dirname } from 'node:path';
27
39
  import { createHash } from 'node:crypto';
28
40
  // Last-resort fallback if CC binary can't be found or scanned.
29
- // These values are the known-good v2.1.104 local-oauth flow.
41
+ // These values are the CC v2.1.104 PROD OAuth config, extracted from
42
+ // the `nh$` object in the shipped binary.
30
43
  const FALLBACK = {
31
- clientId: '22422756-60c9-4084-8eb7-27705fd5cf9a',
44
+ clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
32
45
  authorizeUrl: 'https://claude.com/cai/oauth/authorize',
33
46
  tokenUrl: 'https://platform.claude.com/v1/oauth/token',
34
- scopes: 'user:profile user:inference user:sessions:claude_code user:mcp_servers',
47
+ // Scopes are the full `n36` union from the CC binary, which is the value
48
+ // sent during a normal `claude login` (non-setup-token) flow. In CC's
49
+ // source: `let D = f ? [TI] : n36` where `f = inferenceOnly` (true only
50
+ // for `claude setup-token`). Normal interactive login uses the 6-scope
51
+ // union including `org:create_api_key` — even though that scope is named
52
+ // "Console-only" by convention, CC's own login flow requests it up front.
53
+ // Earlier dario versions (3.2.7 through 3.4.3) dropped `org:create_api_key`
54
+ // from the list based on a misread of the name; the dev-only client_id
55
+ // was lenient enough to accept the shorter list, the prod client_id is not.
56
+ scopes: 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload',
35
57
  source: 'fallback',
36
58
  };
37
- const CACHE_PATH = join(homedir(), '.dario', 'cc-oauth-cache.json');
59
+ // -v3 suffix invalidates v3.4.3 caches that were populated with the wrong
60
+ // 4-scope list (the scanner's regex matched a help-message string literal
61
+ // in the CC binary instead of the real scope array). See the scanner's
62
+ // scope handling below for why scope detection is no longer attempted.
63
+ const CACHE_PATH = join(homedir(), '.dario', 'cc-oauth-cache-v3.json');
38
64
  function candidatePaths() {
39
65
  const home = homedir();
40
66
  if (platform() === 'win32') {
@@ -88,80 +114,58 @@ async function fingerprintBinary(path) {
88
114
  }
89
115
  }
90
116
  /**
91
- * Scan binary bytes for the LOCAL-oauth OAuth block.
92
- * Uses Buffer.indexOf to locate anchor strings, then slices a small
93
- * window of context to run regexes on. This avoids converting the
94
- * whole binary to a JS string.
117
+ * Scan binary bytes for the PROD OAuth config block.
118
+ *
119
+ * Anchors on `BASE_API_URL:"https://api.anthropic.com"` this literal
120
+ * only appears inside the prod config object (`nh$`). The LOCAL-dev block
121
+ * uses `http://localhost:8000` for the same key, and there's no staging
122
+ * block present in shipped builds. Once we find the anchor, the CLIENT_ID,
123
+ * CLAUDE_AI_AUTHORIZE_URL, TOKEN_URL, and scopes all live within a ~1.5KB
124
+ * window after it.
95
125
  */
96
126
  export function scanBinaryForOAuthConfig(buf) {
97
- // Anchor: `OAUTH_FILE_SUFFIX:"-local-oauth"` — this is the config-block
98
- // occurrence, not the switch-case string literal. The switch-case produces
99
- // just `-local-oauth` bytes, but the config object serializes as
100
- // `OAUTH_FILE_SUFFIX:"-local-oauth"` with the key+quote prefix, which is
101
- // stable across minified CC builds.
102
- const anchor = Buffer.from('OAUTH_FILE_SUFFIX:"-local-oauth"');
103
- let anchorIdx = buf.indexOf(anchor);
104
- // Fallback anchor — some builds may tokenize differently.
105
- if (anchorIdx === -1) {
106
- const looseAnchor = Buffer.from('"-local-oauth"');
107
- anchorIdx = buf.indexOf(looseAnchor);
108
- }
127
+ const anchor = Buffer.from('BASE_API_URL:"https://api.anthropic.com"');
128
+ const anchorIdx = buf.indexOf(anchor);
109
129
  if (anchorIdx === -1)
110
130
  return null;
111
- // The CLIENT_ID sits within a few hundred bytes BEFORE the anchor
112
- // (in the same config object). Extract a window around it.
113
- const windowStart = Math.max(0, anchorIdx - 1024);
114
- const windowEnd = Math.min(buf.length, anchorIdx + 64);
115
- const localBlock = buf.slice(windowStart, windowEnd).toString('latin1');
116
- // Pick the CLIENT_ID that's CLOSEST to the anchor (last occurrence in window).
117
- const cidRegex = /CLIENT_ID\s*:\s*"([0-9a-f-]{36})"/gi;
118
- let lastCid = null;
119
- let m;
120
- while ((m = cidRegex.exec(localBlock)) !== null) {
121
- if (m[1])
122
- lastCid = m[1];
123
- }
124
- if (!lastCid)
131
+ // The prod config object is laid out roughly as one line of minified JS.
132
+ // Take a generous window to be safe across minifier differences.
133
+ const windowStart = anchorIdx;
134
+ const windowEnd = Math.min(buf.length, anchorIdx + 2048);
135
+ const prodBlock = buf.slice(windowStart, windowEnd).toString('latin1');
136
+ const cidMatch = /CLIENT_ID\s*:\s*"([0-9a-f-]{36})"/i.exec(prodBlock);
137
+ if (!cidMatch || !cidMatch[1])
138
+ return null;
139
+ const clientId = cidMatch[1];
140
+ // Defensive: if we somehow matched the dev client_id, reject — the
141
+ // anchor should have put us in the prod block, but this guards against
142
+ // the block being laid out in an unexpected order across builds.
143
+ if (clientId === '22422756-60c9-4084-8eb7-27705fd5cf9a')
125
144
  return null;
126
- const clientId = lastCid;
127
- // Authorize URL: CLAUDE_AI_AUTHORIZE_URL appears once in the binary.
128
- const authAnchor = Buffer.from('CLAUDE_AI_AUTHORIZE_URL');
129
- const authIdx = buf.indexOf(authAnchor);
130
145
  let authorizeUrl = FALLBACK.authorizeUrl;
131
- if (authIdx !== -1) {
132
- const w = buf.slice(authIdx, Math.min(buf.length, authIdx + 256)).toString('latin1');
133
- const m = /CLAUDE_AI_AUTHORIZE_URL\s*:\s*"([^"]+)"/.exec(w);
134
- if (m && m[1])
135
- authorizeUrl = m[1];
136
- }
137
- // Token URL: TOKEN_URL — look for the one under platform.claude.com/.../oauth/token
138
- const tokenAnchor = Buffer.from('TOKEN_URL');
139
- let searchFrom = 0;
146
+ const authMatch = /CLAUDE_AI_AUTHORIZE_URL\s*:\s*"([^"]+)"/.exec(prodBlock);
147
+ if (authMatch && authMatch[1])
148
+ authorizeUrl = authMatch[1];
140
149
  let tokenUrl = FALLBACK.tokenUrl;
141
- while (searchFrom < buf.length) {
142
- const idx = buf.indexOf(tokenAnchor, searchFrom);
143
- if (idx === -1)
144
- break;
145
- const w = buf.slice(idx, Math.min(buf.length, idx + 128)).toString('latin1');
146
- const m = /TOKEN_URL\s*:\s*"(https:\/\/[^"]*\/oauth\/token[^"]*)"/.exec(w);
147
- if (m && m[1]) {
148
- tokenUrl = m[1];
149
- break;
150
- }
151
- searchFrom = idx + tokenAnchor.length;
152
- }
153
- // Scopes: contiguous quoted string of "user:X user:Y user:Z ..."
154
- // Search for an anchor like "user:profile " which is the first scope.
155
- const scopeAnchor = Buffer.from('"user:profile ');
156
- let scopes = FALLBACK.scopes;
157
- const scopeIdx = buf.indexOf(scopeAnchor);
158
- if (scopeIdx !== -1) {
159
- const w = buf.slice(scopeIdx, Math.min(buf.length, scopeIdx + 512)).toString('latin1');
160
- const m = /"(user:profile(?:\s+user:[a-z_:]+)+)"/.exec(w);
161
- if (m && m[1])
162
- scopes = m[1];
163
- }
164
- return { clientId, authorizeUrl, tokenUrl, scopes };
150
+ const tokenMatch = /TOKEN_URL\s*:\s*"(https:\/\/[^"]*\/oauth\/token[^"]*)"/.exec(prodBlock);
151
+ if (tokenMatch && tokenMatch[1])
152
+ tokenUrl = tokenMatch[1];
153
+ // Scopes are NOT detected from the binary. Previous versions of this
154
+ // scanner anchored on `"user:profile ` and regex-captured the first
155
+ // contiguous quoted run of scopes, but that anchor matches an error/help
156
+ // message string literal (used by `claude setup-token` error output) that
157
+ // contains only 4 of the 6 actual scopes. The real scope array is stored
158
+ // as a constant-reference array — `dY8 = [B9H, TI, "user:sessions:...", ...]`
159
+ // — where the first two elements are minified variable references, not
160
+ // literal strings, so no regex can reliably extract the full list. And the
161
+ // runtime-computed union `n36` only exists after `Array.from(new Set(...))`
162
+ // executes, which we can't evaluate from a static scan.
163
+ //
164
+ // Given that scopes rarely change across CC releases (Anthropic adds or
165
+ // removes maybe one per major version), hardcoding them in FALLBACK is
166
+ // more reliable than scanning. If Anthropic changes the scope list, the
167
+ // fix is a one-line FALLBACK update in a dario release.
168
+ return { clientId, authorizeUrl, tokenUrl, scopes: FALLBACK.scopes };
165
169
  }
166
170
  async function loadCache() {
167
171
  try {
@@ -886,4 +886,4 @@
886
886
  "WebSearch",
887
887
  "Write"
888
888
  ]
889
- }
889
+ }
@@ -46,6 +46,16 @@ const TOOL_MAP = {
46
46
  browse: { ccTool: 'WebFetch', translateArgs: (a) => ({ url: a.url || '' }) },
47
47
  notebook: { ccTool: 'NotebookEdit' },
48
48
  notebook_edit: { ccTool: 'NotebookEdit' },
49
+ // Additional client tool mappings
50
+ browser: { ccTool: 'WebFetch', translateArgs: (a) => ({ url: String(a.url || '') }) },
51
+ message: { ccTool: 'AskUserQuestion', translateArgs: (a) => ({ question: String(a.message || a.content || '') }) },
52
+ todo_read: { ccTool: 'TodoWrite', translateArgs: () => ({ todos: [] }) },
53
+ todo_write: { ccTool: 'TodoWrite', translateArgs: (a) => ({ todos: a.todos || [] }) },
54
+ notebook_read: { ccTool: 'NotebookEdit', translateArgs: (a) => ({ notebook_path: String(a.notebook_path || a.path || '') }) },
55
+ enter_plan_mode: { ccTool: 'EnterPlanMode' },
56
+ exit_plan_mode: { ccTool: 'ExitPlanMode' },
57
+ enter_worktree: { ccTool: 'EnterWorktree', translateArgs: (a) => ({ path: a.path }) },
58
+ exit_worktree: { ccTool: 'ExitWorktree' },
49
59
  };
50
60
  /**
51
61
  * Build a CC-template request from a client request.
@@ -79,34 +89,47 @@ export function buildCCRequest(clientBody, billingTag, cache1h, identity, opts =
79
89
  const activeToolMap = new Map();
80
90
  const unmappedTools = [];
81
91
  if (clientTools && !opts.preserveTools) {
92
+ // Two passes so the unmapped-tool distributor can avoid colliding with
93
+ // CC tools the client already uses directly. Without this, a client
94
+ // sending both `WebSearch` and some unmapped tool like `memory_get`
95
+ // could have both forward-map to `WebSearch`, and the reverse map would
96
+ // then rewrite real `WebSearch` responses to the collided client name.
97
+ const claimedCC = new Set();
82
98
  for (const tool of clientTools) {
83
99
  const name = (tool.name || '').toLowerCase();
84
100
  const mapping = TOOL_MAP[name];
85
101
  if (mapping) {
86
102
  activeToolMap.set(tool.name, mapping);
103
+ claimedCC.add(mapping.ccTool);
87
104
  }
88
- else {
89
- unmappedTools.push(tool.name);
90
- // Distribute unmapped tools across CC tool names to avoid suspicious
91
- // patterns where every unknown tool maps to Bash
92
- const CC_FALLBACK_TOOLS = ['Bash', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
93
- const fallbackTool = CC_FALLBACK_TOOLS[unmappedTools.length % CC_FALLBACK_TOOLS.length];
94
- activeToolMap.set(tool.name, {
95
- ccTool: fallbackTool,
96
- translateArgs: (a) => {
97
- // Translate args to match the CC tool's expected schema
98
- switch (fallbackTool) {
99
- case 'Bash': return { command: `echo "${JSON.stringify(a).slice(0, 200)}"` };
100
- case 'Read': return { file_path: String(a.path || a.file || a.url || '/tmp/output') };
101
- case 'Grep': return { pattern: String(a.query || a.pattern || a.search || '.'), path: '.' };
102
- case 'Glob': return { pattern: String(a.pattern || a.glob || '*') };
103
- case 'WebSearch': return { query: String(a.query || a.q || a.search || '') };
104
- case 'WebFetch': return { url: String(a.url || a.uri || '') };
105
- default: return a;
106
- }
107
- },
108
- });
109
- }
105
+ }
106
+ const CC_FALLBACK_TOOLS = ['Bash', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
107
+ for (const tool of clientTools) {
108
+ const name = (tool.name || '').toLowerCase();
109
+ if (TOOL_MAP[name])
110
+ continue;
111
+ unmappedTools.push(tool.name);
112
+ // Exclude CC tools the client already uses so we never create a
113
+ // two-client-names-to-one-CC-tool collision. If every fallback is
114
+ // claimed (rare: client already uses 6+ CC tools), fall back to the
115
+ // full pool and accept the ambiguity.
116
+ const pool = CC_FALLBACK_TOOLS.filter(t => !claimedCC.has(t));
117
+ const fallbackPool = pool.length > 0 ? pool : CC_FALLBACK_TOOLS;
118
+ const fallbackTool = fallbackPool[(unmappedTools.length - 1) % fallbackPool.length];
119
+ activeToolMap.set(tool.name, {
120
+ ccTool: fallbackTool,
121
+ translateArgs: (a) => {
122
+ switch (fallbackTool) {
123
+ case 'Bash': return { command: `echo "${JSON.stringify(a).slice(0, 200)}"` };
124
+ case 'Read': return { file_path: String(a.path || a.file || a.url || '/tmp/output') };
125
+ case 'Grep': return { pattern: String(a.query || a.pattern || a.search || '.'), path: '.' };
126
+ case 'Glob': return { pattern: String(a.pattern || a.glob || '*') };
127
+ case 'WebSearch': return { query: String(a.query || a.q || a.search || '') };
128
+ case 'WebFetch': return { url: String(a.url || a.uri || '') };
129
+ default: return a;
130
+ }
131
+ },
132
+ });
110
133
  }
111
134
  }
112
135
  // ── Remap tool_use and tool_result references in message history ──
@@ -232,14 +255,26 @@ export function reverseMapResponse(responseBody, toolMap) {
232
255
  if (toolMap.size === 0)
233
256
  return responseBody;
234
257
  let result = responseBody;
235
- // Build reverse map: CC tool name → original client tool name
258
+ // Build reverse map: CC tool name → original client tool name.
259
+ // Two passes so identity mappings (client sent a tool with the real CC
260
+ // name) claim their CC slot first and can never be overwritten by a
261
+ // non-identity entry. Without this, a collision between a direct
262
+ // `WebSearch` and an unmapped-tool fallback landing on `WebSearch` could
263
+ // rewrite the real search response to the wrong client name.
236
264
  const reverseMap = new Map();
265
+ const identityClaimed = new Set();
237
266
  for (const [clientName, mapping] of toolMap) {
238
- // Only add if not a direct CC tool name
239
- if (clientName.toLowerCase() !== mapping.ccTool.toLowerCase()) {
240
- reverseMap.set(mapping.ccTool, clientName);
267
+ if (clientName.toLowerCase() === mapping.ccTool.toLowerCase()) {
268
+ identityClaimed.add(mapping.ccTool);
241
269
  }
242
270
  }
271
+ for (const [clientName, mapping] of toolMap) {
272
+ if (clientName.toLowerCase() === mapping.ccTool.toLowerCase())
273
+ continue;
274
+ if (identityClaimed.has(mapping.ccTool))
275
+ continue;
276
+ reverseMap.set(mapping.ccTool, clientName);
277
+ }
243
278
  for (const [ccName, clientName] of reverseMap) {
244
279
  result = result.replace(new RegExp(`"name"\\s*:\\s*"${ccName}"`, 'g'), `"name":"${clientName}"`);
245
280
  }
package/dist/cli.js CHANGED
@@ -125,12 +125,22 @@ async function proxy() {
125
125
  console.error('[dario] Invalid port. Must be 1-65535.');
126
126
  process.exit(1);
127
127
  }
128
+ // Bind address — accepts --host=<addr>; falls through to DARIO_HOST env
129
+ // var or the default of 127.0.0.1 inside startProxy. The sanity check
130
+ // here only rejects obviously bad shapes; real address validation
131
+ // happens when the OS tries to bind.
132
+ const hostArg = args.find(a => a.startsWith('--host='));
133
+ const host = hostArg ? hostArg.split('=')[1] : undefined;
134
+ if (host !== undefined && !/^[a-zA-Z0-9._:-]+$/.test(host)) {
135
+ console.error('[dario] Invalid --host. Must be an IP address or hostname.');
136
+ process.exit(1);
137
+ }
128
138
  const verbose = args.includes('--verbose') || args.includes('-v');
129
139
  const passthrough = args.includes('--passthrough') || args.includes('--thin');
130
140
  const preserveTools = args.includes('--preserve-tools') || args.includes('--keep-tools');
131
141
  const modelArg = args.find(a => a.startsWith('--model='));
132
142
  const model = modelArg ? modelArg.split('=')[1] : undefined;
133
- await startProxy({ port, verbose, model, passthrough, preserveTools });
143
+ await startProxy({ port, host, verbose, model, passthrough, preserveTools });
134
144
  }
135
145
  async function help() {
136
146
  console.log(`
@@ -151,6 +161,12 @@ async function help() {
151
161
  --passthrough, --thin Thin proxy — OAuth swap only, no injection
152
162
  --preserve-tools Keep client tool schemas (for agents with custom tools)
153
163
  --port=PORT Port to listen on (default: 3456)
164
+ --host=ADDRESS Address to bind to (default: 127.0.0.1)
165
+ Use 0.0.0.0 to accept connections from other machines.
166
+ Alternatively set DARIO_HOST env var.
167
+ When binding non-loopback, also set DARIO_API_KEY
168
+ so unauthenticated LAN hosts can't proxy through
169
+ your OAuth subscription.
154
170
  --verbose, -v Log all requests
155
171
 
156
172
  Quick start:
package/dist/proxy.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  interface ProxyOptions {
2
2
  port?: number;
3
+ host?: string;
3
4
  verbose?: boolean;
4
5
  model?: string;
5
6
  passthrough?: boolean;
package/dist/proxy.js CHANGED
@@ -13,7 +13,16 @@ const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts
13
13
  const UPSTREAM_TIMEOUT_MS = 300_000; // 5 min — matches Anthropic SDK default
14
14
  const BODY_READ_TIMEOUT_MS = 30_000; // 30s — prevents slow-loris on body reads
15
15
  const MAX_CONCURRENT = 10; // Max concurrent upstream requests
16
- const LOCALHOST = '127.0.0.1';
16
+ const DEFAULT_HOST = '127.0.0.1';
17
+ // A host is "loopback" if it's one of the well-known localhost literals.
18
+ // Used to decide whether to warn at startup about binding to a reachable
19
+ // interface — binding anywhere else means other machines can reach the
20
+ // proxy and should only be done with DARIO_API_KEY set.
21
+ function isLoopbackHost(host) {
22
+ if (host === '127.0.0.1' || host === '::1' || host === 'localhost')
23
+ return true;
24
+ return host.startsWith('127.');
25
+ }
17
26
  // Simple semaphore for concurrency control
18
27
  class Semaphore {
19
28
  max;
@@ -308,6 +317,7 @@ function enrich429(body, headers) {
308
317
  }
309
318
  export async function startProxy(opts = {}) {
310
319
  const port = opts.port ?? DEFAULT_PORT;
320
+ const host = opts.host ?? process.env.DARIO_HOST ?? DEFAULT_HOST;
311
321
  const verbose = opts.verbose ?? false;
312
322
  const passthrough = opts.passthrough ?? false;
313
323
  // Verify auth before starting
@@ -354,7 +364,11 @@ export async function startProxy(opts = {}) {
354
364
  // Optional proxy authentication — pre-encode key buffer for performance
355
365
  const apiKey = process.env.DARIO_API_KEY;
356
366
  const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
357
- const corsOrigin = `http://localhost:${port}`;
367
+ // CORS origin defaults to the localhost URL the proxy is served at. Users
368
+ // binding to a non-loopback address (e.g. a Tailscale interface) can
369
+ // override via DARIO_CORS_ORIGIN — otherwise browser-based clients hitting
370
+ // dario over the mesh will be blocked by their browser's CORS check.
371
+ const corsOrigin = process.env.DARIO_CORS_ORIGIN || `http://localhost:${port}`;
358
372
  // Security headers for all responses
359
373
  const SECURITY_HEADERS = {
360
374
  'X-Content-Type-Options': 'nosniff',
@@ -445,6 +459,10 @@ export async function startProxy(opts = {}) {
445
459
  }
446
460
  // Proxy to Anthropic (with concurrency control)
447
461
  await semaphore.acquire();
462
+ // Hoisted so the finally block can clean up whatever was set.
463
+ let upstreamTimeout = null;
464
+ let onClientClose = null;
465
+ let upstreamAbortReason = null;
448
466
  try {
449
467
  const accessToken = await getAccessToken();
450
468
  // Read request body with size limit and timeout (prevents slow-loris)
@@ -548,11 +566,33 @@ export async function startProxy(opts = {}) {
548
566
  // CC sends 600 on first request per session. With rotation, every request is "first"
549
567
  'x-stainless-timeout': '600',
550
568
  };
569
+ // Client-disconnect abort: if the client drops the connection before
570
+ // we've finished sending the response, we abort the upstream fetch so
571
+ // Anthropic stops generating (and billing) a response nobody will
572
+ // read. Also carries the 5-minute upstream timeout via the same
573
+ // controller, so a single signal covers both cancellation reasons.
574
+ const upstreamAbort = new AbortController();
575
+ upstreamTimeout = setTimeout(() => {
576
+ if (!upstreamAbort.signal.aborted) {
577
+ upstreamAbortReason = 'timeout';
578
+ upstreamAbort.abort();
579
+ }
580
+ }, UPSTREAM_TIMEOUT_MS);
581
+ onClientClose = () => {
582
+ // 'close' fires on both normal teardown and client disconnect.
583
+ // We only want to abort if we haven't finished our response yet —
584
+ // normal teardown happens AFTER res.writableEnded becomes true.
585
+ if (!res.writableEnded && !upstreamAbort.signal.aborted) {
586
+ upstreamAbortReason = 'client_closed';
587
+ upstreamAbort.abort();
588
+ }
589
+ };
590
+ req.on('close', onClientClose);
551
591
  let upstream = await fetch(targetBase, {
552
592
  method: req.method ?? 'POST',
553
593
  headers,
554
594
  body: finalBody ? new Uint8Array(finalBody) : undefined,
555
- signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
595
+ signal: upstreamAbort.signal,
556
596
  });
557
597
  // Auto-retry without context-1m if it triggers a long-context billing error.
558
598
  // Anthropic returns this as either 400 ("long context beta is not yet available
@@ -576,7 +616,7 @@ export async function startProxy(opts = {}) {
576
616
  method: req.method ?? 'POST',
577
617
  headers: retryHeaders,
578
618
  body: finalBody ? new Uint8Array(finalBody) : undefined,
579
- signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
619
+ signal: upstreamAbort.signal,
580
620
  });
581
621
  // Use the retry response from here on — peeked body is now stale
582
622
  upstream = retry;
@@ -736,12 +776,42 @@ export async function startProxy(opts = {}) {
736
776
  }
737
777
  }
738
778
  catch (err) {
739
- // Log full error server-side, return generic message to client
740
- console.error('[dario] Proxy error:', sanitizeError(err));
741
- res.writeHead(502, JSON_HEADERS);
742
- res.end(JSON.stringify({ error: 'Proxy error', message: 'Failed to reach upstream API' }));
779
+ // Differentiate the three failure modes so each gets the right
780
+ // response (and so we don't spam logs when clients simply drop).
781
+ if (upstreamAbortReason === 'client_closed') {
782
+ if (verbose)
783
+ console.log(`[dario] #${requestCount} aborted (client disconnected)`);
784
+ }
785
+ else if (upstreamAbortReason === 'timeout') {
786
+ console.error(`[dario] #${requestCount} upstream timeout after ${UPSTREAM_TIMEOUT_MS / 1000}s`);
787
+ if (!res.headersSent) {
788
+ res.writeHead(504, JSON_HEADERS);
789
+ res.end(JSON.stringify({ error: 'Upstream timeout', message: `Anthropic did not respond within ${UPSTREAM_TIMEOUT_MS / 1000}s` }));
790
+ }
791
+ else if (!res.writableEnded) {
792
+ res.end();
793
+ }
794
+ }
795
+ else {
796
+ // Log full error server-side, return generic message to client
797
+ console.error('[dario] Proxy error:', sanitizeError(err));
798
+ if (!res.headersSent) {
799
+ res.writeHead(502, JSON_HEADERS);
800
+ res.end(JSON.stringify({ error: 'Proxy error', message: 'Failed to reach upstream API' }));
801
+ }
802
+ else if (!res.writableEnded) {
803
+ res.end();
804
+ }
805
+ }
743
806
  }
744
807
  finally {
808
+ // Always clean up the upstream-abort plumbing if it was set up. The
809
+ // setup happens after the body-read phase, so on fast-path errors
810
+ // (413, body read timeout) these may still be null — guard accordingly.
811
+ if (upstreamTimeout !== null)
812
+ clearTimeout(upstreamTimeout);
813
+ if (onClientClose !== null)
814
+ req.off('close', onClientClose);
745
815
  semaphore.release();
746
816
  }
747
817
  });
@@ -754,22 +824,39 @@ export async function startProxy(opts = {}) {
754
824
  }
755
825
  process.exit(1);
756
826
  });
757
- server.listen(port, LOCALHOST, () => {
827
+ server.listen(port, host, () => {
758
828
  const modeLine = passthrough
759
829
  ? 'Mode: passthrough (OAuth swap only, no injection)'
760
830
  : `OAuth: ${status.status} (expires in ${status.expiresIn})`;
761
831
  const modelLine = modelOverride ? `Model: ${modelOverride} (all requests)` : 'Model: passthrough (client decides)';
832
+ // Display URL uses `localhost` for loopback binds and the literal host
833
+ // for exposed binds, so the printed URL is the one a client would
834
+ // actually use to reach the proxy.
835
+ const displayHost = isLoopbackHost(host) ? 'localhost' : host;
762
836
  console.log('');
763
- console.log(` dario — http://localhost:${port}`);
837
+ console.log(` dario — http://${displayHost}:${port}`);
764
838
  console.log('');
765
839
  console.log(' Your Claude subscription is now an API.');
766
840
  console.log('');
767
841
  console.log(' Usage:');
768
- console.log(` ANTHROPIC_BASE_URL=http://localhost:${port}`);
842
+ console.log(` ANTHROPIC_BASE_URL=http://${displayHost}:${port}`);
769
843
  console.log(' ANTHROPIC_API_KEY=dario');
770
844
  console.log('');
771
845
  console.log(` ${modeLine}`);
772
846
  console.log(` ${modelLine}`);
847
+ if (!isLoopbackHost(host)) {
848
+ console.log('');
849
+ console.log(` ⚠ Bound to ${host} — reachable from other machines on the network.`);
850
+ if (!apiKey) {
851
+ console.log(' DARIO_API_KEY is not set. Any host that can reach this port can');
852
+ console.log(' proxy requests through your OAuth subscription. Set DARIO_API_KEY');
853
+ console.log(' before exposing dario beyond loopback.');
854
+ }
855
+ else {
856
+ console.log(' DARIO_API_KEY is set — clients must send x-api-key or Authorization');
857
+ console.log(' to be accepted.');
858
+ }
859
+ }
773
860
  console.log('');
774
861
  });
775
862
  // Session presence heartbeat — keeps the OAuth session marked active
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.4.1",
3
+ "version": "3.4.4",
4
4
  "description": "Use your Claude subscription as an API. No API key needed. Local proxy for Claude Max/Pro subscriptions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,9 @@
26
26
  "start": "node dist/cli.js",
27
27
  "dev": "tsx src/cli.ts",
28
28
  "e2e": "node test/e2e.mjs",
29
- "compat": "node test/compat.mjs"
29
+ "compat": "node test/compat.mjs",
30
+ "lint:pkg": "node scripts/check-package-json.mjs",
31
+ "fix:pkg": "node -e \"const fs=require('fs');fs.writeFileSync('package.json',JSON.stringify(JSON.parse(fs.readFileSync('package.json','utf-8')),null,2)+'\\n')\""
30
32
  },
31
33
  "keywords": [
32
34
  "claude",