@askalf/dario 3.27.0 → 3.29.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 +153 -62
- package/dist/cc-oauth-detect.d.ts +1 -0
- package/dist/cc-oauth-detect.js +4 -0
- package/dist/cli.js +30 -1
- package/dist/proxy.d.ts +12 -0
- package/dist/proxy.js +121 -32
- package/dist/session-rotation.d.ts +153 -0
- package/dist/session-rotation.js +214 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<h1 align="center">dario</h1>
|
|
3
|
-
<p align="center"><strong>A universal LLM router that runs on your machine.<br>One local endpoint, every provider — Anthropic, OpenAI, Groq, OpenRouter, Ollama, any OpenAI-compat URL.
|
|
3
|
+
<p align="center"><strong>A universal LLM router that runs on your machine.<br>One local endpoint, every provider — Anthropic, OpenAI, Groq, OpenRouter, Ollama, any OpenAI-compat URL. Point your tools at localhost and stop caring which vendor is upstream.</strong></p>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
6
|
<p align="center">
|
|
@@ -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. ~
|
|
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. ~11,300 lines of TypeScript across ~25 files. ~1,250 assertions across 32 test suites. [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) on every release. Nothing phones home, ever.**
|
|
30
30
|
|
|
31
31
|
---
|
|
32
32
|
|
|
@@ -44,6 +44,8 @@ You point every tool at one URL. Dario reads each request, decides which backend
|
|
|
44
44
|
|
|
45
45
|
The tool doesn't know. The backend doesn't know. Dario is the seam.
|
|
46
46
|
|
|
47
|
+
Beyond routing, the Claude backend is a **full wire-level Claude Code replay** — every observable axis (bytes, headers, body key order, TLS stack, inter-request timing, session-id lifecycle, stream-consumption shape) is captured from your installed CC binary and replayed on outbound requests so Anthropic's classifier sees a CC session. See [Claude subscription backend](#2-claude-subscription-backend) and [Fingerprint axes](#fingerprint-axes).
|
|
48
|
+
|
|
47
49
|
---
|
|
48
50
|
|
|
49
51
|
## Quick start
|
|
@@ -57,6 +59,8 @@ npm install -g @askalf/dario
|
|
|
57
59
|
# 1. Claude via your Claude Max / Pro subscription (uses your Claude Code
|
|
58
60
|
# OAuth if CC is installed; runs its own OAuth flow otherwise)
|
|
59
61
|
dario login
|
|
62
|
+
# or, for SSH / container setups with no browser:
|
|
63
|
+
dario login --manual
|
|
60
64
|
|
|
61
65
|
# 2. OpenAI or any OpenAI-compat endpoint
|
|
62
66
|
dario backend add openai --key=sk-proj-...
|
|
@@ -76,25 +80,29 @@ export OPENAI_API_KEY=dario
|
|
|
76
80
|
|
|
77
81
|
That's it. Every tool that honors these standard env vars now reaches every backend you configured. No per-tool reconfiguration. No SDK changes. One URL, one fake key, every real provider behind it.
|
|
78
82
|
|
|
79
|
-
Something broken? `dario doctor` prints a single aggregated health report — dario version, Node, platform, CC binary compat, template source + age + drift, OAuth status, pool state, configured backends. Paste that instead of screenshots when you file an issue.
|
|
83
|
+
Something broken? `dario doctor` prints a single aggregated health report — dario version, Node, platform, runtime/TLS classification, CC binary compat, template source + age + drift, OAuth status, pool state, configured backends, sub-agent install state. Paste that instead of screenshots when you file an issue.
|
|
80
84
|
|
|
81
85
|
---
|
|
82
86
|
|
|
83
87
|
## Why you'll install this
|
|
84
88
|
|
|
85
|
-
**You want one URL for every provider.** Cursor, Aider, Continue, Zed, OpenHands, Claude Code, your own scripts — every tool you own has its own per-provider config. Dario collapses that into a single `localhost:3456` that speaks both Anthropic and OpenAI protocols and routes by model name.
|
|
89
|
+
**You want one URL for every provider.** Cursor, Aider, Continue, Zed, OpenHands, Claude Code, your own scripts — every tool you own has its own per-provider config. Dario collapses that into a single `localhost:3456` that speaks both Anthropic and OpenAI protocols and routes by model name.
|
|
86
90
|
|
|
87
|
-
**You pay for Claude Max but only use it in Claude Code.** Cursor, Aider, Zed, Continue — they all want API keys and bill per-token while your $200/mo subscription sits idle. Dario's Claude backend routes requests from all of them through your plan by replaying the exact Claude Code wire shape (template, tools, headers, billing tag) that Anthropic's classifier expects for subscription billing.
|
|
91
|
+
**You pay for Claude Max but only use it in Claude Code.** Cursor, Aider, Zed, Continue — they all want API keys and bill per-token while your $200/mo subscription sits idle. Dario's Claude backend routes requests from all of them through your plan by replaying the exact Claude Code wire shape (template, tools, headers, body key order, billing tag) that Anthropic's classifier expects for subscription billing. See [Claude subscription backend](#2-claude-subscription-backend).
|
|
88
92
|
|
|
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**
|
|
93
|
+
**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** 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
94
|
|
|
91
95
|
**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
96
|
|
|
93
|
-
**You want the proxy layer off the wire entirely.** **Shim mode**
|
|
97
|
+
**You want the proxy layer off the wire entirely.** **Shim mode** 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).
|
|
98
|
+
|
|
99
|
+
**You want dario itself addressable from inside Claude Code or any MCP client.** `dario subagent install` registers a first-party sub-agent under `~/.claude/agents/dario.md` so CC can delegate diagnostics and template-refresh in-session ([Claude Code sub-agent hook](#claude-code-sub-agent-hook-v326)). `dario mcp` turns dario itself into a read-only MCP server — Claude Desktop, Cursor, Zed, any MCP-aware editor can introspect dario's state (auth, pool, backends, template, fingerprint, runtime) without leaving the editor ([dario as MCP server](#dario-as-mcp-server-v327)).
|
|
100
|
+
|
|
101
|
+
**You want to share capacity with a trusted group without surveilling each other.** The **sealed-sender overflow protocol** uses RSA blind signatures (Chaum 1983, implemented from scratch over Node's `crypto`) so members of a trust group can lend unused Claude capacity to each other with cryptographic unlinkability. Dario ships the primitive; [mux](https://github.com/askalf/mux) is the dedicated product around it. See [Sealed-sender overflow](#sealed-sender-overflow-protocol).
|
|
94
102
|
|
|
95
|
-
**You want
|
|
103
|
+
**You want certainty that the proxy isn't trivially fingerprintable.** The "get ahead of Anthropic" release track (v3.22 – v3.28) closed six observable divergence axes between dario and real Claude Code: body field order (v3.22), TLS ClientHello (v3.23), inter-request timing (v3.24), stream-consumption shape (v3.25), sub-agent/MCP reach (v3.26/v3.27), and session-id lifecycle (v3.28). See [Fingerprint axes](#fingerprint-axes).
|
|
96
104
|
|
|
97
|
-
**You want to actually audit the thing.** ~
|
|
105
|
+
**You want to actually audit the thing.** ~11,300 lines of TypeScript across ~25 files. Zero runtime dependencies (`npm ls --production` confirms). Credentials at `~/.dario/` with `0600` permissions. `127.0.0.1`-only by default. Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions. Nothing phones home. Small enough to read in a weekend.
|
|
98
106
|
|
|
99
107
|
---
|
|
100
108
|
|
|
@@ -107,6 +115,7 @@ Something broken? `dario doctor` prints a single aggregated health report — da
|
|
|
107
115
|
- **Anyone building AI coding tools** who wants provider independence without writing an OpenAI ↔ Anthropic translator themselves.
|
|
108
116
|
- **Claude Max / Pro subscribers** who want their subscription usable from every tool on their machine, not just Claude Code.
|
|
109
117
|
- **Power users on multi-agent workloads** who want multi-account pooling, session stickiness, and in-flight 429 failover on their own machine, against their own subscriptions.
|
|
118
|
+
- **Operators who care about wire-level fidelity** — the fingerprint tightening in v3.22 – v3.28 means proxy mode's divergence from CC is observable (via `dario doctor`) and tunable (flags + env vars for each axis).
|
|
110
119
|
|
|
111
120
|
**Not a fit if:**
|
|
112
121
|
|
|
@@ -154,26 +163,43 @@ Force a backend with a **provider prefix** on the model field (`openai:gpt-4o`,
|
|
|
154
163
|
|
|
155
164
|
### 2. Claude subscription backend
|
|
156
165
|
|
|
157
|
-
OAuth-backed Claude Max / Pro, billed against your plan instead of the API. Activated by `dario login
|
|
166
|
+
OAuth-backed Claude Max / Pro, billed against your plan instead of the API. Activated by `dario login` (or `dario login --manual` for SSH / container setups without a browser, v3.20).
|
|
158
167
|
|
|
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, **
|
|
168
|
+
**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, **header insertion order, static header values, `anthropic-beta` flag set, and top-level 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
169
|
|
|
161
170
|
**Key mechanisms:**
|
|
162
171
|
|
|
163
|
-
- **Live fingerprint extraction
|
|
172
|
+
- **Live fingerprint extraction.** 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** (replayed by the shim since v3.13 and the proxy since v3.16), **static header values** and **`anthropic-beta` flag set** (v3.19), and **top-level request-body key order** (v3.22, schema v3). 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 and `mcp__*` tool names at bake time (v3.21 — see `src/scrub-template.ts`).
|
|
164
173
|
- **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
|
-
- **Compat matrix** (v3.17
|
|
174
|
+
- **Compat matrix** (v3.17, bumped in v3.19.5). `SUPPORTED_CC_RANGE` is encoded in code; installed CC outside the band prints a warn (untested above) or fail (below min) — zero-dep dotted-numeric comparator, no `semver` import per the dep policy.
|
|
166
175
|
- **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)`.
|
|
167
|
-
- **OAuth config auto-detection** from the installed CC binary. When Anthropic rotates `client_id`, authorize URL, or scopes, dario picks up the new values on the next run without needing a release.
|
|
176
|
+
- **OAuth config auto-detection** from the installed CC binary. When Anthropic rotates `client_id`, authorize URL, or scopes, dario picks up the new values on the next run without needing a release. Cache at `~/.dario/cc-oauth-cache-v4.json`, keyed by the CC binary fingerprint.
|
|
168
177
|
- **Multi-account pool mode** — see [Multi-account pool mode](#multi-account-pool-mode). Automatic when 2+ accounts are configured.
|
|
169
178
|
- **Framework scrubbing** — known fingerprint tokens (`OpenClaw`, `sessions_*` prefixes, orchestration tags) stripped from system prompt and message content before the request leaves your machine.
|
|
170
179
|
- **Atomic cache writes + cache corruption recovery** (v3.17). Template cache writes go through pid-qualified `.tmp` + `rename`, so an OS crash mid-write doesn't leave a half-written file. Unparseable cache files get quarantined to `cc-template.live.json.bad-<timestamp>` and dario self-heals on the next capture.
|
|
171
180
|
- **OAuth single-flight** (v3.17). Two concurrent refreshes for the same account alias now share one outbound `POST /oauth/token`, so the pool's background refresh timer and a user-triggered request at the same millisecond can't race and invalidate each other's refresh token.
|
|
172
|
-
- **Bun auto-relaunch
|
|
181
|
+
- **Bun auto-relaunch.** When Bun is installed, dario relaunches under it so the TLS ClientHello matches CC's runtime (Bun uses BoringSSL; Node uses OpenSSL — distinct JA3/JA4 hashes). Without Bun, dario runs on Node.js — `dario doctor` surfaces the mismatch as of v3.23 and `--strict-tls` refuses to start proxy mode until it's resolved.
|
|
173
182
|
|
|
174
183
|
**Passthrough mode** (`dario proxy --passthrough`) does an OAuth swap and nothing else — no template, no identity, no scrubbing. Use it when the upstream tool already builds a Claude-Code-shaped request on its own.
|
|
175
184
|
|
|
176
|
-
**Detection scope.** The Claude backend is a per-request layer. Template replay and scrubbing are designed to be indistinguishable from CC at the request level. What they *cannot* defend against is Anthropic's session-level behavioral classifier, which operates on cumulative per-OAuth aggregates (
|
|
185
|
+
**Detection scope.** The Claude backend is a per-request layer. Template replay and scrubbing are designed to be indistinguishable from CC at the request level. What they *cannot* defend against on their own is Anthropic's session-level behavioral classifier, which operates on cumulative per-OAuth aggregates. The v3.22 – v3.28 "get ahead of Anthropic" track closed six of those cumulative axes (body order, TLS, pacing, stream-drain, session-id lifecycle, MCP/sub-agent surface); for anything left, **pool mode** distributes load across multiple subscriptions so no single account accumulates enough signal to trip anything.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Fingerprint axes
|
|
190
|
+
|
|
191
|
+
Between v3.22 and v3.28, dario's Claude backend closed six axes along which a proxy can look different from real Claude Code. Each is a separate knob, each ships with its own test suite, each is surfaced through `dario doctor` where the axis has something to report. Defaults are chosen so existing setups don't regress.
|
|
192
|
+
|
|
193
|
+
| Axis | Release | What it does | How to tune |
|
|
194
|
+
|---|---|---|---|
|
|
195
|
+
| **Request body key order** | v3.22 | Top-level JSON key order of the outbound `/v1/messages` body is captured from CC's wire serialization and replayed byte-for-byte. Schema bumped v2 → v3; stale caches quarantined. | Automatic once a live capture exists. The baked fallback carries a v2.1.112 snapshot. |
|
|
196
|
+
| **Runtime / TLS ClientHello** | v3.23 | Classifies the runtime as `bun-match` / `bun-bypassed` / `node-only` and surfaces the class + hint in `dario doctor`. Bun yields the BoringSSL ClientHello CC presents; Node yields OpenSSL's (distinct JA3). | `--strict-tls` (or `DARIO_STRICT_TLS=1`) refuses to start proxy mode unless `bun-match`. `DARIO_QUIET_TLS=1` silences the startup banner in known-fine environments. |
|
|
197
|
+
| **Inter-request timing** | v3.24 | Replaces the hardcoded 500 ms floor with a configurable floor + uniform jitter. A 500 ms minimum-inter-arrival edge is fingerprintable at scale; jitter dissolves the edge. | `--pace-min=MS`, `--pace-jitter=MS`, or `DARIO_PACE_MIN_MS` / `DARIO_PACE_JITTER_MS`. Legacy `DARIO_MIN_INTERVAL_MS` still honored. |
|
|
198
|
+
| **Stream-consumption shape** | v3.25 | When a downstream client disconnects mid-stream, CC keeps reading SSE to EOF. Dario now offers the same: drain upstream to completion even when the consumer has left. Default off — don't silently burn tokens. | `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1`. Bounded by the existing 5-minute upstream timeout. |
|
|
199
|
+
| **Session-ID lifecycle** | v3.28 | Generalizes the v3.19 hardcoded 15-minute idle rotation into a tunable `SessionRegistry` with jitter, max-age, and per-client bucketing. Fixes a v3.27 body/header rotation race as a side effect. | `--session-idle-rotate=MS` (default 900000), `--session-rotate-jitter=MS`, `--session-max-age=MS`, `--session-per-client`. Env mirrors `DARIO_SESSION_*`. Defaults are bit-identical to v3.27. |
|
|
200
|
+
| **MCP / sub-agent reach** | v3.26 + v3.27 | Not a wire axis — a *surface* axis. CC-aware tools can now address dario directly (sub-agent from inside CC, MCP server for any MCP client), so operators don't have to switch terminals to introspect the proxy. Read-only by design. | `dario subagent install` / `dario mcp`. See dedicated sections below. |
|
|
201
|
+
|
|
202
|
+
The six-direction "get ahead of Anthropic" roadmap is complete. Subsequent releases return to responding to issues and upstream template drift.
|
|
177
203
|
|
|
178
204
|
---
|
|
179
205
|
|
|
@@ -197,7 +223,7 @@ headroom = 1 - max(util_5h, util_7d)
|
|
|
197
223
|
|
|
198
224
|
The response's `anthropic-ratelimit-unified-*` headers are parsed back into the pool so the next selection sees fresh utilization. An account that returns a 429 is marked `rejected` and routed around until its window resets. When every account is exhausted, requests queue for up to 60 seconds waiting for headroom to reappear. Plans can mix freely — Max and Pro accounts sit in the same pool; dario doesn't care about tier, only headroom.
|
|
199
225
|
|
|
200
|
-
### Session stickiness
|
|
226
|
+
### Session stickiness
|
|
201
227
|
|
|
202
228
|
Multi-turn agent sessions pin to one account for the life of the conversation, so the Anthropic prompt cache isn't destroyed by account rotation between turns.
|
|
203
229
|
|
|
@@ -205,7 +231,7 @@ Multi-turn agent sessions pin to one account for the life of the conversation, s
|
|
|
205
231
|
|
|
206
232
|
**The fix.** Dario hashes a conversation's first user message into a 16-hex-char `stickyKey` (SHA-256 truncated, deterministic) and binds the key to whichever account `select()` would have picked on turn 1. Subsequent turns re-use that account as long as it's still healthy (not rejected, token not near expiry, headroom > 2%). On 429 failover, dario rebinds the key to the new account so the next turn doesn't re-select the exhausted one. 6h TTL, 2,000-entry cap, lazy cleanup. No client cooperation required.
|
|
207
233
|
|
|
208
|
-
### In-flight 429 failover
|
|
234
|
+
### In-flight 429 failover
|
|
209
235
|
|
|
210
236
|
When a Claude request hits a 429 mid-flight, dario retries the *same request* against a different account before the client sees an error. The client sees one successful response; the pool sees the rejected account go cold until its window resets. Combined with session stickiness, long agent runs survive pool-level exhaustion without dropping user-facing turns.
|
|
211
237
|
|
|
@@ -220,7 +246,7 @@ Every request carries a `billingBucket` field (`subscription` / `subscription_fa
|
|
|
220
246
|
|
|
221
247
|
---
|
|
222
248
|
|
|
223
|
-
## Sealed-sender overflow protocol
|
|
249
|
+
## Sealed-sender overflow protocol
|
|
224
250
|
|
|
225
251
|
Trust-group members can lend each other Claude capacity with **cryptographic unlinkability**: a lender can verify the borrower is a valid group member without learning *which* member, so no one in the pool can surveil another through borrow telemetry.
|
|
226
252
|
|
|
@@ -255,7 +281,7 @@ Under the hood: `dario shim` spawns the child with `NODE_OPTIONS=--require <dari
|
|
|
255
281
|
|
|
256
282
|
**Why it matters.** Anthropic can fingerprint a proxy via TLS, headers, IP, or `BASE_URL` env. They literally cannot easily detect a `globalThis.fetch` monkey-patch from inside their own process without shipping signed-binary integrity checks against `globalThis` from inside the CC binary — and even then, the shim runs *before* CC's code loads, so it could patch the integrity check too. The longest-half-life transport against classifier evolution.
|
|
257
283
|
|
|
258
|
-
**v3.13
|
|
284
|
+
**Hardening (v3.13+)** added runtime detection (canary for the day Anthropic ships a Bun-compiled CC), template mtime-based auto-reload (long-running children pick up mid-session fingerprint refreshes without restart), strict defensive `rewriteBody` (requires exactly 3 text blocks, passes through on any mismatch instead of inventing structure), and header-order replay (honors captured CC header sequence so the shim matches CC wire-exact).
|
|
259
285
|
|
|
260
286
|
**When to use shim mode:**
|
|
261
287
|
- Running a single CC instance on a locked-down machine where binding a local port is inconvenient.
|
|
@@ -272,12 +298,12 @@ Under the hood: `dario shim` spawns the child with `NODE_OPTIONS=--require <dari
|
|
|
272
298
|
|
|
273
299
|
## Agent compatibility
|
|
274
300
|
|
|
275
|
-
|
|
301
|
+
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
302
|
|
|
277
303
|
| Agent | Covered tool names (subset) |
|
|
278
304
|
|---|---|
|
|
279
305
|
| Claude Code | default — CC's own tools |
|
|
280
|
-
| Cline / Roo Code | `execute_command`, `write_to_file`, `replace_in_file`, `apply_diff`, `list_files`, `search_files`, `read_file` |
|
|
306
|
+
| Cline / Roo Code / Kilo Code | `execute_command`, `write_to_file`, `replace_in_file`, `apply_diff`, `list_files`, `search_files`, `read_file` |
|
|
281
307
|
| Cursor | `run_terminal_cmd`, `edit_file`, `search_replace`, `codebase_search`, `grep_search`, `file_search`, `list_dir`, `read_file` (`target_file`) |
|
|
282
308
|
| Windsurf | `run_command`, `view_file`, `write_to_file`, `replace_file_content`, `find_by_name`, `grep_search`, `list_dir`, `search_web`, `read_url_content` |
|
|
283
309
|
| Continue.dev | `builtin_run_terminal_command`, `builtin_read_file`, `builtin_create_new_file`, `builtin_edit_existing_file`, `builtin_file_glob_search`, `builtin_grep_search`, `builtin_ls` |
|
|
@@ -286,29 +312,70 @@ As of **v3.22**, dario's built-in `TOOL_MAP` carries **~66 schema-verified entri
|
|
|
286
312
|
| OpenClaw | `exec`, `process`, `web_search`, `web_fetch`, `browser`, `message` |
|
|
287
313
|
| Hermes | `terminal`, `patch`, `web_extract`, `clarify` |
|
|
288
314
|
|
|
289
|
-
|
|
315
|
+
Text-tool clients (Cline / Kilo Code / Roo Code and forks) are auto-detected via system-prompt fingerprint and automatically flipped into preserve-tools mode, because mixing CC's `tools` array with their XML protocol makes the model emit `<function_calls><invoke>` that their parsers can't read. If you run dario specifically for fingerprint fidelity and would rather pick `--preserve-tools` yourself, `--no-auto-detect` (v3.20.1, aka `--no-auto-preserve`) disables the heuristic — explicit operator choice then wins.
|
|
316
|
+
|
|
317
|
+
If your agent's tool names aren't pre-mapped and its tools carry fields CC's schema doesn't have, there are two escape hatches: **`--preserve-tools`** (forward your schema verbatim, lose the CC fingerprint) or **`--hybrid-tools`** (keep the fingerprint, fill request-context fields from headers). See [Custom tool schemas](#custom-tool-schemas).
|
|
290
318
|
|
|
291
319
|
The OpenAI-compat backend forwards tool definitions byte-for-byte and doesn't need any of this.
|
|
292
320
|
|
|
293
321
|
---
|
|
294
322
|
|
|
323
|
+
## dario as MCP server (v3.27)
|
|
324
|
+
|
|
325
|
+
`dario mcp` turns dario itself into a **stdio JSON-RPC 2.0 MCP server**. Claude Desktop, Cursor, Zed, any MCP-aware editor can introspect dario's state without leaving the editor.
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
dario mcp # spawns the MCP server on stdin/stdout — wire it up to your MCP client
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Strictly read-only.** The exposed tool set is:
|
|
332
|
+
|
|
333
|
+
| Tool | What it reports |
|
|
334
|
+
|---|---|
|
|
335
|
+
| `doctor` | Full aggregated health report — same output as `dario doctor` |
|
|
336
|
+
| `status` | OAuth authentication state (authenticated / no-credentials / expired-but-refreshable) |
|
|
337
|
+
| `accounts_list` | Pool accounts + expiry times. Never touches API keys. |
|
|
338
|
+
| `backends_list` | Configured OpenAI-compat backends — keys redacted completely (not even a `sk-…` prefix) |
|
|
339
|
+
| `subagent_status` | CC sub-agent install and version-match state |
|
|
340
|
+
| `fingerprint_info` | Runtime / TLS classification, template source + schema version |
|
|
341
|
+
|
|
342
|
+
Mutations (`login`, `logout`, `accounts add/remove`, `backend add/remove`, `subagent install/remove`, `proxy` start/stop) are **not** exposed. An MCP client can observe dario; changing dario's state stays a CLI action the user types with intent. The test suite asserts the forbidden-tool set stays forbidden so a future accidental drift gets caught.
|
|
343
|
+
|
|
344
|
+
Zero runtime deps — the JSON-RPC dispatcher is hand-rolled over Node's `readline`. `src/mcp/protocol.ts` + `src/mcp/tools.ts` + `src/mcp/server.ts` are each pure over their inputs (streams are injectable, data sources are injectable) so the e2e test runs in-process against a `PassThrough` pair.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Claude Code sub-agent hook (v3.26)
|
|
349
|
+
|
|
350
|
+
`dario subagent install` writes `~/.claude/agents/dario.md` so Claude Code has a named handle for running dario diagnostics and template-refresh inside an ongoing CC session. No more `Ctrl+Z → dario doctor → fg` when you hit a `[WARN]` row mid-conversation.
|
|
351
|
+
|
|
352
|
+
```bash
|
|
353
|
+
dario subagent install # writes ~/.claude/agents/dario.md
|
|
354
|
+
dario subagent status # {not-installed, installed+current, installed+stale} + hint
|
|
355
|
+
dario subagent remove # idempotent
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**Tool-scoped.** The sub-agent is restricted to `Bash, Read` and its prompt forbids destructive operations (credential mutation, account pool changes, backend config changes) without explicit user confirmation. `dario proxy` is also off-limits from inside the sub-agent — it would block the parent CC session. CC can ask dario to *report*, not to *change state*. (MCP server has the same read-only boundary for the same reason.)
|
|
359
|
+
|
|
360
|
+
A version marker (`<!-- dario-sub-agent-version: X -->`) embedded in the markdown lets `dario doctor` distinguish installed-and-current from installed-and-stale; the "Sub-agent" row appears between Backends and Home with an inline refresh command when stale.
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
295
364
|
## Commands
|
|
296
365
|
|
|
297
366
|
| Command | Description |
|
|
298
367
|
|---|---|
|
|
299
|
-
| `dario login` | Log in to the Claude backend
|
|
368
|
+
| `dario login [--manual]` | Log in to the Claude backend. Detects CC credentials or runs its own OAuth flow. `--manual` (v3.20) mirrors CC's code-paste flow for SSH / container setups without a browser. |
|
|
300
369
|
| `dario proxy` | Start the local API proxy on port 3456 |
|
|
301
|
-
| `dario doctor` | Aggregated health report — dario / Node / CC binary + compat / template + drift / OAuth / pool / backends |
|
|
370
|
+
| `dario doctor` | Aggregated health report — dario / Node / runtime-TLS / CC binary + compat / template + drift / OAuth / pool / backends / sub-agent |
|
|
302
371
|
| `dario status` | Show Claude backend OAuth token health and expiry |
|
|
303
372
|
| `dario refresh` | Force an immediate Claude token refresh |
|
|
304
373
|
| `dario logout` | Delete stored Claude credentials |
|
|
305
|
-
| `dario accounts list`
|
|
306
|
-
| `dario
|
|
307
|
-
| `dario accounts remove <alias>` | Remove an account from the pool |
|
|
308
|
-
| `dario backend list` | List configured OpenAI-compat backends |
|
|
309
|
-
| `dario backend add <name> --key=<key> [--base-url=<url>]` | Add an OpenAI-compat backend |
|
|
310
|
-
| `dario backend remove <name>` | Remove an OpenAI-compat backend |
|
|
374
|
+
| `dario accounts list` / `add <alias>` / `remove <alias>` | Multi-account pool management |
|
|
375
|
+
| `dario backend list` / `add <name> --key=<key> [--base-url=<url>]` / `remove <name>` | OpenAI-compat backend management |
|
|
311
376
|
| `dario shim -- <cmd> [args...]` | Run a child process with the in-process fetch patch (see [Shim mode](#shim-mode)) |
|
|
377
|
+
| `dario subagent install` / `remove` / `status` | CC sub-agent lifecycle (v3.26 — see [sub-agent hook](#claude-code-sub-agent-hook-v326)) |
|
|
378
|
+
| `dario mcp` | Run dario as an MCP server over stdio (v3.27 — see [dario as MCP server](#dario-as-mcp-server-v327)) |
|
|
312
379
|
| `dario help` | Full command reference |
|
|
313
380
|
|
|
314
381
|
### Proxy options
|
|
@@ -316,17 +383,27 @@ The OpenAI-compat backend forwards tool definitions byte-for-byte and doesn't ne
|
|
|
316
383
|
| Flag / env | Description | Default |
|
|
317
384
|
|---|---|---|
|
|
318
385
|
| `--passthrough` / `--thin` | Thin proxy for the Claude backend — OAuth swap only, no template injection | off |
|
|
319
|
-
| `--preserve-tools` / `--keep-tools` | Keep client tool schemas instead of remapping to CC's. Required for clients whose tools have fields CC doesn't — see [Custom tool schemas](#custom-tool-schemas). Auto-enabled for Cline / Kilo Code / Roo Code and forks (
|
|
320
|
-
| `--
|
|
321
|
-
| `--
|
|
386
|
+
| `--preserve-tools` / `--keep-tools` | Keep client tool schemas instead of remapping to CC's. Required for clients whose tools have fields CC doesn't — see [Custom tool schemas](#custom-tool-schemas). Auto-enabled for Cline / Kilo Code / Roo Code and forks (detected via system-prompt fingerprint). | off (auto for text-tool clients) |
|
|
387
|
+
| `--no-auto-detect` / `--no-auto-preserve` | Disable the text-tool-client detector so the CC fingerprint stays intact on Cline/Kilo/Roo prompts (v3.20.1, dario#40). Explicit `--preserve-tools` still wins. | off |
|
|
388
|
+
| `--hybrid-tools` / `--context-inject` | Remap to CC tools **and** inject request-context values (`sessionId`, `requestId`, `channelId`, `userId`, `timestamp`) into client-declared fields CC's schema doesn't carry. See [Hybrid tool mode](#hybrid-tool-mode). | off |
|
|
389
|
+
| `--model=<name>` | Force a model. Shortcuts (`opus`, `sonnet`, `haiku`), full IDs (`claude-opus-4-7`), or a **provider prefix** (`openai:gpt-4o`, `groq:llama-3.3-70b`, `claude:opus`, `local:qwen-coder`) to force the backend server-wide. | passthrough |
|
|
322
390
|
| `--port=<n>` | Port to listen on | `3456` |
|
|
323
391
|
| `--host=<addr>` / `DARIO_HOST` | Bind address. Use `0.0.0.0` for LAN, or a specific IP (e.g. a Tailscale interface). When non-loopback, also set `DARIO_API_KEY`. | `127.0.0.1` |
|
|
324
392
|
| `--verbose` / `-v` | Log every request (one line per request — method + path + billing bucket) | off |
|
|
325
|
-
| `--verbose=2` / `-vv` / `DARIO_LOG_BODIES=1` | Also dump the outbound request body (redacted: bearer tokens, `sk-ant-*` keys, JWTs stripped; capped at 8KB). For wire-level client-compat debugging
|
|
393
|
+
| `--verbose=2` / `-vv` / `DARIO_LOG_BODIES=1` | Also dump the outbound request body (redacted: bearer tokens, `sk-ant-*` keys, JWTs stripped; capped at 8KB). For wire-level client-compat debugging. | off |
|
|
394
|
+
| `--strict-tls` / `DARIO_STRICT_TLS=1` | Refuse to start proxy mode unless runtime classifies as `bun-match` — i.e. the TLS ClientHello matches CC's. See [Fingerprint axes](#fingerprint-axes). (v3.23) | off |
|
|
395
|
+
| `--pace-min=<ms>` / `DARIO_PACE_MIN_MS` | Minimum inter-request gap in ms. Replaces the legacy hardcoded 500 ms. (v3.24) | `500` |
|
|
396
|
+
| `--pace-jitter=<ms>` / `DARIO_PACE_JITTER_MS` | Uniform random jitter added to each gap. Dissolves the minimum-inter-arrival fingerprint edge. (v3.24) | `0` |
|
|
397
|
+
| `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1` | When a downstream client disconnects mid-stream, keep reading upstream SSE to completion (match CC's consumption shape). Bounded by the 5-min upstream timeout. (v3.25) | off |
|
|
398
|
+
| `--session-idle-rotate=<ms>` / `DARIO_SESSION_IDLE_ROTATE_MS` | Idle threshold before a session-id rotates. (v3.28) | `900000` (15 min) |
|
|
399
|
+
| `--session-rotate-jitter=<ms>` / `DARIO_SESSION_JITTER_MS` | Jitter sampled once per session at creation — hides the exact idle floor. (v3.28) | `0` |
|
|
400
|
+
| `--session-max-age=<ms>` / `DARIO_SESSION_MAX_AGE_MS` | Hard ceiling on a session-id's lifetime regardless of activity. (v3.28) | off |
|
|
401
|
+
| `--session-per-client` / `DARIO_SESSION_PER_CLIENT=1` | Split session-id registry by a per-client header so multi-UI fan-out doesn't collapse onto one id. (v3.28) | off |
|
|
326
402
|
| `DARIO_API_KEY` | If set, all endpoints (except `/health`) require a matching `x-api-key` or `Authorization: Bearer` header. Required when `--host` binds non-loopback. | unset (open) |
|
|
327
403
|
| `DARIO_CORS_ORIGIN` | Override browser CORS origin | `http://localhost:${port}` |
|
|
404
|
+
| `DARIO_QUIET_TLS` | Suppress the runtime/TLS mismatch startup banner | unset |
|
|
328
405
|
| `DARIO_NO_BUN` | Disable automatic Bun relaunch | unset |
|
|
329
|
-
| `DARIO_MIN_INTERVAL_MS` |
|
|
406
|
+
| `DARIO_MIN_INTERVAL_MS` | Legacy name for `DARIO_PACE_MIN_MS`. Still honored; new name wins when both are set. | — |
|
|
330
407
|
| `DARIO_CC_PATH` | Override path to the Claude Code binary for OAuth detection | auto-detect |
|
|
331
408
|
|
|
332
409
|
---
|
|
@@ -418,17 +495,15 @@ curl http://localhost:3456/v1/chat/completions \
|
|
|
418
495
|
|
|
419
496
|
### Streaming, tool use, prompt caching, extended thinking
|
|
420
497
|
|
|
421
|
-
All supported. Claude backend: full Anthropic SSE format plus OpenAI-SSE translation for tool_use streaming. OpenAI-compat backend: streaming body forwarded byte-for-byte.
|
|
498
|
+
All supported. Claude backend: full Anthropic SSE format plus OpenAI-SSE translation for tool_use streaming. OpenAI-compat backend: streaming body forwarded byte-for-byte. See [Fingerprint axes](#fingerprint-axes) for the v3.25 `--drain-on-close` knob that matches CC's read-to-EOF stream-consumption pattern.
|
|
422
499
|
|
|
423
500
|
### Provider prefix
|
|
424
501
|
|
|
425
|
-
Any request's `model` field can be written as `<provider>:<name>` to force which backend handles it, regardless of what the model name looks like.
|
|
426
|
-
|
|
427
|
-
Recognized prefixes:
|
|
502
|
+
Any request's `model` field can be written as `<provider>:<name>` to force which backend handles it, regardless of what the model name looks like.
|
|
428
503
|
|
|
429
504
|
| Prefix | Backend |
|
|
430
505
|
|---|---|
|
|
431
|
-
| `openai:` | OpenAI-compat backend
|
|
506
|
+
| `openai:` | OpenAI-compat backend |
|
|
432
507
|
| `groq:` | OpenAI-compat backend |
|
|
433
508
|
| `openrouter:` | OpenAI-compat backend |
|
|
434
509
|
| `local:` | OpenAI-compat backend |
|
|
@@ -452,7 +527,7 @@ Fix: run dario with `--preserve-tools`. That skips the CC tool remap entirely, p
|
|
|
452
527
|
dario proxy --preserve-tools
|
|
453
528
|
```
|
|
454
529
|
|
|
455
|
-
The cost: requests no longer look like CC on the wire, so the CC subscription fingerprint is gone. On a Max/Pro plan, that means the request may be counted against your API usage rather than your subscription quota.
|
|
530
|
+
The cost: requests no longer look like CC on the wire, so the CC subscription fingerprint is gone. On a Max/Pro plan, that means the request may be counted against your API usage rather than your subscription quota. [Hybrid tool mode](#hybrid-tool-mode) below is the compromise that keeps both.
|
|
456
531
|
|
|
457
532
|
The OpenAI-compat backend is unaffected — it forwards tool definitions byte-for-byte and doesn't need this flag.
|
|
458
533
|
|
|
@@ -474,6 +549,7 @@ dario proxy --hybrid-tools
|
|
|
474
549
|
| Your custom fields are request context (session/request/channel/user ids, timestamps) | `--hybrid-tools` | Keeps the CC fingerprint *and* your validator is satisfied. |
|
|
475
550
|
| Your custom fields need the model's reasoning (e.g. `confidence`, `reasoning_trace`, `tool_selection_rationale`) | `--preserve-tools` | The model has to see the real schema to populate these. Accept the fingerprint loss. |
|
|
476
551
|
| Your client's tools are already a subset of CC's `Bash/Read/Write/Edit/Grep/Glob/WebSearch/WebFetch` | *(neither)* | Default mode works as-is. |
|
|
552
|
+
| You're on a text-tool client (Cline / Kilo Code / Roo Code) and want to override the auto-detect | `--no-auto-detect` (plus `--preserve-tools` or not, your call) | Operator choice outranks the heuristic. |
|
|
477
553
|
|
|
478
554
|
### Library mode
|
|
479
555
|
|
|
@@ -515,17 +591,18 @@ Dario handles your OAuth tokens and API keys locally. Here's why you can trust i
|
|
|
515
591
|
|
|
516
592
|
| Signal | Status |
|
|
517
593
|
|---|---|
|
|
518
|
-
| **Source code** | ~
|
|
594
|
+
| **Source code** | ~11,300 lines of TypeScript across ~25 files — small enough to audit in a weekend |
|
|
519
595
|
| **Dependencies** | 0 runtime dependencies. Verify: `npm ls --production` |
|
|
520
596
|
| **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
597
|
| **Security scanning** | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) runs on every push and weekly |
|
|
522
|
-
| **Test footprint** | ~
|
|
523
|
-
| **Credential handling** | Tokens and API keys never logged, redacted from errors, stored with `0600` permissions |
|
|
524
|
-
| **OAuth flow** | PKCE (Proof Key for Code Exchange), no client secret |
|
|
598
|
+
| **Test footprint** | ~1,250 assertions across 32 test suites. Full `npm test` green on every release |
|
|
599
|
+
| **Credential handling** | Tokens and API keys never logged, redacted from errors, stored with `0600` permissions. MCP server (v3.27) redacts keys at the tool boundary too — not even a `sk-…` prefix leaks. |
|
|
600
|
+
| **OAuth flow** | PKCE (Proof Key for Code Exchange), no client secret. `--manual` flow for headless setups (v3.20). |
|
|
525
601
|
| **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 |
|
|
526
602
|
| **SSRF protection** | `/v1/messages` hits `api.anthropic.com` only; `/v1/chat/completions` hits the configured backend `baseUrl` only — hardcoded allowlist |
|
|
527
|
-
| **Telemetry** | None. Zero analytics, tracking, or data collection |
|
|
603
|
+
| **Telemetry** | None. Zero analytics, tracking, or data collection. The MCP server (v3.27) and CC sub-agent (v3.26) are read-only by design — no tool can mutate dario's state from inside CC or an MCP client. |
|
|
528
604
|
| **Atomic cache writes + corruption recovery** | v3.17 — template cache writes are pid-qualified `.tmp` + `rename`, corrupt cache files are quarantined and regenerated instead of crashing startup |
|
|
605
|
+
| **Baked template scrub** | v3.21 — the bundled fallback template is stripped of host-identifying paths and `mcp__*` tool names at bake time; the nightly drift watcher guards against regression |
|
|
529
606
|
| **Audit trail** | [CHANGELOG.md](CHANGELOG.md) documents every release with file-level rationale |
|
|
530
607
|
|
|
531
608
|
Verify the npm tarball matches this repo:
|
|
@@ -550,10 +627,10 @@ Claude Max and Claude Pro. Any plan that lets you use Claude Code.
|
|
|
550
627
|
Should work if your plan includes Claude Code access. Not widely tested yet — open an issue with results.
|
|
551
628
|
|
|
552
629
|
**Do I need Claude Code installed?**
|
|
553
|
-
Recommended for the Claude backend, not strictly required. With CC installed, `dario login` picks up your credentials automatically, and the live fingerprint extractor reads your CC binary on every startup so the template stays current. Without CC, dario runs its own OAuth flow and falls back to the bundled template snapshot
|
|
630
|
+
Recommended for the Claude backend, not strictly required. With CC installed, `dario login` picks up your credentials automatically, and the live fingerprint extractor reads your CC binary on every startup so the template stays current. Without CC, dario runs its own OAuth flow and falls back to the bundled template snapshot (scrubbed of host context at bake time as of v3.21). Drift detection warns you if your installed CC doesn't match the captured template, so upgrade windows don't silently ship stale templates.
|
|
554
631
|
|
|
555
632
|
**Do I need Bun?**
|
|
556
|
-
Optional, recommended for Claude-backend requests. Dario auto-relaunches under Bun when available so the TLS
|
|
633
|
+
Optional, strongly recommended for Claude-backend requests. Dario auto-relaunches under Bun when available so the TLS ClientHello matches CC's runtime. Without Bun, dario runs on Node.js and works fine — the TLS fingerprint is the only difference. As of v3.23, `dario doctor` surfaces the mismatch explicitly and `--strict-tls` refuses to start proxy mode until it's resolved. The shim transport sidesteps this entirely (it runs inside CC's own process, so its TLS stack *is* CC's).
|
|
557
634
|
|
|
558
635
|
**Can I use dario without a Claude subscription?**
|
|
559
636
|
Yes. Skip `dario login`, just run `dario backend add openai --key=...` (or any OpenAI-compat URL) and `dario proxy`. Claude-backend requests will return an authentication error; OpenAI-compat requests will work normally. Dario becomes a local OpenAI-compat router with no Claude involvement.
|
|
@@ -562,13 +639,13 @@ Yes. Skip `dario login`, just run `dario backend add openai --key=...` (or any O
|
|
|
562
639
|
Yes — anything that speaks the OpenAI Chat Completions API. Groq, OpenRouter, LiteLLM, vLLM, Ollama's openai-compat mode, your own vLLM server, any hosted inference endpoint that exposes `/v1/chat/completions`. Just `dario backend add <name> --key=... --base-url=...`.
|
|
563
640
|
|
|
564
641
|
**Something's wrong. Where do I start?**
|
|
565
|
-
`dario doctor`. One command, one aggregated report — dario version, Node, platform, CC binary compat, template source + age + drift, OAuth status, pool state, backends, home dir. Exit code 1 if any check fails. Paste the output when you file an issue.
|
|
642
|
+
`dario doctor`. One command, one aggregated report — dario version, Node, platform, runtime/TLS classification, CC binary compat, template source + age + drift, OAuth status, pool state, backends, sub-agent install state, home dir. Exit code 1 if any check fails. Paste the output when you file an issue. (If you're inside Claude Code, `dario subagent install` once and then ask CC to "use the dario sub-agent to run doctor" — same output, no context switch.)
|
|
566
643
|
|
|
567
644
|
**What happens when Anthropic rotates the OAuth config?**
|
|
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.
|
|
645
|
+
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. (Path bumped from v3 → v4 in v3.19.4 to invalidate stale caches across the scope-list change that broke the authorize flow between CC v2.1.104 and v2.1.107.)
|
|
569
646
|
|
|
570
647
|
**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, 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
|
|
648
|
+
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 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
649
|
|
|
573
650
|
**First time setup on a fresh Claude account.**
|
|
574
651
|
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:
|
|
@@ -596,7 +673,10 @@ Seeing `seven_day` is a healthy state. Your Max/Pro plan is doing exactly what i
|
|
|
596
673
|
Standalone writeup: [Discussion #32 — why you see `representative-claim: seven_day` and why it's not a downgrade](https://github.com/askalf/dario/discussions/32).
|
|
597
674
|
|
|
598
675
|
**My multi-agent workload is getting reclassified to overage even though dario template-replays per request. Why?**
|
|
599
|
-
Reclassification at high agent volume is not a per-request problem. Anthropic's classifier operates on cumulative per-OAuth-session aggregates — token throughput, conversation depth, streaming duration, inter-arrival timing, thinking-block volume. Dario's Claude backend can make each individual request indistinguishable from Claude Code and still hit this wall on a long-running agent session. Thorough diagnostic work was contributed by [@belangertrading](https://github.com/belangertrading) in [#23](https://github.com/askalf/dario/issues/23). The practical answer at the dario layer is **pool mode** — distribute load across multiple subscriptions so no single account accumulates enough signal to trip anything. See [Multi-account pool mode](#multi-account-pool-mode).
|
|
676
|
+
Reclassification at high agent volume is not a per-request problem. Anthropic's classifier operates on cumulative per-OAuth-session aggregates — token throughput, conversation depth, streaming duration, inter-arrival timing, thinking-block volume. Dario's Claude backend can make each individual request indistinguishable from Claude Code and still hit this wall on a long-running agent session. Thorough diagnostic work was contributed by [@belangertrading](https://github.com/belangertrading) in [#23](https://github.com/askalf/dario/issues/23). The practical answer at the dario layer is **pool mode** — distribute load across multiple subscriptions so no single account accumulates enough signal to trip anything. See [Multi-account pool mode](#multi-account-pool-mode). The v3.22 – v3.28 fingerprint track (pacing, stream-drain, session-id lifecycle) also narrows the cumulative signal on a single account — see [Fingerprint axes](#fingerprint-axes).
|
|
677
|
+
|
|
678
|
+
**My proxy is on Node, not Bun. What's the actual risk?**
|
|
679
|
+
Node uses OpenSSL, Bun uses BoringSSL — the TLS ClientHello differs enough to yield a distinct JA3/JA4 hash. Anthropic can see the hash. Whether they classify on it today is unknown; making the axis visible is the v3.23 contribution. If certainty matters to you, install Bun (dario auto-relaunches under it) or run `dario proxy --strict-tls` to fail loud. If it doesn't, the warning is ignorable — dario still works, the TLS fingerprint is just the one observable axis left.
|
|
600
680
|
|
|
601
681
|
**Why "dario"?**
|
|
602
682
|
It's a name, not an acronym. Don't overthink it.
|
|
@@ -613,22 +693,32 @@ Longer-form writing on how dario works and why it works that way:
|
|
|
613
693
|
- [Billing tag algorithm and fingerprint analysis](https://github.com/askalf/dario/discussions/8)
|
|
614
694
|
- [Rate limit header analysis](https://github.com/askalf/dario/discussions/1)
|
|
615
695
|
|
|
696
|
+
The CHANGELOG documents every v3.22 – v3.28 "get ahead of Anthropic" release with file-level rationale; each one is worth reading as a standalone post on the axis it closes.
|
|
697
|
+
|
|
616
698
|
---
|
|
617
699
|
|
|
618
700
|
## Contributing
|
|
619
701
|
|
|
620
|
-
PRs welcome. The codebase is small TypeScript — ~
|
|
702
|
+
PRs welcome. The codebase is small TypeScript — ~11,300 lines across ~25 files:
|
|
621
703
|
|
|
622
704
|
| File | Purpose |
|
|
623
705
|
|---|---|
|
|
624
|
-
| `src/proxy.ts` | HTTP proxy server, request handler, rate governor, Claude backend dispatch, OpenAI-compat routing, pool failover |
|
|
706
|
+
| `src/proxy.ts` | HTTP proxy server, request handler, rate governor, Claude backend dispatch, OpenAI-compat routing, pool failover, session registry wiring, stream-drain gating |
|
|
625
707
|
| `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
|
|
708
|
+
| `src/cc-template-data.json` | Bundled fallback CC request template (used when live-fingerprint extraction isn't possible). Scrubbed of host-identifying paths and `mcp__*` tools at bake time (v3.21). |
|
|
709
|
+
| `src/scrub-template.ts` | Host-context scrubber for the baked fallback — strips per-session sections, replaces user-dir paths with a placeholder, drops `mcp__*` tools |
|
|
628
710
|
| `src/cc-oauth-detect.ts` | OAuth config auto-detection from the installed CC binary |
|
|
629
711
|
| `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 |
|
|
630
|
-
| `src/
|
|
631
|
-
| `src/
|
|
712
|
+
| `src/runtime-fingerprint.ts` | Runtime / TLS classifier (`bun-match` / `bun-bypassed` / `node-only`) surfaced through `dario doctor` and `--strict-tls` (v3.23) |
|
|
713
|
+
| `src/pacing.ts` | Pure inter-request delay calculator with configurable floor + uniform jitter (v3.24) |
|
|
714
|
+
| `src/stream-drain.ts` | Pure decision function for client-disconnect handling — `abort` / `drain` / `noop` (v3.25) |
|
|
715
|
+
| `src/session-rotation.ts` | `SessionRegistry` with LRU eviction + pure `decideSessionRotation` — idle, jitter, max-age, per-client bucketing (v3.28) |
|
|
716
|
+
| `src/subagent.ts` | CC sub-agent install / remove / status lifecycle; `buildSubagentFile(version)` is pure and pinned (v3.26) |
|
|
717
|
+
| `src/mcp/protocol.ts` | Hand-rolled JSON-RPC 2.0 + MCP method dispatcher — zero deps, pure over inputs, tested without streams (v3.27) |
|
|
718
|
+
| `src/mcp/tools.ts` | Six read-only MCP tools — `doctor`, `status`, `accounts_list`, `backends_list`, `subagent_status`, `fingerprint_info`. Redacts credentials at the tool boundary (v3.27) |
|
|
719
|
+
| `src/mcp/server.ts` | Stdio event loop — ordered serial dispatch, back-pressure-aware writes, injectable streams for testing (v3.27) |
|
|
720
|
+
| `src/doctor.ts` | `dario doctor` health report aggregator — dario / Node / runtime-TLS / CC / template / drift / OAuth / pool / backends / sub-agent |
|
|
721
|
+
| `src/oauth.ts` | Single-account token storage, PKCE flow, auto-refresh, manual/headless flow (v3.20) |
|
|
632
722
|
| `src/accounts.ts` | Multi-account credential storage, independent OAuth lifecycle, refresh single-flight |
|
|
633
723
|
| `src/pool.ts` | Account pool, headroom-aware routing, session stickiness, failover target selection |
|
|
634
724
|
| `src/sealed-pool.ts` | Sealed-sender overflow protocol — RSA blind signatures for unlinkable group pooling |
|
|
@@ -636,7 +726,7 @@ PRs welcome. The codebase is small TypeScript — ~8,100 lines across ~15 files:
|
|
|
636
726
|
| `src/openai-backend.ts` | OpenAI-compat backend credential storage and request forwarder |
|
|
637
727
|
| `src/shim/runtime.cjs` | Hand-written CJS payload loaded into child processes via `NODE_OPTIONS=--require`; patches `globalThis.fetch` for Anthropic messages requests only |
|
|
638
728
|
| `src/shim/host.ts` | Parent-side orchestrator for `dario shim` — spawns the child, owns the telemetry socket / named pipe, feeds analytics |
|
|
639
|
-
| `src/cli.ts` | CLI entry point, command routing, Bun auto-relaunch |
|
|
729
|
+
| `src/cli.ts` | CLI entry point, command routing, Bun auto-relaunch, proxy flag parsing |
|
|
640
730
|
| `src/index.ts` | Library exports |
|
|
641
731
|
|
|
642
732
|
```bash
|
|
@@ -644,7 +734,7 @@ git clone https://github.com/askalf/dario
|
|
|
644
734
|
cd dario
|
|
645
735
|
npm install
|
|
646
736
|
npm run dev # runs with tsx, no build step
|
|
647
|
-
npm test # ~
|
|
737
|
+
npm test # ~1,250 assertions across 32 suites
|
|
648
738
|
npm run e2e # live proxy + OAuth (requires a working Claude backend)
|
|
649
739
|
```
|
|
650
740
|
|
|
@@ -659,8 +749,9 @@ npm run e2e # live proxy + OAuth (requires a working Claude backend)
|
|
|
659
749
|
| [@nathan-widjaja](https://github.com/nathan-widjaja) | README positioning rewrite structure ([#21](https://github.com/askalf/dario/issues/21)) |
|
|
660
750
|
| [@iNicholasBE](https://github.com/iNicholasBE) | macOS keychain credential detection ([#30](https://github.com/askalf/dario/pull/30)) |
|
|
661
751
|
| [@boeingchoco](https://github.com/boeingchoco) | Reverse-direction tool parameter translation ([#29](https://github.com/askalf/dario/issues/29)), SSE event-group framing regression catch (v3.7.1), provider-comparison diagnostic that surfaced the `--preserve-tools` discoverability gap (v3.8.1), motivating case for hybrid tool mode ([#33](https://github.com/askalf/dario/issues/33), v3.9.0), OpenClaw tool-mapping root cause that drove the universal `TOOL_MAP` work ([#36](https://github.com/askalf/dario/issues/36)) |
|
|
662
|
-
| [@tetsuco](https://github.com/tetsuco) | Framework-name path corruption in scrubber ([#35](https://github.com/askalf/dario/issues/35)), OpenClaw Bash/Glob reverse-mapping collisions ([#37](https://github.com/askalf/dario/issues/37)) |
|
|
752
|
+
| [@tetsuco](https://github.com/tetsuco) | Framework-name path corruption in scrubber ([#35](https://github.com/askalf/dario/issues/35)), OpenClaw Bash/Glob reverse-mapping collisions ([#37](https://github.com/askalf/dario/issues/37)), 20x-tier invalid-x-api-key capture artifact + OAuth-scope rejection report that drove v3.19.2 / v3.19.4 / v3.19.5 ([#42](https://github.com/askalf/dario/issues/42)) |
|
|
663
753
|
| [@mikelovatt](https://github.com/mikelovatt) | Silent subscription-percent drain surfaced via friendly billing buckets ([#34](https://github.com/askalf/dario/issues/34)) |
|
|
754
|
+
| [@ringge](https://github.com/ringge) | Fingerprint-fidelity concern motivating the `--no-auto-detect` opt-out for text-tool-client auto-preserve ([#40](https://github.com/askalf/dario/issues/40), v3.20.1) |
|
|
664
755
|
|
|
665
756
|
---
|
|
666
757
|
|
package/dist/cc-oauth-detect.js
CHANGED
|
@@ -60,6 +60,10 @@ const FALLBACK = {
|
|
|
60
60
|
scopes: 'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload',
|
|
61
61
|
source: 'fallback',
|
|
62
62
|
};
|
|
63
|
+
// Re-export of FALLBACK for scripts/check-cc-authorize-probe.mjs. The probe
|
|
64
|
+
// needs the exact values the runtime uses — hardcoding them in the script
|
|
65
|
+
// would drift out of sync silently.
|
|
66
|
+
export const FALLBACK_FOR_DRIFT_CHECK = FALLBACK;
|
|
63
67
|
// -v4 suffix invalidates v3.x caches populated with the 6-scope list that
|
|
64
68
|
// Anthropic now rejects (dario #42). On upgrade, users regenerate the cache
|
|
65
69
|
// with the new FALLBACK scopes automatically — no manual clear required.
|
package/dist/cli.js
CHANGED
|
@@ -220,7 +220,16 @@ async function proxy() {
|
|
|
220
220
|
// read-to-completion pattern. Costs tokens (the response is fully
|
|
221
221
|
// generated even if nobody reads it), so it's opt-in.
|
|
222
222
|
const drainOnClose = args.includes('--drain-on-close') || undefined;
|
|
223
|
-
|
|
223
|
+
// --session-* knobs (v3.28, direction #1). Control the single-account
|
|
224
|
+
// session-id lifecycle: idle threshold, jitter on that threshold, hard
|
|
225
|
+
// max-age, and whether to give each upstream client its own session.
|
|
226
|
+
// All defaults preserve v3.27 behaviour exactly. Logic lives in
|
|
227
|
+
// src/session-rotation.ts; these flags just feed resolveSessionRotationConfig.
|
|
228
|
+
const sessionIdleRotateMs = parsePositiveIntFlag('--session-idle-rotate=');
|
|
229
|
+
const sessionRotateJitterMs = parsePositiveIntFlag('--session-rotate-jitter=');
|
|
230
|
+
const sessionMaxAgeMs = parsePositiveIntFlag('--session-max-age=');
|
|
231
|
+
const sessionPerClient = args.includes('--session-per-client') || undefined;
|
|
232
|
+
await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient });
|
|
224
233
|
}
|
|
225
234
|
function parsePositiveIntFlag(prefix) {
|
|
226
235
|
const found = args.find(a => a.startsWith(prefix));
|
|
@@ -513,6 +522,26 @@ async function help() {
|
|
|
513
522
|
is fully generated even if nobody reads
|
|
514
523
|
it) for fingerprint fidelity. Bounded by
|
|
515
524
|
the 5-minute upstream timeout. (v3.25)
|
|
525
|
+
--session-idle-rotate=MS Idle ms before the single-account session
|
|
526
|
+
id rotates (default: 900000 = 15 min).
|
|
527
|
+
Real CC rotates once per conversation, not
|
|
528
|
+
per call; the default matches its observed
|
|
529
|
+
cadence. Pool mode is unaffected. (v3.28)
|
|
530
|
+
--session-rotate-jitter=MS
|
|
531
|
+
Max additional uniform-random jitter (ms)
|
|
532
|
+
added to the idle threshold, sampled once
|
|
533
|
+
per session at creation. Default: 0 (off).
|
|
534
|
+
Hides the exact threshold from long-run
|
|
535
|
+
rotation statistics. (v3.28)
|
|
536
|
+
--session-max-age=MS Hard cap on a session id's lifetime
|
|
537
|
+
regardless of activity. Default: off. Set
|
|
538
|
+
for always-on pipelines where an idle
|
|
539
|
+
window would never trigger. (v3.28)
|
|
540
|
+
--session-per-client Give each upstream client (keyed by
|
|
541
|
+
x-session-id / x-client-session-id
|
|
542
|
+
header) its own rotated session id.
|
|
543
|
+
Default: off (single session across all
|
|
544
|
+
clients, v3.27 behaviour). (v3.28)
|
|
516
545
|
--port=PORT Port to listen on (default: 3456)
|
|
517
546
|
--host=ADDRESS Address to bind to (default: 127.0.0.1)
|
|
518
547
|
Use 0.0.0.0 for LAN; see README for DARIO_API_KEY
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type IncomingMessage } from 'node:http';
|
|
1
2
|
export declare function parseProviderPrefix(model: string): {
|
|
2
3
|
provider: 'openai' | 'claude';
|
|
3
4
|
model: string;
|
|
@@ -16,7 +17,18 @@ interface ProxyOptions {
|
|
|
16
17
|
pacingMinMs?: number;
|
|
17
18
|
pacingJitterMs?: number;
|
|
18
19
|
drainOnClose?: boolean;
|
|
20
|
+
sessionIdleRotateMs?: number;
|
|
21
|
+
sessionRotateJitterMs?: number;
|
|
22
|
+
sessionMaxAgeMs?: number;
|
|
23
|
+
sessionPerClient?: boolean;
|
|
19
24
|
}
|
|
20
25
|
export declare function sanitizeError(err: unknown): string;
|
|
26
|
+
/**
|
|
27
|
+
* Two-lane auth: DARIO_API_KEY (x-api-key / Authorization: Bearer) for
|
|
28
|
+
* normal clients, and MUX_COORD_SECRET (X-Mux-Coord-Secret) for the mux
|
|
29
|
+
* gateway forwarding a verified sealed borrow. If neither is configured
|
|
30
|
+
* the request is allowed (loopback-only default). Exported for tests.
|
|
31
|
+
*/
|
|
32
|
+
export declare function authenticateRequest(headers: IncomingMessage['headers'], apiKeyBuf: Buffer | null, mcsBuf: Buffer | null): boolean;
|
|
21
33
|
export declare function startProxy(opts?: ProxyOptions): Promise<void>;
|
|
22
34
|
export {};
|
package/dist/proxy.js
CHANGED
|
@@ -97,11 +97,17 @@ function extractFirstUserMessage(body) {
|
|
|
97
97
|
//
|
|
98
98
|
// v3.19 keeps the id stable through a conversation window and rotates
|
|
99
99
|
// only after an idle gap long enough to credibly indicate a new
|
|
100
|
-
// conversation
|
|
101
|
-
//
|
|
100
|
+
// conversation. Pool mode still uses the per-account identity.sessionId
|
|
101
|
+
// (stable across the account's lifetime).
|
|
102
|
+
//
|
|
103
|
+
// v3.28 generalises the single hardcoded 15-min window into a tunable
|
|
104
|
+
// registry (see src/session-rotation.ts) with optional jitter, max-age,
|
|
105
|
+
// and per-client keying. SESSION_ID below is kept only as a mirror of
|
|
106
|
+
// the default single-account session so out-of-band consumers (presence
|
|
107
|
+
// ping, diagnostic logs) can read the most recent id without going
|
|
108
|
+
// through the registry. It's refreshed after every dispatch-path call
|
|
109
|
+
// that assigns a new id.
|
|
102
110
|
let SESSION_ID = randomUUID();
|
|
103
|
-
let SESSION_LAST_USED = 0;
|
|
104
|
-
const SESSION_IDLE_ROTATE_MS = 15 * 60 * 1000;
|
|
105
111
|
const OS_NAME = platform === 'win32' ? 'Windows' : platform === 'darwin' ? 'MacOS' : 'Linux';
|
|
106
112
|
// Claude Code device identity — required for Max plan billing classification.
|
|
107
113
|
// Without metadata.user_id, Anthropic classifies requests as third-party and
|
|
@@ -327,6 +333,35 @@ export function sanitizeError(err) {
|
|
|
327
333
|
.replace(/eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, '[REDACTED_JWT]')
|
|
328
334
|
.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]');
|
|
329
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Two-lane auth: DARIO_API_KEY (x-api-key / Authorization: Bearer) for
|
|
338
|
+
* normal clients, and MUX_COORD_SECRET (X-Mux-Coord-Secret) for the mux
|
|
339
|
+
* gateway forwarding a verified sealed borrow. If neither is configured
|
|
340
|
+
* the request is allowed (loopback-only default). Exported for tests.
|
|
341
|
+
*/
|
|
342
|
+
export function authenticateRequest(headers, apiKeyBuf, mcsBuf) {
|
|
343
|
+
if (!apiKeyBuf && !mcsBuf)
|
|
344
|
+
return true;
|
|
345
|
+
if (mcsBuf) {
|
|
346
|
+
const raw = headers['x-mux-coord-secret'];
|
|
347
|
+
const provided = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw[0] : undefined;
|
|
348
|
+
if (provided) {
|
|
349
|
+
const providedBuf = Buffer.from(provided);
|
|
350
|
+
if (providedBuf.length === mcsBuf.length && timingSafeEqual(providedBuf, mcsBuf))
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (apiKeyBuf) {
|
|
355
|
+
const provided = headers['x-api-key']
|
|
356
|
+
|| headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
357
|
+
if (provided) {
|
|
358
|
+
const providedBuf = Buffer.from(provided);
|
|
359
|
+
if (providedBuf.length === apiKeyBuf.length && timingSafeEqual(providedBuf, apiKeyBuf))
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
330
365
|
/**
|
|
331
366
|
* Enrich Anthropic's unhelpful 429 "Error" body with rate limit details from headers.
|
|
332
367
|
*/
|
|
@@ -593,9 +628,33 @@ export async function startProxy(opts = {}) {
|
|
|
593
628
|
if (verbose) {
|
|
594
629
|
console.log(`[dario] drain-on-close: ${drainOnClose ? 'enabled' : 'disabled'}`);
|
|
595
630
|
}
|
|
631
|
+
// Session-ID lifecycle (v3.28, direction #1). Replaces the v3.27 hardcoded
|
|
632
|
+
// 15-minute idle window with a tunable registry: idle threshold, jitter on
|
|
633
|
+
// that threshold, optional hard max-age, and optional per-client keying.
|
|
634
|
+
// Defaults preserve v3.27 behavior exactly. See src/session-rotation.ts.
|
|
635
|
+
const { SessionRegistry, resolveSessionRotationConfig } = await import('./session-rotation.js');
|
|
636
|
+
const sessionCfg = resolveSessionRotationConfig({
|
|
637
|
+
idleRotateMs: opts.sessionIdleRotateMs,
|
|
638
|
+
jitterMs: opts.sessionRotateJitterMs,
|
|
639
|
+
maxAgeMs: opts.sessionMaxAgeMs,
|
|
640
|
+
perClient: opts.sessionPerClient,
|
|
641
|
+
});
|
|
642
|
+
const sessionRegistry = new SessionRegistry(sessionCfg, () => randomUUID());
|
|
643
|
+
if (verbose) {
|
|
644
|
+
const maxAge = sessionCfg.maxAgeMs !== undefined ? `${sessionCfg.maxAgeMs}ms` : 'off';
|
|
645
|
+
console.log(`[dario] session: idle=${sessionCfg.idleRotateMs}ms jitter=${sessionCfg.jitterMs}ms maxAge=${maxAge} perClient=${sessionCfg.perClient}`);
|
|
646
|
+
}
|
|
596
647
|
// Optional proxy authentication — pre-encode key buffer for performance
|
|
597
648
|
const apiKey = process.env.DARIO_API_KEY;
|
|
598
649
|
const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
|
|
650
|
+
// Mux coord-secret — the shared secret mux uses when forwarding sealed
|
|
651
|
+
// borrow requests to this dario acting as a lender endpoint. A request
|
|
652
|
+
// carrying a matching X-Mux-Coord-Secret header is authenticated without
|
|
653
|
+
// needing DARIO_API_KEY. The sealed-sender envelope has already been
|
|
654
|
+
// verified upstream, so the coord secret is the one-hop auth between
|
|
655
|
+
// the mux gateway and this instance.
|
|
656
|
+
const mcs = process.env.MUX_COORD_SECRET;
|
|
657
|
+
const mcsBuf = mcs ? Buffer.from(mcs) : null;
|
|
599
658
|
// CORS origin defaults to the localhost URL the proxy is served at. Users
|
|
600
659
|
// binding to a non-loopback address (e.g. a Tailscale interface) can
|
|
601
660
|
// override via DARIO_CORS_ORIGIN — otherwise browser-based clients hitting
|
|
@@ -611,7 +670,7 @@ export async function startProxy(opts = {}) {
|
|
|
611
670
|
const CORS_HEADERS = {
|
|
612
671
|
'Access-Control-Allow-Origin': corsOrigin,
|
|
613
672
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
614
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta',
|
|
673
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta, x-mux-coord-secret',
|
|
615
674
|
'Access-Control-Max-Age': '86400',
|
|
616
675
|
...SECURITY_HEADERS,
|
|
617
676
|
};
|
|
@@ -621,16 +680,7 @@ export async function startProxy(opts = {}) {
|
|
|
621
680
|
const ERR_FORBIDDEN = JSON.stringify({ error: 'Forbidden', message: 'Path not allowed. Supported paths: POST /v1/messages, POST /v1/chat/completions, GET /v1/models' });
|
|
622
681
|
const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
|
|
623
682
|
function checkAuth(req) {
|
|
624
|
-
|
|
625
|
-
return true;
|
|
626
|
-
const provided = req.headers['x-api-key']
|
|
627
|
-
|| req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
628
|
-
if (!provided)
|
|
629
|
-
return false;
|
|
630
|
-
const providedBuf = Buffer.from(provided);
|
|
631
|
-
if (providedBuf.length !== apiKeyBuf.length)
|
|
632
|
-
return false;
|
|
633
|
-
return timingSafeEqual(providedBuf, apiKeyBuf);
|
|
683
|
+
return authenticateRequest(req.headers, apiKeyBuf, mcsBuf);
|
|
634
684
|
}
|
|
635
685
|
const server = createServer(async (req, res) => {
|
|
636
686
|
if (req.method === 'OPTIONS') {
|
|
@@ -958,6 +1008,9 @@ export async function startProxy(opts = {}) {
|
|
|
958
1008
|
// selection toward one we already paid cache cost on — passthrough
|
|
959
1009
|
// users aren't doing template replay anyway).
|
|
960
1010
|
let stickyKey = null;
|
|
1011
|
+
// Outbound session id resolved once — either inside the template build
|
|
1012
|
+
// (so body metadata matches) or below for passthrough (no body build).
|
|
1013
|
+
let preBodySessionId;
|
|
961
1014
|
// Request context for hybrid-mode field injection (#33). Built once
|
|
962
1015
|
// per request from incoming headers so the reverse mapper can fill
|
|
963
1016
|
// client-declared fields like `sessionId` that CC's schema doesn't
|
|
@@ -1010,9 +1063,28 @@ export async function startProxy(opts = {}) {
|
|
|
1010
1063
|
}
|
|
1011
1064
|
}
|
|
1012
1065
|
}
|
|
1066
|
+
// Resolve the outbound session id before the body build so the
|
|
1067
|
+
// metadata.session_id in the CC body and the x-claude-code-session-id
|
|
1068
|
+
// header both use the same value. v3.27 consulted SESSION_ID twice
|
|
1069
|
+
// with rotation between the reads, so on rotation events body and
|
|
1070
|
+
// header disagreed — harmless for plain operation but a fingerprint
|
|
1071
|
+
// in its own right.
|
|
1072
|
+
if (poolAccount) {
|
|
1073
|
+
preBodySessionId = poolAccount.identity.sessionId;
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
const clientKey = req.headers['x-session-id']
|
|
1077
|
+
?? req.headers['x-client-session-id'];
|
|
1078
|
+
const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
|
|
1079
|
+
preBodySessionId = assigned.sessionId;
|
|
1080
|
+
SESSION_ID = assigned.sessionId;
|
|
1081
|
+
if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
|
|
1082
|
+
console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1013
1085
|
const bodyIdentity = poolAccount
|
|
1014
1086
|
? poolAccount.identity
|
|
1015
|
-
: { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId:
|
|
1087
|
+
: { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: preBodySessionId };
|
|
1016
1088
|
const { body: ccBody, toolMap, detectedClient } = buildCCRequest(r, billingTag, CACHE_1H, bodyIdentity, {
|
|
1017
1089
|
preserveTools: opts.preserveTools ?? false,
|
|
1018
1090
|
hybridTools: opts.hybridTools ?? false,
|
|
@@ -1102,18 +1174,30 @@ export async function startProxy(opts = {}) {
|
|
|
1102
1174
|
}
|
|
1103
1175
|
lastRequestTime = Date.now();
|
|
1104
1176
|
// Session ID: pool mode uses the per-account identity.sessionId (stable
|
|
1105
|
-
// per account). Single-account mode
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1177
|
+
// per account). Single-account mode delegates to the session registry
|
|
1178
|
+
// (src/session-rotation.ts) which applies the configured idle / jitter /
|
|
1179
|
+
// max-age / per-client policy. Resolution happens earlier, at body-build
|
|
1180
|
+
// time, so the CC body's metadata.session_id and the outbound
|
|
1181
|
+
// x-claude-code-session-id header always agree. preBodySessionId holds
|
|
1182
|
+
// the template-build value; in passthrough mode (no template build)
|
|
1183
|
+
// the registry is consulted here instead.
|
|
1184
|
+
let outboundSessionId;
|
|
1185
|
+
if (poolAccount) {
|
|
1186
|
+
outboundSessionId = poolAccount.identity.sessionId;
|
|
1187
|
+
}
|
|
1188
|
+
else if (preBodySessionId !== undefined) {
|
|
1189
|
+
outboundSessionId = preBodySessionId;
|
|
1190
|
+
}
|
|
1191
|
+
else {
|
|
1192
|
+
const clientKey = req.headers['x-session-id']
|
|
1193
|
+
?? req.headers['x-client-session-id'];
|
|
1194
|
+
const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
|
|
1195
|
+
outboundSessionId = assigned.sessionId;
|
|
1196
|
+
SESSION_ID = assigned.sessionId;
|
|
1197
|
+
if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
|
|
1198
|
+
console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
|
|
1113
1199
|
}
|
|
1114
|
-
SESSION_LAST_USED = nowTs;
|
|
1115
1200
|
}
|
|
1116
|
-
const outboundSessionId = poolAccount ? poolAccount.identity.sessionId : SESSION_ID;
|
|
1117
1201
|
const headers = {
|
|
1118
1202
|
...staticHeaders,
|
|
1119
1203
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1723,14 +1807,19 @@ export async function startProxy(opts = {}) {
|
|
|
1723
1807
|
if (!isLoopbackHost(host)) {
|
|
1724
1808
|
console.log('');
|
|
1725
1809
|
console.log(` ⚠ Bound to ${host} — reachable from other machines on the network.`);
|
|
1726
|
-
if (!apiKey) {
|
|
1727
|
-
console.log('
|
|
1728
|
-
console.log('
|
|
1729
|
-
console.log('
|
|
1810
|
+
if (!apiKey && !mcs) {
|
|
1811
|
+
console.log(' No auth configured. Any host that can reach this port can proxy');
|
|
1812
|
+
console.log(' requests through your OAuth subscription. Set DARIO_API_KEY (for');
|
|
1813
|
+
console.log(' normal clients) or MUX_COORD_SECRET (for mux lender mode) before');
|
|
1814
|
+
console.log(' exposing dario beyond loopback.');
|
|
1730
1815
|
}
|
|
1731
1816
|
else {
|
|
1732
|
-
|
|
1733
|
-
|
|
1817
|
+
const lanes = [];
|
|
1818
|
+
if (apiKey)
|
|
1819
|
+
lanes.push('x-api-key / Authorization (DARIO_API_KEY)');
|
|
1820
|
+
if (mcs)
|
|
1821
|
+
lanes.push('X-Mux-Coord-Secret (MUX_COORD_SECRET — mux lender mode)');
|
|
1822
|
+
console.log(` Auth required — accepted credentials: ${lanes.join(' or ')}.`);
|
|
1734
1823
|
}
|
|
1735
1824
|
}
|
|
1736
1825
|
console.log('');
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-ID lifecycle (v3.28, direction #1 — interactive-side rotation).
|
|
3
|
+
*
|
|
4
|
+
* Every outbound request to Anthropic carries a session identifier in the
|
|
5
|
+
* CC request body's metadata. Real Claude Code holds that id stable through
|
|
6
|
+
* a conversation and mints a new one when the user returns after an idle
|
|
7
|
+
* gap — roughly "one id per conversation", not per HTTP call. A proxy that
|
|
8
|
+
* rotates per-request looks synthetic; one that never rotates looks equally
|
|
9
|
+
* synthetic over long sessions. v3.19 tightened the per-request leak into a
|
|
10
|
+
* single hardcoded 15-minute idle window; this module generalises that into
|
|
11
|
+
* a registry so operators can tune the behaviour and so the multi-client
|
|
12
|
+
* case (dario fanning multiple UIs through one proxy) stops sharing one id.
|
|
13
|
+
*
|
|
14
|
+
* Three independent knobs:
|
|
15
|
+
*
|
|
16
|
+
* idleRotateMs — the v3.19 behaviour: rotate after this many ms of no
|
|
17
|
+
* traffic on a given session. Default 15 min preserves
|
|
18
|
+
* v3.27 exactly when the other knobs stay at defaults.
|
|
19
|
+
*
|
|
20
|
+
* jitterMs — the observable idle threshold for a given session is
|
|
21
|
+
* idleRotateMs + U(0, jitterMs), sampled once at session
|
|
22
|
+
* creation. A zero-jitter proxy rotates at exactly the
|
|
23
|
+
* same interval every time; adding jitter means the floor
|
|
24
|
+
* can't be inferred from long-run rotation cadence.
|
|
25
|
+
*
|
|
26
|
+
* maxAgeMs — hard cap on a session's total lifetime regardless of
|
|
27
|
+
* activity. Optional (undefined disables). A chatty
|
|
28
|
+
* always-on pipeline would otherwise keep one session id
|
|
29
|
+
* alive for days; real CC conversations don't.
|
|
30
|
+
*
|
|
31
|
+
* perClient — when true, the registry keys sessions by the caller's
|
|
32
|
+
* `x-session-id` / `x-client-session-id` header so two
|
|
33
|
+
* upstream UIs talking to one dario don't collapse onto
|
|
34
|
+
* a single session id. Default false preserves v3.27
|
|
35
|
+
* single-account semantics.
|
|
36
|
+
*
|
|
37
|
+
* Pure logic (decideSessionRotation) is separated from the stateful cache
|
|
38
|
+
* (SessionRegistry) so tests can walk every decision branch without Maps,
|
|
39
|
+
* timers, or UUID sources. The proxy injects a `() => string` id factory
|
|
40
|
+
* (randomUUID) and `() => number` rng so both are swappable in tests.
|
|
41
|
+
*
|
|
42
|
+
* Pool mode is unaffected — each account carries a stable identity.sessionId
|
|
43
|
+
* for its lifetime, and the caller doesn't consult this registry. This
|
|
44
|
+
* module only governs the single-account SESSION_ID slot.
|
|
45
|
+
*/
|
|
46
|
+
export interface SessionRotationConfig {
|
|
47
|
+
/** Idle threshold in ms: if no traffic for this long, the session rotates on the next request. */
|
|
48
|
+
idleRotateMs: number;
|
|
49
|
+
/** Max additional uniform-random ms added to the idle threshold at session creation. Pass 0 to disable. */
|
|
50
|
+
jitterMs: number;
|
|
51
|
+
/** Optional hard cap on session lifetime in ms. Undefined = no cap. */
|
|
52
|
+
maxAgeMs?: number;
|
|
53
|
+
/** When true, key sessions by client header so multiple upstreams get distinct ids. Default false. */
|
|
54
|
+
perClient: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface SessionEntry {
|
|
57
|
+
/** The session id sent to Anthropic in the outbound body. */
|
|
58
|
+
upstreamSessionId: string;
|
|
59
|
+
/** Wall-clock creation time (ms since epoch). */
|
|
60
|
+
createdAt: number;
|
|
61
|
+
/** Wall-clock time of last outbound use (ms since epoch). */
|
|
62
|
+
lastUsedAt: number;
|
|
63
|
+
/** Jitter offset sampled once at creation; added to cfg.idleRotateMs to get this session's effective idle threshold. */
|
|
64
|
+
idleJitterOffsetMs: number;
|
|
65
|
+
}
|
|
66
|
+
export type RotationDecision = 'keep' | 'rotate-new' | 'rotate-idle' | 'rotate-age';
|
|
67
|
+
/**
|
|
68
|
+
* Pure decision: should the given entry be rotated at `now`?
|
|
69
|
+
*
|
|
70
|
+
* Returns 'rotate-new' when no entry exists yet (first use for this key).
|
|
71
|
+
* Returns 'rotate-idle' when traffic has been silent for longer than this
|
|
72
|
+
* entry's sampled threshold. Returns 'rotate-age' when the entry's
|
|
73
|
+
* absolute lifetime exceeds cfg.maxAgeMs (when set). Otherwise 'keep'.
|
|
74
|
+
*
|
|
75
|
+
* Idle is checked before age so an idle-but-young session rotates on a
|
|
76
|
+
* fresh conversation boundary rather than churning mid-conversation at
|
|
77
|
+
* exactly its max-age. Negative config values are clamped to 0 (lenient:
|
|
78
|
+
* a typoed flag should behave like "rotate eagerly", not crash startup).
|
|
79
|
+
*/
|
|
80
|
+
export declare function decideSessionRotation(entry: SessionEntry | undefined, now: number, cfg: SessionRotationConfig): RotationDecision;
|
|
81
|
+
/** Result of SessionRegistry.getOrCreate — both the id to send and why it was chosen. */
|
|
82
|
+
export interface RegistryResult {
|
|
83
|
+
sessionId: string;
|
|
84
|
+
rotated: boolean;
|
|
85
|
+
reason: RotationDecision;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Per-client session cache with rotation + LRU eviction.
|
|
89
|
+
*
|
|
90
|
+
* Not concurrency-safe — the proxy's dispatch loop is single-threaded
|
|
91
|
+
* JavaScript and call sites are serialized by the event loop. The
|
|
92
|
+
* registry is intentionally a plain Map, not a TTL cache, because
|
|
93
|
+
* rotation timing is part of the observable behaviour we're modelling
|
|
94
|
+
* and a background sweeper would add a separate dimension (WHEN entries
|
|
95
|
+
* disappear) that doesn't exist in a real CC client.
|
|
96
|
+
*
|
|
97
|
+
* maxEntries defaults to 1024 — more than enough for any reasonable
|
|
98
|
+
* fan-out while capping memory growth against a pathological client
|
|
99
|
+
* that sends a fresh session header on every request.
|
|
100
|
+
*/
|
|
101
|
+
export declare class SessionRegistry {
|
|
102
|
+
private readonly cfg;
|
|
103
|
+
private readonly newId;
|
|
104
|
+
private readonly rng;
|
|
105
|
+
private readonly maxEntries;
|
|
106
|
+
private readonly entries;
|
|
107
|
+
constructor(cfg: SessionRotationConfig, newId: () => string, rng?: () => number, maxEntries?: number);
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the outbound session id for a given client key at time `now`.
|
|
110
|
+
*
|
|
111
|
+
* `clientKey` is the caller-side session header when cfg.perClient is
|
|
112
|
+
* true, and ignored (replaced with 'default') when perClient is false.
|
|
113
|
+
* Callers pass the raw header value and let the registry decide —
|
|
114
|
+
* otherwise flipping perClient at runtime would require threading
|
|
115
|
+
* the decision to every call site.
|
|
116
|
+
*
|
|
117
|
+
* Updates lastUsedAt on the entry (whether kept or freshly minted),
|
|
118
|
+
* and nudges the entry to the end of the insertion-order map so
|
|
119
|
+
* eviction under maxEntries pressure is LRU.
|
|
120
|
+
*/
|
|
121
|
+
getOrCreate(clientKey: string | undefined, now: number): RegistryResult;
|
|
122
|
+
/**
|
|
123
|
+
* Read the current id for a client key without touching lastUsedAt.
|
|
124
|
+
*
|
|
125
|
+
* Used by out-of-band consumers (e.g. presence pings) that want to
|
|
126
|
+
* reflect the most recently assigned session id but must not count
|
|
127
|
+
* as activity for rotation purposes. Returns undefined if no entry.
|
|
128
|
+
*/
|
|
129
|
+
peek(clientKey: string | undefined): string | undefined;
|
|
130
|
+
size(): number;
|
|
131
|
+
clear(): void;
|
|
132
|
+
private evictIfOverCap;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve a SessionRotationConfig from explicit options, env vars, and defaults.
|
|
136
|
+
*
|
|
137
|
+
* Precedence (highest first):
|
|
138
|
+
* 1. Explicit argument (typically from CLI flag)
|
|
139
|
+
* 2. DARIO_SESSION_IDLE_ROTATE_MS / DARIO_SESSION_JITTER_MS /
|
|
140
|
+
* DARIO_SESSION_MAX_AGE_MS / DARIO_SESSION_PER_CLIENT env vars
|
|
141
|
+
* 3. Defaults: idleRotateMs=15min, jitterMs=0, maxAgeMs=undefined,
|
|
142
|
+
* perClient=false — exactly matches the hardcoded v3.27 behaviour.
|
|
143
|
+
*
|
|
144
|
+
* Invalid numeric strings fall through to the next source. For perClient,
|
|
145
|
+
* '1' / 'true' / 'yes' (case-insensitive) enable; anything else stays at
|
|
146
|
+
* the explicit or default value.
|
|
147
|
+
*/
|
|
148
|
+
export declare function resolveSessionRotationConfig(explicit?: {
|
|
149
|
+
idleRotateMs?: number;
|
|
150
|
+
jitterMs?: number;
|
|
151
|
+
maxAgeMs?: number;
|
|
152
|
+
perClient?: boolean;
|
|
153
|
+
}, env?: NodeJS.ProcessEnv): SessionRotationConfig;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-ID lifecycle (v3.28, direction #1 — interactive-side rotation).
|
|
3
|
+
*
|
|
4
|
+
* Every outbound request to Anthropic carries a session identifier in the
|
|
5
|
+
* CC request body's metadata. Real Claude Code holds that id stable through
|
|
6
|
+
* a conversation and mints a new one when the user returns after an idle
|
|
7
|
+
* gap — roughly "one id per conversation", not per HTTP call. A proxy that
|
|
8
|
+
* rotates per-request looks synthetic; one that never rotates looks equally
|
|
9
|
+
* synthetic over long sessions. v3.19 tightened the per-request leak into a
|
|
10
|
+
* single hardcoded 15-minute idle window; this module generalises that into
|
|
11
|
+
* a registry so operators can tune the behaviour and so the multi-client
|
|
12
|
+
* case (dario fanning multiple UIs through one proxy) stops sharing one id.
|
|
13
|
+
*
|
|
14
|
+
* Three independent knobs:
|
|
15
|
+
*
|
|
16
|
+
* idleRotateMs — the v3.19 behaviour: rotate after this many ms of no
|
|
17
|
+
* traffic on a given session. Default 15 min preserves
|
|
18
|
+
* v3.27 exactly when the other knobs stay at defaults.
|
|
19
|
+
*
|
|
20
|
+
* jitterMs — the observable idle threshold for a given session is
|
|
21
|
+
* idleRotateMs + U(0, jitterMs), sampled once at session
|
|
22
|
+
* creation. A zero-jitter proxy rotates at exactly the
|
|
23
|
+
* same interval every time; adding jitter means the floor
|
|
24
|
+
* can't be inferred from long-run rotation cadence.
|
|
25
|
+
*
|
|
26
|
+
* maxAgeMs — hard cap on a session's total lifetime regardless of
|
|
27
|
+
* activity. Optional (undefined disables). A chatty
|
|
28
|
+
* always-on pipeline would otherwise keep one session id
|
|
29
|
+
* alive for days; real CC conversations don't.
|
|
30
|
+
*
|
|
31
|
+
* perClient — when true, the registry keys sessions by the caller's
|
|
32
|
+
* `x-session-id` / `x-client-session-id` header so two
|
|
33
|
+
* upstream UIs talking to one dario don't collapse onto
|
|
34
|
+
* a single session id. Default false preserves v3.27
|
|
35
|
+
* single-account semantics.
|
|
36
|
+
*
|
|
37
|
+
* Pure logic (decideSessionRotation) is separated from the stateful cache
|
|
38
|
+
* (SessionRegistry) so tests can walk every decision branch without Maps,
|
|
39
|
+
* timers, or UUID sources. The proxy injects a `() => string` id factory
|
|
40
|
+
* (randomUUID) and `() => number` rng so both are swappable in tests.
|
|
41
|
+
*
|
|
42
|
+
* Pool mode is unaffected — each account carries a stable identity.sessionId
|
|
43
|
+
* for its lifetime, and the caller doesn't consult this registry. This
|
|
44
|
+
* module only governs the single-account SESSION_ID slot.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Pure decision: should the given entry be rotated at `now`?
|
|
48
|
+
*
|
|
49
|
+
* Returns 'rotate-new' when no entry exists yet (first use for this key).
|
|
50
|
+
* Returns 'rotate-idle' when traffic has been silent for longer than this
|
|
51
|
+
* entry's sampled threshold. Returns 'rotate-age' when the entry's
|
|
52
|
+
* absolute lifetime exceeds cfg.maxAgeMs (when set). Otherwise 'keep'.
|
|
53
|
+
*
|
|
54
|
+
* Idle is checked before age so an idle-but-young session rotates on a
|
|
55
|
+
* fresh conversation boundary rather than churning mid-conversation at
|
|
56
|
+
* exactly its max-age. Negative config values are clamped to 0 (lenient:
|
|
57
|
+
* a typoed flag should behave like "rotate eagerly", not crash startup).
|
|
58
|
+
*/
|
|
59
|
+
export function decideSessionRotation(entry, now, cfg) {
|
|
60
|
+
if (!entry)
|
|
61
|
+
return 'rotate-new';
|
|
62
|
+
const idleBase = Math.max(0, cfg.idleRotateMs);
|
|
63
|
+
const idleThreshold = idleBase + Math.max(0, entry.idleJitterOffsetMs);
|
|
64
|
+
if (now - entry.lastUsedAt > idleThreshold)
|
|
65
|
+
return 'rotate-idle';
|
|
66
|
+
if (cfg.maxAgeMs !== undefined && cfg.maxAgeMs > 0 && now - entry.createdAt > cfg.maxAgeMs) {
|
|
67
|
+
return 'rotate-age';
|
|
68
|
+
}
|
|
69
|
+
return 'keep';
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Per-client session cache with rotation + LRU eviction.
|
|
73
|
+
*
|
|
74
|
+
* Not concurrency-safe — the proxy's dispatch loop is single-threaded
|
|
75
|
+
* JavaScript and call sites are serialized by the event loop. The
|
|
76
|
+
* registry is intentionally a plain Map, not a TTL cache, because
|
|
77
|
+
* rotation timing is part of the observable behaviour we're modelling
|
|
78
|
+
* and a background sweeper would add a separate dimension (WHEN entries
|
|
79
|
+
* disappear) that doesn't exist in a real CC client.
|
|
80
|
+
*
|
|
81
|
+
* maxEntries defaults to 1024 — more than enough for any reasonable
|
|
82
|
+
* fan-out while capping memory growth against a pathological client
|
|
83
|
+
* that sends a fresh session header on every request.
|
|
84
|
+
*/
|
|
85
|
+
export class SessionRegistry {
|
|
86
|
+
cfg;
|
|
87
|
+
newId;
|
|
88
|
+
rng;
|
|
89
|
+
maxEntries;
|
|
90
|
+
entries = new Map();
|
|
91
|
+
constructor(cfg, newId, rng = Math.random, maxEntries = 1024) {
|
|
92
|
+
this.cfg = cfg;
|
|
93
|
+
this.newId = newId;
|
|
94
|
+
this.rng = rng;
|
|
95
|
+
this.maxEntries = maxEntries;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the outbound session id for a given client key at time `now`.
|
|
99
|
+
*
|
|
100
|
+
* `clientKey` is the caller-side session header when cfg.perClient is
|
|
101
|
+
* true, and ignored (replaced with 'default') when perClient is false.
|
|
102
|
+
* Callers pass the raw header value and let the registry decide —
|
|
103
|
+
* otherwise flipping perClient at runtime would require threading
|
|
104
|
+
* the decision to every call site.
|
|
105
|
+
*
|
|
106
|
+
* Updates lastUsedAt on the entry (whether kept or freshly minted),
|
|
107
|
+
* and nudges the entry to the end of the insertion-order map so
|
|
108
|
+
* eviction under maxEntries pressure is LRU.
|
|
109
|
+
*/
|
|
110
|
+
getOrCreate(clientKey, now) {
|
|
111
|
+
const key = this.cfg.perClient ? (clientKey && clientKey.length > 0 ? clientKey : 'default') : 'default';
|
|
112
|
+
const existing = this.entries.get(key);
|
|
113
|
+
const decision = decideSessionRotation(existing, now, this.cfg);
|
|
114
|
+
if (decision === 'keep' && existing) {
|
|
115
|
+
existing.lastUsedAt = now;
|
|
116
|
+
// Re-insert to refresh LRU position.
|
|
117
|
+
this.entries.delete(key);
|
|
118
|
+
this.entries.set(key, existing);
|
|
119
|
+
return { sessionId: existing.upstreamSessionId, rotated: false, reason: 'keep' };
|
|
120
|
+
}
|
|
121
|
+
const jitterOffset = this.cfg.jitterMs > 0 ? Math.floor(this.rng() * this.cfg.jitterMs) : 0;
|
|
122
|
+
const entry = {
|
|
123
|
+
upstreamSessionId: this.newId(),
|
|
124
|
+
createdAt: now,
|
|
125
|
+
lastUsedAt: now,
|
|
126
|
+
idleJitterOffsetMs: jitterOffset,
|
|
127
|
+
};
|
|
128
|
+
this.entries.set(key, entry);
|
|
129
|
+
this.evictIfOverCap();
|
|
130
|
+
return { sessionId: entry.upstreamSessionId, rotated: true, reason: decision };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Read the current id for a client key without touching lastUsedAt.
|
|
134
|
+
*
|
|
135
|
+
* Used by out-of-band consumers (e.g. presence pings) that want to
|
|
136
|
+
* reflect the most recently assigned session id but must not count
|
|
137
|
+
* as activity for rotation purposes. Returns undefined if no entry.
|
|
138
|
+
*/
|
|
139
|
+
peek(clientKey) {
|
|
140
|
+
const key = this.cfg.perClient ? (clientKey && clientKey.length > 0 ? clientKey : 'default') : 'default';
|
|
141
|
+
return this.entries.get(key)?.upstreamSessionId;
|
|
142
|
+
}
|
|
143
|
+
size() {
|
|
144
|
+
return this.entries.size;
|
|
145
|
+
}
|
|
146
|
+
clear() {
|
|
147
|
+
this.entries.clear();
|
|
148
|
+
}
|
|
149
|
+
evictIfOverCap() {
|
|
150
|
+
while (this.entries.size > this.maxEntries) {
|
|
151
|
+
const oldest = this.entries.keys().next().value;
|
|
152
|
+
if (oldest === undefined)
|
|
153
|
+
break;
|
|
154
|
+
this.entries.delete(oldest);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a SessionRotationConfig from explicit options, env vars, and defaults.
|
|
160
|
+
*
|
|
161
|
+
* Precedence (highest first):
|
|
162
|
+
* 1. Explicit argument (typically from CLI flag)
|
|
163
|
+
* 2. DARIO_SESSION_IDLE_ROTATE_MS / DARIO_SESSION_JITTER_MS /
|
|
164
|
+
* DARIO_SESSION_MAX_AGE_MS / DARIO_SESSION_PER_CLIENT env vars
|
|
165
|
+
* 3. Defaults: idleRotateMs=15min, jitterMs=0, maxAgeMs=undefined,
|
|
166
|
+
* perClient=false — exactly matches the hardcoded v3.27 behaviour.
|
|
167
|
+
*
|
|
168
|
+
* Invalid numeric strings fall through to the next source. For perClient,
|
|
169
|
+
* '1' / 'true' / 'yes' (case-insensitive) enable; anything else stays at
|
|
170
|
+
* the explicit or default value.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveSessionRotationConfig(explicit = {}, env = process.env) {
|
|
173
|
+
const idleRotateMs = pickNonNegativeInt(explicit.idleRotateMs, env.DARIO_SESSION_IDLE_ROTATE_MS) ?? 15 * 60 * 1000;
|
|
174
|
+
const jitterMs = pickNonNegativeInt(explicit.jitterMs, env.DARIO_SESSION_JITTER_MS) ?? 0;
|
|
175
|
+
const maxAgeMs = pickPositiveInt(explicit.maxAgeMs, env.DARIO_SESSION_MAX_AGE_MS);
|
|
176
|
+
const perClient = pickBool(explicit.perClient, env.DARIO_SESSION_PER_CLIENT) ?? false;
|
|
177
|
+
return { idleRotateMs, jitterMs, maxAgeMs, perClient };
|
|
178
|
+
}
|
|
179
|
+
function pickNonNegativeInt(...candidates) {
|
|
180
|
+
for (const c of candidates) {
|
|
181
|
+
if (c === undefined || c === null || c === '')
|
|
182
|
+
continue;
|
|
183
|
+
const n = typeof c === 'number' ? c : parseInt(c, 10);
|
|
184
|
+
if (Number.isFinite(n) && n >= 0)
|
|
185
|
+
return Math.floor(n);
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
function pickPositiveInt(...candidates) {
|
|
190
|
+
for (const c of candidates) {
|
|
191
|
+
if (c === undefined || c === null || c === '')
|
|
192
|
+
continue;
|
|
193
|
+
const n = typeof c === 'number' ? c : parseInt(c, 10);
|
|
194
|
+
if (Number.isFinite(n) && n > 0)
|
|
195
|
+
return Math.floor(n);
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
function pickBool(...candidates) {
|
|
200
|
+
for (const c of candidates) {
|
|
201
|
+
if (c === undefined || c === null)
|
|
202
|
+
continue;
|
|
203
|
+
if (typeof c === 'boolean')
|
|
204
|
+
return c;
|
|
205
|
+
const s = c.trim().toLowerCase();
|
|
206
|
+
if (s === '')
|
|
207
|
+
continue;
|
|
208
|
+
if (s === '1' || s === 'true' || s === 'yes' || s === 'on')
|
|
209
|
+
return true;
|
|
210
|
+
if (s === '0' || s === 'false' || s === 'no' || s === 'off')
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.29.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/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.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/mux-coord-secret.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/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/session-rotation.mjs && node test/drift-detection.mjs && node test/cc-authorize-probe-classifier.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",
|