@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 +28 -5
- package/dist/oauth.d.ts +53 -0
- package/dist/oauth.js +154 -0
- package/package.json +2 -2
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: ${
|
|
94
|
-
|
|
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
|
|
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.
|
|
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",
|