@askalf/dario 3.19.5 → 3.20.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/dist/cli.js CHANGED
@@ -35,7 +35,7 @@ if (!('Bun' in globalThis) && !process.env.DARIO_NO_BUN) {
35
35
  import { unlink } from 'node:fs/promises';
36
36
  import { join } from 'node:path';
37
37
  import { homedir } from 'node:os';
38
- import { startAutoOAuthFlow, getStatus, refreshTokens, loadCredentials } from './oauth.js';
38
+ import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials } from './oauth.js';
39
39
  import { startProxy, sanitizeError } from './proxy.js';
40
40
  import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, removeAccount } from './accounts.js';
41
41
  import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
@@ -46,6 +46,7 @@ async function login() {
46
46
  console.log(' dario — Claude Login');
47
47
  console.log(' ───────────────────');
48
48
  console.log('');
49
+ const manualFlag = args.includes('--manual') || args.includes('--headless');
49
50
  // Check for existing credentials (Claude Code or dario's own)
50
51
  const creds = await loadCredentials();
51
52
  if (creds?.claudeAiOauth?.accessToken && creds.claudeAiOauth.expiresAt > Date.now()) {
@@ -79,8 +80,21 @@ async function login() {
79
80
  console.log(' No Claude Code credentials found. Starting OAuth flow...');
80
81
  console.log('');
81
82
  }
83
+ // If the user didn't explicitly pick `--manual`, surface a hint when
84
+ // heuristics suggest the local-callback flow won't work (SSH session,
85
+ // container). We don't auto-flip — false positives would be more
86
+ // annoying than false negatives — but the hint keeps users from
87
+ // waiting for a browser redirect that can't land.
88
+ if (!manualFlag) {
89
+ const reason = detectHeadlessEnvironment();
90
+ if (reason) {
91
+ console.log(` Note: ${reason}. If the browser redirect doesn't land,`);
92
+ console.log(' re-run with: dario login --manual');
93
+ console.log('');
94
+ }
95
+ }
82
96
  try {
83
- const tokens = await startAutoOAuthFlow();
97
+ const tokens = manualFlag ? await startManualOAuthFlow() : await startAutoOAuthFlow();
84
98
  const expiresIn = Math.round((tokens.expiresAt - Date.now()) / 60000);
85
99
  console.log(' Login successful!');
86
100
  console.log(` Token expires in ${expiresIn} minutes (auto-refreshes).`);
@@ -89,9 +103,15 @@ async function login() {
89
103
  console.log('');
90
104
  }
91
105
  catch (err) {
106
+ const msg = sanitizeError(err);
92
107
  console.error('');
93
- console.error(` Login failed: ${sanitizeError(err)}`);
94
- console.error(' Try again with `dario login`.');
108
+ console.error(` Login failed: ${msg}`);
109
+ if (!manualFlag && /callback server|EADDRINUSE|bind|timed out/i.test(msg)) {
110
+ console.error(' Hint: try `dario login --manual` for headless / container setups.');
111
+ }
112
+ else {
113
+ console.error(' Try again with `dario login`.');
114
+ }
95
115
  process.exit(1);
96
116
  }
97
117
  }
@@ -384,7 +404,10 @@ async function help() {
384
404
  dario — Use your Claude subscription as an API.
385
405
 
386
406
  Usage:
387
- dario login Detect credentials + start proxy (or run OAuth)
407
+ dario login [--manual] Detect credentials + start proxy (or run OAuth)
408
+ --manual (alias: --headless) for container / SSH
409
+ setups — prints an authorize URL and reads the
410
+ code you paste back instead of a local redirect
388
411
  dario proxy [options] Start the API proxy server
389
412
  dario status Check authentication status
390
413
  dario refresh Force token refresh
package/dist/oauth.d.ts CHANGED
@@ -19,6 +19,59 @@ export declare function loadCredentials(): Promise<CredentialsFile | null>;
19
19
  * Opens browser, captures the authorization code automatically.
20
20
  */
21
21
  export declare function startAutoOAuthFlow(): Promise<OAuthTokens>;
22
+ /**
23
+ * Build the authorize URL used by the manual / headless flow. Exported
24
+ * so tests can assert the shape (code=true, MANUAL_REDIRECT_URI, PKCE)
25
+ * without exercising the full interactive flow.
26
+ */
27
+ export declare function buildManualAuthorizeUrl(cfg: {
28
+ clientId: string;
29
+ authorizeUrl: string;
30
+ scopes: string;
31
+ }, codeChallenge: string, state: string): string;
32
+ /**
33
+ * Parse whatever the user pastes back from Anthropic's success page.
34
+ *
35
+ * The success page renders the authorization code and state joined with
36
+ * a `#` (the fragment-identifier convention CC itself uses for its
37
+ * `claude setup-token` flow), so the happy-path paste is `code#state`.
38
+ * Some browsers / copy UIs strip the fragment, so we also accept a bare
39
+ * code. When state is present, callers should verify it matches the
40
+ * state they generated; when absent, callers can prompt separately or
41
+ * accept the trade-off (code + PKCE + client_id are still verified on
42
+ * the token exchange).
43
+ */
44
+ export declare function parseManualPaste(input: string): {
45
+ code: string;
46
+ state: string | null;
47
+ };
48
+ /**
49
+ * Heuristic for "dario is probably running somewhere the local-callback
50
+ * OAuth flow won't work because the browser is on a different host."
51
+ * Returns a short reason string when the heuristic fires, null otherwise.
52
+ *
53
+ * Callers use this to *offer* `--manual` to the user, never to force it —
54
+ * false positives are more annoying than false negatives (the user can
55
+ * always opt in explicitly).
56
+ */
57
+ export declare function detectHeadlessEnvironment(): string | null;
58
+ /**
59
+ * Manual / headless OAuth flow (dario #43).
60
+ *
61
+ * Mirrors Claude Code's own `claude setup-token` flow: asks Anthropic to
62
+ * display the authorization code as text instead of redirecting to a
63
+ * local callback server, then reads the code the user copies back.
64
+ * Works for container installs (browser on host, dario in container),
65
+ * SSH installs (no browser on the remote box), and any other setup
66
+ * where a localhost redirect can't reach the dario process.
67
+ *
68
+ * Security posture is unchanged from the auto flow: PKCE + client_id +
69
+ * single-use code + server-side code expiry. State parameter is
70
+ * verified when the pasted input includes it; bare-code pastes still
71
+ * exchange because state isn't load-bearing for the token endpoint
72
+ * (it's CSRF protection for a redirect we don't have here).
73
+ */
74
+ export declare function startManualOAuthFlow(): Promise<OAuthTokens>;
22
75
  /**
23
76
  * Refresh the access token using the refresh token.
24
77
  * Retries with exponential backoff on transient failures.
package/dist/oauth.js CHANGED
@@ -5,11 +5,18 @@
5
5
  * Handles authorization, token exchange, storage, and auto-refresh.
6
6
  */
7
7
  import { randomBytes, createHash } from 'node:crypto';
8
+ import { existsSync, readFileSync } from 'node:fs';
8
9
  import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
9
10
  import { execFile } from 'node:child_process';
10
11
  import { dirname, join } from 'node:path';
11
12
  import { homedir, platform } from 'node:os';
12
13
  import { detectCCOAuthConfig } from './cc-oauth-detect.js';
14
+ // Manual-flow redirect URI. Anthropic's authorize endpoint special-cases
15
+ // this value (also baked into CC as MANUAL_REDIRECT_URL) to render the
16
+ // authorization code + state on a copy-paste success page instead of
17
+ // redirecting back to a localhost callback. Used by startManualOAuthFlow
18
+ // for container / headless / SSH installs where a local bind won't work.
19
+ const MANUAL_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
13
20
  // OAuth config is auto-detected at runtime from the installed Claude Code
14
21
  // binary. This eliminates the "Anthropic rotated the client_id again" class
15
22
  // of bugs — dario stays in sync with whatever CC version the user has
@@ -293,6 +300,153 @@ async function exchangeCodeWithRedirect(code, codeVerifier, state, port) {
293
300
  await saveCredentials({ claudeAiOauth: tokens });
294
301
  return tokens;
295
302
  }
303
+ /**
304
+ * Build the authorize URL used by the manual / headless flow. Exported
305
+ * so tests can assert the shape (code=true, MANUAL_REDIRECT_URI, PKCE)
306
+ * without exercising the full interactive flow.
307
+ */
308
+ export function buildManualAuthorizeUrl(cfg, codeChallenge, state) {
309
+ const params = new URLSearchParams({
310
+ code: 'true',
311
+ client_id: cfg.clientId,
312
+ response_type: 'code',
313
+ redirect_uri: MANUAL_REDIRECT_URI,
314
+ scope: cfg.scopes,
315
+ code_challenge: codeChallenge,
316
+ code_challenge_method: 'S256',
317
+ state,
318
+ });
319
+ return `${cfg.authorizeUrl}?${params.toString()}`;
320
+ }
321
+ /**
322
+ * Parse whatever the user pastes back from Anthropic's success page.
323
+ *
324
+ * The success page renders the authorization code and state joined with
325
+ * a `#` (the fragment-identifier convention CC itself uses for its
326
+ * `claude setup-token` flow), so the happy-path paste is `code#state`.
327
+ * Some browsers / copy UIs strip the fragment, so we also accept a bare
328
+ * code. When state is present, callers should verify it matches the
329
+ * state they generated; when absent, callers can prompt separately or
330
+ * accept the trade-off (code + PKCE + client_id are still verified on
331
+ * the token exchange).
332
+ */
333
+ export function parseManualPaste(input) {
334
+ const trimmed = input.trim();
335
+ if (!trimmed)
336
+ return { code: '', state: null };
337
+ const hashIdx = trimmed.indexOf('#');
338
+ if (hashIdx === -1)
339
+ return { code: trimmed, state: null };
340
+ return {
341
+ code: trimmed.slice(0, hashIdx).trim(),
342
+ state: trimmed.slice(hashIdx + 1).trim(),
343
+ };
344
+ }
345
+ /**
346
+ * Heuristic for "dario is probably running somewhere the local-callback
347
+ * OAuth flow won't work because the browser is on a different host."
348
+ * Returns a short reason string when the heuristic fires, null otherwise.
349
+ *
350
+ * Callers use this to *offer* `--manual` to the user, never to force it —
351
+ * false positives are more annoying than false negatives (the user can
352
+ * always opt in explicitly).
353
+ */
354
+ export function detectHeadlessEnvironment() {
355
+ if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
356
+ return 'SSH session detected';
357
+ }
358
+ try {
359
+ if (existsSync('/.dockerenv')) {
360
+ return 'container detected (/.dockerenv)';
361
+ }
362
+ if (existsSync('/proc/1/cgroup')) {
363
+ const cg = readFileSync('/proc/1/cgroup', 'utf-8');
364
+ if (/\b(docker|containerd|lxc|kubepods)\b/.test(cg)) {
365
+ return 'container detected (cgroup)';
366
+ }
367
+ }
368
+ }
369
+ catch { /* best-effort — /proc is Linux-only, absence is fine */ }
370
+ return null;
371
+ }
372
+ /**
373
+ * Manual / headless OAuth flow (dario #43).
374
+ *
375
+ * Mirrors Claude Code's own `claude setup-token` flow: asks Anthropic to
376
+ * display the authorization code as text instead of redirecting to a
377
+ * local callback server, then reads the code the user copies back.
378
+ * Works for container installs (browser on host, dario in container),
379
+ * SSH installs (no browser on the remote box), and any other setup
380
+ * where a localhost redirect can't reach the dario process.
381
+ *
382
+ * Security posture is unchanged from the auto flow: PKCE + client_id +
383
+ * single-use code + server-side code expiry. State parameter is
384
+ * verified when the pasted input includes it; bare-code pastes still
385
+ * exchange because state isn't load-bearing for the token endpoint
386
+ * (it's CSRF protection for a redirect we don't have here).
387
+ */
388
+ export async function startManualOAuthFlow() {
389
+ const { codeVerifier, codeChallenge } = generatePKCE();
390
+ const state = base64url(randomBytes(16));
391
+ const cfg = await getOAuthConfig();
392
+ const authUrl = buildManualAuthorizeUrl(cfg, codeChallenge, state);
393
+ console.log('');
394
+ console.log(' Open this URL in any browser (on any machine):');
395
+ console.log('');
396
+ console.log(` ${authUrl}`);
397
+ console.log('');
398
+ console.log(' After you approve, Anthropic will display an authorization code.');
399
+ console.log(' Paste it below (format: "code#state" or just the code).');
400
+ console.log('');
401
+ const pasted = await readLineFromStdin(' Code: ');
402
+ const { code, state: returnedState } = parseManualPaste(pasted);
403
+ if (!code) {
404
+ throw new Error('No authorization code entered. Re-run `dario login --manual`.');
405
+ }
406
+ if (returnedState && returnedState !== state) {
407
+ throw new Error('State mismatch — the pasted code is from a different login attempt. Re-run `dario login --manual` and paste the most recent code.');
408
+ }
409
+ return exchangeCodeManual(code, codeVerifier, state);
410
+ }
411
+ async function exchangeCodeManual(code, codeVerifier, state) {
412
+ const cfg = await getOAuthConfig();
413
+ const res = await fetch(cfg.tokenUrl, {
414
+ method: 'POST',
415
+ headers: { 'Content-Type': 'application/json' },
416
+ body: JSON.stringify({
417
+ grant_type: 'authorization_code',
418
+ client_id: cfg.clientId,
419
+ code,
420
+ redirect_uri: MANUAL_REDIRECT_URI,
421
+ code_verifier: codeVerifier,
422
+ state,
423
+ }),
424
+ signal: AbortSignal.timeout(30000),
425
+ });
426
+ if (!res.ok) {
427
+ const body = await res.text().catch(() => '');
428
+ throw new Error(`Token exchange failed (HTTP ${res.status}): ${body.slice(0, 200)}`);
429
+ }
430
+ const data = await res.json();
431
+ const tokens = {
432
+ accessToken: data.access_token,
433
+ refreshToken: data.refresh_token,
434
+ expiresAt: Date.now() + data.expires_in * 1000,
435
+ scopes: data.scope?.split(' ') || ['user:inference'],
436
+ };
437
+ await saveCredentials({ claudeAiOauth: tokens });
438
+ return tokens;
439
+ }
440
+ async function readLineFromStdin(prompt) {
441
+ const { createInterface } = await import('node:readline/promises');
442
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
443
+ try {
444
+ return (await rl.question(prompt)).trim();
445
+ }
446
+ finally {
447
+ rl.close();
448
+ }
449
+ }
296
450
  /**
297
451
  * Refresh the access token using the refresh token.
298
452
  * Retries with exponential backoff on transient failures.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.19.5",
3
+ "version": "3.20.0",
4
4
  "description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsc && cp src/cc-template-data.json dist/ && node -e \"require('fs').mkdirSync('dist/shim',{recursive:true})\" && cp src/shim/runtime.cjs dist/shim/",
24
- "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs",
24
+ "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs",
25
25
  "audit": "npm audit --production --audit-level=high",
26
26
  "prepublishOnly": "npm run build",
27
27
  "start": "node dist/cli.js",