@askalf/dario 3.37.10 → 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 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
- console.log(' Run `dario proxy` to start the API proxy.');
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
- console.log('[dario] No credentials found.');
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] Detect credentials + start proxy (or run OAuth)
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
- console.error(`[dario] Refresh failed: ${err instanceof Error ? err.message : err}. Will retry in 60s. Run \`dario login\` if this persists.`);
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 { authenticated: false, status: 'expired', expiresAt, canRefresh };
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: true,
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
@@ -792,14 +792,25 @@ export async function startProxy(opts = {}) {
792
792
  // Strip query parameters for endpoint matching
793
793
  const urlPath = req.url?.split('?')[0] ?? '';
794
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.
795
801
  if (urlPath === '/health' || urlPath === '/') {
796
802
  const s = await getStatus();
797
- res.writeHead(200, JSON_HEADERS);
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);
798
807
  res.end(JSON.stringify({
799
- status: 'ok',
808
+ status: dead ? 'degraded' : 'ok',
800
809
  oauth: s.status,
801
810
  expiresIn: s.expiresIn,
802
811
  requests: requestCount,
812
+ ...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
813
+ ...(s.lastRefreshError ? { lastRefreshError: s.lastRefreshError } : {}),
803
814
  }));
804
815
  return;
805
816
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.37.10",
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": {