@askalf/dario 6.0.12 → 6.0.13

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,1234 @@
1
+ /**
2
+ * dario doctor — health report aggregator.
3
+ *
4
+ * Runs every check we know how to run and returns a list of labelled
5
+ * results. The CLI passes the result list through `formatChecks` for
6
+ * display; `runChecks` is the I/O-heavy collector, `formatChecks` is a
7
+ * pure function the tests exercise directly.
8
+ *
9
+ * Keep `runChecks` defensive: a check that throws must not take the
10
+ * rest of the report down — every check is wrapped so a broken sub-
11
+ * system surfaces as `fail` instead of crashing the CLI.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { homedir, platform, arch, release } from 'node:os';
17
+ import { execFileSync } from 'node:child_process';
18
+ import { createServer } from 'node:http';
19
+ import { CC_TEMPLATE, resolveSystemPrompt, } from './cc-template.js';
20
+ import { describeTemplate, detectDrift, checkCCCompat, findInstalledCC, missingVariantFamilies, SUPPORTED_CC_RANGE, CURRENT_SCHEMA_VERSION, compareVersions, VARIANT_FAMILIES, } from './live-fingerprint.js';
21
+ import { detectCCOAuthConfig } from './cc-oauth-detect.js';
22
+ import { runAuthorizeProbe } from './cc-authorize-probe.js';
23
+ import { MIGRATED_LOGIN_ALIAS } from './accounts.js';
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ /**
26
+ * Decide what `doctor` should say about pool-exhaustion failover, from
27
+ * configuration alone. Pure and exported so every branch is testable — the
28
+ * live box can only ever exercise the one that matches its own credentials,
29
+ * which is how the inert case went unnoticed in the first place.
30
+ *
31
+ * This check exists because of a specific outage. The box ran with
32
+ * --pool-fallback armed, no Codex account and no api-key backend, so failover
33
+ * was INERT: correctly configured by every check that existed, and incapable of
34
+ * doing anything. On 2026-08-29 the Claude pool filled twice and the fleet went
35
+ * dark beside an idle ChatGPT subscription. Nothing reported it, because
36
+ * nothing asked "armed" and "has somewhere to go" as a single question.
37
+ *
38
+ * It reports configuration, never reachability. Claiming a route WORKS needs a
39
+ * live request, and this release was built on the lesson that a green config is
40
+ * not a working path.
41
+ */
42
+ export function failoverReadiness(input) {
43
+ const { chain, codexAccounts, backends } = input;
44
+ const hasCodex = codexAccounts > 0;
45
+ const hasBackend = backends.length > 0;
46
+ if (chain.length === 0) {
47
+ return { status: 'info', detail: 'off — a drained Claude pool returns 429/503 (--pool-fallback to arm)' };
48
+ }
49
+ if (!hasCodex && !hasBackend) {
50
+ return {
51
+ status: 'warn',
52
+ detail: `armed (${chain.join(' → ')}) but INERT — no Codex account and no backend to fall back to. `
53
+ + 'Add one: `dario add altman` (subscription) or `dario backend add …` (api key).',
54
+ };
55
+ }
56
+ if (hasCodex && chain.length > 1) {
57
+ return {
58
+ status: 'ok',
59
+ detail: `symmetric: ${chain.join(' → ')}, across ${codexAccounts} Codex account`
60
+ + `${codexAccounts === 1 ? '' : 's'}${hasBackend ? ` + ${backends.length} backend(s)` : ''}`,
61
+ };
62
+ }
63
+ if (hasCodex) {
64
+ return {
65
+ status: 'ok',
66
+ detail: `claude → codex as ${chain[0]} (both wire shapes). One-way — add a Claude model to `
67
+ + 'the chain (--pool-fallback=a,b) to cover a rate-limited ChatGPT plan too.',
68
+ };
69
+ }
70
+ return {
71
+ status: 'ok',
72
+ detail: `claude → ${backends[0]} as ${chain[0]} — OpenAI path only. A Codex account would `
73
+ + 'extend it to Anthropic-shape clients (Claude Code, agent runtimes).',
74
+ };
75
+ }
76
+ /**
77
+ * Format a epoch timestamp reset time relative to the current time.
78
+ * Returns a human-friendly string like "1h 9m", "45m", "2d 3h".
79
+ */
80
+ export function formatReset(resetEpochSecs, nowMs) {
81
+ const ms = (resetEpochSecs * 1000) - nowMs;
82
+ if (ms <= 0)
83
+ return '0m';
84
+ const totalMins = Math.round(ms / 60000);
85
+ if (totalMins <= 0)
86
+ return '0m';
87
+ const days = Math.floor(totalMins / 1440);
88
+ const hours = Math.floor((totalMins % 1440) / 60);
89
+ const mins = totalMins % 60;
90
+ if (days > 0) {
91
+ return `${days}d ${hours}h`;
92
+ }
93
+ if (hours > 0) {
94
+ return `${hours}h ${mins}m`;
95
+ }
96
+ return `${mins}m`;
97
+ }
98
+ /**
99
+ * Pretty-print a list of Check results as aligned ASCII. No color codes —
100
+ * Windows cmd / CI logs render plain text reliably; colors are a downside
101
+ * not an upside for a report that's often piped or pasted.
102
+ */
103
+ export function formatChecks(checks) {
104
+ const prefix = {
105
+ ok: '[ OK ]',
106
+ warn: '[WARN]',
107
+ fail: '[FAIL]',
108
+ info: '[INFO]',
109
+ };
110
+ const labelWidth = checks.reduce((n, c) => Math.max(n, c.label.length), 0);
111
+ const lines = checks.map((c) => ` ${prefix[c.status]} ${c.label.padEnd(labelWidth)} ${c.detail}`);
112
+ return lines.join('\n');
113
+ }
114
+ /**
115
+ * Derive a CLI exit code from a set of check results. Any `fail` → 1.
116
+ * `warn` alone does not fail — we don't want `dario doctor` to CI-fail
117
+ * a user's machine just because they're on an untested CC version.
118
+ */
119
+ export function exitCodeFor(checks) {
120
+ return checks.some((c) => c.status === 'fail') ? 1 : 0;
121
+ }
122
+ /**
123
+ * Serialize a check report as structured JSON. Lets other tools
124
+ * (claude-bridge's /status command, deepdive, CI scripts) consume
125
+ * dario's health programmatically instead of scraping the formatted
126
+ * text. Emitted by `dario doctor --json`.
127
+ */
128
+ export function formatChecksJson(checks) {
129
+ const summary = {
130
+ ok: checks.filter((c) => c.status === 'ok').length,
131
+ warn: checks.filter((c) => c.status === 'warn').length,
132
+ fail: checks.filter((c) => c.status === 'fail').length,
133
+ info: checks.filter((c) => c.status === 'info').length,
134
+ };
135
+ return JSON.stringify({
136
+ generatedAt: new Date().toISOString(),
137
+ exitCode: exitCodeFor(checks),
138
+ summary,
139
+ checks,
140
+ }, null, 2);
141
+ }
142
+ /**
143
+ * The OAuth doctor row, as a pure decision — mirrors checkIdentityDrift so the
144
+ * branch logic is unit-testable without touching the filesystem.
145
+ *
146
+ * WHY THIS EXISTS. The legacy `credentials.json` is not what serves once an
147
+ * account pool exists: dario#805 deliberately keeps a NEWER pool token and
148
+ * refuses to overwrite that file, so "credentials.json is stale" is an expected
149
+ * steady state — every recovery that restores a pool account leaves one behind.
150
+ * Reporting that as `OAuth expired` while the pool answers every request is a
151
+ * false alarm, and dario-doctor-watch opens an issue for it on EVERY run:
152
+ * dario#1105 was filed while a live probe was returning 200 on both Haiku and
153
+ * Sonnet. A watcher that cries wolf on a healthy proxy trains its reader to
154
+ * ignore it, which is the failure the watcher exists to prevent.
155
+ *
156
+ * So: a live pool overrides a dead legacy file (and says so, rather than hiding
157
+ * it), and only "nothing can serve" is reported as a failure.
158
+ */
159
+ export function oauthCheckRow(input) {
160
+ const { legacyStatus, legacyCanRefresh, poolHealthy, poolTotal } = input;
161
+ if (poolHealthy > 0) {
162
+ return {
163
+ status: 'ok',
164
+ label: 'OAuth',
165
+ detail: `pool credential live (${poolHealthy}/${poolTotal} account${poolTotal === 1 ? '' : 's'}) — ` +
166
+ `the legacy credentials.json is ${legacyStatus} and unused (dario#805)`,
167
+ };
168
+ }
169
+ return {
170
+ status: legacyStatus === 'expired' && legacyCanRefresh ? 'warn' : 'fail',
171
+ label: 'OAuth',
172
+ detail: legacyStatus === 'none' ? 'not authenticated — run `dario login`' : legacyStatus,
173
+ };
174
+ }
175
+ export function checkIdentityDrift(input) {
176
+ const { live, poolAccounts } = input;
177
+ // Short-prefix for surfaced IDs — 64-char userIDs and 36-char UUIDs are
178
+ // noisy in a doctor report. Operators only need enough to recognize / diff
179
+ // by eye; full values stay in the source files.
180
+ const shortId = (s) => (s ? `${s.slice(0, 8)}…` : '(empty)');
181
+ if (!live || (!live.deviceId && !live.accountUuid)) {
182
+ return [{
183
+ status: 'info',
184
+ label: 'Identity',
185
+ detail: 'no ~/.claude.json found — proxy will send requests without metadata.user_id, which routes them to Extra Usage billing instead of the Max plan allocation. Run Claude Code at least once to generate it.',
186
+ }];
187
+ }
188
+ if (poolAccounts.length === 0) {
189
+ return [{
190
+ status: 'info',
191
+ label: 'Identity',
192
+ detail: `~/.claude.json userID=${shortId(live.deviceId)} — no pool accounts snapshotted yet, so identity drift can't be checked. It starts once the login pool-of-one is materialized (\`dario login\` / \`dario proxy\`) or you \`dario accounts add\` more; until then a mismatch only surfaces as a 401 from Anthropic on non-Haiku models.`,
193
+ }];
194
+ }
195
+ const aligned = [];
196
+ const drifted = [];
197
+ const driftedAliases = [];
198
+ for (const acc of poolAccounts) {
199
+ const deviceMatch = acc.deviceId === live.deviceId;
200
+ const acctMatch = acc.accountUuid === live.accountUuid;
201
+ if (deviceMatch && acctMatch) {
202
+ aligned.push(acc.alias);
203
+ }
204
+ else {
205
+ const which = !deviceMatch && !acctMatch ? 'both' : !deviceMatch ? 'deviceId' : 'accountUuid';
206
+ drifted.push(`${acc.alias} (${which})`);
207
+ driftedAliases.push(acc.alias);
208
+ }
209
+ }
210
+ if (drifted.length === 0) {
211
+ return [{
212
+ status: 'ok',
213
+ label: 'Identity',
214
+ detail: `${aligned.length}/${poolAccounts.length} pool account${poolAccounts.length === 1 ? '' : 's'} match ~/.claude.json (userID=${shortId(live.deviceId)})`,
215
+ }];
216
+ }
217
+ // The remedy is deliberately NOT `accounts add <alias>`. That command
218
+ // exits 1 on an alias that already exists ("Remove it first"), and its
219
+ // default path runs a full OAuth browser flow rather than re-snapshotting
220
+ // — so naming it here walked every drifted user into a dead end. The two
221
+ // alias kinds genuinely need different commands: the reserved login alias
222
+ // is back-filled from whatever credentials are current, so removing it is
223
+ // enough (it re-materializes on the next `dario login` / `dario proxy`),
224
+ // while a user-added alias has to be removed and re-added, which re-runs
225
+ // OAuth for that account by design.
226
+ const loginDrifted = driftedAliases.includes(MIGRATED_LOGIN_ALIAS);
227
+ const otherDrifted = driftedAliases.filter((a) => a !== MIGRATED_LOGIN_ALIAS);
228
+ const fixes = [];
229
+ if (loginDrifted) {
230
+ fixes.push(`\`dario accounts remove ${MIGRATED_LOGIN_ALIAS}\` — it re-materializes from your ` +
231
+ `current credentials on the next \`dario login\` / \`dario proxy\``);
232
+ }
233
+ if (otherDrifted.length > 0) {
234
+ fixes.push(`\`dario accounts remove <alias>\` then \`dario accounts add <alias>\` to re-snapshot` +
235
+ `${otherDrifted.length === 1 ? '' : ' (for each of them)'} — the add re-runs OAuth for that account`);
236
+ }
237
+ return [{
238
+ status: 'warn',
239
+ label: 'Identity',
240
+ detail: `${drifted.length}/${poolAccounts.length} pool account${poolAccounts.length === 1 ? '' : 's'} drifted from ~/.claude.json (live userID=${shortId(live.deviceId)}): ${drifted.join('; ')} — non-Haiku requests on the drifted account(s) will 401. Fix: ${fixes.join('; ')}`,
241
+ }];
242
+ }
243
+ /**
244
+ * Ask npm for the latest @anthropic-ai/claude-code version. One 3s
245
+ * timeout; failures return null so doctor silently drops the check.
246
+ * Result is cached module-scoped so back-to-back doctor invocations
247
+ * (e.g. from a wrapping script) don't hammer the npm registry.
248
+ */
249
+ let _npmLatestCache = null;
250
+ const NPM_CACHE_TTL_MS = 60 * 1000;
251
+ export function probeNpmLatestCC() {
252
+ if (_npmLatestCache && Date.now() - _npmLatestCache.at < NPM_CACHE_TTL_MS) {
253
+ return _npmLatestCache.value;
254
+ }
255
+ let value = null;
256
+ try {
257
+ // `npm view <pkg> version` prints the version as a single line.
258
+ // 3s timeout keeps doctor responsive even with flaky network /
259
+ // corporate proxies; stdio ignores stderr so "npm notice" banners
260
+ // don't pollute stdout parsing.
261
+ const out = execFileSync('npm', ['view', '@anthropic-ai/claude-code', 'version'], {
262
+ encoding: 'utf-8',
263
+ timeout: 3_000,
264
+ stdio: ['ignore', 'pipe', 'ignore'],
265
+ windowsHide: true,
266
+ // npm ships as .cmd on Windows; execFile can't spawn it directly
267
+ // without shell:true. `npm` is not user-overridable here so the
268
+ // command-injection risk is nil.
269
+ shell: process.platform === 'win32',
270
+ });
271
+ const m = /(\d+\.\d+\.\d+(?:[.\-][\w.\-]+)?)/.exec(out);
272
+ value = m ? m[1] : null;
273
+ }
274
+ catch {
275
+ value = null;
276
+ }
277
+ _npmLatestCache = { value, at: Date.now() };
278
+ return value;
279
+ }
280
+ /**
281
+ * One representative model per family, shared by the `--usage` probe
282
+ * (Anthropic only returns a family's 7d bucket header on a request TO
283
+ * that family) and the `--obedience` probe (behavioral drift is
284
+ * per-family — the 2026-06-12 regression hit sonnet while haiku obeyed
285
+ * the identical merged body).
286
+ */
287
+ const PROBE_FAMILIES = [
288
+ { family: 'haiku', model: 'claude-haiku-4-5' },
289
+ { family: 'sonnet', model: 'claude-sonnet-5' },
290
+ { family: 'opus', model: 'claude-opus-5' },
291
+ { family: 'fable', model: 'claude-fable-5' },
292
+ ];
293
+ /**
294
+ * The client system prompt the `--obedience` probe sends. Deliberately
295
+ * trivial: any model that weighs client system text at all can comply,
296
+ * so a miss isolates "the client system prompt is being ignored" from
297
+ * "the instruction was too hard".
298
+ */
299
+ export const OBEDIENCE_SYSTEM_PROMPT = 'Reply with ONLY the word PONG. No other words, no punctuation, no formatting.';
300
+ /**
301
+ * Join the text blocks of a `/v1/messages` response body. Thinking
302
+ * blocks are excluded — adaptive thinking may prepend them and they are
303
+ * not part of what the client-facing instruction governs. A refusal
304
+ * (empty content) or a malformed body yields ''.
305
+ */
306
+ export function extractMessageText(body) {
307
+ const content = body?.content;
308
+ if (!Array.isArray(content))
309
+ return '';
310
+ return content
311
+ .filter((b) => b?.type === 'text' && typeof b.text === 'string')
312
+ .map((b) => b.text)
313
+ .join('')
314
+ .trim();
315
+ }
316
+ /**
317
+ * Verdict for one obedience reply. Lenient on case and a single trailing
318
+ * `.`/`!` — the drift class this detects is "the model ignored the client
319
+ * system prompt entirely" (it answers as the CC persona instead), and a
320
+ * stray "Pong!" is obedient in substance. Strict equality would file
321
+ * 6-hourly drift issues over punctuation sampling.
322
+ */
323
+ export function isObedientReply(text) {
324
+ return /^pong[.!]?$/i.test(text.trim());
325
+ }
326
+ /**
327
+ * Run every available health check. Never throws — each check is
328
+ * individually try/caught so a broken subsystem (e.g. unreadable accounts
329
+ * dir) shows up as a `fail` row instead of crashing the CLI.
330
+ *
331
+ * The order is curated — more fundamental checks first (Node, dario
332
+ * version, platform) so a reader scanning the output top-down sees
333
+ * the environment before the subsystems.
334
+ */
335
+ export async function runChecks(opts = {}) {
336
+ const checks = [];
337
+ // ---- dario version
338
+ try {
339
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
340
+ checks.push({ status: 'info', label: 'dario', detail: `v${pkg.version}` });
341
+ }
342
+ catch {
343
+ checks.push({ status: 'warn', label: 'dario', detail: 'package.json not readable — version unknown' });
344
+ }
345
+ // ---- Node
346
+ checks.push({
347
+ status: nodeStatus(),
348
+ label: 'Node',
349
+ detail: process.version,
350
+ });
351
+ // ---- Platform
352
+ checks.push({
353
+ status: 'info',
354
+ label: 'Platform',
355
+ detail: `${platform()} ${arch()} (${release()})`,
356
+ });
357
+ // ---- Runtime TLS fingerprint (v3.23, direction #3)
358
+ // Proxy mode terminates TLS in this process, so Bun-vs-Node is a
359
+ // fingerprint axis Anthropic can read directly off the wire.
360
+ try {
361
+ const { detectRuntimeFingerprint } = await import('./runtime-fingerprint.js');
362
+ const rt = detectRuntimeFingerprint();
363
+ const status = rt.status === 'bun-match' ? 'ok' : 'warn';
364
+ checks.push({
365
+ status,
366
+ label: 'Runtime / TLS',
367
+ detail: rt.hint ? `${rt.detail}. ${rt.hint}` : rt.detail,
368
+ });
369
+ }
370
+ catch (err) {
371
+ checks.push({
372
+ status: 'warn',
373
+ label: 'Runtime / TLS',
374
+ detail: `check failed: ${err.message}`,
375
+ });
376
+ }
377
+ // ---- CC binary
378
+ const cc = safely(() => findInstalledCC(), { path: null, version: null });
379
+ if (cc.path && cc.version) {
380
+ const compat = checkCCCompat(cc.version);
381
+ const status = compat.status === 'ok' ? 'ok' :
382
+ compat.status === 'untested-above' ? 'warn' :
383
+ compat.status === 'below-min' ? 'fail' :
384
+ 'warn';
385
+ checks.push({
386
+ status,
387
+ label: 'CC binary',
388
+ detail: `v${cc.version} at ${cc.path} (range: v${SUPPORTED_CC_RANGE.min} – v${SUPPORTED_CC_RANGE.maxTested})`,
389
+ });
390
+ // Stale-upstream probe: compare installed against npm's @latest.
391
+ // One network hop (3s timeout, 60s in-process cache). Silent on
392
+ // failure — no check row emitted — since a flaky network
393
+ // shouldn't turn doctor's output noisy. Only emits when the
394
+ // installed CC is strictly older than the npm latest.
395
+ try {
396
+ const npmLatest = probeNpmLatestCC();
397
+ if (npmLatest && compareVersions(cc.version, npmLatest) < 0) {
398
+ checks.push({
399
+ status: 'info',
400
+ label: 'CC upstream',
401
+ detail: `npm latest is v${npmLatest} — installed is v${cc.version}. ` +
402
+ `Run \`npm install -g @anthropic-ai/claude-code@latest\` to upgrade; ` +
403
+ `dario's template will re-capture automatically on next startup.`,
404
+ });
405
+ }
406
+ }
407
+ catch { /* silent */ }
408
+ }
409
+ else if (cc.path) {
410
+ checks.push({
411
+ status: 'warn',
412
+ label: 'CC binary',
413
+ detail: `found at ${cc.path} but --version didn't parse — compat unchecked`,
414
+ });
415
+ }
416
+ else {
417
+ // CC not installed locally is the correct, intended state for containerized
418
+ // deploys and CI runners — dario uses the bundled scrubbed template (whose
419
+ // freshness is surfaced by the separate "Template" row below). Marking
420
+ // this WARN scared container users into thinking something was broken;
421
+ // it's INFO with a hint pointing at the install upside instead.
422
+ checks.push({
423
+ status: 'info',
424
+ label: 'CC binary',
425
+ detail: 'not installed locally — dario uses the bundled template. (Install @anthropic-ai/claude-code if you want auto-refresh from your own CC binary.)',
426
+ });
427
+ }
428
+ // ---- Template source
429
+ try {
430
+ checks.push({
431
+ status: CC_TEMPLATE._source === 'live' ? 'ok' : 'info',
432
+ label: 'Template',
433
+ detail: `${describeTemplate(CC_TEMPLATE)} (schema v${CC_TEMPLATE._schemaVersion ?? '?'})`,
434
+ });
435
+ }
436
+ catch (err) {
437
+ checks.push({ status: 'fail', label: 'Template', detail: `load failed: ${err.message}` });
438
+ }
439
+ // ---- Per-model prompt variants (dario#lock-step)
440
+ // CC ships several model families a different system prompt than the shared
441
+ // base. A template missing a family's variant silently serves that family
442
+ // the base prompt — a wire-fidelity degradation with no other symptom
443
+ // (requests still 200). Known causes: a bundle baked while variant capture
444
+ // failed, or a pre-variants live cache shadowing the bundle.
445
+ try {
446
+ const missing = missingVariantFamilies(CC_TEMPLATE);
447
+ if (missing.length === 0) {
448
+ checks.push({
449
+ status: 'ok',
450
+ label: 'Prompt variants',
451
+ detail: `all ${VARIANT_FAMILIES.length} model families carried (${VARIANT_FAMILIES.map((f) => f.key).join(', ')})`,
452
+ });
453
+ }
454
+ else {
455
+ checks.push({
456
+ status: 'warn',
457
+ label: 'Prompt variants',
458
+ detail: `missing: ${missing.join(', ')} — those models get the shared base prompt, not CC's model-specific one. Re-bake the template, or remove a pre-variants live cache (~/.dario/cc-template.live.json) and restart.`,
459
+ });
460
+ }
461
+ }
462
+ catch (err) {
463
+ checks.push({ status: 'warn', label: 'Prompt variants', detail: `check failed: ${err.message}` });
464
+ }
465
+ // ---- Per-request overhead surfacing.
466
+ // The CC system prompt + tool definitions are injected into every
467
+ // non-passthrough request and dominate the input-token cost on small
468
+ // turns. Anthropic caches them after the first hit (cache_creation
469
+ // tokens on call 1, then cache_read on subsequent calls within the
470
+ // 5-minute TTL), but non-CC users routing heavy tooling get
471
+ // surprised by the first-request charge. Surface the size up front
472
+ // so they can plan.
473
+ //
474
+ // No token estimate — char counts and tool count are factual; the
475
+ // tokenizer ratio varies enough between prose and tool-schema JSON
476
+ // (compressible structural keys) that any single divisor is
477
+ // misleading. Operators who want the exact number can read it off
478
+ // their first request's `cache_creation_input_tokens` once the proxy
479
+ // is warm. `--usage` adds the live snapshot for those who want it.
480
+ try {
481
+ const promptChars = CC_TEMPLATE.system_prompt?.length ?? 0;
482
+ const toolCount = (CC_TEMPLATE.tools ?? []).length;
483
+ const toolChars = JSON.stringify(CC_TEMPLATE.tools ?? []).length;
484
+ if (promptChars > 0 || toolCount > 0) {
485
+ checks.push({
486
+ status: 'info',
487
+ label: 'Overhead',
488
+ detail: `${promptChars.toLocaleString()} chars system prompt + ${toolCount} tool defs ` +
489
+ `(${toolChars.toLocaleString()} chars JSON-serialized) injected per non-passthrough ` +
490
+ `request. Cached after first hit; read-cost only on subsequent calls within ` +
491
+ `the 5-minute TTL. Exact token count surfaces as cache_creation_input_tokens ` +
492
+ `on the first response (or run \`dario doctor --usage\`).`,
493
+ });
494
+ }
495
+ }
496
+ catch { /* don't let overhead reporting break the doctor */ }
497
+ // ---- System-prompt mode (v3.34.0)
498
+ // Surfaces the configured `--system-prompt` mode + the resulting char
499
+ // count delta vs CC verbatim. Read-only — does not run a request.
500
+ // Env-only path here so doctor can be invoked without a live proxy.
501
+ try {
502
+ const rawMode = process.env['DARIO_SYSTEM_PROMPT'];
503
+ if (rawMode && rawMode !== 'verbatim') {
504
+ const cc = CC_TEMPLATE.system_prompt ?? '';
505
+ let resolved;
506
+ if (rawMode === 'partial' || rawMode === 'aggressive') {
507
+ resolved = resolveSystemPrompt(rawMode);
508
+ }
509
+ else {
510
+ // file-path mode — doctor doesn't read files (might leak path),
511
+ // just report that custom mode is active.
512
+ resolved = '';
513
+ }
514
+ const isCustom = rawMode !== 'partial' && rawMode !== 'aggressive';
515
+ const detail = isCustom
516
+ ? `DARIO_SYSTEM_PROMPT=${rawMode} (custom file). Runtime path replaces system[2].text with file contents.`
517
+ : `DARIO_SYSTEM_PROMPT=${rawMode}. Strips ${(cc.length - resolved.length).toLocaleString()} chars from CC's ${cc.length.toLocaleString()}-char prompt. ` +
518
+ `See docs/research/system-prompt-classifier-study.md for the empirical validation that this slot is unfingerprinted by the billing classifier.`;
519
+ checks.push({ status: 'info', label: 'System-prompt mode', detail });
520
+ }
521
+ }
522
+ catch { /* never let prompt-mode reporting break the doctor */ }
523
+ // ---- Outbound proxy mode (v3.35.0)
524
+ // Surfaces whether `--upstream-proxy` / DARIO_UPSTREAM_PROXY is set.
525
+ // Doctor runs without a live proxy, so we read the env-var path only
526
+ // (the CLI flag's effect is in-process and not visible from doctor).
527
+ // Credentials in the URL are masked; only host:port is shown.
528
+ try {
529
+ const rawProxy = process.env['DARIO_UPSTREAM_PROXY'];
530
+ if (rawProxy && rawProxy.trim() !== '') {
531
+ let display = rawProxy;
532
+ try {
533
+ const u = new URL(rawProxy);
534
+ if (u.username)
535
+ u.username = '***';
536
+ if (u.password)
537
+ u.password = '***';
538
+ display = u.toString();
539
+ }
540
+ catch { /* leave raw if unparseable; CLI will error at startup */ }
541
+ checks.push({
542
+ status: 'info',
543
+ label: 'Outbound proxy',
544
+ detail: `DARIO_UPSTREAM_PROXY=${display}. Upstream fetches routed via this proxy; localhost calls bypass. Requires Bun runtime. See docs/vpn-routing.md.`,
545
+ });
546
+ }
547
+ }
548
+ catch { /* never let proxy reporting break the doctor */ }
549
+ // ---- Template drift
550
+ try {
551
+ const drift = detectDrift(CC_TEMPLATE);
552
+ const status = drift.installedVersion === null ? 'info' : drift.drifted ? 'warn' : 'ok';
553
+ checks.push({ status, label: 'Template drift', detail: drift.message });
554
+ }
555
+ catch (err) {
556
+ checks.push({ status: 'warn', label: 'Template drift', detail: `check failed: ${err.message}` });
557
+ }
558
+ void CURRENT_SCHEMA_VERSION; // keep the import load-bearing for future schema checks
559
+ // ---- OAuth
560
+ try {
561
+ const { getStatus } = await import('./oauth.js');
562
+ const s = await getStatus();
563
+ if (!s.authenticated) {
564
+ // What actually serves is the POOL, not the legacy credentials.json —
565
+ // so ask the pool before reporting an auth failure. See oauthCheckRow.
566
+ let poolHealthy = 0, poolTotal = 0;
567
+ try {
568
+ const { listAccountAliases, loadAllAccounts } = await import('./accounts.js');
569
+ if ((await listAccountAliases()).length > 0) {
570
+ const loaded = await loadAllAccounts();
571
+ const now = Date.now();
572
+ poolTotal = loaded.length;
573
+ poolHealthy = loaded.filter((a) => a.expiresAt > now).length;
574
+ }
575
+ }
576
+ catch { /* pool unreadable — oauthCheckRow falls back to the legacy verdict */ }
577
+ checks.push(oauthCheckRow({ legacyStatus: s.status, legacyCanRefresh: !!s.canRefresh, poolHealthy, poolTotal }));
578
+ }
579
+ else {
580
+ checks.push({ status: 'ok', label: 'OAuth', detail: `${s.status} (expires in ${s.expiresIn})` });
581
+ }
582
+ }
583
+ catch (err) {
584
+ checks.push({ status: 'warn', label: 'OAuth', detail: `check failed: ${err.message}` });
585
+ }
586
+ // ---- Authorize-URL probe (opt-in, --probe).
587
+ // One GET to the authorize endpoint with dario's effective OAuth config.
588
+ // This is the single reliable signal for the class of bug that broke
589
+ // #42 / #71 — Anthropic flipping server-side scope policy without
590
+ // changing the CC binary. The nightly probe in check-cc-authorize-
591
+ // probe.mjs hits Cloudflare challenges from CI IPs; running from a
592
+ // user's machine bypasses that. No PII leaves: the probe uses a
593
+ // fresh PKCE challenge and a dummy redirect_uri, and only reads the
594
+ // status code / Location header / response body markers.
595
+ if (opts.probe) {
596
+ try {
597
+ const cfg = await detectCCOAuthConfig();
598
+ const result = await runAuthorizeProbe({
599
+ clientId: cfg.clientId,
600
+ authorizeUrl: cfg.authorizeUrl,
601
+ scopes: cfg.scopes,
602
+ });
603
+ const status = result.verdict === 'accepted'
604
+ ? 'ok'
605
+ : result.verdict === 'rejected'
606
+ ? 'fail'
607
+ : 'warn';
608
+ const label = 'Authorize probe';
609
+ const summary = `${result.scopeCount}-scope ${result.verdict} — ${result.reason}`;
610
+ checks.push({ status, label, detail: summary });
611
+ if (result.verdict !== 'accepted') {
612
+ // On rejection: the URL is the one `accounts add` would open —
613
+ // surface it so the user can paste and diff against `claude
614
+ // /login`'s URL. On inconclusive (often Cloudflare from our
615
+ // fetch-based probe — CF challenges non-browser clients
616
+ // regardless of IP): the same URL pasted into the user's
617
+ // browser bypasses CF since a real browser passes the
618
+ // challenge. Either way, the URL is the actionable artifact.
619
+ checks.push({ status: 'info', label: 'Probe URL', detail: result.probedUrl });
620
+ }
621
+ }
622
+ catch (err) {
623
+ checks.push({
624
+ status: 'warn',
625
+ label: 'Authorize probe',
626
+ detail: `check failed: ${err.message}`,
627
+ });
628
+ }
629
+ }
630
+ // ---- Usage snapshot (opt-in, --usage).
631
+ // Fires one `POST /v1/messages` via the loaded OAuth (Haiku, max_tokens=1)
632
+ // to capture the current rate-limit snapshot including the per-model
633
+ // buckets Anthropic started carving around 2026-04-25. Surfaces the
634
+ // `All models` vs `Sonnet only` split the way the user dashboard does.
635
+ // Direct-to-Anthropic, not through the proxy — the proxy doesn't need
636
+ // to be running for `dario doctor --usage`.
637
+ if (opts.usage) {
638
+ try {
639
+ const { parseRateLimits } = await import('./pool.js');
640
+ const { billingBucketFromClaim } = await import('./analytics.js');
641
+ // Probe routing decision: Anthropic's subscription path rejects
642
+ // non-CC-shaped requests on Sonnet/Opus (returns 429 with no
643
+ // rate-limit headers). Haiku accepts the raw shape. So:
644
+ // - If a local `dario proxy` is listening, route through it —
645
+ // the proxy injects the full CC template and all three families
646
+ // succeed, giving us the _sonnet / _opus / _haiku per-model
647
+ // bucket headers on a single round trip each.
648
+ // - Else fall back to direct-to-Anthropic with Haiku only.
649
+ // Unified buckets surface but per-model buckets won't.
650
+ const dario_base = process.env.DARIO_TEST_URL || 'http://127.0.0.1:3456';
651
+ // The proxy validates Authorization against DARIO_API_KEY when set.
652
+ // Fall back to literal 'dario' (the documented loopback-only default)
653
+ // when unset so local-dev probes against a no-auth proxy continue to
654
+ // work. Without reading the env here, `dario doctor --usage` 401s on
655
+ // every deploy that sets a real auth secret — which is every prod
656
+ // deploy that follows the README's "non-loopback bind" guidance.
657
+ const dario_auth = process.env.DARIO_API_KEY || 'dario';
658
+ let probeEndpoint = `${dario_base}/v1/messages`;
659
+ let probeHeaders = {
660
+ 'content-type': 'application/json',
661
+ 'anthropic-version': '2023-06-01',
662
+ 'authorization': `Bearer ${dario_auth}`,
663
+ };
664
+ let proxyAvailable = false;
665
+ try {
666
+ const healthRes = await fetch(`${dario_base}/health`, { signal: AbortSignal.timeout(800) });
667
+ proxyAvailable = healthRes.ok;
668
+ }
669
+ catch { /* proxy not running */ }
670
+ if (!proxyAvailable) {
671
+ const { getAccessToken } = await import('./oauth.js');
672
+ const token = await getAccessToken();
673
+ probeEndpoint = 'https://api.anthropic.com/v1/messages';
674
+ probeHeaders = {
675
+ 'content-type': 'application/json',
676
+ 'anthropic-version': '2023-06-01',
677
+ 'anthropic-beta': 'oauth-2025-04-20',
678
+ 'authorization': `Bearer ${token}`,
679
+ };
680
+ checks.push({
681
+ status: 'info',
682
+ label: 'Usage probe',
683
+ detail: 'dario proxy not running — probing direct. Per-model buckets visible only when probing through a running proxy (start `dario proxy` in another terminal and re-run).',
684
+ });
685
+ }
686
+ // Probe each family in parallel. Anthropic only returns the
687
+ // per-model 7d bucket header on a request TO that family.
688
+ const probe = async (model) => {
689
+ const res = await fetch(probeEndpoint, {
690
+ method: 'POST',
691
+ headers: probeHeaders,
692
+ body: JSON.stringify({
693
+ model,
694
+ max_tokens: 1,
695
+ messages: [{ role: 'user', content: 'ok' }],
696
+ }),
697
+ signal: AbortSignal.timeout(15_000),
698
+ });
699
+ // Consume the body so the socket releases; we only care about headers.
700
+ await res.text().catch(() => '');
701
+ // Ignore 429/4xx snapshots without useful rate-limit headers.
702
+ if (!res.headers.get('anthropic-ratelimit-unified-status'))
703
+ return null;
704
+ return parseRateLimits(res.headers);
705
+ };
706
+ const results = await Promise.all(PROBE_FAMILIES.map(f => probe(f.model).catch(() => null)));
707
+ // Use the first non-null snapshot for the unified view — they
708
+ // should all agree on the unified buckets (same account, same moment).
709
+ const firstOk = results.find(s => s !== null);
710
+ if (!firstOk)
711
+ throw new Error('all probe requests failed');
712
+ const bucket = billingBucketFromClaim(firstOk.claim);
713
+ const pct = (n) => `${(n * 100).toFixed(1)}%`;
714
+ let reset5hStr = '';
715
+ let reset7dStr = '';
716
+ if (firstOk.reset > 0) {
717
+ const relativeReset = formatReset(firstOk.reset, Date.now());
718
+ if (firstOk.claim.startsWith('five_hour')) {
719
+ reset5hStr = ` • resets in ${relativeReset}`;
720
+ }
721
+ else if (firstOk.claim.startsWith('seven_day')) {
722
+ reset7dStr = ` • resets in ${relativeReset}`;
723
+ }
724
+ }
725
+ checks.push({
726
+ status: firstOk.util5h >= 0.90 ? 'warn' : 'ok',
727
+ label: 'Usage 5h (all)',
728
+ detail: `${pct(firstOk.util5h)} used • status=${firstOk.status}${reset5hStr} • claim=${firstOk.claim} (${bucket})`,
729
+ });
730
+ checks.push({
731
+ status: firstOk.util7d >= 0.90 ? 'warn' : 'ok',
732
+ label: 'Usage 7d (all)',
733
+ detail: `${pct(firstOk.util7d)} used${reset7dStr}`,
734
+ });
735
+ // Merge per-model buckets across all probes — each probe's response
736
+ // carries at most its own family bucket; union them for display.
737
+ const mergedPerModel = {};
738
+ for (const s of results) {
739
+ if (!s)
740
+ continue;
741
+ for (const [family, util] of Object.entries(s.perModel7d)) {
742
+ mergedPerModel[family] = util;
743
+ }
744
+ }
745
+ for (const [family, util] of Object.entries(mergedPerModel).sort()) {
746
+ const divergence = util - firstOk.util7d;
747
+ const marker = Math.abs(divergence) > 0.05
748
+ ? ` • Δ vs 7d(all): ${divergence >= 0 ? '+' : ''}${(divergence * 100).toFixed(1)}pp`
749
+ : '';
750
+ checks.push({
751
+ status: util >= 0.90 ? 'warn' : 'ok',
752
+ label: `Usage 7d (${family} only)`,
753
+ detail: `${pct(util)} used${marker}`,
754
+ });
755
+ }
756
+ if (firstOk.overageUtil > 0) {
757
+ checks.push({
758
+ status: firstOk.overageUtil >= 0.90 ? 'warn' : 'info',
759
+ label: 'Usage overage',
760
+ detail: `${pct(firstOk.overageUtil)} of configured monthly spend`,
761
+ });
762
+ }
763
+ }
764
+ catch (err) {
765
+ checks.push({
766
+ status: 'warn',
767
+ label: 'Usage snapshot',
768
+ detail: `probe failed: ${err.message}`,
769
+ });
770
+ }
771
+ }
772
+ // ---- Client-system obedience probe (opt-in, --obedience).
773
+ // dario#509 / the 2026-06-12 deepdive planner outage: Anthropic's serving
774
+ // side changed how claude-sonnet-4-6 weighed client system text merged
775
+ // after the CC persona in block 3 — wire bytes identical, every existing
776
+ // check green — and the only symptom was a downstream consumer failing to
777
+ // get the JSON its system prompt demanded. This probe asserts the property
778
+ // those checks miss: a model, reached THROUGH the proxy's template merge,
779
+ // actually follows a client-supplied system instruction.
780
+ //
781
+ // Per family: up to 3 attempts (tolerates sampling), pass on the first
782
+ // obedient reply. Families probe in parallel, so worst-case wall time is
783
+ // bounded by attempts × timeout, not by family count.
784
+ //
785
+ // Requires a running proxy: a direct-to-Anthropic raw client shape would
786
+ // not exercise the cc-template merge seam (and Sonnet/Opus reject non-CC
787
+ // shapes on the subscription path anyway).
788
+ //
789
+ // Verdict semantics (consumed by scripts/check-doctor-drift.mjs):
790
+ // fail = the model ANSWERED but ignored the instruction. Behavioral and
791
+ // upstream-influenced — investigate the system-prompt
792
+ // presentation/merge (CLIENT_SYSTEM_PREFACE, block-3 structure),
793
+ // NOT necessarily a dario bug, and don't start with version
794
+ // bisects (the 2026-06-12 incident burned hours on those; they
795
+ // were all negative because nothing on dario's side changed).
796
+ // warn = the probe never got an answer (network/429/5xx) — infra
797
+ // flake, not drift.
798
+ if (opts.obedience) {
799
+ const dario_base = process.env.DARIO_TEST_URL || 'http://127.0.0.1:3456';
800
+ // Same auth fallback as the --usage probe: the proxy validates
801
+ // Authorization against DARIO_API_KEY when set; literal 'dario' is the
802
+ // documented loopback-only default for no-auth local proxies.
803
+ const dario_auth = process.env.DARIO_API_KEY || 'dario';
804
+ let proxyUp = false;
805
+ try {
806
+ const healthRes = await fetch(`${dario_base}/health`, { signal: AbortSignal.timeout(800) });
807
+ proxyUp = healthRes.ok;
808
+ }
809
+ catch { /* proxy not running */ }
810
+ if (!proxyUp) {
811
+ checks.push({
812
+ status: 'info',
813
+ label: 'Obedience',
814
+ detail: 'dario proxy not running — skipped. The probe must route through the proxy to exercise the client-system merge; start `dario proxy` and re-run.',
815
+ });
816
+ }
817
+ else {
818
+ const ATTEMPTS = 3;
819
+ const probeFamily = async ({ family, model }) => {
820
+ let lastReply = '';
821
+ let lastErr = null;
822
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
823
+ try {
824
+ const res = await fetch(`${dario_base}/v1/messages`, {
825
+ method: 'POST',
826
+ headers: {
827
+ 'content-type': 'application/json',
828
+ 'anthropic-version': '2023-06-01',
829
+ 'authorization': `Bearer ${dario_auth}`,
830
+ },
831
+ body: JSON.stringify({
832
+ model,
833
+ max_tokens: 64,
834
+ system: OBEDIENCE_SYSTEM_PROMPT,
835
+ messages: [{ role: 'user', content: 'ping' }],
836
+ }),
837
+ signal: AbortSignal.timeout(30_000),
838
+ });
839
+ if (!res.ok) {
840
+ await res.text().catch(() => '');
841
+ lastErr = `HTTP ${res.status}`;
842
+ continue;
843
+ }
844
+ const body = await res.json().catch(() => null);
845
+ const text = extractMessageText(body);
846
+ if (isObedientReply(text)) {
847
+ return {
848
+ status: 'ok',
849
+ label: `Obedience (${family})`,
850
+ detail: `"${text}" (attempt ${attempt}/${ATTEMPTS})`,
851
+ };
852
+ }
853
+ // A completed-but-disobedient reply (including a refusal's
854
+ // empty content) is the drift signal — remember it so it
855
+ // dominates over any later transport error.
856
+ lastReply = text;
857
+ lastErr = null;
858
+ }
859
+ catch (err) {
860
+ lastErr = err.message;
861
+ }
862
+ }
863
+ if (lastErr !== null && lastReply === '') {
864
+ return {
865
+ status: 'warn',
866
+ label: `Obedience (${family})`,
867
+ detail: `probe could not complete (${lastErr}) — infra flake, not behavioral drift`,
868
+ };
869
+ }
870
+ return {
871
+ status: 'fail',
872
+ label: `Obedience (${family})`,
873
+ detail: `model answered but ignored the client system prompt (last reply: "${lastReply.slice(0, 80)}") — behavioral, upstream-influenced; investigate presentation/merge (CLIENT_SYSTEM_PREFACE seam), not necessarily a dario bug`,
874
+ };
875
+ };
876
+ const rows = await Promise.all(PROBE_FAMILIES.map((f) => probeFamily(f)));
877
+ checks.push(...rows);
878
+ }
879
+ }
880
+ // ---- Account pool
881
+ try {
882
+ const { listAccountAliases, loadAllAccounts } = await import('./accounts.js');
883
+ const aliases = await listAccountAliases();
884
+ if (aliases.length === 0) {
885
+ // Pool-as-primitive (v5.0): no accounts/ entries. Either not logged in,
886
+ // or a pre-v5 `dario login` whose credentials.json hasn't been back-filled
887
+ // into the pool yet — that happens on the next `dario login` / `dario proxy`.
888
+ const { loadCredentials } = await import('./oauth.js');
889
+ const creds = await loadCredentials();
890
+ if (creds?.claudeAiOauth?.accessToken) {
891
+ checks.push({ status: 'info', label: 'Pool', detail: 'pool of 1 (login credentials present, not yet materialized — runs on next `dario login` or `dario proxy`); `dario accounts add <alias>` adds more for headroom routing' });
892
+ }
893
+ else {
894
+ checks.push({ status: 'info', label: 'Pool', detail: 'empty — run `dario login` (a pool of one) or `dario accounts add <alias>`' });
895
+ }
896
+ }
897
+ else {
898
+ const loaded = await loadAllAccounts();
899
+ const now = Date.now();
900
+ const expired = loaded.filter((a) => a.expiresAt <= now).length;
901
+ checks.push({
902
+ status: expired > 0 ? 'warn' : 'ok',
903
+ label: 'Pool',
904
+ detail: `pool of ${aliases.length}` +
905
+ (expired > 0 ? `, ${expired} expired` : '') +
906
+ (aliases.length === 1 ? ' (a pool of one — `dario accounts add <alias>` to load-balance)' : ''),
907
+ });
908
+ // Next-account-in-rotation surfacing. The proxy's per-request
909
+ // selector picks by max headroom (with 7d_<family> per-model
910
+ // bucket considered when a request's model family is known);
911
+ // doctor doesn't know the next request's model so it reports
912
+ // the family-agnostic pick. That's still the right preview for
913
+ // operators wondering "if I send a request right now, which
914
+ // account gets it?" — it matches `pool.select()` with no family
915
+ // hint, the same call the proxy uses when no model is parsed
916
+ // yet (e.g. on misshapen requests). Shown for a pool of one too
917
+ // (v5.0): it confirms the sole account is eligible, not rejected.
918
+ if (aliases.length >= 1) {
919
+ try {
920
+ const { AccountPool } = await import('./pool.js');
921
+ const pool = new AccountPool();
922
+ for (const acc of loaded) {
923
+ pool.add(acc.alias, {
924
+ accessToken: acc.accessToken,
925
+ refreshToken: acc.refreshToken,
926
+ expiresAt: acc.expiresAt,
927
+ deviceId: acc.deviceId,
928
+ accountUuid: acc.accountUuid,
929
+ });
930
+ }
931
+ const next = pool.select();
932
+ const ps = pool.status();
933
+ checks.push({
934
+ status: 'info',
935
+ label: 'Pool routing',
936
+ detail: next
937
+ ? `next: ${next.alias} (max-headroom select; ${ps.healthy}/${ps.accounts} healthy)`
938
+ : `no eligible account — all rejected or near-expiry (${ps.exhausted}/${ps.accounts} exhausted)`,
939
+ });
940
+ }
941
+ catch (err) {
942
+ checks.push({ status: 'warn', label: 'Pool routing', detail: `check failed: ${err.message}` });
943
+ }
944
+ }
945
+ }
946
+ }
947
+ catch (err) {
948
+ checks.push({ status: 'warn', label: 'Pool', detail: `check failed: ${err.message}` });
949
+ }
950
+ // ---- Live session state (only observable from a running proxy)
951
+ // Session/sticky counts live in the proxy's memory, so — unlike the
952
+ // local-state checks above — this reads them off /health (internal-disclosure
953
+ // caller, so it must reach dario on loopback). Silent skip when no proxy is
954
+ // up: the counts don't exist, and doctor is often run because it's down.
955
+ try {
956
+ const darioBase = process.env.DARIO_TEST_URL || 'http://127.0.0.1:3456';
957
+ const healthRes = await fetch(`${darioBase}/health`, { signal: AbortSignal.timeout(800) });
958
+ if (healthRes.ok) {
959
+ const health = (await healthRes.json().catch(() => null));
960
+ const sx = health?.sessions;
961
+ if (sx && typeof sx.mode === 'string') {
962
+ const detail = sx.mode === 'pool'
963
+ ? `${sx.stickyBindings ?? 0} sticky binding${sx.stickyBindings === 1 ? '' : 's'} — conversation→account affinity, lazily reaped (6h idle TTL, cap 2000)`
964
+ : `${sx.active ?? 0} active session id${sx.active === 1 ? '' : 's'} — lazily reaped (LRU cap 1024, no background sweeper)`;
965
+ checks.push({ status: 'info', label: 'Sessions', detail });
966
+ }
967
+ }
968
+ }
969
+ catch { /* proxy not running — live session state unavailable, skip */ }
970
+ // ---- Identity drift (pool account snapshot vs live ~/.claude.json)
971
+ try {
972
+ const { detectClaudeIdentity, listAccountAliases, loadAllAccounts } = await import('./accounts.js');
973
+ const live = await detectClaudeIdentity();
974
+ const aliases = await listAccountAliases();
975
+ const loaded = aliases.length ? await loadAllAccounts() : [];
976
+ const driftChecks = checkIdentityDrift({
977
+ live,
978
+ poolAccounts: loaded.map((a) => ({
979
+ alias: a.alias,
980
+ deviceId: a.deviceId,
981
+ accountUuid: a.accountUuid,
982
+ })),
983
+ });
984
+ for (const c of driftChecks)
985
+ checks.push(c);
986
+ }
987
+ catch (err) {
988
+ checks.push({ status: 'warn', label: 'Identity', detail: `check failed: ${err.message}` });
989
+ }
990
+ // ---- Secondary backends
991
+ try {
992
+ const { listBackends } = await import('./openai-backend.js');
993
+ const backends = await listBackends();
994
+ checks.push({
995
+ status: 'info',
996
+ label: 'Backends',
997
+ detail: backends.length === 0
998
+ ? 'none configured (Claude subscription is the only route)'
999
+ : `${backends.length} configured: ${backends.map((b) => b.name).join(', ')}`,
1000
+ });
1001
+ }
1002
+ catch (err) {
1003
+ checks.push({ status: 'warn', label: 'Backends', detail: `check failed: ${err.message}` });
1004
+ }
1005
+ // ---- Failover readiness (v6.0.0) — see failoverReadiness() for the why.
1006
+ try {
1007
+ const { loadConfig } = await import('./config-file.js');
1008
+ const { listCodexAccountAliases } = await import('./codex-accounts.js');
1009
+ const { listBackends } = await import('./openai-backend.js');
1010
+ const cfg = loadConfig().config;
1011
+ const raw = (process.env.DARIO_POOL_FALLBACK ?? cfg.poolFallback?.model ?? '').trim();
1012
+ const chain = raw.split(',').map((m) => m.trim()).filter(Boolean);
1013
+ const codexAliases = await listCodexAccountAliases().catch(() => []);
1014
+ const backends = await listBackends().catch(() => []);
1015
+ const verdict = failoverReadiness({
1016
+ chain,
1017
+ codexAccounts: codexAliases.length,
1018
+ backends: backends.map((b) => b.name),
1019
+ });
1020
+ checks.push({ status: verdict.status, label: 'Failover', detail: verdict.detail });
1021
+ }
1022
+ catch (err) {
1023
+ checks.push({ status: 'warn', label: 'Failover', detail: `check failed: ${err.message}` });
1024
+ }
1025
+ // ---- CC sub-agent (v3.26, direction #2)
1026
+ try {
1027
+ const { loadSubagentStatus } = await import('./subagent.js');
1028
+ const s = loadSubagentStatus();
1029
+ if (!s.agentsDirExists) {
1030
+ checks.push({ status: 'info', label: 'Sub-agent', detail: 'not installed (~/.claude/agents missing — Claude Code not installed?)' });
1031
+ }
1032
+ else if (!s.installed) {
1033
+ checks.push({ status: 'info', label: 'Sub-agent', detail: 'not installed — run `dario subagent install` to enable CC integration' });
1034
+ }
1035
+ else if (!s.current) {
1036
+ checks.push({
1037
+ status: 'warn',
1038
+ label: 'Sub-agent',
1039
+ detail: `installed v${s.fileVersion ?? 'unknown'}, does not match this dario — run \`dario subagent install\` to refresh`,
1040
+ });
1041
+ }
1042
+ else {
1043
+ checks.push({ status: 'ok', label: 'Sub-agent', detail: `installed v${s.fileVersion} at ${s.path}` });
1044
+ }
1045
+ }
1046
+ catch (err) {
1047
+ checks.push({ status: 'warn', label: 'Sub-agent', detail: `check failed: ${err.message}` });
1048
+ }
1049
+ // ---- ~/.dario dir
1050
+ try {
1051
+ const home = join(homedir(), '.dario');
1052
+ checks.push({ status: 'info', label: 'Home', detail: home });
1053
+ }
1054
+ catch {
1055
+ // never fails in practice — homedir() is always defined on supported platforms
1056
+ }
1057
+ return checks;
1058
+ }
1059
+ function nodeStatus() {
1060
+ const m = /^v(\d+)\./.exec(process.version);
1061
+ const major = m ? parseInt(m[1], 10) : 0;
1062
+ // engines: >=18 (see package.json). 18/20 are current supported Node LTS
1063
+ // lines — anything below 18 fails; above is ok.
1064
+ if (major >= 18)
1065
+ return 'ok';
1066
+ if (major === 0)
1067
+ return 'warn';
1068
+ return 'fail';
1069
+ }
1070
+ function safely(fn, fallback) {
1071
+ try {
1072
+ return fn();
1073
+ }
1074
+ catch {
1075
+ return fallback;
1076
+ }
1077
+ }
1078
+ // Exported for unit tests.
1079
+ export function redactSecret(value) {
1080
+ if (value.length <= 8)
1081
+ return `<${value.length} chars>`;
1082
+ return `${value.slice(0, 4)}…${value.slice(-4)} (length ${value.length})`;
1083
+ }
1084
+ // Exported for unit tests. Pure — takes the two headers + the expected
1085
+ // key, returns the classification. Separated from the HTTP dance so
1086
+ // tests can drive synthetic inputs without binding a socket.
1087
+ export function classifyAuthHeaders(headers, expected) {
1088
+ const xRaw = headers['x-api-key'];
1089
+ const aRaw = headers['authorization'];
1090
+ const xVal = Array.isArray(xRaw) ? xRaw[0] : xRaw;
1091
+ const aVal = Array.isArray(aRaw) ? aRaw[0] : aRaw;
1092
+ const xApiKey = { present: xVal !== undefined };
1093
+ if (xVal !== undefined) {
1094
+ xApiKey.length = xVal.length;
1095
+ xApiKey.redacted = redactSecret(xVal);
1096
+ xApiKey.matches = xVal === expected;
1097
+ }
1098
+ const authorization = { present: aVal !== undefined };
1099
+ if (aVal !== undefined) {
1100
+ authorization.length = aVal.length;
1101
+ authorization.bearerPrefix = /^Bearer\s+/i.test(aVal);
1102
+ const stripped = aVal.replace(/^Bearer\s+/i, '');
1103
+ authorization.redacted = redactSecret(stripped);
1104
+ authorization.matches = stripped === expected;
1105
+ }
1106
+ let verdict;
1107
+ if (!xApiKey.present && !authorization.present) {
1108
+ verdict = 'no-auth-header';
1109
+ }
1110
+ else if (xApiKey.matches === true || authorization.matches === true) {
1111
+ verdict = 'match';
1112
+ }
1113
+ else {
1114
+ verdict = 'mismatch';
1115
+ }
1116
+ return { xApiKey, authorization, verdict };
1117
+ }
1118
+ function diagnoseAuthCheck(result) {
1119
+ switch (result.verdict) {
1120
+ case 'match':
1121
+ return `client auth matches DARIO_API_KEY. A real dario proxy would accept this request.`;
1122
+ case 'mismatch': {
1123
+ const parts = [];
1124
+ if (result.authorization?.present) {
1125
+ const bearer = result.authorization.bearerPrefix ? ' (Bearer prefix present)' : ' (Bearer prefix missing)';
1126
+ parts.push(`Authorization header${bearer}: value ${result.authorization.redacted} — does NOT match expected ${redactSecret(result.expected)}.`);
1127
+ }
1128
+ if (result.xApiKey?.present) {
1129
+ parts.push(`x-api-key header: value ${result.xApiKey.redacted} — does NOT match expected ${redactSecret(result.expected)}.`);
1130
+ }
1131
+ const hint = suggestAuthFix(result);
1132
+ return parts.join(' ') + (hint ? ' ' + hint : '');
1133
+ }
1134
+ case 'no-auth-header':
1135
+ return (`client sent no x-api-key and no Authorization header. ` +
1136
+ `Expected ${redactSecret(result.expected)} in either. ` +
1137
+ `Set ANTHROPIC_API_KEY=${result.expected} in your client's environment, ` +
1138
+ `or use your tool's own "API key" config field if it has one.`);
1139
+ case 'timeout':
1140
+ return (`no request received within the timeout. Did your client target the ` +
1141
+ `port printed above? If the client uses a base URL you configured ` +
1142
+ `elsewhere, point it at the --auth-check listener for this one request.`);
1143
+ case 'no-enforcement':
1144
+ return (`DARIO_API_KEY is not set — dario does not enforce auth on loopback ` +
1145
+ `by default, so any request would be allowed through. To test auth ` +
1146
+ `enforcement, set DARIO_API_KEY=your-secret before running --auth-check.`);
1147
+ }
1148
+ }
1149
+ /** Pattern-match common failure modes for a sharper hint. */
1150
+ function suggestAuthFix(result) {
1151
+ const auth = result.authorization;
1152
+ const exp = result.expected;
1153
+ // Authorization value looks like a real Anthropic key — very common
1154
+ // pattern: client has one stashed from an earlier setup (OpenClaw's
1155
+ // auth-profiles.json, ANTHROPIC_API_KEY env, config file) and it's
1156
+ // shadowing the intended "dario" value. dario#97 exactly.
1157
+ if (auth?.present && auth.redacted?.startsWith('sk-a')) {
1158
+ return (`The value your client sent looks like a real Anthropic API key ` +
1159
+ `(starts with "sk-a…"). Your client has that key configured somewhere ` +
1160
+ `(auth-profiles.json, ANTHROPIC_API_KEY env, client config file) and it's ` +
1161
+ `overriding "${exp}". Either replace it with "${exp}" or bypass auth entirely ` +
1162
+ `by running dario on --host=127.0.0.1 without DARIO_API_KEY set.`);
1163
+ }
1164
+ // Authorization with no "Bearer " prefix: some clients just set the
1165
+ // header value raw.
1166
+ if (auth?.present && auth.bearerPrefix === false) {
1167
+ return (`The Authorization header is missing the "Bearer " prefix. Most ` +
1168
+ `HTTP client libraries want "Bearer <key>" as one value — yours seems ` +
1169
+ `to be setting the key directly. Check your client's auth config.`);
1170
+ }
1171
+ return null;
1172
+ }
1173
+ /**
1174
+ * Listen for one inbound request on a random loopback port, classify
1175
+ * whatever auth headers it carries against `DARIO_API_KEY`, return a
1176
+ * structured result. Sends 200 / 401 to the inbound request so the
1177
+ * client doesn't hang, then closes. This is a probe — it does not
1178
+ * proxy, does not log, does not persist.
1179
+ */
1180
+ export async function runAuthCheck(opts = {}) {
1181
+ const timeoutMs = opts.timeoutMs ?? 30_000;
1182
+ const expected = opts.expectedKey ?? process.env.DARIO_API_KEY ?? '';
1183
+ if (!expected) {
1184
+ return {
1185
+ received: false,
1186
+ expected: '<unset>',
1187
+ verdict: 'no-enforcement',
1188
+ diagnosis: diagnoseAuthCheck({ received: false, expected: '<unset>', verdict: 'no-enforcement' }),
1189
+ };
1190
+ }
1191
+ return new Promise((resolve) => {
1192
+ let settled = false;
1193
+ const settle = (result) => {
1194
+ if (settled)
1195
+ return;
1196
+ settled = true;
1197
+ server.close(() => resolve(result));
1198
+ };
1199
+ const server = createServer((req, res) => {
1200
+ const { xApiKey, authorization, verdict } = classifyAuthHeaders(req.headers, expected);
1201
+ const port = server.address()?.port;
1202
+ const result = {
1203
+ received: true,
1204
+ port,
1205
+ expected,
1206
+ xApiKey,
1207
+ authorization,
1208
+ verdict,
1209
+ diagnosis: '',
1210
+ };
1211
+ result.diagnosis = diagnoseAuthCheck(result);
1212
+ res.writeHead(verdict === 'match' ? 200 : 401, { 'content-type': 'application/json' });
1213
+ res.end(JSON.stringify({
1214
+ message: 'dario auth-check received this request — see the dario CLI output for the diagnostic.',
1215
+ verdict,
1216
+ }));
1217
+ settle(result);
1218
+ });
1219
+ server.listen(0, '127.0.0.1', () => {
1220
+ const port = server.address().port;
1221
+ opts.onListening?.(port);
1222
+ });
1223
+ setTimeout(() => {
1224
+ const port = server.address()?.port;
1225
+ settle({
1226
+ received: false,
1227
+ port,
1228
+ expected,
1229
+ verdict: 'timeout',
1230
+ diagnosis: diagnoseAuthCheck({ received: false, port, expected, verdict: 'timeout' }),
1231
+ });
1232
+ }, timeoutMs);
1233
+ });
1234
+ }