@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.
- package/dist/doctor-core.d.ts +239 -0
- package/dist/doctor-core.js +1234 -0
- package/dist/doctor-serving.d.ts +7 -0
- package/dist/doctor-serving.js +17 -0
- package/dist/doctor.d.ts +17 -234
- package/dist/doctor.js +32 -1226
- package/dist/serving-probe.d.ts +7 -92
- package/dist/serving-probe.js +34 -112
- package/dist/upstream-rejection.d.ts +6 -2
- package/dist/upstream-rejection.js +26 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|