@askalf/dario 6.12.30 → 6.13.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.
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
 
29
29
  <p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
30
30
 
31
- <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~40k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
31
+ <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~41k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
32
32
 
33
33
  <sub><a href="#start-in-60-seconds">Start</a> · <a href="#why-people-pick-it">Why dario</a> · <a href="#what-it-does-with-a-request">Routing</a> · <a href="#will-my-account-get-suspended">Risk</a> · <a href="#reference">Reference</a> · <a href="#trust--transparency">Trust</a> · <a href="docs/returning.md">Coming back after a while?</a></sub>
34
34
 
@@ -115,7 +115,7 @@ dario runs entirely on your machine, authenticates as you with your own Claude l
115
115
 
116
116
  | Signal | Status |
117
117
  |---|---|
118
- | Source | **~40k** lines of TypeScript across **78** files, auditable in a weekend. One credential path since v5: the pool. |
118
+ | Source | **~41k** lines of TypeScript across **79** files, auditable in a weekend. One credential path since v5: the pool. |
119
119
  | Dependencies | **0 runtime.** Verify: `npm ls --production` |
120
120
  | Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore, published with OIDC trusted publishing — no long-lived npm token exists to leak |
121
121
  | Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push to master, every PR and weekly · [ClusterFuzzLite](./.github/workflows/cflite.yml) fuzzes the SSE translator, the rejection parsers and the cch stamper weekly · [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/askalf/dario) and [Best Practices](https://www.bestpractices.dev/projects/13638) badges above are live |
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@
19
19
  import { unlink, writeFile } from 'node:fs/promises';
20
20
  import { formatLedgerSummary, formatLedgerConsumers, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
21
21
  import { renderSpendDonuts } from './donuts.js';
22
- import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE, setKeyBudget, parseUsdBudget, parseTokenBudget, formatBudget } from './keys.js';
22
+ import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE, parseKeySocketSpec, setKeyBudget, parseUsdBudget, parseTokenBudget, formatBudget } from './keys.js';
23
23
  /**
24
24
  * `--budget=$5/day` / `--budget-tokens=2M/day` → a KeyBudget, or undefined when
25
25
  * neither flag is present. A flag that does not parse exits 1 with the accepted
@@ -729,6 +729,29 @@ async function proxy() {
729
729
  || ['0', 'false', 'no', 'off'].includes((process.env['DARIO_KEYS'] ?? '').toLowerCase()));
730
730
  const keysPathArg = args.find(a => a.startsWith('--keys-path='));
731
731
  const keysPath = keysPathArg ? keysPathArg.slice('--keys-path='.length) : undefined;
732
+ // --key-socket=<path>=<key> (repeatable) / DARIO_KEY_SOCKETS (comma-separated)
733
+ // Each unix socket is bound to one named key; see ProxyOptions.keySockets.
734
+ const keySocketSpecs = [
735
+ ...args.filter(a => a.startsWith('--key-socket=')).map(a => a.slice('--key-socket='.length)),
736
+ ...(process.env['DARIO_KEY_SOCKETS'] ?? '').split(',').map(s => s.trim()).filter(Boolean),
737
+ ];
738
+ const keySockets = [];
739
+ for (const spec of keySocketSpecs) {
740
+ const ks = parseKeySocketSpec(spec);
741
+ if (!ks) {
742
+ console.error(`[dario] --key-socket: "${spec}" is not <absolute socket path>=<key name>`);
743
+ process.exit(1);
744
+ }
745
+ if (keySockets.some(k => k.path === ks.path)) {
746
+ console.error(`[dario] --key-socket: ${ks.path} is given twice`);
747
+ process.exit(1);
748
+ }
749
+ keySockets.push(ks);
750
+ }
751
+ if (keySockets.length > 0 && !keys) {
752
+ console.error('[dario] --key-socket binds a named key, but named keys are off (--no-keys / DARIO_KEYS=0).');
753
+ process.exit(1);
754
+ }
732
755
  // --preserve-output-format — carry the client body's `output_config.format`
733
756
  // (structured-output JSON schema) through to upstream instead of dropping it
734
757
  // during the CC rebuild. See ProxyOptions.preserveOutputFormat for rationale.
@@ -753,7 +776,7 @@ async function proxy() {
753
776
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
754
777
  process.exit(1);
755
778
  }
756
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, shutdownGraceMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath });
779
+ await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, shutdownGraceMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
757
780
  }
758
781
  /**
759
782
  * `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
@@ -2120,6 +2143,13 @@ async function help() {
2120
2143
  DARIO_API_KEY authenticates. Env: DARIO_KEYS=0;
2121
2144
  --keys-path=<file> / DARIO_KEYS_PATH moves the
2122
2145
  file. See \`dario keys\`. (v6.8)
2146
+ --key-socket=<path>=<key>
2147
+ Also listen on a unix socket bound to one
2148
+ named key: every request on it is that
2149
+ key's, with no secret sent or stored by the
2150
+ caller. Who may connect is the socket's
2151
+ directory and group (it is created 0660).
2152
+ Repeatable. Env: DARIO_KEY_SOCKETS=<spec>,..
2123
2153
  --session-idle-rotate=MS Idle ms before an account's session id
2124
2154
  rotates (default: 900000 = 15 min).
2125
2155
  Real CC rotates once per conversation, not
package/dist/keys.d.ts CHANGED
@@ -207,6 +207,22 @@ export interface KeyPublic {
207
207
  export declare function publicKey(k: KeyRecord, now?: number): KeyPublic;
208
208
  /** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
209
209
  export declare function parseExpiry(value: string, now?: number): number | null;
210
+ /**
211
+ * A unix socket bound to one named key: a request that arrives on it IS that
212
+ * key's, with no secret on the wire and none on the caller's disk. Who may
213
+ * connect is decided by the filesystem (the socket's directory and mode), so
214
+ * a caller that must not hold a credential, such as a CI job running code it
215
+ * did not write, never has one to leak. A header on the socket names no
216
+ * other key: the socket decides.
217
+ */
218
+ export interface KeySocket {
219
+ /** Absolute path of the socket file. */
220
+ path: string;
221
+ /** The named key every request on it is. */
222
+ key: string;
223
+ }
224
+ /** `<absolute path>=<key name>` (`--key-socket`, `DARIO_KEY_SOCKETS`), or null when malformed. */
225
+ export declare function parseKeySocketSpec(spec: string): KeySocket | null;
210
226
  /**
211
227
  * The proxy's live view of the file. Reloads when the file's mtime moves (a
212
228
  * stat per auth — the cost of "no restart"), records last-used with a
@@ -228,6 +244,12 @@ export declare class KeyStore {
228
244
  list(now?: number): KeyPublic[];
229
245
  /** The record a request's credential names, or null. Reloads first when the file moved. */
230
246
  match(provided: string | undefined, now?: number): KeyRecord | null;
247
+ /**
248
+ * The usable record named `name`, or null (unknown, revoked or expired).
249
+ * For a key socket, where the connection names the key and no secret is
250
+ * presented. Reloads first when the file moved.
251
+ */
252
+ byName(name: string, now?: number): KeyRecord | null;
231
253
  /** Note a use; written to disk after a quiet moment. */
232
254
  touch(k: KeyRecord, now?: number): void;
233
255
  /** Apply a mutation to the file on disk (re-read first), then adopt it. */
package/dist/keys.js CHANGED
@@ -386,6 +386,17 @@ export function parseExpiry(value, now = Date.now()) {
386
386
  const abs = Date.parse(v);
387
387
  return Number.isNaN(abs) ? null : abs;
388
388
  }
389
+ /** `<absolute path>=<key name>` (`--key-socket`, `DARIO_KEY_SOCKETS`), or null when malformed. */
390
+ export function parseKeySocketSpec(spec) {
391
+ const i = spec.lastIndexOf('=');
392
+ if (i <= 0)
393
+ return null;
394
+ const path = spec.slice(0, i);
395
+ const key = spec.slice(i + 1);
396
+ if (!path.startsWith('/') || path.includes('\0') || !KEY_NAME_RE.test(key))
397
+ return null;
398
+ return { path, key };
399
+ }
389
400
  /**
390
401
  * The proxy's live view of the file. Reloads when the file's mtime moves (a
391
402
  * stat per auth — the cost of "no restart"), records last-used with a
@@ -435,6 +446,16 @@ export class KeyStore {
435
446
  return null;
436
447
  return matchKey(this.file, provided, now);
437
448
  }
449
+ /**
450
+ * The usable record named `name`, or null (unknown, revoked or expired).
451
+ * For a key socket, where the connection names the key and no secret is
452
+ * presented. Reloads first when the file moved.
453
+ */
454
+ byName(name, now = Date.now()) {
455
+ this.load();
456
+ const k = this.file.keys.find((r) => r.name === name);
457
+ return k && keyIsUsable(k, now) ? k : null;
458
+ }
438
459
  /** Note a use; written to disk after a quiet moment. */
439
460
  touch(k, now = Date.now()) {
440
461
  const iso = new Date(now).toISOString();
@@ -510,7 +510,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
510
510
  */
511
511
  export declare const SUPPORTED_CC_RANGE: {
512
512
  readonly min: "1.0.0";
513
- readonly maxTested: "2.1.291";
513
+ readonly maxTested: "2.1.292";
514
514
  };
515
515
  /**
516
516
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -1211,7 +1211,7 @@ export function detectDrift(t, installedOverride) {
1211
1211
  */
1212
1212
  export const SUPPORTED_CC_RANGE = {
1213
1213
  min: '1.0.0',
1214
- maxTested: '2.1.291',
1214
+ maxTested: '2.1.292',
1215
1215
  };
1216
1216
  /**
1217
1217
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
package/dist/proxy.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type IncomingMessage } from 'node:http';
2
2
  import { type WriteStream } from 'node:fs';
3
3
  import { getAccessToken, getStatus } from './oauth.js';
4
4
  import { type EffortValue } from './cc-template.js';
5
+ import { type KeySocket } from './keys.js';
5
6
  /**
6
7
  * Assemble the `x-anthropic-billing-header` system-block text, following
7
8
  * Claude Code's format:
@@ -398,6 +399,13 @@ interface ProxyOptions {
398
399
  */
399
400
  keys?: boolean;
400
401
  keysPath?: string;
402
+ /**
403
+ * Unix sockets bound to named keys (`--key-socket=<path>=<key>`). Every
404
+ * request on one is that key's, whatever its headers say, so a caller
405
+ * reaches dario with no secret at all and the socket's directory and mode
406
+ * decide who may (keys.ts, KeySocket). Needs named keys on. POSIX only.
407
+ */
408
+ keySockets?: KeySocket[];
401
409
  sessionIdleRotateMs?: number;
402
410
  sessionRotateJitterMs?: number;
403
411
  sessionMaxAgeMs?: number;
@@ -761,5 +769,24 @@ export declare function resolveSingleAccountStartupStatus(deps?: {
761
769
  getStatus: typeof getStatus;
762
770
  getAccessToken: typeof getAccessToken;
763
771
  }): Promise<Awaited<ReturnType<typeof getStatus>>>;
772
+ /**
773
+ * Take `<path>.lock`, held from the stale-socket probe through the bind and
774
+ * released after either. Two starts that both found the same stale socket
775
+ * would otherwise both remove it, the second removing the first's fresh
776
+ * socket. Created with O_EXCL, so one start wins and the other fails. An
777
+ * existing lock is never removed here, however old: removing one is itself a
778
+ * race between two starts, so a lock left by a start that crashed while
779
+ * binding is the operator's to delete. Returns the release.
780
+ */
781
+ export declare function acquireSocketLock(path: string): () => void;
782
+ /**
783
+ * Clear a key socket's path for a new bind. Call it holding acquireSocketLock. A socket nothing answers on is
784
+ * stale (a crashed or killed run) and is removed. One that accepts, or that
785
+ * neither accepts nor refuses within `timeoutMs`, belongs to a running
786
+ * listener: removing it would cut that listener off from every new client,
787
+ * so this throws instead. Any other file is left for the bind to refuse; an
788
+ * unexpected filesystem error is thrown as is.
789
+ */
790
+ export declare function clearStaleSocket(path: string, timeoutMs?: number): Promise<void>;
764
791
  export declare function startProxy(opts?: ProxyOptions): Promise<void>;
765
792
  export {};
package/dist/proxy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
3
- import { readFileSync, readdirSync, createWriteStream } from 'node:fs';
3
+ import { readFileSync, readdirSync, createWriteStream, lstatSync, unlinkSync, chmodSync, openSync, writeSync, closeSync } from 'node:fs';
4
+ import { createServer as createNetServer, connect as netConnect } from 'node:net';
4
5
  import { join } from 'node:path';
5
6
  import { homedir } from 'node:os';
6
7
  import { setDefaultResultOrder } from 'node:dns';
@@ -1230,6 +1231,73 @@ export async function resolveSingleAccountStartupStatus(deps = {
1230
1231
  }
1231
1232
  return status;
1232
1233
  }
1234
+ /**
1235
+ * Take `<path>.lock`, held from the stale-socket probe through the bind and
1236
+ * released after either. Two starts that both found the same stale socket
1237
+ * would otherwise both remove it, the second removing the first's fresh
1238
+ * socket. Created with O_EXCL, so one start wins and the other fails. An
1239
+ * existing lock is never removed here, however old: removing one is itself a
1240
+ * race between two starts, so a lock left by a start that crashed while
1241
+ * binding is the operator's to delete. Returns the release.
1242
+ */
1243
+ export function acquireSocketLock(path) {
1244
+ const lock = `${path}.lock`;
1245
+ let fd;
1246
+ try {
1247
+ fd = openSync(lock, 'wx', 0o600);
1248
+ }
1249
+ catch (err) {
1250
+ if (err.code !== 'EEXIST')
1251
+ throw err;
1252
+ throw new Error(`${lock} exists: another start is binding this socket, or one crashed while binding it; if no dario is starting, remove it`);
1253
+ }
1254
+ try {
1255
+ writeSync(fd, `${process.pid}\n`);
1256
+ }
1257
+ finally {
1258
+ closeSync(fd);
1259
+ }
1260
+ return () => { try {
1261
+ unlinkSync(lock);
1262
+ }
1263
+ catch { /* already gone */ } };
1264
+ }
1265
+ /**
1266
+ * Clear a key socket's path for a new bind. Call it holding acquireSocketLock. A socket nothing answers on is
1267
+ * stale (a crashed or killed run) and is removed. One that accepts, or that
1268
+ * neither accepts nor refuses within `timeoutMs`, belongs to a running
1269
+ * listener: removing it would cut that listener off from every new client,
1270
+ * so this throws instead. Any other file is left for the bind to refuse; an
1271
+ * unexpected filesystem error is thrown as is.
1272
+ */
1273
+ export async function clearStaleSocket(path, timeoutMs = 1000) {
1274
+ let isSocket;
1275
+ try {
1276
+ isSocket = lstatSync(path).isSocket();
1277
+ }
1278
+ catch (err) {
1279
+ if (err.code === 'ENOENT')
1280
+ return;
1281
+ throw err;
1282
+ }
1283
+ if (!isSocket)
1284
+ return;
1285
+ const live = await new Promise((resolve, reject) => {
1286
+ const c = netConnect(path);
1287
+ const timer = setTimeout(() => { c.destroy(); resolve(true); }, timeoutMs);
1288
+ c.once('connect', () => { clearTimeout(timer); c.destroy(); resolve(true); });
1289
+ c.once('error', (err) => {
1290
+ clearTimeout(timer);
1291
+ if (err.code === 'ECONNREFUSED')
1292
+ resolve(false);
1293
+ else
1294
+ reject(err);
1295
+ });
1296
+ });
1297
+ if (live)
1298
+ throw new Error(`${path} is in use by a running listener (another dario?)`);
1299
+ unlinkSync(path);
1300
+ }
1233
1301
  export async function startProxy(opts = {}) {
1234
1302
  const port = opts.port ?? DEFAULT_PORT;
1235
1303
  const host = opts.host ?? process.env.DARIO_HOST ?? DEFAULT_HOST;
@@ -2255,6 +2323,8 @@ export async function startProxy(opts = {}) {
2255
2323
  const ERR_UNAUTH = JSON.stringify({ error: 'Unauthorized', message: 'Invalid or missing API key' });
2256
2324
  const ERR_FORBIDDEN = JSON.stringify({ error: 'Forbidden', message: 'Path not allowed. Supported paths: POST /v1/messages, POST /v1/messages/count_tokens, POST /v1/chat/completions, GET /v1/models' });
2257
2325
  const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
2326
+ /** The named key each key-socket connection is (opts.keySockets); absent for TCP. */
2327
+ const keySocketOf = new WeakMap();
2258
2328
  /**
2259
2329
  * Who is asking. A named key wins when it matches, and is then the
2260
2330
  * request's consumer; otherwise the root key decides as it always has,
@@ -2262,6 +2332,15 @@ export async function startProxy(opts = {}) {
2262
2332
  * A revoked or expired named key is indistinguishable from a wrong one.
2263
2333
  */
2264
2334
  function resolveRequestAuth(req) {
2335
+ // A key socket names the key; no header can name another, or drop to the root key.
2336
+ const bound = keySocketOf.get(req.socket);
2337
+ if (bound !== undefined) {
2338
+ const key = keyStore ? keyStore.byName(bound) : null;
2339
+ if (!key)
2340
+ return { ok: false, key: null };
2341
+ keyStore.touch(key);
2342
+ return { ok: true, key };
2343
+ }
2265
2344
  const provided = req.headers['x-api-key']
2266
2345
  || req.headers.authorization?.replace(/^Bearer\s+/i, '');
2267
2346
  const key = keyStore ? keyStore.match(provided) : null;
@@ -2483,13 +2562,35 @@ export async function startProxy(opts = {}) {
2483
2562
  // The request's first stamp (src/timing.ts): every split below is
2484
2563
  // measured from here, before any parsing, auth or queueing.
2485
2564
  const arrivedAt = Date.now();
2565
+ // Strip query parameters for endpoint matching
2566
+ const urlPath = req.url?.split('?')[0] ?? '';
2567
+ // A key socket's request is its bound key's or it is refused, on every
2568
+ // route: no header (root key, analytics token, admin token) grants a
2569
+ // socket caller anything its key does not. An unusable bound key is a 401
2570
+ // here, before any route can answer; the admin API and seat pins, which
2571
+ // authenticate by their own token, are not served on a key socket.
2572
+ const socketKey = keySocketOf.get(req.socket);
2573
+ if (socketKey !== undefined) {
2574
+ if (!keyStore?.byName(socketKey)) {
2575
+ if (verbose)
2576
+ console.error(`[dario] 401 rejected (key socket: named key "${socketKey}" unknown, revoked or expired): ${req.method} ${urlPath}`);
2577
+ res.writeHead(401, JSON_HEADERS);
2578
+ res.end(ERR_UNAUTH);
2579
+ return;
2580
+ }
2581
+ if (urlPath.startsWith('/admin/') || req.headers[SEAT_PIN_HEADER] !== undefined) {
2582
+ res.writeHead(403, JSON_HEADERS);
2583
+ res.end(JSON.stringify({ type: 'error', error: { type: 'permission_error', message: 'the admin API and seat pins are not served on a key socket' } }));
2584
+ return;
2585
+ }
2586
+ }
2587
+ // CORS preflight, after the key-socket gate so a socket's unusable key
2588
+ // refuses it like any other request.
2486
2589
  if (req.method === 'OPTIONS') {
2487
2590
  res.writeHead(204, CORS_HEADERS);
2488
2591
  res.end();
2489
2592
  return;
2490
2593
  }
2491
- // Strip query parameters for endpoint matching
2492
- const urlPath = req.url?.split('?')[0] ?? '';
2493
2594
  // Liveness probe — always 200 while the HTTP server is accepting requests,
2494
2595
  // deliberately decoupled from OAuth state. Docker's healthcheck (and the
2495
2596
  // autoheal watchdog that keys on it) points HERE, not /health: a broken or
@@ -2518,7 +2619,8 @@ export async function startProxy(opts = {}) {
2518
2619
  // view. Now: authenticated, OR bare loopback that did not arrive via CF.
2519
2620
  const viaCfRay = req.headers['cf-ray'] !== undefined;
2520
2621
  const includeInternal = shouldDiscloseHealthInternals({
2521
- authenticated: authenticateRequest(req.headers, apiKeyBuf),
2622
+ // On a key socket the caller is its named key, never the root key.
2623
+ authenticated: socketKey === undefined && authenticateRequest(req.headers, apiKeyBuf),
2522
2624
  // Without this, `authenticated` is vacuously true on an unkeyed proxy
2523
2625
  // and the tunnel check below is never reached — see the gate's docs.
2524
2626
  keyConfigured: apiKeyBuf !== null,
@@ -2699,10 +2801,12 @@ export async function startProxy(opts = {}) {
2699
2801
  res.end(ANALYTICS_UI_SHELL);
2700
2802
  return;
2701
2803
  }
2702
- // Read-only analytics token: accepted on the read-only surfaces only.
2703
- // Anything else falls through to the normal request auth below.
2804
+ // Read-only analytics token: accepted on the read-only surfaces only, and
2805
+ // only over TCP: on a key socket the bound key decides, so no header can
2806
+ // stand in for one that is unusable. Anything else falls through to the
2807
+ // normal request auth below.
2704
2808
  const analyticsRead = isAnalyticsReadPath(urlPath) && req.method === 'GET';
2705
- const requestAuth = (analyticsRead && analyticsTokenBuf && authenticateRequest(req.headers, analyticsTokenBuf))
2809
+ const requestAuth = (analyticsRead && analyticsTokenBuf && !keySocketOf.has(req.socket) && authenticateRequest(req.headers, analyticsTokenBuf))
2706
2810
  ? { ok: true, key: null }
2707
2811
  : resolveRequestAuth(req);
2708
2812
  if (!requestAuth.ok) {
@@ -2713,7 +2817,10 @@ export async function startProxy(opts = {}) {
2713
2817
  // `dk_` value that did not match is a named key that is unknown,
2714
2818
  // revoked or expired — the three are one case on purpose.
2715
2819
  const provided = req.headers['x-api-key'] || req.headers.authorization?.replace(/^Bearer\s+/i, '');
2716
- console.error(`[dario] #${requestCount} 401 rejected (${looksLikeNamedKey(provided) ? 'named key unknown, revoked or expired' : 'DARIO_API_KEY mismatch'}): ${describeAuthReject(req.headers)}`);
2820
+ const boundKey = keySocketOf.get(req.socket);
2821
+ console.error(boundKey !== undefined
2822
+ ? `[dario] #${requestCount} 401 rejected (key socket: named key "${boundKey}" unknown, revoked or expired)`
2823
+ : `[dario] #${requestCount} 401 rejected (${looksLikeNamedKey(provided) ? 'named key unknown, revoked or expired' : 'DARIO_API_KEY mismatch'}): ${describeAuthReject(req.headers)}`);
2717
2824
  }
2718
2825
  writeLogLine(logFileStream, {
2719
2826
  ts: new Date().toISOString(), req: requestCount,
@@ -5980,6 +6087,58 @@ export async function startProxy(opts = {}) {
5980
6087
  else {
5981
6088
  console.log('[dario] --no-live-capture: background live fingerprint refresh skipped; using bundled template.');
5982
6089
  }
6090
+ // Key sockets: each connection is handed to the same HTTP server, tagged with
6091
+ // its key, so routing, limits, timeouts and the shutdown drain are the TCP
6092
+ // listener's own. A stale socket file from a crashed run is replaced; a
6093
+ // live one, or any other file at the path, is left alone and the start fails.
6094
+ const keySocketServers = [];
6095
+ for (const ks of opts.keySockets ?? []) {
6096
+ if (process.platform === 'win32')
6097
+ throw new Error('--key-socket needs a POSIX host (a unix socket)');
6098
+ if (!keyStore)
6099
+ throw new Error(`--key-socket ${ks.path}: named keys are off (--no-keys / DARIO_KEYS=0), so no key can be bound`);
6100
+ const store = keyStore;
6101
+ const ns = createNetServer((sock) => { keySocketOf.set(sock, ks.key); server.emit('connection', sock); });
6102
+ // Probe, removal and bind under one lock, so a concurrent start cannot
6103
+ // remove the socket this one just bound.
6104
+ let release = null;
6105
+ try {
6106
+ release = acquireSocketLock(ks.path);
6107
+ await clearStaleSocket(ks.path);
6108
+ await new Promise((resolve, reject) => {
6109
+ ns.once('error', reject);
6110
+ // Born 0660 (umask around the synchronous bind), not chmod'd after: no
6111
+ // moment where the socket is wider than it ends up.
6112
+ const umask = process.umask(0o117);
6113
+ try {
6114
+ ns.listen(ks.path, () => { ns.off('error', reject); resolve(); });
6115
+ }
6116
+ finally {
6117
+ process.umask(umask);
6118
+ }
6119
+ });
6120
+ }
6121
+ catch (err) {
6122
+ ns.close();
6123
+ throw new Error(`--key-socket ${ks.path}: ${err instanceof Error ? err.message : String(err)}`);
6124
+ }
6125
+ finally {
6126
+ release?.();
6127
+ }
6128
+ ns.on('error', (err) => {
6129
+ console.error(`[dario] key socket ${ks.path}: ${err.code ?? err.message}`);
6130
+ });
6131
+ // Owner and group read/write, nobody else: with the directory's own mode, the filesystem is the credential.
6132
+ // Kept for a platform that ignores umask on bind.
6133
+ try {
6134
+ chmodSync(ks.path, 0o660);
6135
+ }
6136
+ catch (err) {
6137
+ console.error(`[dario] key socket ${ks.path}: chmod failed (${err instanceof Error ? err.message : err})`);
6138
+ }
6139
+ console.log(`[dario] key socket: ${ks.path} → named key "${ks.key}"${store.byName(ks.key) ? '' : ' (no usable key of that name yet; its requests are refused until one exists)'}`);
6140
+ keySocketServers.push(ns);
6141
+ }
5983
6142
  server.listen(port, host, () => {
5984
6143
  const modeLine = passthrough
5985
6144
  ? 'Mode: passthrough (OAuth swap only, no injection)'
@@ -6114,6 +6273,8 @@ export async function startProxy(opts = {}) {
6114
6273
  // force-exit guard sits past the grace so a stream that never ends cannot
6115
6274
  // wedge shutdown, and a hung fsync cannot either.
6116
6275
  server.close();
6276
+ for (const ns of keySocketServers)
6277
+ ns.close();
6117
6278
  const { graceMs, forceExitMs } = shutdownTimers(opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
6118
6279
  void drainThenClose(() => waitForIdle(() => queue.snapshot().active, { graceMs }), {
6119
6280
  before: [flushPoolTokens, () => ledger?.flush()],
package/docs/commands.md CHANGED
@@ -39,6 +39,7 @@ This page is the per-flag reference. For environment variables grouped by task
39
39
  | `--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` |
40
40
  | `--verbose` / `-v` | Log every request (one line per request — method + path + billing bucket) | off |
41
41
  | `--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 |
42
+ | `--key-socket=<path>=<key>` / `DARIO_KEY_SOCKETS` | Also listen on a unix socket bound to one named key: every request on it is that key's, with no secret sent or stored by the caller; the socket's directory and group decide who may connect. Repeatable. See [Key sockets](./keys.md#a-key-with-no-secret-key-sockets). | none |
42
43
  | `--no-keys` / `DARIO_KEYS=0`; `--keys-path=<file>` / `DARIO_KEYS_PATH` | Ignore named keys (`~/.dario/keys.json`) so only `DARIO_API_KEY` authenticates; or move the file. See [Named keys](./keys.md). (v6.8) | on, `~/.dario/keys.json` |
43
44
  | `--log-file=<path>` / `DARIO_LOG_FILE` | Append one JSON-ND record per completed request to PATH. Useful for backgrounded proxies where stdout is unobserved (where `--verbose` can't help). Field set: `ts`, `req`, `method`, `path`, `model`, `status`, `latency_ms`, `in_tokens`, `out_tokens`, `cache_read`, `cache_create`, `claim`, `bucket`, `account`, `client`, `preserve_tools`, `stream`, plus `reject` / `error` on failure paths. Secrets scrubbed via the same redactor that `--verbose-bodies` uses; no request bodies. | off |
44
45
  | `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
@@ -49,6 +49,7 @@ Halts the proxy when an upstream response reports `representative-claim: overage
49
49
  |---|---|---|---|
50
50
  | `DARIO_KEYS` | `--no-keys` | on | `0` ignores `~/.dario/keys.json`: only `DARIO_API_KEY` authenticates. See [Named keys](./keys.md) |
51
51
  | `DARIO_KEYS_PATH` | `--keys-path=<file>` | `~/.dario/keys.json` | where the key hashes live; `dario keys` and the running proxy read the same file |
52
+ | `DARIO_KEY_SOCKETS` | `--key-socket=<path>=<key>` (repeatable) | none | unix sockets bound to one named key each: every request on one is that key's, with no secret sent. See [Key sockets](./keys.md#a-key-with-no-secret-key-sockets) |
52
53
 
53
54
  ## ChatGPT seats
54
55
 
package/docs/keys.md CHANGED
@@ -146,6 +146,39 @@ For a deployment where only named keys should get in, set `DARIO_API_KEY` to
146
146
  a long random value nobody is given — the non-loopback bind requires one
147
147
  anyway — and hand out named keys.
148
148
 
149
+ ## A key with no secret: key sockets
150
+
151
+ A caller that must not hold any credential (a CI job running code it did not
152
+ write, a sandboxed agent) can still be a named key. `--key-socket=<path>=<key>`
153
+ also listens on a unix socket, and every request on it is that key's: no
154
+ header is needed, and none can name another key or the root key. The key's
155
+ models, budgets, seat, expiry and revoke apply as on TCP; a socket whose key is
156
+ unknown, revoked or expired answers `401` on every route, `/health` and
157
+ `/livez` included. No header grants a socket caller more than its key: the
158
+ analytics token does not apply there, `/health` gives the public view, and the
159
+ admin API and seat pins are refused with `403`.
160
+
161
+ ```bash
162
+ dario keys create ci --models=claude-sonnet-5 --budget=20
163
+ sudo install -d -o dario -g ci-exec -m 2750 /run/dario # setgid: the socket's group is ci-exec
164
+ dario proxy --key-socket=/run/dario/ci.sock=ci
165
+ curl -s --unix-socket /run/dario/ci.sock http://dario/v1/messages -H 'content-type: application/json' -d '…'
166
+ ```
167
+
168
+ Who may connect is the filesystem's call: the socket is created `0660`, so its
169
+ owner and its group can connect and nobody else can. Put it in a directory
170
+ owned by the dario account, grouped to the accounts that may spend the key,
171
+ mode `2750`: the setgid bit gives the socket that group (without it the group
172
+ is dario's own, and the callers cannot connect), and the directory keeps every
173
+ other account from reaching the socket at all. Repeat the flag for more sockets, or list them in
174
+ `DARIO_KEY_SOCKETS=<path>=<key>,…`. A stale socket file at the path is
175
+ replaced; one a running listener still answers on, or any other file there,
176
+ stops the start, as do named keys being off and
177
+ a Windows host. A start holds `<path>.lock` while it checks and binds the
178
+ socket, so two starts at once cannot remove each other's. dario never removes
179
+ an existing lock: one left by a start that crashed while binding stops the
180
+ next start until it is deleted. POSIX only.
181
+
149
182
  ## Compared with a gateway
150
183
 
151
184
  A LiteLLM-style gateway in front of dario does this with a database, a UI and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.12.30",
3
+ "version": "6.13.1",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {