@askalf/dario 3.22.0 → 3.24.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,33 @@ 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
+ // --pace-min=MS / --pace-jitter=MS (v3.24, direction #6 behavioral
212
+ // smoothing). Inter-request gap floor + optional uniform-random jitter.
213
+ // Defaults preserve v3.23 behavior (500ms floor, no jitter). The pure
214
+ // calc lives in src/pacing.ts; the flags just feed it.
215
+ const pacingMinMs = parsePositiveIntFlag('--pace-min=');
216
+ const pacingJitterMs = parsePositiveIntFlag('--pace-jitter=');
217
+ await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs });
218
+ }
219
+ function parsePositiveIntFlag(prefix) {
220
+ const found = args.find(a => a.startsWith(prefix));
221
+ if (!found)
222
+ return undefined;
223
+ const raw = found.slice(prefix.length);
224
+ const n = parseInt(raw, 10);
225
+ if (!Number.isFinite(n) || n < 0) {
226
+ console.error(`[dario] Invalid ${prefix.replace(/=$/, '')} value: ${JSON.stringify(raw)}. Must be a non-negative integer (ms).`);
227
+ process.exit(1);
228
+ }
229
+ return n;
206
230
  }
207
231
  async function accounts() {
208
232
  const sub = args[1];
@@ -446,6 +470,22 @@ async function help() {
446
470
  intact even when a text-tool client is
447
471
  detected; use --preserve-tools per session
448
472
  when edits are needed. (dario#40)
473
+ --strict-tls Refuse to start proxy mode if this process
474
+ isn't running under Bun. Bun is what Claude
475
+ Code uses; matching its TLS stack keeps the
476
+ proxy's JA3/JA4 ClientHello indistinguishable
477
+ from a stock CC request. Install Bun
478
+ (https://bun.sh) so dario auto-relaunches
479
+ under it, or use shim mode. (v3.23)
480
+ --pace-min=MS Minimum ms between upstream requests
481
+ (default: 500). Prevents request floods
482
+ that are distinguishable from human-paced
483
+ CC traffic.
484
+ --pace-jitter=MS Max additional uniform-random jitter (ms)
485
+ added on top of --pace-min per request.
486
+ Default: 0 (off). Set to e.g. 300 to hide
487
+ the floor from long-run inter-arrival
488
+ statistics. (v3.24)
449
489
  --port=PORT Port to listen on (default: 3456)
450
490
  --host=ADDRESS Address to bind to (default: 127.0.0.1)
451
491
  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) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Inter-request pacing (v3.24, direction #6 — behavioral smoothing).
3
+ *
4
+ * Real CC traffic has human-paced gaps between requests — sub-second when
5
+ * the model is streaming tool-loop output, multi-second when the user is
6
+ * typing the next message. A proxy that fires requests at machine speed
7
+ * with perfectly uniform spacing stands out against that rhythm.
8
+ *
9
+ * This module supplies the pure gap-calculation function the proxy's
10
+ * rate governor calls before every outbound fetch. Two knobs:
11
+ *
12
+ * minGapMs — lower bound on the wall-clock distance between requests.
13
+ * Was a hardcoded 500ms through v3.23; keep 500 as default
14
+ * so back-compat is exact when both knobs stay at defaults.
15
+ *
16
+ * jitterMs — uniform random addition on top of minGap. The *effective*
17
+ * gap for a given request is minGap + U(0, jitter). Adds
18
+ * non-uniformity so an observer can't infer the floor from
19
+ * the long-run minimum of inter-arrival times.
20
+ *
21
+ * Pure over (now, lastRequestTime, minGap, jitter, rng) so the tests can
22
+ * exercise every edge without spawning timers. The proxy passes
23
+ * `Math.random` as the rng at runtime; tests pass a deterministic stub.
24
+ *
25
+ * The first request in a session (lastRequestTime === 0) is never paced —
26
+ * the purpose is smoothing the *gap between* requests, not delaying the
27
+ * first one from whenever the consumer happens to connect.
28
+ */
29
+ export interface PacingConfig {
30
+ /** Minimum wall-clock milliseconds between the completion of one request and the start of the next. */
31
+ minGapMs: number;
32
+ /** Max additional uniform-random jitter (ms) added on top of minGap. Pass 0 to disable. */
33
+ jitterMs: number;
34
+ }
35
+ /**
36
+ * How many milliseconds to sleep before the next upstream fetch.
37
+ *
38
+ * Returns 0 when no delay is required — either because this is the first
39
+ * request of the session, or enough wall-clock time has already elapsed
40
+ * since `lastRequestTime`.
41
+ *
42
+ * `rng` defaults to Math.random; tests inject a deterministic stub.
43
+ * Negative configuration values are clamped to 0 (lenient, not an error).
44
+ */
45
+ export declare function computePacingDelay(now: number, lastRequestTime: number, cfg: PacingConfig, rng?: () => number): number;
46
+ /**
47
+ * Resolve a PacingConfig from explicit options, env vars, and defaults.
48
+ *
49
+ * Precedence (highest first):
50
+ * 1. Explicit argument (typically from CLI flag)
51
+ * 2. DARIO_PACE_MIN_MS / DARIO_PACE_JITTER_MS env vars
52
+ * 3. Legacy DARIO_MIN_INTERVAL_MS env var (minGap only — matches v3.23
53
+ * behavior so existing setups don't regress silently)
54
+ * 4. Defaults: minGap=500, jitter=0
55
+ *
56
+ * Invalid strings (non-numeric, negative) are ignored and fall through to
57
+ * the next source — a typoed env var shouldn't fail-loud at startup.
58
+ */
59
+ export declare function resolvePacingConfig(explicit?: {
60
+ minGapMs?: number;
61
+ jitterMs?: number;
62
+ }, env?: NodeJS.ProcessEnv): PacingConfig;
package/dist/pacing.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Inter-request pacing (v3.24, direction #6 — behavioral smoothing).
3
+ *
4
+ * Real CC traffic has human-paced gaps between requests — sub-second when
5
+ * the model is streaming tool-loop output, multi-second when the user is
6
+ * typing the next message. A proxy that fires requests at machine speed
7
+ * with perfectly uniform spacing stands out against that rhythm.
8
+ *
9
+ * This module supplies the pure gap-calculation function the proxy's
10
+ * rate governor calls before every outbound fetch. Two knobs:
11
+ *
12
+ * minGapMs — lower bound on the wall-clock distance between requests.
13
+ * Was a hardcoded 500ms through v3.23; keep 500 as default
14
+ * so back-compat is exact when both knobs stay at defaults.
15
+ *
16
+ * jitterMs — uniform random addition on top of minGap. The *effective*
17
+ * gap for a given request is minGap + U(0, jitter). Adds
18
+ * non-uniformity so an observer can't infer the floor from
19
+ * the long-run minimum of inter-arrival times.
20
+ *
21
+ * Pure over (now, lastRequestTime, minGap, jitter, rng) so the tests can
22
+ * exercise every edge without spawning timers. The proxy passes
23
+ * `Math.random` as the rng at runtime; tests pass a deterministic stub.
24
+ *
25
+ * The first request in a session (lastRequestTime === 0) is never paced —
26
+ * the purpose is smoothing the *gap between* requests, not delaying the
27
+ * first one from whenever the consumer happens to connect.
28
+ */
29
+ /**
30
+ * How many milliseconds to sleep before the next upstream fetch.
31
+ *
32
+ * Returns 0 when no delay is required — either because this is the first
33
+ * request of the session, or enough wall-clock time has already elapsed
34
+ * since `lastRequestTime`.
35
+ *
36
+ * `rng` defaults to Math.random; tests inject a deterministic stub.
37
+ * Negative configuration values are clamped to 0 (lenient, not an error).
38
+ */
39
+ export function computePacingDelay(now, lastRequestTime, cfg, rng = Math.random) {
40
+ if (lastRequestTime <= 0)
41
+ return 0;
42
+ const minGap = Math.max(0, cfg.minGapMs);
43
+ const jitter = Math.max(0, cfg.jitterMs);
44
+ const jitterAdd = jitter > 0 ? Math.floor(rng() * jitter) : 0;
45
+ const effectiveGap = minGap + jitterAdd;
46
+ const elapsed = now - lastRequestTime;
47
+ if (elapsed >= effectiveGap)
48
+ return 0;
49
+ return effectiveGap - elapsed;
50
+ }
51
+ /**
52
+ * Resolve a PacingConfig from explicit options, env vars, and defaults.
53
+ *
54
+ * Precedence (highest first):
55
+ * 1. Explicit argument (typically from CLI flag)
56
+ * 2. DARIO_PACE_MIN_MS / DARIO_PACE_JITTER_MS env vars
57
+ * 3. Legacy DARIO_MIN_INTERVAL_MS env var (minGap only — matches v3.23
58
+ * behavior so existing setups don't regress silently)
59
+ * 4. Defaults: minGap=500, jitter=0
60
+ *
61
+ * Invalid strings (non-numeric, negative) are ignored and fall through to
62
+ * the next source — a typoed env var shouldn't fail-loud at startup.
63
+ */
64
+ export function resolvePacingConfig(explicit = {}, env = process.env) {
65
+ const minGap = pickNonNegativeInt(explicit.minGapMs, env.DARIO_PACE_MIN_MS, env.DARIO_MIN_INTERVAL_MS) ?? 500;
66
+ const jitter = pickNonNegativeInt(explicit.jitterMs, env.DARIO_PACE_JITTER_MS) ?? 0;
67
+ return { minGapMs: minGap, jitterMs: jitter };
68
+ }
69
+ function pickNonNegativeInt(...candidates) {
70
+ for (const c of candidates) {
71
+ if (c === undefined || c === null || c === '')
72
+ continue;
73
+ const n = typeof c === 'number' ? c : parseInt(c, 10);
74
+ if (Number.isFinite(n) && n >= 0)
75
+ return Math.floor(n);
76
+ }
77
+ return undefined;
78
+ }
package/dist/proxy.d.ts CHANGED
@@ -12,6 +12,9 @@ interface ProxyOptions {
12
12
  preserveTools?: boolean;
13
13
  hybridTools?: boolean;
14
14
  noAutoDetect?: boolean;
15
+ strictTls?: boolean;
16
+ pacingMinMs?: number;
17
+ pacingJitterMs?: number;
15
18
  }
16
19
  export declare function sanitizeError(err: unknown): string;
17
20
  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
@@ -555,10 +571,19 @@ export async function startProxy(opts = {}) {
555
571
  betaBase = betaBase ? `${betaBase},oauth-2025-04-20` : 'oauth-2025-04-20';
556
572
  }
557
573
  const betaWithoutContext1m = betaBase.split(',').filter((t) => t !== 'context-1m-2025-08-07').join(',');
558
- // Rate governor — minimum 500ms between requests. Fast enough for agents,
559
- // slow enough to not look like a scripted flood of identical traffic.
574
+ // Rate governor — floor + optional jitter between requests. A hardcoded
575
+ // 500ms floor keeps the default behavior identical to v3.23; `--pace-min`
576
+ // and `--pace-jitter` let callers tune the distribution. Pure calc lives
577
+ // in src/pacing.ts so the edge cases are unit-tested without timers.
578
+ const { computePacingDelay, resolvePacingConfig } = await import('./pacing.js');
560
579
  let lastRequestTime = 0;
561
- const MIN_REQUEST_INTERVAL_MS = parseInt(process.env.DARIO_MIN_INTERVAL_MS || '500', 10);
580
+ const pacingCfg = resolvePacingConfig({
581
+ minGapMs: opts.pacingMinMs,
582
+ jitterMs: opts.pacingJitterMs,
583
+ });
584
+ if (verbose) {
585
+ console.log(`[dario] pacing: min=${pacingCfg.minGapMs}ms jitter=${pacingCfg.jitterMs}ms`);
586
+ }
562
587
  // Optional proxy authentication — pre-encode key buffer for performance
563
588
  const apiKey = process.env.DARIO_API_KEY;
564
589
  const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
@@ -1060,11 +1085,11 @@ export async function startProxy(opts = {}) {
1060
1085
  beta = beta.split(',').filter((t) => t.length > 0 && !rejectedSet.has(t)).join(',');
1061
1086
  }
1062
1087
  }
1063
- // Rate governor — prevent inhuman request cadence
1064
- const now = Date.now();
1065
- const elapsed = now - lastRequestTime;
1066
- if (elapsed < MIN_REQUEST_INTERVAL_MS && lastRequestTime > 0) {
1067
- await new Promise(r => setTimeout(r, MIN_REQUEST_INTERVAL_MS - elapsed));
1088
+ // Rate governor — prevent inhuman request cadence. See src/pacing.ts
1089
+ // for the pure delay calculator (floor + uniform jitter).
1090
+ const pacingDelay = computePacingDelay(Date.now(), lastRequestTime, pacingCfg);
1091
+ if (pacingDelay > 0) {
1092
+ await new Promise(r => setTimeout(r, pacingDelay));
1068
1093
  }
1069
1094
  lastRequestTime = Date.now();
1070
1095
  // Session ID: pool mode uses the per-account identity.sessionId (stable
@@ -1635,6 +1660,15 @@ export async function startProxy(opts = {}) {
1635
1660
  if (compat.status === 'below-min' || compat.status === 'untested-above') {
1636
1661
  console.log(`[dario] ⚠ CC compat: ${compat.message}`);
1637
1662
  }
1663
+ // TLS-fingerprint banner (v3.23). Proxy mode terminates TLS from this
1664
+ // process, so the Bun-vs-Node runtime choice is actually on the wire.
1665
+ // Silence via DARIO_QUIET_TLS=1 for known-fine environments.
1666
+ if (runtimeFp.status !== 'bun-match' && process.env.DARIO_QUIET_TLS !== '1') {
1667
+ console.log(`[dario] ⚠ TLS fingerprint: ${runtimeFp.detail}`);
1668
+ if (runtimeFp.hint)
1669
+ console.log(`[dario] → ${runtimeFp.hint}`);
1670
+ console.log('[dario] (silence with DARIO_QUIET_TLS=1, or use --strict-tls to hard-fail)');
1671
+ }
1638
1672
  // Kick off a live fingerprint refresh in the background. Re-captures the
1639
1673
  // user's own CC binary request shape and updates ~/.dario/cc-template.live.json
1640
1674
  // 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.24.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/pacing.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",