@askalf/dario 4.8.119 → 4.8.121

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.
@@ -41,4 +41,22 @@ export interface PoolDerivedStatus {
41
41
  accounts: number;
42
42
  }
43
43
  export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
44
- export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, viaPublicTunnel: boolean): HealthResponse;
44
+ export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, includeInternal: boolean): HealthResponse;
45
+ /**
46
+ * Decide whether a /health caller may see the OAuth internals (#642).
47
+ *
48
+ * /health is intentionally auth-free (docker healthchecks need it before a
49
+ * key is configured), so we cannot simply gate on the API key. Trust model:
50
+ * - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
51
+ * - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
52
+ * - otherwise bare loopback -> internal (docker HC / doctor)
53
+ * - otherwise (LAN, other container, WAN) -> public
54
+ * The cf-ray check is now only ever used to DENY (force public), never to
55
+ * grant, so spoofing it cannot widen disclosure — the previous fail-open
56
+ * direction is closed.
57
+ */
58
+ export declare function shouldDiscloseHealthInternals(opts: {
59
+ authenticated: boolean;
60
+ loopback: boolean;
61
+ viaCfRay: boolean;
62
+ }): boolean;
@@ -59,24 +59,50 @@ export function derivePoolStatus(accounts, now, adminEnabled) {
59
59
  expiresIn: formatMsLeft(earliest - now),
60
60
  };
61
61
  }
62
- export function buildHealthResponse(s, requestCount, viaPublicTunnel) {
62
+ export function buildHealthResponse(s, requestCount, includeInternal) {
63
63
  const dead = s.status === 'broken' ||
64
64
  s.status === 'none' ||
65
65
  (s.status === 'expired' && s.canRefresh === false);
66
66
  const httpStatus = dead ? 503 : 200;
67
67
  const liveness = { status: dead ? 'degraded' : 'ok' };
68
- const body = viaPublicTunnel
69
- ? liveness
70
- : {
68
+ // Only trusted callers (authenticated, or bare loopback not via the CF
69
+ // tunnel — see shouldDiscloseHealthInternals) get the OAuth internals.
70
+ // Everyone else (LAN, public tunnel) gets the liveness verdict only; the
71
+ // HTTP status is identical either way so uptime checks still work. #642:
72
+ // this used to key on the presence of the client-suppliable `cf-ray`
73
+ // header, which failed OPEN — a direct non-tunnel caller omits it and got
74
+ // the full internal view. `lastRefreshError` is no longer exposed here at
75
+ // all (it can carry a raw upstream error string); it remains on the
76
+ // key-gated /status.
77
+ const body = includeInternal
78
+ ? {
71
79
  ...liveness,
72
- // Version for internal callers only — an update-check signal (#640),
73
- // withheld from the public-tunnel view alongside the OAuth internals.
74
80
  ...(s.version ? { version: s.version } : {}),
75
81
  oauth: s.status,
76
82
  expiresIn: s.expiresIn,
77
83
  requests: requestCount,
78
84
  ...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
79
- ...(s.lastRefreshError ? { lastRefreshError: s.lastRefreshError } : {}),
80
- };
85
+ }
86
+ : liveness;
81
87
  return { httpStatus, body };
82
88
  }
89
+ /**
90
+ * Decide whether a /health caller may see the OAuth internals (#642).
91
+ *
92
+ * /health is intentionally auth-free (docker healthchecks need it before a
93
+ * key is configured), so we cannot simply gate on the API key. Trust model:
94
+ * - authenticated (valid DARIO_API_KEY) -> internal (an internal caller)
95
+ * - came via the Cloudflare tunnel (cf-ray) -> public (world-reachable)
96
+ * - otherwise bare loopback -> internal (docker HC / doctor)
97
+ * - otherwise (LAN, other container, WAN) -> public
98
+ * The cf-ray check is now only ever used to DENY (force public), never to
99
+ * grant, so spoofing it cannot widen disclosure — the previous fail-open
100
+ * direction is closed.
101
+ */
102
+ export function shouldDiscloseHealthInternals(opts) {
103
+ if (opts.authenticated)
104
+ return true;
105
+ if (opts.viaCfRay)
106
+ return false;
107
+ return opts.loopback;
108
+ }
package/dist/proxy.d.ts CHANGED
@@ -174,6 +174,14 @@ export declare function buildOrchestrationPatterns(preserveTags?: Set<string>):
174
174
  * opt any tag out of the scrub. dario#78.
175
175
  */
176
176
  export declare function sanitizeMessages(body: Record<string, unknown>, preserveTags?: Set<string>): void;
177
+ /**
178
+ * Translate Anthropic SSE → OpenAI SSE. Returns a stateful per-CALL closure so
179
+ * concurrent /v1/chat/completions streams don't share tool-call index/id state
180
+ * (#642-audit: the previous module-global counters cross-contaminated
181
+ * interleaved streams and emitted malformed tool_calls deltas). One translator
182
+ * per request, mirroring createStreamingReverseMapper.
183
+ */
184
+ export declare function createOpenAIStreamTranslator(): (line: string) => string | null;
177
185
  export declare const OPENAI_MODELS_LIST: {
178
186
  object: string;
179
187
  data: Array<{
package/dist/proxy.js CHANGED
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
7
7
  import { setDefaultResultOrder } from 'node:dns';
8
8
  import { arch, platform } from 'node:process';
9
9
  import { getAccessToken, getStatus } from './oauth.js';
10
- import { buildHealthResponse, derivePoolStatus } from './health-response.js';
10
+ import { buildHealthResponse, derivePoolStatus, shouldDiscloseHealthInternals } from './health-response.js';
11
11
  import { darioVersion } from './version.js';
12
12
  import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
13
13
  import { stampCch } from './cch.js';
@@ -38,6 +38,18 @@ function isLoopbackHost(host) {
38
38
  return true;
39
39
  return host.startsWith('127.');
40
40
  }
41
+ // A socket peer address is loopback if it is 127.0.0.0/8 or ::1, including the
42
+ // IPv4-mapped-IPv6 form Node reports on dual-stack sockets (`::ffff:127.0.0.1`).
43
+ // Distinct from isLoopbackHost (which classifies a bind-address string): this
44
+ // classifies an inbound connection's peer for the /health disclosure gate (#642).
45
+ function isLoopbackAddr(addr) {
46
+ if (!addr)
47
+ return false;
48
+ if (addr === '::1')
49
+ return true;
50
+ const v4 = addr.startsWith('::ffff:') ? addr.slice(7) : addr;
51
+ return v4 === '127.0.0.1' || v4.startsWith('127.');
52
+ }
41
53
  // Concurrency control: see src/request-queue.ts for the bounded queue
42
54
  // (replaced the v3.30.x-and-earlier simple unbounded semaphore in dario#80).
43
55
  // Billing tag hash seed — matches Claude Code's value
@@ -532,48 +544,55 @@ function anthropicToOpenai(body) {
532
544
  usage: { prompt_tokens: u?.input_tokens ?? 0, completion_tokens: u?.output_tokens ?? 0, total_tokens: (u?.input_tokens ?? 0) + (u?.output_tokens ?? 0) },
533
545
  };
534
546
  }
535
- /** Translate Anthropic SSE → OpenAI SSE. */
536
- // Track tool call state across stream chunks
537
- let _streamToolIndex = 0;
538
- let _streamToolId = '';
539
- function translateStreamChunk(line) {
540
- if (!line.startsWith('data: '))
541
- return null;
542
- const json = line.slice(6).trim();
543
- if (json === '[DONE]')
544
- return 'data: [DONE]\n\n';
545
- try {
546
- const e = JSON.parse(json);
547
- const ts = Math.floor(Date.now() / 1000);
548
- if (e.type === 'content_block_start') {
549
- const block = e.content_block;
550
- if (block?.type === 'tool_use' && block.name) {
551
- _streamToolId = block.id ?? `call_${_streamToolIndex}`;
552
- return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: _streamToolIndex, id: _streamToolId, type: 'function', function: { name: block.name, arguments: '' } }] }, finish_reason: null }] })}\n\n`;
547
+ /**
548
+ * Translate Anthropic SSE OpenAI SSE. Returns a stateful per-CALL closure so
549
+ * concurrent /v1/chat/completions streams don't share tool-call index/id state
550
+ * (#642-audit: the previous module-global counters cross-contaminated
551
+ * interleaved streams and emitted malformed tool_calls deltas). One translator
552
+ * per request, mirroring createStreamingReverseMapper.
553
+ */
554
+ export function createOpenAIStreamTranslator() {
555
+ let toolIndex = 0;
556
+ let toolId = '';
557
+ return function translateStreamChunk(line) {
558
+ if (!line.startsWith('data: '))
559
+ return null;
560
+ const json = line.slice(6).trim();
561
+ if (json === '[DONE]')
562
+ return 'data: [DONE]\n\n';
563
+ try {
564
+ const e = JSON.parse(json);
565
+ const ts = Math.floor(Date.now() / 1000);
566
+ if (e.type === 'content_block_start') {
567
+ const block = e.content_block;
568
+ if (block?.type === 'tool_use' && block.name) {
569
+ toolId = block.id ?? `call_${toolIndex}`;
570
+ return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: toolIndex, id: toolId, type: 'function', function: { name: block.name, arguments: '' } }] }, finish_reason: null }] })}\n\n`;
571
+ }
553
572
  }
554
- }
555
- if (e.type === 'content_block_delta') {
556
- const d = e.delta;
557
- if (d?.type === 'text_delta' && d.text)
558
- return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { content: d.text }, finish_reason: null }] })}\n\n`;
559
- if (d?.type === 'input_json_delta' && d.partial_json)
560
- return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: _streamToolIndex, function: { arguments: d.partial_json } }] }, finish_reason: null }] })}\n\n`;
561
- }
562
- if (e.type === 'content_block_stop') {
563
- if (_streamToolId) {
564
- _streamToolIndex++;
565
- _streamToolId = '';
573
+ if (e.type === 'content_block_delta') {
574
+ const d = e.delta;
575
+ if (d?.type === 'text_delta' && d.text)
576
+ return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { content: d.text }, finish_reason: null }] })}\n\n`;
577
+ if (d?.type === 'input_json_delta' && d.partial_json)
578
+ return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: toolIndex, function: { arguments: d.partial_json } }] }, finish_reason: null }] })}\n\n`;
579
+ }
580
+ if (e.type === 'content_block_stop') {
581
+ if (toolId) {
582
+ toolIndex++;
583
+ toolId = '';
584
+ }
585
+ return null;
586
+ }
587
+ if (e.type === 'message_stop') {
588
+ toolIndex = 0;
589
+ toolId = '';
590
+ return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`;
566
591
  }
567
- return null;
568
- }
569
- if (e.type === 'message_stop') {
570
- _streamToolIndex = 0;
571
- _streamToolId = '';
572
- return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`;
573
592
  }
574
- }
575
- catch { }
576
- return null;
593
+ catch { }
594
+ return null;
595
+ };
577
596
  }
578
597
  // Baked /v1/models payload — what the proxy advertises before (or without)
579
598
  // a successful upstream catalog fetch. The live route serves the
@@ -1169,6 +1188,14 @@ export async function startProxy(opts = {}) {
1169
1188
  const adminTokenBuf = adminToken ? Buffer.from(adminToken) : null;
1170
1189
  if (adminEnabled) {
1171
1190
  console.log(`[dario] admin API enabled at /admin/* (token ${adminTokenBuf ? 'configured' : 'MISSING — endpoints return 403 until DARIO_ADMIN_TOKEN is set'})`);
1191
+ // Hardening (#642): the admin API adds/removes OAuth accounts. When it
1192
+ // shares DARIO_API_KEY (which is embedded in every client config) instead
1193
+ // of a distinct DARIO_ADMIN_TOKEN, every client that can proxy can also
1194
+ // control the account pool. Non-fatal for back-compat, but warn loudly.
1195
+ if (adminTokenBuf && !process.env.DARIO_ADMIN_TOKEN) {
1196
+ console.warn('[dario] WARNING: admin API is using DARIO_API_KEY as its token (no DARIO_ADMIN_TOKEN set).');
1197
+ console.warn('[dario] every client holding the proxy key can add/remove accounts. Set a distinct DARIO_ADMIN_TOKEN.');
1198
+ }
1172
1199
  }
1173
1200
  // Admin rate limiting (#620). Two token buckets, created once per proxy:
1174
1201
  // failed auth (brute-force / broken-client throttle) and mutations. Global,
@@ -1269,10 +1296,16 @@ export async function startProxy(opts = {}) {
1269
1296
  // react instead of cheerfully passing while every /v1/messages 401s.
1270
1297
  if (urlPath === '/health' || urlPath === '/') {
1271
1298
  const s = await currentStatus();
1272
- // Public requests arrive through the Cloudflare tunnel (the edge stamps
1273
- // `cf-ray`); they get only the liveness verdict, never the OAuth internals.
1274
- // See buildHealthResponse for the full rationale.
1275
- const { httpStatus, body } = buildHealthResponse({ ...s, version: darioVersion() }, requestCount, req.headers['cf-ray'] !== undefined);
1299
+ // Disclose OAuth internals only to trusted callers (#642). This used to
1300
+ // key on the presence of the client-suppliable `cf-ray` header and failed
1301
+ // OPEN a direct non-tunnel caller omits it and got the full internal
1302
+ // view. Now: authenticated, OR bare loopback that did not arrive via CF.
1303
+ const includeInternal = shouldDiscloseHealthInternals({
1304
+ authenticated: authenticateRequest(req.headers, apiKeyBuf),
1305
+ loopback: isLoopbackAddr(req.socket?.remoteAddress),
1306
+ viaCfRay: req.headers['cf-ray'] !== undefined,
1307
+ });
1308
+ const { httpStatus, body } = buildHealthResponse({ ...s, version: darioVersion() }, requestCount, includeInternal);
1276
1309
  res.writeHead(httpStatus, JSON_HEADERS);
1277
1310
  res.end(JSON.stringify(body));
1278
1311
  return;
@@ -2753,14 +2786,29 @@ export async function startProxy(opts = {}) {
2753
2786
  const streamMapper = ccToolMap && !isOpenAI
2754
2787
  ? createStreamingReverseMapper(ccToolMap, reqCtx)
2755
2788
  : null;
2789
+ // Per-request OpenAI SSE translator — isolated tool-call state so
2790
+ // concurrent /v1/chat/completions streams don't cross-contaminate (#642-audit).
2791
+ const openaiTranslate = isOpenAI ? createOpenAIStreamTranslator() : null;
2756
2792
  // Gated writer — a no-op once the downstream client has gone away
2757
2793
  // in drain-on-close mode. The read loop keeps consuming so the
2758
2794
  // upstream sees a full-length read; writes to a closed socket are
2759
2795
  // suppressed to avoid EPIPE/warnings and pointless work.
2796
+ // Honor downstream backpressure (#642-audit): when res.write() returns
2797
+ // false the client's socket buffer is full, so pause the read loop until
2798
+ // it drains instead of buffering the whole (fast) upstream in memory.
2799
+ let needsDrain = false;
2760
2800
  const writeToClient = (chunk) => {
2761
- if (!clientDisconnected)
2762
- res.write(chunk);
2801
+ if (clientDisconnected)
2802
+ return;
2803
+ if (res.write(chunk) === false)
2804
+ needsDrain = true;
2763
2805
  };
2806
+ // Resolves on 'close' too, so a vanished client can't wedge the loop.
2807
+ const waitForDrain = () => new Promise((resolve) => {
2808
+ const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
2809
+ res.once('drain', done);
2810
+ res.once('close', done);
2811
+ });
2764
2812
  try {
2765
2813
  let buffer = '';
2766
2814
  const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
@@ -2834,7 +2882,7 @@ export async function startProxy(opts = {}) {
2834
2882
  const lines = buffer.split('\n');
2835
2883
  buffer = lines.pop() ?? '';
2836
2884
  for (const line of lines) {
2837
- const translated = translateStreamChunk(line);
2885
+ const translated = openaiTranslate(line);
2838
2886
  if (translated)
2839
2887
  writeToClient(translated);
2840
2888
  }
@@ -2847,10 +2895,16 @@ export async function startProxy(opts = {}) {
2847
2895
  else {
2848
2896
  writeToClient(value);
2849
2897
  }
2898
+ // Backpressure (#642-audit): if the last writes filled the client
2899
+ // buffer, pause reading upstream until it drains.
2900
+ if (needsDrain && !clientDisconnected) {
2901
+ needsDrain = false;
2902
+ await waitForDrain();
2903
+ }
2850
2904
  }
2851
2905
  // Flush remaining buffer
2852
2906
  if (isOpenAI && buffer.trim()) {
2853
- const translated = translateStreamChunk(buffer);
2907
+ const translated = openaiTranslate(buffer);
2854
2908
  if (translated)
2855
2909
  writeToClient(translated);
2856
2910
  }
@@ -2863,6 +2917,11 @@ export async function startProxy(opts = {}) {
2863
2917
  catch (err) {
2864
2918
  if (verbose)
2865
2919
  console.error('[dario] Stream error:', sanitizeError(err));
2920
+ // Tear down the upstream body reader deterministically on an abnormal
2921
+ // mid-stream error (e.g. a bare upstream socket reset) so the undici
2922
+ // socket is released now, not at GC (#642-audit). No-op if aborted.
2923
+ if (!upstreamAbort.signal.aborted)
2924
+ upstreamAbort.abort();
2866
2925
  }
2867
2926
  res.end();
2868
2927
  // Stamp the response-completion timestamp + token count so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "4.8.119",
3
+ "version": "4.8.121",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {