teamclaude-cloud 1.6.0 → 1.8.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/package.json +1 -1
- package/src/account-manager.js +11 -1
- package/src/cloud.js +12 -2
- package/src/config.js +42 -0
- package/src/index.js +78 -37
- package/src/server.js +121 -10
- package/src/tui.js +17 -17
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -61,6 +61,11 @@ export class AccountManager {
|
|
|
61
61
|
// means "no preference" — selection then falls back to use-or-lose. So a
|
|
62
62
|
// config with no priorities behaves exactly as before.
|
|
63
63
|
priority: Number.isFinite(acct.priority) ? Math.floor(acct.priority) : null,
|
|
64
|
+
// Provenance: an account that belongs to a personal (private) cloud group
|
|
65
|
+
// carries cloudPrivate (set by mergePulledAccounts from the pull response's
|
|
66
|
+
// `personal` flag). Surfaced so the dashboard/status can label it "private"
|
|
67
|
+
// vs a shared/fleet account.
|
|
68
|
+
isPrivate: acct.cloudPrivate === true,
|
|
64
69
|
quota: emptyQuota(),
|
|
65
70
|
usage: {
|
|
66
71
|
totalInputTokens: 0,
|
|
@@ -1054,6 +1059,7 @@ export class AccountManager {
|
|
|
1054
1059
|
status: 'active',
|
|
1055
1060
|
enabled: acctData.enabled !== false,
|
|
1056
1061
|
priority: Number.isFinite(acctData.priority) ? Math.floor(acctData.priority) : null,
|
|
1062
|
+
isPrivate: acctData.cloudPrivate === true,
|
|
1057
1063
|
quota: emptyQuota(),
|
|
1058
1064
|
usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
|
|
1059
1065
|
rateLimitedUntil: null,
|
|
@@ -1210,9 +1216,12 @@ export class AccountManager {
|
|
|
1210
1216
|
importQuotaState(saved) {
|
|
1211
1217
|
for (const s of Array.isArray(saved) ? saved : []) {
|
|
1212
1218
|
if (!s || typeof s !== 'object') continue;
|
|
1219
|
+
// Identity match: UUID-bearing snapshot → UUID only; UUID-less → legacy-only
|
|
1220
|
+
// name (a UUID-less snapshot can't restore its quota onto a different
|
|
1221
|
+
// same-name account that owns a UUID).
|
|
1213
1222
|
const a = s.accountUuid
|
|
1214
1223
|
? this.accounts.find(x => x.accountUuid === s.accountUuid)
|
|
1215
|
-
: this.accounts.find(x => x.name === s.name);
|
|
1224
|
+
: this.accounts.find(x => !x.accountUuid && x.name === s.name);
|
|
1216
1225
|
if (!a) continue;
|
|
1217
1226
|
if (s.quota && typeof s.quota === 'object') {
|
|
1218
1227
|
// Merge over emptyQuota so a cache written by an older version (missing
|
|
@@ -1251,6 +1260,7 @@ export class AccountManager {
|
|
|
1251
1260
|
type: a.type,
|
|
1252
1261
|
status: a.status,
|
|
1253
1262
|
enabled: a.enabled !== false,
|
|
1263
|
+
isPrivate: a.isPrivate === true,
|
|
1254
1264
|
priority: a.priority ?? null,
|
|
1255
1265
|
// Deep-copy the nested modelWeekly map — the shallow quota spread would
|
|
1256
1266
|
// otherwise hand callers a live reference into account state.
|
package/src/cloud.js
CHANGED
|
@@ -593,6 +593,14 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
|
|
|
593
593
|
if (Number.isInteger(incoming.priority)) target.priority = incoming.priority;
|
|
594
594
|
else delete target.priority; // null → automatic ordering
|
|
595
595
|
};
|
|
596
|
+
// Personal-group (private) display flag from the pull response. Authoritative when
|
|
597
|
+
// the server sends `personal` (a pull can move an account in/out of a personal
|
|
598
|
+
// group); left untouched on a pre-personal server response so it isn't wiped.
|
|
599
|
+
const applyPrivate = (target, incoming) => {
|
|
600
|
+
if (!('personal' in incoming)) return;
|
|
601
|
+
if (incoming.personal === true) target.cloudPrivate = true;
|
|
602
|
+
else delete target.cloudPrivate;
|
|
603
|
+
};
|
|
596
604
|
// Multi-source tagging: an account can be managed by several keys (a consumer
|
|
597
605
|
// using >1 key whose groups overlap). Each key adds its source; an account is
|
|
598
606
|
// only pruned once NO managing key still allows it (Codex round 4 R4-3).
|
|
@@ -624,6 +632,7 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
|
|
|
624
632
|
applyOverlay(fresh); // honor an offline local-disable that survived a prune+re-add
|
|
625
633
|
addSource(fresh);
|
|
626
634
|
applyPriority(fresh, a);
|
|
635
|
+
applyPrivate(fresh, a);
|
|
627
636
|
config.accounts.push(fresh);
|
|
628
637
|
summary.added++;
|
|
629
638
|
continue;
|
|
@@ -638,9 +647,9 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
|
|
|
638
647
|
addSource(cur); // now managed by this key (so this key can prune it later)
|
|
639
648
|
// A payload entry with no token at all (allowed-but-tokenless) must not clobber
|
|
640
649
|
// a valid local token — only apply priority/metadata, keep the local token.
|
|
641
|
-
if (!hasAnyToken(a)) { applyPriority(cur, a); summary.skipped++; continue; }
|
|
650
|
+
if (!hasAnyToken(a)) { applyPriority(cur, a); applyPrivate(cur, a); summary.skipped++; continue; }
|
|
642
651
|
const curExp = normalizeExpiresAt(cur.expiresAt) || 0;
|
|
643
|
-
if (curExp > incomingExp) { applyPriority(cur, a); summary.skipped++; continue; } // local token fresher — keep it
|
|
652
|
+
if (curExp > incomingExp) { applyPriority(cur, a); applyPrivate(cur, a); summary.skipped++; continue; } // local token fresher — keep it
|
|
644
653
|
config.accounts[idx] = {
|
|
645
654
|
...cur,
|
|
646
655
|
accountUuid: a.accountUuid || cur.accountUuid,
|
|
@@ -654,6 +663,7 @@ export function mergePulledAccounts(config, pulled, source = null, allowed = nul
|
|
|
654
663
|
};
|
|
655
664
|
applyOverlay(config.accounts[idx]); // re-assert overlay on the rebuilt row
|
|
656
665
|
applyPriority(config.accounts[idx], a);
|
|
666
|
+
applyPrivate(config.accounts[idx], a);
|
|
657
667
|
summary.updated++;
|
|
658
668
|
}
|
|
659
669
|
// Prune: only when the server sent an authoritative allow-set (`allowed`). Never
|
package/src/config.js
CHANGED
|
@@ -63,6 +63,48 @@ export function writeQuotaCacheSync(data) {
|
|
|
63
63
|
} catch { /* best-effort — a failed snapshot must never break the proxy */ }
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Find the index of a config account matching `account`. Returns -1 when none match.
|
|
68
|
+
*
|
|
69
|
+
* Identity rules (the sync/dedup key used across the proxy):
|
|
70
|
+
* - A record that HAS an accountUuid matches ONLY by that UUID. It does NOT fall
|
|
71
|
+
* back to a name match, because two genuinely distinct accounts can share a
|
|
72
|
+
* display name — a name fallback there would cross-apply one account's token /
|
|
73
|
+
* enabled / priority / cloudSources (an access-control tag) onto the other.
|
|
74
|
+
* If a UUID-bearing record's UUID isn't present, it's a new/different account.
|
|
75
|
+
* - Name is the fallback identity ONLY for legacy records that carry no UUID.
|
|
76
|
+
*
|
|
77
|
+
* Written as explicit numeric branches on purpose: an inline
|
|
78
|
+
* `account.accountUuid && findIndex(...)` yields `false` for a UUID-less account,
|
|
79
|
+
* and `false >= 0` is `true` in JS — which would misattribute the match to index 0.
|
|
80
|
+
*/
|
|
81
|
+
export function findAccountIndex(accounts, account) {
|
|
82
|
+
if (account.accountUuid) {
|
|
83
|
+
const byUuid = accounts.findIndex(a => a.accountUuid === account.accountUuid);
|
|
84
|
+
if (byUuid >= 0) return byUuid;
|
|
85
|
+
}
|
|
86
|
+
// Legacy-only name fallback (both for a UUID-less incoming record and for a
|
|
87
|
+
// UUID-bearing one whose UUID isn't present yet). It matches ONLY a UUID-LESS
|
|
88
|
+
// same-name entry (`!a.accountUuid`):
|
|
89
|
+
// - UUID-bearing incoming → this is a legacy UPGRADE (the same account named "X"
|
|
90
|
+
// gaining a UUID via re-import); we adopt the existing legacy entry in place
|
|
91
|
+
// instead of appending a duplicate that shares one credential.
|
|
92
|
+
// - It never matches a DIFFERENT-UUID same-name entry (that entry owns a UUID),
|
|
93
|
+
// so two distinct accounts sharing a display name can't cross-apply
|
|
94
|
+
// token / enabled / priority / cloudSources.
|
|
95
|
+
return accounts.findIndex(a => !a.accountUuid && a.name === account.name);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function findConfigAccount(config, account) {
|
|
99
|
+
return findAccountIndex(config.accounts, account);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Object-returning variant for call sites that want the entry, not its index.
|
|
103
|
+
export function findAccount(accounts, account) {
|
|
104
|
+
const i = findAccountIndex(accounts, account);
|
|
105
|
+
return i >= 0 ? accounts[i] : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
66
108
|
export function createDefaultConfig() {
|
|
67
109
|
return {
|
|
68
110
|
proxy: {
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { unlinkSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
|
-
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, getServerStatePath, writeServerState, readServerState, clearServerState, readQuotaCache, writeQuotaCacheSync } from './config.js';
|
|
6
|
+
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, getServerStatePath, writeServerState, readServerState, clearServerState, readQuotaCache, writeQuotaCacheSync, findConfigAccount, findAccount, findAccountIndex } from './config.js';
|
|
7
7
|
import { AccountManager } from './account-manager.js';
|
|
8
8
|
import { createProxyServer } from './server.js';
|
|
9
9
|
import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
@@ -183,6 +183,10 @@ async function serverCommand() {
|
|
|
183
183
|
// initialized before this ever runs: the snapshot writers are registered
|
|
184
184
|
// inside the listen callback.)
|
|
185
185
|
probeTemplate: server.exportProbeTemplate?.() ?? null,
|
|
186
|
+
// The Fable-window (model-weekly) probe shape, persisted separately so R and
|
|
187
|
+
// the top-up can refresh the Fable limit right after a restart — even once a
|
|
188
|
+
// low-tier request has replaced the general template above.
|
|
189
|
+
modelWeeklyTemplate: server.exportModelWeeklyTemplate?.() ?? null,
|
|
186
190
|
});
|
|
187
191
|
|
|
188
192
|
// Persist refreshed tokens back to config (re-read from disk to avoid clobbering
|
|
@@ -268,19 +272,18 @@ async function serverCommand() {
|
|
|
268
272
|
// Match the live account by IDENTITY — never by array index:
|
|
269
273
|
// resolveAccounts() can skip a tokenless/bad config entry, so
|
|
270
274
|
// config.accounts and accountManager.accounts are not index-aligned, and
|
|
271
|
-
// an index map would overlay the wrong account's credentials.
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
275
|
+
// an index map would overlay the wrong account's credentials. Identity
|
|
276
|
+
// match: UUID-bearing → UUID only; UUID-less → legacy-only name (so a
|
|
277
|
+
// UUID-bearing record whose UUID isn't live can't name-hijack a different
|
|
278
|
+
// same-name account and cross-apply its credentials).
|
|
279
|
+
const am = findAccount(accountManager.accounts, a);
|
|
276
280
|
const live = am ? {
|
|
277
281
|
...a,
|
|
278
282
|
accessToken: am.credential,
|
|
279
283
|
refreshToken: am.refreshToken,
|
|
280
284
|
expiresAt: am.expiresAt,
|
|
281
285
|
} : a;
|
|
282
|
-
const diskAcct = (
|
|
283
|
-
|| diskConfig.accounts.find(d => d.name === a.name);
|
|
286
|
+
const diskAcct = findAccount(diskConfig.accounts, a);
|
|
284
287
|
return diskAcct ? { ...diskAcct, ...live } : live;
|
|
285
288
|
});
|
|
286
289
|
// The TUI's in-memory config is authoritative for the account SET (an
|
|
@@ -325,8 +328,7 @@ async function serverCommand() {
|
|
|
325
328
|
cloudPush: config.cloud ? (async () => {
|
|
326
329
|
// Use the running server's LIVE tokens (freshest) for each account.
|
|
327
330
|
const accounts = (config.accounts || []).filter(a => a.type !== 'apikey').map(a => {
|
|
328
|
-
const am = (
|
|
329
|
-
|| accountManager.accounts.find(x => x.name === a.name);
|
|
331
|
+
const am = findAccount(accountManager.accounts, a);
|
|
330
332
|
return am ? { ...a, accessToken: am.credential, refreshToken: am.refreshToken, expiresAt: am.expiresAt } : a;
|
|
331
333
|
});
|
|
332
334
|
if (!accounts.length) throw new Error('No OAuth accounts to push');
|
|
@@ -387,6 +389,12 @@ async function serverCommand() {
|
|
|
387
389
|
if (quotaCache?.probeTemplate && server.importProbeTemplate?.(quotaCache.probeTemplate)) {
|
|
388
390
|
console.log('[TeamClaude] Restored warm-up probe template');
|
|
389
391
|
}
|
|
392
|
+
// Restore the Fable-window probe shape too, so R / the top-up can refresh the
|
|
393
|
+
// Fable limit on a freshly restarted idle proxy without waiting for fresh
|
|
394
|
+
// Fable traffic to re-seed it.
|
|
395
|
+
if (quotaCache?.modelWeeklyTemplate && server.importModelWeeklyTemplate?.(quotaCache.modelWeeklyTemplate)) {
|
|
396
|
+
console.log('[TeamClaude] Restored Fable-window probe template');
|
|
397
|
+
}
|
|
390
398
|
// Catch bind-time errors (e.g. EADDRINUSE) only. Once the socket is bound we
|
|
391
399
|
// remove this handler so a later runtime 'error' isn't misreported as a
|
|
392
400
|
// listen failure and exit the whole proxy.
|
|
@@ -884,7 +892,8 @@ async function statusCommand() {
|
|
|
884
892
|
const current = acct.name === data.currentAccount ? ' *' : '';
|
|
885
893
|
|
|
886
894
|
const disabledTag = acct.enabled === false ? ' [disabled]' : '';
|
|
887
|
-
|
|
895
|
+
const privateTag = acct.isPrivate ? ' [private]' : '';
|
|
896
|
+
console.log(` ${maskIf(acct.name, config.maskMode)} (${acct.type})${current}${privateTag}${disabledTag}`);
|
|
888
897
|
console.log(` Status: ${acct.status}${acct.enabled === false ? ' (disabled — out of rotation)' : ''}`);
|
|
889
898
|
if (acct.priority != null) console.log(` Priority: ${acct.priority} (lower = preferred)`);
|
|
890
899
|
if (acct.maxConcurrent != null) {
|
|
@@ -1272,11 +1281,10 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
|
|
|
1272
1281
|
expiresAt: creds.expiresAt,
|
|
1273
1282
|
};
|
|
1274
1283
|
|
|
1275
|
-
// Deduplicate:
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
|
|
1284
|
+
// Deduplicate by identity: UUID-bearing → UUID only; UUID-less → legacy-only name
|
|
1285
|
+
// (a UUID-bearing profile whose UUID isn't present is a NEW account, not a
|
|
1286
|
+
// same-name hijack). Same contract as the TUI import path.
|
|
1287
|
+
const idx = findAccountIndex(config.accounts, { accountUuid: profile?.accountUuid, name });
|
|
1280
1288
|
|
|
1281
1289
|
if (idx >= 0) {
|
|
1282
1290
|
// Re-credentialing an existing account must not wipe its manual routing
|
|
@@ -1296,17 +1304,7 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
|
|
|
1296
1304
|
}
|
|
1297
1305
|
|
|
1298
1306
|
// ── config sync helpers ─────────────────────────────────────
|
|
1299
|
-
|
|
1300
|
-
/**
|
|
1301
|
-
* Find a config account entry matching an in-memory account (by UUID, then name).
|
|
1302
|
-
*/
|
|
1303
|
-
function findConfigAccount(diskConfig, account) {
|
|
1304
|
-
if (account.accountUuid) {
|
|
1305
|
-
const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
|
|
1306
|
-
if (idx >= 0) return idx;
|
|
1307
|
-
}
|
|
1308
|
-
return diskConfig.accounts.findIndex(a => a.name === account.name);
|
|
1309
|
-
}
|
|
1307
|
+
// findConfigAccount lives in config.js (pure + unit-tested for the UUID-less case).
|
|
1310
1308
|
|
|
1311
1309
|
/**
|
|
1312
1310
|
* Sync accounts from disk config: add new accounts and refresh credentials
|
|
@@ -1316,10 +1314,11 @@ function findConfigAccount(diskConfig, account) {
|
|
|
1316
1314
|
async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
1317
1315
|
let added = 0;
|
|
1318
1316
|
for (const diskAcct of diskConfig.accounts) {
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1317
|
+
// Match the disk entry to an in-memory account (UUID-bearing → UUID only;
|
|
1318
|
+
// UUID-less → name). The previous inline form let `false >= 0` misattribute a
|
|
1319
|
+
// UUID-less disk entry to in-memory index 0 — corrupting enabled/priority/
|
|
1320
|
+
// cloudSources on the wrong account.
|
|
1321
|
+
const memIdx = findConfigAccount(memConfig, diskAcct);
|
|
1323
1322
|
|
|
1324
1323
|
if (memIdx < 0) {
|
|
1325
1324
|
// New account discovered on disk — add to running server
|
|
@@ -1330,11 +1329,14 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
|
1330
1329
|
continue;
|
|
1331
1330
|
}
|
|
1332
1331
|
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
|
|
1337
|
-
|
|
1332
|
+
// Resolve the live AccountManager entry with the SAME identity contract as
|
|
1333
|
+
// findConfigAccount above (UUID-bearing → UUID only; UUID-less → name). Using
|
|
1334
|
+
// one helper for both keeps mgr and memIdx from diverging onto *different*
|
|
1335
|
+
// accounts — otherwise a disk entry {uuid:B,name:X} whose UUID isn't live yet
|
|
1336
|
+
// would let memIdx pick config-B while mgr name-matched live-A, cross-applying
|
|
1337
|
+
// B's token / enabled / priority / cloudSources (an access-control tag) onto A.
|
|
1338
|
+
const mgrIdx = findConfigAccount({ accounts: accountManager.accounts }, diskAcct);
|
|
1339
|
+
const mgr = mgrIdx >= 0 ? accountManager.accounts[mgrIdx] : undefined;
|
|
1338
1340
|
|
|
1339
1341
|
// Apply enable/disable + priority from disk FIRST — independent of credential
|
|
1340
1342
|
// re-resolution below. A failed re-import (freshCred null) must NOT strand a
|
|
@@ -1345,6 +1347,9 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
|
1345
1347
|
if (mgr.enabled !== wantEnabled) accountManager.setEnabled(mgr, wantEnabled);
|
|
1346
1348
|
const diskPriority = Number.isFinite(diskAcct.priority) ? Math.floor(diskAcct.priority) : null;
|
|
1347
1349
|
if (mgr.priority !== diskPriority) accountManager.setPriority(mgr, diskPriority);
|
|
1350
|
+
// Provenance can change while running (a cloud pull moves an account in/out of
|
|
1351
|
+
// a personal group), so refresh the private flag on reload too.
|
|
1352
|
+
mgr.isPrivate = diskAcct.cloudPrivate === true;
|
|
1348
1353
|
// Mirror the applied state into the in-memory config copy too. Otherwise a
|
|
1349
1354
|
// later TUI saveConfig (for any unrelated op) would spread the pre-sync
|
|
1350
1355
|
// enabled/priority over the disk value and silently revert a CLI change.
|
|
@@ -1352,6 +1357,20 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
|
1352
1357
|
if (memAcct) {
|
|
1353
1358
|
if (wantEnabled) delete memAcct.enabled; else memAcct.enabled = false;
|
|
1354
1359
|
if (diskPriority === null) delete memAcct.priority; else memAcct.priority = diskPriority;
|
|
1360
|
+
// cloudSources (prune scoping) and cloudPrivate (personal-group display) are
|
|
1361
|
+
// both authoritative on disk — a cloud pull tags/untags them. Mirror both, or
|
|
1362
|
+
// a later TUI saveConfig would overlay the stale in-memory value onto disk
|
|
1363
|
+
// and resurrect a source tag / private flag a pull just changed.
|
|
1364
|
+
if (Array.isArray(diskAcct.cloudSources) && diskAcct.cloudSources.length > 0) {
|
|
1365
|
+
memAcct.cloudSources = diskAcct.cloudSources;
|
|
1366
|
+
} else {
|
|
1367
|
+
delete memAcct.cloudSources;
|
|
1368
|
+
}
|
|
1369
|
+
if (diskAcct.cloudPrivate === true) {
|
|
1370
|
+
memAcct.cloudPrivate = true;
|
|
1371
|
+
} else {
|
|
1372
|
+
delete memAcct.cloudPrivate;
|
|
1373
|
+
}
|
|
1355
1374
|
}
|
|
1356
1375
|
}
|
|
1357
1376
|
|
|
@@ -1370,7 +1389,29 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
|
1370
1389
|
freshCred = { apiKey: diskAcct.apiKey };
|
|
1371
1390
|
}
|
|
1372
1391
|
|
|
1373
|
-
if (!freshCred
|
|
1392
|
+
if (!freshCred) continue;
|
|
1393
|
+
|
|
1394
|
+
// In config (memIdx >= 0) but not live: it was skipped at startup as tokenless
|
|
1395
|
+
// and now has a credential on disk. Add it to rotation without waiting for a
|
|
1396
|
+
// restart, merging the freshly-resolved credential (covers the importFrom path
|
|
1397
|
+
// where diskAcct carries no inline token). Mirrors the new-account branch.
|
|
1398
|
+
if (!mgr) {
|
|
1399
|
+
// `mgr` was resolved before the importCredentials() await above, which yields
|
|
1400
|
+
// the event loop — a concurrent reload (TUI R / /teamclaude/reload / cloud
|
|
1401
|
+
// sync) may have already added this account. Re-resolve against the live set
|
|
1402
|
+
// immediately before the SYNCHRONOUS addAccount: the re-check and push run
|
|
1403
|
+
// with no await between them, so at most one sync adds the UUID (no duplicate
|
|
1404
|
+
// that would double effective concurrency/quota for one credential).
|
|
1405
|
+
if (findConfigAccount({ accounts: accountManager.accounts }, diskAcct) < 0) {
|
|
1406
|
+
const creds = freshCred.apiKey
|
|
1407
|
+
? { apiKey: freshCred.apiKey }
|
|
1408
|
+
: { accessToken: freshCred.accessToken, refreshToken: freshCred.refreshToken, expiresAt: freshCred.expiresAt };
|
|
1409
|
+
accountManager.addAccount({ ...diskAcct, ...creds });
|
|
1410
|
+
added++;
|
|
1411
|
+
console.log(`[TeamClaude] Added now-credentialed account "${diskAcct.name}" to rotation`);
|
|
1412
|
+
}
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1374
1415
|
|
|
1375
1416
|
if (freshCred.accessToken) {
|
|
1376
1417
|
const changed = mgr.credential !== freshCred.accessToken ||
|
package/src/server.js
CHANGED
|
@@ -58,6 +58,12 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
58
58
|
: 5 * 60 * 1000;
|
|
59
59
|
const WARMUP_PROBE_TIMEOUT_MS = 15_000;
|
|
60
60
|
let probeTemplate = null; // committed { model, version, beta, system } — only after a 2xx
|
|
61
|
+
let mwTemplate = null; // last shape known to elicit the model-weekly (Fable 7d_oi) window,
|
|
62
|
+
// kept in a SEPARATE slot so low-tier traffic (which legitimately
|
|
63
|
+
// replaces probeTemplate) can't erase the fleet's ability to probe
|
|
64
|
+
// the Fable limit. Without this, the first low-tier request after a
|
|
65
|
+
// restart downgrades the restored template and R/top-up can no longer
|
|
66
|
+
// refresh Fable until fresh Fable traffic happens to flow through.
|
|
61
67
|
let warmupInFlight = false; // guard against overlapping fan-outs
|
|
62
68
|
let warmupClosed = false; // set on server close: stop scheduling, abort in-flight probes
|
|
63
69
|
const warmupAbort = new AbortController();
|
|
@@ -78,7 +84,11 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
78
84
|
model: json.model,
|
|
79
85
|
version: req.headers['anthropic-version'] || '2023-06-01',
|
|
80
86
|
beta: req.headers['anthropic-beta'] || null,
|
|
81
|
-
|
|
87
|
+
// Sanitize at the single capture point so BOTH the general and the Fable
|
|
88
|
+
// template store only the canonical OAuth identifier — never the client's
|
|
89
|
+
// secret-bearing project/CLAUDE.md context. Every probe (warm-up, R,
|
|
90
|
+
// top-up) then replays a secret-free system to every account.
|
|
91
|
+
system: sanitizeProbeSystem(json.system ?? null),
|
|
82
92
|
};
|
|
83
93
|
}
|
|
84
94
|
|
|
@@ -94,6 +104,19 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
94
104
|
function commitProbeTemplate(candidate, status, elicitsModelWeekly = false) {
|
|
95
105
|
if (!activeWarmup || warmupClosed) return;
|
|
96
106
|
if (!(status >= 200 && status < 300)) return; // only trust an accepted shape
|
|
107
|
+
// A shape whose own response carried a model-scoped weekly window is the only
|
|
108
|
+
// thing that can probe the Fable (7d_oi) limit. Keep it in its own slot,
|
|
109
|
+
// refreshed from fresh evidence, so the general probeTemplate below can follow
|
|
110
|
+
// the newest tier (and be replaced by low-tier traffic) WITHOUT losing the
|
|
111
|
+
// ability to refresh Fable. A restored mwTemplate is simply overwritten by the
|
|
112
|
+
// next fresh Fable commit; if its model was retired it 4xx's and the per-account
|
|
113
|
+
// _mwProbes cap retires it (needsModelWeekly), then real Fable traffic re-seeds.
|
|
114
|
+
// candidate.system is already sanitized (stageProbeTemplate) to the canonical
|
|
115
|
+
// identifier block only — no client project/CLAUDE.md secret is stored here or
|
|
116
|
+
// persisted to the snapshot.
|
|
117
|
+
if (elicitsModelWeekly) {
|
|
118
|
+
mwTemplate = { model: candidate.model, version: candidate.version, beta: candidate.beta, system: candidate.system };
|
|
119
|
+
}
|
|
97
120
|
// A template RESTORED from the last run's snapshot is provisional: it let
|
|
98
121
|
// probes work before any traffic, but upstream accepted it in a previous
|
|
99
122
|
// process — the model may have been retired since. The first freshly
|
|
@@ -116,6 +139,43 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
116
139
|
return JSON.stringify(b);
|
|
117
140
|
}
|
|
118
141
|
|
|
142
|
+
// Build the probe's system WITHOUT persisting or replaying ANY client text.
|
|
143
|
+
// A captured system may carry tenant secrets — not only in later blocks, but
|
|
144
|
+
// appended after a valid identifier prefix ("You are Claude Code\nSECRET=…") or
|
|
145
|
+
// as the whole string. A prefix/first-block match is therefore only a SIGNAL
|
|
146
|
+
// that this was a Claude-Code OAuth request; when it matches we emit our OWN
|
|
147
|
+
// fixed canonical identifier constant (proxy-controlled, zero client
|
|
148
|
+
// characters), else null. This makes leakage impossible by construction: no
|
|
149
|
+
// client-provided byte is ever stored in the snapshot or sent to another
|
|
150
|
+
// account. A null (systemless) probe may 4xx → the _mwProbes/_partialProbes
|
|
151
|
+
// caps retire it; that only means "no probe-measured quota until real traffic",
|
|
152
|
+
// never a leak. If the canonical constant isn't accepted upstream, warm-up
|
|
153
|
+
// degrades gracefully to real-traffic measurement (a quality tradeoff, not a
|
|
154
|
+
// security one — chosen deliberately: no secret may ever cross accounts).
|
|
155
|
+
const CLAUDE_CODE_SYSTEM_PREFIX = 'You are Claude Code';
|
|
156
|
+
const CLAUDE_CODE_SYSTEM = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
157
|
+
function sanitizeProbeSystem(system) {
|
|
158
|
+
const looksLikeId = (v) => typeof v === 'string' && v.startsWith(CLAUDE_CODE_SYSTEM_PREFIX);
|
|
159
|
+
let firstText = null;
|
|
160
|
+
if (typeof system === 'string') firstText = system;
|
|
161
|
+
else if (Array.isArray(system) && system.length) {
|
|
162
|
+
const b0 = system[0];
|
|
163
|
+
firstText = typeof b0 === 'string' ? b0 : (b0 && typeof b0 === 'object' ? b0.text : null);
|
|
164
|
+
}
|
|
165
|
+
return looksLikeId(firstText) ? [{ type: 'text', text: CLAUDE_CODE_SYSTEM }] : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The shape to replay when probing the model-weekly (Fable) window: prefer the
|
|
169
|
+
// live general template if it already elicits the window, else the dedicated
|
|
170
|
+
// mwTemplate (which survives low-tier template churn and restarts). null when
|
|
171
|
+
// no Fable-eliciting shape has ever been observed — nothing can probe Fable then.
|
|
172
|
+
// mwTemplate carries its OWN sanitized system (the canonical identifier block
|
|
173
|
+
// only — see commitProbeTemplate), so it needs no borrowing: it's both
|
|
174
|
+
// OAuth-acceptable and secret-free.
|
|
175
|
+
function modelWeeklyTemplate() {
|
|
176
|
+
return probeTemplate && probeTemplate._elicitsModelWeekly ? probeTemplate : mwTemplate;
|
|
177
|
+
}
|
|
178
|
+
|
|
119
179
|
// A probe fetch is bounded by BOTH a timeout and server-close, so a scheduled or
|
|
120
180
|
// in-flight probe can't keep sending a credentialed request after teardown.
|
|
121
181
|
// Returns { signal, cleanup }: the caller MUST call cleanup() when the probe
|
|
@@ -159,8 +219,12 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
159
219
|
// capacity 429 could not.
|
|
160
220
|
// - Learns ONLY from a response upstream accepted (2xx) or an account-level
|
|
161
221
|
// quota 429 ('rejected') — a 4xx / non-exhaustion 429 / 5xx never mutates state.
|
|
162
|
-
async function warmupAccount(account, { force = false } = {}) {
|
|
163
|
-
|
|
222
|
+
async function warmupAccount(account, { force = false, template = null } = {}) {
|
|
223
|
+
// `template` overrides the shape to replay (used by the model-weekly top-up /
|
|
224
|
+
// R Fable pass, which must probe with a Fable-eliciting shape even when the
|
|
225
|
+
// live general template is a lower tier). Defaults to the general template.
|
|
226
|
+
const tmpl = template || probeTemplate;
|
|
227
|
+
if (!tmpl || warmupClosed || account._warming) return;
|
|
164
228
|
// Don't refresh from a background probe; skip an OAuth account that needs one.
|
|
165
229
|
if (account.type === 'oauth' && isTokenExpiringSoon(account.expiresAt)) return;
|
|
166
230
|
// Re-confirm it's still an available, unmeasured, idle candidate — unless
|
|
@@ -172,13 +236,13 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
172
236
|
account._warming = true;
|
|
173
237
|
const probe = probeSignal();
|
|
174
238
|
try {
|
|
175
|
-
const headers = { 'content-type': 'application/json', 'anthropic-version':
|
|
176
|
-
if (
|
|
239
|
+
const headers = { 'content-type': 'application/json', 'anthropic-version': tmpl.version };
|
|
240
|
+
if (tmpl.beta) headers['anthropic-beta'] = tmpl.beta;
|
|
177
241
|
if (account.type === 'oauth') headers['authorization'] = `Bearer ${account.credential}`;
|
|
178
242
|
else headers['x-api-key'] = account.credential;
|
|
179
243
|
|
|
180
244
|
const res = await fetch(`${upstream}/v1/messages`, {
|
|
181
|
-
method: 'POST', headers, body: buildProbeBody(
|
|
245
|
+
method: 'POST', headers, body: buildProbeBody(tmpl), signal: probe.signal,
|
|
182
246
|
});
|
|
183
247
|
const rl = {};
|
|
184
248
|
for (const [k, v] of res.headers.entries()) {
|
|
@@ -227,6 +291,13 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
227
291
|
// permanently even after upstream recovers.
|
|
228
292
|
account._partialProbes = (account._partialProbes || 0) + 1;
|
|
229
293
|
account._lastFruitlessProbeAt = Date.now(); // paces the slow retry backstop
|
|
294
|
+
// Also cap the model-weekly budget: a Fable-shape probe that 4xx's (e.g. a
|
|
295
|
+
// restored template whose model was retired → 404) never reaches the
|
|
296
|
+
// success branch above, so without this `_mwProbes` would never increment
|
|
297
|
+
// and needsModelWeekly() would re-probe the retired model every interval
|
|
298
|
+
// forever. A deterministic-fruitless outcome retires the Fable probe too;
|
|
299
|
+
// a later success (window populated / window swept) resets it.
|
|
300
|
+
account._mwProbes = (account._mwProbes || 0) + 1;
|
|
230
301
|
}
|
|
231
302
|
} catch (err) {
|
|
232
303
|
// Best-effort: leave the account unmeasured (exactly as before warm-up).
|
|
@@ -270,9 +341,22 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
270
341
|
// Renew both probe budgets — R is an explicit "measure everything now".
|
|
271
342
|
for (const a of alive) { a._partialProbes = 0; a._mwProbes = 0; }
|
|
272
343
|
const outcomes = await Promise.all(alive.map(a => warmupAccount(a, { force: true })));
|
|
344
|
+
// Fable pass: if the live general template can't elicit the model-weekly
|
|
345
|
+
// window but a shape that can survives (mwTemplate — possibly from before a
|
|
346
|
+
// restart + low-tier churn), probe the accounts still missing the window with
|
|
347
|
+
// it. This closes the "R refreshes everything except the Fable limit" gap.
|
|
348
|
+
// When the general template already elicits Fable, the pass above did it, so
|
|
349
|
+
// modelWeeklyTemplate() returns probeTemplate and this branch is skipped.
|
|
350
|
+
const mw = modelWeeklyTemplate();
|
|
351
|
+
if (mw && !(probeTemplate && probeTemplate._elicitsModelWeekly)) {
|
|
352
|
+
const mwTargets = alive.filter(a =>
|
|
353
|
+
a.type === 'oauth' && !a._warming && Object.keys(a.quota.modelWeekly).length === 0);
|
|
354
|
+
await Promise.all(mwTargets.map(a => warmupAccount(a, { force: true, template: mw })));
|
|
355
|
+
}
|
|
273
356
|
// Honest accounting: `targets` is what the user asked to refresh, `measured`
|
|
274
357
|
// is what actually got fresh data — the TUI reports M/N, never a blanket
|
|
275
|
-
// "refreshed N" while probes silently skipped or failed.
|
|
358
|
+
// "refreshed N" while probes silently skipped or failed. (The supplemental
|
|
359
|
+
// Fable pass only fills a secondary window; it doesn't change these counts.)
|
|
276
360
|
return { targets: targets.length, measured: outcomes.filter(Boolean).length };
|
|
277
361
|
}
|
|
278
362
|
|
|
@@ -285,12 +369,17 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
285
369
|
// that exact account is idle. Force-probes so the fully-measured guard doesn't
|
|
286
370
|
// exclude them; still skips in-flight/disabled/error accounts.
|
|
287
371
|
async function topUpModelWeekly() {
|
|
288
|
-
if (!activeWarmup || warmupClosed
|
|
372
|
+
if (!activeWarmup || warmupClosed) return;
|
|
373
|
+
// Probe with a Fable-eliciting shape: the general template if it is one, else
|
|
374
|
+
// the dedicated mwTemplate — so the top-up keeps working even after low-tier
|
|
375
|
+
// traffic has replaced the general template (the common post-restart case).
|
|
376
|
+
const mw = modelWeeklyTemplate();
|
|
377
|
+
if (!mw) return;
|
|
289
378
|
const targets = accountManager.accounts.filter(a =>
|
|
290
379
|
a.enabled !== false && a.status !== 'error' && a.inflight === 0 && !a._warming
|
|
291
380
|
&& accountManager.needsModelWeekly(a));
|
|
292
381
|
if (!targets.length) return;
|
|
293
|
-
await Promise.all(targets.map(a => warmupAccount(a, { force: true })));
|
|
382
|
+
await Promise.all(targets.map(a => warmupAccount(a, { force: true, template: mw })));
|
|
294
383
|
}
|
|
295
384
|
|
|
296
385
|
// Probe every currently-unmeasured idle account in parallel. Guarded so two
|
|
@@ -511,13 +600,35 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
511
600
|
model: t.model,
|
|
512
601
|
version: typeof t.version === 'string' && t.version ? t.version : '2023-06-01',
|
|
513
602
|
beta: typeof t.beta === 'string' && t.beta ? t.beta : null,
|
|
514
|
-
|
|
603
|
+
// Re-sanitize on restore (defence in depth: a snapshot from an older build
|
|
604
|
+
// could hold a full, secret-bearing system).
|
|
605
|
+
system: sanitizeProbeSystem(t.system),
|
|
515
606
|
_elicitsModelWeekly: t._elicitsModelWeekly === true,
|
|
516
607
|
_restored: true,
|
|
517
608
|
};
|
|
518
609
|
return true;
|
|
519
610
|
};
|
|
520
611
|
|
|
612
|
+
// The Fable-window template is persisted separately so that after a restart the
|
|
613
|
+
// fleet can refresh Fable via R / top-up even before fresh Fable traffic flows
|
|
614
|
+
// (and even once a low-tier request replaces the general probeTemplate). It has
|
|
615
|
+
// no _restored/_elicits flags: it is always a Fable-eliciting shape, and the
|
|
616
|
+
// next fresh Fable commit overwrites it.
|
|
617
|
+
server.exportModelWeeklyTemplate = () => (mwTemplate ? { ...mwTemplate } : null);
|
|
618
|
+
server.importModelWeeklyTemplate = (t) => {
|
|
619
|
+
if (!activeWarmup || warmupClosed || mwTemplate) return false;
|
|
620
|
+
if (!t || typeof t !== 'object' || typeof t.model !== 'string' || !t.model) return false;
|
|
621
|
+
mwTemplate = {
|
|
622
|
+
model: t.model,
|
|
623
|
+
version: typeof t.version === 'string' && t.version ? t.version : '2023-06-01',
|
|
624
|
+
beta: typeof t.beta === 'string' && t.beta ? t.beta : null,
|
|
625
|
+
// Re-sanitize on restore (defence in depth: a snapshot from an older build
|
|
626
|
+
// could hold a full system). Only the canonical identifier block survives.
|
|
627
|
+
system: sanitizeProbeSystem(t.system),
|
|
628
|
+
};
|
|
629
|
+
return true;
|
|
630
|
+
};
|
|
631
|
+
|
|
521
632
|
return server;
|
|
522
633
|
}
|
|
523
634
|
|
package/src/tui.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { importCredentials, fetchProfile } from './oauth.js';
|
|
2
2
|
import { maskIf, maskEmailsIn } from './mask.js';
|
|
3
|
+
import { findAccount, findAccountIndex } from './config.js';
|
|
3
4
|
|
|
4
5
|
// ── ANSI helpers ─────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -463,11 +464,10 @@ export class TUI {
|
|
|
463
464
|
expiresAt: creds.expiresAt,
|
|
464
465
|
};
|
|
465
466
|
|
|
466
|
-
// Deduplicate:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
|
|
467
|
+
// Deduplicate by identity: UUID-bearing → UUID only; UUID-less → legacy-only
|
|
468
|
+
// name (a UUID-bearing profile whose UUID isn't present is a NEW account, not
|
|
469
|
+
// a same-name hijack).
|
|
470
|
+
const idx = findAccountIndex(this.config.accounts, { accountUuid: profile?.accountUuid, name });
|
|
471
471
|
|
|
472
472
|
if (idx >= 0) {
|
|
473
473
|
// Preserve manual routing settings across a re-import (the new entry
|
|
@@ -482,8 +482,7 @@ export class TUI {
|
|
|
482
482
|
// which can point at a different live account when a tokenless config entry
|
|
483
483
|
// was skipped at load (config.accounts is not index-aligned with
|
|
484
484
|
// accountManager.accounts).
|
|
485
|
-
const amAcct = (
|
|
486
|
-
|| this.am.accounts.find(a => a.name === prev.name);
|
|
485
|
+
const amAcct = findAccount(this.am.accounts, prev);
|
|
487
486
|
if (amAcct) {
|
|
488
487
|
amAcct.credential = creds.accessToken;
|
|
489
488
|
amAcct.refreshToken = creds.refreshToken;
|
|
@@ -531,10 +530,9 @@ export class TUI {
|
|
|
531
530
|
// Splice the config entry by IDENTITY, not by the AccountManager index —
|
|
532
531
|
// config.accounts can hold more entries than AccountManager (tokenless accounts
|
|
533
532
|
// are skipped at load), so the AM index may delete a different config entry.
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
if (cfgIdx < 0) cfgIdx = this.config.accounts.findIndex(c => c.name === name);
|
|
533
|
+
// Identity match (UUID-bearing → UUID only; UUID-less → legacy-only name) so a
|
|
534
|
+
// remove can't splice out a different same-name account.
|
|
535
|
+
const cfgIdx = findAccountIndex(this.config.accounts, { accountUuid: uuid, name });
|
|
538
536
|
if (cfgIdx >= 0) this.config.accounts.splice(cfgIdx, 1);
|
|
539
537
|
// Drop a cursor anchor that pointed at the removed account; _selected()
|
|
540
538
|
// falls back to the remembered position on the next keypress/render.
|
|
@@ -555,10 +553,9 @@ export class TUI {
|
|
|
555
553
|
// 1:1 onto config.accounts. Matching by UUID/name avoids corrupting a
|
|
556
554
|
// different config entry.
|
|
557
555
|
this.am.setEnabled(amAcct, newEnabled);
|
|
558
|
-
// Match the config entry UUID-
|
|
559
|
-
//
|
|
560
|
-
const cfg = (
|
|
561
|
-
|| this.config.accounts.find(a => a.name === amAcct.name);
|
|
556
|
+
// Match the config entry by identity (UUID-bearing → UUID only; UUID-less →
|
|
557
|
+
// legacy-only name) so the flag can't persist onto a different same-name entry.
|
|
558
|
+
const cfg = findAccount(this.config.accounts, amAcct);
|
|
562
559
|
if (cfg) cfg.enabled = newEnabled;
|
|
563
560
|
await this.saveConfig(this.config);
|
|
564
561
|
// Maintain the offline local-disable overlay (cloud.localDisabled, by uuid) at the
|
|
@@ -658,8 +655,7 @@ export class TUI {
|
|
|
658
655
|
// display index may not map 1:1 onto config.accounts. Write `null` (not a
|
|
659
656
|
// deleted key) to clear, so the saveConfig `{...diskAcct, ...live}` merge
|
|
660
657
|
// can't let a stale disk priority survive.
|
|
661
|
-
const cfg = (
|
|
662
|
-
|| this.config.accounts.find(c => c.name === a.name);
|
|
658
|
+
const cfg = findAccount(this.config.accounts, a);
|
|
663
659
|
if (cfg) cfg.priority = want;
|
|
664
660
|
}
|
|
665
661
|
// Persist via the coalescing saver: rapid ↑/↓ presses mutate synchronously but
|
|
@@ -865,6 +861,10 @@ export class TUI {
|
|
|
865
861
|
// badges stay column-aligned across mixed account types.
|
|
866
862
|
line += l3 ? ` ${l3} ${bar(r3, bw, t3)}` : ' '.repeat(6 + bw);
|
|
867
863
|
}
|
|
864
|
+
// Provenance badge: an account in a personal (private) cloud group is tagged
|
|
865
|
+
// "private" so it's distinguishable from a shared/fleet account. Appended (like
|
|
866
|
+
// the rank badge) so it never disrupts the fixed-width columns / quota bars.
|
|
867
|
+
if (a.isPrivate) line += ` ${cyan('private')}`;
|
|
868
868
|
// Order badge: ranked accounts show their 1-based position (#1 = most
|
|
869
869
|
// preferred). While ordering, unranked accounts are labelled "auto" so the
|
|
870
870
|
// two groups (pinned order vs use-or-lose) are visible. Appended last so it
|