@askalf/dario 6.18.4 → 6.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.
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  <p><strong>One local endpoint. Every AI tool you own. Your Claude seats first, your ChatGPT plan and a metered key as the rungs you chain behind them, and a ledger of who paid for what.</strong></p>
18
18
 
19
- <sub><code>npm i -g @askalf/dario</code> or <code>brew install askalf/tap/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~42k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
19
+ <sub><code>npm i -g @askalf/dario</code> or <code>brew install askalf/tap/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~43k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
20
20
 
21
21
  <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>
22
22
 
@@ -113,11 +113,11 @@ dario runs entirely on your machine, authenticates as you with your own Claude l
113
113
 
114
114
  | Signal | Status |
115
115
  |---|---|
116
- | Source | **~42k** lines of TypeScript across **87** files, auditable in a weekend. One credential path since v5: the pool. |
116
+ | Source | **~43k** lines of TypeScript across **87** files, auditable in a weekend. One credential path since v5: the pool. |
117
117
  | Dependencies | **0 runtime.** Verify: `npm ls --production` |
118
118
  | 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 |
119
119
  | 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 · the [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/askalf/dario) badge above and the [Best Practices](https://www.bestpractices.dev/projects/13638) entry are live |
120
- | Tests | **282 test files** run in parallel by `npm test` on Node 18, 20 and 22; the live e2e / compat / stealth suites have their own entry points. Green on every release |
120
+ | Tests | **285 test files** run in parallel by `npm test` on Node 18, 20 and 22; the live e2e / compat / stealth suites have their own entry points. Green on every release |
121
121
  | Credentials | Your own subscription tokens, never logged, redacted from errors, `0600` on disk in `0700` dirs |
122
122
  | Network | Binds `127.0.0.1` by default; upstream only to configured backends over HTTPS; hardcoded SSRF allow-list; refuses a non-loopback bind without `DARIO_API_KEY`; refuses cross-origin and DNS-rebound browser requests |
123
123
  | Telemetry | **None.** No analytics, no tracking, nothing phones home |
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Shared account registry (`--pool-shared-accounts`). Every instance merges
3
+ * the Claude accounts in its `~/.dario/accounts/` with a registry on the
4
+ * refresh-lock service, through three endpoints both reference backends
5
+ * implement (redis-lock/, cloudflare/refresh-lock/):
6
+ *
7
+ * POST /accounts/list {} → { accounts: { alias: AccountCredentials }, removed: { alias: removedAt } }
8
+ * POST /accounts/<alias>/put { credentials } → { stored }
9
+ * POST /accounts/<alias>/remove { removedAt } → { removed }
10
+ *
11
+ * A sync runs at startup, before the pool loads, then every 15 seconds and
12
+ * after each admin API change. It publishes the removals this process made,
13
+ * reads the registry, writes into the accounts directory every record newer
14
+ * than the file there, removes the files a removal marker covers, pushes
15
+ * every file newer than the registry's record, and hands the running pool
16
+ * what changed, checked against the removals made here up to that moment.
17
+ * Newer is credentialsAreNewer: the later grant, then the later expiry. A
18
+ * removal marker beats every grant made before it and loses to every grant
19
+ * made after it, so the instances' clocks must agree (NTP) to well under the
20
+ * time between a removal and a later login.
21
+ *
22
+ * Every call fails open: with the service unreachable an instance serves the
23
+ * accounts on its own disk, logging once per outage. The registry holds
24
+ * every seat's full credentials, refresh tokens included, with no expiry, so
25
+ * DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
26
+ * separate from `--pool-shared-state`.
27
+ */
28
+ import { type AccountCredentials } from './accounts.js';
29
+ /** Where the sync reads and writes this instance's accounts. accounts.ts in production; a test passes its own. */
30
+ export interface AccountSyncStore {
31
+ loadAll(): Promise<AccountCredentials[]>;
32
+ save(creds: AccountCredentials, opts: {
33
+ fetchedAt: number;
34
+ removedAt?: number;
35
+ }): Promise<{
36
+ saved: boolean;
37
+ replaced?: AccountCredentials;
38
+ }>;
39
+ remove(alias: string, removedAt: number): Promise<boolean>;
40
+ /** Removals this process made, alias to epoch ms. */
41
+ localRemovals(): ReadonlyMap<string, number>;
42
+ }
43
+ export declare const accountFileStore: AccountSyncStore;
44
+ /** What one sync changed on this instance, handed to the running proxy. */
45
+ export interface AccountSyncChange {
46
+ /** Records the pool lacks or holds older credentials for. */
47
+ records: AccountCredentials[];
48
+ /** Seats the pool still holds whose account was removed. */
49
+ removed: Array<{
50
+ alias: string;
51
+ removedAt: number;
52
+ }>;
53
+ }
54
+ /** The credentials a live pool seat holds, as far as ordering them needs. */
55
+ export type LiveSeat = {
56
+ expiresAt: number;
57
+ grantedAt?: number;
58
+ };
59
+ export interface AccountSyncStatus {
60
+ enabled: true;
61
+ intervalMs: number;
62
+ lastSyncAt: number | null;
63
+ lastOkAt: number | null;
64
+ /** Records and removal markers the registry held at the last good read. */
65
+ registryAccounts: number | null;
66
+ registryRemoved: number | null;
67
+ /** Cumulative: records written from the registry, files removed, records pushed, removals published. */
68
+ saved: number;
69
+ removed: number;
70
+ pushed: number;
71
+ removalsPublished: number;
72
+ errors: number;
73
+ lastError: string | null;
74
+ }
75
+ export interface AccountSyncOptions {
76
+ baseUrl: string;
77
+ token: string;
78
+ /** Sync interval, ms. Default 15000. */
79
+ intervalMs?: number;
80
+ store?: AccountSyncStore;
81
+ log?: (line: string) => void;
82
+ }
83
+ export declare const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15000;
84
+ export interface AccountSyncPlan {
85
+ /** Registry records to write here, each with the removal time it must outlive. */
86
+ save: Array<{
87
+ creds: AccountCredentials;
88
+ removedAt?: number;
89
+ }>;
90
+ /** Local files a removal covers. */
91
+ remove: Array<{
92
+ alias: string;
93
+ removedAt: number;
94
+ }>;
95
+ /** Local records newer than the registry's. */
96
+ push: AccountCredentials[];
97
+ }
98
+ /** The latest removal of `alias` either side knows of, or undefined. */
99
+ export declare function removalTime(alias: string, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): number | undefined;
100
+ /**
101
+ * Merge this instance's account files with the registry, per alias. A record
102
+ * counts only when it outlives the latest removal of its alias (the
103
+ * registry's marker or one this process made); of the two that count, the
104
+ * newer wins. The registry's goes to disk, ours goes to the registry, and a
105
+ * local file no record outlives is removed.
106
+ */
107
+ export declare function planAccountSync(local: readonly AccountCredentials[], registry: ReadonlyMap<string, AccountCredentials>, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): AccountSyncPlan;
108
+ /** A registry record complete enough to serve from, stored under its own alias. */
109
+ export declare function isRegistryRecord(alias: string, rec: unknown): rec is AccountCredentials;
110
+ /** How `dario accounts remove` ended with the shared account registry on. */
111
+ export type SharedRemovalResult =
112
+ /** The registry took the removal, and the file here is gone. */
113
+ 'removed'
114
+ /** The registry holds a login for the alias granted after the removal; nothing changed. */
115
+ | 'registry-newer'
116
+ /** The registry took the removal, but the file here holds a login granted after it, which stays. */
117
+ | 'local-newer'
118
+ /** The registry took the removal, but the file here, which it covers, could not be deleted. */
119
+ | 'local-failed';
120
+ /**
121
+ * Remove an account from the registry, then from this instance's disk, for
122
+ * `dario accounts remove`. The removal is published first: a running
123
+ * replica would otherwise push the account back from its own copy. The file
124
+ * goes only once the registry took the removal, and only when it holds no
125
+ * login granted after it. Throws when the service does not answer.
126
+ */
127
+ export declare function removeAccountEverywhere(baseUrl: string, token: string, alias: string, opts?: {
128
+ store?: AccountSyncStore;
129
+ removedAt?: number;
130
+ }): Promise<SharedRemovalResult>;
131
+ export declare class AccountSync {
132
+ readonly intervalMs: number;
133
+ private readonly baseUrl;
134
+ private readonly token;
135
+ private readonly store;
136
+ private readonly log;
137
+ private onChange;
138
+ private live;
139
+ private timer;
140
+ private running;
141
+ private queued;
142
+ private readonly published;
143
+ private markers;
144
+ private down;
145
+ private stats;
146
+ constructor(opts: AccountSyncOptions);
147
+ /**
148
+ * Connect a running pool: `live` reads a seat's credentials, `onChange`
149
+ * gets what each later sync changed. Until then a sync updates the
150
+ * accounts directory only, which is all the startup sync needs, since the
151
+ * pool loads from that directory after it.
152
+ */
153
+ attach(live: (alias: string) => LiveSeat | undefined, onChange: (change: AccountSyncChange) => Promise<void> | void): void;
154
+ /** Sync on the interval. The timer never keeps the process alive. */
155
+ start(): void;
156
+ stop(): void;
157
+ status(): AccountSyncStatus;
158
+ /**
159
+ * The latest removal of `alias` this instance knows of: the registry's
160
+ * marker at the last good read, or one this process made. A path that
161
+ * writes account files outside the sync checks it, so it never restores a
162
+ * login the sync just removed.
163
+ */
164
+ removalOf(alias: string): number | undefined;
165
+ /** One sync. Null when the registry could not be read; never throws. */
166
+ syncOnce(): Promise<AccountSyncChange | null>;
167
+ private run;
168
+ /**
169
+ * One POST to the service. Null on any failure: the caller carries on with
170
+ * the accounts on disk, which is the whole contract. Logged once per
171
+ * transition into and out of the failed state, not per call.
172
+ */
173
+ private call;
174
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Shared account registry (`--pool-shared-accounts`). Every instance merges
3
+ * the Claude accounts in its `~/.dario/accounts/` with a registry on the
4
+ * refresh-lock service, through three endpoints both reference backends
5
+ * implement (redis-lock/, cloudflare/refresh-lock/):
6
+ *
7
+ * POST /accounts/list {} → { accounts: { alias: AccountCredentials }, removed: { alias: removedAt } }
8
+ * POST /accounts/<alias>/put { credentials } → { stored }
9
+ * POST /accounts/<alias>/remove { removedAt } → { removed }
10
+ *
11
+ * A sync runs at startup, before the pool loads, then every 15 seconds and
12
+ * after each admin API change. It publishes the removals this process made,
13
+ * reads the registry, writes into the accounts directory every record newer
14
+ * than the file there, removes the files a removal marker covers, pushes
15
+ * every file newer than the registry's record, and hands the running pool
16
+ * what changed, checked against the removals made here up to that moment.
17
+ * Newer is credentialsAreNewer: the later grant, then the later expiry. A
18
+ * removal marker beats every grant made before it and loses to every grant
19
+ * made after it, so the instances' clocks must agree (NTP) to well under the
20
+ * time between a removal and a later login.
21
+ *
22
+ * Every call fails open: with the service unreachable an instance serves the
23
+ * accounts on its own disk, logging once per outage. The registry holds
24
+ * every seat's full credentials, refresh tokens included, with no expiry, so
25
+ * DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
26
+ * separate from `--pool-shared-state`.
27
+ */
28
+ import { loadAllAccounts, saveSharedAccount, removeSharedAccount, localAccountRemovals, credentialsAreNewer, grantedAfter, isValidAccountAlias, } from './accounts.js';
29
+ import { maskEmail } from './pool.js';
30
+ import { clampTimerMs } from './timer-ms.js';
31
+ export const accountFileStore = {
32
+ loadAll: loadAllAccounts,
33
+ save: saveSharedAccount,
34
+ remove: removeSharedAccount,
35
+ localRemovals: localAccountRemovals,
36
+ };
37
+ export const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15_000;
38
+ const CALL_TIMEOUT_MS = 3_000;
39
+ /** The latest removal of `alias` either side knows of, or undefined. */
40
+ export function removalTime(alias, markers, localRemovals) {
41
+ const a = markers.get(alias), b = localRemovals.get(alias);
42
+ if (a === undefined)
43
+ return b;
44
+ if (b === undefined)
45
+ return a;
46
+ return Math.max(a, b);
47
+ }
48
+ /**
49
+ * Merge this instance's account files with the registry, per alias. A record
50
+ * counts only when it outlives the latest removal of its alias (the
51
+ * registry's marker or one this process made); of the two that count, the
52
+ * newer wins. The registry's goes to disk, ours goes to the registry, and a
53
+ * local file no record outlives is removed.
54
+ */
55
+ export function planAccountSync(local, registry, markers, localRemovals) {
56
+ const plan = { save: [], remove: [], push: [] };
57
+ const localByAlias = new Map(local.map((a) => [a.alias, a]));
58
+ for (const alias of new Set([...localByAlias.keys(), ...registry.keys()])) {
59
+ const removedAt = removalTime(alias, markers, localRemovals);
60
+ const mine = localByAlias.get(alias);
61
+ const theirs = registry.get(alias);
62
+ const mineCounts = mine !== undefined && grantedAfter(mine, removedAt);
63
+ const theirsCount = theirs !== undefined && grantedAfter(theirs, removedAt);
64
+ if (theirsCount && (!mineCounts || credentialsAreNewer(theirs, mine))) {
65
+ plan.save.push(removedAt === undefined ? { creds: theirs } : { creds: theirs, removedAt });
66
+ }
67
+ else if (mineCounts) {
68
+ if (!theirsCount || credentialsAreNewer(mine, theirs))
69
+ plan.push.push(mine);
70
+ }
71
+ else if (mine !== undefined && removedAt !== undefined) {
72
+ plan.remove.push({ alias, removedAt });
73
+ }
74
+ }
75
+ return plan;
76
+ }
77
+ /** A registry record complete enough to serve from, stored under its own alias. */
78
+ export function isRegistryRecord(alias, rec) {
79
+ const r = rec;
80
+ return !!r && typeof r === 'object' && r.alias === alias && isValidAccountAlias(alias)
81
+ && typeof r.accessToken === 'string' && r.accessToken.length > 0
82
+ && typeof r.refreshToken === 'string' && r.refreshToken.length > 0
83
+ && typeof r.expiresAt === 'number' && Number.isFinite(r.expiresAt)
84
+ && Array.isArray(r.scopes) && typeof r.deviceId === 'string' && typeof r.accountUuid === 'string';
85
+ }
86
+ function parseRegistry(raw) {
87
+ const out = new Map();
88
+ if (!raw || typeof raw !== 'object')
89
+ return out;
90
+ for (const [alias, rec] of Object.entries(raw))
91
+ if (isRegistryRecord(alias, rec))
92
+ out.set(alias, rec);
93
+ return out;
94
+ }
95
+ function parseMarkers(raw) {
96
+ const out = new Map();
97
+ if (!raw || typeof raw !== 'object')
98
+ return out;
99
+ for (const [alias, at] of Object.entries(raw)) {
100
+ if (isValidAccountAlias(alias) && typeof at === 'number' && Number.isFinite(at))
101
+ out.set(alias, at);
102
+ }
103
+ return out;
104
+ }
105
+ async function registryCall(baseUrl, token, path, body) {
106
+ const res = await fetch(`${baseUrl.replace(/\/+$/, '')}${path}`, {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
109
+ body: JSON.stringify(body),
110
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
111
+ });
112
+ if (!res.ok)
113
+ throw new Error(`HTTP ${res.status}`);
114
+ return (await res.json());
115
+ }
116
+ /**
117
+ * Remove an account from the registry, then from this instance's disk, for
118
+ * `dario accounts remove`. The removal is published first: a running
119
+ * replica would otherwise push the account back from its own copy. The file
120
+ * goes only once the registry took the removal, and only when it holds no
121
+ * login granted after it. Throws when the service does not answer.
122
+ */
123
+ export async function removeAccountEverywhere(baseUrl, token, alias, opts = {}) {
124
+ const store = opts.store ?? accountFileStore;
125
+ const removedAt = opts.removedAt ?? Date.now();
126
+ const res = await registryCall(baseUrl, token, `/accounts/${encodeURIComponent(alias)}/remove`, { removedAt });
127
+ if (res.removed !== true)
128
+ return 'registry-newer';
129
+ if (await store.remove(alias, removedAt))
130
+ return 'removed';
131
+ // Not removed by this call: a running proxy on this disk applied the
132
+ // marker first, a login landed in the file after the removal, or the
133
+ // file could not be deleted. Only an absent file counts as removed.
134
+ const current = (await store.loadAll()).find((a) => a.alias === alias);
135
+ if (!current)
136
+ return 'removed';
137
+ return grantedAfter(current, removedAt) ? 'local-newer' : 'local-failed';
138
+ }
139
+ function describeAccount(c) {
140
+ return maskEmail(c.accountEmail) ?? c.accountId ?? 'unknown account';
141
+ }
142
+ export class AccountSync {
143
+ intervalMs;
144
+ baseUrl;
145
+ token;
146
+ store;
147
+ log;
148
+ onChange = null;
149
+ live = null;
150
+ timer = null;
151
+ // One sync at a time, and at most one waiting: a call while one runs gets
152
+ // the next run, which starts after it and so sees what it wrote.
153
+ running = Promise.resolve();
154
+ queued = null;
155
+ // Removal time last published per alias, so each removal goes out once.
156
+ published = new Map();
157
+ // The registry's removal markers at the last good read.
158
+ markers = new Map();
159
+ down = false;
160
+ stats = {
161
+ lastSyncAt: null, lastOkAt: null,
162
+ registryAccounts: null, registryRemoved: null,
163
+ saved: 0, removed: 0, pushed: 0, removalsPublished: 0, errors: 0, lastError: null,
164
+ };
165
+ constructor(opts) {
166
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
167
+ this.token = opts.token;
168
+ this.intervalMs = clampTimerMs(Math.max(1_000, opts.intervalMs ?? DEFAULT_ACCOUNT_SYNC_INTERVAL_MS));
169
+ this.store = opts.store ?? accountFileStore;
170
+ this.log = opts.log ?? ((line) => console.error(line));
171
+ }
172
+ /**
173
+ * Connect a running pool: `live` reads a seat's credentials, `onChange`
174
+ * gets what each later sync changed. Until then a sync updates the
175
+ * accounts directory only, which is all the startup sync needs, since the
176
+ * pool loads from that directory after it.
177
+ */
178
+ attach(live, onChange) {
179
+ this.live = live;
180
+ this.onChange = onChange;
181
+ }
182
+ /** Sync on the interval. The timer never keeps the process alive. */
183
+ start() {
184
+ if (this.timer)
185
+ return;
186
+ this.timer = setInterval(() => { void this.syncOnce(); }, this.intervalMs);
187
+ this.timer.unref?.();
188
+ }
189
+ stop() {
190
+ if (this.timer)
191
+ clearInterval(this.timer);
192
+ this.timer = null;
193
+ }
194
+ status() {
195
+ return { enabled: true, intervalMs: this.intervalMs, ...this.stats };
196
+ }
197
+ /**
198
+ * The latest removal of `alias` this instance knows of: the registry's
199
+ * marker at the last good read, or one this process made. A path that
200
+ * writes account files outside the sync checks it, so it never restores a
201
+ * login the sync just removed.
202
+ */
203
+ removalOf(alias) {
204
+ return removalTime(alias, this.markers, this.store.localRemovals());
205
+ }
206
+ /** One sync. Null when the registry could not be read; never throws. */
207
+ syncOnce() {
208
+ if (this.queued)
209
+ return this.queued;
210
+ const next = this.running.then(() => {
211
+ this.queued = null;
212
+ return this.run().catch((err) => {
213
+ this.stats.errors++;
214
+ this.stats.lastError = err instanceof Error ? err.message : String(err);
215
+ this.log(`[dario] shared accounts: sync failed (${this.stats.lastError}); retrying on the next sync`);
216
+ return null;
217
+ });
218
+ });
219
+ this.queued = next;
220
+ this.running = next;
221
+ return next;
222
+ }
223
+ async run() {
224
+ this.stats.lastSyncAt = Date.now();
225
+ // Removals first: a list read after them no longer holds those records,
226
+ // and one read before would hand them straight back.
227
+ for (const [alias, at] of this.store.localRemovals()) {
228
+ if ((this.published.get(alias) ?? -Infinity) >= at)
229
+ continue;
230
+ const res = await this.call(`/accounts/${encodeURIComponent(alias)}/remove`, { removedAt: at });
231
+ if (!res)
232
+ break;
233
+ this.published.set(alias, at);
234
+ this.stats.removalsPublished++;
235
+ }
236
+ const fetchedAt = Date.now();
237
+ const res = await this.call('/accounts/list', {});
238
+ if (!res)
239
+ return null;
240
+ const registry = parseRegistry(res.accounts);
241
+ const markers = parseMarkers(res.removed);
242
+ this.markers = markers;
243
+ this.stats.registryAccounts = registry.size;
244
+ this.stats.registryRemoved = markers.size;
245
+ const local = await this.store.loadAll();
246
+ const localRemovals = this.store.localRemovals();
247
+ const plan = planAccountSync(local, registry, markers, localRemovals);
248
+ // The record each alias ends on here: the files that outlive their
249
+ // alias's removal, then whatever the registry replaced.
250
+ const kept = new Map();
251
+ for (const a of local)
252
+ if (grantedAfter(a, removalTime(a.alias, markers, localRemovals)))
253
+ kept.set(a.alias, a);
254
+ const removed = [];
255
+ for (const { alias, removedAt } of plan.remove) {
256
+ // Kept when a login landed in the file since it was read; the next
257
+ // sync takes that login to the pool.
258
+ if (!(await this.store.remove(alias, removedAt)))
259
+ continue;
260
+ kept.delete(alias);
261
+ removed.push({ alias, removedAt });
262
+ this.stats.removed++;
263
+ this.log(`[dario] shared accounts: removed account "${alias}"; it was removed on another instance`);
264
+ }
265
+ for (const { creds, removedAt } of plan.save) {
266
+ const out = await this.store.save(creds, removedAt === undefined ? { fetchedAt } : { fetchedAt, removedAt });
267
+ if (!out.saved)
268
+ continue;
269
+ kept.set(creds.alias, creds);
270
+ this.stats.saved++;
271
+ const was = out.replaced;
272
+ if (!was) {
273
+ this.log(`[dario] shared accounts: added account "${creds.alias}" (${describeAccount(creds)}) from the registry`);
274
+ }
275
+ else if (was.accountId && creds.accountId && was.accountId !== creds.accountId) {
276
+ // Aliases are one namespace across every instance. Two instances
277
+ // that each logged a different subscription in under one alias
278
+ // (`login`, most often) keep only the newer grant.
279
+ this.log(`[dario] shared accounts: account "${creds.alias}" here was ${describeAccount(was)} and is now ${describeAccount(creds)}, a newer login under the same alias on another instance; aliases are shared across instances, so give each subscription its own alias`);
280
+ }
281
+ }
282
+ for (const creds of plan.push) {
283
+ // A removal made here while this sync ran waits for the next one;
284
+ // pushing the file it removed would bring the account back until then.
285
+ const removedSince = this.store.localRemovals().get(creds.alias);
286
+ if (!grantedAfter(creds, removedSince))
287
+ continue;
288
+ const out = await this.call(`/accounts/${encodeURIComponent(creds.alias)}/put`, { credentials: creds });
289
+ if (!out)
290
+ break;
291
+ if (!out.stored)
292
+ continue;
293
+ this.stats.pushed++;
294
+ if (!registry.has(creds.alias))
295
+ this.log(`[dario] shared accounts: published account "${creds.alias}" to the registry`);
296
+ }
297
+ const change = { records: [], removed };
298
+ if (this.live) {
299
+ // The pool gets the state as of now: a removal made here while this
300
+ // sync waited on the service covers the records read before it.
301
+ const removedNow = this.store.localRemovals();
302
+ for (const [alias, rec] of kept)
303
+ if (!grantedAfter(rec, removedNow.get(alias)))
304
+ kept.delete(alias);
305
+ for (const rec of kept.values()) {
306
+ const seat = this.live(rec.alias);
307
+ if (!seat || credentialsAreNewer(rec, seat))
308
+ change.records.push(rec);
309
+ }
310
+ // A seat whose file went another way (a CLI removal in another
311
+ // process, or a peer on a shared accounts volume) is still in the pool.
312
+ const planned = new Set(plan.remove.map((r) => r.alias));
313
+ for (const alias of new Set([...markers.keys(), ...removedNow.keys()])) {
314
+ if (kept.has(alias) || planned.has(alias))
315
+ continue;
316
+ const removedAt = removalTime(alias, markers, removedNow);
317
+ const seat = this.live(alias);
318
+ if (seat && !grantedAfter(seat, removedAt))
319
+ removed.push({ alias, removedAt });
320
+ }
321
+ }
322
+ if (this.onChange && (change.records.length > 0 || change.removed.length > 0))
323
+ await this.onChange(change);
324
+ return change;
325
+ }
326
+ /**
327
+ * One POST to the service. Null on any failure: the caller carries on with
328
+ * the accounts on disk, which is the whole contract. Logged once per
329
+ * transition into and out of the failed state, not per call.
330
+ */
331
+ async call(path, body) {
332
+ try {
333
+ const out = await registryCall(this.baseUrl, this.token, path, body);
334
+ this.stats.lastOkAt = Date.now();
335
+ if (this.down) {
336
+ this.down = false;
337
+ this.log(`[dario] shared accounts: service reachable again (${this.baseUrl})`);
338
+ }
339
+ return out;
340
+ }
341
+ catch (err) {
342
+ this.stats.errors++;
343
+ this.stats.lastError = err instanceof Error ? err.message : String(err);
344
+ if (!this.down) {
345
+ this.down = true;
346
+ this.log(`[dario] shared accounts: ${this.baseUrl} unreachable (${this.stats.lastError}); serving the accounts on this instance's disk until it answers again`);
347
+ }
348
+ return null;
349
+ }
350
+ }
351
+ }
@@ -1,3 +1,5 @@
1
+ /** Whether `alias` is one an account file can be stored under. */
2
+ export declare function isValidAccountAlias(alias: string): boolean;
1
3
  export interface AccountCredentials {
2
4
  alias: string;
3
5
  accessToken: string;
@@ -38,8 +40,54 @@ export type IdentitySource = 'claude-code' | 'generated';
38
40
  export declare function listAccountAliases(): Promise<string[]>;
39
41
  export declare function loadAccount(alias: string): Promise<AccountCredentials | null>;
40
42
  export declare function loadAllAccounts(): Promise<AccountCredentials[]>;
43
+ /**
44
+ * Whether `candidate` holds newer credentials than `current`. A newer grant
45
+ * (a fresh login) wins outright, since an older grant's refreshed access
46
+ * token can expire later. Same grant, or a grant time missing on either
47
+ * side: the later expiry wins. The lock services apply the same rule.
48
+ */
49
+ export declare function credentialsAreNewer(candidate: {
50
+ expiresAt: number;
51
+ grantedAt?: number;
52
+ }, current: {
53
+ expiresAt?: number;
54
+ grantedAt?: number;
55
+ }): boolean;
41
56
  export declare function saveAccount(creds: AccountCredentials): Promise<void>;
57
+ export declare function _setAccountWriteHookForTest(hook: (() => Promise<void>) | null): void;
58
+ /** The account files this process has removed, alias to epoch ms. */
59
+ export declare function localAccountRemovals(): ReadonlyMap<string, number>;
42
60
  export declare function removeAccount(alias: string): Promise<boolean>;
61
+ /**
62
+ * Whether credentials outlive a removal made at `removedAt`: granted after
63
+ * it, so a login that came later. With no removal, every record does. A
64
+ * record with no grant time predates grant times, so it predates the removal.
65
+ */
66
+ export declare function grantedAfter(creds: {
67
+ grantedAt?: number;
68
+ }, removedAt: number | undefined): boolean;
69
+ /**
70
+ * Write a record read from the shared account registry (account-sync.ts),
71
+ * inside the alias's write chain. Skipped when this process removed the file
72
+ * at or after `fetchedAt`, since the registry was read before that removal,
73
+ * and when the file already holds credentials at least as new that outlive
74
+ * `removedAt`. `replaced` is the record the write overwrote.
75
+ */
76
+ export declare function saveSharedAccount(creds: AccountCredentials, opts: {
77
+ fetchedAt: number;
78
+ removedAt?: number;
79
+ }): Promise<{
80
+ saved: boolean;
81
+ replaced?: AccountCredentials;
82
+ }>;
83
+ /**
84
+ * Remove an account file because the shared account registry holds a
85
+ * removal made at `removedAt`, inside the alias's write chain. A file granted
86
+ * after the removal is a later login and is kept. Not recorded in
87
+ * localAccountRemovals: publishing it again under a later time would remove
88
+ * a login a peer made between the two.
89
+ */
90
+ export declare function removeSharedAccount(alias: string, removedAt: number): Promise<boolean>;
43
91
  /**
44
92
  * Carry the organization the proxy observed on a seat into a record that is
45
93
  * about to be written anyway — its token refresh (dario#1244). The observation
@@ -118,6 +166,18 @@ export declare function chooseClientIdentity(existing: readonly AccountCredentia
118
166
  export declare function regenerateClientIdentity(aliases: readonly string[]): Promise<string[]>;
119
167
  /** Refresh an account's OAuth token using dario's auto-detected CC OAuth config. */
120
168
  export declare function refreshAccountToken(creds: AccountCredentials): Promise<AccountCredentials>;
169
+ /**
170
+ * Adopt credentials another instance refreshed for this account, when the
171
+ * lock service holds a newer copy than `local` (dario#1733). Returns the
172
+ * newest credentials newer than `local`, saving them when the account file
173
+ * is older; null when nothing newer is shared, the account is gone, no lock
174
+ * service is set, or it cannot be reached. Never refreshes and never takes the lock. The
175
+ * proxy asks at startup and on every refresh tick, so a replica runs on its
176
+ * peer's rotation within one tick instead of carrying the old pair until its
177
+ * own refresh window. "Newer" is the lock service's rule: a newer grant, or
178
+ * the same grant with a later expiry.
179
+ */
180
+ export declare function adoptSharedCredentials(local: Pick<AccountCredentials, 'alias' | 'expiresAt' | 'grantedAt'>): Promise<AccountCredentials | null>;
121
181
  /**
122
182
  * Fill in `accountId` (and the profile's other fields) on a record that predates
123
183
  * them, from one profile GET; saves and returns the record, unchanged when
@@ -227,8 +287,13 @@ export declare const MIGRATED_LOGIN_ALIAS = "login";
227
287
  *
228
288
  * @param preferredAlias caller may request a specific alias. If it's
229
289
  * already the reserved `login` (or collides), falls back to `default`.
290
+ * @param opts.removedAt when the shared account registry (account-sync.ts)
291
+ * removed the alias. A login granted before it is not back-filled, since
292
+ * that would serve a removed account; a later login is.
230
293
  */
231
- export declare function ensureLoginCredentialsInPool(alias?: string): Promise<string | null>;
294
+ export declare function ensureLoginCredentialsInPool(alias?: string, opts?: {
295
+ removedAt?: number | undefined;
296
+ }): Promise<string | null>;
232
297
  /**
233
298
  * Detect divergence between `accounts/login.json` and the current
234
299
  * `credentials.json` (or whichever store loadCredentials finds), and