maxpool 1.5.24 → 1.5.26
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/package.json +1 -1
- package/src/account-manager.js +24 -3
- package/src/index.js +42 -25
- package/src/oauth.js +13 -0
- package/src/tui.js +40 -13
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { refreshAccessToken, isTokenExpiringSoon, modelFamily } from './oauth.js';
|
|
1
|
+
import { refreshAccessToken, isTokenExpiringSoon, modelFamily, tokenFingerprint } from './oauth.js';
|
|
2
2
|
|
|
3
3
|
// Bounded re-poll hold for an account blocked ONLY by a transient, self-clearing
|
|
4
4
|
// condition whose exact recovery time is unknown: (a) a weekly-critical account
|
|
@@ -2298,8 +2298,24 @@ export class AccountManager {
|
|
|
2298
2298
|
account.expiresAt = newTokens.expiresAt;
|
|
2299
2299
|
account.status = 'active';
|
|
2300
2300
|
account.cooldownUntil = null;
|
|
2301
|
-
console.log(`[Maxpool] Token refreshed for account "${account.name}"`);
|
|
2302
|
-
|
|
2301
|
+
console.log(`[Maxpool] Token refreshed for account "${account.name}" (rotated ${tokenFingerprint(account._refreshedFrom)} → ${tokenFingerprint(newTokens.refreshToken)})`);
|
|
2302
|
+
// Persist-before-serve: the rotated single-use refresh token must be
|
|
2303
|
+
// DURABLE on disk before we return true (before this request serves on the
|
|
2304
|
+
// new access token). A non-graceful kill (SIGKILL/crash/OOM/terminal-close)
|
|
2305
|
+
// between here and the disk write would otherwise leave the now-CONSUMED
|
|
2306
|
+
// token on disk → next boot POSTs it → invalid_grant → forced re-auth.
|
|
2307
|
+
// This minimizes the loss window to the write duration (it cannot be zero —
|
|
2308
|
+
// the upstream consumes the old token the instant the POST returns); the
|
|
2309
|
+
// fingerprint audit trail makes the irreducible residual diagnosable.
|
|
2310
|
+
// persistTokenRefresh is bulletproofed to never throw, but the refresh has
|
|
2311
|
+
// ALREADY succeeded — a persist anomaly must never be re-classified as a
|
|
2312
|
+
// refresh failure (which would latch refreshDead on a working account), so
|
|
2313
|
+
// guard the await too.
|
|
2314
|
+
try {
|
|
2315
|
+
await this._onTokenRefresh?.(accountIndex, newTokens);
|
|
2316
|
+
} catch (persistErr) {
|
|
2317
|
+
console.error(`[Maxpool] Token persist raised unexpectedly for "${account.name}": ${persistErr?.message || persistErr}`);
|
|
2318
|
+
}
|
|
2303
2319
|
return true;
|
|
2304
2320
|
} catch (err) {
|
|
2305
2321
|
console.error(`[Maxpool] Token refresh failed for "${account.name}": ${err.message}`);
|
|
@@ -2319,6 +2335,11 @@ export class AccountManager {
|
|
|
2319
2335
|
// dead token. Set ONLY here (a rejected refresh), never in markAuthFailed
|
|
2320
2336
|
// (shared with provider auth failures).
|
|
2321
2337
|
account.refreshDead = true;
|
|
2338
|
+
// Monitoring: name the exact token that was rejected + how to diagnose it
|
|
2339
|
+
// from the persistent event log next time this recurs. (fp= is safe from
|
|
2340
|
+
// the log's secret-redactor; refresh_token= would be redacted.)
|
|
2341
|
+
const rejFp = tokenFingerprint(account._refreshedFrom);
|
|
2342
|
+
console.error(`[Maxpool] Token refresh REJECTED for "${account.name}" (invalid_grant) — the refresh token maxpool sent (fp=${rejFp}) was not accepted. Diagnose from the event log: an earlier "rotated → ${rejFp}" that WAS "Persisted" ⇒ upstream revocation; NO persisted line for ${rejFp} ⇒ the rotation was lost across a restart (double-spend); the SAME source fp in two "rotated" lines in one window ⇒ two writers double-spent it. Re-login via the TUI ('l' key).`);
|
|
2322
2343
|
}
|
|
2323
2344
|
return false;
|
|
2324
2345
|
}
|
package/src/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { SleepGuard } from './sleep-guard.js';
|
|
|
8
8
|
import { AccountManager } from './account-manager.js';
|
|
9
9
|
import { createProxyServer } from './server.js';
|
|
10
10
|
import { Prober } from './prober.js';
|
|
11
|
-
import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
11
|
+
import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, tokenFingerprint } from './oauth.js';
|
|
12
12
|
import { TUI } from './tui.js';
|
|
13
13
|
import { RestartController } from './restart-controller.js';
|
|
14
14
|
import { resolveAccounts } from './account-config.js';
|
|
@@ -595,46 +595,63 @@ async function serverWorkerCommand() {
|
|
|
595
595
|
// baton hands off and the new worker boots from the now-invalidated on-disk
|
|
596
596
|
// token (B1/M3). New refreshes can't start without the lease (ensureTokenFresh
|
|
597
597
|
// no-ops), so every callback here is from a legitimate lease-era refresh.
|
|
598
|
-
|
|
598
|
+
//
|
|
599
|
+
// BULLETPROOF CONTRACT: ensureTokenFresh now AWAITs this (persist-before-serve),
|
|
600
|
+
// so it must NEVER throw or reject. A throw here — including from the synchronous
|
|
601
|
+
// prologue (findConfigAccount / addAccount) — would land in ensureTokenFresh's
|
|
602
|
+
// refresh-FAILURE catch and latch `refreshDead` on an account whose refresh POST
|
|
603
|
+
// actually SUCCEEDED (bricking a working account — strictly worse than the window
|
|
604
|
+
// this fix closes). So the ENTIRE body is wrapped, prologue included, and every
|
|
605
|
+
// exit resolves. Returns the awaitable persist promise.
|
|
606
|
+
const persistTokenRefresh = async (idx, newTokens) => {
|
|
599
607
|
const account = accountManager.accounts[idx];
|
|
600
608
|
if (!account) return;
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
atomicConfigUpdate(diskConfig => {
|
|
609
|
-
// Pick up any new accounts from disk so index matching stays correct
|
|
610
|
-
// (only add, don't refresh credentials — we're about to write the authoritative tokens)
|
|
611
|
-
for (const diskAcct of diskConfig.accounts) {
|
|
612
|
-
const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
|
|
613
|
-
|| config.accounts.some(a => a.name === diskAcct.name);
|
|
614
|
-
if (!known) {
|
|
615
|
-
config.accounts.push(diskAcct);
|
|
616
|
-
accountManager.addAccount(diskAcct);
|
|
617
|
-
}
|
|
609
|
+
try {
|
|
610
|
+
// Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
|
|
611
|
+
const memIdx = findConfigAccount(config, account);
|
|
612
|
+
if (memIdx >= 0) {
|
|
613
|
+
config.accounts[memIdx].accessToken = newTokens.accessToken;
|
|
614
|
+
config.accounts[memIdx].refreshToken = newTokens.refreshToken;
|
|
615
|
+
config.accounts[memIdx].expiresAt = newTokens.expiresAt;
|
|
618
616
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
617
|
+
let skipped = false; // guard-skip or account-not-on-disk → nothing was persisted
|
|
618
|
+
await atomicConfigUpdate(diskConfig => {
|
|
619
|
+
// Pick up any new accounts from disk so index matching stays correct
|
|
620
|
+
// (only add, don't refresh credentials — we're about to write the authoritative tokens)
|
|
621
|
+
for (const diskAcct of diskConfig.accounts) {
|
|
622
|
+
const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
|
|
623
|
+
|| config.accounts.some(a => a.name === diskAcct.name);
|
|
624
|
+
if (!known) {
|
|
625
|
+
config.accounts.push(diskAcct);
|
|
626
|
+
accountManager.addAccount(diskAcct);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// Match by UUID first, then by name — index may have shifted
|
|
630
|
+
const cfgIdx = findConfigAccount(diskConfig, account);
|
|
631
|
+
if (cfgIdx < 0) { skipped = true; return; }
|
|
622
632
|
const onDisk = diskConfig.accounts[cfgIdx];
|
|
623
633
|
// Generation guard: if the on-disk refresh token already advanced past
|
|
624
634
|
// the token we rotated FROM, another writer beat us — skip the write so
|
|
625
635
|
// we don't revert a fresher single-use token (the brick-the-account case).
|
|
626
636
|
if (onDisk.refreshToken && onDisk.refreshToken !== account._refreshedFrom &&
|
|
627
637
|
onDisk.refreshToken !== newTokens.refreshToken) {
|
|
638
|
+
skipped = true;
|
|
628
639
|
return;
|
|
629
640
|
}
|
|
630
641
|
onDisk.accessToken = newTokens.accessToken;
|
|
631
642
|
onDisk.refreshToken = newTokens.refreshToken;
|
|
632
643
|
onDisk.expiresAt = newTokens.expiresAt;
|
|
644
|
+
});
|
|
645
|
+
// Monitoring: the rotated token is now DURABLE on disk. Correlate this fp
|
|
646
|
+
// with a later "REJECTED sent fp=" line to tell a lost-rotation double-spend
|
|
647
|
+
// (no matching Persisted line) from an upstream revocation (fp matches).
|
|
648
|
+
if (!skipped) {
|
|
649
|
+
console.log(`[Maxpool] Persisted rotated token for "${account.name}" (fp=${tokenFingerprint(newTokens.refreshToken)})`);
|
|
633
650
|
}
|
|
634
|
-
}
|
|
651
|
+
} catch (err) {
|
|
635
652
|
if (err?.code === 'STALE_GENERATION') return; // another writer advanced; benign
|
|
636
|
-
console.error(`[Maxpool] Failed to save refreshed token: ${err
|
|
637
|
-
}
|
|
653
|
+
console.error(`[Maxpool] Failed to save refreshed token for "${account.name}" (fp=${tokenFingerprint(newTokens?.refreshToken)}): ${err?.message || err}`);
|
|
654
|
+
}
|
|
638
655
|
};
|
|
639
656
|
accountManager.onTokenRefresh(persistTokenRefresh);
|
|
640
657
|
|
package/src/oauth.js
CHANGED
|
@@ -10,6 +10,19 @@ const DEFAULT_TOKEN_ENDPOINT = process.env.MAXPOOL_OAUTH_TOKEN_ENDPOINT
|
|
|
10
10
|
|| 'https://platform.claude.com/v1/oauth/token';
|
|
11
11
|
const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* A short, NON-REVERSIBLE fingerprint of a refresh token, for audit logging of
|
|
15
|
+
* the single-use-token rotation lifecycle. NEVER log the token itself — an
|
|
16
|
+
* 8-char sha256 prefix is irreversible yet enough to correlate a rotation
|
|
17
|
+
* ("rotated → fp / Persisted fp") with the token later rejected on boot
|
|
18
|
+
* ("REJECTED sent fp"), which is what distinguishes a lost-rotation double-spend
|
|
19
|
+
* from an upstream revocation. Returns 'none' for a falsy token.
|
|
20
|
+
*/
|
|
21
|
+
export function tokenFingerprint(token) {
|
|
22
|
+
if (!token) return 'none';
|
|
23
|
+
return createHash('sha256').update(String(token)).digest('hex').slice(0, 8);
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
/**
|
|
14
27
|
* Refresh an expired OAuth access token using the refresh token.
|
|
15
28
|
* Retries on 5xx and network errors with exponential backoff.
|
package/src/tui.js
CHANGED
|
@@ -402,8 +402,8 @@ export class TUI {
|
|
|
402
402
|
};
|
|
403
403
|
} else if (k === 'l') {
|
|
404
404
|
this._confirm(
|
|
405
|
-
'Log in via browser?',
|
|
406
|
-
'Opens a browser
|
|
405
|
+
'Log in / re-authenticate via browser?',
|
|
406
|
+
'Opens a browser. Logging into an account that\'s already added RE-AUTHENTICATES it in place (revives a reauth/error account) — no duplicate. Otherwise a new account is added.',
|
|
407
407
|
() => this._doLogin(),
|
|
408
408
|
);
|
|
409
409
|
} else if (k === 'n' && this.am.accounts.length > 0) {
|
|
@@ -577,8 +577,10 @@ export class TUI {
|
|
|
577
577
|
}
|
|
578
578
|
}
|
|
579
579
|
|
|
580
|
-
// Browser OAuth login
|
|
581
|
-
//
|
|
580
|
+
// Browser OAuth login — the SAME flow re-authenticates an existing account (matched
|
|
581
|
+
// by accountUuid) or adds a new one. When the login is for an account already added,
|
|
582
|
+
// we say so, keep its name, and skip the "name this account" prompt — so it's clear
|
|
583
|
+
// it's a RE-AUTH in place (revives a dead-refresh/reauth account), not a duplicate.
|
|
582
584
|
async _doLogin() {
|
|
583
585
|
const wasRunning = this.running;
|
|
584
586
|
if (wasRunning) this.stop();
|
|
@@ -586,14 +588,26 @@ export class TUI {
|
|
|
586
588
|
process.stdout.write('\nOpening browser to log into Claude…\n');
|
|
587
589
|
const creds = await loginOAuth();
|
|
588
590
|
const profile = await fetchProfile(creds.accessToken);
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
591
|
+
const existing = this._findExistingOAuthAccount(profile);
|
|
592
|
+
let name;
|
|
593
|
+
if (existing) {
|
|
594
|
+
process.stdout.write(`\nThis Claude account is already added as "${existing.name}" — re-authenticating it in place (no duplicate).\n`);
|
|
595
|
+
name = existing.name;
|
|
596
|
+
} else {
|
|
597
|
+
const suggested = profile?.email
|
|
598
|
+
|| `account-${this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1}`;
|
|
599
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
600
|
+
const answer = await new Promise(resolve => rl.question(`Name this NEW account [${suggested}]: `, resolve));
|
|
601
|
+
rl.close();
|
|
602
|
+
name = String(answer || '').trim() || suggested;
|
|
603
|
+
}
|
|
604
|
+
// Message honors the ACTUAL result: if a manually-typed name collided with a
|
|
605
|
+
// different existing account, upsert updates in place → "Re-authenticated", never
|
|
606
|
+
// a false "Added new account".
|
|
607
|
+
const { updated } = await this._upsertOAuthAccount({ creds, profile, name, source: 'login' });
|
|
608
|
+
process.stdout.write(updated
|
|
609
|
+
? `\nRe-authenticated "${name}". Returning to maxpool…\n`
|
|
610
|
+
: `\nAdded new account "${name}". Returning to maxpool…\n`);
|
|
597
611
|
} catch (e) {
|
|
598
612
|
process.stdout.write(`\nLogin failed: ${e.message}\n`);
|
|
599
613
|
} finally {
|
|
@@ -671,6 +685,7 @@ export class TUI {
|
|
|
671
685
|
if (amAcct.status === 'error') amAcct.status = 'active';
|
|
672
686
|
}
|
|
673
687
|
this._addLog(`Updated account "${name}"`);
|
|
688
|
+
return { updated: true, name };
|
|
674
689
|
} else {
|
|
675
690
|
this.config.accounts.push(entry);
|
|
676
691
|
try {
|
|
@@ -681,9 +696,21 @@ export class TUI {
|
|
|
681
696
|
}
|
|
682
697
|
this.am.addAccount(entry);
|
|
683
698
|
this._addLog(`${verb} account "${name}"`);
|
|
699
|
+
return { updated: false, name };
|
|
684
700
|
}
|
|
685
701
|
}
|
|
686
702
|
|
|
703
|
+
// Find an already-configured OAuth account matching a fresh browser-login profile —
|
|
704
|
+
// by accountUuid (primary), then name===email — the SAME precedence _upsertOAuthAccount
|
|
705
|
+
// dedupes on, so the login flow can honestly say "re-authenticating" vs "adding new".
|
|
706
|
+
_findExistingOAuthAccount(profile) {
|
|
707
|
+
const uuid = profile?.accountUuid || null;
|
|
708
|
+
const email = profile?.email || null;
|
|
709
|
+
let acct = uuid ? this.config.accounts.find(a => a.type === 'oauth' && a.accountUuid === uuid) : null;
|
|
710
|
+
if (!acct && email) acct = this.config.accounts.find(a => a.type === 'oauth' && a.name === email);
|
|
711
|
+
return acct || null;
|
|
712
|
+
}
|
|
713
|
+
|
|
687
714
|
async _doAddKey(apiKey) {
|
|
688
715
|
const key = String(apiKey || '').trim();
|
|
689
716
|
if (!key) { this._addLog('No API key entered'); return; }
|
|
@@ -1120,7 +1147,7 @@ export class TUI {
|
|
|
1120
1147
|
case 'normal':
|
|
1121
1148
|
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
|
|
1122
1149
|
case 'accounts':
|
|
1123
|
-
return ` ${bold('l')} Login (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
1150
|
+
return ` ${bold('l')} Login/re-auth (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
1124
1151
|
case 'routing':
|
|
1125
1152
|
return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider fallback ${bold('Esc')} Back`;
|
|
1126
1153
|
case 'select': {
|