@askalf/dario 3.4.1 → 3.4.3

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.
@@ -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,18 @@ 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: 'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload',
35
48
  source: 'fallback',
36
49
  };
37
- const CACHE_PATH = join(homedir(), '.dario', 'cc-oauth-cache.json');
50
+ // -v2 suffix invalidates the v3.4.0-v3.4.2 cache that pinned the wrong
51
+ // (dev) client_id extracted from the dead-code -local-oauth block.
52
+ const CACHE_PATH = join(homedir(), '.dario', 'cc-oauth-cache-v2.json');
38
53
  function candidatePaths() {
39
54
  const home = homedir();
40
55
  if (platform() === 'win32') {
@@ -88,72 +103,47 @@ async function fingerprintBinary(path) {
88
103
  }
89
104
  }
90
105
  /**
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.
106
+ * Scan binary bytes for the PROD OAuth config block.
107
+ *
108
+ * Anchors on `BASE_API_URL:"https://api.anthropic.com"` this literal
109
+ * only appears inside the prod config object (`nh$`). The LOCAL-dev block
110
+ * uses `http://localhost:8000` for the same key, and there's no staging
111
+ * block present in shipped builds. Once we find the anchor, the CLIENT_ID,
112
+ * CLAUDE_AI_AUTHORIZE_URL, TOKEN_URL, and scopes all live within a ~1.5KB
113
+ * window after it.
95
114
  */
96
115
  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
- }
116
+ const anchor = Buffer.from('BASE_API_URL:"https://api.anthropic.com"');
117
+ const anchorIdx = buf.indexOf(anchor);
109
118
  if (anchorIdx === -1)
110
119
  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)
120
+ // The prod config object is laid out roughly as one line of minified JS.
121
+ // Take a generous window to be safe across minifier differences.
122
+ const windowStart = anchorIdx;
123
+ const windowEnd = Math.min(buf.length, anchorIdx + 2048);
124
+ const prodBlock = buf.slice(windowStart, windowEnd).toString('latin1');
125
+ const cidMatch = /CLIENT_ID\s*:\s*"([0-9a-f-]{36})"/i.exec(prodBlock);
126
+ if (!cidMatch || !cidMatch[1])
127
+ return null;
128
+ const clientId = cidMatch[1];
129
+ // Defensive: if we somehow matched the dev client_id, reject — the
130
+ // anchor should have put us in the prod block, but this guards against
131
+ // the block being laid out in an unexpected order across builds.
132
+ if (clientId === '22422756-60c9-4084-8eb7-27705fd5cf9a')
125
133
  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
134
  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;
135
+ const authMatch = /CLAUDE_AI_AUTHORIZE_URL\s*:\s*"([^"]+)"/.exec(prodBlock);
136
+ if (authMatch && authMatch[1])
137
+ authorizeUrl = authMatch[1];
140
138
  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 ');
139
+ const tokenMatch = /TOKEN_URL\s*:\s*"(https:\/\/[^"]*\/oauth\/token[^"]*)"/.exec(prodBlock);
140
+ if (tokenMatch && tokenMatch[1])
141
+ tokenUrl = tokenMatch[1];
142
+ // Scopes live in a separate array-of-strings block elsewhere in the
143
+ // binary, not inside the prod config object itself. Search globally
144
+ // for the first quoted `user:profile ...` run.
156
145
  let scopes = FALLBACK.scopes;
146
+ const scopeAnchor = Buffer.from('"user:profile ');
157
147
  const scopeIdx = buf.indexOf(scopeAnchor);
158
148
  if (scopeIdx !== -1) {
159
149
  const w = buf.slice(scopeIdx, Math.min(buf.length, scopeIdx + 512)).toString('latin1');
@@ -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',
@@ -754,22 +768,39 @@ export async function startProxy(opts = {}) {
754
768
  }
755
769
  process.exit(1);
756
770
  });
757
- server.listen(port, LOCALHOST, () => {
771
+ server.listen(port, host, () => {
758
772
  const modeLine = passthrough
759
773
  ? 'Mode: passthrough (OAuth swap only, no injection)'
760
774
  : `OAuth: ${status.status} (expires in ${status.expiresIn})`;
761
775
  const modelLine = modelOverride ? `Model: ${modelOverride} (all requests)` : 'Model: passthrough (client decides)';
776
+ // Display URL uses `localhost` for loopback binds and the literal host
777
+ // for exposed binds, so the printed URL is the one a client would
778
+ // actually use to reach the proxy.
779
+ const displayHost = isLoopbackHost(host) ? 'localhost' : host;
762
780
  console.log('');
763
- console.log(` dario — http://localhost:${port}`);
781
+ console.log(` dario — http://${displayHost}:${port}`);
764
782
  console.log('');
765
783
  console.log(' Your Claude subscription is now an API.');
766
784
  console.log('');
767
785
  console.log(' Usage:');
768
- console.log(` ANTHROPIC_BASE_URL=http://localhost:${port}`);
786
+ console.log(` ANTHROPIC_BASE_URL=http://${displayHost}:${port}`);
769
787
  console.log(' ANTHROPIC_API_KEY=dario');
770
788
  console.log('');
771
789
  console.log(` ${modeLine}`);
772
790
  console.log(` ${modelLine}`);
791
+ if (!isLoopbackHost(host)) {
792
+ console.log('');
793
+ console.log(` ⚠ Bound to ${host} — reachable from other machines on the network.`);
794
+ if (!apiKey) {
795
+ console.log(' DARIO_API_KEY is not set. Any host that can reach this port can');
796
+ console.log(' proxy requests through your OAuth subscription. Set DARIO_API_KEY');
797
+ console.log(' before exposing dario beyond loopback.');
798
+ }
799
+ else {
800
+ console.log(' DARIO_API_KEY is set — clients must send x-api-key or Authorization');
801
+ console.log(' to be accepted.');
802
+ }
803
+ }
773
804
  console.log('');
774
805
  });
775
806
  // 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.3",
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",