@askalf/dario 3.3.0 → 3.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/README.md CHANGED
@@ -74,6 +74,8 @@ Opus, Sonnet, Haiku — all models, streaming, tool use. **Zero dependencies.**
74
74
  </tr>
75
75
  </table>
76
76
 
77
+ > **Need more than a proxy?** Dario solves the API access problem. If you need a full agent fleet — desktop control, browser automation, scheduling, custom tools, persistent memory — check out the [askalf platform](https://askalf.org). Same team, different execution model that solves the proxy ceiling entirely.
78
+
77
79
  ---
78
80
 
79
81
  ## Why dario
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CC OAuth Auto-Detection
3
+ *
4
+ * Scans the installed Claude Code binary to extract its OAuth configuration
5
+ * (client_id, authorize URL, token URL, scopes). Eliminates the need to
6
+ * hardcode values that Anthropic rotates between CC releases.
7
+ *
8
+ * CC ships two OAuth client configurations in one binary:
9
+ *
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
+ *
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.
17
+ *
18
+ * We scan for the LOCAL block and extract its config.
19
+ *
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.
22
+ */
23
+ export interface DetectedOAuthConfig {
24
+ clientId: string;
25
+ authorizeUrl: string;
26
+ tokenUrl: string;
27
+ scopes: string;
28
+ source: 'detected' | 'cached' | 'fallback';
29
+ ccPath?: string;
30
+ ccHash?: string;
31
+ }
32
+ /**
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.
37
+ */
38
+ export declare function scanBinaryForOAuthConfig(buf: Buffer): Omit<DetectedOAuthConfig, 'source' | 'ccPath' | 'ccHash'> | null;
39
+ /**
40
+ * Get the OAuth config for dario to use. Scans the installed CC binary
41
+ * on first call, caches to disk, and memoizes in-process for subsequent
42
+ * calls. If no binary is found or scanning fails, falls back to the
43
+ * known-good v2.1.104 values.
44
+ */
45
+ export declare function detectCCOAuthConfig(): Promise<DetectedOAuthConfig>;
46
+ /** Test-only: reset in-process memoization. */
47
+ export declare function _resetDetectorCache(): void;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * CC OAuth Auto-Detection
3
+ *
4
+ * Scans the installed Claude Code binary to extract its OAuth configuration
5
+ * (client_id, authorize URL, token URL, scopes). Eliminates the need to
6
+ * hardcode values that Anthropic rotates between CC releases.
7
+ *
8
+ * CC ships two OAuth client configurations in one binary:
9
+ *
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
+ *
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.
17
+ *
18
+ * We scan for the LOCAL block and extract its config.
19
+ *
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.
22
+ */
23
+ import { readFile, writeFile, mkdir, stat, open as openFile } from 'node:fs/promises';
24
+ import { existsSync } from 'node:fs';
25
+ import { homedir, platform } from 'node:os';
26
+ import { join, dirname } from 'node:path';
27
+ import { createHash } from 'node:crypto';
28
+ // 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.
30
+ const FALLBACK = {
31
+ clientId: '22422756-60c9-4084-8eb7-27705fd5cf9a',
32
+ authorizeUrl: 'https://claude.com/cai/oauth/authorize',
33
+ tokenUrl: 'https://platform.claude.com/v1/oauth/token',
34
+ scopes: 'user:profile user:inference user:sessions:claude_code user:mcp_servers',
35
+ source: 'fallback',
36
+ };
37
+ const CACHE_PATH = join(homedir(), '.dario', 'cc-oauth-cache.json');
38
+ function candidatePaths() {
39
+ const home = homedir();
40
+ if (platform() === 'win32') {
41
+ return [
42
+ join(home, '.local', 'bin', 'claude.exe'),
43
+ join(home, 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
44
+ join(home, 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.mjs'),
45
+ join(home, '.claude', 'local', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
46
+ join(home, '.claude', 'local', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.mjs'),
47
+ ];
48
+ }
49
+ return [
50
+ join(home, '.local', 'bin', 'claude'),
51
+ '/usr/local/bin/claude',
52
+ '/opt/homebrew/bin/claude',
53
+ '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js',
54
+ '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.mjs',
55
+ '/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js',
56
+ join(home, '.claude', 'local', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
57
+ join(home, '.claude', 'local', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.mjs'),
58
+ ];
59
+ }
60
+ function findCCBinary() {
61
+ const override = process.env['DARIO_CC_PATH'];
62
+ if (override && existsSync(override))
63
+ return override;
64
+ for (const p of candidatePaths()) {
65
+ if (existsSync(p))
66
+ return p;
67
+ }
68
+ return null;
69
+ }
70
+ /**
71
+ * Fast fingerprint of a binary for caching. We hash the first 64KB plus
72
+ * size+mtime — this discriminates CC versions without reading GBs off disk.
73
+ */
74
+ async function fingerprintBinary(path) {
75
+ const st = await stat(path);
76
+ const fh = await openFile(path, 'r');
77
+ try {
78
+ const buf = Buffer.alloc(Math.min(65536, st.size));
79
+ await fh.read(buf, 0, buf.length, 0);
80
+ const h = createHash('sha256');
81
+ h.update(buf);
82
+ h.update(String(st.size));
83
+ h.update(String(st.mtimeMs));
84
+ return h.digest('hex').slice(0, 16);
85
+ }
86
+ finally {
87
+ await fh.close();
88
+ }
89
+ }
90
+ /**
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.
95
+ */
96
+ 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
+ }
109
+ if (anchorIdx === -1)
110
+ 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)
125
+ 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
+ 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;
140
+ 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 };
165
+ }
166
+ async function loadCache() {
167
+ try {
168
+ const raw = await readFile(CACHE_PATH, 'utf-8');
169
+ const parsed = JSON.parse(raw);
170
+ if (parsed?.hash && parsed?.config?.clientId) {
171
+ return { hash: parsed.hash, config: parsed.config };
172
+ }
173
+ }
174
+ catch { /* no cache */ }
175
+ return null;
176
+ }
177
+ async function saveCache(hash, config) {
178
+ try {
179
+ await mkdir(dirname(CACHE_PATH), { recursive: true });
180
+ await writeFile(CACHE_PATH, JSON.stringify({ hash, config, savedAt: Date.now() }, null, 2));
181
+ }
182
+ catch { /* ignore cache write errors */ }
183
+ }
184
+ let memoized = null;
185
+ /**
186
+ * Get the OAuth config for dario to use. Scans the installed CC binary
187
+ * on first call, caches to disk, and memoizes in-process for subsequent
188
+ * calls. If no binary is found or scanning fails, falls back to the
189
+ * known-good v2.1.104 values.
190
+ */
191
+ export async function detectCCOAuthConfig() {
192
+ if (memoized)
193
+ return memoized;
194
+ try {
195
+ const ccPath = findCCBinary();
196
+ if (!ccPath) {
197
+ memoized = FALLBACK;
198
+ return memoized;
199
+ }
200
+ const hash = await fingerprintBinary(ccPath);
201
+ // Check cache
202
+ const cached = await loadCache();
203
+ if (cached && cached.hash === hash) {
204
+ memoized = { ...cached.config, source: 'cached', ccPath, ccHash: hash };
205
+ return memoized;
206
+ }
207
+ // Read binary and scan
208
+ const buf = await readFile(ccPath);
209
+ const scanned = scanBinaryForOAuthConfig(buf);
210
+ if (!scanned) {
211
+ memoized = { ...FALLBACK, ccPath, ccHash: hash };
212
+ return memoized;
213
+ }
214
+ const detected = {
215
+ ...scanned,
216
+ source: 'detected',
217
+ ccPath,
218
+ ccHash: hash,
219
+ };
220
+ await saveCache(hash, detected);
221
+ memoized = detected;
222
+ return memoized;
223
+ }
224
+ catch {
225
+ memoized = FALLBACK;
226
+ return memoized;
227
+ }
228
+ }
229
+ /** Test-only: reset in-process memoization. */
230
+ export function _resetDetectorCache() {
231
+ memoized = null;
232
+ }
package/dist/oauth.js CHANGED
@@ -8,13 +8,17 @@ import { randomBytes, createHash } from 'node:crypto';
8
8
  import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
10
10
  import { homedir } from 'node:os';
11
- // Claude Code's public OAuth client (PKCE, no secret needed) — extracted from CC v2.1.104 binary
12
- const OAUTH_CLIENT_ID = '22422756-60c9-4084-8eb7-27705fd5cf9a';
13
- // Max Plan OAuth (for Claude Pro/Max subscriptions) claude.com/cai/oauth/authorize
14
- const OAUTH_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
15
- const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
16
- // Max plan scopes (excludes org:create_api_key which requires Console plan)
17
- const OAUTH_SCOPES = 'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
11
+ import { detectCCOAuthConfig } from './cc-oauth-detect.js';
12
+ // OAuth config is auto-detected at runtime from the installed Claude Code
13
+ // binary. This eliminates the "Anthropic rotated the client_id again" class
14
+ // of bugs — dario stays in sync with whatever CC version the user has
15
+ // installed, forever. See cc-oauth-detect.ts for the scanner.
16
+ //
17
+ // Hardcoded fallbacks live in cc-oauth-detect.ts and are the known-good
18
+ // CC v2.1.104 local-oauth flow values.
19
+ async function getOAuthConfig() {
20
+ return detectCCOAuthConfig();
21
+ }
18
22
  // Refresh 30 min before expiry
19
23
  const REFRESH_BUFFER_MS = 30 * 60 * 1000;
20
24
  // After a failed refresh, don't retry for 60s to avoid spam
@@ -116,17 +120,18 @@ export async function startAutoOAuthFlow() {
116
120
  server.listen(0, 'localhost', async () => {
117
121
  const addr = server.address();
118
122
  port = typeof addr === 'object' && addr ? addr.port : 0;
123
+ const cfg = await getOAuthConfig();
119
124
  const params = new URLSearchParams({
120
125
  code: 'true',
121
- client_id: OAUTH_CLIENT_ID,
126
+ client_id: cfg.clientId,
122
127
  response_type: 'code',
123
128
  redirect_uri: `http://localhost:${port}/callback`,
124
- scope: OAUTH_SCOPES,
129
+ scope: cfg.scopes,
125
130
  code_challenge: codeChallenge,
126
131
  code_challenge_method: 'S256',
127
132
  state,
128
133
  });
129
- const authUrl = `${OAUTH_AUTHORIZE_URL}?${params.toString()}`;
134
+ const authUrl = `${cfg.authorizeUrl}?${params.toString()}`;
130
135
  // Open browser
131
136
  console.log(' Opening browser to sign in...');
132
137
  console.log(` If the browser didn't open, visit: ${authUrl}`);
@@ -152,12 +157,13 @@ export async function startAutoOAuthFlow() {
152
157
  * Exchange code using the localhost redirect URI.
153
158
  */
154
159
  async function exchangeCodeWithRedirect(code, codeVerifier, state, port) {
155
- const res = await fetch(OAUTH_TOKEN_URL, {
160
+ const cfg = await getOAuthConfig();
161
+ const res = await fetch(cfg.tokenUrl, {
156
162
  method: 'POST',
157
163
  headers: { 'Content-Type': 'application/json' },
158
164
  body: JSON.stringify({
159
165
  grant_type: 'authorization_code',
160
- client_id: OAUTH_CLIENT_ID,
166
+ client_id: cfg.clientId,
161
167
  code,
162
168
  redirect_uri: `http://localhost:${port}/callback`,
163
169
  code_verifier: codeVerifier,
@@ -201,16 +207,17 @@ async function doRefreshTokens() {
201
207
  throw new Error('No refresh token available. Run `dario login` first.');
202
208
  }
203
209
  const oauth = creds.claudeAiOauth;
210
+ const cfg = await getOAuthConfig();
204
211
  for (let attempt = 0; attempt < 3; attempt++) {
205
212
  if (attempt > 0)
206
213
  await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
207
- const res = await fetch(OAUTH_TOKEN_URL, {
214
+ const res = await fetch(cfg.tokenUrl, {
208
215
  method: 'POST',
209
216
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
210
217
  body: new URLSearchParams({
211
218
  grant_type: 'refresh_token',
212
219
  refresh_token: oauth.refreshToken,
213
- client_id: OAUTH_CLIENT_ID,
220
+ client_id: cfg.clientId,
214
221
  }),
215
222
  signal: AbortSignal.timeout(15000),
216
223
  });
package/dist/proxy.js CHANGED
@@ -767,12 +767,22 @@ export async function startProxy(opts = {}) {
767
767
  body: finalBody ? new Uint8Array(finalBody) : undefined,
768
768
  signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
769
769
  });
770
- // Auto-retry without context-1m if it triggers a long-context billing error
771
- if (upstream.status === 429 && !passthrough) {
772
- const peekBody = await upstream.text().catch(() => '');
773
- if (peekBody.includes('long context') || peekBody.includes('Extra usage is required')) {
770
+ // Auto-retry without context-1m if it triggers a long-context billing error.
771
+ // Anthropic returns this as either 400 ("long context beta is not yet available
772
+ // for this subscription") or 429 ("Extra usage is required for long context
773
+ // requests") depending on the endpoint we handle both.
774
+ //
775
+ // Note: `upstream.text()` consumes the body, so once we peek we MUST
776
+ // handle the response here (can't fall through to the normal forwarder).
777
+ let peekedBody = null;
778
+ if ((upstream.status === 400 || upstream.status === 429) && !passthrough) {
779
+ peekedBody = await upstream.text().catch(() => '');
780
+ const isLongContextError = peekedBody.includes('long context')
781
+ || peekedBody.includes('Extra usage is required')
782
+ || peekedBody.includes('long_context');
783
+ if (isLongContextError) {
774
784
  if (verbose)
775
- console.log(`[dario] #${requestCount} context-1m rejected — retrying without it`);
785
+ console.log(`[dario] #${requestCount} context-1m rejected (${upstream.status}) — retrying without it`);
776
786
  const reducedBeta = beta.replace(',context-1m-2025-08-07', '').replace('context-1m-2025-08-07,', '');
777
787
  const retryHeaders = { ...headers, 'anthropic-beta': reducedBeta };
778
788
  const retry = await fetch(targetBase, {
@@ -781,13 +791,13 @@ export async function startProxy(opts = {}) {
781
791
  body: finalBody ? new Uint8Array(finalBody) : undefined,
782
792
  signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
783
793
  });
784
- // Use the retry response from here on
794
+ // Use the retry response from here on — peeked body is now stale
785
795
  upstream = retry;
796
+ peekedBody = null;
786
797
  }
787
- else {
788
- // Not a context-1m issue — handle as normal 429 below
789
- // Re-wrap the already-consumed body for downstream handling
790
- const enriched = enrich429(peekBody, upstream.headers);
798
+ else if (upstream.status === 429) {
799
+ // Not a context-1m issue — return enriched 429 directly
800
+ const enriched = enrich429(peekedBody, upstream.headers);
791
801
  if (!(cliAvailable && !useCli)) {
792
802
  const responseHeaders = {
793
803
  'Content-Type': 'application/json',
@@ -804,7 +814,25 @@ export async function startProxy(opts = {}) {
804
814
  res.end(enriched);
805
815
  return;
806
816
  }
807
- // Fall through to CLI fallback below
817
+ // Fall through to CLI fallback below — need to re-handle 429 with
818
+ // already-consumed body; stash it for the fallback path.
819
+ }
820
+ else if (upstream.status === 400) {
821
+ // Non-long-context 400 — forward upstream error directly.
822
+ // The body is already consumed, so we write it straight out.
823
+ const responseHeaders = {
824
+ 'Content-Type': upstream.headers.get('content-type') ?? 'application/json',
825
+ 'Access-Control-Allow-Origin': corsOrigin,
826
+ ...SECURITY_HEADERS,
827
+ };
828
+ for (const [key, value] of upstream.headers.entries()) {
829
+ if (key === 'request-id')
830
+ responseHeaders[key] = value;
831
+ }
832
+ requestCount++;
833
+ res.writeHead(400, responseHeaders);
834
+ res.end(peekedBody);
835
+ return;
808
836
  }
809
837
  }
810
838
  // Enrich 429 errors with rate limit details from headers (Anthropic only returns "Error")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
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": {