@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.
- package/dist/account-sync.d.ts +43 -1
- package/dist/account-sync.js +52 -0
- package/dist/admin-api.d.ts +8 -0
- package/dist/admin-api.js +179 -24
- package/dist/proxy.js +3 -0
- package/docs/admin-api.md +21 -1
- package/docs/multi-instance.md +4 -2
- package/package.json +1 -1
package/dist/account-sync.d.ts
CHANGED
|
@@ -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;
|
package/dist/account-sync.js
CHANGED
|
@@ -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)
|
package/dist/admin-api.d.ts
CHANGED
|
@@ -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 (!
|
|
79
|
-
return { ok: false, status: 429, error:
|
|
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
|
-
|
|
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
|
|
224
|
+
const found = await findPending('claude', pendingLogins, alias, deps);
|
|
225
|
+
const p = found.p;
|
|
104
226
|
if (!p || p.expiresAt <= now) {
|
|
105
|
-
pendingLogins
|
|
106
|
-
return { ok: false, status: 410, error:
|
|
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
|
-
|
|
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 (!
|
|
130
|
-
return { ok: false, status: 429, error:
|
|
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
|
-
|
|
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
|
|
279
|
+
const found = await findPending('codex', pendingCodexLogins, alias, deps);
|
|
280
|
+
const p = found.p;
|
|
150
281
|
if (!p || p.expiresAt <= now) {
|
|
151
|
-
pendingCodexLogins
|
|
152
|
-
return { ok: false, status: 410, error:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/docs/multi-instance.md
CHANGED
|
@@ -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.
|
|
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": {
|