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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamclaude-cloud",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Multi-account Claude proxy with quota-based rotation + cloud token sync and auth-key control",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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. Two-phase
272
- // (UUID first, then name): a single `uuid===x || name===x` find could
273
- // return an earlier same-name account before reaching the real UUID match.
274
- const am = (a.accountUuid && accountManager.accounts.find(x => x.accountUuid === a.accountUuid))
275
- || accountManager.accounts.find(x => x.name === a.name);
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 = (a.accountUuid && diskConfig.accounts.find(d => d.accountUuid === a.accountUuid))
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 = (a.accountUuid && accountManager.accounts.find(x => x.accountUuid === a.accountUuid))
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
- console.log(` ${maskIf(acct.name, config.maskMode)} (${acct.type})${current}${disabledTag}`);
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: match by UUID first, then by name
1276
- let idx = profile?.accountUuid
1277
- ? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
1278
- : -1;
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
- const matchByUuid = diskAcct.accountUuid &&
1320
- memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid);
1321
- const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
1322
- const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);
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
- // Find the corresponding AccountManager entry UUID first, then name, so a
1334
- // disk entry whose UUID and name resolve to *different* live accounts can't
1335
- // misattribute the update to the name-match when a UUID-match exists.
1336
- const mgr = (diskAcct.accountUuid && accountManager.accounts.find(a => a.accountUuid === diskAcct.accountUuid))
1337
- || accountManager.accounts.find(a => a.name === diskAcct.name);
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 || !mgr) continue;
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
- system: json.system ?? null,
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
- if (!probeTemplate || warmupClosed || account._warming) return;
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': probeTemplate.version };
176
- if (probeTemplate.beta) headers['anthropic-beta'] = probeTemplate.beta;
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(probeTemplate), signal: probe.signal,
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 || !probeTemplate || !probeTemplate._elicitsModelWeekly) return;
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
- system: t.system ?? null,
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: match by UUID first, then by name
467
- let idx = profile?.accountUuid
468
- ? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
469
- : -1;
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 = (prev.accountUuid && this.am.accounts.find(a => a.accountUuid === prev.accountUuid))
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
- // Two-phase (UUID first, then name): a single `uuid===c || name===c` predicate
535
- // could match an earlier same-name entry before the real UUID match.
536
- let cfgIdx = uuid ? this.config.accounts.findIndex(c => c.accountUuid === uuid) : -1;
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-first, then name an OR match could persist the
559
- // flag onto the wrong entry when a UUID and a name resolve to different accounts.
560
- const cfg = (amAcct.accountUuid && this.config.accounts.find(a => a.accountUuid === amAcct.accountUuid))
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 = (a.accountUuid && this.config.accounts.find(c => c.accountUuid === a.accountUuid))
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