@askalf/dario 3.31.4 → 3.31.7
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-template-data.json +5 -5
- package/dist/cli.js +10 -1
- package/dist/doctor.d.ts +13 -1
- package/dist/doctor.js +47 -1
- package/dist/live-fingerprint.d.ts +1 -0
- package/dist/live-fingerprint.js +63 -16
- 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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_version": "2.1.
|
|
3
|
-
"_captured": "2026-04-
|
|
2
|
+
"_version": "2.1.118",
|
|
3
|
+
"_captured": "2026-04-23T15:14:02.377Z",
|
|
4
4
|
"_source": "bundled",
|
|
5
5
|
"_schemaVersion": 3,
|
|
6
6
|
"agent_identity": "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
|
|
@@ -622,7 +622,7 @@
|
|
|
622
622
|
},
|
|
623
623
|
{
|
|
624
624
|
"name": "Read",
|
|
625
|
-
"description": "Reads a file from the local filesystem. You can access any file directly by using this tool.\nAssume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n- The file_path parameter must be an absolute path, not a relative path\n- By default, it reads up to 2000 lines starting from the beginning of the file\n- When you already know which part of the file you need, only read that part. This can be important for larger files.\n- Results are returned using cat -n format, with line numbers starting at 1\n- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.\n- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: \"1-5\"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.\n- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.\n- This tool can only read files, not directories. To
|
|
625
|
+
"description": "Reads a file from the local filesystem. You can access any file directly by using this tool.\nAssume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n- The file_path parameter must be an absolute path, not a relative path\n- By default, it reads up to 2000 lines starting from the beginning of the file\n- When you already know which part of the file you need, only read that part. This can be important for larger files.\n- Results are returned using cat -n format, with line numbers starting at 1\n- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.\n- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: \"1-5\"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.\n- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.\n- This tool can only read files, not directories. To list files in a directory, use the registered shell tool.\n- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.\n- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.",
|
|
626
626
|
"input_schema": {
|
|
627
627
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
628
628
|
"type": "object",
|
|
@@ -973,7 +973,7 @@
|
|
|
973
973
|
"anthropic_beta": "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,effort-2025-11-24,afk-mode-2026-01-31",
|
|
974
974
|
"header_values": {
|
|
975
975
|
"accept": "application/json",
|
|
976
|
-
"user-agent": "claude-cli/2.1.
|
|
976
|
+
"user-agent": "claude-cli/2.1.118 (external, sdk-cli)",
|
|
977
977
|
"x-stainless-arch": "x64",
|
|
978
978
|
"x-stainless-lang": "js",
|
|
979
979
|
"x-stainless-os": "Windows",
|
|
@@ -998,5 +998,5 @@
|
|
|
998
998
|
"output_config",
|
|
999
999
|
"stream"
|
|
1000
1000
|
],
|
|
1001
|
-
"_supportedMaxTested": "2.1.
|
|
1001
|
+
"_supportedMaxTested": "2.1.118"
|
|
1002
1002
|
}
|
package/dist/cli.js
CHANGED
|
@@ -643,6 +643,11 @@ 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.
|
|
646
651
|
|
|
647
652
|
Proxy options:
|
|
648
653
|
--model=MODEL Force a model for all requests
|
|
@@ -943,16 +948,20 @@ async function mcp() {
|
|
|
943
948
|
}
|
|
944
949
|
async function doctor() {
|
|
945
950
|
const { runChecks, formatChecks, exitCodeFor } = await import('./doctor.js');
|
|
951
|
+
const probe = args.includes('--probe');
|
|
946
952
|
console.log('');
|
|
947
953
|
console.log(' dario — Doctor');
|
|
948
954
|
console.log(' ─────────────');
|
|
949
955
|
console.log('');
|
|
950
|
-
const checks = await runChecks();
|
|
956
|
+
const checks = await runChecks({ probe });
|
|
951
957
|
console.log(formatChecks(checks));
|
|
952
958
|
console.log('');
|
|
953
959
|
const code = exitCodeFor(checks);
|
|
954
960
|
if (code !== 0) {
|
|
955
961
|
console.log(' One or more checks failed. Address the [FAIL] rows and re-run `dario doctor`.');
|
|
962
|
+
if (!probe) {
|
|
963
|
+
console.log(' For a live check against Anthropic\'s authorize endpoint, re-run with `--probe`.');
|
|
964
|
+
}
|
|
956
965
|
console.log('');
|
|
957
966
|
}
|
|
958
967
|
process.exit(code);
|
package/dist/doctor.d.ts
CHANGED
|
@@ -31,6 +31,18 @@ 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
|
+
export interface RunChecksOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Opt-in: hit Anthropic's authorize endpoint with the scope set dario
|
|
37
|
+
* would use on `accounts add`, and surface the server's verdict as a
|
|
38
|
+
* check row. Default off — `dario doctor` without `--probe` is a
|
|
39
|
+
* read-only local scan, no outbound traffic beyond what the other
|
|
40
|
+
* checks already make (OAuth token refresh, CC binary version probe,
|
|
41
|
+
* npm drift check). Enable with `dario doctor --probe`; costs one
|
|
42
|
+
* GET to `claude.ai` and runs in parallel with the other checks.
|
|
43
|
+
*/
|
|
44
|
+
probe?: boolean;
|
|
45
|
+
}
|
|
34
46
|
/**
|
|
35
47
|
* Run every available health check. Never throws — each check is
|
|
36
48
|
* individually try/caught so a broken subsystem (e.g. unreadable accounts
|
|
@@ -40,4 +52,4 @@ export declare function exitCodeFor(checks: Check[]): number;
|
|
|
40
52
|
* version, platform) so a reader scanning the output top-down sees
|
|
41
53
|
* the environment before the subsystems.
|
|
42
54
|
*/
|
|
43
|
-
export declare function runChecks(): Promise<Check[]>;
|
|
55
|
+
export declare function runChecks(opts?: RunChecksOptions): Promise<Check[]>;
|
package/dist/doctor.js
CHANGED
|
@@ -16,6 +16,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import { homedir, platform, arch, release } from 'node:os';
|
|
17
17
|
import { CC_TEMPLATE, } from './cc-template.js';
|
|
18
18
|
import { describeTemplate, detectDrift, checkCCCompat, findInstalledCC, SUPPORTED_CC_RANGE, CURRENT_SCHEMA_VERSION, } from './live-fingerprint.js';
|
|
19
|
+
import { detectCCOAuthConfig } from './cc-oauth-detect.js';
|
|
20
|
+
import { runAuthorizeProbe } from './cc-authorize-probe.js';
|
|
19
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
22
|
/**
|
|
21
23
|
* Pretty-print a list of Check results as aligned ASCII. No color codes —
|
|
@@ -50,7 +52,7 @@ export function exitCodeFor(checks) {
|
|
|
50
52
|
* version, platform) so a reader scanning the output top-down sees
|
|
51
53
|
* the environment before the subsystems.
|
|
52
54
|
*/
|
|
53
|
-
export async function runChecks() {
|
|
55
|
+
export async function runChecks(opts = {}) {
|
|
54
56
|
const checks = [];
|
|
55
57
|
// ---- dario version
|
|
56
58
|
try {
|
|
@@ -159,6 +161,50 @@ export async function runChecks() {
|
|
|
159
161
|
catch (err) {
|
|
160
162
|
checks.push({ status: 'warn', label: 'OAuth', detail: `check failed: ${err.message}` });
|
|
161
163
|
}
|
|
164
|
+
// ---- Authorize-URL probe (opt-in, --probe).
|
|
165
|
+
// One GET to the authorize endpoint with dario's effective OAuth config.
|
|
166
|
+
// This is the single reliable signal for the class of bug that broke
|
|
167
|
+
// #42 / #71 — Anthropic flipping server-side scope policy without
|
|
168
|
+
// changing the CC binary. The nightly probe in check-cc-authorize-
|
|
169
|
+
// probe.mjs hits Cloudflare challenges from CI IPs; running from a
|
|
170
|
+
// user's machine bypasses that. No PII leaves: the probe uses a
|
|
171
|
+
// fresh PKCE challenge and a dummy redirect_uri, and only reads the
|
|
172
|
+
// status code / Location header / response body markers.
|
|
173
|
+
if (opts.probe) {
|
|
174
|
+
try {
|
|
175
|
+
const cfg = await detectCCOAuthConfig();
|
|
176
|
+
const result = await runAuthorizeProbe({
|
|
177
|
+
clientId: cfg.clientId,
|
|
178
|
+
authorizeUrl: cfg.authorizeUrl,
|
|
179
|
+
scopes: cfg.scopes,
|
|
180
|
+
});
|
|
181
|
+
const status = result.verdict === 'accepted'
|
|
182
|
+
? 'ok'
|
|
183
|
+
: result.verdict === 'rejected'
|
|
184
|
+
? 'fail'
|
|
185
|
+
: 'warn';
|
|
186
|
+
const label = 'Authorize probe';
|
|
187
|
+
const summary = `${result.scopeCount}-scope ${result.verdict} — ${result.reason}`;
|
|
188
|
+
checks.push({ status, label, detail: summary });
|
|
189
|
+
if (result.verdict !== 'accepted') {
|
|
190
|
+
// On rejection: the URL is the one `accounts add` would open —
|
|
191
|
+
// surface it so the user can paste and diff against `claude
|
|
192
|
+
// /login`'s URL. On inconclusive (often Cloudflare from our
|
|
193
|
+
// fetch-based probe — CF challenges non-browser clients
|
|
194
|
+
// regardless of IP): the same URL pasted into the user's
|
|
195
|
+
// browser bypasses CF since a real browser passes the
|
|
196
|
+
// challenge. Either way, the URL is the actionable artifact.
|
|
197
|
+
checks.push({ status: 'info', label: 'Probe URL', detail: result.probedUrl });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
checks.push({
|
|
202
|
+
status: 'warn',
|
|
203
|
+
label: 'Authorize probe',
|
|
204
|
+
detail: `check failed: ${err.message}`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
162
208
|
// ---- Account pool
|
|
163
209
|
try {
|
|
164
210
|
const { listAccountAliases, loadAllAccounts } = await import('./accounts.js');
|
|
@@ -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,
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.31.
|
|
3
|
+
"version": "3.31.7",
|
|
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": {
|