@askalf/dario 6.0.25 → 6.0.27
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 +5 -5
- package/dist/accounts.d.ts +2 -0
- package/dist/accounts.js +6 -0
- package/dist/admin-api.d.ts +2 -0
- package/dist/admin-api.js +7 -1
- package/dist/cli.js +2 -0
- package/dist/doctor-core.d.ts +16 -0
- package/dist/doctor-core.js +34 -4
- package/dist/health-response.d.ts +20 -0
- package/dist/health-response.js +4 -0
- package/dist/oauth.d.ts +8 -0
- package/dist/oauth.js +3 -0
- package/dist/pool.d.ts +4 -0
- package/dist/pool.js +2 -0
- package/dist/proxy.js +53 -0
- package/dist/refresh-grant.d.ts +56 -0
- package/dist/refresh-grant.js +80 -0
- package/docs/integrations/agent-compat.md +3 -3
- package/docs/integrations/compat-matrix.md +1 -1
- package/docs/integrations/cordon.md +97 -0
- package/docs/integrations/hands-walkthrough.md +2 -0
- package/docs/multi-account-pool.md +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
<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 · ~30k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
|
|
22
22
|
|
|
23
|
-
<sub>Part of <a href="#own-your-stack"><strong>Own Your Stack</strong></a> —
|
|
23
|
+
<sub>Part of <a href="#own-your-stack"><strong>Own Your Stack</strong></a> — 11 open tools for owning your AI infra: <a href="https://github.com/askalf/redstamp">redstamp</a> · <a href="https://github.com/askalf/truecopy">truecopy</a> · <a href="https://github.com/askalf/fieldpass">fieldpass</a> · <a href="https://github.com/askalf/plumbline">plumbline</a> · <a href="#own-your-stack">full family ↓</a></sub>
|
|
24
24
|
|
|
25
25
|
</div>
|
|
26
26
|
|
|
@@ -456,16 +456,16 @@ dario is the routing layer of **[Own Your Stack](https://github.com/askalf)**
|
|
|
456
456
|
|
|
457
457
|
- **[dario](https://github.com/askalf/dario)** — own your routing _(you are here)_
|
|
458
458
|
- **[hybrid](https://github.com/askalf/hybrid)** — own your inference
|
|
459
|
-
- **[deepdive](https://github.com/askalf/deepdive)** — own your research
|
|
460
|
-
- **[hands](https://github.com/askalf/hands)** — own your computer-use
|
|
461
459
|
- **[browser-bridge](https://github.com/askalf/browser-bridge)** — own your browser
|
|
462
460
|
- **[redstamp](https://github.com/askalf/redstamp)** — own your agent security
|
|
463
461
|
- **[truecopy](https://github.com/askalf/truecopy)** — own your agent skills
|
|
464
|
-
- **[
|
|
465
|
-
- **[cordon](https://github.com/askalf/cordon)** — own your prompts
|
|
462
|
+
- **[agent-security-stack](https://github.com/askalf/agent-security-stack)** — own your agent security stack: redstamp + truecopy + strongroom leases, one MCP server
|
|
463
|
+
- **[cordon](https://github.com/askalf/cordon)** — own your prompts · [pair it with dario](./docs/integrations/cordon.md)
|
|
466
464
|
- **[fieldpass](https://github.com/askalf/fieldpass)** — own your agent browser
|
|
467
465
|
- **[plumbline](https://github.com/askalf/plumbline)** — own your agent oversight
|
|
468
466
|
- **[amnesia](https://github.com/askalf/amnesia)** — own your search
|
|
467
|
+
- **[pgflex](https://github.com/askalf/pgflex)** — own your Postgres
|
|
468
|
+
- **[redisflex](https://github.com/askalf/redisflex)** — own your Redis
|
|
469
469
|
- **[askalf](https://askalf.org)** — own your operation: the AI operation that runs Sprayberry Labs
|
|
470
470
|
|
|
471
471
|
---
|
package/dist/accounts.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface AccountCredentials {
|
|
|
6
6
|
scopes: string[];
|
|
7
7
|
deviceId: string;
|
|
8
8
|
accountUuid: string;
|
|
9
|
+
/** Epoch ms of the OAuth grant; see OAuthTokens.grantedAt / refresh-grant.ts. */
|
|
10
|
+
grantedAt?: number;
|
|
9
11
|
}
|
|
10
12
|
export declare function listAccountAliases(): Promise<string[]>;
|
|
11
13
|
export declare function loadAccount(alias: string): Promise<AccountCredentials | null>;
|
package/dist/accounts.js
CHANGED
|
@@ -373,6 +373,7 @@ export async function addAccountViaOAuth(alias) {
|
|
|
373
373
|
scopes: tokens.scope?.split(' ') ?? cfg.scopes.split(' '),
|
|
374
374
|
deviceId: identity.deviceId,
|
|
375
375
|
accountUuid: identity.accountUuid,
|
|
376
|
+
grantedAt: Date.now(),
|
|
376
377
|
};
|
|
377
378
|
await saveAccount(creds);
|
|
378
379
|
resolve(creds);
|
|
@@ -508,6 +509,7 @@ export async function completeAddAccount(alias, code, codeVerifier, state) {
|
|
|
508
509
|
scopes: tokens.scope?.split(' ') ?? cfg.scopes.split(' '),
|
|
509
510
|
deviceId: identity.deviceId,
|
|
510
511
|
accountUuid: identity.accountUuid,
|
|
512
|
+
grantedAt: Date.now(),
|
|
511
513
|
};
|
|
512
514
|
await saveAccount(creds);
|
|
513
515
|
return creds;
|
|
@@ -582,6 +584,7 @@ export async function addAccountFromKeychain(alias, target) {
|
|
|
582
584
|
scopes: oauth.scopes ?? ['user:inference'],
|
|
583
585
|
deviceId: identity.deviceId,
|
|
584
586
|
accountUuid: identity.accountUuid,
|
|
587
|
+
grantedAt: oauth.grantedAt,
|
|
585
588
|
};
|
|
586
589
|
await saveAccount(creds);
|
|
587
590
|
return creds;
|
|
@@ -641,6 +644,7 @@ export async function ensureLoginCredentialsInPool(alias = MIGRATED_LOGIN_ALIAS)
|
|
|
641
644
|
scopes: tok.scopes ?? [],
|
|
642
645
|
deviceId: identity.deviceId,
|
|
643
646
|
accountUuid: identity.accountUuid,
|
|
647
|
+
grantedAt: tok.grantedAt,
|
|
644
648
|
});
|
|
645
649
|
return alias;
|
|
646
650
|
}
|
|
@@ -719,6 +723,7 @@ export async function resyncLoginFromCredentialsIfStale() {
|
|
|
719
723
|
scopes: tok.scopes ?? loginAcc.scopes ?? [],
|
|
720
724
|
deviceId: loginAcc.deviceId,
|
|
721
725
|
accountUuid: loginAcc.accountUuid,
|
|
726
|
+
grantedAt: tok.grantedAt ?? loginAcc.grantedAt,
|
|
722
727
|
});
|
|
723
728
|
return 'resynced';
|
|
724
729
|
}
|
|
@@ -769,6 +774,7 @@ export async function mirrorLoginToCredentials(refreshed) {
|
|
|
769
774
|
refreshToken: refreshed.refreshToken,
|
|
770
775
|
expiresAt: refreshed.expiresAt,
|
|
771
776
|
scopes: refreshed.scopes ?? creds?.claudeAiOauth?.scopes ?? [],
|
|
777
|
+
grantedAt: refreshed.grantedAt ?? creds?.claudeAiOauth?.grantedAt,
|
|
772
778
|
});
|
|
773
779
|
return 'mirrored';
|
|
774
780
|
}
|
package/dist/admin-api.d.ts
CHANGED
|
@@ -72,6 +72,8 @@ export interface AdminAccountRecord {
|
|
|
72
72
|
alias: string;
|
|
73
73
|
scopes: string[];
|
|
74
74
|
expiresAt: number;
|
|
75
|
+
/** Epoch ms of the OAuth grant (refresh-grant.ts); undefined when unknown. */
|
|
76
|
+
grantedAt?: number;
|
|
75
77
|
}
|
|
76
78
|
/** Live per-account pool status keyed by alias — see `AdminDeps.poolStatus`. */
|
|
77
79
|
export interface AdminAccountLive {
|
package/dist/admin-api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { startAddAccount, completeAddAccount, removeAccount, listAccountAliases, loadAccount, } from './accounts.js';
|
|
3
3
|
import { parseManualPaste } from './oauth.js';
|
|
4
|
+
import { grantAge } from './refresh-grant.js';
|
|
4
5
|
const PENDING_TTL_MS = 10 * 60_000;
|
|
5
6
|
const MAX_PENDING = 64; // backstop against unbounded growth (distinct aliases)
|
|
6
7
|
const ACCOUNTS_PREFIX = '/admin/accounts/';
|
|
@@ -98,7 +99,7 @@ async function defaultListAccounts() {
|
|
|
98
99
|
const aliases = await listAccountAliases();
|
|
99
100
|
const loaded = await Promise.all(aliases.map(async (alias) => {
|
|
100
101
|
const a = await loadAccount(alias);
|
|
101
|
-
return a ? { alias: a.alias, scopes: a.scopes, expiresAt: a.expiresAt } : null;
|
|
102
|
+
return a ? { alias: a.alias, scopes: a.scopes, expiresAt: a.expiresAt, ...(a.grantedAt !== undefined ? { grantedAt: a.grantedAt } : {}) } : null;
|
|
102
103
|
}));
|
|
103
104
|
return loaded.filter((a) => a !== null);
|
|
104
105
|
}
|
|
@@ -345,10 +346,15 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
|
|
|
345
346
|
const live = deps.poolStatus?.() ?? null;
|
|
346
347
|
const accounts = records.map((r) => {
|
|
347
348
|
const l = live?.get(r.alias);
|
|
349
|
+
const grant = grantAge(r.grantedAt, now);
|
|
348
350
|
return {
|
|
349
351
|
alias: r.alias,
|
|
350
352
|
scopes: r.scopes,
|
|
351
353
|
expires_in_ms: Math.max(0, r.expiresAt - now),
|
|
354
|
+
granted_at: r.grantedAt ?? null,
|
|
355
|
+
grant_age_days: grant.ageDays,
|
|
356
|
+
grant_level: grant.level,
|
|
357
|
+
refresh_wall_at: grant.wallAt,
|
|
352
358
|
// Inline the running pool's live status when this account is in it.
|
|
353
359
|
...(l ? {
|
|
354
360
|
util5h: l.util5h,
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
24
24
|
import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials, readLineFromStdin } from './oauth.js';
|
|
25
25
|
import { startProxy, sanitizeError, parseModelAliasSpecs } from './proxy.js';
|
|
26
26
|
import { VALID_EFFORT_VALUES } from './cc-template.js';
|
|
27
|
+
import { grantAge, describeGrantAge } from './refresh-grant.js';
|
|
27
28
|
import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, addAccountViaManualOAuth, addAccountFromKeychain, KeychainImportError, removeAccount, ensureLoginCredentialsInPool, resyncLoginFromCredentialsIfStale, MIGRATED_LOGIN_ALIAS } from './accounts.js';
|
|
28
29
|
import { listCodexAccountAliases, loadAllCodexAccounts, startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, parseCodexManualPaste } from './codex-accounts.js';
|
|
29
30
|
import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
|
|
@@ -916,6 +917,7 @@ async function accounts() {
|
|
|
916
917
|
const mins = Math.floor((msLeft % 3600000) / 60000);
|
|
917
918
|
const expiry = msLeft > 0 ? `${hours}h ${mins}m` : 'expired';
|
|
918
919
|
console.log(` ${a.alias.padEnd(20)} token expires in ${expiry}`);
|
|
920
|
+
console.log(` ${''.padEnd(20)} ${describeGrantAge(grantAge(a.grantedAt, now))}`);
|
|
919
921
|
}
|
|
920
922
|
console.log('');
|
|
921
923
|
return;
|
package/dist/doctor-core.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* rest of the report down — every check is wrapped so a broken sub-
|
|
11
11
|
* system surfaces as `fail` instead of crashing the CLI.
|
|
12
12
|
*/
|
|
13
|
+
import { type GrantThresholds } from './refresh-grant.js';
|
|
13
14
|
export type CheckStatus = 'ok' | 'warn' | 'fail' | 'info';
|
|
14
15
|
export interface Check {
|
|
15
16
|
/** 'ok' passes; 'warn' is advisory; 'fail' blocks (exit code 1); 'info' is neutral. */
|
|
@@ -125,6 +126,21 @@ export declare function oauthCheckRow(input: {
|
|
|
125
126
|
poolHealthy: number;
|
|
126
127
|
poolTotal: number;
|
|
127
128
|
}): Check;
|
|
129
|
+
/**
|
|
130
|
+
* The refresh-token grant row (refresh-grant.ts), as a pure decision. A token
|
|
131
|
+
* refresh keeps the access token fresh; it does not move the ~28-day wall on
|
|
132
|
+
* the grant. Worst seat decides the row: urgent → fail, warn → warn, unknown
|
|
133
|
+
* (unstamped) → info, all ok → ok. Every seat is listed so the operator sees
|
|
134
|
+
* which one to re-grant.
|
|
135
|
+
*/
|
|
136
|
+
export declare function checkRefreshGrant(input: {
|
|
137
|
+
accounts: Array<{
|
|
138
|
+
alias: string;
|
|
139
|
+
grantedAt?: number | null;
|
|
140
|
+
}>;
|
|
141
|
+
now: number;
|
|
142
|
+
thresholds?: GrantThresholds;
|
|
143
|
+
}): Check[];
|
|
128
144
|
export declare function checkIdentityDrift(input: IdentityDriftInput): Check[];
|
|
129
145
|
export declare function probeNpmLatestCC(): string | null;
|
|
130
146
|
/**
|
package/dist/doctor-core.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* rest of the report down — every check is wrapped so a broken sub-
|
|
11
11
|
* system surfaces as `fail` instead of crashing the CLI.
|
|
12
12
|
*/
|
|
13
|
+
import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
|
|
13
14
|
import { readFileSync } from 'node:fs';
|
|
14
15
|
import { join, dirname } from 'node:path';
|
|
15
16
|
import { fileURLToPath } from 'node:url';
|
|
@@ -172,6 +173,25 @@ export function oauthCheckRow(input) {
|
|
|
172
173
|
detail: legacyStatus === 'none' ? 'not authenticated — run `dario login`' : legacyStatus,
|
|
173
174
|
};
|
|
174
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* The refresh-token grant row (refresh-grant.ts), as a pure decision. A token
|
|
178
|
+
* refresh keeps the access token fresh; it does not move the ~28-day wall on
|
|
179
|
+
* the grant. Worst seat decides the row: urgent → fail, warn → warn, unknown
|
|
180
|
+
* (unstamped) → info, all ok → ok. Every seat is listed so the operator sees
|
|
181
|
+
* which one to re-grant.
|
|
182
|
+
*/
|
|
183
|
+
export function checkRefreshGrant(input) {
|
|
184
|
+
if (input.accounts.length === 0)
|
|
185
|
+
return [];
|
|
186
|
+
const t = input.thresholds ?? grantThresholds();
|
|
187
|
+
const ages = input.accounts.map((a) => ({ alias: a.alias, age: grantAge(a.grantedAt, input.now, t) }));
|
|
188
|
+
const worst = worstGrantLevel(ages.map((a) => a.age.level));
|
|
189
|
+
const status = worst === 'urgent' ? 'fail' : worst === 'warn' ? 'warn' : worst === 'unknown' ? 'info' : 'ok';
|
|
190
|
+
const perSeat = ages.map((a) => `${a.alias}: ${describeGrantAge(a.age, t)}`).join('; ');
|
|
191
|
+
const fix = worst === 'ok' ? '' : REGRANT_FIX;
|
|
192
|
+
return [{ status, label: 'Refresh grant', detail: `${perSeat}${fix}` }];
|
|
193
|
+
}
|
|
194
|
+
const REGRANT_FIX = " — re-grant with `dario accounts add <alias>` (or `dario login --force-reauth` for the login seat); the new grant restarts the clock";
|
|
175
195
|
export function checkIdentityDrift(input) {
|
|
176
196
|
const { live, poolAccounts } = input;
|
|
177
197
|
// Short-prefix for surfaced IDs — 64-char userIDs and 36-char UUIDs are
|
|
@@ -182,14 +202,14 @@ export function checkIdentityDrift(input) {
|
|
|
182
202
|
return [{
|
|
183
203
|
status: 'info',
|
|
184
204
|
label: 'Identity',
|
|
185
|
-
detail: 'no ~/.claude.json found —
|
|
205
|
+
detail: 'no ~/.claude.json found — pool seats carry their own snapshot identity on every request, so they are unaffected; only single-account api-key mode sends no metadata.user_id, and dario cannot tell how Anthropic bills a request that carries none. Run Claude Code once if you want this machine\'s identity snapshotted into seats added here.',
|
|
186
206
|
}];
|
|
187
207
|
}
|
|
188
208
|
if (poolAccounts.length === 0) {
|
|
189
209
|
return [{
|
|
190
210
|
status: 'info',
|
|
191
211
|
label: 'Identity',
|
|
192
|
-
detail: `~/.claude.json userID=${shortId(live.deviceId)} — no pool accounts snapshotted yet, so
|
|
212
|
+
detail: `~/.claude.json userID=${shortId(live.deviceId)} — no pool accounts snapshotted yet, so there is nothing to compare. Seats materialize on the next \`dario login\` / \`dario proxy\` or \`dario accounts add\`.`,
|
|
193
213
|
}];
|
|
194
214
|
}
|
|
195
215
|
const aligned = [];
|
|
@@ -234,10 +254,19 @@ export function checkIdentityDrift(input) {
|
|
|
234
254
|
fixes.push(`\`dario accounts remove <alias>\` then \`dario accounts add <alias>\` to re-snapshot` +
|
|
235
255
|
`${otherDrifted.length === 1 ? '' : ' (for each of them)'} — the add re-runs OAuth for that account`);
|
|
236
256
|
}
|
|
257
|
+
// Informational, not a warning. "Differs from this machine's ~/.claude.json"
|
|
258
|
+
// is the expected state for any seat added from another machine or headless
|
|
259
|
+
// (a minted identity), and it is not a fault: a seat with a minted identity
|
|
260
|
+
// served Sonnet 5 / Opus 5 / Haiku with 200 on 2026-09-06, on a plan with
|
|
261
|
+
// Extra Usage disabled. The failure this check used to promise — non-Haiku
|
|
262
|
+
// 401s — has only been observed when the identity belongs to a DIFFERENT
|
|
263
|
+
// account than the bearer (a transplant that copied one file and not the
|
|
264
|
+
// other). So the row names the seats and the one symptom that would make
|
|
265
|
+
// re-snapshotting them the fix.
|
|
237
266
|
return [{
|
|
238
|
-
status: '
|
|
267
|
+
status: 'info',
|
|
239
268
|
label: 'Identity',
|
|
240
|
-
detail: `${drifted.length}/${poolAccounts.length} pool account${poolAccounts.length === 1 ? '' : 's'}
|
|
269
|
+
detail: `${drifted.length}/${poolAccounts.length} pool account${poolAccounts.length === 1 ? '' : 's'} carry an identity that differs from this machine's ~/.claude.json (live userID=${shortId(live.deviceId)}): ${drifted.join('; ')} — expected for seats added elsewhere or headless (minted identity), and not a fault by itself: a minted identity serves every model normally. Only an identity that belongs to a different account than the seat's bearer has produced 401s on non-Haiku models; if a listed seat does that, re-snapshot it: ${fixes.join('; ')}`,
|
|
241
270
|
}];
|
|
242
271
|
}
|
|
243
272
|
/**
|
|
@@ -905,6 +934,7 @@ export async function runChecks(opts = {}) {
|
|
|
905
934
|
(expired > 0 ? `, ${expired} expired` : '') +
|
|
906
935
|
(aliases.length === 1 ? ' (a pool of one — `dario accounts add <alias>` to load-balance)' : ''),
|
|
907
936
|
});
|
|
937
|
+
checks.push(...checkRefreshGrant({ accounts: loaded.map((a) => ({ alias: a.alias, grantedAt: a.grantedAt })), now }));
|
|
908
938
|
// Next-account-in-rotation surfacing. The proxy's per-request
|
|
909
939
|
// selector picks by max headroom (with 7d_<family> per-model
|
|
910
940
|
// bucket considered when a request's model family is known);
|
|
@@ -14,7 +14,15 @@
|
|
|
14
14
|
* The HTTP status (200 healthy / 503 degraded) is identical either way, so external
|
|
15
15
|
* uptime monitoring that keys on the status code is unaffected.
|
|
16
16
|
*/
|
|
17
|
+
/** Pool-wide refresh-token grant age (refresh-grant.ts), internal-only. */
|
|
18
|
+
export interface RefreshGrantSummary {
|
|
19
|
+
level: 'ok' | 'warn' | 'urgent' | 'unknown';
|
|
20
|
+
oldestAgeDays: number | null;
|
|
21
|
+
daysToWall: number | null;
|
|
22
|
+
seats: Record<string, 'ok' | 'warn' | 'urgent' | 'unknown'>;
|
|
23
|
+
}
|
|
17
24
|
export interface HealthStatusLike {
|
|
25
|
+
refreshGrant?: RefreshGrantSummary;
|
|
18
26
|
status: string;
|
|
19
27
|
canRefresh?: boolean;
|
|
20
28
|
/**
|
|
@@ -25,6 +33,18 @@ export interface HealthStatusLike {
|
|
|
25
33
|
* buildHealthResponse.
|
|
26
34
|
*/
|
|
27
35
|
upstreamApiKeyMode?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* A stored Codex account is serving in place of the Claude pool: the proxy
|
|
38
|
+
* runs `--no-claude-auth` (pool deliberately empty) AND at least one Codex
|
|
39
|
+
* account is present, per the same presence check the codex router asks.
|
|
40
|
+
* Same reasoning as upstreamApiKeyMode — OAuth state is not evidence about
|
|
41
|
+
* serving, so an empty pool must not read as 503 (found by
|
|
42
|
+
* codex-drift-watch.yml: its --no-claude-auth proxy never passed /health).
|
|
43
|
+
* The flag ALONE is not evidence: `--no-claude-auth` with no account and no
|
|
44
|
+
* API key starts fine and can serve nothing — that stays 503 (review on
|
|
45
|
+
* #1224), which is why the caller, not this function, resolves presence.
|
|
46
|
+
*/
|
|
47
|
+
codexServes?: boolean;
|
|
28
48
|
expiresIn?: string;
|
|
29
49
|
refreshFailures?: number;
|
|
30
50
|
lastRefreshError?: string;
|
package/dist/health-response.js
CHANGED
|
@@ -99,7 +99,10 @@ export function buildHealthResponse(s, requestCount, includeInternal, now = Date
|
|
|
99
99
|
// even though the suite's own traffic was being served by the API key.) The
|
|
100
100
|
// probe below stays authoritative in BOTH modes, so a real failed round-trip
|
|
101
101
|
// still degrades — this narrows the structural guess, not the measurement.
|
|
102
|
+
// The same holds when a Codex account serves under --no-claude-auth: the
|
|
103
|
+
// pool is empty BY DESIGN, so OAuth 'none' says nothing about liveness.
|
|
102
104
|
const structurallyDead = s.upstreamApiKeyMode !== true &&
|
|
105
|
+
s.codexServes !== true &&
|
|
103
106
|
(s.status === 'broken' ||
|
|
104
107
|
s.status === 'none' ||
|
|
105
108
|
(s.status === 'expired' && s.canRefresh === false));
|
|
@@ -131,6 +134,7 @@ export function buildHealthResponse(s, requestCount, includeInternal, now = Date
|
|
|
131
134
|
...(s.queue ? { queue: withStalledFor(s.queue, now) } : {}),
|
|
132
135
|
...(s.probe ? { probe: { ...s.probe, ageMs: Math.max(0, now - s.probe.checkedAt) } } : {}),
|
|
133
136
|
...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
|
|
137
|
+
...(s.refreshGrant ? { refreshGrant: s.refreshGrant } : {}),
|
|
134
138
|
}
|
|
135
139
|
: liveness;
|
|
136
140
|
return { httpStatus, body };
|
package/dist/oauth.d.ts
CHANGED
|
@@ -18,6 +18,14 @@ export interface OAuthTokens {
|
|
|
18
18
|
refreshToken: string;
|
|
19
19
|
expiresAt: number;
|
|
20
20
|
scopes: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Epoch ms of the OAuth grant this refresh-token family descends from.
|
|
23
|
+
* Set by the login flows, preserved across refreshes (a rotation does not
|
|
24
|
+
* extend Anthropic's ~28-day refresh-token lifetime — see refresh-grant.ts).
|
|
25
|
+
* Absent on credentials minted before this field existed or imported from a
|
|
26
|
+
* Claude Code keychain, which never recorded it.
|
|
27
|
+
*/
|
|
28
|
+
grantedAt?: number;
|
|
21
29
|
}
|
|
22
30
|
export interface CredentialsFile {
|
|
23
31
|
claudeAiOauth: OAuthTokens;
|
package/dist/oauth.js
CHANGED
|
@@ -597,6 +597,7 @@ async function exchangeCodeWithRedirect(code, codeVerifier, state, port) {
|
|
|
597
597
|
refreshToken: data.refresh_token,
|
|
598
598
|
expiresAt: Date.now() + data.expires_in * 1000,
|
|
599
599
|
scopes: data.scope?.split(' ') || ['user:inference'],
|
|
600
|
+
grantedAt: Date.now(),
|
|
600
601
|
};
|
|
601
602
|
await saveCredentials({ claudeAiOauth: tokens });
|
|
602
603
|
return tokens;
|
|
@@ -739,6 +740,7 @@ async function exchangeCodeManual(code, codeVerifier, state) {
|
|
|
739
740
|
refreshToken: data.refresh_token,
|
|
740
741
|
expiresAt: Date.now() + data.expires_in * 1000,
|
|
741
742
|
scopes: data.scope?.split(' ') || ['user:inference'],
|
|
743
|
+
grantedAt: Date.now(),
|
|
742
744
|
};
|
|
743
745
|
await saveCredentials({ claudeAiOauth: tokens });
|
|
744
746
|
return tokens;
|
|
@@ -854,6 +856,7 @@ async function doRefreshTokens() {
|
|
|
854
856
|
refreshToken: data.refresh_token,
|
|
855
857
|
expiresAt: Date.now() + data.expires_in * 1000,
|
|
856
858
|
scopes: oauth.scopes,
|
|
859
|
+
grantedAt: oauth.grantedAt,
|
|
857
860
|
};
|
|
858
861
|
await saveCredentials({ claudeAiOauth: tokens });
|
|
859
862
|
consecutiveRefreshFailures = 0;
|
package/dist/pool.d.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface PoolAccount {
|
|
|
73
73
|
identity: AccountIdentity;
|
|
74
74
|
rateLimit: RateLimitSnapshot;
|
|
75
75
|
requestCount: number;
|
|
76
|
+
/** Epoch ms of the OAuth grant (refresh-grant.ts); undefined when unknown. */
|
|
77
|
+
grantedAt?: number;
|
|
76
78
|
/**
|
|
77
79
|
* Auth-failure cool-down (dario#234). Set when an upstream returns
|
|
78
80
|
* 401/403 or an `authentication_error` / `permission_error` /
|
|
@@ -198,6 +200,7 @@ export declare class AccountPool {
|
|
|
198
200
|
expiresAt: number;
|
|
199
201
|
deviceId: string;
|
|
200
202
|
accountUuid: string;
|
|
203
|
+
grantedAt?: number;
|
|
201
204
|
}): void;
|
|
202
205
|
remove(alias: string): boolean;
|
|
203
206
|
get size(): number;
|
|
@@ -288,6 +291,7 @@ export interface ReconcilableAccount {
|
|
|
288
291
|
expiresAt: number;
|
|
289
292
|
deviceId: string;
|
|
290
293
|
accountUuid: string;
|
|
294
|
+
grantedAt?: number;
|
|
291
295
|
}
|
|
292
296
|
/**
|
|
293
297
|
* Reconcile a live pool against the current on-disk account set: add or refresh
|
package/dist/pool.js
CHANGED
|
@@ -277,6 +277,7 @@ export class AccountPool {
|
|
|
277
277
|
accessToken: opts.accessToken,
|
|
278
278
|
refreshToken: opts.refreshToken,
|
|
279
279
|
expiresAt: opts.expiresAt,
|
|
280
|
+
grantedAt: opts.grantedAt ?? existing?.grantedAt,
|
|
280
281
|
identity: existing?.identity ?? {
|
|
281
282
|
deviceId: opts.deviceId,
|
|
282
283
|
accountUuid: opts.accountUuid,
|
|
@@ -626,6 +627,7 @@ export function reconcilePoolAccounts(pool, accounts) {
|
|
|
626
627
|
expiresAt: a.expiresAt,
|
|
627
628
|
deviceId: a.deviceId,
|
|
628
629
|
accountUuid: a.accountUuid,
|
|
630
|
+
grantedAt: a.grantedAt,
|
|
629
631
|
});
|
|
630
632
|
}
|
|
631
633
|
for (const existing of pool.all()) {
|
package/dist/proxy.js
CHANGED
|
@@ -16,6 +16,7 @@ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCo
|
|
|
16
16
|
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, CODEX_CLAIM } from './analytics.js';
|
|
17
17
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
18
18
|
import { notify as osNotify } from './notify.js';
|
|
19
|
+
import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
|
|
19
20
|
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials } from './accounts.js';
|
|
20
21
|
import { handleAdminRequest } from './admin-api.js';
|
|
21
22
|
import { createTokenBucket } from './rate-limit.js';
|
|
@@ -1393,6 +1394,7 @@ export async function startProxy(opts = {}) {
|
|
|
1393
1394
|
expiresAt: acc.expiresAt,
|
|
1394
1395
|
deviceId: acc.deviceId,
|
|
1395
1396
|
accountUuid: acc.accountUuid,
|
|
1397
|
+
grantedAt: acc.grantedAt,
|
|
1396
1398
|
});
|
|
1397
1399
|
}
|
|
1398
1400
|
// Startup self-heal (dario#790): eagerly refresh any account whose access
|
|
@@ -1428,10 +1430,37 @@ export async function startProxy(opts = {}) {
|
|
|
1428
1430
|
}));
|
|
1429
1431
|
}
|
|
1430
1432
|
}
|
|
1433
|
+
// Refresh-token grant watch (refresh-grant.ts). The access-token refresh
|
|
1434
|
+
// below cannot save a seat whose GRANT is at the ~28-day wall — the family
|
|
1435
|
+
// dies mid-refresh with invalid_grant and every request on it fails over.
|
|
1436
|
+
// Say so before it happens: once per level per seat, repeated daily while it
|
|
1437
|
+
// stays there, on stderr and as an OS notification.
|
|
1438
|
+
const grantWatch = new Map();
|
|
1439
|
+
const GRANT_NAG_MS = 24 * 60 * 60 * 1000;
|
|
1440
|
+
const watchGrants = () => {
|
|
1441
|
+
const now = Date.now();
|
|
1442
|
+
const t = grantThresholds();
|
|
1443
|
+
for (const acc of pool.all()) {
|
|
1444
|
+
const age = grantAge(acc.grantedAt, now, t);
|
|
1445
|
+
const prev = grantWatch.get(acc.alias);
|
|
1446
|
+
if (age.level !== 'warn' && age.level !== 'urgent') {
|
|
1447
|
+
grantWatch.delete(acc.alias);
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
if (prev && prev.level === age.level && now - prev.at < GRANT_NAG_MS)
|
|
1451
|
+
continue;
|
|
1452
|
+
grantWatch.set(acc.alias, { level: age.level, at: now });
|
|
1453
|
+
console.warn(`[dario] refresh-token grant ${age.level.toUpperCase()} for account "${acc.alias}": ${describeGrantAge(age, t)}`);
|
|
1454
|
+
osNotify(`dario: seat "${acc.alias}" refresh-token grant ${age.level}`, describeGrantAge(age, t));
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
if (!opts.noClaudeAuth)
|
|
1458
|
+
watchGrants();
|
|
1431
1459
|
// Background refresh — keep every account's token fresh without blocking requests
|
|
1432
1460
|
const refreshInterval = setInterval(async () => {
|
|
1433
1461
|
if (opts.noClaudeAuth)
|
|
1434
1462
|
return; // never touch the Claude token in OpenAI-only mode
|
|
1463
|
+
watchGrants();
|
|
1435
1464
|
for (const acc of pool.all()) {
|
|
1436
1465
|
if (acc.expiresAt < Date.now() + 45 * 60 * 1000) {
|
|
1437
1466
|
try {
|
|
@@ -1482,6 +1511,7 @@ export async function startProxy(opts = {}) {
|
|
|
1482
1511
|
expiresAt: acc.expiresAt,
|
|
1483
1512
|
deviceId: acc.deviceId,
|
|
1484
1513
|
accountUuid: acc.accountUuid,
|
|
1514
|
+
grantedAt: acc.grantedAt,
|
|
1485
1515
|
});
|
|
1486
1516
|
}
|
|
1487
1517
|
}
|
|
@@ -1994,10 +2024,25 @@ export async function startProxy(opts = {}) {
|
|
|
1994
2024
|
upstreamApiKey: upstreamApiKey || undefined,
|
|
1995
2025
|
})
|
|
1996
2026
|
: undefined;
|
|
2027
|
+
const grantNow = Date.now();
|
|
2028
|
+
const grantAges = pool.all().map((a) => ({ alias: a.alias, age: grantAge(a.grantedAt, grantNow) }));
|
|
2029
|
+
const refreshGrant = grantAges.length === 0 ? undefined : {
|
|
2030
|
+
level: worstGrantLevel(grantAges.map((g) => g.age.level)),
|
|
2031
|
+
oldestAgeDays: grantAges.reduce((m, g) => g.age.ageDays === null ? m : Math.max(m ?? -1, g.age.ageDays), null),
|
|
2032
|
+
daysToWall: grantAges.reduce((m, g) => g.age.daysToWall === null ? m : Math.min(m ?? Number.MAX_SAFE_INTEGER, g.age.daysToWall), null),
|
|
2033
|
+
seats: Object.fromEntries(grantAges.map((g) => [g.alias, g.age.level])),
|
|
2034
|
+
};
|
|
1997
2035
|
const { httpStatus, body } = buildHealthResponse({
|
|
1998
2036
|
...s,
|
|
2037
|
+
...(refreshGrant ? { refreshGrant } : {}),
|
|
1999
2038
|
version: darioVersion(),
|
|
2000
2039
|
upstreamApiKeyMode: !!upstreamApiKey,
|
|
2040
|
+
// --no-claude-auth: the empty Claude pool is deliberate — but only a
|
|
2041
|
+
// present Codex account is evidence something serves. Same presence
|
|
2042
|
+
// check the codex router asks per request (#1138), so an account
|
|
2043
|
+
// added or removed mid-run is reflected without a restart. Skipped
|
|
2044
|
+
// entirely outside that mode so the Claude path never stats the dir.
|
|
2045
|
+
codexServes: opts.noClaudeAuth === true && await hasAnyCodexAccount(),
|
|
2001
2046
|
...(probe ? { probe } : {}),
|
|
2002
2047
|
// pool.size === 0 is single-account mode (session-id registry drives
|
|
2003
2048
|
// the SESSION_ID slot); a loaded pool routes via sticky bindings.
|
|
@@ -2147,6 +2192,7 @@ export async function startProxy(opts = {}) {
|
|
|
2147
2192
|
// timestamp at all. `updatedAt` was already on the snapshot; it was
|
|
2148
2193
|
// simply never surfaced. null means "never observed" (no response has
|
|
2149
2194
|
// been served on this account yet) rather than "observed at epoch 0".
|
|
2195
|
+
const grant = grantAge(a.grantedAt, now);
|
|
2150
2196
|
return {
|
|
2151
2197
|
alias: a.alias,
|
|
2152
2198
|
util5h: a.rateLimit.util5h,
|
|
@@ -2156,6 +2202,13 @@ export async function startProxy(opts = {}) {
|
|
|
2156
2202
|
status: inCooldown ? 'auth-cooldown' : a.rateLimit.status,
|
|
2157
2203
|
requestCount: a.requestCount,
|
|
2158
2204
|
expiresInMs: Math.max(0, a.expiresAt - now),
|
|
2205
|
+
// Refresh-token grant age (refresh-grant.ts): the wall a token
|
|
2206
|
+
// refresh cannot move. null fields = grant date unknown.
|
|
2207
|
+
grantedAt: a.grantedAt ?? null,
|
|
2208
|
+
grantAgeDays: grant.ageDays,
|
|
2209
|
+
grantLevel: grant.level,
|
|
2210
|
+
refreshWallAt: grant.wallAt,
|
|
2211
|
+
daysToWall: grant.daysToWall,
|
|
2159
2212
|
...(inCooldown
|
|
2160
2213
|
? {
|
|
2161
2214
|
lastAuthFailureAt: a.lastAuthFailureAt,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refresh-token grant age.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic's OAuth refresh token has a hard lifetime measured from the
|
|
5
|
+
* ORIGINAL grant, not from the last rotation. A seat that refreshed every 8h
|
|
6
|
+
* for four weeks still died with `invalid_grant "Refresh token expired"`
|
|
7
|
+
* 28 days 10 hours after its grant (observed 2026-09-05; the only data point,
|
|
8
|
+
* hence the conservative defaults below). The token itself is opaque and the
|
|
9
|
+
* token endpoint reports no refresh expiry, so the calendar is the only
|
|
10
|
+
* signal — and until this module existed dario did not keep the calendar:
|
|
11
|
+
* `grantedAt` is recorded by every code path that performs a grant and
|
|
12
|
+
* preserved across refreshes, and every surface (`/health`, `/accounts`,
|
|
13
|
+
* `dario accounts list`, `dario doctor`, the background refresh loop) reads
|
|
14
|
+
* the age through here.
|
|
15
|
+
*
|
|
16
|
+
* Levels:
|
|
17
|
+
* ok age < warn
|
|
18
|
+
* warn warn ≤ age < urgent — re-grant this week
|
|
19
|
+
* urgent urgent ≤ age — re-grant today; the wall is ~lifetime
|
|
20
|
+
* unknown no grantedAt — seat minted before this field existed
|
|
21
|
+
* (or hand-installed); re-grant to start
|
|
22
|
+
* the clock
|
|
23
|
+
*
|
|
24
|
+
* All three thresholds are env-tunable so an operator who observes a
|
|
25
|
+
* different wall can move them without a release:
|
|
26
|
+
* DARIO_REFRESH_GRANT_LIFETIME_DAYS (28)
|
|
27
|
+
* DARIO_REFRESH_GRANT_WARN_DAYS (21)
|
|
28
|
+
* DARIO_REFRESH_GRANT_URGENT_DAYS (26)
|
|
29
|
+
*/
|
|
30
|
+
export type GrantLevel = 'ok' | 'warn' | 'urgent' | 'unknown';
|
|
31
|
+
export interface GrantThresholds {
|
|
32
|
+
lifetimeDays: number;
|
|
33
|
+
warnDays: number;
|
|
34
|
+
urgentDays: number;
|
|
35
|
+
}
|
|
36
|
+
export interface GrantAge {
|
|
37
|
+
level: GrantLevel;
|
|
38
|
+
/** Whole days since the grant; null when unknown. */
|
|
39
|
+
ageDays: number | null;
|
|
40
|
+
/** Epoch ms of the projected wall (grantedAt + lifetime); null when unknown. */
|
|
41
|
+
wallAt: number | null;
|
|
42
|
+
/** Whole days until the wall (negative once past it); null when unknown. */
|
|
43
|
+
daysToWall: number | null;
|
|
44
|
+
}
|
|
45
|
+
/** Thresholds from the environment, with the documented defaults. Ordering is
|
|
46
|
+
* enforced (warn ≤ urgent ≤ lifetime) so a misconfigured pair can never make
|
|
47
|
+
* a seat skip a level or page after the wall. */
|
|
48
|
+
export declare function grantThresholds(env?: NodeJS.ProcessEnv): GrantThresholds;
|
|
49
|
+
/** Age a single grant. `grantedAt` undefined/null/non-finite → unknown. */
|
|
50
|
+
export declare function grantAge(grantedAt: number | null | undefined, now: number, t?: GrantThresholds): GrantAge;
|
|
51
|
+
/** The level a pool reports as a whole: the worst seat wins. `unknown` ranks
|
|
52
|
+
* between ok and warn — a pool whose seats are all unstamped is not healthy
|
|
53
|
+
* knowledge, but it is not a page either. */
|
|
54
|
+
export declare function worstGrantLevel(levels: readonly GrantLevel[]): GrantLevel;
|
|
55
|
+
/** One-line human summary, shared by `accounts list` and doctor. */
|
|
56
|
+
export declare function describeGrantAge(a: GrantAge, t?: GrantThresholds): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refresh-token grant age.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic's OAuth refresh token has a hard lifetime measured from the
|
|
5
|
+
* ORIGINAL grant, not from the last rotation. A seat that refreshed every 8h
|
|
6
|
+
* for four weeks still died with `invalid_grant "Refresh token expired"`
|
|
7
|
+
* 28 days 10 hours after its grant (observed 2026-09-05; the only data point,
|
|
8
|
+
* hence the conservative defaults below). The token itself is opaque and the
|
|
9
|
+
* token endpoint reports no refresh expiry, so the calendar is the only
|
|
10
|
+
* signal — and until this module existed dario did not keep the calendar:
|
|
11
|
+
* `grantedAt` is recorded by every code path that performs a grant and
|
|
12
|
+
* preserved across refreshes, and every surface (`/health`, `/accounts`,
|
|
13
|
+
* `dario accounts list`, `dario doctor`, the background refresh loop) reads
|
|
14
|
+
* the age through here.
|
|
15
|
+
*
|
|
16
|
+
* Levels:
|
|
17
|
+
* ok age < warn
|
|
18
|
+
* warn warn ≤ age < urgent — re-grant this week
|
|
19
|
+
* urgent urgent ≤ age — re-grant today; the wall is ~lifetime
|
|
20
|
+
* unknown no grantedAt — seat minted before this field existed
|
|
21
|
+
* (or hand-installed); re-grant to start
|
|
22
|
+
* the clock
|
|
23
|
+
*
|
|
24
|
+
* All three thresholds are env-tunable so an operator who observes a
|
|
25
|
+
* different wall can move them without a release:
|
|
26
|
+
* DARIO_REFRESH_GRANT_LIFETIME_DAYS (28)
|
|
27
|
+
* DARIO_REFRESH_GRANT_WARN_DAYS (21)
|
|
28
|
+
* DARIO_REFRESH_GRANT_URGENT_DAYS (26)
|
|
29
|
+
*/
|
|
30
|
+
const DAY_MS = 86_400_000;
|
|
31
|
+
function envDays(name, fallback, env) {
|
|
32
|
+
const raw = env[name];
|
|
33
|
+
if (raw === undefined || raw === '')
|
|
34
|
+
return fallback;
|
|
35
|
+
const n = Number(raw);
|
|
36
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
37
|
+
}
|
|
38
|
+
/** Thresholds from the environment, with the documented defaults. Ordering is
|
|
39
|
+
* enforced (warn ≤ urgent ≤ lifetime) so a misconfigured pair can never make
|
|
40
|
+
* a seat skip a level or page after the wall. */
|
|
41
|
+
export function grantThresholds(env = process.env) {
|
|
42
|
+
const lifetimeDays = envDays('DARIO_REFRESH_GRANT_LIFETIME_DAYS', 28, env);
|
|
43
|
+
const urgentDays = Math.min(envDays('DARIO_REFRESH_GRANT_URGENT_DAYS', 26, env), lifetimeDays);
|
|
44
|
+
const warnDays = Math.min(envDays('DARIO_REFRESH_GRANT_WARN_DAYS', 21, env), urgentDays);
|
|
45
|
+
return { lifetimeDays, warnDays, urgentDays };
|
|
46
|
+
}
|
|
47
|
+
/** Age a single grant. `grantedAt` undefined/null/non-finite → unknown. */
|
|
48
|
+
export function grantAge(grantedAt, now, t = grantThresholds()) {
|
|
49
|
+
if (grantedAt === undefined || grantedAt === null || !Number.isFinite(grantedAt) || grantedAt <= 0) {
|
|
50
|
+
return { level: 'unknown', ageDays: null, wallAt: null, daysToWall: null };
|
|
51
|
+
}
|
|
52
|
+
const ageDays = Math.floor(Math.max(0, now - grantedAt) / DAY_MS);
|
|
53
|
+
const wallAt = grantedAt + t.lifetimeDays * DAY_MS;
|
|
54
|
+
const daysToWall = Math.floor((wallAt - now) / DAY_MS);
|
|
55
|
+
const level = ageDays >= t.urgentDays ? 'urgent' : ageDays >= t.warnDays ? 'warn' : 'ok';
|
|
56
|
+
return { level, ageDays, wallAt, daysToWall };
|
|
57
|
+
}
|
|
58
|
+
const LEVEL_RANK = { ok: 0, unknown: 1, warn: 2, urgent: 3 };
|
|
59
|
+
/** The level a pool reports as a whole: the worst seat wins. `unknown` ranks
|
|
60
|
+
* between ok and warn — a pool whose seats are all unstamped is not healthy
|
|
61
|
+
* knowledge, but it is not a page either. */
|
|
62
|
+
export function worstGrantLevel(levels) {
|
|
63
|
+
let worst = 'ok';
|
|
64
|
+
for (const l of levels)
|
|
65
|
+
if (LEVEL_RANK[l] > LEVEL_RANK[worst])
|
|
66
|
+
worst = l;
|
|
67
|
+
return worst;
|
|
68
|
+
}
|
|
69
|
+
/** One-line human summary, shared by `accounts list` and doctor. */
|
|
70
|
+
export function describeGrantAge(a, t = grantThresholds()) {
|
|
71
|
+
if (a.level === 'unknown' || a.ageDays === null || a.daysToWall === null) {
|
|
72
|
+
return 'grant date unknown — re-grant to start the ~' + t.lifetimeDays + 'd refresh-token clock';
|
|
73
|
+
}
|
|
74
|
+
const wall = a.daysToWall >= 0 ? `~${a.daysToWall}d to the ~${t.lifetimeDays}d wall` : `${-a.daysToWall}d PAST the ~${t.lifetimeDays}d wall`;
|
|
75
|
+
switch (a.level) {
|
|
76
|
+
case 'ok': return `grant ${a.ageDays}d old, ${wall}`;
|
|
77
|
+
case 'warn': return `grant ${a.ageDays}d old, ${wall} — re-grant this week`;
|
|
78
|
+
case 'urgent': return `grant ${a.ageDays}d old, ${wall} — re-grant TODAY or the seat dies mid-refresh`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -14,7 +14,7 @@ For a one-page status table of every tool dario supports — working / inferred
|
|
|
14
14
|
| GitHub Copilot | `run_in_terminal`, `insert_edit_into_file`, `semantic_search`, `codebase_search`, `list_dir`, `fetch_webpage` |
|
|
15
15
|
| OpenHands | `execute_bash`, `str_replace_editor` |
|
|
16
16
|
| OpenClaw | `exec`, `process`, `web_search`, `web_fetch`, `browser`, `message` |
|
|
17
|
-
| hands ([askalf/hands](https://github.com/askalf/hands)) | Anthropic beta computer-use tools (`computer`, `bash`, `str_replace_based_edit_tool`) — auto-preserved via system-prompt identity match (v3.33.0) |
|
|
17
|
+
| hands ([askalf/hands](https://github.com/askalf/hands), archived 2026-09-06) | Anthropic beta computer-use tools (`computer`, `bash`, `str_replace_based_edit_tool`) — auto-preserved via system-prompt identity match (v3.33.0) |
|
|
18
18
|
| Hermes Agent (Nous Research) | `terminal`, `process`, `read_file`, `write_file`, `patch`, `search_files`, `web_search`, `web_extract`, `todo` mapped directly. Hermes-specific tools (`browser_*`, `vision_analyze`, `image_generate`, `skill_*`, `memory`, `session_search`, `cronjob`, `send_message`, `ha_*`, `mixture_of_agents`, `delegate_task`, `execute_code`, `text_to_speech`) have no CC equivalent and auto-preserve through the identity detector. Also consider `--max-tokens=client` so Hermes's 64k/128k per-model caps survive dario's outbound pin. |
|
|
19
19
|
|
|
20
20
|
Text-tool clients (Cline / Kilo Code / Roo Code and forks) are auto-detected via system-prompt identity markers 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. The same identity path also catches `hands` (askalf's computer-use agent) — its tool names overlap with `TOOL_MAP` but its schemas diverge, so identity match → preserve-tools is the only correct routing. If you run dario specifically for wire-level 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.
|
|
@@ -208,9 +208,9 @@ OpenClaw uses the standard `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` env vars
|
|
|
208
208
|
|
|
209
209
|
For a full end-to-end walkthrough — auth-profiles handling, classifier-filter protection, subscription-billing verification, multi-account pool, and the gotchas that bite first-time users — see [`openclaw-walkthrough.md`](./openclaw-walkthrough.md).
|
|
210
210
|
|
|
211
|
-
### hands
|
|
211
|
+
### hands (archived)
|
|
212
212
|
|
|
213
|
-
[hands](https://github.com/askalf/hands)
|
|
213
|
+
[hands](https://github.com/askalf/hands) (archived 2026-09-06; the wire-format notes below still apply to any computer-use agent) was a sister project to dario — a local computer-use agent that drives your OS through its native shell instead of a screenshot loop. Two modes: Claude Login (uses the `claude` CLI directly, no dario required) and SDK mode (audit-logged, supports `--dry-run`, routes through dario for $0 per task).
|
|
214
214
|
|
|
215
215
|
```bash
|
|
216
216
|
# SDK mode — env vars route the Anthropic SDK through dario
|
|
@@ -18,7 +18,7 @@ Status legend:
|
|
|
18
18
|
| **Zed** | Anthropic | Claude backend | ✅ Working | [`agent-compat.md#zed`](./agent-compat.md#zed) |
|
|
19
19
|
| **OpenHands** | Anthropic | Claude backend | ✅ Working | Full walkthrough: [`openhands-walkthrough.md`](./openhands-walkthrough.md) |
|
|
20
20
|
| **OpenClaw** | Anthropic | Claude backend | ✅ Working | Full walkthrough: [`openclaw-walkthrough.md`](./openclaw-walkthrough.md). Identity-detected for preserve-tools. |
|
|
21
|
-
| **hands** | Anthropic | Claude backend | ✅ Working |
|
|
21
|
+
| **hands** (archived) | Anthropic | Claude backend | ✅ Working | Repo archived 2026-09-06. Walkthrough kept for the wire format: [`hands-walkthrough.md`](./hands-walkthrough.md). Identity-detected. |
|
|
22
22
|
| **CC sub-agents** | Anthropic | Claude backend | ✅ Working | `dario subagent install` registers a CC sub-agent that exposes `dario doctor` and other read-only diagnostics inside any CC session. [`sub-agent.md`](./sub-agent.md) |
|
|
23
23
|
| **Claude Agent SDK** | Anthropic | Claude backend | ✅ Working | `baseURL: 'http://localhost:3456'` on the `Anthropic` client. SDK examples in [`usage.md`](./usage.md). |
|
|
24
24
|
| **MCP clients (any)** | MCP / JSON-RPC | dario as MCP server | ✅ Working | `dario mcp` exposes dario as a read-only MCP server. [`mcp-server.md`](./mcp-server.md) |
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# dario + cordon: share a subscription without leaking PII
|
|
2
|
+
|
|
3
|
+
dario lets a team share one Claude or ChatGPT subscription through a single API endpoint. That endpoint sees every prompt. Put [cordon](https://github.com/askalf/cordon) in front of it and raw email addresses, phone numbers, card numbers, SSNs and API keys are tokenized **before** they reach dario, the provider, or the provider's logs. The client still gets the real values back in the reply.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
client ──▶ cordon :8080 ──▶ dario :3456 ──▶ Anthropic / OpenAI
|
|
7
|
+
│ redact / tokenize │ subscription auth, pool, failover
|
|
8
|
+
└ restore in the reply └ nothing changes here
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Both speak the Anthropic and OpenAI wire formats, so nothing about your clients changes except the base URL.
|
|
12
|
+
|
|
13
|
+
## Compose
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
services:
|
|
17
|
+
dario:
|
|
18
|
+
image: ghcr.io/askalf/dario:latest
|
|
19
|
+
environment:
|
|
20
|
+
DARIO_API_KEY: ${DARIO_API_KEY} # required, see docker.md
|
|
21
|
+
DARIO_HOST: 0.0.0.0
|
|
22
|
+
volumes:
|
|
23
|
+
- dario_data:/home/dario/.dario
|
|
24
|
+
expose: ["3456"]
|
|
25
|
+
|
|
26
|
+
cordon:
|
|
27
|
+
# cordon does not publish an image yet; build it from the repo.
|
|
28
|
+
build: https://github.com/askalf/cordon.git
|
|
29
|
+
environment:
|
|
30
|
+
ANTHROPIC_BASE: http://dario:3456
|
|
31
|
+
OPENAI_BASE: http://dario:3456
|
|
32
|
+
DEFAULT_MODE: reversible # de-identify upstream, restore in the reply
|
|
33
|
+
FAIL_MODE: closed # if cordon cannot redact, it refuses, never forwards raw
|
|
34
|
+
ACTIVE_SETS: pii,pci,secrets # add phi if you need MRN/date detection
|
|
35
|
+
ADMIN_TOKEN: ${CORDON_ADMIN_TOKEN}
|
|
36
|
+
AUDIT_LOG: /app/data/audit.jsonl
|
|
37
|
+
POLICY_STORE: /app/data/policies.json
|
|
38
|
+
volumes:
|
|
39
|
+
- cordon_data:/app/data
|
|
40
|
+
ports: ["8080:8080"] # the only port clients need
|
|
41
|
+
|
|
42
|
+
volumes:
|
|
43
|
+
dario_data:
|
|
44
|
+
cordon_data:
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Only cordon is published. dario stays reachable from cordon alone.
|
|
48
|
+
|
|
49
|
+
## Point clients at cordon
|
|
50
|
+
|
|
51
|
+
Use the same dario key you already hand out. cordon forwards `x-api-key` and `authorization` verbatim and never terminates auth.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Anthropic-compatible clients
|
|
55
|
+
export ANTHROPIC_BASE_URL=http://localhost:8080
|
|
56
|
+
export ANTHROPIC_API_KEY=$DARIO_API_KEY
|
|
57
|
+
|
|
58
|
+
# OpenAI-compatible clients
|
|
59
|
+
export OPENAI_BASE_URL=http://localhost:8080/v1
|
|
60
|
+
export OPENAI_API_KEY=$DARIO_API_KEY
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## What you get
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
curl localhost:8080/v1/messages \
|
|
67
|
+
-H "x-api-key: $DARIO_API_KEY" -H 'anthropic-version: 2023-06-01' \
|
|
68
|
+
-H 'content-type: application/json' \
|
|
69
|
+
-d '{"model":"claude-sonnet-5","max_tokens":64,"messages":[{"role":"user",
|
|
70
|
+
"content":"draft a reply to jane@acme.com about card 4012-8888-8888-1881"}]}'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| | value |
|
|
74
|
+
|---|---|
|
|
75
|
+
| the model sees | `draft a reply to <EMAIL_7F3A2B_1> about card <CREDIT_CARD_7F3A2B_1>` |
|
|
76
|
+
| the client gets | the reply with `jane@acme.com` and the card number restored |
|
|
77
|
+
| response headers | `X-Redacted: 2`, `X-Redacted-Types: EMAIL:1,CREDIT_CARD:1` |
|
|
78
|
+
| audit log | counts and types per request, hash-chained, never values |
|
|
79
|
+
|
|
80
|
+
Streaming works in reversible mode; tokens are restored as they arrive.
|
|
81
|
+
|
|
82
|
+
## Per-request control
|
|
83
|
+
|
|
84
|
+
| header | effect |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `X-Redact-Mode: strip` | irreversible placeholders, nothing restored |
|
|
87
|
+
| `X-Redact-Mode: off` | passthrough, still logged as a bypass |
|
|
88
|
+
| `X-Redact-Sets: pii,secrets` | narrow the detector set for this call |
|
|
89
|
+
| `X-Tenant: <id>` | pick a tenant policy instead of deriving it from the key |
|
|
90
|
+
|
|
91
|
+
Detection is deterministic (regex plus checksum validators, no ML), so a redaction never silently rewrites prose that isn't PII. Per-tenant policies and the audit chain are documented in the [cordon README](https://github.com/askalf/cordon#per-tenant-policy--admin).
|
|
92
|
+
|
|
93
|
+
## What this does not do
|
|
94
|
+
|
|
95
|
+
- It does not hide the prompt from dario's own logs before redaction, because there is nothing before redaction: cordon is the first hop.
|
|
96
|
+
- It does not replace provider-side data handling terms. It reduces what those terms apply to.
|
|
97
|
+
- `phi` is off by default. Its date detector redacts framework context (version strings, changelog dates) in coding prompts. Turn it on for clinical workloads only.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dario + hands — battletested setup
|
|
2
2
|
|
|
3
|
+
> **Note:** hands was archived 2026-09-06; this walkthrough is kept for the wire-format details, which still apply to any computer-use agent.
|
|
4
|
+
|
|
3
5
|
End-to-end walkthrough for running [hands](https://github.com/askalf/hands) — a local computer-use agent that drives your OS through its native shell — through dario so the model spend bills against your Claude Pro / Max subscription instead of per-token overage on the computer-use beta. Covers install → mode selection → first run → verification → the gotchas that bite first-time users.
|
|
4
6
|
|
|
5
7
|
This is the **first-party** walkthrough. hands is one of dario's sister projects under [askalf](https://github.com/askalf), so unlike the OpenHands / OpenClaw guides where dario is *integrating* with someone else's tool, this is the canonical end-to-end stack we run ourselves. Most of the integration work has already been done on both ends: dario v3.33.0 auto-detects hands via system-prompt identity match and preserves the computer-use beta tools (`computer`, `bash`, `str_replace_based_edit_tool`) without you needing any flag.
|
|
@@ -92,3 +92,20 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
|
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Every request carries a `billingBucket` field (`subscription` / `subscription_fallback` / `extra_usage` / `api` / `unknown`) so you can see which bucket each request billed against and a `subscriptionPercent` headline number tells you at a glance whether dario is actually routing through your subscription or silently falling to API overage.
|
|
95
|
+
|
|
96
|
+
## Refresh-token grant age
|
|
97
|
+
|
|
98
|
+
A token refresh keeps the access token fresh. It does not move the wall on the refresh token: Anthropic expires the refresh-token family about **28 days after the original OAuth grant**, rotation or not. A seat that refreshed every 8h for four weeks still died with `invalid_grant "Refresh token expired"` 28d 10h after its grant (2026-09-05), and every request on it failed over silently.
|
|
99
|
+
|
|
100
|
+
dario records `grantedAt` on every grant (`dario login`, `dario accounts add`, the admin login flow), preserves it across refreshes, and ages it everywhere the pool is inspected:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
dario accounts list # "grant 12d old, ~16d to the ~28d wall" under each seat
|
|
104
|
+
dario doctor # "Refresh grant" row: warn at 21d, fail at 26d, info when unknown
|
|
105
|
+
curl http://localhost:3456/accounts # grantedAt, grantAgeDays, grantLevel, refreshWallAt, daysToWall per seat
|
|
106
|
+
curl http://localhost:3456/health # refreshGrant: { level, oldestAgeDays, daysToWall, seats } (trusted callers)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The proxy also warns on stderr and sends an OS notification when a seat crosses `warn` or `urgent` (once per level, repeated daily while it stays there). A seat minted before this field existed, or imported from a Claude Code keychain, reports `unknown` — re-grant it to start the clock.
|
|
110
|
+
|
|
111
|
+
Re-grant a seat before the wall with `dario accounts add <alias>` (remove the old entry first) or `dario login --force-reauth` for the `login` seat. Thresholds: `DARIO_REFRESH_GRANT_LIFETIME_DAYS` (28), `DARIO_REFRESH_GRANT_WARN_DAYS` (21), `DARIO_REFRESH_GRANT_URGENT_DAYS` (26).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.27",
|
|
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": {
|