@askalf/dario 6.20.3 → 6.21.0

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.
@@ -19,6 +19,10 @@
19
19
  * made after it, so the instances' clocks must agree (NTP) to well under the
20
20
  * time between a removal and a later login.
21
21
  *
22
+ * Admin API logins that are started but not yet completed ride the same
23
+ * service (`/logins/<claude|codex>/...`, see SharedPendingLogins below), so
24
+ * a login started on one replica completes on another.
25
+ *
22
26
  * Every call fails open: with the service unreachable an instance serves the
23
27
  * accounts on its own disk, logging once per outage. The registry holds
24
28
  * every seat's full credentials, refresh tokens included, with no expiry, so
@@ -41,6 +45,38 @@ export interface AccountSyncStore {
41
45
  localRemovals(): ReadonlyMap<string, number>;
42
46
  }
43
47
  export declare const accountFileStore: AccountSyncStore;
48
+ /** A started, not yet completed admin API login: the PKCE secrets and when they lapse. */
49
+ export interface SharedLogin {
50
+ codeVerifier: string;
51
+ state: string;
52
+ expiresAt: number;
53
+ }
54
+ /** Which engine's pending logins: they are separate namespaces, as the seats are. */
55
+ export type LoginEngine = 'claude' | 'codex';
56
+ /**
57
+ * Pending admin API logins held on the shared service, so any instance can
58
+ * complete one that another started. The verifier and state are secrets and
59
+ * are stored as the registry stores credentials. 'failed' is the service
60
+ * not answering or not knowing the endpoint (an older service); the caller
61
+ * then keeps the login on this instance only.
62
+ */
63
+ export interface SharedPendingLogins {
64
+ /**
65
+ * Publish a login for `alias`, replacing any other. 'full' when the service already holds its cap of other aliases.
66
+ * With `create`, a live login under the alias is left alone and the answer is 'taken'.
67
+ */
68
+ put(engine: LoginEngine, alias: string, login: SharedLogin, ttlMs: number, create?: boolean): Promise<'stored' | 'full' | 'taken' | 'failed'>;
69
+ /** The login for `alias`, null when the service holds none (or it lapsed). Does not consume it. */
70
+ get(engine: LoginEngine, alias: string): Promise<SharedLogin | null | 'failed'>;
71
+ /**
72
+ * Delete the login for `alias` whose state is `state`, and only that one. True when this call removed
73
+ * it live; false when none was there, it lapsed, or another login has replaced it (another instance
74
+ * completed or restarted it first), which the caller must treat as lost.
75
+ */
76
+ remove(engine: LoginEngine, alias: string, state: string): Promise<boolean | 'failed'>;
77
+ /** The aliases with a pending login on any instance. */
78
+ aliases(engine: LoginEngine): Promise<string[] | 'failed'>;
79
+ }
44
80
  /** What one sync changed on this instance, handed to the running proxy. */
45
81
  export interface AccountSyncChange {
46
82
  /** Records the pool lacks or holds older credentials for. */
@@ -128,7 +164,7 @@ export declare function removeAccountEverywhere(baseUrl: string, token: string,
128
164
  store?: AccountSyncStore;
129
165
  removedAt?: number;
130
166
  }): Promise<SharedRemovalResult>;
131
- export declare class AccountSync {
167
+ export declare class AccountSync implements SharedPendingLogins {
132
168
  readonly intervalMs: number;
133
169
  private readonly baseUrl;
134
170
  private readonly token;
@@ -162,6 +198,12 @@ export declare class AccountSync {
162
198
  * login the sync just removed.
163
199
  */
164
200
  removalOf(alias: string): number | undefined;
201
+ private loginPath;
202
+ private loginFailed;
203
+ put(engine: LoginEngine, alias: string, login: SharedLogin, ttlMs: number, create?: boolean): Promise<'stored' | 'full' | 'taken' | 'failed'>;
204
+ get(engine: LoginEngine, alias: string): Promise<SharedLogin | null | 'failed'>;
205
+ remove(engine: LoginEngine, alias: string, state: string): Promise<boolean | 'failed'>;
206
+ aliases(engine: LoginEngine): Promise<string[] | 'failed'>;
165
207
  /** One sync. Null when the registry could not be read; never throws. */
166
208
  syncOnce(): Promise<AccountSyncChange | null>;
167
209
  private run;
@@ -19,6 +19,10 @@
19
19
  * made after it, so the instances' clocks must agree (NTP) to well under the
20
20
  * time between a removal and a later login.
21
21
  *
22
+ * Admin API logins that are started but not yet completed ride the same
23
+ * service (`/logins/<claude|codex>/...`, see SharedPendingLogins below), so
24
+ * a login started on one replica completes on another.
25
+ *
22
26
  * Every call fails open: with the service unreachable an instance serves the
23
27
  * accounts on its own disk, logging once per outage. The registry holds
24
28
  * every seat's full credentials, refresh tokens included, with no expiry, so
@@ -34,6 +38,18 @@ export const accountFileStore = {
34
38
  remove: removeSharedAccount,
35
39
  localRemovals: localAccountRemovals,
36
40
  };
41
+ function parseSharedLogin(raw) {
42
+ const r = raw;
43
+ if (!r || typeof r !== 'object')
44
+ return null;
45
+ if (typeof r.codeVerifier !== 'string' || r.codeVerifier.length === 0)
46
+ return null;
47
+ if (typeof r.state !== 'string' || r.state.length === 0)
48
+ return null;
49
+ if (typeof r.expiresAt !== 'number' || !Number.isFinite(r.expiresAt))
50
+ return null;
51
+ return { codeVerifier: r.codeVerifier, state: r.state, expiresAt: r.expiresAt };
52
+ }
37
53
  export const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15_000;
38
54
  const CALL_TIMEOUT_MS = 3_000;
39
55
  /** The latest removal of `alias` either side knows of, or undefined. */
@@ -203,6 +219,42 @@ export class AccountSync {
203
219
  removalOf(alias) {
204
220
  return removalTime(alias, this.markers, this.store.localRemovals());
205
221
  }
222
+ loginPath(engine, alias, action) {
223
+ return `/logins/${engine}/${encodeURIComponent(alias)}/${action}`;
224
+ }
225
+ // Never logs the login: the line carries the alias and the transport error only.
226
+ loginFailed(what, alias) {
227
+ this.log(`[dario] shared accounts: could not ${what} the pending login for "${alias}" on the service (${this.stats.lastError ?? 'no answer'}); using this instance's copy`);
228
+ return 'failed';
229
+ }
230
+ async put(engine, alias, login, ttlMs, create = false) {
231
+ const out = await this.call(this.loginPath(engine, alias, 'put'), create ? { login, ttlMs, create: true } : { login, ttlMs });
232
+ if (!out)
233
+ return this.loginFailed('publish', alias);
234
+ if (out.stored === true)
235
+ return 'stored';
236
+ if (out.full === true)
237
+ return 'full';
238
+ return out.taken === true ? 'taken' : this.loginFailed('publish', alias);
239
+ }
240
+ async get(engine, alias) {
241
+ const out = await this.call(this.loginPath(engine, alias, 'get'), {});
242
+ if (!out)
243
+ return this.loginFailed('read', alias);
244
+ return parseSharedLogin(out.login);
245
+ }
246
+ async remove(engine, alias, state) {
247
+ const out = await this.call(this.loginPath(engine, alias, 'remove'), { state });
248
+ if (!out)
249
+ return this.loginFailed('remove', alias);
250
+ return out.removed === true;
251
+ }
252
+ async aliases(engine) {
253
+ const out = await this.call(`/logins/${engine}/list`, {});
254
+ if (!out)
255
+ return 'failed';
256
+ return Array.isArray(out.aliases) ? out.aliases.filter((a) => typeof a === 'string') : [];
257
+ }
206
258
  /** One sync. Null when the registry could not be read; never throws. */
207
259
  syncOnce() {
208
260
  if (this.queued)
@@ -77,6 +77,7 @@
77
77
  import type { IncomingMessage, ServerResponse } from 'node:http';
78
78
  import type { CodexSeatState } from './codex-accounts.js';
79
79
  import { type CodexUsageView } from './codex-usage.js';
80
+ import type { SharedPendingLogins } from './account-sync.js';
80
81
  import { type KeyStore } from './keys.js';
81
82
  /** Persisted account metadata surfaced by `GET /admin/accounts`. */
82
83
  export interface AdminAccountRecord {
@@ -226,6 +227,13 @@ export interface AdminDeps {
226
227
  * off on this proxy; the routes answer 404.
227
228
  */
228
229
  keys?: KeyStore | null;
230
+ /**
231
+ * The shared account registry's pending-login store (account-sync.ts), set
232
+ * when `--pool-shared-accounts` is on. A login started here is also
233
+ * published there, so any instance can complete it. Absent: pending logins
234
+ * live in this process only.
235
+ */
236
+ sharedLogins?: SharedPendingLogins | null;
229
237
  }
230
238
  /**
231
239
  * Handle an `/admin/*` request. Returns `true` if it owned the request (matched
package/dist/admin-api.js CHANGED
@@ -49,6 +49,104 @@ const pendingLogins = new Map();
49
49
  // The ChatGPT seats' pending logins live apart: an alias may name a Claude
50
50
  // seat and a ChatGPT seat at once (the stores are separate directories).
51
51
  const pendingCodexLogins = new Map();
52
+ const PENDING_FULL = 'too many pending logins; complete or wait for one to expire';
53
+ const NO_PENDING = 'no pending login for that alias (unknown or expired): start a new login';
54
+ const NO_PENDING_CODEX = 'no pending codex login for that alias (unknown or expired): start a new login';
55
+ const DEFAULT_ALIAS_TRIES = 8;
56
+ /**
57
+ * Publish a new pending login on the shared service when one is configured.
58
+ * 'failed' (the service did not answer or does not know the endpoint) leaves
59
+ * the login in this process only, which is what a single instance has always
60
+ * done. The service holds the MAX_PENDING cap for the whole fleet; 'full'
61
+ * means it is reached. `create` (a generated alias) refuses to replace a
62
+ * live login another instance published under it: 'taken'.
63
+ */
64
+ async function publishPending(engine, alias, login, deps, create) {
65
+ if (!deps.sharedLogins)
66
+ return 'local';
67
+ return deps.sharedLogins.put(engine, alias, { codeVerifier: login.codeVerifier, state: login.state, expiresAt: login.expiresAt }, PENDING_TTL_MS, create);
68
+ }
69
+ /** Aliases with a login pending on any instance, so a generated default does not take another instance's. */
70
+ async function sharedAliases(engine, deps) {
71
+ if (!deps.sharedLogins)
72
+ return [];
73
+ const out = await deps.sharedLogins.aliases(engine);
74
+ return out === 'failed' ? [] : out;
75
+ }
76
+ /**
77
+ * The pending login for `alias`. With the shared service on, the service is
78
+ * the source of truth when it answers: a login another instance started is
79
+ * found there, and one another instance already completed is gone from it
80
+ * even though this process still holds a copy, which is dropped. This
81
+ * process's own copy answers when the service does not, and when the login
82
+ * was never published.
83
+ */
84
+ async function findPending(engine, map, alias, deps) {
85
+ const own = map.get(alias);
86
+ if (!deps.sharedLogins)
87
+ return { p: own, viaService: false };
88
+ const got = await deps.sharedLogins.get(engine, alias);
89
+ if (got === 'failed')
90
+ return { p: own, viaService: false };
91
+ if (got)
92
+ return { p: got, viaService: true };
93
+ if (own?.shared) {
94
+ if (map.get(alias) === own)
95
+ map.delete(alias);
96
+ return { p: undefined, viaService: false };
97
+ }
98
+ return { p: own, viaService: false };
99
+ }
100
+ /**
101
+ * Take the login found for `alias`: single-use, so false means a concurrent
102
+ * completion or a restart got there first and the caller must not exchange.
103
+ * The lookup awaits, so taking this process's copy is a synchronous check
104
+ * that the map still holds the very login the lookup returned (deleting by
105
+ * alias alone would delete a login started since). A published login is then
106
+ * deleted on the service by its state, which answers true to one of two
107
+ * instances. A definite false from the service is always a lost claim. When
108
+ * the service does not answer, the claim stands only for the caller that took
109
+ * an unconsumed copy held here; a login found on the service alone cannot be
110
+ * claimed during an outage.
111
+ */
112
+ async function claimPending(engine, map, alias, found, deps) {
113
+ const p = found.p;
114
+ const own = map.get(alias);
115
+ // This process still holds this very login; taking it here is synchronous, so only one caller gets it.
116
+ const mine = found.viaService ? own?.state === p.state : own === p;
117
+ if (!found.viaService && !mine)
118
+ return false;
119
+ if (mine)
120
+ map.delete(alias);
121
+ if (!deps.sharedLogins || !(found.viaService || own?.shared))
122
+ return true;
123
+ const removed = await deps.sharedLogins.remove(engine, alias, p.state);
124
+ if (removed === 'failed')
125
+ return mine;
126
+ return removed;
127
+ }
128
+ /** Room for `alias` in the map. A shared copy is only this instance's fallback, since the service holds the login and its cap, so the oldest one gives way; logins kept here alone do not. */
129
+ function makeRoom(map, alias) {
130
+ if (map.has(alias) || map.size < MAX_PENDING)
131
+ return true;
132
+ for (const [k, v] of map) {
133
+ if (v.shared) {
134
+ map.delete(k);
135
+ return true;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+ /** Forget a login that was found but lapsed, here and on the service, by identity. */
141
+ async function dropPending(engine, map, alias, found, deps) {
142
+ const p = found.p;
143
+ if (!p)
144
+ return;
145
+ if (map.get(alias) === p || (found.viaService && map.get(alias)?.state === p.state))
146
+ map.delete(alias);
147
+ if (deps.sharedLogins && (found.viaService || p.shared))
148
+ await deps.sharedLogins.remove(engine, alias, p.state);
149
+ }
52
150
  function prunePending(now) {
53
151
  for (const map of [pendingLogins, pendingCodexLogins]) {
54
152
  for (const [id, p] of map) {
@@ -65,6 +163,25 @@ function nextDefaultAlias(taken, prefix = 'account') {
65
163
  return candidate; // taken is finite → always terminates
66
164
  }
67
165
  }
166
+ function takenResult(alias) {
167
+ return { ok: false, status: 409, error: `alias "${alias}" has a login pending on another instance; try again or pass an "alias"`, taken: true };
168
+ }
169
+ /**
170
+ * Start a login under a generated alias. The shared service creates it only
171
+ * if no live login holds it, so two instances picking the same default at
172
+ * once cannot replace each other's; the loser takes the next candidate.
173
+ */
174
+ async function startDefaultLogin(start, taken, prefix) {
175
+ let last = takenResult(nextDefaultAlias(taken, prefix));
176
+ for (let i = 0; i < DEFAULT_ALIAS_TRIES; i++) {
177
+ const alias = nextDefaultAlias(taken, prefix);
178
+ last = await start(alias);
179
+ if (last.ok || !last.taken)
180
+ return last;
181
+ taken.add(alias);
182
+ }
183
+ return last;
184
+ }
68
185
  /**
69
186
  * Core of `/admin/login/start`, factored out so `/admin/login/start-needed`
70
187
  * (#913) can drive the same PKCE-start + pending-login-registration + audit
@@ -73,15 +190,19 @@ function nextDefaultAlias(taken, prefix = 'account') {
73
190
  * caller looping over many aliases can skip one bad alias without aborting
74
191
  * the rest.
75
192
  */
76
- async function doStartLogin(alias, now, deps, remote) {
193
+ async function doStartLogin(alias, now, deps, remote, create = false) {
77
194
  // Only a brand-new alias grows the map; a repeat start replaces in place.
78
- if (!pendingLogins.has(alias) && pendingLogins.size >= MAX_PENDING) {
79
- return { ok: false, status: 429, error: 'too many pending logins; complete or wait for one to expire' };
80
- }
195
+ if (!makeRoom(pendingLogins, alias))
196
+ return { ok: false, status: 429, error: PENDING_FULL };
81
197
  try {
82
198
  const { authorizeUrl, codeVerifier, state } = await startAddAccount(alias);
83
199
  const expiresAt = now + PENDING_TTL_MS;
84
- pendingLogins.set(alias, { codeVerifier, state, expiresAt });
200
+ const published = await publishPending('claude', alias, { codeVerifier, state, expiresAt }, deps, create);
201
+ if (published === 'full')
202
+ return { ok: false, status: 429, error: PENDING_FULL };
203
+ if (published === 'taken')
204
+ return takenResult(alias);
205
+ pendingLogins.set(alias, { codeVerifier, state, expiresAt, shared: published === 'stored' });
85
206
  deps.audit?.({ action: 'login_start', ok: true, status: 200, alias, remote });
86
207
  return { ok: true, alias, authorizeUrl, expiresAt };
87
208
  }
@@ -100,10 +221,11 @@ async function doStartLogin(alias, now, deps, remote) {
100
221
  async function doCompleteLogin(alias, rawCode, now, deps, remote) {
101
222
  if (!alias || !rawCode)
102
223
  return { ok: false, status: 400, error: 'missing "alias" or "code"' };
103
- const p = pendingLogins.get(alias);
224
+ const found = await findPending('claude', pendingLogins, alias, deps);
225
+ const p = found.p;
104
226
  if (!p || p.expiresAt <= now) {
105
- pendingLogins.delete(alias);
106
- return { ok: false, status: 410, error: 'no pending login for that alias (unknown or expired) — start a new login' };
227
+ await dropPending('claude', pendingLogins, alias, found, deps);
228
+ return { ok: false, status: 410, error: NO_PENDING };
107
229
  }
108
230
  // Accept "code#state" or a bare code; verify the embedded state if present.
109
231
  const { code, state: pastedState } = parseManualPaste(rawCode);
@@ -112,7 +234,11 @@ async function doCompleteLogin(alias, rawCode, now, deps, remote) {
112
234
  if (pastedState && pastedState !== p.state) {
113
235
  return { ok: false, status: 400, error: 'state mismatch — code is from a different login attempt' };
114
236
  }
115
- pendingLogins.delete(alias); // single-use, regardless of exchange outcome
237
+ // Single-use, regardless of exchange outcome.
238
+ if (!(await claimPending('claude', pendingLogins, alias, found, deps))) {
239
+ // Completed or replaced since the lookup. A replacement is left for its own paste.
240
+ return { ok: false, status: 410, error: NO_PENDING };
241
+ }
116
242
  try {
117
243
  const creds = await completeAddAccount(alias, code, p.codeVerifier, p.state);
118
244
  await deps.onAccountsChanged?.();
@@ -125,17 +251,21 @@ async function doCompleteLogin(alias, rawCode, now, deps, remote) {
125
251
  return { ok: false, status: 400, error: message };
126
252
  }
127
253
  }
128
- async function doStartCodexLogin(alias, now, deps, remote) {
129
- if (!pendingCodexLogins.has(alias) && pendingCodexLogins.size >= MAX_PENDING) {
130
- return { ok: false, status: 429, error: 'too many pending logins; complete or wait for one to expire' };
131
- }
254
+ async function doStartCodexLogin(alias, now, deps, remote, create = false) {
255
+ if (!makeRoom(pendingCodexLogins, alias))
256
+ return { ok: false, status: 429, error: PENDING_FULL };
132
257
  if ((await listCodexAccountAliases()).includes(alias)) {
133
258
  return { ok: false, status: 409, error: `codex account "${alias}" already exists — DELETE /admin/codex/accounts/${alias} first` };
134
259
  }
135
260
  try {
136
261
  const { authorizeUrl, codeVerifier, state } = await startAddCodexAccount(alias);
137
262
  const expiresAt = now + PENDING_TTL_MS;
138
- pendingCodexLogins.set(alias, { codeVerifier, state, expiresAt });
263
+ const published = await publishPending('codex', alias, { codeVerifier, state, expiresAt }, deps, create);
264
+ if (published === 'full')
265
+ return { ok: false, status: 429, error: PENDING_FULL };
266
+ if (published === 'taken')
267
+ return takenResult(alias);
268
+ pendingCodexLogins.set(alias, { codeVerifier, state, expiresAt, shared: published === 'stored' });
139
269
  deps.audit?.({ action: 'login_start', ok: true, status: 200, alias, remote, engine: 'codex' });
140
270
  return { ok: true, alias, authorizeUrl, expiresAt };
141
271
  }
@@ -146,10 +276,11 @@ async function doStartCodexLogin(alias, now, deps, remote) {
146
276
  async function doCompleteCodexLogin(alias, rawCode, now, deps, remote) {
147
277
  if (!alias || !rawCode)
148
278
  return { ok: false, status: 400, error: 'missing "alias" or "code"' };
149
- const p = pendingCodexLogins.get(alias);
279
+ const found = await findPending('codex', pendingCodexLogins, alias, deps);
280
+ const p = found.p;
150
281
  if (!p || p.expiresAt <= now) {
151
- pendingCodexLogins.delete(alias);
152
- return { ok: false, status: 410, error: 'no pending codex login for that alias (unknown or expired) — start a new login' };
282
+ await dropPending('codex', pendingCodexLogins, alias, found, deps);
283
+ return { ok: false, status: 410, error: NO_PENDING_CODEX };
153
284
  }
154
285
  // The whole redirect URL (what the CLI asks the user to paste) or a bare code; the
155
286
  // state in a URL is checked against the login it was printed for, as the CLI does.
@@ -159,7 +290,17 @@ async function doCompleteCodexLogin(alias, rawCode, now, deps, remote) {
159
290
  if (pastedState !== null && pastedState !== p.state) {
160
291
  return { ok: false, status: 400, error: 'state mismatch — the redirect is from a different login attempt' };
161
292
  }
162
- pendingCodexLogins.delete(alias); // single-use, regardless of exchange outcome
293
+ // ChatGPT seats are per instance, and the login may have been started on one that lacks this
294
+ // alias. Checked here, before the claim, so nothing is consumed and the login still completes
295
+ // on an instance without the seat.
296
+ if ((await listCodexAccountAliases()).includes(alias)) {
297
+ return { ok: false, status: 409, error: `codex account "${alias}" already exists on this instance: DELETE /admin/codex/accounts/${alias} first` };
298
+ }
299
+ // Single-use, regardless of exchange outcome.
300
+ if (!(await claimPending('codex', pendingCodexLogins, alias, found, deps))) {
301
+ // Completed or replaced since the lookup. A replacement is left for its own paste.
302
+ return { ok: false, status: 410, error: NO_PENDING_CODEX };
303
+ }
163
304
  try {
164
305
  const creds = await completeAddCodexAccount(alias, code, p.codeVerifier);
165
306
  await deps.onCodexAccountsChanged?.();
@@ -335,15 +476,22 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
335
476
  }
336
477
  const body = await readJsonBody(req);
337
478
  let alias = typeof body.alias === 'string' ? body.alias.trim() : '';
479
+ let result;
338
480
  // Alias is optional: a headless caller can omit it and get a generated,
339
481
  // non-colliding default (account-1, account-2, …) back in the response to
340
482
  // thread into /complete. Explicit aliases still win for named setups.
341
483
  if (!alias) {
342
484
  const records = await (deps.listAccounts ?? defaultListAccounts)();
343
- const taken = new Set([...records.map((r) => r.alias), ...pendingLogins.keys()]);
344
- alias = nextDefaultAlias(taken);
485
+ const taken = new Set([...records.map((r) => r.alias), ...pendingLogins.keys(), ...(await sharedAliases('claude', deps))]);
486
+ const made = await startDefaultLogin((a) => doStartLogin(a, now, deps, remote, true), taken);
487
+ if (!made.ok) {
488
+ send(res, made.status, { error: made.error });
489
+ return true;
490
+ }
491
+ alias = made.alias;
492
+ result = made;
345
493
  }
346
- const result = await doStartLogin(alias, now, deps, remote);
494
+ result ??= await doStartLogin(alias, now, deps, remote);
347
495
  if (!result.ok) {
348
496
  send(res, result.status, { error: result.error });
349
497
  return true;
@@ -364,11 +512,18 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
364
512
  }
365
513
  const body = await readJsonBody(req);
366
514
  let alias = typeof body.alias === 'string' ? body.alias.trim() : '';
515
+ let result;
367
516
  if (!alias) {
368
- const taken = new Set([...(await listCodexAccountAliases()), ...pendingCodexLogins.keys()]);
369
- alias = nextDefaultAlias(taken, 'altman');
517
+ const taken = new Set([...(await listCodexAccountAliases()), ...pendingCodexLogins.keys(), ...(await sharedAliases('codex', deps))]);
518
+ const made = await startDefaultLogin((a) => doStartCodexLogin(a, now, deps, remote, true), taken, 'altman');
519
+ if (!made.ok) {
520
+ send(res, made.status, { error: made.error });
521
+ return true;
522
+ }
523
+ alias = made.alias;
524
+ result = made;
370
525
  }
371
- const result = await doStartCodexLogin(alias, now, deps, remote);
526
+ result ??= await doStartCodexLogin(alias, now, deps, remote);
372
527
  if (!result.ok) {
373
528
  send(res, result.status, { error: result.error });
374
529
  return true;
package/dist/proxy.js CHANGED
@@ -2995,6 +2995,9 @@ export async function startProxy(opts = {}) {
2995
2995
  // through the store the live proxy authenticates from, so a key made
2996
2996
  // here works on the next request.
2997
2997
  keys: keyStore,
2998
+ // Logins started and not yet completed ride the shared registry's
2999
+ // service, so a login started on one replica completes on another.
3000
+ sharedLogins: accountSync,
2998
3001
  onCodexAccountsChanged: async () => {
2999
3002
  // A ChatGPT seat came or went over HTTP (dario#1009): forget the
3000
3003
  // "no codex account" answer so the next request routes to it.
package/docs/admin-api.md CHANGED
@@ -78,7 +78,8 @@ Pending-login mechanics, for scripting against it:
78
78
  different login attempt.
79
79
  - An expired or unknown alias gets `410` — start a new login.
80
80
  - The PKCE verifier and state never touch disk and are never returned to the
81
- client.
81
+ client. With `--pool-shared-accounts` on they are also held by the shared
82
+ service until the login completes, is replaced or lapses; see below.
82
83
 
83
84
  ## Endpoint reference
84
85
 
@@ -131,6 +132,25 @@ one instance this way provisions them all, and a new replica starts with the
131
132
  accounts already in the registry. `GET /accounts` then carries a
132
133
  `sharedAccounts` block with the sync's state.
133
134
 
135
+ **Behind a load balancer, `/start` and `/complete` can land on different
136
+ instances.** With the same switch on, a pending login is published to the shared
137
+ service too, for both Claude and ChatGPT logins, and `/complete` reads it from
138
+ there. The mechanics above hold fleet-wide: 10 minutes, single-use (of two
139
+ instances completing at once, one gets `200` and the other `410`; a
140
+ `/start` that replaces the login in between leaves the new one for its own
141
+ paste), one per alias, at most 64 per engine. A generated default alias skips
142
+ one pending on another instance, and the service creates it only if no live
143
+ login holds it, so two instances starting at once get different aliases (if
144
+ all 8 candidates it tries are taken it answers `409`). If the service does not answer when a login starts, it is
145
+ kept on that instance only and logged (never the verifier or state); a
146
+ `/complete` that lands elsewhere gets the usual `410`, so retry it through
147
+ the instance that started it or start again. A ChatGPT seat is not part of the
148
+ shared registry, so the instance that completes the login is the one that holds
149
+ the seat. That instance refuses with `409` if it already holds a seat under
150
+ the alias, without consuming the login, so complete it on an instance that
151
+ does not (or `DELETE /admin/codex/accounts/<alias>` there first). With the switch off, pending logins stay in the process that started
152
+ them, so send `/start` and `/complete` to the same instance.
153
+
134
154
  ## Pinning a request to one seat
135
155
 
136
156
  `x-dario-account: <alias>` on `POST /v1/messages` (or `/v1/chat/completions`) routes that one request to the named pool account with **no failover**: no headroom selection, no sticky rebinding, no peer retry on 401/429, no Codex leg. The upstream status comes back as-is. It is the primitive behind `dario accounts check <alias>` — the answer to "does this seat serve Sonnet right now?" without copying a credential anywhere or restarting anything.
@@ -131,6 +131,7 @@ Set it on every instance. It works with or without `--pool-shared-state`.
131
131
 
132
132
  - **At startup**, before the pool loads, an instance reads the registry and merges it with its accounts directory. Per alias the newer record wins: a newer login first, then, for the same login, the later expiry. Accounts only this instance holds are published; accounts only the registry holds are written to this instance's accounts directory. The pool then loads from that directory as usual, so `/health` is ready as soon as a peer has an account.
133
133
  - **Every 15 seconds** after that it merges again, and updates the running pool too: a new account joins it, newer credentials replace older ones, and a removed account leaves it. A `dario accounts add`, an admin API login or a token refresh on one instance reaches the others within one sync. An admin API login or removal is published straight away, and the other instances apply it on their next sync.
134
+ - **Admin API logins complete on any instance.** A login started with `POST /admin/login/start` (or `/admin/codex/login/start`) is also published to the service for its 10 minutes, so `/complete` can land on a different replica behind the load balancer. It is single-use across the fleet, and is removed from the service when it completes or is replaced. A lapsed login is never served; the Worker deletes it at its lapse by an alarm, and Redis deletes it on the next start after the lapse or when the hash expires at the newest lapse of any login in it, at most an hour past its own. If the service does not answer at `/start`, that login stays on the instance that started it, with a log line that names the alias only. During an outage a login found only on the service cannot complete; this instance completes one only if it still holds its own copy, and only one overlapping call gets it. An instance keeps at most 64 pending logins: copies of logins the service holds give way to new starts, logins kept here alone do not. Without the switch, send both calls to the same instance.
134
135
  - **A removal leaves a marker** in the registry. The marker beats every login made before the removal, so an instance that was offline and still holds the old file deletes it instead of publishing it back. That holds for the `login` account taken from `credentials.json` at startup too: an instance whose login was removed starts without it instead of serving it. A login made after the removal beats the marker, and the account comes back everywhere.
135
136
  - **`dario accounts remove <alias>`** publishes the removal first and deletes the file only once the service has taken it. If the service cannot be reached it removes nothing and says so, since the other instances would put the account straight back. A login made after the removal stays, in the registry or in the file here, and the command says so; run it again to remove that login too. If the file here can't be deleted, the command says so and exits non-zero, and this instance keeps serving the account until the file goes.
136
137
 
@@ -138,12 +139,13 @@ The proxy says it is on at startup (`Pool shared accounts: on (via …, syncing
138
139
 
139
140
  Before turning it on:
140
141
 
142
+ - **The service also holds the PKCE verifier and state of a login in progress,** for its 10 minutes, and in Redis possibly up to an hour past that (see above). They are secrets, stored as the account records are, and a caller holding `DARIO_REFRESH_LOCK_TOKEN` can complete that login.
141
143
  - **The registry holds every seat's credentials, refresh tokens included, and they do not expire there.** That is what lets a new replica start with no files. Guard the lock service like the accounts directory itself: a real random `DARIO_REFRESH_LOCK_TOKEN`, the service on a private network, and a Redis that nothing else uses. Anyone who can read that Redis or the Durable Object can use every subscription in the pool.
142
144
  - **Aliases are one namespace across all instances.** Two instances that each log a different subscription in under the same alias (`login`, most often) keep only the newer login, and the instance whose account was replaced logs it loudly: `account "login" here was ... and is now ...; aliases are shared across instances, so give each subscription its own alias`. Give each subscription its own alias.
143
145
  - **Clocks have to agree.** The newer login and removal rules compare wall-clock times taken on different machines. Run NTP; Kubernetes nodes do by default.
144
- - **Claude seats only.** ChatGPT accounts stay per instance, and the switch is off under `--no-claude-auth`.
146
+ - **Claude seats only.** ChatGPT accounts stay per instance, and the switch is off under `--no-claude-auth`. A ChatGPT login started over the admin API can still complete on another instance, but the seat is then held by the instance that completed it. An instance that already holds a seat under that alias answers `409` and leaves the login unconsumed, so it can complete on another instance.
145
147
  - **It fails open.** With the service unreachable, each instance serves the accounts on its own disk and logs once per outage. A removal made while it is down is published when it answers again; if the instance restarts before then, that removal is lost and the account comes back from the registry, so remove it again.
146
- - **Upgrade the lock service.** The `/accounts/*` endpoints are new: rebuild the Redis image or redeploy the Worker. An older service answers 404, which an instance logs as the service being unreachable and then serves its own disk, so nothing breaks, but nothing is shared either.
148
+ - **Upgrade the lock service.** The `/accounts/*` endpoints are new: rebuild the Redis image or redeploy the Worker. An older service answers 404, which an instance logs as the service being unreachable and then serves its own disk, so nothing breaks, but nothing is shared either. The `/logins/*` endpoints for pending admin API logins arrived after `/accounts/*`; on a service without them an instance keeps its pending logins to itself and logs it.
147
149
 
148
150
  ## Proving it works on your own setup
149
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.20.3",
3
+ "version": "6.21.0",
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, opt-in failover when one hits its limit, and a ledger of what each plan absorbed.",
5
5
  "type": "module",
6
6
  "bin": {