@askalf/dario 3.19.5 → 3.20.1

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.
@@ -150,6 +150,7 @@ export declare function buildCCRequest(clientBody: Record<string, unknown>, bill
150
150
  }, opts?: {
151
151
  preserveTools?: boolean;
152
152
  hybridTools?: boolean;
153
+ noAutoDetect?: boolean;
153
154
  }): {
154
155
  body: Record<string, unknown>;
155
156
  toolMap: Map<string, ToolMapping>;
@@ -653,8 +653,15 @@ export function buildCCRequest(clientBody, billingTag, cache1h, identity, opts =
653
653
  // brand name is still present, decide whether to auto-switch into
654
654
  // preserve-tools behavior below. Explicit --hybrid-tools outranks the
655
655
  // heuristic (operator opt-in wins). dario#40.
656
+ //
657
+ // `noAutoDetect` skips the detector entirely — operators who want the
658
+ // full CC fingerprint restored (tools array included) even when their
659
+ // client is Cline/Kilo/Roo can opt out. They keep explicit control via
660
+ // --preserve-tools per session. dario#40 (ringge's fingerprint concern).
656
661
  const rawSystemForDetection = extractSystemText(clientBody);
657
- const detectedClient = detectTextToolClient(rawSystemForDetection) ?? undefined;
662
+ const detectedClient = opts.noAutoDetect
663
+ ? undefined
664
+ : (detectTextToolClient(rawSystemForDetection) ?? undefined);
658
665
  const autoPreserve = Boolean(detectedClient) && !opts.hybridTools;
659
666
  const effectivePreserveTools = Boolean(opts.preserveTools) || autoPreserve;
660
667
  // ── Strip thinking from history ──
package/dist/cli.js CHANGED
@@ -35,7 +35,7 @@ if (!('Bun' in globalThis) && !process.env.DARIO_NO_BUN) {
35
35
  import { unlink } from 'node:fs/promises';
36
36
  import { join } from 'node:path';
37
37
  import { homedir } from 'node:os';
38
- import { startAutoOAuthFlow, getStatus, refreshTokens, loadCredentials } from './oauth.js';
38
+ import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials } from './oauth.js';
39
39
  import { startProxy, sanitizeError } from './proxy.js';
40
40
  import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, removeAccount } from './accounts.js';
41
41
  import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
@@ -46,6 +46,7 @@ async function login() {
46
46
  console.log(' dario — Claude Login');
47
47
  console.log(' ───────────────────');
48
48
  console.log('');
49
+ const manualFlag = args.includes('--manual') || args.includes('--headless');
49
50
  // Check for existing credentials (Claude Code or dario's own)
50
51
  const creds = await loadCredentials();
51
52
  if (creds?.claudeAiOauth?.accessToken && creds.claudeAiOauth.expiresAt > Date.now()) {
@@ -79,8 +80,21 @@ async function login() {
79
80
  console.log(' No Claude Code credentials found. Starting OAuth flow...');
80
81
  console.log('');
81
82
  }
83
+ // If the user didn't explicitly pick `--manual`, surface a hint when
84
+ // heuristics suggest the local-callback flow won't work (SSH session,
85
+ // container). We don't auto-flip — false positives would be more
86
+ // annoying than false negatives — but the hint keeps users from
87
+ // waiting for a browser redirect that can't land.
88
+ if (!manualFlag) {
89
+ const reason = detectHeadlessEnvironment();
90
+ if (reason) {
91
+ console.log(` Note: ${reason}. If the browser redirect doesn't land,`);
92
+ console.log(' re-run with: dario login --manual');
93
+ console.log('');
94
+ }
95
+ }
82
96
  try {
83
- const tokens = await startAutoOAuthFlow();
97
+ const tokens = manualFlag ? await startManualOAuthFlow() : await startAutoOAuthFlow();
84
98
  const expiresIn = Math.round((tokens.expiresAt - Date.now()) / 60000);
85
99
  console.log(' Login successful!');
86
100
  console.log(` Token expires in ${expiresIn} minutes (auto-refreshes).`);
@@ -89,9 +103,15 @@ async function login() {
89
103
  console.log('');
90
104
  }
91
105
  catch (err) {
106
+ const msg = sanitizeError(err);
92
107
  console.error('');
93
- console.error(` Login failed: ${sanitizeError(err)}`);
94
- console.error(' Try again with `dario login`.');
108
+ console.error(` Login failed: ${msg}`);
109
+ if (!manualFlag && /callback server|EADDRINUSE|bind|timed out/i.test(msg)) {
110
+ console.error(' Hint: try `dario login --manual` for headless / container setups.');
111
+ }
112
+ else {
113
+ console.error(' Try again with `dario login`.');
114
+ }
95
115
  process.exit(1);
96
116
  }
97
117
  }
@@ -175,9 +195,14 @@ async function proxy() {
175
195
  console.error('[dario] --preserve-tools and --hybrid-tools are mutually exclusive. Pick one.');
176
196
  process.exit(1);
177
197
  }
198
+ // Opt-out for v3.19.3's text-tool-client auto-detection. Operators who
199
+ // want the full CC fingerprint restored (tools array included) even
200
+ // when Cline/Kilo/Roo is detected can pass --no-auto-detect; they keep
201
+ // explicit control with --preserve-tools per session. dario#40 (ringge).
202
+ const noAutoDetect = args.includes('--no-auto-detect') || args.includes('--no-auto-preserve');
178
203
  const modelArg = args.find(a => a.startsWith('--model='));
179
204
  const model = modelArg ? modelArg.split('=')[1] : undefined;
180
- await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools });
205
+ await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect });
181
206
  }
182
207
  async function accounts() {
183
208
  const sub = args[1];
@@ -384,7 +409,10 @@ async function help() {
384
409
  dario — Use your Claude subscription as an API.
385
410
 
386
411
  Usage:
387
- dario login Detect credentials + start proxy (or run OAuth)
412
+ dario login [--manual] Detect credentials + start proxy (or run OAuth)
413
+ --manual (alias: --headless) for container / SSH
414
+ setups — prints an authorize URL and reads the
415
+ code you paste back instead of a local redirect
388
416
  dario proxy [options] Start the API proxy server
389
417
  dario status Check authentication status
390
418
  dario refresh Force token refresh
@@ -413,6 +441,11 @@ async function help() {
413
441
  Loses subscription routing; use for custom agents
414
442
  --hybrid-tools Remap to CC tools, inject sessionId/requestId/etc.
415
443
  Keeps subscription routing for custom agents
444
+ --no-auto-detect Disable Cline/Kilo/Roo auto-preserve-tools
445
+ (v3.19.3 behavior). Keeps CC fingerprint
446
+ intact even when a text-tool client is
447
+ detected; use --preserve-tools per session
448
+ when edits are needed. (dario#40)
416
449
  --port=PORT Port to listen on (default: 3456)
417
450
  --host=ADDRESS Address to bind to (default: 127.0.0.1)
418
451
  Use 0.0.0.0 for LAN; see README for DARIO_API_KEY
package/dist/oauth.d.ts CHANGED
@@ -19,6 +19,59 @@ export declare function loadCredentials(): Promise<CredentialsFile | null>;
19
19
  * Opens browser, captures the authorization code automatically.
20
20
  */
21
21
  export declare function startAutoOAuthFlow(): Promise<OAuthTokens>;
22
+ /**
23
+ * Build the authorize URL used by the manual / headless flow. Exported
24
+ * so tests can assert the shape (code=true, MANUAL_REDIRECT_URI, PKCE)
25
+ * without exercising the full interactive flow.
26
+ */
27
+ export declare function buildManualAuthorizeUrl(cfg: {
28
+ clientId: string;
29
+ authorizeUrl: string;
30
+ scopes: string;
31
+ }, codeChallenge: string, state: string): string;
32
+ /**
33
+ * Parse whatever the user pastes back from Anthropic's success page.
34
+ *
35
+ * The success page renders the authorization code and state joined with
36
+ * a `#` (the fragment-identifier convention CC itself uses for its
37
+ * `claude setup-token` flow), so the happy-path paste is `code#state`.
38
+ * Some browsers / copy UIs strip the fragment, so we also accept a bare
39
+ * code. When state is present, callers should verify it matches the
40
+ * state they generated; when absent, callers can prompt separately or
41
+ * accept the trade-off (code + PKCE + client_id are still verified on
42
+ * the token exchange).
43
+ */
44
+ export declare function parseManualPaste(input: string): {
45
+ code: string;
46
+ state: string | null;
47
+ };
48
+ /**
49
+ * Heuristic for "dario is probably running somewhere the local-callback
50
+ * OAuth flow won't work because the browser is on a different host."
51
+ * Returns a short reason string when the heuristic fires, null otherwise.
52
+ *
53
+ * Callers use this to *offer* `--manual` to the user, never to force it —
54
+ * false positives are more annoying than false negatives (the user can
55
+ * always opt in explicitly).
56
+ */
57
+ export declare function detectHeadlessEnvironment(): string | null;
58
+ /**
59
+ * Manual / headless OAuth flow (dario #43).
60
+ *
61
+ * Mirrors Claude Code's own `claude setup-token` flow: asks Anthropic to
62
+ * display the authorization code as text instead of redirecting to a
63
+ * local callback server, then reads the code the user copies back.
64
+ * Works for container installs (browser on host, dario in container),
65
+ * SSH installs (no browser on the remote box), and any other setup
66
+ * where a localhost redirect can't reach the dario process.
67
+ *
68
+ * Security posture is unchanged from the auto flow: PKCE + client_id +
69
+ * single-use code + server-side code expiry. State parameter is
70
+ * verified when the pasted input includes it; bare-code pastes still
71
+ * exchange because state isn't load-bearing for the token endpoint
72
+ * (it's CSRF protection for a redirect we don't have here).
73
+ */
74
+ export declare function startManualOAuthFlow(): Promise<OAuthTokens>;
22
75
  /**
23
76
  * Refresh the access token using the refresh token.
24
77
  * Retries with exponential backoff on transient failures.
package/dist/oauth.js CHANGED
@@ -5,11 +5,18 @@
5
5
  * Handles authorization, token exchange, storage, and auto-refresh.
6
6
  */
7
7
  import { randomBytes, createHash } from 'node:crypto';
8
+ import { existsSync, readFileSync } from 'node:fs';
8
9
  import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
9
10
  import { execFile } from 'node:child_process';
10
11
  import { dirname, join } from 'node:path';
11
12
  import { homedir, platform } from 'node:os';
12
13
  import { detectCCOAuthConfig } from './cc-oauth-detect.js';
14
+ // Manual-flow redirect URI. Anthropic's authorize endpoint special-cases
15
+ // this value (also baked into CC as MANUAL_REDIRECT_URL) to render the
16
+ // authorization code + state on a copy-paste success page instead of
17
+ // redirecting back to a localhost callback. Used by startManualOAuthFlow
18
+ // for container / headless / SSH installs where a local bind won't work.
19
+ const MANUAL_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
13
20
  // OAuth config is auto-detected at runtime from the installed Claude Code
14
21
  // binary. This eliminates the "Anthropic rotated the client_id again" class
15
22
  // of bugs — dario stays in sync with whatever CC version the user has
@@ -293,6 +300,153 @@ async function exchangeCodeWithRedirect(code, codeVerifier, state, port) {
293
300
  await saveCredentials({ claudeAiOauth: tokens });
294
301
  return tokens;
295
302
  }
303
+ /**
304
+ * Build the authorize URL used by the manual / headless flow. Exported
305
+ * so tests can assert the shape (code=true, MANUAL_REDIRECT_URI, PKCE)
306
+ * without exercising the full interactive flow.
307
+ */
308
+ export function buildManualAuthorizeUrl(cfg, codeChallenge, state) {
309
+ const params = new URLSearchParams({
310
+ code: 'true',
311
+ client_id: cfg.clientId,
312
+ response_type: 'code',
313
+ redirect_uri: MANUAL_REDIRECT_URI,
314
+ scope: cfg.scopes,
315
+ code_challenge: codeChallenge,
316
+ code_challenge_method: 'S256',
317
+ state,
318
+ });
319
+ return `${cfg.authorizeUrl}?${params.toString()}`;
320
+ }
321
+ /**
322
+ * Parse whatever the user pastes back from Anthropic's success page.
323
+ *
324
+ * The success page renders the authorization code and state joined with
325
+ * a `#` (the fragment-identifier convention CC itself uses for its
326
+ * `claude setup-token` flow), so the happy-path paste is `code#state`.
327
+ * Some browsers / copy UIs strip the fragment, so we also accept a bare
328
+ * code. When state is present, callers should verify it matches the
329
+ * state they generated; when absent, callers can prompt separately or
330
+ * accept the trade-off (code + PKCE + client_id are still verified on
331
+ * the token exchange).
332
+ */
333
+ export function parseManualPaste(input) {
334
+ const trimmed = input.trim();
335
+ if (!trimmed)
336
+ return { code: '', state: null };
337
+ const hashIdx = trimmed.indexOf('#');
338
+ if (hashIdx === -1)
339
+ return { code: trimmed, state: null };
340
+ return {
341
+ code: trimmed.slice(0, hashIdx).trim(),
342
+ state: trimmed.slice(hashIdx + 1).trim(),
343
+ };
344
+ }
345
+ /**
346
+ * Heuristic for "dario is probably running somewhere the local-callback
347
+ * OAuth flow won't work because the browser is on a different host."
348
+ * Returns a short reason string when the heuristic fires, null otherwise.
349
+ *
350
+ * Callers use this to *offer* `--manual` to the user, never to force it —
351
+ * false positives are more annoying than false negatives (the user can
352
+ * always opt in explicitly).
353
+ */
354
+ export function detectHeadlessEnvironment() {
355
+ if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
356
+ return 'SSH session detected';
357
+ }
358
+ try {
359
+ if (existsSync('/.dockerenv')) {
360
+ return 'container detected (/.dockerenv)';
361
+ }
362
+ if (existsSync('/proc/1/cgroup')) {
363
+ const cg = readFileSync('/proc/1/cgroup', 'utf-8');
364
+ if (/\b(docker|containerd|lxc|kubepods)\b/.test(cg)) {
365
+ return 'container detected (cgroup)';
366
+ }
367
+ }
368
+ }
369
+ catch { /* best-effort — /proc is Linux-only, absence is fine */ }
370
+ return null;
371
+ }
372
+ /**
373
+ * Manual / headless OAuth flow (dario #43).
374
+ *
375
+ * Mirrors Claude Code's own `claude setup-token` flow: asks Anthropic to
376
+ * display the authorization code as text instead of redirecting to a
377
+ * local callback server, then reads the code the user copies back.
378
+ * Works for container installs (browser on host, dario in container),
379
+ * SSH installs (no browser on the remote box), and any other setup
380
+ * where a localhost redirect can't reach the dario process.
381
+ *
382
+ * Security posture is unchanged from the auto flow: PKCE + client_id +
383
+ * single-use code + server-side code expiry. State parameter is
384
+ * verified when the pasted input includes it; bare-code pastes still
385
+ * exchange because state isn't load-bearing for the token endpoint
386
+ * (it's CSRF protection for a redirect we don't have here).
387
+ */
388
+ export async function startManualOAuthFlow() {
389
+ const { codeVerifier, codeChallenge } = generatePKCE();
390
+ const state = base64url(randomBytes(16));
391
+ const cfg = await getOAuthConfig();
392
+ const authUrl = buildManualAuthorizeUrl(cfg, codeChallenge, state);
393
+ console.log('');
394
+ console.log(' Open this URL in any browser (on any machine):');
395
+ console.log('');
396
+ console.log(` ${authUrl}`);
397
+ console.log('');
398
+ console.log(' After you approve, Anthropic will display an authorization code.');
399
+ console.log(' Paste it below (format: "code#state" or just the code).');
400
+ console.log('');
401
+ const pasted = await readLineFromStdin(' Code: ');
402
+ const { code, state: returnedState } = parseManualPaste(pasted);
403
+ if (!code) {
404
+ throw new Error('No authorization code entered. Re-run `dario login --manual`.');
405
+ }
406
+ if (returnedState && returnedState !== state) {
407
+ throw new Error('State mismatch — the pasted code is from a different login attempt. Re-run `dario login --manual` and paste the most recent code.');
408
+ }
409
+ return exchangeCodeManual(code, codeVerifier, state);
410
+ }
411
+ async function exchangeCodeManual(code, codeVerifier, state) {
412
+ const cfg = await getOAuthConfig();
413
+ const res = await fetch(cfg.tokenUrl, {
414
+ method: 'POST',
415
+ headers: { 'Content-Type': 'application/json' },
416
+ body: JSON.stringify({
417
+ grant_type: 'authorization_code',
418
+ client_id: cfg.clientId,
419
+ code,
420
+ redirect_uri: MANUAL_REDIRECT_URI,
421
+ code_verifier: codeVerifier,
422
+ state,
423
+ }),
424
+ signal: AbortSignal.timeout(30000),
425
+ });
426
+ if (!res.ok) {
427
+ const body = await res.text().catch(() => '');
428
+ throw new Error(`Token exchange failed (HTTP ${res.status}): ${body.slice(0, 200)}`);
429
+ }
430
+ const data = await res.json();
431
+ const tokens = {
432
+ accessToken: data.access_token,
433
+ refreshToken: data.refresh_token,
434
+ expiresAt: Date.now() + data.expires_in * 1000,
435
+ scopes: data.scope?.split(' ') || ['user:inference'],
436
+ };
437
+ await saveCredentials({ claudeAiOauth: tokens });
438
+ return tokens;
439
+ }
440
+ async function readLineFromStdin(prompt) {
441
+ const { createInterface } = await import('node:readline/promises');
442
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
443
+ try {
444
+ return (await rl.question(prompt)).trim();
445
+ }
446
+ finally {
447
+ rl.close();
448
+ }
449
+ }
296
450
  /**
297
451
  * Refresh the access token using the refresh token.
298
452
  * Retries with exponential backoff on transient failures.
package/dist/proxy.d.ts CHANGED
@@ -11,6 +11,7 @@ interface ProxyOptions {
11
11
  passthrough?: boolean;
12
12
  preserveTools?: boolean;
13
13
  hybridTools?: boolean;
14
+ noAutoDetect?: boolean;
14
15
  }
15
16
  export declare function sanitizeError(err: unknown): string;
16
17
  export declare function startProxy(opts?: ProxyOptions): Promise<void>;
package/dist/proxy.js CHANGED
@@ -982,6 +982,7 @@ export async function startProxy(opts = {}) {
982
982
  const { body: ccBody, toolMap, detectedClient } = buildCCRequest(r, billingTag, CACHE_1H, bodyIdentity, {
983
983
  preserveTools: opts.preserveTools ?? false,
984
984
  hybridTools: opts.hybridTools ?? false,
985
+ noAutoDetect: opts.noAutoDetect ?? false,
985
986
  });
986
987
  // Log the auto-preserve-tools switch once per text-tool
987
988
  // client family. Skip when the operator already opted into
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.19.5",
3
+ "version": "3.20.1",
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": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsc && cp src/cc-template-data.json dist/ && node -e \"require('fs').mkdirSync('dist/shim',{recursive:true})\" && cp src/shim/runtime.cjs dist/shim/",
24
- "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs",
24
+ "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs",
25
25
  "audit": "npm audit --production --audit-level=high",
26
26
  "prepublishOnly": "npm run build",
27
27
  "start": "node dist/cli.js",