@askalf/dario 3.37.9 → 3.37.11
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 +17 -0
- package/dist/cli.js +50 -6
- package/dist/oauth.d.ts +9 -1
- package/dist/oauth.js +70 -7
- package/dist/proxy.js +20 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -344,3 +344,20 @@ npm run e2e # live proxy + OAuth (requires a working Claude backend)
|
|
|
344
344
|
## License
|
|
345
345
|
|
|
346
346
|
MIT — see [LICENSE](LICENSE) and [DISCLAIMER.md](DISCLAIMER.md).
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Also by askalf
|
|
351
|
+
|
|
352
|
+
| Project | What it does |
|
|
353
|
+
|---------|-------------|
|
|
354
|
+
| [arnie](https://github.com/askalf/arnie) | Portable IT troubleshooting companion. Networking, AD, Windows Update, package managers, log triage, hardware checks. |
|
|
355
|
+
| [brio](https://github.com/askalf/brio) | Capability layer for AI workloads — semantic cache, cost tiering, policy. Sits in front of any Anthropic-compat endpoint. |
|
|
356
|
+
| [browser-bridge](https://github.com/askalf/browser-bridge) | Stealth headless Chromium in a container. CDP on 9222 — Playwright/Puppeteer/MCP-compatible. |
|
|
357
|
+
| [claude-bridge](https://github.com/askalf/claude-bridge) | Bridge Claude Code sessions to Discord. |
|
|
358
|
+
| [deepdive](https://github.com/askalf/deepdive) | Local research agent. Plan → search → fetch → extract → synthesize. Cited answers. |
|
|
359
|
+
| [git-providers](https://github.com/askalf/git-providers) | Unified GitHub + GitLab + Bitbucket Cloud REST clients behind one GitProvider interface. Plus a 44-entry api-key-provider taxonomy. |
|
|
360
|
+
| [hands](https://github.com/askalf/hands) | Cross-platform computer-use agent. Mouse, keyboard, screen. |
|
|
361
|
+
| [install-kit](https://github.com/askalf/install-kit) | curl-pipe-bash template for self-hosted Docker apps. |
|
|
362
|
+
| [pgflex](https://github.com/askalf/pgflex) | One Postgres API. Two modes (real PG ↔ PGlite WASM). |
|
|
363
|
+
| [redisflex](https://github.com/askalf/redisflex) | One Redis API. Two modes (ioredis ↔ in-process). |
|
package/dist/cli.js
CHANGED
|
@@ -38,9 +38,24 @@ async function login() {
|
|
|
38
38
|
console.log(' ───────────────────');
|
|
39
39
|
console.log('');
|
|
40
40
|
const manualFlag = args.includes('--manual') || args.includes('--headless');
|
|
41
|
+
// --force-reauth skips the existing-credentials short-circuit entirely.
|
|
42
|
+
// Use when the refresh token is dead and you need a clean OAuth re-auth
|
|
43
|
+
// without manually deleting credentials.json first.
|
|
44
|
+
const forceReauth = args.includes('--force-reauth') || args.includes('--force');
|
|
45
|
+
// --no-proxy keeps `dario login` to its name — it just does auth, doesn't
|
|
46
|
+
// try to start the proxy as a side effect. Useful in containerised deploys
|
|
47
|
+
// where the proxy is the container's CMD and is already running. Implicitly
|
|
48
|
+
// set by --manual since manual flow is for headless / scripted contexts
|
|
49
|
+
// where proxy lifecycle is managed externally.
|
|
50
|
+
const noProxy = args.includes('--no-proxy') || manualFlag;
|
|
41
51
|
// Check for existing credentials (Claude Code or dario's own)
|
|
42
|
-
const creds = await loadCredentials();
|
|
52
|
+
const creds = forceReauth ? null : await loadCredentials();
|
|
43
53
|
if (creds?.claudeAiOauth?.accessToken && creds.claudeAiOauth.expiresAt > Date.now()) {
|
|
54
|
+
if (noProxy) {
|
|
55
|
+
console.log(' Found valid credentials. (--no-proxy / --manual: not starting proxy.)');
|
|
56
|
+
console.log('');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
44
59
|
console.log(' Found credentials. Starting proxy...');
|
|
45
60
|
console.log('');
|
|
46
61
|
await proxy();
|
|
@@ -67,6 +82,10 @@ async function login() {
|
|
|
67
82
|
console.log('');
|
|
68
83
|
}
|
|
69
84
|
}
|
|
85
|
+
else if (forceReauth) {
|
|
86
|
+
console.log(' --force-reauth: skipping credential detection, starting fresh OAuth flow...');
|
|
87
|
+
console.log('');
|
|
88
|
+
}
|
|
70
89
|
else {
|
|
71
90
|
console.log(' No Claude Code credentials found. Starting OAuth flow...');
|
|
72
91
|
console.log('');
|
|
@@ -90,7 +109,12 @@ async function login() {
|
|
|
90
109
|
console.log(' Login successful!');
|
|
91
110
|
console.log(` Token expires in ${expiresIn} minutes (auto-refreshes).`);
|
|
92
111
|
console.log('');
|
|
93
|
-
|
|
112
|
+
if (noProxy) {
|
|
113
|
+
console.log(' (--no-proxy / --manual: credentials saved, proxy not started.)');
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
console.log(' Run `dario proxy` to start the API proxy.');
|
|
117
|
+
}
|
|
94
118
|
console.log('');
|
|
95
119
|
}
|
|
96
120
|
catch (err) {
|
|
@@ -150,8 +174,19 @@ async function logout() {
|
|
|
150
174
|
await unlink(path);
|
|
151
175
|
console.log('[dario] Credentials removed.');
|
|
152
176
|
}
|
|
153
|
-
catch {
|
|
154
|
-
|
|
177
|
+
catch (err) {
|
|
178
|
+
const code = err?.code;
|
|
179
|
+
if (code === 'ENOENT') {
|
|
180
|
+
console.log('[dario] No credentials found.');
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Permission denied, EISDIR, EBUSY, etc — surface the real error so the
|
|
184
|
+
// operator can fix it. Previous catch-all silently lied with "No
|
|
185
|
+
// credentials found" even when the file was clearly there but unreadable
|
|
186
|
+
// (e.g. ownership got mangled by a `docker run --user 0` recovery op).
|
|
187
|
+
console.error(`[dario] Could not remove ${path}: ${err.message}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
155
190
|
}
|
|
156
191
|
}
|
|
157
192
|
async function proxy() {
|
|
@@ -796,10 +831,19 @@ async function help() {
|
|
|
796
831
|
dario — Use your Claude subscription as an API.
|
|
797
832
|
|
|
798
833
|
Usage:
|
|
799
|
-
dario login [--manual]
|
|
834
|
+
dario login [--manual] [--no-proxy] [--force-reauth]
|
|
835
|
+
Detect credentials + start proxy (or run OAuth).
|
|
800
836
|
--manual (alias: --headless) for container / SSH
|
|
801
837
|
setups — prints an authorize URL and reads the
|
|
802
|
-
code you paste back instead of a local redirect
|
|
838
|
+
code you paste back instead of a local redirect.
|
|
839
|
+
--no-proxy stops after auth — do not start the
|
|
840
|
+
proxy (implied by --manual). Use this when the
|
|
841
|
+
proxy is already running in a separate process /
|
|
842
|
+
container so login doesn't collide on the port.
|
|
843
|
+
--force-reauth (alias: --force) ignores any
|
|
844
|
+
existing credentials and runs a fresh OAuth
|
|
845
|
+
flow — for when the refresh token is dead and
|
|
846
|
+
/health still reports access-token countdown.
|
|
803
847
|
dario proxy [options] Start the API proxy server
|
|
804
848
|
dario status Check authentication status
|
|
805
849
|
dario refresh Force token refresh
|
package/dist/oauth.d.ts
CHANGED
|
@@ -103,11 +103,19 @@ export declare function refreshTokens(): Promise<OAuthTokens>;
|
|
|
103
103
|
export declare function getAccessToken(): Promise<string>;
|
|
104
104
|
/**
|
|
105
105
|
* Get token status info.
|
|
106
|
+
*
|
|
107
|
+
* `status` returns 'broken' when refresh has failed REFRESH_BROKEN_THRESHOLD
|
|
108
|
+
* times in a row — this matters because the access token can still be ticking
|
|
109
|
+
* down (so naive "expiresIn" looks fine) while every actual upstream call
|
|
110
|
+
* returns 401. Operators relying on /health for a docker healthcheck or for
|
|
111
|
+
* `depends_on: service_healthy` need to see this state.
|
|
106
112
|
*/
|
|
107
113
|
export declare function getStatus(): Promise<{
|
|
108
114
|
authenticated: boolean;
|
|
109
|
-
status: 'healthy' | 'expiring' | 'expired' | 'none';
|
|
115
|
+
status: 'healthy' | 'expiring' | 'expired' | 'broken' | 'none';
|
|
110
116
|
expiresAt?: number;
|
|
111
117
|
expiresIn?: string;
|
|
112
118
|
canRefresh?: boolean;
|
|
119
|
+
refreshFailures?: number;
|
|
120
|
+
lastRefreshError?: string;
|
|
113
121
|
}>;
|
package/dist/oauth.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { randomBytes, createHash } from 'node:crypto';
|
|
8
8
|
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
-
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
9
|
+
import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
|
|
10
10
|
import { execFile } from 'node:child_process';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
12
|
import { homedir, platform } from 'node:os';
|
|
@@ -33,6 +33,12 @@ const REFRESH_BUFFER_MS = 30 * 60 * 1000;
|
|
|
33
33
|
// After a failed refresh, don't retry for 60s to avoid spam
|
|
34
34
|
let lastRefreshFailure = 0;
|
|
35
35
|
const REFRESH_COOLDOWN_MS = 60 * 1000;
|
|
36
|
+
// Track consecutive refresh failures so /health can surface a dead refresh
|
|
37
|
+
// token instead of cheerfully reporting `oauth: "expiring"` while every
|
|
38
|
+
// upstream call returns 401.
|
|
39
|
+
let consecutiveRefreshFailures = 0;
|
|
40
|
+
let lastRefreshError;
|
|
41
|
+
const REFRESH_BROKEN_THRESHOLD = 3;
|
|
36
42
|
// In-memory credential cache — avoids disk reads on every request
|
|
37
43
|
let credentialsCache = null;
|
|
38
44
|
let credentialsCacheTime = 0;
|
|
@@ -65,6 +71,34 @@ function getDarioCredentialsPath() {
|
|
|
65
71
|
function getClaudeCodeCredentialsPath() {
|
|
66
72
|
return join(homedir(), '.claude', '.credentials.json');
|
|
67
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Verify the credentials directory is writable BEFORE we open the OAuth URL.
|
|
76
|
+
*
|
|
77
|
+
* Why: an unwritable ~/.dario (e.g. EACCES from a `--user 0` docker op that
|
|
78
|
+
* left the volume owned by root) is a silent killer — the OAuth round-trip
|
|
79
|
+
* succeeds, the user pastes the code, and saveCredentials() crashes with
|
|
80
|
+
* EACCES on the .tmp file. The auth code is now consumed and unrecoverable;
|
|
81
|
+
* the user has to start over and re-paste a fresh code, only to hit the same
|
|
82
|
+
* EACCES. Probing first surfaces the permission problem cleanly while the
|
|
83
|
+
* user still holds an un-burned auth code.
|
|
84
|
+
*/
|
|
85
|
+
async function probeWritability() {
|
|
86
|
+
const dir = dirname(getDarioCredentialsPath());
|
|
87
|
+
await mkdir(dir, { recursive: true });
|
|
88
|
+
const probe = join(dir, `.write-probe.${process.pid}`);
|
|
89
|
+
try {
|
|
90
|
+
await writeFile(probe, '', { mode: 0o600 });
|
|
91
|
+
await unlink(probe);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
const code = err?.code;
|
|
95
|
+
throw new Error(`Credentials directory is not writable: ${dir} (${code || 'unknown'}). ` +
|
|
96
|
+
`Fix permissions before running 'dario login' so the OAuth code isn't ` +
|
|
97
|
+
`consumed by a flow that can't persist the result. ` +
|
|
98
|
+
`On Docker volumes left root-owned by a '--user 0' op, run: ` +
|
|
99
|
+
`chown -R dario:dario ${dir}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
68
102
|
/**
|
|
69
103
|
* Read Claude Code credentials from the OS keychain.
|
|
70
104
|
*
|
|
@@ -242,6 +276,8 @@ async function saveCredentials(creds) {
|
|
|
242
276
|
* Opens browser, captures the authorization code automatically.
|
|
243
277
|
*/
|
|
244
278
|
export async function startAutoOAuthFlow() {
|
|
279
|
+
// Fail fast on unwritable credentials dir BEFORE the auth code is issued.
|
|
280
|
+
await probeWritability();
|
|
245
281
|
const { createServer } = await import('node:http');
|
|
246
282
|
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
247
283
|
// 32 random bytes → 43-char base64url state. See dario#71 — Anthropic's
|
|
@@ -438,6 +474,8 @@ export function detectHeadlessEnvironment() {
|
|
|
438
474
|
* (it's CSRF protection for a redirect we don't have here).
|
|
439
475
|
*/
|
|
440
476
|
export async function startManualOAuthFlow() {
|
|
477
|
+
// Fail fast on unwritable credentials dir BEFORE the auth code is issued.
|
|
478
|
+
await probeWritability();
|
|
441
479
|
const { codeVerifier, codeChallenge } = generatePKCE();
|
|
442
480
|
// 32 bytes — same reason as startAutoOAuthFlow. See dario#71.
|
|
443
481
|
const state = base64url(randomBytes(32));
|
|
@@ -555,6 +593,8 @@ async function doRefreshTokens() {
|
|
|
555
593
|
scopes: oauth.scopes,
|
|
556
594
|
};
|
|
557
595
|
await saveCredentials({ claudeAiOauth: tokens });
|
|
596
|
+
consecutiveRefreshFailures = 0;
|
|
597
|
+
lastRefreshError = undefined;
|
|
558
598
|
return tokens;
|
|
559
599
|
}
|
|
560
600
|
throw new Error('Token refresh failed after 3 attempts');
|
|
@@ -584,13 +624,26 @@ export async function getAccessToken() {
|
|
|
584
624
|
}
|
|
585
625
|
catch (err) {
|
|
586
626
|
lastRefreshFailure = Date.now();
|
|
587
|
-
|
|
627
|
+
consecutiveRefreshFailures++;
|
|
628
|
+
// Redact tokens/JWTs/Bearer values and truncate before storing — this
|
|
629
|
+
// string surfaces on /status and /health (CodeQL js/stack-trace-exposure
|
|
630
|
+
// dario#17). The raw err.message can include URLs, partial response
|
|
631
|
+
// bodies, and stack-derived paths from fetch/JSON-parse errors.
|
|
632
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
633
|
+
lastRefreshError = redactSecrets(raw.slice(0, 200));
|
|
634
|
+
console.error(`[dario] Refresh failed (${consecutiveRefreshFailures} consecutive): ${lastRefreshError}. Will retry in 60s. Run \`dario login\` if this persists.`);
|
|
588
635
|
// Return current token — it might still work for a few more minutes
|
|
589
636
|
return oauth.accessToken;
|
|
590
637
|
}
|
|
591
638
|
}
|
|
592
639
|
/**
|
|
593
640
|
* Get token status info.
|
|
641
|
+
*
|
|
642
|
+
* `status` returns 'broken' when refresh has failed REFRESH_BROKEN_THRESHOLD
|
|
643
|
+
* times in a row — this matters because the access token can still be ticking
|
|
644
|
+
* down (so naive "expiresIn" looks fine) while every actual upstream call
|
|
645
|
+
* returns 401. Operators relying on /health for a docker healthcheck or for
|
|
646
|
+
* `depends_on: service_healthy` need to see this state.
|
|
594
647
|
*/
|
|
595
648
|
export async function getStatus() {
|
|
596
649
|
const creds = await loadCredentials();
|
|
@@ -599,19 +652,29 @@ export async function getStatus() {
|
|
|
599
652
|
}
|
|
600
653
|
const { expiresAt } = creds.claudeAiOauth;
|
|
601
654
|
const now = Date.now();
|
|
655
|
+
const broken = consecutiveRefreshFailures >= REFRESH_BROKEN_THRESHOLD;
|
|
602
656
|
if (expiresAt < now) {
|
|
603
|
-
// Expired but has refresh token — can be refreshed
|
|
604
|
-
const canRefresh = !!creds.claudeAiOauth.refreshToken;
|
|
605
|
-
return {
|
|
657
|
+
// Expired but has refresh token — can be refreshed (unless refresh itself is dead)
|
|
658
|
+
const canRefresh = !!creds.claudeAiOauth.refreshToken && !broken;
|
|
659
|
+
return {
|
|
660
|
+
authenticated: false,
|
|
661
|
+
status: broken ? 'broken' : 'expired',
|
|
662
|
+
expiresAt,
|
|
663
|
+
canRefresh,
|
|
664
|
+
refreshFailures: consecutiveRefreshFailures,
|
|
665
|
+
lastRefreshError,
|
|
666
|
+
};
|
|
606
667
|
}
|
|
607
668
|
const ms = expiresAt - now;
|
|
608
669
|
const hours = Math.floor(ms / 3600000);
|
|
609
670
|
const mins = Math.floor((ms % 3600000) / 60000);
|
|
610
671
|
const expiresIn = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
|
611
672
|
return {
|
|
612
|
-
authenticated:
|
|
613
|
-
status: ms < REFRESH_BUFFER_MS ? 'expiring' : 'healthy',
|
|
673
|
+
authenticated: !broken,
|
|
674
|
+
status: broken ? 'broken' : (ms < REFRESH_BUFFER_MS ? 'expiring' : 'healthy'),
|
|
614
675
|
expiresAt,
|
|
615
676
|
expiresIn,
|
|
677
|
+
refreshFailures: consecutiveRefreshFailures || undefined,
|
|
678
|
+
lastRefreshError,
|
|
616
679
|
};
|
|
617
680
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -125,8 +125,9 @@ function loadClaudeIdentity() {
|
|
|
125
125
|
}
|
|
126
126
|
// Model shortcuts — users can pass short names
|
|
127
127
|
const MODEL_ALIASES = {
|
|
128
|
-
'opus': 'claude-opus-4-
|
|
129
|
-
'
|
|
128
|
+
'opus': 'claude-opus-4-7',
|
|
129
|
+
'opus46': 'claude-opus-4-6',
|
|
130
|
+
'opus1m': 'claude-opus-4-7[1m]',
|
|
130
131
|
'sonnet': 'claude-sonnet-4-6',
|
|
131
132
|
'sonnet1m': 'claude-sonnet-4-6[1m]',
|
|
132
133
|
'haiku': 'claude-haiku-4-5',
|
|
@@ -358,7 +359,7 @@ function translateStreamChunk(line) {
|
|
|
358
359
|
catch { }
|
|
359
360
|
return null;
|
|
360
361
|
}
|
|
361
|
-
const OPENAI_MODELS_LIST = { object: 'list', data: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'].map(id => ({ id, object: 'model', created: 1700000000, owned_by: 'anthropic' })) };
|
|
362
|
+
const OPENAI_MODELS_LIST = { object: 'list', data: ['claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'].map(id => ({ id, object: 'model', created: 1700000000, owned_by: 'anthropic' })) };
|
|
362
363
|
/**
|
|
363
364
|
* Append a JSON-ND line to the proxy log file. No-op when stream is
|
|
364
365
|
* null (logFile not configured). Errors are swallowed — log writes
|
|
@@ -768,7 +769,9 @@ export async function startProxy(opts = {}) {
|
|
|
768
769
|
const CORS_HEADERS = {
|
|
769
770
|
'Access-Control-Allow-Origin': corsOrigin,
|
|
770
771
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
771
|
-
|
|
772
|
+
// *-wildcard covers custom headers in non-credentialed mode, except
|
|
773
|
+
// Authorization, which is a CORS non-wildcard request-header name.
|
|
774
|
+
'Access-Control-Allow-Headers': '*, Authorization',
|
|
772
775
|
'Access-Control-Max-Age': '86400',
|
|
773
776
|
...SECURITY_HEADERS,
|
|
774
777
|
};
|
|
@@ -789,14 +792,25 @@ export async function startProxy(opts = {}) {
|
|
|
789
792
|
// Strip query parameters for endpoint matching
|
|
790
793
|
const urlPath = req.url?.split('?')[0] ?? '';
|
|
791
794
|
// Health check
|
|
795
|
+
//
|
|
796
|
+
// Returns HTTP 503 when OAuth is in a state that will cause every upstream
|
|
797
|
+
// call to fail: refresh has failed N consecutive times ('broken'), or the
|
|
798
|
+
// access token is expired with no usable refresh path. Docker healthchecks
|
|
799
|
+
// and dependent services (`depends_on: service_healthy`) need this to
|
|
800
|
+
// react instead of cheerfully passing while every /v1/messages 401s.
|
|
792
801
|
if (urlPath === '/health' || urlPath === '/') {
|
|
793
802
|
const s = await getStatus();
|
|
794
|
-
|
|
803
|
+
const dead = s.status === 'broken' || s.status === 'none' ||
|
|
804
|
+
(s.status === 'expired' && s.canRefresh === false);
|
|
805
|
+
const httpStatus = dead ? 503 : 200;
|
|
806
|
+
res.writeHead(httpStatus, JSON_HEADERS);
|
|
795
807
|
res.end(JSON.stringify({
|
|
796
|
-
status: 'ok',
|
|
808
|
+
status: dead ? 'degraded' : 'ok',
|
|
797
809
|
oauth: s.status,
|
|
798
810
|
expiresIn: s.expiresIn,
|
|
799
811
|
requests: requestCount,
|
|
812
|
+
...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
|
|
813
|
+
...(s.lastRefreshError ? { lastRefreshError: s.lastRefreshError } : {}),
|
|
800
814
|
}));
|
|
801
815
|
return;
|
|
802
816
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.37.
|
|
3
|
+
"version": "3.37.11",
|
|
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": {
|