@askalf/dario 3.16.0 → 3.19.1

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.
@@ -84,13 +84,20 @@
84
84
  * contributor — or dario maintainer six months from now — can pick up
85
85
  * the right piece without re-deriving the threat model.
86
86
  */
87
- import { spawn } from 'node:child_process';
87
+ import { spawn, execFileSync } from 'node:child_process';
88
88
  import { createServer } from 'node:http';
89
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
89
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkSync } from 'node:fs';
90
90
  import { homedir } from 'node:os';
91
91
  import { join, dirname } from 'node:path';
92
92
  import { fileURLToPath } from 'node:url';
93
93
  const __dirname = dirname(fileURLToPath(import.meta.url));
94
+ /**
95
+ * Cache-file schema version. Bump when `TemplateData` gains a required
96
+ * field or changes shape in a way that would make older caches produce
97
+ * wrong behavior if loaded verbatim. Mismatched caches are rejected at
98
+ * load time so the fallback + next background refresh write a fresh one.
99
+ */
100
+ export const CURRENT_SCHEMA_VERSION = 2;
94
101
  const LIVE_CACHE = join(homedir(), '.dario', 'cc-template.live.json');
95
102
  const LIVE_TTL_MS = 24 * 60 * 60 * 1000; // re-extract once a day
96
103
  /**
@@ -162,20 +169,87 @@ function loadBundledTemplate() {
162
169
  function readLiveCache() {
163
170
  if (!existsSync(LIVE_CACHE))
164
171
  return null;
172
+ let raw;
165
173
  try {
166
- const data = JSON.parse(readFileSync(LIVE_CACHE, 'utf-8'));
167
- if (!data.system_prompt || !Array.isArray(data.tools) || data.tools.length === 0)
168
- return null;
169
- data._source = 'live';
170
- return data;
174
+ raw = readFileSync(LIVE_CACHE, 'utf-8');
171
175
  }
172
176
  catch {
173
177
  return null;
174
178
  }
179
+ let parsed;
180
+ try {
181
+ parsed = JSON.parse(raw);
182
+ }
183
+ catch (err) {
184
+ // Unparseable JSON — typically a crash or power-loss mid-write on a
185
+ // pre-v3.17 dario that still used a non-atomic writer. Quarantine
186
+ // the bad file so the next refresh can write a clean one, and log
187
+ // loudly so the user doesn't silently sit on a broken cache forever.
188
+ quarantineCorruptCache(`unparseable JSON (${err.message})`);
189
+ return null;
190
+ }
191
+ if (!parsed || !parsed.system_prompt || !Array.isArray(parsed.tools) || parsed.tools.length === 0) {
192
+ quarantineCorruptCache('missing required fields (system_prompt / tools)');
193
+ return null;
194
+ }
195
+ // Schema version mismatch is NOT corruption — it's an expected event on
196
+ // dario upgrade or downgrade. Skip the cache silently; the background
197
+ // refresh will rewrite it in the new shape.
198
+ if (parsed._schemaVersion !== CURRENT_SCHEMA_VERSION)
199
+ return null;
200
+ parsed._source = 'live';
201
+ return parsed;
202
+ }
203
+ /**
204
+ * Rename a corrupt cache file aside to `.corrupt-<ISO>` so the next
205
+ * refresh writes a fresh cache without first having to overwrite a bad
206
+ * file. Keeping the original as-is would also work, but quarantining
207
+ * makes it clearer in `ls ~/.dario` that the file was rejected, and
208
+ * preserves the contents for post-mortem in case a user files an issue.
209
+ */
210
+ function quarantineCorruptCache(reason) {
211
+ try {
212
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
213
+ const aside = `${LIVE_CACHE}.corrupt-${stamp}`;
214
+ renameSync(LIVE_CACHE, aside);
215
+ console.error(`[dario] ⚠ live template cache rejected: ${reason}. Quarantined to ${aside}. Next background refresh will re-capture.`);
216
+ }
217
+ catch (err) {
218
+ // If the rename itself fails, leave the file in place — a subsequent
219
+ // refresh will overwrite it atomically. Log so the state is visible.
220
+ console.error(`[dario] ⚠ live template cache rejected: ${reason}. (quarantine rename failed: ${err.message})`);
221
+ }
222
+ }
223
+ /**
224
+ * Atomic JSON write: dump to a sibling `.tmp` file, then rename over the
225
+ * target path. A crash or Ctrl+C between writes never leaves a half-
226
+ * written file where `JSON.parse` would throw on next read. Uses a pid-
227
+ * qualified tmp name so concurrent dario processes don't stomp on each
228
+ * other's partial writes. Exposed for tests via `_atomicWriteJsonForTest`.
229
+ */
230
+ function atomicWriteJson(targetPath, data) {
231
+ mkdirSync(dirname(targetPath), { recursive: true });
232
+ const tmp = `${targetPath}.${process.pid}.tmp`;
233
+ try {
234
+ writeFileSync(tmp, JSON.stringify(data, null, 2));
235
+ renameSync(tmp, targetPath);
236
+ }
237
+ catch (err) {
238
+ // Clean up the stray tmp if the rename failed; swallow its own
239
+ // unlink error — nothing useful to do with it.
240
+ try {
241
+ unlinkSync(tmp);
242
+ }
243
+ catch { /* noop */ }
244
+ throw err;
245
+ }
246
+ }
247
+ /** Test-only surface for `atomicWriteJson`. Production code uses `writeLiveCache`. */
248
+ export function _atomicWriteJsonForTest(targetPath, data) {
249
+ atomicWriteJson(targetPath, data);
175
250
  }
176
251
  function writeLiveCache(data) {
177
- mkdirSync(dirname(LIVE_CACHE), { recursive: true });
178
- writeFileSync(LIVE_CACHE, JSON.stringify(data, null, 2));
252
+ atomicWriteJson(LIVE_CACHE, data);
179
253
  }
180
254
  /**
181
255
  * Run a loopback MITM server on a random port, spawn CC with
@@ -302,6 +376,14 @@ async function runCapture(timeoutMs) {
302
376
  settle(null);
303
377
  return;
304
378
  }
379
+ // Node 20+ won't spawn `.cmd`/`.bat` without `shell: true` (CVE-2024-27980).
380
+ // `useShell` triggers cmd.exe on Windows — reject overrides that carry
381
+ // shell metacharacters before the spawn, same guard as probeInstalledCCVersion.
382
+ const useShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(claudeBin);
383
+ if (useShell && /[&|><^"'%\r\n`$;(){}[\]]/.test(claudeBin)) {
384
+ settle(null);
385
+ return;
386
+ }
305
387
  try {
306
388
  child = spawn(claudeBin, ['--print', '-p', 'hi'], {
307
389
  env: {
@@ -313,6 +395,7 @@ async function runCapture(timeoutMs) {
313
395
  },
314
396
  stdio: ['ignore', 'ignore', 'ignore'],
315
397
  windowsHide: true,
398
+ shell: useShell,
316
399
  });
317
400
  child.on('error', () => settle(null));
318
401
  child.on('exit', () => {
@@ -331,6 +414,17 @@ async function runCapture(timeoutMs) {
331
414
  setTimeout(() => settle(captured), timeoutMs);
332
415
  });
333
416
  }
417
+ /**
418
+ * Locate the installed `claude` binary and its version. Thin public
419
+ * wrapper over `findClaudeBinary` + `probeInstalledCCVersion` — the
420
+ * doctor CLI and external callers use this to report install state
421
+ * without reaching into module-private helpers.
422
+ */
423
+ export function findInstalledCC() {
424
+ const path = findClaudeBinary();
425
+ const version = path ? probeInstalledCCVersion() : null;
426
+ return { path, version };
427
+ }
334
428
  function findClaudeBinary() {
335
429
  // Honor an explicit override first — useful for tests and for users on
336
430
  // non-standard installs.
@@ -394,15 +488,287 @@ export function extractTemplate(captured) {
394
488
  return null;
395
489
  const version = extractCCVersion(captured.headers) ?? 'unknown';
396
490
  const headerOrder = extractHeaderOrder(captured.rawHeaders);
491
+ const anthropicBeta = captured.headers['anthropic-beta'];
492
+ const headerValues = extractStaticHeaderValues(captured.headers);
397
493
  return {
398
494
  _version: version,
399
495
  _captured: new Date().toISOString(),
400
496
  _source: 'live',
497
+ _schemaVersion: CURRENT_SCHEMA_VERSION,
401
498
  agent_identity: agentIdentity,
402
499
  system_prompt: systemPrompt,
403
500
  tools,
404
501
  tool_names: tools.map((t) => t.name),
405
502
  header_order: headerOrder,
503
+ anthropic_beta: typeof anthropicBeta === 'string' ? anthropicBeta : undefined,
504
+ header_values: Object.keys(headerValues).length > 0 ? headerValues : undefined,
505
+ };
506
+ }
507
+ /**
508
+ * Pick header values from the captured request that CC would set identically
509
+ * on every outbound call. The replayer overlays these on top of whatever the
510
+ * caller supplied, so anything session-scoped, auth-bearing, or computed by
511
+ * the HTTP stack itself must be excluded.
512
+ */
513
+ const STATIC_HEADER_EXCLUDE = new Set([
514
+ // Auth — never replay across identities
515
+ 'authorization',
516
+ // Body-framing — computed per request
517
+ 'content-type', 'content-length', 'transfer-encoding',
518
+ // Host / connection — managed by the HTTP stack
519
+ 'host', 'connection', 'keep-alive', 'accept-encoding',
520
+ // Session / request identifiers — rotate per call
521
+ 'x-claude-code-session-id', 'x-client-request-id', 'x-request-id',
522
+ // Beta flag is captured separately
523
+ 'anthropic-beta',
524
+ // Billing tag — rebuilt per request from cc_version
525
+ 'x-anthropic-billing-header',
526
+ ]);
527
+ function extractStaticHeaderValues(headers) {
528
+ const out = {};
529
+ for (const [k, v] of Object.entries(headers)) {
530
+ const lk = k.toLowerCase();
531
+ if (STATIC_HEADER_EXCLUDE.has(lk))
532
+ continue;
533
+ if (typeof v !== 'string')
534
+ continue;
535
+ out[lk] = v;
536
+ }
537
+ return out;
538
+ }
539
+ // ============================================================
540
+ // Drift detection + startup diagnostics (v3.17)
541
+ // ============================================================
542
+ let _installedVersionProbe = { value: null, cached: false };
543
+ /**
544
+ * Sync-probe `claude --version` and return the parsed version string, e.g.
545
+ * `"2.1.104"`. Memoized per-process — the binary is invoked at most once,
546
+ * subsequent calls return the cached result. Returns `null` if the binary
547
+ * isn't on PATH, or the probe failed / timed out, or the output didn't
548
+ * match the expected format.
549
+ *
550
+ * Used by `detectDrift` to compare the installed CC against the version
551
+ * recorded in the cache at capture time.
552
+ */
553
+ export function probeInstalledCCVersion() {
554
+ if (_installedVersionProbe.cached)
555
+ return _installedVersionProbe.value;
556
+ const value = probeInstalledCCVersionUncached();
557
+ _installedVersionProbe = { value, cached: true };
558
+ return value;
559
+ }
560
+ function probeInstalledCCVersionUncached() {
561
+ const bin = findClaudeBinary();
562
+ if (!bin)
563
+ return null;
564
+ try {
565
+ // Node 20+ refuses to spawn `.cmd`/`.bat` via execFile without
566
+ // explicit `shell: true` (CVE-2024-27980 hardening). On Windows,
567
+ // npm-installed CLIs commonly live behind a `.cmd` shim — detect
568
+ // that and opt into the shell path.
569
+ //
570
+ // `bin` is normally from findClaudeBinary's fixed allow-list, but
571
+ // DARIO_CLAUDE_BIN lets users override it. If that override reaches
572
+ // the shell path, cmd.exe interprets its contents — so reject any
573
+ // override that carries shell metacharacters before we spawn.
574
+ const useShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(bin);
575
+ if (useShell && /[&|><^"'%\r\n`$;(){}[\]]/.test(bin)) {
576
+ return null;
577
+ }
578
+ const out = execFileSync(bin, ['--version'], {
579
+ encoding: 'utf-8',
580
+ timeout: 2_000,
581
+ stdio: ['ignore', 'pipe', 'ignore'],
582
+ windowsHide: true,
583
+ shell: useShell,
584
+ });
585
+ // `claude --version` currently prints e.g. `1.0.79 (Claude Code)` or
586
+ // `claude-cli 2.1.104`. Accept anything that contains a dotted numeric
587
+ // version — the first match wins.
588
+ const m = /(\d+\.\d+\.\d+(?:[.\-][\w.\-]+)?)/.exec(out);
589
+ return m ? m[1] : null;
590
+ }
591
+ catch {
592
+ return null;
593
+ }
594
+ }
595
+ /**
596
+ * Format how old a captured timestamp is, human-readable. `_captured` is
597
+ * an ISO string written by `extractTemplate` or the bundled snapshot.
598
+ * Falls back to `"unknown age"` if the timestamp doesn't parse.
599
+ */
600
+ export function formatCaptureAge(capturedIso, now = Date.now()) {
601
+ const t = Date.parse(capturedIso);
602
+ if (!Number.isFinite(t))
603
+ return 'unknown age';
604
+ const ageMs = Math.max(0, now - t);
605
+ const s = Math.floor(ageMs / 1000);
606
+ if (s < 60)
607
+ return `${s}s`;
608
+ const m = Math.floor(s / 60);
609
+ if (m < 60)
610
+ return `${m}m`;
611
+ const h = Math.floor(m / 60);
612
+ if (h < 48)
613
+ return `${h}h`;
614
+ const d = Math.floor(h / 24);
615
+ return `${d}d`;
616
+ }
617
+ /**
618
+ * One-line human summary of the active template — what source, which CC
619
+ * version captured it, and how old that capture is. Proxy and shim
620
+ * startup log this so users can tell at a glance whether they're on a
621
+ * fresh live capture or a stale bundled fallback.
622
+ */
623
+ export function describeTemplate(t) {
624
+ const source = t._source ?? 'bundled';
625
+ const age = formatCaptureAge(t._captured);
626
+ return `${source} capture, CC v${t._version} (${age} old)`;
627
+ }
628
+ /**
629
+ * Compare the loaded template's captured CC version against the version
630
+ * reported by `claude --version` on the current machine. Drifted caches
631
+ * are still usable — the shape is probably compatible — but the proxy
632
+ * should force-refresh ASAP so the next startup is back in sync.
633
+ *
634
+ * @param installedOverride test-only injection for unit tests; production
635
+ * callers pass nothing and the real binary probe runs.
636
+ */
637
+ export function detectDrift(t, installedOverride) {
638
+ const installed = installedOverride !== undefined ? installedOverride : probeInstalledCCVersion();
639
+ const cachedVersion = t._version;
640
+ if (installed === null) {
641
+ return {
642
+ drifted: false,
643
+ cachedVersion,
644
+ installedVersion: null,
645
+ message: 'installed CC version not probed (binary not on PATH or probe failed)',
646
+ };
647
+ }
648
+ if (installed === cachedVersion) {
649
+ return {
650
+ drifted: false,
651
+ cachedVersion,
652
+ installedVersion: installed,
653
+ message: `cache matches installed CC (v${installed})`,
654
+ };
655
+ }
656
+ return {
657
+ drifted: true,
658
+ cachedVersion,
659
+ installedVersion: installed,
660
+ message: `cache is from CC v${cachedVersion} but installed CC is v${installed} — background refresh will re-capture`,
661
+ };
662
+ }
663
+ /**
664
+ * Reset the memoized `claude --version` probe. Test-only — production
665
+ * code should never need to clear the cache since the installed binary
666
+ * doesn't change mid-process.
667
+ */
668
+ export function _resetInstalledVersionProbeForTest() {
669
+ _installedVersionProbe = { value: null, cached: false };
670
+ }
671
+ // ============================================================
672
+ // CC version compat matrix (v3.17)
673
+ // ============================================================
674
+ /**
675
+ * The CC version range the current dario release has been exercised
676
+ * against. Update `maxTested` every time we validate against a new CC
677
+ * (ideally as part of the release checklist — the e2e test against the
678
+ * user's own CC is the ground-truth signal).
679
+ *
680
+ * - `min`: below this, dario's extractor hasn't been validated; proxy
681
+ * will still run but may mis-parse CC's request body.
682
+ * - `maxTested`: the newest CC version the current dario release has
683
+ * been exercised against. Above this, dario is *likely* fine (CC's
684
+ * request shape evolves slowly) but it's explicitly untested, so
685
+ * users get a soft warn and we get a signal to refresh the bundled
686
+ * snapshot + rerun e2e.
687
+ */
688
+ export const SUPPORTED_CC_RANGE = {
689
+ min: '1.0.0',
690
+ maxTested: '2.1.104',
691
+ };
692
+ /**
693
+ * Compare two dotted-numeric version strings. Returns negative if `a<b`,
694
+ * zero if equal, positive if `a>b`. Handles suffixes like `-beta.1` or
695
+ * `.dev` by comparing the numeric prefix first and treating anything
696
+ * after as a tiebreaker (strings compared lexicographically; absence of
697
+ * suffix beats presence, matching semver's "release > prerelease").
698
+ *
699
+ * Intentionally minimal — dario's "zero runtime deps" policy rules out
700
+ * pulling `semver`. CC versions are well-formed `M.m.p[-suffix]` so we
701
+ * don't need the full spec.
702
+ */
703
+ export function compareVersions(a, b) {
704
+ const splitPrefixSuffix = (v) => {
705
+ const m = /^(\d+(?:\.\d+)*)(.*)$/.exec(v);
706
+ if (!m)
707
+ return { parts: [0], suffix: v };
708
+ const parts = m[1].split('.').map((s) => parseInt(s, 10));
709
+ return { parts, suffix: m[2] ?? '' };
710
+ };
711
+ const A = splitPrefixSuffix(a);
712
+ const B = splitPrefixSuffix(b);
713
+ const len = Math.max(A.parts.length, B.parts.length);
714
+ for (let i = 0; i < len; i++) {
715
+ const ai = A.parts[i] ?? 0;
716
+ const bi = B.parts[i] ?? 0;
717
+ if (ai !== bi)
718
+ return ai - bi;
719
+ }
720
+ // Numeric prefix equal — compare suffix. Empty suffix beats non-empty
721
+ // (release > prerelease). Otherwise lexicographic.
722
+ if (A.suffix === B.suffix)
723
+ return 0;
724
+ if (A.suffix === '')
725
+ return 1;
726
+ if (B.suffix === '')
727
+ return -1;
728
+ return A.suffix < B.suffix ? -1 : 1;
729
+ }
730
+ /**
731
+ * Check whether the installed CC version sits inside the supported range.
732
+ * Called at startup by the proxy; the result drives whether we emit a
733
+ * compatibility warning to the user.
734
+ *
735
+ * `unknown` is not a failure — it just means we couldn't probe (no CC on
736
+ * PATH, timeout, parse miss). Dario still runs on bundled template.
737
+ *
738
+ * @param installedOverride test-only injection; production callers pass nothing.
739
+ */
740
+ export function checkCCCompat(installedOverride) {
741
+ const installed = installedOverride !== undefined ? installedOverride : probeInstalledCCVersion();
742
+ const range = { min: SUPPORTED_CC_RANGE.min, maxTested: SUPPORTED_CC_RANGE.maxTested };
743
+ if (installed === null) {
744
+ return {
745
+ status: 'unknown',
746
+ installedVersion: null,
747
+ range,
748
+ message: 'installed CC version not probed — compatibility unchecked',
749
+ };
750
+ }
751
+ if (compareVersions(installed, range.min) < 0) {
752
+ return {
753
+ status: 'below-min',
754
+ installedVersion: installed,
755
+ range,
756
+ message: `installed CC v${installed} is older than the minimum dario supports (v${range.min}); extractor may mis-parse requests — upgrade CC`,
757
+ };
758
+ }
759
+ if (compareVersions(installed, range.maxTested) > 0) {
760
+ return {
761
+ status: 'untested-above',
762
+ installedVersion: installed,
763
+ range,
764
+ message: `installed CC v${installed} is newer than dario's last tested version (v${range.maxTested}); usually fine, but untested`,
765
+ };
766
+ }
767
+ return {
768
+ status: 'ok',
769
+ installedVersion: installed,
770
+ range,
771
+ message: `installed CC v${installed} is within the tested range (v${range.min} – v${range.maxTested})`,
406
772
  };
407
773
  }
408
774
  /**
@@ -13,10 +13,27 @@
13
13
  * in a follow-up release.
14
14
  */
15
15
  import { readFile, writeFile, mkdir, unlink, readdir } from 'node:fs/promises';
16
- import { join } from 'node:path';
16
+ import { join, basename } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
18
  const DARIO_DIR = join(homedir(), '.dario');
19
19
  const BACKENDS_DIR = join(DARIO_DIR, 'backends');
20
+ /**
21
+ * Normalize a caller-supplied backend name into a filesystem-safe leaf.
22
+ * Strips any directory component and rejects names outside the allowed
23
+ * charset. Defense in depth — CLI input is already constrained.
24
+ */
25
+ function safeBackendPath(name) {
26
+ if (typeof name !== 'string' || name.length === 0)
27
+ return null;
28
+ const leaf = basename(name);
29
+ if (leaf !== name)
30
+ return null;
31
+ if (leaf === '.' || leaf === '..')
32
+ return null;
33
+ if (!/^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/.test(leaf))
34
+ return null;
35
+ return join(BACKENDS_DIR, `${leaf}.json`);
36
+ }
20
37
  async function ensureDir() {
21
38
  await mkdir(BACKENDS_DIR, { recursive: true, mode: 0o700 });
22
39
  }
@@ -40,12 +57,16 @@ export async function listBackends() {
40
57
  }
41
58
  }
42
59
  export async function saveBackend(creds) {
60
+ const path = safeBackendPath(creds.name);
61
+ if (!path)
62
+ throw new Error(`invalid backend name: ${creds.name}`);
43
63
  await ensureDir();
44
- const path = join(BACKENDS_DIR, `${creds.name}.json`);
45
64
  await writeFile(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
46
65
  }
47
66
  export async function removeBackend(name) {
48
- const path = join(BACKENDS_DIR, `${name}.json`);
67
+ const path = safeBackendPath(name);
68
+ if (!path)
69
+ return false;
49
70
  try {
50
71
  await unlink(path);
51
72
  return true;