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