@askalf/dario 5.4.11 → 5.4.12

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.
@@ -239,6 +239,35 @@ interface CapturedRequest {
239
239
  rawHeaders: string[];
240
240
  body: Record<string, unknown>;
241
241
  }
242
+ /**
243
+ * Does this request belong to the capture we started?
244
+ *
245
+ * The MITM used to accept the FIRST request whose URL contained
246
+ * `/v1/messages` with nothing tying it to the child we spawned (dario#872).
247
+ * The port is ephemeral, so a collision is improbable rather than
248
+ * impossible, and nothing downstream could tell a foreign request from the
249
+ * child's once it was captured.
250
+ *
251
+ * The nonce rides in the URL path because that is the one channel we fully
252
+ * control without touching what CC sends. Two measurements decided it over a
253
+ * key-borne nonce:
254
+ *
255
+ * 1. CC honours a path segment in ANTHROPIC_BASE_URL — given
256
+ * `http://127.0.0.1:PORT/<nonce>` it requests
257
+ * `/<nonce>/v1/messages?beta=true` (and probes `/<nonce>/api/hello`
258
+ * first, which still 404s here).
259
+ * 2. On a subscription install CC authenticates with
260
+ * `authorization: Bearer sk-ant-…`, so ANTHROPIC_API_KEY is not the auth
261
+ * header at all. A key-borne nonce would do nothing on OAuth installs
262
+ * while changing the auth path for API-key ones — and changing what the
263
+ * child authenticates with can change what it sends, which is the whole
264
+ * thing a fingerprint capture must not do.
265
+ *
266
+ * Fails closed: no nonce, no capture. A miss makes the capture return null
267
+ * and the bake exit non-zero, which is the correct outcome for a request we
268
+ * cannot attribute.
269
+ */
270
+ export declare function isOwnCaptureRequest(url: string | undefined, nonce: string): boolean;
242
271
  /**
243
272
  * Run a loopback MITM server on a random port, spawn CC with
244
273
  * ANTHROPIC_BASE_URL pointed at it, wait for one request, respond with a
@@ -85,6 +85,7 @@
85
85
  * the right piece without re-deriving the threat model.
86
86
  */
87
87
  import { spawn, execFileSync } from 'node:child_process';
88
+ import { randomBytes } from 'node:crypto';
88
89
  import { createServer } from 'node:http';
89
90
  import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'node:fs';
90
91
  import { homedir } from 'node:os';
@@ -363,6 +364,41 @@ export function _atomicWriteJsonForTest(targetPath, data) {
363
364
  function writeLiveCache(data) {
364
365
  atomicWriteJson(liveCachePath(), data);
365
366
  }
367
+ /**
368
+ * Does this request belong to the capture we started?
369
+ *
370
+ * The MITM used to accept the FIRST request whose URL contained
371
+ * `/v1/messages` with nothing tying it to the child we spawned (dario#872).
372
+ * The port is ephemeral, so a collision is improbable rather than
373
+ * impossible, and nothing downstream could tell a foreign request from the
374
+ * child's once it was captured.
375
+ *
376
+ * The nonce rides in the URL path because that is the one channel we fully
377
+ * control without touching what CC sends. Two measurements decided it over a
378
+ * key-borne nonce:
379
+ *
380
+ * 1. CC honours a path segment in ANTHROPIC_BASE_URL — given
381
+ * `http://127.0.0.1:PORT/<nonce>` it requests
382
+ * `/<nonce>/v1/messages?beta=true` (and probes `/<nonce>/api/hello`
383
+ * first, which still 404s here).
384
+ * 2. On a subscription install CC authenticates with
385
+ * `authorization: Bearer sk-ant-…`, so ANTHROPIC_API_KEY is not the auth
386
+ * header at all. A key-borne nonce would do nothing on OAuth installs
387
+ * while changing the auth path for API-key ones — and changing what the
388
+ * child authenticates with can change what it sends, which is the whole
389
+ * thing a fingerprint capture must not do.
390
+ *
391
+ * Fails closed: no nonce, no capture. A miss makes the capture return null
392
+ * and the bake exit non-zero, which is the correct outcome for a request we
393
+ * cannot attribute.
394
+ */
395
+ export function isOwnCaptureRequest(url, nonce) {
396
+ if (!url || nonce.length === 0)
397
+ return false;
398
+ if (!url.startsWith(`/${nonce}/`))
399
+ return false;
400
+ return url.includes('/v1/messages');
401
+ }
366
402
  /**
367
403
  * Run a loopback MITM server on a random port, spawn CC with
368
404
  * ANTHROPIC_BASE_URL pointed at it, wait for one request, respond with a
@@ -377,9 +413,11 @@ export async function captureLiveTemplateAsync(timeoutMs = 10_000) {
377
413
  return extractTemplate(captured);
378
414
  }
379
415
  async function runCapture(timeoutMs) {
416
+ const nonce = `dario-capture-${randomBytes(12).toString('hex')}`;
380
417
  return new Promise((resolve) => {
381
418
  let captured = null;
382
419
  let settled = false;
420
+ let foreign = 0;
383
421
  const settle = (result) => {
384
422
  if (settled)
385
423
  return;
@@ -395,9 +433,17 @@ async function runCapture(timeoutMs) {
395
433
  resolve(result);
396
434
  };
397
435
  const server = createServer((req, res) => {
398
- // Only handle /v1/messages — everything else gets a 404 so CC doesn't
399
- // accidentally think /v1/models is live.
400
- if (!req.url?.includes('/v1/messages')) {
436
+ // Only handle OUR /v1/messages — everything else gets a 404 so CC
437
+ // doesn't accidentally think /v1/models is live, and so a request we
438
+ // cannot attribute to the spawned child is never captured (dario#872).
439
+ if (!isOwnCaptureRequest(req.url, nonce)) {
440
+ // A /v1/messages without our nonce is exactly the case #872 is about:
441
+ // something else reached this port. Say so — a silent 404 here is how
442
+ // a foreign capture would have gone unnoticed.
443
+ if (req.url?.includes('/v1/messages')) {
444
+ foreign++;
445
+ console.error(`[dario] capture: rejected a /v1/messages request that did not carry this capture's nonce (${foreign} so far) — see dario#872`);
446
+ }
401
447
  res.writeHead(404, { 'content-type': 'application/json' });
402
448
  res.end('{"type":"error","error":{"type":"not_found_error","message":"not found"}}');
403
449
  return;
@@ -417,7 +463,7 @@ async function runCapture(timeoutMs) {
417
463
  }
418
464
  captured = {
419
465
  method: req.method ?? 'POST',
420
- path: req.url ?? '/v1/messages',
466
+ path: (req.url ?? '/v1/messages').replace(`/${nonce}`, ''),
421
467
  headers,
422
468
  rawHeaders: Array.isArray(req.rawHeaders) ? [...req.rawHeaders] : [],
423
469
  body,
@@ -481,7 +527,7 @@ async function runCapture(timeoutMs) {
481
527
  settle(null);
482
528
  return;
483
529
  }
484
- const url = `http://127.0.0.1:${address.port}`;
530
+ const url = `http://127.0.0.1:${address.port}/${nonce}`;
485
531
  // Spawn CC with ANTHROPIC_BASE_URL pointed at our MITM.
486
532
  const claudeBin = findClaudeBinary();
487
533
  if (!claudeBin) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.4.11",
3
+ "version": "5.4.12",
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": {
@@ -22,7 +22,7 @@
22
22
  "scripts": {
23
23
  "build": "tsc && cp src/cc-template-data.json dist/",
24
24
  "test": "node --test --test-concurrency=8 test/all.test.mjs",
25
- "test:serial": "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/live-fingerprint.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/doctor-identity-drift.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/durable-token-persist.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs && node test/context-bleed.mjs && node test/sanitize-messages.mjs && node test/platform-tools.mjs && node test/strict-template-flags.mjs && node test/request-queue.mjs && node test/effort-flag.mjs && node test/template-invariants.mjs",
25
+ "test:serial": "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/live-fingerprint.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/doctor-identity-drift.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/durable-token-persist.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs && node test/context-bleed.mjs && node test/capture-provenance.mjs && node test/sanitize-messages.mjs && node test/platform-tools.mjs && node test/strict-template-flags.mjs && node test/request-queue.mjs && node test/effort-flag.mjs && node test/template-invariants.mjs",
26
26
  "audit": "npm audit --production --audit-level=high",
27
27
  "prepublishOnly": "npm run build",
28
28
  "start": "node dist/cli.js",