@askalf/dario 3.31.5 → 3.31.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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Live probe of Anthropic's /oauth/authorize endpoint.
3
+ *
4
+ * Lifted from scripts/_authorize-probe-classifier.mjs and
5
+ * scripts/check-cc-authorize-probe.mjs so `dario doctor --probe` can
6
+ * reuse the same logic without re-importing from scripts. The .mjs
7
+ * files re-export from this module for the CI drift watcher.
8
+ *
9
+ * Motivating pattern: Anthropic's policy engine flips scope
10
+ * acceptability without changing what CC ships in its binary, so the
11
+ * binary-scan drift watcher can't see the flip. The live probe fills
12
+ * that gap — but from GitHub Actions IPs it hits Cloudflare's bot
13
+ * challenge and comes back "inconclusive" most of the time. Running
14
+ * the same probe from a user's own machine (via `dario doctor
15
+ * --probe`) is the workaround: CF doesn't challenge residential IPs
16
+ * the same way. See dario #42 / #71 for the incidents this prevents.
17
+ */
18
+ /**
19
+ * The specific string Anthropic's authorize endpoint returns for a
20
+ * scope set its policy engine rejects. Observed across multiple
21
+ * incidents (dario #22, #42, #71) — stable across the policy flips.
22
+ */
23
+ export declare const REJECT_MARKER = "Invalid request format";
24
+ /**
25
+ * The minimal response shape the classifier needs. `fetch` results can
26
+ * be mapped to this in one call (see `probeAuthorize` below).
27
+ */
28
+ export interface AuthorizeResponse {
29
+ status: number;
30
+ location: string | null;
31
+ body: string;
32
+ error: string | null;
33
+ }
34
+ export type ProbeVerdict = 'accepted' | 'rejected' | 'inconclusive';
35
+ export interface ClassifiedVerdict {
36
+ verdict: ProbeVerdict;
37
+ reason: string;
38
+ }
39
+ /**
40
+ * Classify a single authorize-endpoint response.
41
+ *
42
+ * accepted — 3xx redirect to login/consent, OR 2xx without the
43
+ * reject marker. The scope set passed validation.
44
+ * rejected — body contains the specific "Invalid request format"
45
+ * marker. The scope set was refused.
46
+ * inconclusive — fetch error, Cloudflare challenge, or any other
47
+ * response shape we can't categorize. Not drift; try
48
+ * again.
49
+ */
50
+ export declare function classifyAuthorizeResponse({ status, location, body, error }: AuthorizeResponse): ClassifiedVerdict;
51
+ export interface DriftItem {
52
+ probe: 'A' | 'B';
53
+ severity: 'high' | 'medium' | 'info';
54
+ message: string;
55
+ }
56
+ export interface CombinedVerdict {
57
+ outcome: 'clean' | 'drift' | 'inconclusive';
58
+ drift: boolean;
59
+ items: DriftItem[];
60
+ }
61
+ /**
62
+ * Combine the verdicts for probe A (pinned 6-scope) and probe B
63
+ * (5-scope, org:create_api_key removed) into a single watcher result.
64
+ * See scripts/check-cc-authorize-probe.mjs for the full rationale.
65
+ */
66
+ export declare function combineVerdicts(a: ClassifiedVerdict, b: ClassifiedVerdict): CombinedVerdict;
67
+ export interface ProbeConfig {
68
+ clientId: string;
69
+ authorizeUrl: string;
70
+ scopes: string;
71
+ }
72
+ export interface ProbeOptions {
73
+ timeoutMs?: number;
74
+ /** Test hook. Default: `globalThis.fetch`. */
75
+ fetchImpl?: typeof fetch;
76
+ }
77
+ export interface ProbeResult extends ClassifiedVerdict {
78
+ /** The exact URL sent — useful for the user to paste into a browser if the verdict is rejected. */
79
+ probedUrl: string;
80
+ /** Scope count from `scopes`, for quick human read-out. */
81
+ scopeCount: number;
82
+ }
83
+ export declare function buildProbeAuthorizeUrl(cfg: ProbeConfig): string;
84
+ /**
85
+ * Probe the authorize endpoint with the given config. Follows exactly
86
+ * one redirect hop if the Location points at a trusted Anthropic host
87
+ * (claude.ai / claude.com / *.anthropic.com) — the legacy
88
+ * `claude.com/cai/oauth/authorize` edge 307s to claude.ai and the
89
+ * destination is where the validation happens. Stopping at the edge
90
+ * would silently treat every scope set as accepted.
91
+ */
92
+ export declare function runAuthorizeProbe(cfg: ProbeConfig, opts?: ProbeOptions): Promise<ProbeResult>;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Live probe of Anthropic's /oauth/authorize endpoint.
3
+ *
4
+ * Lifted from scripts/_authorize-probe-classifier.mjs and
5
+ * scripts/check-cc-authorize-probe.mjs so `dario doctor --probe` can
6
+ * reuse the same logic without re-importing from scripts. The .mjs
7
+ * files re-export from this module for the CI drift watcher.
8
+ *
9
+ * Motivating pattern: Anthropic's policy engine flips scope
10
+ * acceptability without changing what CC ships in its binary, so the
11
+ * binary-scan drift watcher can't see the flip. The live probe fills
12
+ * that gap — but from GitHub Actions IPs it hits Cloudflare's bot
13
+ * challenge and comes back "inconclusive" most of the time. Running
14
+ * the same probe from a user's own machine (via `dario doctor
15
+ * --probe`) is the workaround: CF doesn't challenge residential IPs
16
+ * the same way. See dario #42 / #71 for the incidents this prevents.
17
+ */
18
+ import { createHash, randomBytes } from 'node:crypto';
19
+ /**
20
+ * The specific string Anthropic's authorize endpoint returns for a
21
+ * scope set its policy engine rejects. Observed across multiple
22
+ * incidents (dario #22, #42, #71) — stable across the policy flips.
23
+ */
24
+ export const REJECT_MARKER = 'Invalid request format';
25
+ /** Not a real callback — the probe only needs the initial response. */
26
+ const PROBE_REDIRECT_URI = 'http://localhost:12345/callback';
27
+ const PROBE_TIMEOUT_MS = 15_000;
28
+ /** Cloudflare fronts claude.ai and challenges unrecognized clients. */
29
+ function isCloudflareChallenge({ status, body }) {
30
+ const bodyText = typeof body === 'string' ? body : '';
31
+ if (bodyText.includes('Just a moment...') || bodyText.includes('/cdn-cgi/challenge-platform/')) {
32
+ return true;
33
+ }
34
+ if (status === 403 && bodyText.includes('cdn-cgi')) {
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ /**
40
+ * Classify a single authorize-endpoint response.
41
+ *
42
+ * accepted — 3xx redirect to login/consent, OR 2xx without the
43
+ * reject marker. The scope set passed validation.
44
+ * rejected — body contains the specific "Invalid request format"
45
+ * marker. The scope set was refused.
46
+ * inconclusive — fetch error, Cloudflare challenge, or any other
47
+ * response shape we can't categorize. Not drift; try
48
+ * again.
49
+ */
50
+ export function classifyAuthorizeResponse({ status, location, body, error }) {
51
+ if (error) {
52
+ return { verdict: 'inconclusive', reason: `fetch error: ${error}` };
53
+ }
54
+ if (isCloudflareChallenge({ status, body })) {
55
+ return {
56
+ verdict: 'inconclusive',
57
+ reason: `blocked by Cloudflare bot challenge (status=${status}). ` +
58
+ `The live probe is unreliable from CI IPs — rely on the scope-literal ` +
59
+ `scan in check-cc-drift.mjs, or run this probe from a trusted network.`,
60
+ };
61
+ }
62
+ const bodyText = typeof body === 'string' ? body : '';
63
+ if (bodyText.includes(REJECT_MARKER)) {
64
+ return { verdict: 'rejected', reason: `body contains "${REJECT_MARKER}"` };
65
+ }
66
+ if (status >= 300 && status < 400 && typeof location === 'string' && location.length > 0) {
67
+ return { verdict: 'accepted', reason: `${status} redirect to ${location}` };
68
+ }
69
+ if (status >= 200 && status < 300) {
70
+ return { verdict: 'accepted', reason: `${status} body rendered, no reject marker` };
71
+ }
72
+ return {
73
+ verdict: 'inconclusive',
74
+ reason: `unexpected response: status=${status}, location=${location ?? 'none'}, body_len=${bodyText.length}`,
75
+ };
76
+ }
77
+ /**
78
+ * Combine the verdicts for probe A (pinned 6-scope) and probe B
79
+ * (5-scope, org:create_api_key removed) into a single watcher result.
80
+ * See scripts/check-cc-authorize-probe.mjs for the full rationale.
81
+ */
82
+ export function combineVerdicts(a, b) {
83
+ if (a.verdict === 'inconclusive' || b.verdict === 'inconclusive') {
84
+ const items = [];
85
+ if (a.verdict === 'inconclusive')
86
+ items.push({ probe: 'A', severity: 'info', message: `probe A inconclusive: ${a.reason}` });
87
+ if (b.verdict === 'inconclusive')
88
+ items.push({ probe: 'B', severity: 'info', message: `probe B inconclusive: ${b.reason}` });
89
+ return { outcome: 'inconclusive', drift: false, items };
90
+ }
91
+ const items = [];
92
+ if (a.verdict !== 'accepted') {
93
+ items.push({
94
+ probe: 'A',
95
+ severity: 'high',
96
+ message: `Pinned FALLBACK.scopes (6-scope) no longer accepted by authorize endpoint (${a.reason}). ` +
97
+ `This is the dario #42 / #71 failure mode: users will hit "Invalid request format" on fresh login. ` +
98
+ `Investigate which scope the server now rejects and update FALLBACK.scopes in src/cc-oauth-detect.ts; ` +
99
+ `bump the cache suffix (CACHE_PATH) so existing users regenerate.`,
100
+ });
101
+ }
102
+ if (b.verdict === 'accepted') {
103
+ items.push({
104
+ probe: 'B',
105
+ severity: 'info',
106
+ message: `5-scope form (org:create_api_key removed) is ALSO accepted. Anthropic's authorize ` +
107
+ `endpoint appears permissive for this client_id — both the 6-scope form our users send ` +
108
+ `and the 5-scope form are accepted. Nothing to fix; recorded so we notice when it flips.`,
109
+ });
110
+ }
111
+ const hasDrift = items.some((i) => i.severity === 'high');
112
+ return {
113
+ outcome: hasDrift ? 'drift' : 'clean',
114
+ drift: hasDrift,
115
+ items,
116
+ };
117
+ }
118
+ function base64url(buf) {
119
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
120
+ }
121
+ function pkceChallenge() {
122
+ const verifier = base64url(randomBytes(32));
123
+ return base64url(createHash('sha256').update(verifier).digest());
124
+ }
125
+ export function buildProbeAuthorizeUrl(cfg) {
126
+ const params = new URLSearchParams({
127
+ code: 'true',
128
+ client_id: cfg.clientId,
129
+ response_type: 'code',
130
+ redirect_uri: PROBE_REDIRECT_URI,
131
+ scope: cfg.scopes,
132
+ code_challenge: pkceChallenge(),
133
+ code_challenge_method: 'S256',
134
+ state: base64url(randomBytes(16)),
135
+ });
136
+ return `${cfg.authorizeUrl}?${params.toString()}`;
137
+ }
138
+ const PROBE_HEADERS = {
139
+ 'User-Agent': 'dario-cc-authorize-probe/1 (+https://github.com/askalf/dario)',
140
+ Accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8',
141
+ };
142
+ async function fetchOnce(url, opts) {
143
+ const fetchImpl = opts.fetchImpl ?? fetch;
144
+ const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
145
+ try {
146
+ const res = await fetchImpl(url, {
147
+ method: 'GET',
148
+ redirect: 'manual',
149
+ signal: AbortSignal.timeout(timeoutMs),
150
+ headers: PROBE_HEADERS,
151
+ });
152
+ const location = res.headers.get('location');
153
+ let body = '';
154
+ try {
155
+ body = await res.text();
156
+ }
157
+ catch (err) {
158
+ return { status: res.status, location, body: '', error: `read body failed: ${err?.message ?? String(err)}` };
159
+ }
160
+ return { status: res.status, location, body, error: null };
161
+ }
162
+ catch (err) {
163
+ return { status: 0, location: null, body: '', error: err?.message ?? String(err) };
164
+ }
165
+ }
166
+ /**
167
+ * Probe the authorize endpoint with the given config. Follows exactly
168
+ * one redirect hop if the Location points at a trusted Anthropic host
169
+ * (claude.ai / claude.com / *.anthropic.com) — the legacy
170
+ * `claude.com/cai/oauth/authorize` edge 307s to claude.ai and the
171
+ * destination is where the validation happens. Stopping at the edge
172
+ * would silently treat every scope set as accepted.
173
+ */
174
+ export async function runAuthorizeProbe(cfg, opts = {}) {
175
+ const url = buildProbeAuthorizeUrl(cfg);
176
+ const first = await fetchOnce(url, opts);
177
+ const trustedRedirect = first.status >= 300 && first.status < 400 &&
178
+ typeof first.location === 'string' &&
179
+ /^https:\/\/(claude\.ai|claude\.com|[\w-]+\.anthropic\.com)\//.test(first.location);
180
+ const response = trustedRedirect && first.location !== null
181
+ ? await fetchOnce(first.location, opts)
182
+ : first;
183
+ const classified = classifyAuthorizeResponse(response);
184
+ const scopeCount = cfg.scopes.split(/\s+/).filter(Boolean).length;
185
+ return { ...classified, probedUrl: url, scopeCount };
186
+ }
@@ -75,6 +75,16 @@ export declare const FALLBACK_FOR_DRIFT_CHECK: Readonly<DetectedOAuthConfig>;
75
75
  * window after it.
76
76
  */
77
77
  export declare function scanBinaryForOAuthConfig(buf: Buffer): Omit<DetectedOAuthConfig, 'source' | 'ccPath' | 'ccHash'> | null;
78
+ /**
79
+ * Given CC's binary and a list of scope literals we expect to find,
80
+ * return the subset that actually appear as quoted-string literals in
81
+ * the binary. Scoping the match to `"<scope>"` (with surrounding
82
+ * double quotes) avoids false matches on partial substrings. Pure
83
+ * over its inputs — safe to unit-test without a real CC binary.
84
+ *
85
+ * Exported for unit tests.
86
+ */
87
+ export declare function filterScopesByBinaryPresence(buf: Buffer, expected: readonly string[]): string[];
78
88
  /**
79
89
  * Get the OAuth config for dario to use. Scans the installed CC binary
80
90
  * on first call, caches to disk, and memoizes in-process for subsequent
@@ -273,22 +273,51 @@ export function scanBinaryForOAuthConfig(buf) {
273
273
  const tokenMatch = /TOKEN_URL\s*:\s*"(https:\/\/[^"]*\/oauth\/token[^"]*)"/.exec(prodBlock);
274
274
  if (tokenMatch && tokenMatch[1])
275
275
  tokenUrl = tokenMatch[1];
276
- // Scopes are NOT detected from the binary. Previous versions of this
277
- // scanner anchored on `"user:profile ` and regex-captured the first
278
- // contiguous quoted run of scopes, but that anchor matches an error/help
279
- // message string literal (used by `claude setup-token` error output) that
280
- // contains only 4 of the 6 actual scopes. The real scope array is stored
281
- // as a constant-reference array — `dY8 = [B9H, TI, "user:sessions:...", ...]`
282
- // — where the first two elements are minified variable references, not
283
- // literal strings, so no regex can reliably extract the full list. And the
284
- // runtime-computed union `n36` only exists after `Array.from(new Set(...))`
285
- // executes, which we can't evaluate from a static scan.
276
+ // Scopes can't be EXTRACTED from the binary the real scope array is
277
+ // stored as a constant-reference array (`dY8 = [B9H, TI, "user:sessions:
278
+ // ...", ...]`) where the first elements are minified variable references,
279
+ // not literal strings, so no regex resolves the full list statically.
286
280
  //
287
- // Given that scopes rarely change across CC releases (Anthropic adds or
288
- // removes maybe one per major version), hardcoding them in FALLBACK is
289
- // more reliable than scanning. If Anthropic changes the scope list, the
290
- // fix is a one-line FALLBACK update in a dario release.
291
- return { clientId, authorizeUrl, tokenUrl, scopes: FALLBACK.scopes };
281
+ // But we CAN verify them: every scope CC uses does appear as a quoted
282
+ // literal string somewhere in the binary (either in the scope array
283
+ // itself when not minified to a variable ref, or in help-text /
284
+ // error-message references that name scopes by literal). Scan for each
285
+ // FALLBACK scope's quoted literal; if any go missing, drop those
286
+ // scopes from the returned config and log a warning.
287
+ //
288
+ // This catches one class of drift the hardcoded FALLBACK doesn't: a
289
+ // future CC release that REMOVES a scope from the active set — Anthropic
290
+ // deprecates `user:file_upload` in CC v2.2.0, say — would leave dario
291
+ // sending a stale scope that the server now rejects, same failure mode
292
+ // as #42 / #71. Binary verification catches it at startup without
293
+ // waiting for a user to hit the "Invalid request format" page.
294
+ //
295
+ // It does NOT catch the opposite direction (Anthropic starts accepting
296
+ // a new scope CC didn't previously use) — those still require a FALLBACK
297
+ // bump. The probe in scripts/check-cc-authorize-probe.mjs + `dario
298
+ // doctor --probe` covers that direction.
299
+ const expected = FALLBACK.scopes.split(/\s+/).filter(Boolean);
300
+ const verified = filterScopesByBinaryPresence(buf, expected);
301
+ const scopes = verified.length > 0 ? verified.join(' ') : FALLBACK.scopes;
302
+ return { clientId, authorizeUrl, tokenUrl, scopes };
303
+ }
304
+ /**
305
+ * Given CC's binary and a list of scope literals we expect to find,
306
+ * return the subset that actually appear as quoted-string literals in
307
+ * the binary. Scoping the match to `"<scope>"` (with surrounding
308
+ * double quotes) avoids false matches on partial substrings. Pure
309
+ * over its inputs — safe to unit-test without a real CC binary.
310
+ *
311
+ * Exported for unit tests.
312
+ */
313
+ export function filterScopesByBinaryPresence(buf, expected) {
314
+ const out = [];
315
+ for (const scope of expected) {
316
+ const needle = Buffer.from(`"${scope}"`);
317
+ if (buf.includes(needle))
318
+ out.push(scope);
319
+ }
320
+ return out;
292
321
  }
293
322
  async function loadCache() {
294
323
  try {
package/dist/cli.js CHANGED
@@ -643,6 +643,30 @@ async function help() {
643
643
  CLI. (v3.27)
644
644
  dario doctor Print a health report: dario / Node / CC /
645
645
  template / drift / OAuth / pool / backends
646
+ dario doctor --probe Also hit Anthropic's authorize endpoint with
647
+ dario's effective OAuth config and surface
648
+ the server's verdict — the single reliable
649
+ signal for scope-policy drift (dario#42/#71
650
+ class). One GET to claude.ai; no PII.
651
+ dario doctor --json Emit the check report as structured JSON
652
+ for machine consumption (claude-bridge
653
+ /status, CI scripts, etc.) instead of the
654
+ human-readable table.
655
+ dario doctor --auth-check
656
+ One-shot listener on an ephemeral loopback
657
+ port. Send ONE request from your client
658
+ (OpenClaw, Hermes, curl, etc.) and dario
659
+ classifies the auth headers it sees against
660
+ DARIO_API_KEY — with redacted previews and
661
+ a targeted diagnosis (dario#97 class). Use
662
+ --timeout-ms=N to adjust the 30s default.
663
+ dario config Print the effective configuration (port,
664
+ host, DARIO_API_KEY state, OAuth status,
665
+ pool, backends, paths) with credentials
666
+ redacted. Safe to paste into bug reports.
667
+ --json for structured output.
668
+ dario upgrade npm install -g @askalf/dario@latest with a
669
+ pre-flight current-vs-latest check.
646
670
 
647
671
  Proxy options:
648
672
  --model=MODEL Force a model for all requests
@@ -942,17 +966,65 @@ async function mcp() {
942
966
  }
943
967
  }
944
968
  async function doctor() {
945
- const { runChecks, formatChecks, exitCodeFor } = await import('./doctor.js');
969
+ const { runChecks, formatChecks, formatChecksJson, exitCodeFor, runAuthCheck } = await import('./doctor.js');
970
+ const probe = args.includes('--probe');
971
+ const asJson = args.includes('--json');
972
+ const authCheck = args.includes('--auth-check');
973
+ if (authCheck) {
974
+ console.log('');
975
+ console.log(' dario — Auth Check');
976
+ console.log(' ──────────────────');
977
+ console.log('');
978
+ const timeoutArg = args.find((a) => a.startsWith('--timeout-ms='));
979
+ const timeoutMs = timeoutArg ? Math.max(1000, parseInt(timeoutArg.split('=')[1], 10)) : 30_000;
980
+ const result = await runAuthCheck({
981
+ timeoutMs,
982
+ onListening: (port) => {
983
+ console.log(` Listening on http://127.0.0.1:${port}/`);
984
+ console.log(` Waiting up to ${Math.round(timeoutMs / 1000)}s for ONE request from your client.`);
985
+ console.log(' Any path and method are fine — dario only inspects the auth headers.');
986
+ console.log('');
987
+ },
988
+ });
989
+ console.log(` Verdict: ${result.verdict}`);
990
+ console.log(` Expected: ${result.expected === '<unset>' ? '(unset)' : 'Bearer <key> (DARIO_API_KEY matches)'}`);
991
+ if (result.received) {
992
+ const seen = [];
993
+ if (result.authorization?.present)
994
+ seen.push(`Authorization: ${result.authorization.redacted}${result.authorization.bearerPrefix === false ? ' (no Bearer prefix)' : ''}`);
995
+ if (result.xApiKey?.present)
996
+ seen.push(`x-api-key: ${result.xApiKey.redacted}`);
997
+ console.log(` Received: ${seen.length > 0 ? seen.join(', ') : 'no auth headers'}`);
998
+ }
999
+ else {
1000
+ console.log(` Received: (no request within ${Math.round(timeoutMs / 1000)}s)`);
1001
+ }
1002
+ console.log('');
1003
+ console.log(' ' + result.diagnosis);
1004
+ console.log('');
1005
+ process.exit(result.verdict === 'match' ? 0 : 1);
1006
+ }
1007
+ const checks = await runChecks({ probe });
1008
+ if (asJson) {
1009
+ // JSON mode is meant for machine consumption (claude-bridge /status,
1010
+ // deepdive health checks, CI scripts) — no decorative header, no
1011
+ // trailing prose, stable shape. Exit code is also surfaced in the
1012
+ // JSON envelope for callers that can't read process exit codes.
1013
+ process.stdout.write(formatChecksJson(checks) + '\n');
1014
+ process.exit(exitCodeFor(checks));
1015
+ }
946
1016
  console.log('');
947
1017
  console.log(' dario — Doctor');
948
1018
  console.log(' ─────────────');
949
1019
  console.log('');
950
- const checks = await runChecks();
951
1020
  console.log(formatChecks(checks));
952
1021
  console.log('');
953
1022
  const code = exitCodeFor(checks);
954
1023
  if (code !== 0) {
955
1024
  console.log(' One or more checks failed. Address the [FAIL] rows and re-run `dario doctor`.');
1025
+ if (!probe) {
1026
+ console.log(' For a live check against Anthropic\'s authorize endpoint, re-run with `--probe`.');
1027
+ }
956
1028
  console.log('');
957
1029
  }
958
1030
  process.exit(code);
@@ -969,6 +1041,86 @@ async function version() {
969
1041
  console.log('unknown');
970
1042
  }
971
1043
  }
1044
+ async function config() {
1045
+ const { collectEffectiveConfig, formatEffectiveConfig, formatEffectiveConfigJson } = await import('./config-report.js');
1046
+ const asJson = args.includes('--json');
1047
+ const report = await collectEffectiveConfig();
1048
+ if (asJson) {
1049
+ process.stdout.write(formatEffectiveConfigJson(report) + '\n');
1050
+ return;
1051
+ }
1052
+ console.log('');
1053
+ console.log(' dario — Config');
1054
+ console.log(' ─────────────');
1055
+ console.log('');
1056
+ console.log(formatEffectiveConfig(report));
1057
+ }
1058
+ async function upgrade() {
1059
+ // Thin wrapper over `npm install -g @askalf/dario@latest`. The value
1060
+ // isn't in saving the user typing — it's in the pre-flight (print
1061
+ // current vs. latest version, refuse to run if already on latest,
1062
+ // fail with a clear hint if npm is missing).
1063
+ const { spawnSync } = await import('node:child_process');
1064
+ const { fileURLToPath } = await import('node:url');
1065
+ const { readFile: rf } = await import('node:fs/promises');
1066
+ const dir = join(fileURLToPath(import.meta.url), '..', '..');
1067
+ let currentVersion = 'unknown';
1068
+ try {
1069
+ const pkg = JSON.parse(await rf(join(dir, 'package.json'), 'utf-8'));
1070
+ currentVersion = pkg.version ?? 'unknown';
1071
+ }
1072
+ catch { /* noop */ }
1073
+ console.log('');
1074
+ console.log(' dario — Upgrade');
1075
+ console.log(' ──────────────');
1076
+ console.log('');
1077
+ console.log(` Current: v${currentVersion}`);
1078
+ // Probe npm for the latest version first — avoids a long npm install if
1079
+ // the user's already on @latest. 3s timeout keeps the pre-flight short.
1080
+ let latestVersion = null;
1081
+ try {
1082
+ const res = spawnSync('npm', ['view', '@askalf/dario', 'version'], {
1083
+ encoding: 'utf8',
1084
+ timeout: 3000,
1085
+ shell: process.platform === 'win32',
1086
+ });
1087
+ if (res.status === 0) {
1088
+ const m = /(\d+\.\d+\.\d+(?:[.\-][\w.\-]+)?)/.exec(res.stdout);
1089
+ latestVersion = m ? m[1] : null;
1090
+ }
1091
+ }
1092
+ catch { /* noop */ }
1093
+ if (!latestVersion) {
1094
+ console.log(' Latest: (npm view failed — is npm on PATH? Continuing anyway.)');
1095
+ }
1096
+ else {
1097
+ console.log(` Latest: v${latestVersion}`);
1098
+ if (latestVersion === currentVersion) {
1099
+ console.log('');
1100
+ console.log(' Already on the latest release. Nothing to do.');
1101
+ console.log('');
1102
+ return;
1103
+ }
1104
+ }
1105
+ console.log('');
1106
+ console.log(' Running: npm install -g @askalf/dario@latest');
1107
+ console.log('');
1108
+ const install = spawnSync('npm', ['install', '-g', '@askalf/dario@latest'], {
1109
+ stdio: 'inherit',
1110
+ shell: process.platform === 'win32',
1111
+ });
1112
+ if (install.status !== 0) {
1113
+ console.error('');
1114
+ console.error(' npm install failed. If this is a permissions issue, try:');
1115
+ console.error(' sudo npm install -g @askalf/dario@latest # POSIX');
1116
+ console.error(' npm install -g @askalf/dario@latest # Windows (may need admin)');
1117
+ console.error('');
1118
+ process.exit(install.status ?? 1);
1119
+ }
1120
+ console.log('');
1121
+ console.log(' Upgrade complete. Run `dario --version` to confirm.');
1122
+ console.log('');
1123
+ }
972
1124
  // Main
973
1125
  const commands = {
974
1126
  login,
@@ -982,6 +1134,8 @@ const commands = {
982
1134
  subagent,
983
1135
  mcp,
984
1136
  doctor,
1137
+ config,
1138
+ upgrade,
985
1139
  help,
986
1140
  version,
987
1141
  '--help': help,
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `dario config` — print effective configuration with credentials redacted.
3
+ *
4
+ * Different from `dario doctor`: doctor is "is it working?", config is
5
+ * "what IS it?". The overlap is intentional — config shows *settings*
6
+ * (port, host, DARIO_API_KEY state, model defaults) that operators
7
+ * need to confirm when debugging a client misconfiguration. Doctor
8
+ * shows *health* (OAuth expiry, template drift, TLS fingerprint
9
+ * match) that operators need to confirm when debugging a routing
10
+ * failure.
11
+ *
12
+ * Every output row is already safe to paste into a bug report:
13
+ * credentials are replaced with `set`/`unset` state tags, paths are
14
+ * left untouched because they're operationally useful, tokens never
15
+ * appear.
16
+ */
17
+ export interface ConfigSection {
18
+ title: string;
19
+ rows: Array<{
20
+ label: string;
21
+ value: string;
22
+ }>;
23
+ }
24
+ export interface ConfigReport {
25
+ generatedAt: string;
26
+ version: string;
27
+ sections: ConfigSection[];
28
+ }
29
+ /**
30
+ * Collect the effective dario configuration the proxy would run with.
31
+ * Reads env vars, filesystem state (credentials, override files, caches),
32
+ * account pool, and configured backends. Never reads the actual
33
+ * credential VALUES — only their presence/absence/path.
34
+ */
35
+ export declare function collectEffectiveConfig(): Promise<ConfigReport>;
36
+ export declare function formatAge(ms: number): string;
37
+ /**
38
+ * Pretty-print a ConfigReport as aligned ASCII. Same approach as
39
+ * doctor's formatChecks — plain text, no colors, pasteable.
40
+ */
41
+ export declare function formatEffectiveConfig(report: ConfigReport): string;
42
+ /** Structured envelope for `dario config --json`. */
43
+ export declare function formatEffectiveConfigJson(report: ConfigReport): string;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `dario config` — print effective configuration with credentials redacted.
3
+ *
4
+ * Different from `dario doctor`: doctor is "is it working?", config is
5
+ * "what IS it?". The overlap is intentional — config shows *settings*
6
+ * (port, host, DARIO_API_KEY state, model defaults) that operators
7
+ * need to confirm when debugging a client misconfiguration. Doctor
8
+ * shows *health* (OAuth expiry, template drift, TLS fingerprint
9
+ * match) that operators need to confirm when debugging a routing
10
+ * failure.
11
+ *
12
+ * Every output row is already safe to paste into a bug report:
13
+ * credentials are replaced with `set`/`unset` state tags, paths are
14
+ * left untouched because they're operationally useful, tokens never
15
+ * appear.
16
+ */
17
+ import { readFileSync, existsSync, statSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { homedir } from 'node:os';
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ /**
23
+ * Collect the effective dario configuration the proxy would run with.
24
+ * Reads env vars, filesystem state (credentials, override files, caches),
25
+ * account pool, and configured backends. Never reads the actual
26
+ * credential VALUES — only their presence/absence/path.
27
+ */
28
+ export async function collectEffectiveConfig() {
29
+ const sections = [];
30
+ const home = join(homedir(), '.dario');
31
+ // ── Identity
32
+ let version = 'unknown';
33
+ try {
34
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
35
+ version = pkg.version ?? 'unknown';
36
+ }
37
+ catch { /* noop */ }
38
+ sections.push({
39
+ title: 'Identity',
40
+ rows: [
41
+ { label: 'version', value: `v${version}` },
42
+ { label: 'runtime', value: `node ${process.version} on ${process.platform} ${process.arch}` },
43
+ ],
44
+ });
45
+ // ── Proxy bind
46
+ sections.push({
47
+ title: 'Proxy (on `dario proxy`)',
48
+ rows: [
49
+ { label: 'port', value: envOrDefault('DARIO_PORT', '3456') },
50
+ { label: 'host', value: envOrDefault('DARIO_HOST', '127.0.0.1') },
51
+ { label: 'model', value: envOrDefault('DARIO_MODEL', '(passthrough — client picks)') },
52
+ { label: 'effort', value: envOrDefault('DARIO_EFFORT', '(CC default)') },
53
+ ],
54
+ });
55
+ // ── Auth gate
56
+ sections.push({
57
+ title: 'Auth gate',
58
+ rows: [
59
+ {
60
+ label: 'DARIO_API_KEY',
61
+ value: process.env.DARIO_API_KEY
62
+ ? `set (length ${process.env.DARIO_API_KEY.length}) — x-api-key / Authorization Bearer required`
63
+ : 'unset — auth not enforced on loopback',
64
+ },
65
+ {
66
+ label: 'DARIO_STRICT_TLS',
67
+ value: process.env.DARIO_STRICT_TLS === '1' ? 'on' : 'off',
68
+ },
69
+ ],
70
+ });
71
+ // ── OAuth (Claude subscription credentials)
72
+ const credsPath = join(home, 'credentials.json');
73
+ const credsInfo = describeCreds(credsPath);
74
+ sections.push({
75
+ title: 'OAuth',
76
+ rows: [
77
+ { label: 'credentials', value: credsInfo },
78
+ { label: 'path', value: credsPath },
79
+ ],
80
+ });
81
+ // ── Pool
82
+ try {
83
+ const { listAccountAliases } = await import('./accounts.js');
84
+ const aliases = await listAccountAliases();
85
+ sections.push({
86
+ title: 'Account pool',
87
+ rows: [
88
+ { label: 'mode', value: aliases.length === 0 ? 'single-account (no pool)' : `pool of ${aliases.length}` },
89
+ ...(aliases.length > 0 ? [{ label: 'aliases', value: aliases.join(', ') }] : []),
90
+ ],
91
+ });
92
+ }
93
+ catch {
94
+ sections.push({
95
+ title: 'Account pool',
96
+ rows: [{ label: 'mode', value: '(check failed)' }],
97
+ });
98
+ }
99
+ // ── Backends
100
+ try {
101
+ const { listBackends } = await import('./openai-backend.js');
102
+ const backends = await listBackends();
103
+ sections.push({
104
+ title: 'OpenAI-compat backends',
105
+ rows: [
106
+ { label: 'count', value: String(backends.length) },
107
+ ...(backends.length > 0
108
+ ? [{ label: 'names', value: backends.map((b) => b.name).join(', ') }]
109
+ : []),
110
+ ],
111
+ });
112
+ }
113
+ catch {
114
+ sections.push({
115
+ title: 'OpenAI-compat backends',
116
+ rows: [{ label: 'count', value: '(check failed)' }],
117
+ });
118
+ }
119
+ // ── Paths (everything dario reads/writes on disk)
120
+ sections.push({
121
+ title: 'Paths',
122
+ rows: [
123
+ { label: 'home', value: home },
124
+ { label: 'credentials', value: credsPath },
125
+ { label: 'accounts', value: join(home, 'accounts') },
126
+ { label: 'oauth cache', value: join(home, 'cc-oauth-cache-v6.json') },
127
+ { label: 'oauth override', value: join(home, 'oauth-config.override.json') },
128
+ { label: 'template cache', value: join(home, 'template-cache.json') },
129
+ ],
130
+ });
131
+ return {
132
+ generatedAt: new Date().toISOString(),
133
+ version,
134
+ sections,
135
+ };
136
+ }
137
+ function envOrDefault(name, dflt) {
138
+ return process.env[name] ? `${process.env[name]} (from ${name})` : dflt;
139
+ }
140
+ function describeCreds(path) {
141
+ if (!existsSync(path))
142
+ return 'not authenticated (run `dario login`)';
143
+ try {
144
+ const s = statSync(path);
145
+ const mode = (s.mode & 0o777).toString(8);
146
+ const age = formatAge(Date.now() - s.mtimeMs);
147
+ return `present (mode ${mode}, last updated ${age} ago)`;
148
+ }
149
+ catch {
150
+ return 'present (stat failed)';
151
+ }
152
+ }
153
+ // Exported for unit tests.
154
+ export function formatAge(ms) {
155
+ const s = Math.max(0, Math.round(ms / 1000));
156
+ if (s < 60)
157
+ return `${s}s`;
158
+ const m = Math.round(s / 60);
159
+ if (m < 60)
160
+ return `${m}m`;
161
+ const h = Math.round(m / 60);
162
+ if (h < 24)
163
+ return `${h}h`;
164
+ const d = Math.round(h / 24);
165
+ return `${d}d`;
166
+ }
167
+ /**
168
+ * Pretty-print a ConfigReport as aligned ASCII. Same approach as
169
+ * doctor's formatChecks — plain text, no colors, pasteable.
170
+ */
171
+ export function formatEffectiveConfig(report) {
172
+ const lines = [];
173
+ for (const section of report.sections) {
174
+ lines.push(` ${section.title}`);
175
+ lines.push(` ${'─'.repeat(section.title.length)}`);
176
+ const labelWidth = section.rows.reduce((n, r) => Math.max(n, r.label.length), 0);
177
+ for (const r of section.rows) {
178
+ lines.push(` ${r.label.padEnd(labelWidth)} ${r.value}`);
179
+ }
180
+ lines.push('');
181
+ }
182
+ return lines.join('\n');
183
+ }
184
+ /** Structured envelope for `dario config --json`. */
185
+ export function formatEffectiveConfigJson(report) {
186
+ return JSON.stringify(report, null, 2);
187
+ }
package/dist/doctor.d.ts CHANGED
@@ -31,6 +31,26 @@ export declare function formatChecks(checks: Check[]): string;
31
31
  * a user's machine just because they're on an untested CC version.
32
32
  */
33
33
  export declare function exitCodeFor(checks: Check[]): number;
34
+ /**
35
+ * Serialize a check report as structured JSON. Lets other tools
36
+ * (claude-bridge's /status command, deepdive, CI scripts) consume
37
+ * dario's health programmatically instead of scraping the formatted
38
+ * text. Emitted by `dario doctor --json`.
39
+ */
40
+ export declare function formatChecksJson(checks: Check[]): string;
41
+ export declare function probeNpmLatestCC(): string | null;
42
+ export interface RunChecksOptions {
43
+ /**
44
+ * Opt-in: hit Anthropic's authorize endpoint with the scope set dario
45
+ * would use on `accounts add`, and surface the server's verdict as a
46
+ * check row. Default off — `dario doctor` without `--probe` is a
47
+ * read-only local scan, no outbound traffic beyond what the other
48
+ * checks already make (OAuth token refresh, CC binary version probe,
49
+ * npm drift check). Enable with `dario doctor --probe`; costs one
50
+ * GET to `claude.ai` and runs in parallel with the other checks.
51
+ */
52
+ probe?: boolean;
53
+ }
34
54
  /**
35
55
  * Run every available health check. Never throws — each check is
36
56
  * individually try/caught so a broken subsystem (e.g. unreadable accounts
@@ -40,4 +60,49 @@ export declare function exitCodeFor(checks: Check[]): number;
40
60
  * version, platform) so a reader scanning the output top-down sees
41
61
  * the environment before the subsystems.
42
62
  */
43
- export declare function runChecks(): Promise<Check[]>;
63
+ export declare function runChecks(opts?: RunChecksOptions): Promise<Check[]>;
64
+ export interface SeenHeader {
65
+ present: boolean;
66
+ /** Redacted preview: `"abcd...wxyz"` — first 4 + last 4 chars, or length tag if the value is too short to excerpt safely. */
67
+ redacted?: string;
68
+ length?: number;
69
+ /** For Authorization: whether the value started with "Bearer " (case-insensitive). */
70
+ bearerPrefix?: boolean;
71
+ /** Did this header's value (after any `Bearer ` strip) match DARIO_API_KEY? */
72
+ matches?: boolean;
73
+ }
74
+ export type AuthCheckVerdict = 'match' | 'mismatch' | 'no-auth-header' | 'timeout' | 'no-enforcement';
75
+ export interface AuthCheckResult {
76
+ received: boolean;
77
+ port?: number;
78
+ expected: string;
79
+ xApiKey?: SeenHeader;
80
+ authorization?: SeenHeader;
81
+ verdict: AuthCheckVerdict;
82
+ diagnosis: string;
83
+ }
84
+ export interface AuthCheckOptions {
85
+ /** Milliseconds to wait for an inbound request. Default 30,000. */
86
+ timeoutMs?: number;
87
+ /** Override the expected key. Default: `process.env.DARIO_API_KEY`. */
88
+ expectedKey?: string;
89
+ /** Test hook: called when the server is listening, with the port. */
90
+ onListening?: (port: number) => void;
91
+ }
92
+ export declare function redactSecret(value: string): string;
93
+ export declare function classifyAuthHeaders(headers: {
94
+ 'x-api-key'?: string | string[];
95
+ authorization?: string | string[];
96
+ }, expected: string): {
97
+ xApiKey: SeenHeader;
98
+ authorization: SeenHeader;
99
+ verdict: AuthCheckVerdict;
100
+ };
101
+ /**
102
+ * Listen for one inbound request on a random loopback port, classify
103
+ * whatever auth headers it carries against `DARIO_API_KEY`, return a
104
+ * structured result. Sends 200 / 401 to the inbound request so the
105
+ * client doesn't hang, then closes. This is a probe — it does not
106
+ * proxy, does not log, does not persist.
107
+ */
108
+ export declare function runAuthCheck(opts?: AuthCheckOptions): Promise<AuthCheckResult>;
package/dist/doctor.js CHANGED
@@ -14,8 +14,12 @@ import { readFileSync } from 'node:fs';
14
14
  import { join, dirname } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { homedir, platform, arch, release } from 'node:os';
17
+ import { execFileSync } from 'node:child_process';
18
+ import { createServer } from 'node:http';
17
19
  import { CC_TEMPLATE, } from './cc-template.js';
18
- import { describeTemplate, detectDrift, checkCCCompat, findInstalledCC, SUPPORTED_CC_RANGE, CURRENT_SCHEMA_VERSION, } from './live-fingerprint.js';
20
+ import { describeTemplate, detectDrift, checkCCCompat, findInstalledCC, SUPPORTED_CC_RANGE, CURRENT_SCHEMA_VERSION, compareVersions, } from './live-fingerprint.js';
21
+ import { detectCCOAuthConfig } from './cc-oauth-detect.js';
22
+ import { runAuthorizeProbe } from './cc-authorize-probe.js';
19
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
24
  /**
21
25
  * Pretty-print a list of Check results as aligned ASCII. No color codes —
@@ -41,6 +45,63 @@ export function formatChecks(checks) {
41
45
  export function exitCodeFor(checks) {
42
46
  return checks.some((c) => c.status === 'fail') ? 1 : 0;
43
47
  }
48
+ /**
49
+ * Serialize a check report as structured JSON. Lets other tools
50
+ * (claude-bridge's /status command, deepdive, CI scripts) consume
51
+ * dario's health programmatically instead of scraping the formatted
52
+ * text. Emitted by `dario doctor --json`.
53
+ */
54
+ export function formatChecksJson(checks) {
55
+ const summary = {
56
+ ok: checks.filter((c) => c.status === 'ok').length,
57
+ warn: checks.filter((c) => c.status === 'warn').length,
58
+ fail: checks.filter((c) => c.status === 'fail').length,
59
+ info: checks.filter((c) => c.status === 'info').length,
60
+ };
61
+ return JSON.stringify({
62
+ generatedAt: new Date().toISOString(),
63
+ exitCode: exitCodeFor(checks),
64
+ summary,
65
+ checks,
66
+ }, null, 2);
67
+ }
68
+ /**
69
+ * Ask npm for the latest @anthropic-ai/claude-code version. One 3s
70
+ * timeout; failures return null so doctor silently drops the check.
71
+ * Result is cached module-scoped so back-to-back doctor invocations
72
+ * (e.g. from a wrapping script) don't hammer the npm registry.
73
+ */
74
+ let _npmLatestCache = null;
75
+ const NPM_CACHE_TTL_MS = 60 * 1000;
76
+ export function probeNpmLatestCC() {
77
+ if (_npmLatestCache && Date.now() - _npmLatestCache.at < NPM_CACHE_TTL_MS) {
78
+ return _npmLatestCache.value;
79
+ }
80
+ let value = null;
81
+ try {
82
+ // `npm view <pkg> version` prints the version as a single line.
83
+ // 3s timeout keeps doctor responsive even with flaky network /
84
+ // corporate proxies; stdio ignores stderr so "npm notice" banners
85
+ // don't pollute stdout parsing.
86
+ const out = execFileSync('npm', ['view', '@anthropic-ai/claude-code', 'version'], {
87
+ encoding: 'utf-8',
88
+ timeout: 3_000,
89
+ stdio: ['ignore', 'pipe', 'ignore'],
90
+ windowsHide: true,
91
+ // npm ships as .cmd on Windows; execFile can't spawn it directly
92
+ // without shell:true. `npm` is not user-overridable here so the
93
+ // command-injection risk is nil.
94
+ shell: process.platform === 'win32',
95
+ });
96
+ const m = /(\d+\.\d+\.\d+(?:[.\-][\w.\-]+)?)/.exec(out);
97
+ value = m ? m[1] : null;
98
+ }
99
+ catch {
100
+ value = null;
101
+ }
102
+ _npmLatestCache = { value, at: Date.now() };
103
+ return value;
104
+ }
44
105
  /**
45
106
  * Run every available health check. Never throws — each check is
46
107
  * individually try/caught so a broken subsystem (e.g. unreadable accounts
@@ -50,7 +111,7 @@ export function exitCodeFor(checks) {
50
111
  * version, platform) so a reader scanning the output top-down sees
51
112
  * the environment before the subsystems.
52
113
  */
53
- export async function runChecks() {
114
+ export async function runChecks(opts = {}) {
54
115
  const checks = [];
55
116
  // ---- dario version
56
117
  try {
@@ -105,6 +166,24 @@ export async function runChecks() {
105
166
  label: 'CC binary',
106
167
  detail: `v${cc.version} at ${cc.path} (range: v${SUPPORTED_CC_RANGE.min} – v${SUPPORTED_CC_RANGE.maxTested})`,
107
168
  });
169
+ // Stale-upstream probe: compare installed against npm's @latest.
170
+ // One network hop (3s timeout, 60s in-process cache). Silent on
171
+ // failure — no check row emitted — since a flaky network
172
+ // shouldn't turn doctor's output noisy. Only emits when the
173
+ // installed CC is strictly older than the npm latest.
174
+ try {
175
+ const npmLatest = probeNpmLatestCC();
176
+ if (npmLatest && compareVersions(cc.version, npmLatest) < 0) {
177
+ checks.push({
178
+ status: 'info',
179
+ label: 'CC upstream',
180
+ detail: `npm latest is v${npmLatest} — installed is v${cc.version}. ` +
181
+ `Run \`npm install -g @anthropic-ai/claude-code@latest\` to upgrade; ` +
182
+ `dario's template will re-capture automatically on next startup.`,
183
+ });
184
+ }
185
+ }
186
+ catch { /* silent */ }
108
187
  }
109
188
  else if (cc.path) {
110
189
  checks.push({
@@ -159,6 +238,50 @@ export async function runChecks() {
159
238
  catch (err) {
160
239
  checks.push({ status: 'warn', label: 'OAuth', detail: `check failed: ${err.message}` });
161
240
  }
241
+ // ---- Authorize-URL probe (opt-in, --probe).
242
+ // One GET to the authorize endpoint with dario's effective OAuth config.
243
+ // This is the single reliable signal for the class of bug that broke
244
+ // #42 / #71 — Anthropic flipping server-side scope policy without
245
+ // changing the CC binary. The nightly probe in check-cc-authorize-
246
+ // probe.mjs hits Cloudflare challenges from CI IPs; running from a
247
+ // user's machine bypasses that. No PII leaves: the probe uses a
248
+ // fresh PKCE challenge and a dummy redirect_uri, and only reads the
249
+ // status code / Location header / response body markers.
250
+ if (opts.probe) {
251
+ try {
252
+ const cfg = await detectCCOAuthConfig();
253
+ const result = await runAuthorizeProbe({
254
+ clientId: cfg.clientId,
255
+ authorizeUrl: cfg.authorizeUrl,
256
+ scopes: cfg.scopes,
257
+ });
258
+ const status = result.verdict === 'accepted'
259
+ ? 'ok'
260
+ : result.verdict === 'rejected'
261
+ ? 'fail'
262
+ : 'warn';
263
+ const label = 'Authorize probe';
264
+ const summary = `${result.scopeCount}-scope ${result.verdict} — ${result.reason}`;
265
+ checks.push({ status, label, detail: summary });
266
+ if (result.verdict !== 'accepted') {
267
+ // On rejection: the URL is the one `accounts add` would open —
268
+ // surface it so the user can paste and diff against `claude
269
+ // /login`'s URL. On inconclusive (often Cloudflare from our
270
+ // fetch-based probe — CF challenges non-browser clients
271
+ // regardless of IP): the same URL pasted into the user's
272
+ // browser bypasses CF since a real browser passes the
273
+ // challenge. Either way, the URL is the actionable artifact.
274
+ checks.push({ status: 'info', label: 'Probe URL', detail: result.probedUrl });
275
+ }
276
+ }
277
+ catch (err) {
278
+ checks.push({
279
+ status: 'warn',
280
+ label: 'Authorize probe',
281
+ detail: `check failed: ${err.message}`,
282
+ });
283
+ }
284
+ }
162
285
  // ---- Account pool
163
286
  try {
164
287
  const { listAccountAliases, loadAllAccounts } = await import('./accounts.js');
@@ -250,3 +373,160 @@ function safely(fn, fallback) {
250
373
  return fallback;
251
374
  }
252
375
  }
376
+ // Exported for unit tests.
377
+ export function redactSecret(value) {
378
+ if (value.length <= 8)
379
+ return `<${value.length} chars>`;
380
+ return `${value.slice(0, 4)}…${value.slice(-4)} (length ${value.length})`;
381
+ }
382
+ // Exported for unit tests. Pure — takes the two headers + the expected
383
+ // key, returns the classification. Separated from the HTTP dance so
384
+ // tests can drive synthetic inputs without binding a socket.
385
+ export function classifyAuthHeaders(headers, expected) {
386
+ const xRaw = headers['x-api-key'];
387
+ const aRaw = headers['authorization'];
388
+ const xVal = Array.isArray(xRaw) ? xRaw[0] : xRaw;
389
+ const aVal = Array.isArray(aRaw) ? aRaw[0] : aRaw;
390
+ const xApiKey = { present: xVal !== undefined };
391
+ if (xVal !== undefined) {
392
+ xApiKey.length = xVal.length;
393
+ xApiKey.redacted = redactSecret(xVal);
394
+ xApiKey.matches = xVal === expected;
395
+ }
396
+ const authorization = { present: aVal !== undefined };
397
+ if (aVal !== undefined) {
398
+ authorization.length = aVal.length;
399
+ authorization.bearerPrefix = /^Bearer\s+/i.test(aVal);
400
+ const stripped = aVal.replace(/^Bearer\s+/i, '');
401
+ authorization.redacted = redactSecret(stripped);
402
+ authorization.matches = stripped === expected;
403
+ }
404
+ let verdict;
405
+ if (!xApiKey.present && !authorization.present) {
406
+ verdict = 'no-auth-header';
407
+ }
408
+ else if (xApiKey.matches === true || authorization.matches === true) {
409
+ verdict = 'match';
410
+ }
411
+ else {
412
+ verdict = 'mismatch';
413
+ }
414
+ return { xApiKey, authorization, verdict };
415
+ }
416
+ function diagnoseAuthCheck(result) {
417
+ switch (result.verdict) {
418
+ case 'match':
419
+ return `client auth matches DARIO_API_KEY. A real dario proxy would accept this request.`;
420
+ case 'mismatch': {
421
+ const parts = [];
422
+ if (result.authorization?.present) {
423
+ const bearer = result.authorization.bearerPrefix ? ' (Bearer prefix present)' : ' (Bearer prefix missing)';
424
+ parts.push(`Authorization header${bearer}: value ${result.authorization.redacted} — does NOT match expected ${redactSecret(result.expected)}.`);
425
+ }
426
+ if (result.xApiKey?.present) {
427
+ parts.push(`x-api-key header: value ${result.xApiKey.redacted} — does NOT match expected ${redactSecret(result.expected)}.`);
428
+ }
429
+ const hint = suggestAuthFix(result);
430
+ return parts.join(' ') + (hint ? ' ' + hint : '');
431
+ }
432
+ case 'no-auth-header':
433
+ return (`client sent no x-api-key and no Authorization header. ` +
434
+ `Expected ${redactSecret(result.expected)} in either. ` +
435
+ `Set ANTHROPIC_API_KEY=${result.expected} in your client's environment, ` +
436
+ `or use your tool's own "API key" config field if it has one.`);
437
+ case 'timeout':
438
+ return (`no request received within the timeout. Did your client target the ` +
439
+ `port printed above? If the client uses a base URL you configured ` +
440
+ `elsewhere, point it at the --auth-check listener for this one request.`);
441
+ case 'no-enforcement':
442
+ return (`DARIO_API_KEY is not set — dario does not enforce auth on loopback ` +
443
+ `by default, so any request would be allowed through. To test auth ` +
444
+ `enforcement, set DARIO_API_KEY=your-secret before running --auth-check.`);
445
+ }
446
+ }
447
+ /** Pattern-match common failure modes for a sharper hint. */
448
+ function suggestAuthFix(result) {
449
+ const auth = result.authorization;
450
+ const exp = result.expected;
451
+ // Authorization value looks like a real Anthropic key — very common
452
+ // pattern: client has one stashed from an earlier setup (OpenClaw's
453
+ // auth-profiles.json, ANTHROPIC_API_KEY env, config file) and it's
454
+ // shadowing the intended "dario" value. dario#97 exactly.
455
+ if (auth?.present && auth.redacted?.startsWith('sk-a')) {
456
+ return (`The value your client sent looks like a real Anthropic API key ` +
457
+ `(starts with "sk-a…"). Your client has that key configured somewhere ` +
458
+ `(auth-profiles.json, ANTHROPIC_API_KEY env, client config file) and it's ` +
459
+ `overriding "${exp}". Either replace it with "${exp}" or bypass auth entirely ` +
460
+ `by running dario on --host=127.0.0.1 without DARIO_API_KEY set.`);
461
+ }
462
+ // Authorization with no "Bearer " prefix: some clients just set the
463
+ // header value raw.
464
+ if (auth?.present && auth.bearerPrefix === false) {
465
+ return (`The Authorization header is missing the "Bearer " prefix. Most ` +
466
+ `HTTP client libraries want "Bearer <key>" as one value — yours seems ` +
467
+ `to be setting the key directly. Check your client's auth config.`);
468
+ }
469
+ return null;
470
+ }
471
+ /**
472
+ * Listen for one inbound request on a random loopback port, classify
473
+ * whatever auth headers it carries against `DARIO_API_KEY`, return a
474
+ * structured result. Sends 200 / 401 to the inbound request so the
475
+ * client doesn't hang, then closes. This is a probe — it does not
476
+ * proxy, does not log, does not persist.
477
+ */
478
+ export async function runAuthCheck(opts = {}) {
479
+ const timeoutMs = opts.timeoutMs ?? 30_000;
480
+ const expected = opts.expectedKey ?? process.env.DARIO_API_KEY ?? '';
481
+ if (!expected) {
482
+ return {
483
+ received: false,
484
+ expected: '<unset>',
485
+ verdict: 'no-enforcement',
486
+ diagnosis: diagnoseAuthCheck({ received: false, expected: '<unset>', verdict: 'no-enforcement' }),
487
+ };
488
+ }
489
+ return new Promise((resolve) => {
490
+ let settled = false;
491
+ const settle = (result) => {
492
+ if (settled)
493
+ return;
494
+ settled = true;
495
+ server.close(() => resolve(result));
496
+ };
497
+ const server = createServer((req, res) => {
498
+ const { xApiKey, authorization, verdict } = classifyAuthHeaders(req.headers, expected);
499
+ const port = server.address()?.port;
500
+ const result = {
501
+ received: true,
502
+ port,
503
+ expected,
504
+ xApiKey,
505
+ authorization,
506
+ verdict,
507
+ diagnosis: '',
508
+ };
509
+ result.diagnosis = diagnoseAuthCheck(result);
510
+ res.writeHead(verdict === 'match' ? 200 : 401, { 'content-type': 'application/json' });
511
+ res.end(JSON.stringify({
512
+ message: 'dario auth-check received this request — see the dario CLI output for the diagnostic.',
513
+ verdict,
514
+ }));
515
+ settle(result);
516
+ });
517
+ server.listen(0, '127.0.0.1', () => {
518
+ const port = server.address().port;
519
+ opts.onListening?.(port);
520
+ });
521
+ setTimeout(() => {
522
+ const port = server.address()?.port;
523
+ settle({
524
+ received: false,
525
+ port,
526
+ expected,
527
+ verdict: 'timeout',
528
+ diagnosis: diagnoseAuthCheck({ received: false, port, expected, verdict: 'timeout' }),
529
+ });
530
+ }, timeoutMs);
531
+ });
532
+ }
@@ -210,6 +210,7 @@ export declare function findInstalledCC(): {
210
210
  path: string | null;
211
211
  version: string | null;
212
212
  };
213
+ export declare function enumerateClaudeCandidates(): string[];
213
214
  /**
214
215
  * Given a captured /v1/messages request body, pull out the fields that
215
216
  * matter for template replay: agent identity, system prompt, tool list,
@@ -281,7 +282,7 @@ export declare function _resetInstalledVersionProbeForTest(): void;
281
282
  */
282
283
  export declare const SUPPORTED_CC_RANGE: {
283
284
  readonly min: "1.0.0";
284
- readonly maxTested: "2.1.118";
285
+ readonly maxTested: "2.1.119";
285
286
  };
286
287
  /**
287
288
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -449,30 +449,77 @@ function findClaudeBinary() {
449
449
  // non-standard installs.
450
450
  if (process.env.DARIO_CLAUDE_BIN)
451
451
  return process.env.DARIO_CLAUDE_BIN;
452
- // Try the obvious name. On Windows spawn resolves `.cmd` shims
453
- // automatically when shell:true, but we don't want shell:true for
454
- // safety. The `where` / `which` probe handles Windows via PATHEXT.
455
- const candidates = process.platform === 'win32'
456
- ? ['claude.cmd', 'claude.exe', 'claude']
457
- : ['claude'];
458
- for (const name of candidates) {
459
- if (existsOnPath(name))
460
- return name;
452
+ const candidates = enumerateClaudeCandidates();
453
+ if (candidates.length === 0)
454
+ return null;
455
+ if (candidates.length === 1)
456
+ return candidates[0];
457
+ // Multiple installs on PATH — common on Windows where an npm-wrapper
458
+ // (~/AppData/Roaming/npm/claude.cmd) coexists with a native install
459
+ // (~/.local/bin/claude.exe). Version-probe each and pick the newest.
460
+ // Falls back to the first candidate if no probe succeeds (e.g. every
461
+ // spawn fails on a sandboxed runtime).
462
+ const probed = [];
463
+ for (const path of candidates) {
464
+ const version = probeOneVersion(path);
465
+ if (version)
466
+ probed.push({ path, version });
461
467
  }
462
- return null;
468
+ if (probed.length === 0)
469
+ return candidates[0];
470
+ probed.sort((a, b) => compareVersions(b.version, a.version));
471
+ return probed[0].path;
463
472
  }
464
- function existsOnPath(name) {
473
+ // Exported for unit tests.
474
+ export function enumerateClaudeCandidates() {
465
475
  const pathEnv = process.env.PATH ?? '';
466
476
  const sep = process.platform === 'win32' ? ';' : ':';
467
477
  const dirs = pathEnv.split(sep).filter(Boolean);
478
+ // `.exe` first on Windows: the native binary beats a `.cmd` wrapper
479
+ // when both live in the same dir. Across dirs we version-probe anyway
480
+ // so order here only matters when probes all fail.
481
+ const names = process.platform === 'win32'
482
+ ? ['claude.exe', 'claude.cmd', 'claude']
483
+ : ['claude'];
484
+ const found = [];
485
+ const seen = new Set();
468
486
  for (const d of dirs) {
469
- try {
470
- if (existsSync(join(d, name)))
471
- return true;
487
+ for (const name of names) {
488
+ const full = join(d, name);
489
+ if (seen.has(full))
490
+ continue;
491
+ try {
492
+ if (existsSync(full)) {
493
+ seen.add(full);
494
+ found.push(full);
495
+ }
496
+ }
497
+ catch { /* noop */ }
472
498
  }
473
- catch { /* noop */ }
474
499
  }
475
- return false;
500
+ return found;
501
+ }
502
+ // Version-probe one specific binary path. Same safety logic as
503
+ // probeInstalledCCVersionUncached below (reject shell metacharacters in
504
+ // override paths before spawning with shell:true on Windows).
505
+ function probeOneVersion(bin) {
506
+ try {
507
+ const useShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(bin);
508
+ if (useShell && /[&|><^"'%\r\n`$;(){}[\]]/.test(bin))
509
+ return null;
510
+ const out = execFileSync(bin, ['--version'], {
511
+ encoding: 'utf-8',
512
+ timeout: 2_000,
513
+ stdio: ['ignore', 'pipe', 'ignore'],
514
+ windowsHide: true,
515
+ shell: useShell,
516
+ });
517
+ const m = /(\d+\.\d+\.\d+(?:[.\-][\w.\-]+)?)/.exec(out);
518
+ return m ? m[1] : null;
519
+ }
520
+ catch {
521
+ return null;
522
+ }
476
523
  }
477
524
  /**
478
525
  * Given a captured /v1/messages request body, pull out the fields that
@@ -730,7 +777,7 @@ export function _resetInstalledVersionProbeForTest() {
730
777
  */
731
778
  export const SUPPORTED_CC_RANGE = {
732
779
  min: '1.0.0',
733
- maxTested: '2.1.118',
780
+ maxTested: '2.1.119',
734
781
  };
735
782
  /**
736
783
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.31.5",
3
+ "version": "3.31.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": {