@askalf/dario 3.31.7 → 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.
@@ -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
@@ -648,6 +648,25 @@ async function help() {
648
648
  the server's verdict — the single reliable
649
649
  signal for scope-policy drift (dario#42/#71
650
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.
651
670
 
652
671
  Proxy options:
653
672
  --model=MODEL Force a model for all requests
@@ -947,13 +966,57 @@ async function mcp() {
947
966
  }
948
967
  }
949
968
  async function doctor() {
950
- const { runChecks, formatChecks, exitCodeFor } = await import('./doctor.js');
969
+ const { runChecks, formatChecks, formatChecksJson, exitCodeFor, runAuthCheck } = await import('./doctor.js');
951
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
+ }
952
1016
  console.log('');
953
1017
  console.log(' dario — Doctor');
954
1018
  console.log(' ─────────────');
955
1019
  console.log('');
956
- const checks = await runChecks({ probe });
957
1020
  console.log(formatChecks(checks));
958
1021
  console.log('');
959
1022
  const code = exitCodeFor(checks);
@@ -978,6 +1041,86 @@ async function version() {
978
1041
  console.log('unknown');
979
1042
  }
980
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
+ }
981
1124
  // Main
982
1125
  const commands = {
983
1126
  login,
@@ -991,6 +1134,8 @@ const commands = {
991
1134
  subagent,
992
1135
  mcp,
993
1136
  doctor,
1137
+ config,
1138
+ upgrade,
994
1139
  help,
995
1140
  version,
996
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,14 @@ 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;
34
42
  export interface RunChecksOptions {
35
43
  /**
36
44
  * Opt-in: hit Anthropic's authorize endpoint with the scope set dario
@@ -53,3 +61,48 @@ export interface RunChecksOptions {
53
61
  * the environment before the subsystems.
54
62
  */
55
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,10 @@ 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';
19
21
  import { detectCCOAuthConfig } from './cc-oauth-detect.js';
20
22
  import { runAuthorizeProbe } from './cc-authorize-probe.js';
21
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -43,6 +45,63 @@ export function formatChecks(checks) {
43
45
  export function exitCodeFor(checks) {
44
46
  return checks.some((c) => c.status === 'fail') ? 1 : 0;
45
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
+ }
46
105
  /**
47
106
  * Run every available health check. Never throws — each check is
48
107
  * individually try/caught so a broken subsystem (e.g. unreadable accounts
@@ -107,6 +166,24 @@ export async function runChecks(opts = {}) {
107
166
  label: 'CC binary',
108
167
  detail: `v${cc.version} at ${cc.path} (range: v${SUPPORTED_CC_RANGE.min} – v${SUPPORTED_CC_RANGE.maxTested})`,
109
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 */ }
110
187
  }
111
188
  else if (cc.path) {
112
189
  checks.push({
@@ -296,3 +373,160 @@ function safely(fn, fallback) {
296
373
  return fallback;
297
374
  }
298
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
+ }
@@ -282,7 +282,7 @@ export declare function _resetInstalledVersionProbeForTest(): void;
282
282
  */
283
283
  export declare const SUPPORTED_CC_RANGE: {
284
284
  readonly min: "1.0.0";
285
- readonly maxTested: "2.1.118";
285
+ readonly maxTested: "2.1.119";
286
286
  };
287
287
  /**
288
288
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -777,7 +777,7 @@ export function _resetInstalledVersionProbeForTest() {
777
777
  */
778
778
  export const SUPPORTED_CC_RANGE = {
779
779
  min: '1.0.0',
780
- maxTested: '2.1.118',
780
+ maxTested: '2.1.119',
781
781
  };
782
782
  /**
783
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.7",
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": {