@askalf/dario 3.22.0 → 3.23.0

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/README.md CHANGED
@@ -26,7 +26,7 @@ One command, one local URL, every provider behind it. Point `ANTHROPIC_BASE_URL`
26
26
  - `llama-3.3-70b`, `deepseek-v3`, anything else → **Groq**, **OpenRouter**, **local LiteLLM**, **vLLM**, **Ollama**, whichever OpenAI-compat backend you wired up
27
27
  - Force a backend explicitly with a prefix: `openai:gpt-4o`, `groq:llama-3.3-70b`, `local:qwen-coder`, `claude:opus`
28
28
 
29
- Switching providers is a **model-name change** in your tool. Not a reconfigure. Not new base URLs. Not new API keys. Not a new SDK import. **Zero runtime dependencies. ~7,600 lines of TypeScript across ~15 files. ~640 assertions across 20 test suites. [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) on every release. Nothing phones home, ever.**
29
+ Switching providers is a **model-name change** in your tool. Not a reconfigure. Not new base URLs. Not new API keys. Not a new SDK import. **Zero runtime dependencies. ~8,100 lines of TypeScript across ~15 files. ~840 assertions across 24 test suites. [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) on every release. Nothing phones home, ever.**
30
30
 
31
31
  ---
32
32
 
@@ -88,7 +88,7 @@ Something broken? `dario doctor` prints a single aggregated health report — da
88
88
 
89
89
  **You hit rate limits on long agent runs.** Add a second / third Claude subscription with `dario accounts add work` and pool mode routes each request to whichever account has the most headroom. **Session stickiness** (v3.13.0) pins a multi-turn conversation to one account so the Anthropic prompt cache survives the run. **In-flight 429 failover** retries the same request against a different account before your client sees an error. See [Multi-account pool mode](#multi-account-pool-mode).
90
90
 
91
- **You run a coding agent that isn't Claude Code.** Cline, Roo Code, Cursor, Windsurf, Continue.dev, GitHub Copilot, OpenHands, OpenClaw, Hermes — they each ship their own tool schemas and their own validators. Dario's universal `TOOL_MAP` (**71 entries as of v3.15**) pre-maps every major coding agent's tool names to Claude Code's native set on the outbound path and rebuilds to your agent's exact expected shape on the inbound path. No `--preserve-tools`, no fingerprint loss, no validator errors. See [Agent compatibility](#agent-compatibility).
91
+ **You run a coding agent that isn't Claude Code.** Cline, Roo Code, Cursor, Windsurf, Continue.dev, GitHub Copilot, OpenHands, OpenClaw, Hermes — they each ship their own tool schemas and their own validators. Dario's universal `TOOL_MAP` (**~66 schema-verified entries**) pre-maps every major coding agent's tool names to Claude Code's native set on the outbound path and rebuilds to your agent's exact expected shape on the inbound path. No `--preserve-tools`, no fingerprint loss, no validator errors. See [Agent compatibility](#agent-compatibility).
92
92
 
93
93
  **You want the proxy layer off the wire entirely.** **Shim mode** (v3.12, hardened in v3.13) is an in-process `globalThis.fetch` patch injected via `NODE_OPTIONS=--require`. No HTTP hop, no port to bind, no `BASE_URL` to set. `dario shim -- claude --print "hi"` and CC thinks it's talking directly to `api.anthropic.com`. See [Shim mode](#shim-mode).
94
94
 
@@ -156,11 +156,11 @@ Force a backend with a **provider prefix** on the model field (`openai:gpt-4o`,
156
156
 
157
157
  OAuth-backed Claude Max / Pro, billed against your plan instead of the API. Activated by `dario login`.
158
158
 
159
- **What it does.** Every outbound Claude request is rebuilt to look exactly like a request Claude Code itself would make — system prompt, tool definitions, fingerprint headers, billing tag, beta flags, **even the exact header insertion order** — using a live-extracted template from your actually-installed CC binary that self-heals on every Anthropic release. Anthropic's classifier sees a CC session because, from the wire up, it *is* one. That's what keeps your usage on subscription billing instead of API overage.
159
+ **What it does.** Every outbound Claude request is rebuilt to look exactly like a request Claude Code itself would make — system prompt, tool definitions, fingerprint headers, billing tag, beta flags, **even the exact header insertion order and request-body key order** — using a live-extracted template from your actually-installed CC binary that self-heals on every Anthropic release. Anthropic's classifier sees a CC session because, from the wire up, it *is* one. That's what keeps your usage on subscription billing instead of API overage.
160
160
 
161
161
  **Key mechanisms:**
162
162
 
163
- - **Live fingerprint extraction** (v3.11). Dario spawns your installed `claude` binary against a loopback MITM endpoint on startup, captures its outbound request, and extracts the live template (system prompt, tools, user-agent, beta flags, **header insertion order** as of v3.13, replayed on the wire by the shim since v3.13 and the proxy since v3.16). Eliminates the "Anthropic ships a new CC, dario is stale for 48 hours" window. Cached at `~/.dario/cc-template.live.json` with a 24h TTL. Falls back to the bundled snapshot if CC isn't installed.
163
+ - **Live fingerprint extraction** (v3.11). Dario spawns your installed `claude` binary against a loopback MITM endpoint on startup, captures its outbound request, and extracts the live template (system prompt, tools, user-agent, beta flags, **header insertion order** as of v3.13 replayed by the shim since v3.13 and the proxy since v3.16, **static header values** + **`anthropic-beta` flags** as of v3.19, and **top-level request-body key order** as of v3.22). Eliminates the "Anthropic ships a new CC, dario is stale for 48 hours" window. Cached at `~/.dario/cc-template.live.json` with a 24h TTL. Falls back to the bundled snapshot if CC isn't installed; the bundled snapshot is scrubbed of host-identifying paths at bake time (v3.21).
164
164
  - **Drift detection** (v3.17). On startup dario probes the installed `claude` binary and compares against the captured template. Mismatch triggers a forced refresh and prints a one-line warning. Users never silently sit on a stale template again.
165
165
  - **Compat matrix** (v3.17). `SUPPORTED_CC_RANGE = { min: "1.0.0", maxTested: "2.1.104" }` is encoded in code. Installed CC outside that band prints a warn (untested above) or fail (below min) — zero-dep dotted-numeric comparator, no `semver` import per the dep policy.
166
166
  - **Billing tag** reconstructed using CC's own algorithm: `x-anthropic-billing-header: cc_version=<version>.<build_tag>; cc_entrypoint=cli; cch=<5-char-hex>;` where `build_tag = SHA-256(seed + chars[4,7,20] of user message + version).slice(0,3)`.
@@ -272,7 +272,7 @@ Under the hood: `dario shim` spawns the child with `NODE_OPTIONS=--require <dari
272
272
 
273
273
  ## Agent compatibility
274
274
 
275
- As of **v3.18**, dario's built-in `TOOL_MAP` carries **~65 schema-verified entries** covering the tool schemas of every major coding agent. On the Claude backend, tool calls translate to CC's native `Bash / Read / Write / Edit / Glob / Grep / WebSearch / WebFetch` on the outbound path (keeping the subscription fingerprint intact) and rebuild to your agent's exact expected shape on the inbound path (so your validator is happy). No flag required.
275
+ As of **v3.22**, dario's built-in `TOOL_MAP` carries **~66 schema-verified entries** covering the tool schemas of every major coding agent. On the Claude backend, tool calls translate to CC's native `Bash / Read / Write / Edit / Glob / Grep / WebSearch / WebFetch` on the outbound path (keeping the subscription fingerprint intact) and rebuild to your agent's exact expected shape on the inbound path (so your validator is happy). No flag required.
276
276
 
277
277
  | Agent | Covered tool names (subset) |
278
278
  |---|---|
@@ -515,11 +515,11 @@ Dario handles your OAuth tokens and API keys locally. Here's why you can trust i
515
515
 
516
516
  | Signal | Status |
517
517
  |---|---|
518
- | **Source code** | ~7,600 lines of TypeScript across ~15 files — small enough to audit in a weekend |
518
+ | **Source code** | ~8,100 lines of TypeScript across ~15 files — small enough to audit in a weekend |
519
519
  | **Dependencies** | 0 runtime dependencies. Verify: `npm ls --production` |
520
520
  | **npm provenance** | Every release is [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions with sigstore provenance attached to the transparency log |
521
521
  | **Security scanning** | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) runs on every push and weekly |
522
- | **Test footprint** | ~640 assertions across 20 files. Full `npm test` green on every release |
522
+ | **Test footprint** | ~840 assertions across 24 files. Full `npm test` green on every release |
523
523
  | **Credential handling** | Tokens and API keys never logged, redacted from errors, stored with `0600` permissions |
524
524
  | **OAuth flow** | PKCE (Proof Key for Code Exchange), no client secret |
525
525
  | **Network scope** | Binds to `127.0.0.1` by default. `--host` allows LAN/mesh with `DARIO_API_KEY` gating. Upstream traffic goes only to the configured backend target URLs over HTTPS |
@@ -568,7 +568,7 @@ Yes — anything that speaks the OpenAI Chat Completions API. Groq, OpenRouter,
568
568
  Dario auto-detects OAuth config from the installed Claude Code binary. When CC ships a new version with rotated values, dario picks them up on the next run. Cache at `~/.dario/cc-oauth-cache-v4.json`, keyed by the CC binary fingerprint.
569
569
 
570
570
  **What happens when Anthropic changes the CC request template?**
571
- Dario extracts the live request template from your installed Claude Code binary on startup — the system prompt, tool schemas, user-agent, beta flags, and header insertion order — and uses those to replay requests instead of a version pinned into dario itself. When CC ships a new version with a tweaked template, the next `dario proxy` run picks it up automatically. Drift detection (v3.17) forces a refresh when the installed CC version changes under dario.
571
+ Dario extracts the live request template from your installed Claude Code binary on startup — the system prompt, tool schemas, user-agent, beta flags, header insertion order, static header values, and top-level request-body key order — and uses those to replay requests instead of a version pinned into dario itself. When CC ships a new version with a tweaked template, the next `dario proxy` run picks it up automatically. Drift detection (v3.17) forces a refresh when the installed CC version changes under dario, and the nightly `cc-drift-watch` workflow catches upstream rotations (client_id, URLs, tool set, version) the day they ship on npm.
572
572
 
573
573
  **First time setup on a fresh Claude account.**
574
574
  If dario is the first thing you run against a brand-new Claude account, prime the account with a few real Claude Code commands first:
@@ -617,15 +617,16 @@ Longer-form writing on how dario works and why it works that way:
617
617
 
618
618
  ## Contributing
619
619
 
620
- PRs welcome. The codebase is small TypeScript — ~7,600 lines across ~15 files:
620
+ PRs welcome. The codebase is small TypeScript — ~8,100 lines across ~15 files:
621
621
 
622
622
  | File | Purpose |
623
623
  |---|---|
624
624
  | `src/proxy.ts` | HTTP proxy server, request handler, rate governor, Claude backend dispatch, OpenAI-compat routing, pool failover |
625
- | `src/cc-template.ts` | CC request template engine, universal `TOOL_MAP` (~65 schema-verified entries), orchestration and framework scrubbing, header-order replay |
626
- | `src/cc-template-data.json` | Bundled fallback CC request template (used when live-fingerprint extraction isn't possible) |
625
+ | `src/cc-template.ts` | CC request template engine, universal `TOOL_MAP` (~66 schema-verified entries), orchestration and framework scrubbing, header-order + body-field-order replay |
626
+ | `src/cc-template-data.json` | Bundled fallback CC request template (used when live-fingerprint extraction isn't possible). Scrubbed of host-identifying paths at bake time. |
627
+ | `src/scrub-template.ts` | Host-context scrubber for the baked fallback template — strips per-session sections, replaces user-dir paths with a placeholder, drops `mcp__*` tools (v3.21) |
627
628
  | `src/cc-oauth-detect.ts` | OAuth config auto-detection from the installed CC binary |
628
- | `src/live-fingerprint.ts` | Live extraction of the CC request template (system prompt, tools, user-agent, beta flags, header order) from the installed Claude Code binary, drift detection, compat matrix, atomic cache writes, corruption recovery |
629
+ | `src/live-fingerprint.ts` | Live extraction of the CC request template (system prompt, tools, user-agent, beta flags, header order, static header values, body field order) from the installed Claude Code binary, drift detection, compat matrix, atomic cache writes, corruption recovery |
629
630
  | `src/doctor.ts` | `dario doctor` health report aggregator — dario/Node/CC/template/drift/OAuth/pool/backends |
630
631
  | `src/oauth.ts` | Single-account token storage, PKCE flow, auto-refresh |
631
632
  | `src/accounts.ts` | Multi-account credential storage, independent OAuth lifecycle, refresh single-flight |
package/dist/cli.js CHANGED
@@ -200,9 +200,15 @@ async function proxy() {
200
200
  // when Cline/Kilo/Roo is detected can pass --no-auto-detect; they keep
201
201
  // explicit control with --preserve-tools per session. dario#40 (ringge).
202
202
  const noAutoDetect = args.includes('--no-auto-detect') || args.includes('--no-auto-preserve');
203
+ // --strict-tls refuses to start proxy mode when the process's TLS stack
204
+ // doesn't match Claude Code's (i.e. we're on Node without Bun). Opt-in
205
+ // hard guardrail for operators who want certainty that the JA3 the
206
+ // proxy presents to Anthropic is Bun's BoringSSL ClientHello, not
207
+ // Node's OpenSSL one. v3.23 (direction #3).
208
+ const strictTls = args.includes('--strict-tls');
203
209
  const modelArg = args.find(a => a.startsWith('--model='));
204
210
  const model = modelArg ? modelArg.split('=')[1] : undefined;
205
- await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect });
211
+ await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls });
206
212
  }
207
213
  async function accounts() {
208
214
  const sub = args[1];
@@ -446,6 +452,13 @@ async function help() {
446
452
  intact even when a text-tool client is
447
453
  detected; use --preserve-tools per session
448
454
  when edits are needed. (dario#40)
455
+ --strict-tls Refuse to start proxy mode if this process
456
+ isn't running under Bun. Bun is what Claude
457
+ Code uses; matching its TLS stack keeps the
458
+ proxy's JA3/JA4 ClientHello indistinguishable
459
+ from a stock CC request. Install Bun
460
+ (https://bun.sh) so dario auto-relaunches
461
+ under it, or use shim mode. (v3.23)
449
462
  --port=PORT Port to listen on (default: 3456)
450
463
  --host=ADDRESS Address to bind to (default: 127.0.0.1)
451
464
  Use 0.0.0.0 for LAN; see README for DARIO_API_KEY
package/dist/doctor.js CHANGED
@@ -72,6 +72,26 @@ export async function runChecks() {
72
72
  label: 'Platform',
73
73
  detail: `${platform()} ${arch()} (${release()})`,
74
74
  });
75
+ // ---- Runtime TLS fingerprint (v3.23, direction #3)
76
+ // Proxy mode terminates TLS in this process, so Bun-vs-Node is a
77
+ // fingerprint axis Anthropic can read directly off the wire.
78
+ try {
79
+ const { detectRuntimeFingerprint } = await import('./runtime-fingerprint.js');
80
+ const rt = detectRuntimeFingerprint();
81
+ const status = rt.status === 'bun-match' ? 'ok' : 'warn';
82
+ checks.push({
83
+ status,
84
+ label: 'Runtime / TLS',
85
+ detail: rt.hint ? `${rt.detail}. ${rt.hint}` : rt.detail,
86
+ });
87
+ }
88
+ catch (err) {
89
+ checks.push({
90
+ status: 'warn',
91
+ label: 'Runtime / TLS',
92
+ detail: `check failed: ${err.message}`,
93
+ });
94
+ }
75
95
  // ---- CC binary
76
96
  const cc = safely(() => findInstalledCC(), { path: null, version: null });
77
97
  if (cc.path && cc.version) {
package/dist/proxy.d.ts CHANGED
@@ -12,6 +12,7 @@ interface ProxyOptions {
12
12
  preserveTools?: boolean;
13
13
  hybridTools?: boolean;
14
14
  noAutoDetect?: boolean;
15
+ strictTls?: boolean;
15
16
  }
16
17
  export declare function sanitizeError(err: unknown): string;
17
18
  export declare function startProxy(opts?: ProxyOptions): Promise<void>;
package/dist/proxy.js CHANGED
@@ -363,6 +363,22 @@ export async function startProxy(opts = {}) {
363
363
  const host = opts.host ?? process.env.DARIO_HOST ?? DEFAULT_HOST;
364
364
  const verbose = opts.verbose ?? false;
365
365
  const passthrough = opts.passthrough ?? false;
366
+ // TLS-fingerprint axis (v3.23, direction #3). Proxy mode terminates TLS
367
+ // to api.anthropic.com from this process; if we're not on Bun, the
368
+ // ClientHello that reaches Anthropic is Node's OpenSSL shape, not CC's
369
+ // Bun/BoringSSL shape. `--strict-tls` turns this silent divergence into
370
+ // a startup refusal. Doctor + the always-on banner below surface the
371
+ // same information without aborting, for users who know they're fine
372
+ // (API-key billing, single-call invocations, shim-mode-elsewhere, etc.).
373
+ const { detectRuntimeFingerprint } = await import('./runtime-fingerprint.js');
374
+ const runtimeFp = detectRuntimeFingerprint();
375
+ if (opts.strictTls && runtimeFp.status !== 'bun-match') {
376
+ console.error(`[dario] --strict-tls: ${runtimeFp.detail}`);
377
+ if (runtimeFp.hint)
378
+ console.error(`[dario] → ${runtimeFp.hint}`);
379
+ console.error('[dario] refusing to start proxy mode. Omit --strict-tls to run anyway.');
380
+ process.exit(1);
381
+ }
366
382
  // Text-tool-protocol client families that have already logged a
367
383
  // "detected → auto-enabling preserve-tools" banner this session.
368
384
  // Set once on first sighting per family so the startup log stays
@@ -1635,6 +1651,15 @@ export async function startProxy(opts = {}) {
1635
1651
  if (compat.status === 'below-min' || compat.status === 'untested-above') {
1636
1652
  console.log(`[dario] ⚠ CC compat: ${compat.message}`);
1637
1653
  }
1654
+ // TLS-fingerprint banner (v3.23). Proxy mode terminates TLS from this
1655
+ // process, so the Bun-vs-Node runtime choice is actually on the wire.
1656
+ // Silence via DARIO_QUIET_TLS=1 for known-fine environments.
1657
+ if (runtimeFp.status !== 'bun-match' && process.env.DARIO_QUIET_TLS !== '1') {
1658
+ console.log(`[dario] ⚠ TLS fingerprint: ${runtimeFp.detail}`);
1659
+ if (runtimeFp.hint)
1660
+ console.log(`[dario] → ${runtimeFp.hint}`);
1661
+ console.log('[dario] (silence with DARIO_QUIET_TLS=1, or use --strict-tls to hard-fail)');
1662
+ }
1638
1663
  // Kick off a live fingerprint refresh in the background. Re-captures the
1639
1664
  // user's own CC binary request shape and updates ~/.dario/cc-template.live.json
1640
1665
  // for the next startup. No-op if CC isn't installed or the cache is fresh.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Runtime TLS-fingerprint detector (direction #3 from the v3.22 roadmap).
3
+ *
4
+ * The Claude Code binary is a Bun-compiled standalone executable, so every
5
+ * HTTPS request it makes goes out through Bun's BoringSSL-derived TLS stack.
6
+ * That ClientHello (JA3/JA4 hash) is what Anthropic's TLS-layer classifier
7
+ * actually sees on the wire.
8
+ *
9
+ * Dario has two transports with different exposure to this axis:
10
+ *
11
+ * - **Shim mode** runs inside CC's own process (NODE_OPTIONS=--require),
12
+ * so its outbound fetch rides on CC's TLS stack by construction.
13
+ * Nothing to reconcile — the shim is always TLS-matched to CC.
14
+ *
15
+ * - **Proxy mode** is a separate process holding its own TLS sessions
16
+ * to api.anthropic.com. Anthropic sees the proxy's TLS fingerprint,
17
+ * not the consumer client's. If the proxy runs under Node, the
18
+ * ClientHello is OpenSSL-shaped — distinct from Bun's BoringSSL shape.
19
+ * That's the JA3 gap this module flags.
20
+ *
21
+ * Mitigation today: dario auto-relaunches under Bun when Bun is on PATH
22
+ * (see top of `src/cli.ts`). When Bun isn't available the auto-relaunch
23
+ * is a silent no-op, so proxy mode silently runs on Node's TLS stack
24
+ * with no indication to the operator. This module makes the runtime
25
+ * status a first-class check: `dario doctor` reports it, proxy startup
26
+ * warns when the axis is mismatched, and `--strict-tls` hard-fails
27
+ * instead of silently running with a divergent fingerprint.
28
+ *
29
+ * Pure-function: every input is passed in explicitly so tests can
30
+ * exercise each runtime combination without spawning processes.
31
+ */
32
+ /** Canonical buckets the caller pivots on. */
33
+ export type RuntimeFingerprintStatus =
34
+ /** Running under Bun — TLS stack matches CC. */
35
+ 'bun-match'
36
+ /** Running under Node, Bun available on PATH but auto-relaunch was bypassed. */
37
+ | 'bun-bypassed'
38
+ /** Running under Node, Bun not installed. */
39
+ | 'node-only';
40
+ export interface RuntimeFingerprint {
41
+ status: RuntimeFingerprintStatus;
42
+ /** 'bun' or 'node' — which runtime this process is actually on. */
43
+ runtime: 'bun' | 'node';
44
+ /** Version string from the runtime (e.g. "1.1.30" or "v20.11.1"). */
45
+ runtimeVersion: string;
46
+ /** Bun version discovered on PATH, if any. undefined when runtime==='bun' or bun-not-found. */
47
+ availableBunVersion?: string;
48
+ /** Why auto-relaunch didn't fire when `status === 'bun-bypassed'`. */
49
+ bypassReason?: 'DARIO_NO_BUN' | 'unknown';
50
+ /** Human-readable one-line explanation for the check label. */
51
+ detail: string;
52
+ /** Actionable hint when status !== 'bun-match'. undefined otherwise. */
53
+ hint?: string;
54
+ }
55
+ /**
56
+ * Probe the Bun binary on PATH without relaunching. Returns undefined
57
+ * when bun isn't installed or the version probe fails for any reason
58
+ * (timeout, non-zero exit, etc.). Kept synchronous to match cli.ts's
59
+ * pre-import flow; doctor.ts is the only other caller and is fine with
60
+ * the (~sub-100ms) cost when Bun is installed.
61
+ */
62
+ export declare function probeBunVersion(): string | undefined;
63
+ /**
64
+ * Synthesize the TLS-fingerprint status from three inputs. All three are
65
+ * passed explicitly so tests can cover every combination without touching
66
+ * the real environment. Production callers pass
67
+ * `classifyRuntimeFingerprint(typeof Bun !== 'undefined', probeBunVersion(), process.env)`.
68
+ *
69
+ * The `env` parameter is read-only — this function never mutates it.
70
+ */
71
+ export declare function classifyRuntimeFingerprint(runningUnderBun: boolean, availableBunVersion: string | undefined, env: Record<string, string | undefined>, nodeVersion?: string): RuntimeFingerprint;
72
+ /**
73
+ * Convenience wrapper that reads the current process state. doctor.ts
74
+ * calls this once; tests do not — they exercise classifyRuntimeFingerprint
75
+ * directly with synthetic inputs.
76
+ */
77
+ export declare function detectRuntimeFingerprint(): RuntimeFingerprint;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Runtime TLS-fingerprint detector (direction #3 from the v3.22 roadmap).
3
+ *
4
+ * The Claude Code binary is a Bun-compiled standalone executable, so every
5
+ * HTTPS request it makes goes out through Bun's BoringSSL-derived TLS stack.
6
+ * That ClientHello (JA3/JA4 hash) is what Anthropic's TLS-layer classifier
7
+ * actually sees on the wire.
8
+ *
9
+ * Dario has two transports with different exposure to this axis:
10
+ *
11
+ * - **Shim mode** runs inside CC's own process (NODE_OPTIONS=--require),
12
+ * so its outbound fetch rides on CC's TLS stack by construction.
13
+ * Nothing to reconcile — the shim is always TLS-matched to CC.
14
+ *
15
+ * - **Proxy mode** is a separate process holding its own TLS sessions
16
+ * to api.anthropic.com. Anthropic sees the proxy's TLS fingerprint,
17
+ * not the consumer client's. If the proxy runs under Node, the
18
+ * ClientHello is OpenSSL-shaped — distinct from Bun's BoringSSL shape.
19
+ * That's the JA3 gap this module flags.
20
+ *
21
+ * Mitigation today: dario auto-relaunches under Bun when Bun is on PATH
22
+ * (see top of `src/cli.ts`). When Bun isn't available the auto-relaunch
23
+ * is a silent no-op, so proxy mode silently runs on Node's TLS stack
24
+ * with no indication to the operator. This module makes the runtime
25
+ * status a first-class check: `dario doctor` reports it, proxy startup
26
+ * warns when the axis is mismatched, and `--strict-tls` hard-fails
27
+ * instead of silently running with a divergent fingerprint.
28
+ *
29
+ * Pure-function: every input is passed in explicitly so tests can
30
+ * exercise each runtime combination without spawning processes.
31
+ */
32
+ import { execFileSync } from 'node:child_process';
33
+ /**
34
+ * Probe the Bun binary on PATH without relaunching. Returns undefined
35
+ * when bun isn't installed or the version probe fails for any reason
36
+ * (timeout, non-zero exit, etc.). Kept synchronous to match cli.ts's
37
+ * pre-import flow; doctor.ts is the only other caller and is fine with
38
+ * the (~sub-100ms) cost when Bun is installed.
39
+ */
40
+ export function probeBunVersion() {
41
+ try {
42
+ const out = execFileSync('bun', ['--version'], {
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ timeout: 3000,
45
+ encoding: 'utf-8',
46
+ });
47
+ const trimmed = out.trim();
48
+ // `bun --version` prints just the version like "1.1.30". Reject anything
49
+ // longer than a sanity threshold so an unrelated `bun` binary can't
50
+ // poison the detection.
51
+ if (trimmed.length > 0 && trimmed.length < 32 && /^[0-9]/.test(trimmed)) {
52
+ return trimmed;
53
+ }
54
+ return undefined;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ /**
61
+ * Synthesize the TLS-fingerprint status from three inputs. All three are
62
+ * passed explicitly so tests can cover every combination without touching
63
+ * the real environment. Production callers pass
64
+ * `classifyRuntimeFingerprint(typeof Bun !== 'undefined', probeBunVersion(), process.env)`.
65
+ *
66
+ * The `env` parameter is read-only — this function never mutates it.
67
+ */
68
+ export function classifyRuntimeFingerprint(runningUnderBun, availableBunVersion, env, nodeVersion = process.version) {
69
+ if (runningUnderBun) {
70
+ // When we're under Bun, we expose the Bun version if globalThis.Bun.version
71
+ // is readable; we don't require a separate probe. The caller passes the
72
+ // resolved version string as `availableBunVersion` in the bun case.
73
+ const bunVer = availableBunVersion ?? 'unknown';
74
+ return {
75
+ status: 'bun-match',
76
+ runtime: 'bun',
77
+ runtimeVersion: bunVer,
78
+ detail: `Bun v${bunVer} — TLS fingerprint matches Claude Code`,
79
+ };
80
+ }
81
+ if (availableBunVersion !== undefined) {
82
+ const reason = env.DARIO_NO_BUN ? 'DARIO_NO_BUN' : 'unknown';
83
+ return {
84
+ status: 'bun-bypassed',
85
+ runtime: 'node',
86
+ runtimeVersion: nodeVersion,
87
+ availableBunVersion,
88
+ bypassReason: reason,
89
+ detail: `Node ${nodeVersion} — Bun v${availableBunVersion} on PATH but auto-relaunch bypassed (${reason})`,
90
+ hint: reason === 'DARIO_NO_BUN'
91
+ ? 'Unset DARIO_NO_BUN to auto-relaunch under Bun on the next invocation.'
92
+ : 'Run dario fresh (no inherited DARIO_NO_BUN) so auto-relaunch can fire.',
93
+ };
94
+ }
95
+ return {
96
+ status: 'node-only',
97
+ runtime: 'node',
98
+ runtimeVersion: nodeVersion,
99
+ detail: `Node ${nodeVersion} — Bun not installed; proxy-mode TLS fingerprint diverges from Claude Code`,
100
+ hint: 'Install Bun (https://bun.sh) so dario can auto-relaunch under it, or use shim mode ' +
101
+ '(`dario shim -- claude …`) which runs inside CC\'s own process and inherits its TLS stack.',
102
+ };
103
+ }
104
+ /**
105
+ * Convenience wrapper that reads the current process state. doctor.ts
106
+ * calls this once; tests do not — they exercise classifyRuntimeFingerprint
107
+ * directly with synthetic inputs.
108
+ */
109
+ export function detectRuntimeFingerprint() {
110
+ const bunGlobal = globalThis.Bun;
111
+ const runningUnderBun = typeof bunGlobal?.version === 'string';
112
+ if (runningUnderBun) {
113
+ return classifyRuntimeFingerprint(true, bunGlobal?.version, process.env);
114
+ }
115
+ const probed = probeBunVersion();
116
+ return classifyRuntimeFingerprint(false, probed, process.env);
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.22.0",
3
+ "version": "3.23.0",
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/proxy-body-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 && node test/scrub-template.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/proxy-body-order.mjs && node test/runtime-fingerprint.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 && node test/scrub-template.mjs",
25
25
  "audit": "npm audit --production --audit-level=high",
26
26
  "prepublishOnly": "npm run build",
27
27
  "start": "node dist/cli.js",