teamclaude-cloud 1.8.0 → 1.9.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/src/index.js CHANGED
@@ -6,8 +6,8 @@ import { createInterface } from 'node:readline';
6
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
- import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
10
- import { cloudLogin, cloudValidateOwner, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull, cloudRefreshToken, cloudReportToken, cloudKeyCreate, cloudKeyList, cloudKeyRevoke, cloudKeyMove, cloudGroupList, cloudGroupCreate, cloudGroupRename, cloudGroupDelete, cloudGroupSetAccounts, cloudAccountSetEnabled, buildUsageFromQuota, mergePulledAccounts, resolveCloud, DEFAULT_CLOUD, keySource, reconcileRemovedAccounts } from './cloud.js';
9
+ import { importCredentials, loginOAuth, fetchProfile, isTokenExpiringSoon, normalizeExpiresAt } from './oauth.js';
10
+ import { cloudLogin, cloudValidateOwner, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull, cloudReportToken, cloudKeyCreate, cloudKeyList, cloudKeyRevoke, cloudKeyMove, cloudGroupList, cloudGroupCreate, cloudGroupRename, cloudGroupDelete, cloudGroupSetAccounts, cloudAccountSetEnabled, buildUsageFromQuota, mergePulledAccounts, resolveCloud, DEFAULT_CLOUD, isDefaultCloudUrl, keySource, reconcileRemovedAccounts, createCloudRefreshHandler, isCloudManaged } from './cloud.js';
11
11
  import { TUI } from './tui.js';
12
12
  import { maskIf, maskEmail, maskEmailsIn } from './mask.js';
13
13
 
@@ -191,7 +191,7 @@ async function serverCommand() {
191
191
 
192
192
  // Persist refreshed tokens back to config (re-read from disk to avoid clobbering
193
193
  // accounts added externally, e.g. by `teamclaude import` while server is running)
194
- accountManager.onTokenRefresh((idx, newTokens) => {
194
+ accountManager.onTokenRefresh((idx, newTokens, parentRefreshToken) => {
195
195
  const account = accountManager.accounts[idx];
196
196
  if (!account) return;
197
197
  // Keep the in-memory config.accounts in sync so a TUI saveConfig doesn't
@@ -204,7 +204,7 @@ async function serverCommand() {
204
204
  config.accounts[memIdx].refreshToken = newTokens.refreshToken;
205
205
  config.accounts[memIdx].expiresAt = newTokens.expiresAt;
206
206
  }
207
- atomicConfigUpdate(diskConfig => {
207
+ const persist = atomicConfigUpdate(diskConfig => {
208
208
  // Persist ONLY the refreshed account's tokens. We deliberately do NOT ingest
209
209
  // disk-only accounts here: that loop existed to keep the old INDEX-based
210
210
  // matching aligned, but matching is identity-based now (findConfigAccount),
@@ -214,9 +214,34 @@ async function serverCommand() {
214
214
  // Match by UUID first, then by name — index may have shifted.
215
215
  const cfgIdx = findConfigAccount(diskConfig, account);
216
216
  if (cfgIdx >= 0) {
217
- diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
218
- diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
219
- diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
217
+ // Freshest-wins: don't clobber a fresher token already on disk (a delayed T1
218
+ // callback after a concurrent pull/central refresh wrote a newer T2). Write when:
219
+ // • the token differs from disk (skip identical — no-op), AND
220
+ // • EITHER the disk still holds the exact PARENT we just rotated away
221
+ // (supersedesParent → our token is unambiguously newer, even at an equal expiry
222
+ // second — Anthropic invalidated that parent when newTokens was issued; this
223
+ // fixes a same-second sequential refresh: proactive T1 → 401-forced T2 within
224
+ // one expiry second leaving the invalid T1 on disk),
225
+ // • OR disk is not fresher-or-equal by expiry (the cross-process freshest-wins).
226
+ // The remaining ambiguous case (equal expiry, DIFFERENT token that is NOT our parent)
227
+ // is a concurrent write we keep — and it's benign anyway: a cloud-managed account
228
+ // refreshes via the CLOUD (never the local refreshToken), and a non-cloud account is
229
+ // single-PC so it has no such concurrent write.
230
+ // NORMALIZE both before comparing — OAuth endpoints return seconds (~1.7e9), Claude Code
231
+ // credentials use milliseconds (~1.7e12). A raw compare makes a genuinely NEWER disk
232
+ // token stored in seconds look older than an ms value and get clobbered (restoring an
233
+ // invalidated refresh chain). See CLAUDE.md "expiresAt may arrive in seconds or ms".
234
+ const diskExp = normalizeExpiresAt(diskConfig.accounts[cfgIdx].expiresAt);
235
+ const newExp = normalizeExpiresAt(newTokens.expiresAt);
236
+ const diskTok = diskConfig.accounts[cfgIdx].refreshToken;
237
+ const sameToken = diskTok === newTokens.refreshToken;
238
+ const supersedesParent = parentRefreshToken && diskTok === parentRefreshToken;
239
+ const diskIsFresher = diskExp && newExp && diskExp >= newExp;
240
+ if (!sameToken && (supersedesParent || !diskIsFresher)) {
241
+ diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
242
+ diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
243
+ diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
244
+ }
220
245
  }
221
246
  }).catch(err => console.error(`[TeamClaude] Failed to save refreshed token: ${err.message}`));
222
247
  // Admin auto-push: if this machine can push (has a cloud login session), upload
@@ -227,33 +252,25 @@ async function serverCommand() {
227
252
  // token valid, so remove/re-add from a group restores a WORKING token, not a
228
253
  // stale one. Best-effort: cloud problems never affect the proxy.
229
254
  maybePushRefreshedToken(config, account, newTokens);
255
+ // Return the PERSIST promise so the AccountManager awaits the rotated token reaching disk
256
+ // before its _refreshPromise resolves. Otherwise a concurrent `cloud push` — whose onReload
257
+ // drains _refreshPromise, then snapshots refreshPushSnapshot(loadConfig()) — could read the
258
+ // PRE-rotation token off disk and upload a now-dead chain (invalid_grant for every consumer,
259
+ // the exact multi-PC poison this file exists to prevent). Already .catch()'d, so it never
260
+ // rejects the refresh. maybePushRefreshedToken stays fire-and-forget (best-effort).
261
+ return persist;
230
262
  });
231
263
 
232
- // Cloud-centralized refresh: on a consumer (has a pull key), route token
233
- // refreshes through the cloud so the SAME account used from multiple PCs is not
234
- // rotated by each PC independently (which invalidates the others). The cloud does
235
- // the single rotation; this PC only receives the fresh access token. Falls back
236
- // to a local refresh if the cloud is unreachable (availability over strict
237
- // single-authority a brief local rotation is the accepted tradeoff).
238
- if (config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY) {
239
- accountManager.setRefreshHandler(async (account) => {
240
- const cloud = resolveCloud(config);
241
- const key = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
242
- if (key && account.accountUuid) {
243
- try {
244
- const t = await cloudRefreshToken(cloud, key, account.accountUuid);
245
- return {
246
- accessToken: t.accessToken,
247
- refreshToken: t.refreshToken || account.refreshToken,
248
- expiresAt: t.expiresAt,
249
- };
250
- } catch (err) {
251
- console.error(`[TeamClaude] Cloud refresh failed for "${account.name}", falling back to local: ${err.message}`);
252
- }
253
- }
254
- return refreshAccessToken(account.refreshToken); // local fallback
255
- });
256
- }
264
+ // Cloud-centralized refresh: route token refreshes for cloud-managed accounts
265
+ // through the cloud so the SAME account used from multiple PCs is rotated exactly
266
+ // ONCE (by the cloud), never independently by each PC (which invalidates the
267
+ // others). A cloud-managed account is NEVER rotated locally a local rotation
268
+ // would poison the token shared across PCs (Anthropic invalidates the previous
269
+ // refresh token on every rotation). See createCloudRefreshHandler for the full
270
+ // rationale. Non-cloud (locally-owned) accounts keep the local refresh. Set
271
+ // unconditionally: the handler internally decides per-account, so a pure-local
272
+ // install (no cloud config) behaves exactly as the default local refresh.
273
+ accountManager.setRefreshHandler(createCloudRefreshHandler(config));
257
274
 
258
275
  const port = config.proxy.port;
259
276
  const useTUI = process.stdout.isTTY && process.stdin.isTTY;
@@ -332,10 +349,19 @@ async function serverCommand() {
332
349
  return am ? { ...a, accessToken: am.credential, refreshToken: am.refreshToken, expiresAt: am.expiresAt } : a;
333
350
  });
334
351
  if (!accounts.length) throw new Error('No OAuth accounts to push');
352
+ const _pushLock = await acquirePushLock(); // serialize with any CLI/TUI push (no concurrent-share race)
353
+ try {
335
354
  // Prefer the AUTH KEY (push to your own tenant, no expiring owner login) —
336
355
  // it CREATES + updates accounts. Fall back to an owner session only if no key.
337
356
  const fresh = (await loadConfig()) || config;
338
- const key = fresh.cloud?.pullKey || config.cloud?.pullKey;
357
+ // Derive the key ONLY from fresh disk (endpoint-consistent) + the endpoint-bound env key.
358
+ // NEVER fall back to the live in-memory config.cloud.pullKey: if the endpoint was just
359
+ // retargeted (e.g. to an attacker URL) its keys are cleared on disk, but the stale live
360
+ // key would then be POSTed to the NEW `resolveCloud(fresh)` endpoint below — key + token
361
+ // exfiltration. envAuthKey is default-URL-only, so it can't leak to a custom endpoint.
362
+ const key = fresh.cloud?.pullKey
363
+ || (fresh.cloud?.pullKeys && Object.values(fresh.cloud.pullKeys).find(Boolean))
364
+ || envAuthKey(fresh);
339
365
  const usageByUuid = new Map();
340
366
  const quotaCache = await readQuotaCache();
341
367
  for (const entry of quotaCache?.accounts || []) {
@@ -343,14 +369,61 @@ async function serverCommand() {
343
369
  const usage = buildUsageFromQuota(entry);
344
370
  if (usage) usageByUuid.set(entry.accountUuid, usage);
345
371
  }
346
- let cloud, authKey = null;
347
- if (key) { cloud = resolveCloud(fresh); authKey = key; }
348
- else {
349
- cloud = await ensureCloudSession(fresh);
350
- if (!cloud?.session?.access_token) throw new Error('No auth key and not logged in — pull once with a key, or run `teamclaude cloud login`');
372
+ // Require an auth key: an owner-session-only push would mark accounts cloud-shared
373
+ // with NO key to refresh them they go permanently unavailable at expiry (see the
374
+ // CLI cloudPushCommand rationale). Refuse rather than share un-refreshable accounts.
375
+ if (!key) throw new Error('cloud push에는 auth key가 필요합니다 — 공유 계정은 키가 있어야 갱신됩니다. `teamclaude cloud pull --key <key>` 로 키를 받으세요.');
376
+ const cloud = resolveCloud(fresh);
377
+ const authKey = key;
378
+ // Protect these accounts BEFORE uploading them (mark cloud-shared + propagate to
379
+ // the live fleet via reload), THEN push — same protect-then-share + CAS-revert-on-
380
+ // definitive-4xx contract as the CLI `cloud push` path (markAccountsCloudPushed /
381
+ // unmarkAccountsCloudPushed). Without this a bad-key/rejected push would leave the
382
+ // (unshared) accounts permanently cloud-managed and unrefreshable.
383
+ const mark = await markAccountsCloudPushed(config, accounts);
384
+ // Fail closed if a running proxy could not be protected before sharing (else it
385
+ // would local-rotate the now-shared token — poison). abortPushIfUnprotected reverts
386
+ // the marks + logs; throw here so the TUI push action surfaces the failure (can't
387
+ // process.exit inside the live server).
388
+ if (await abortPushIfUnprotected(config, mark)) {
389
+ throw new Error('보호 실패로 푸시 중단 — `teamclaude restart` 후 재시도하세요');
390
+ }
391
+ // In-flight-refresh drain: protection above made these accounts cloudManaged so the
392
+ // proxy starts NO new local rotations — but a rotation that BEGAN just before
393
+ // protection may still be settling. Await those so the snapshot below can't publish a
394
+ // T0 the proxy is about to invalidate by rotating to T1 (which would poison the cloud
395
+ // with T0). In-process only; the CLI path relies on the cloud freshest-wins + the
396
+ // cloudManaged-gated maybePushRefreshedToken re-push (see known limitation in docs).
397
+ await Promise.all(accountManager.accounts.map(a => a._refreshPromise).filter(Boolean));
398
+ // TOCTOU guard: re-read the freshest LIVE tokens AFTER protection + drain. account-
399
+ // Manager is in-process here, so its credentials are the freshest — no disk-write lag.
400
+ const pushAccounts = accounts.map(a => {
401
+ const am = findAccount(accountManager.accounts, a);
402
+ // Removed from the live fleet (concurrent revocation/removal) → omit, don't
403
+ // resurrect a revoked account by re-sharing the stale snapshot object.
404
+ return am ? { ...a, accessToken: am.credential, refreshToken: am.refreshToken, expiresAt: am.expiresAt } : null;
405
+ }).filter(Boolean);
406
+ let r;
407
+ try {
408
+ r = await cloudPush(cloud, pushAccounts, usageByUuid, authKey);
409
+ } catch (err) {
410
+ if (isDefinitivePushRejection(err)) await revertMarksAfterRejection(config, mark, cloud, authKey);
411
+ throw err;
412
+ }
413
+ // Reconcile not-stored skips (key path only — pull needs a key): drop the
414
+ // cloudPushed mark from any account the key can't actually manage, so it isn't
415
+ // left falsely cloud-managed and unrefreshable. Keeps staler skips (cloud-stored).
416
+ if (authKey && mark && mark.pushed?.length) {
417
+ try {
418
+ const { accounts: pulledBack } = await cloudPull(cloud, authKey);
419
+ const confirmed = new Set((pulledBack || []).map(a => a.accountUuid).filter(Boolean));
420
+ await reconcileCloudPushedMarks(config, mark, confirmed, authKey);
421
+ } catch { /* transient re-pull failure — keep marks (dominant skip=staler is correct) */ }
351
422
  }
352
- const r = await cloudPush(cloud, accounts, usageByUuid, authKey);
353
423
  return { summary: `${r.pushed} new, ${r.updated} updated, ${r.skipped} skipped${usageByUuid.size ? `, usage ${r.usage ?? 0}` : ''}${authKey ? ' (via auth key)' : ''}` };
424
+ } finally {
425
+ releasePushLock(_pushLock);
426
+ }
354
427
  }) : undefined,
355
428
  });
356
429
  hooks = {
@@ -368,7 +441,76 @@ async function serverCommand() {
368
441
  // route BOTH through the one serialized applyDiskToFleet chain (below) so they can't
369
442
  // interleave — an older apply must not reconcile a removal while a newer one re-adds
370
443
  // from a stale snapshot (Codex HIGH). Each run re-reads disk fresh.
371
- hooks.onReload = () => applyDiskToFleet(config, accountManager);
444
+ // Start the cloud live-sync + recovery timers once cloud config exists. Callable from
445
+ // startup AND onReload so a server that began PURE-LOCAL (no config.cloud at all) still
446
+ // starts enforcing revocation after a later `cloud pull --key` adds cloud config —
447
+ // otherwise it would serve the pulled accounts indefinitely without polling for
448
+ // revocation (auth-revocation bypass until restart). Idempotent (guard flag).
449
+ // Manage each timer's lifecycle across reloads WITHOUT one-shot "started" flags (a proxy that
450
+ // began with an interval of 0 created no timer; a one-shot flag would block a later reload from
451
+ // ever starting it) — but ALSO without live restart-on-value-change: tearing down and recreating
452
+ // a running loop leaves the OLD loop's in-flight async tick racing the new one (it can merge
453
+ // OLD-endpoint results into a retargeted config). So we only START (0→positive) and STOP
454
+ // (positive→0). An interval VALUE change (e.g. 30s→60s, both positive) applies on the next
455
+ // restart — it's a tuning knob, not a security boundary; only the →0 opt-out must be live.
456
+ let _stopCloudSync = null;
457
+ let _cloudExitHooked = false;
458
+ let _recoverTimer = null;
459
+ function ensureCloudTimers() {
460
+ // Start when there's a cloud config OR an ambient env auth key that authorizes the DEFAULT
461
+ // endpoint — an env-only deployment (TEAMCLAUDE_AUTH_KEY, no config.cloud) must still
462
+ // live-sync so key revocation / group changes are polled and enforced. envAuthKey returns
463
+ // the env key only for the default endpoint (never for a custom URL), so this can't arm the
464
+ // loop to POST an env key to an attacker endpoint.
465
+ if (!config.cloud && !envAuthKey(config)) return;
466
+ const cloudKey = config.cloud?.pullKey
467
+ || (config.cloud?.pullKeys && Object.values(config.cloud.pullKeys).find(Boolean))
468
+ || envAuthKey(config); // endpoint-bound: never send the env key to a custom URL
469
+ const cloudSyncIntervalMs = Number.isFinite(config.cloudSyncIntervalMs)
470
+ ? Math.max(0, config.cloudSyncIntervalMs) : 30_000;
471
+ if (cloudSyncIntervalMs > 0 && !_stopCloudSync) {
472
+ _stopCloudSync = startCloudSyncLoop(config, accountManager, cloudKey, cloudSyncIntervalMs);
473
+ if (!_cloudExitHooked) { _cloudExitHooked = true; process.on('exit', () => { try { _stopCloudSync?.(); } catch { /* nothing to do on exit */ } }); }
474
+ console.log(`[TeamClaude] Cloud live-sync every ${Math.round(cloudSyncIntervalMs / 1000)}s (token/group changes + key revocation apply automatically)`);
475
+ } else if (cloudSyncIntervalMs === 0 && _stopCloudSync) {
476
+ // Opt-out took effect on a live reload — stop at once (stop() also aborts an in-flight tick).
477
+ _stopCloudSync();
478
+ _stopCloudSync = null;
479
+ console.log('[TeamClaude] Cloud live-sync disabled (cloudSyncIntervalMs=0) — applied to the running server.');
480
+ }
481
+ const recoverIntervalMs = Number.isFinite(config.recoverIntervalMs)
482
+ ? Math.max(0, config.recoverIntervalMs) : 300_000;
483
+ if (recoverIntervalMs > 0 && !_recoverTimer) {
484
+ _recoverTimer = setInterval(() => {
485
+ for (const account of accountManager.accounts) {
486
+ if (account.status === 'error' && account.type === 'oauth'
487
+ && (account.cloudManaged === true || isCloudManaged(config, account))) {
488
+ accountManager.ensureTokenFresh(account, true).catch(() => { /* stays error until the cloud token heals */ });
489
+ }
490
+ }
491
+ }, recoverIntervalMs);
492
+ _recoverTimer.unref();
493
+ } else if (recoverIntervalMs === 0 && _recoverTimer) {
494
+ clearInterval(_recoverTimer); // live opt-out (the recovery re-probe is a plain idempotent timer)
495
+ _recoverTimer = null;
496
+ }
497
+ }
498
+
499
+ // Live-reload hook (both TUI and headless): a local CLI edit POSTs /teamclaude/reload
500
+ // so its change applies to THIS running server immediately. Also (re)start the cloud
501
+ // timers if the reload just introduced cloud config.
502
+ hooks.onReload = async () => {
503
+ const r = await applyDiskToFleet(config, accountManager);
504
+ ensureCloudTimers();
505
+ // Drain in-flight token refreshes before responding. A cross-process `cloud push`
506
+ // (via noteRunningServerReload → this endpoint, which the server awaits) can't await
507
+ // the proxy's _refreshPromise itself; by waiting it out HERE, the push's post-reload
508
+ // snapshot reads the ALREADY-rotated token on disk instead of a T0 the proxy is about
509
+ // to invalidate (push/refresh race). applyDiskToFleet already applied any cloudPushed
510
+ // protection, so no NEW local rotation starts — this just settles the one in flight.
511
+ await Promise.all(accountManager.accounts.map(a => a._refreshPromise).filter(Boolean));
512
+ return r;
513
+ };
372
514
 
373
515
  // If a TeamClaude server is already running on this config's port, don't try to
374
516
  // bind on top of it — point the user at stop/restart instead of a raw EADDRINUSE.
@@ -415,19 +557,16 @@ async function serverCommand() {
415
557
  process.on('exit', saveQuotaSnapshot);
416
558
  setInterval(saveQuotaSnapshot, 60_000).unref();
417
559
 
418
- // Live cloud sync: if this proxy was populated from the cloud (a stored pull
419
- // key), keep it in sync with the cloud while running — token/group changes and
420
- // key revocation apply automatically, no restart. Polling (zero-dep); cloud
421
- // outage is non-blocking (transient errors keep the last-known accounts).
422
- const cloudKey = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
423
- const cloudSyncIntervalMs = Number.isFinite(config.cloudSyncIntervalMs)
424
- ? Math.max(0, config.cloudSyncIntervalMs)
425
- : 30_000; // 0 disables
426
- if (cloudKey && cloudSyncIntervalMs > 0) {
427
- const stopCloudSync = startCloudSyncLoop(config, accountManager, resolveCloud(config), cloudKey, cloudSyncIntervalMs);
428
- process.on('exit', () => { try { stopCloudSync(); } catch { /* nothing to do on exit */ } });
429
- console.log(`[TeamClaude] Cloud live-sync every ${Math.round(cloudSyncIntervalMs / 1000)}s (token/group changes + key revocation apply automatically)`);
430
- }
560
+ // The auth key that also drives the usage sync below (usage report uses the same key
561
+ // as pull/refresh). Derive from pullKeys too: if the last-used pullKey was revoked
562
+ // while OTHER keys remain, cloud.pullKey is cleared but those keys still manage
563
+ // accounts. The cloud live-sync + revocation-recovery timers themselves are started
564
+ // by ensureCloudTimers() (defined near onReload) — a single idempotent path so a proxy
565
+ // that began PURE-LOCAL starts them after a later `cloud pull --key` without a restart.
566
+ const cloudKey = config.cloud?.pullKey
567
+ || (config.cloud?.pullKeys && Object.values(config.cloud.pullKeys).find(Boolean))
568
+ || envAuthKey(config); // endpoint-bound: never send the env key to a custom URL
569
+ ensureCloudTimers();
431
570
 
432
571
  // Live usage sync: the proxies that actually USE the accounts report the live
433
572
  // 5h/7d/Fable numbers they observe, so the dashboard reflects real-time usage —
@@ -454,11 +593,23 @@ async function serverCommand() {
454
593
  if (!Object.keys(usageByUuid).length) return; // nothing measured yet
455
594
  const snapshot = JSON.stringify(usageByUuid);
456
595
  if (snapshot === lastUsageJson) return; // unchanged since last report — skip
457
- if (cloudKey) {
458
- await cloudReportUsage(resolveCloud(config), cloudKey, usageByUuid);
596
+ // Resolve the endpoint AND the key FRESH each tick — NEVER reuse the key captured at
597
+ // startup. A live `cloud login --url <new>` / `cloud pull --url <new>` retargets
598
+ // config.cloud (and its endpoint-change handling drops the OLD endpoint's keys), so a
599
+ // captured key would be POSTed as x-teamclaude-key to the NEW (possibly attacker) URL
600
+ // — key exfiltration. envAuthKey is endpoint-bound (default URL only), so the derived
601
+ // key is always valid for the current endpoint.
602
+ const cloud = resolveCloud(config);
603
+ const curKey = config.cloud?.pullKey
604
+ || (config.cloud?.pullKeys && Object.values(config.cloud.pullKeys).find(Boolean))
605
+ || envAuthKey(config);
606
+ if (curKey) {
607
+ await cloudReportUsage(cloud, curKey, usageByUuid);
608
+ } else if (config.cloud?.session?.access_token) {
609
+ const s = await ensureCloudSession(config).catch(() => config.cloud);
610
+ await cloudPushUsage(s, usageByUuid);
459
611
  } else {
460
- const cloud = await ensureCloudSession(config).catch(() => config.cloud);
461
- await cloudPushUsage(cloud, usageByUuid);
612
+ return; // no credential valid for the current endpoint — nothing to report
462
613
  }
463
614
  lastUsageJson = snapshot;
464
615
  } catch (err) {
@@ -892,7 +1043,7 @@ async function statusCommand() {
892
1043
  const current = acct.name === data.currentAccount ? ' *' : '';
893
1044
 
894
1045
  const disabledTag = acct.enabled === false ? ' [disabled]' : '';
895
- const privateTag = acct.isPrivate ? ' [private]' : '';
1046
+ const privateTag = acct.isPrivate ? ' [🔑 private]' : '';
896
1047
  console.log(` ${maskIf(acct.name, config.maskMode)} (${acct.type})${current}${privateTag}${disabledTag}`);
897
1048
  console.log(` Status: ${acct.status}${acct.enabled === false ? ' (disabled — out of rotation)' : ''}`);
898
1049
  if (acct.priority != null) console.log(` Priority: ${acct.priority} (lower = preferred)`);
@@ -943,22 +1094,59 @@ async function accountsCommand() {
943
1094
  return;
944
1095
  }
945
1096
 
946
- // Refresh expired tokens before fetching profiles
947
- let configDirty = false;
1097
+ // Refresh expiring tokens before fetching profiles — route through the cloud refresh
1098
+ // handler, NOT a raw local refreshAccessToken(): a cloud-managed (shared) account must
1099
+ // be refreshed via the cloud, never rotated locally, or this command would poison the
1100
+ // token shared across PCs (the exact bug this change prevents). The handler local-
1101
+ // refreshes only non-cloud accounts; a cloud-managed refresh that fails just throws and
1102
+ // is skipped here (fetchProfile then reports the expired-token error) — no rotation.
1103
+ const acctRefresh = createCloudRefreshHandler(config);
1104
+ const refreshed = [];
948
1105
  await Promise.all(config.accounts.map(async (a) => {
949
1106
  if (a.type !== 'oauth' || !a.refreshToken) return;
950
1107
  if (!isTokenExpiringSoon(a.expiresAt)) return;
951
1108
  try {
952
- const newTokens = await refreshAccessToken(a.refreshToken);
953
- a.accessToken = newTokens.accessToken;
1109
+ const parentRefreshToken = a.refreshToken; // the token we're rotating away (parent)
1110
+ const newTokens = await acctRefresh(a);
1111
+ a.accessToken = newTokens.accessToken; // in-memory only, for the profile fetch below
954
1112
  a.refreshToken = newTokens.refreshToken;
955
1113
  a.expiresAt = newTokens.expiresAt;
956
- configDirty = true;
1114
+ refreshed.push({ account: a, newTokens, parentRefreshToken });
957
1115
  } catch {
958
- // refresh failed fetchProfile will report the specific error
1116
+ // refresh failed (or cloud-managed + cloud unreachable deliberately NOT rotated
1117
+ // locally) — fetchProfile will report the specific error
959
1118
  }
960
1119
  }));
961
- if (configDirty) await saveConfig(config);
1120
+ // Persist ONLY the refreshed accounts' tokens, matched by identity (UUID→name) never
1121
+ // saveConfig the whole in-memory copy: a concurrent `cloud pull` may have pruned a
1122
+ // revoked account or written a cloudPushed provenance mark, and a full-config overwrite
1123
+ // would resurrect/undo that (→ a later local rotation of a shared token after restart).
1124
+ if (refreshed.length) {
1125
+ await atomicConfigUpdate(diskConfig => {
1126
+ for (const { account, newTokens, parentRefreshToken } of refreshed) {
1127
+ const i = findConfigAccount(diskConfig, account);
1128
+ if (i < 0) continue;
1129
+ // Freshest-wins with the same parent-token exception as the server's onTokenRefresh
1130
+ // persist: skip identical tokens (no-op); overwrite when disk still holds the exact
1131
+ // PARENT we just rotated away (our token is then unambiguously newer even at an equal
1132
+ // expiry second); otherwise keep a disk token that's fresher-or-equal by expiry (a
1133
+ // concurrent `cloud pull`/other PC wrote it). Anthropic invalidates the previous
1134
+ // refresh token on rotation, so writing a staler chain can brick the account.
1135
+ // Normalize both (seconds vs ms) before comparing — else a newer seconds-based disk token
1136
+ // is misread as stale and a staler chain overwrites it (bricking risk). See CLAUDE.md.
1137
+ const diskExp = normalizeExpiresAt(diskConfig.accounts[i].expiresAt);
1138
+ const newExp = normalizeExpiresAt(newTokens.expiresAt);
1139
+ const diskTok = diskConfig.accounts[i].refreshToken;
1140
+ if (diskTok === newTokens.refreshToken) continue; // already identical
1141
+ const supersedesParent = parentRefreshToken && diskTok === parentRefreshToken;
1142
+ const diskIsFresher = diskExp && newExp && diskExp >= newExp;
1143
+ if (!supersedesParent && diskIsFresher) continue;
1144
+ diskConfig.accounts[i].accessToken = newTokens.accessToken;
1145
+ diskConfig.accounts[i].refreshToken = newTokens.refreshToken;
1146
+ diskConfig.accounts[i].expiresAt = newTokens.expiresAt;
1147
+ }
1148
+ });
1149
+ }
962
1150
 
963
1151
  // Fetch profiles in parallel for all OAuth accounts
964
1152
  const profiles = await Promise.all(
@@ -1113,9 +1301,26 @@ async function removeCommand() {
1113
1301
  // ── enable / disable / priority ─────────────────────────────
1114
1302
 
1115
1303
  /** Note that changes apply to a running server only after a reload/restart. */
1304
+ // Returns { serverFound, reloaded }: serverFound=false means no proxy is running (nothing
1305
+ // to protect); serverFound=true+reloaded=false means a proxy IS running but did NOT pick
1306
+ // up the just-written config — callers that share tokens (cloud push) must fail closed on
1307
+ // that, or the live proxy keeps cloudManaged=false and could local-rotate a now-shared
1308
+ // token (protect-then-share bypass). Existing callers that ignore the return are unaffected.
1309
+ // The ambient env auth key (TEAMCLAUDE_AUTH_KEY) carries no endpoint binding, so it's
1310
+ // presumed issued for the DEFAULT public cloud. Offer it ONLY when the resolved endpoint IS
1311
+ // the default — never auto-send it to a custom/self-hosted URL the config points at: a
1312
+ // tricked `cloud pull --url <attacker>` (or a config edit) would otherwise exfiltrate the
1313
+ // key via the background sync/refresh/usage/auto-push loops. Custom clouds bind their key
1314
+ // per-endpoint via `cloud pull --key` (endpoint-scoped pullKeys), which these loops use.
1315
+ function envAuthKey(config) {
1316
+ const k = process.env.TEAMCLAUDE_AUTH_KEY;
1317
+ if (!k) return undefined;
1318
+ return isDefaultCloudUrl(resolveCloud(config).url) ? k : undefined; // trailing-slash tolerant
1319
+ }
1320
+
1116
1321
  async function noteRunningServerReload(config) {
1117
1322
  const running = await findRunningServer(config).catch(() => null);
1118
- if (!running) return;
1323
+ if (!running) return { serverFound: false, reloaded: false };
1119
1324
  // Tell the running server to apply the just-written config change RIGHT NOW
1120
1325
  // (localhost-only endpoint), instead of waiting for its next cloud-sync tick.
1121
1326
  try {
@@ -1125,10 +1330,11 @@ async function noteRunningServerReload(config) {
1125
1330
  if (res.ok) {
1126
1331
  await res.json().catch(() => ({}));
1127
1332
  console.log(' Applied to the running server immediately.');
1128
- return;
1333
+ return { serverFound: true, reloaded: true };
1129
1334
  }
1130
1335
  } catch { /* server unreachable mid-shutdown — fall back to the manual hint */ }
1131
1336
  console.log(' A server is running — apply with: teamclaude restart (or "R" in the TUI).');
1337
+ return { serverFound: true, reloaded: false };
1132
1338
  }
1133
1339
 
1134
1340
  async function setEnabledCommand(enabled) {
@@ -1292,6 +1498,15 @@ async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
1292
1498
  const prev = config.accounts[idx];
1293
1499
  if (prev.enabled !== undefined) account.enabled = prev.enabled;
1294
1500
  if (prev.priority !== undefined) account.priority = prev.priority;
1501
+ // CRITICAL: preserve cloud provenance/display too. A re-auth/re-import replaces the TOKEN, not
1502
+ // the account's cloud-sharing status. Dropping cloudSources/cloudPushed would make a
1503
+ // cloud-managed (shared) account look LOCAL after restart → the refresh handler would then
1504
+ // rotate its now-shared refresh token LOCALLY and poison the chain for every other PC (the
1505
+ // exact multi-PC invalid_grant failure the single-authority design exists to prevent), and it
1506
+ // would no longer be held on key revocation. cloudPrivate is a display flag, preserved likewise.
1507
+ if (Array.isArray(prev.cloudSources) && prev.cloudSources.length) account.cloudSources = prev.cloudSources;
1508
+ if (prev.cloudPushed) account.cloudPushed = prev.cloudPushed;
1509
+ if (prev.cloudPrivate) account.cloudPrivate = prev.cloudPrivate;
1295
1510
  config.accounts[idx] = account;
1296
1511
  console.log(`Updated account "${name}"`);
1297
1512
  } else {
@@ -1343,19 +1558,38 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1343
1558
  // `teamclaude disable`/`priority` set while the server runs. setEnabled drains
1344
1559
  // the overflow queue when re-enabling so a freed-up account is used at once.
1345
1560
  if (mgr) {
1346
- const wantEnabled = diskAcct.enabled !== false;
1561
+ // `diskEnabled` is what disk PERSISTS (user/CLI intent). `wantEnabled` is the LIVE target:
1562
+ // a live-only revocation hold (`_revHeld`, set by the sync loop when a key managing this
1563
+ // account 403s) forces it disabled WITHOUT touching disk — otherwise an UNRELATED key's
1564
+ // successful pull (which runs this reconcile) would re-enable an account solely authorized
1565
+ // by a still-revoked key (auth-revocation bypass). The hold is cleared ONLY by
1566
+ // clearRevocationHold when an AUTHORIZING source's own pull succeeds. The two are kept
1567
+ // SEPARATE so the hold is applied to the live fleet but NEVER mirrored to persistable
1568
+ // config (see the memAcct mirror below).
1569
+ const diskEnabled = diskAcct.enabled !== false;
1570
+ const wantEnabled = diskEnabled && !mgr._revHeld;
1347
1571
  if (mgr.enabled !== wantEnabled) accountManager.setEnabled(mgr, wantEnabled);
1348
1572
  const diskPriority = Number.isFinite(diskAcct.priority) ? Math.floor(diskAcct.priority) : null;
1349
1573
  if (mgr.priority !== diskPriority) accountManager.setPriority(mgr, diskPriority);
1350
1574
  // Provenance can change while running (a cloud pull moves an account in/out of
1351
1575
  // a personal group), so refresh the private flag on reload too.
1352
1576
  mgr.isPrivate = diskAcct.cloudPrivate === true;
1577
+ // Same for cloud-management provenance — keep the live-object flag in step with
1578
+ // disk so a pull (cloudSources) or push (cloudPushed) that starts sharing an
1579
+ // account is reflected. Either provenance means the token is cloud-shared.
1580
+ mgr.cloudManaged = (Array.isArray(diskAcct.cloudSources) && diskAcct.cloudSources.length > 0) || !!diskAcct.cloudPushed;
1353
1581
  // Mirror the applied state into the in-memory config copy too. Otherwise a
1354
1582
  // later TUI saveConfig (for any unrelated op) would spread the pre-sync
1355
1583
  // enabled/priority over the disk value and silently revert a CLI change.
1584
+ // Mirror DISK's OWN enabled (`diskEnabled`), NOT the `_revHeld`-adjusted live value — the
1585
+ // revocation hold is LIVE-ONLY and must never be persisted: a TUI saveConfig writing
1586
+ // enabled:false would convert a transient 403 hold into a durable disable, and
1587
+ // mergePulledAccounts would then seed that into localDisabled (sticky forever, breaking
1588
+ // recovery). clearRevocationHold reads this mem value, so keeping it at disk's truth lets
1589
+ // an authorizing recovery re-enable correctly.
1356
1590
  const memAcct = memConfig.accounts[memIdx];
1357
1591
  if (memAcct) {
1358
- if (wantEnabled) delete memAcct.enabled; else memAcct.enabled = false;
1592
+ if (diskEnabled) delete memAcct.enabled; else memAcct.enabled = false;
1359
1593
  if (diskPriority === null) delete memAcct.priority; else memAcct.priority = diskPriority;
1360
1594
  // cloudSources (prune scoping) and cloudPrivate (personal-group display) are
1361
1595
  // both authoritative on disk — a cloud pull tags/untags them. Mirror both, or
@@ -1371,6 +1605,15 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1371
1605
  } else {
1372
1606
  delete memAcct.cloudPrivate;
1373
1607
  }
1608
+ // cloudPushed (explicit `cloud push` provenance) is authoritative on disk too;
1609
+ // mirror the VALUE (a per-push generation marker or true) so it survives a later
1610
+ // saveConfig, keeps the account cloud-managed, and preserves the generation used
1611
+ // by a pending push's compare-and-swap revert.
1612
+ if (diskAcct.cloudPushed) {
1613
+ memAcct.cloudPushed = diskAcct.cloudPushed;
1614
+ } else {
1615
+ delete memAcct.cloudPushed;
1616
+ }
1374
1617
  }
1375
1618
  }
1376
1619
 
@@ -1418,8 +1661,11 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1418
1661
  mgr.refreshToken !== freshCred.refreshToken;
1419
1662
  // Don't overwrite in-memory credentials with staler ones from disk
1420
1663
  // (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
1421
- const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
1422
- freshCred.expiresAt < mgr.expiresAt;
1664
+ // Normalize both (seconds vs ms) before comparing — a seconds-based disk value would else
1665
+ // always look "staler" than an ms live value and never apply a genuinely newer disk token.
1666
+ const freshExpN = normalizeExpiresAt(freshCred.expiresAt);
1667
+ const mgrExpN = normalizeExpiresAt(mgr.expiresAt);
1668
+ const diskIsStaler = freshExpN && mgrExpN && freshExpN < mgrExpN;
1423
1669
  if (changed && !diskIsStaler) {
1424
1670
  accountManager.updateAccountTokens(mgr.index, freshCred);
1425
1671
  console.log(`[TeamClaude] Refreshed credentials for "${mgr.name}"`);
@@ -1430,6 +1676,74 @@ async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
1430
1676
  console.log(`[TeamClaude] Updated API key for "${mgr.name}"`);
1431
1677
  }
1432
1678
  }
1679
+ // Keep the running config's cloud keys in step with disk so the refresh handler
1680
+ // (which closed over this config object) selects the right per-account key after
1681
+ // ANOTHER CLI pulls a new key mid-run. Without this, a freshly-pulled key's accounts
1682
+ // would keep refreshing with the stale in-memory key and 403 until restart. Merge
1683
+ // (never drop) pullKeys — a key still authorizes its accounts until explicitly revoked.
1684
+ if (diskConfig.cloud && typeof diskConfig.cloud === 'object') {
1685
+ memConfig.cloud = memConfig.cloud || {};
1686
+ // Mirror the cloud ENDPOINT (url/anonKey) too, not just the keys. A server that began
1687
+ // PURE-LOCAL has no memConfig.cloud.url; if a later `cloud pull --key` targeted a
1688
+ // CUSTOM/self-hosted cloud, copying only the key would leave resolveCloud() falling back
1689
+ // to DEFAULT_CLOUD — and the sync/refresh loops would then POST the freshly-pulled key
1690
+ // to the wrong (default) server, leaking it. Copy the endpoint the key was pulled from.
1691
+ // 🔒 If the endpoint CHANGED (a pull retargeted a different cloud URL), REPLACE the live
1692
+ // key set with disk's instead of merging — merging would keep keys issued for the OLD
1693
+ // endpoint and the sync loop would then POST them to the NEW endpoint (key exfiltration).
1694
+ // cloudPullCommand already dropped old-endpoint keys on disk for this case; mirror that.
1695
+ // An ABSENT url (either side) = the implicit DEFAULT endpoint — normalize BOTH before
1696
+ // comparing so an endpoint change is detected in EITHER direction: legacy/default (no url)
1697
+ // → custom, AND custom → default via a url REMOVAL. Without normalizing the disk side, a
1698
+ // config whose custom `cloud.url` is deleted keeps its old custom keys live, and the next
1699
+ // sync/usage/refresh tick (now resolving DEFAULT) POSTs those custom-endpoint keys to the
1700
+ // default server — key exfiltration.
1701
+ const diskUrlN = typeof diskConfig.cloud.url === 'string' ? diskConfig.cloud.url : DEFAULT_CLOUD.url;
1702
+ const memUrlN = memConfig.cloud.url || DEFAULT_CLOUD.url;
1703
+ const endpointChanged = memUrlN !== diskUrlN;
1704
+ if (typeof diskConfig.cloud.url === 'string') memConfig.cloud.url = diskConfig.cloud.url;
1705
+ else if (endpointChanged) delete memConfig.cloud.url; // disk removed url → mem back to implicit default
1706
+ if (typeof diskConfig.cloud.anonKey === 'string') memConfig.cloud.anonKey = diskConfig.cloud.anonKey;
1707
+ // Mirror the singular pullKey — SET it when disk has one, but also DELETE a stale live one
1708
+ // when disk intentionally removed it on an endpoint change (cloud login/pull --url clears
1709
+ // old-endpoint keys). Without the delete, a retargeted proxy keeps the OLD key live and the
1710
+ // usage/live-sync/refresh loops POST it to the NEW (possibly attacker) URL — key exfiltration.
1711
+ if (typeof diskConfig.cloud.pullKey === 'string') memConfig.cloud.pullKey = diskConfig.cloud.pullKey;
1712
+ else if (endpointChanged) delete memConfig.cloud.pullKey;
1713
+ if (diskConfig.cloud.pullKeys && typeof diskConfig.cloud.pullKeys === 'object') {
1714
+ memConfig.cloud.pullKeys = endpointChanged
1715
+ ? { ...diskConfig.cloud.pullKeys }
1716
+ : { ...(memConfig.cloud.pullKeys || {}), ...diskConfig.cloud.pullKeys };
1717
+ } else if (endpointChanged) {
1718
+ memConfig.cloud.pullKeys = {};
1719
+ }
1720
+ // Drop the endpoint-scoped owner session (JWT) on an endpoint change, so the live
1721
+ // auto-push/refresh loops can't send the OLD endpoint's owner JWT to the NEW URL.
1722
+ // (cloudPullCommand already cleared it on disk for this case; mirror that to the fleet.)
1723
+ if (endpointChanged) delete memConfig.cloud.session;
1724
+ // Bump the cloud-config GENERATION whenever the endpoint OR the key set changes. An in-flight
1725
+ // cloud-sync pull captures this generation at its start and re-checks it inside the atomic
1726
+ // merge; a bare URL check would MISS an A→B→A retarget (URL returns to A) or a same-endpoint
1727
+ // key change, letting a delayed old-scope allow-set merge and re-enable obsolete-scope
1728
+ // accounts (Codex). Fingerprint = url + sorted key sources + pullKey.
1729
+ const _fp = (memConfig.cloud.url || '') + '|'
1730
+ + Object.keys(memConfig.cloud.pullKeys || {}).sort().join(',') + '|'
1731
+ + (memConfig.cloud.pullKey || '');
1732
+ if (_fp !== _cloudIdentityFp) { _cloudIdentityFp = _fp; _cloudConfigGen++; }
1733
+ // A key may have just become available (e.g. an owner-session server that ran
1734
+ // `cloud pull --key …`). Immediately re-probe cloud-managed accounts stuck in
1735
+ // 'error' so they recover NOW — even if the pull returned the same (still-expired)
1736
+ // access token (changed === false), ensureTokenFresh refreshes through the cloud
1737
+ // with the newly-authorized key and clears the error, without waiting for the
1738
+ // recovery timer's next tick or a restart. Coalesced + best-effort.
1739
+ if (memConfig.cloud.pullKey || (memConfig.cloud.pullKeys && Object.keys(memConfig.cloud.pullKeys).length)) {
1740
+ for (const mgr of accountManager.accounts) {
1741
+ if (mgr.status === 'error' && mgr.type === 'oauth' && mgr.cloudManaged === true) {
1742
+ accountManager.ensureTokenFresh(mgr, true).catch(() => { /* stays error until a key/cloud recovers */ });
1743
+ }
1744
+ }
1745
+ }
1746
+ }
1433
1747
  return added;
1434
1748
  }
1435
1749
 
@@ -1453,46 +1767,232 @@ function applyDiskToFleet(config, accountManager) {
1453
1767
 
1454
1768
  // ── live cloud sync (running server ⇄ cloud) ────────────────
1455
1769
 
1770
+ // Revocation policy — a 403 NEVER triggers a destructive action, only a reversible one.
1771
+ // A 403 is inherently ambiguous: a real key revocation, OR an auth/policy outage, a buggy
1772
+ // custom endpoint, or a server regression. Pruning accounts (or deleting the stored key) on
1773
+ // a 403 would be irreversible local credential loss on a mere outage — the shared
1774
+ // refresh-token chain can't be rebuilt without a re-pull (irreversible-mutation-uncertain-
1775
+ // outcome; external-probe-gate-classification: 401/403 = HOLD, never a definitive verdict).
1776
+ // So on ANY 403 we ONLY DISABLE the key's accounts (a reversible, live-only hold):
1777
+ // • transient/outage → the key recovers, a successful pull lifts the hold, accounts resume;
1778
+ // • genuine revocation → accounts stay disabled (safe — not serving) and their tokens
1779
+ // naturally expire to `error` (cloud refresh keeps 403ing). No serving, no poison, no loss.
1780
+ // Account REMOVAL happens EXCLUSIVELY through the server's authoritative allow-set on a
1781
+ // SUCCESSFUL (200) pull (mergePulledAccounts) — the one signal that definitively says
1782
+ // "this account is no longer in your group." That is the only place a cloud-managed account
1783
+ // is pruned, and it is never driven by a 403.
1784
+
1785
+ // On a 403 for a key, STOP serving the accounts it manages — a REVERSIBLE, live-only disable
1786
+ // (never persisted to disk), so a revoked key can't keep proxying and a transient 403 costs
1787
+ // only a serving pause, not credential loss. A user's explicit disable is persisted
1788
+ // (enabled:false / localDisabled), so this never resurrects it (we only ever set enabled=false
1789
+ // here) and the recovery re-enable respects the persisted state. Scope: accounts tagged by
1790
+ // this key's source (cloudSources), plus freshly-PUSHED (cloudPushed) accounts whose reconcile
1791
+ // hasn't tagged a source yet. `_revHeld` prevents an UNRELATED key's successful pull from
1792
+ // re-enabling an account only THIS (revoked) key authorized. Idempotent per tick.
1793
+ function disableKeyAccounts(config, accountManager, key) {
1794
+ const src = keySource(key);
1795
+ for (const a of config.accounts || []) {
1796
+ const bySource = Array.isArray(a.cloudSources) && a.cloudSources.includes(src);
1797
+ // A freshly-PUSHED account (cloudPushed) whose post-push reconcile pull hasn't yet tagged
1798
+ // cloudSources has no source to match, but its token IS cloud-shared and could have been
1799
+ // pushed via THIS key. Disable it too (reversible — a valid key's pull re-enables it) so a
1800
+ // key revoked in that reconcile window can't keep it proxying (Codex: push→reconcile-fail→
1801
+ // revoke bypass). Over-disabling a multi-key account here is a harmless flap — a surviving
1802
+ // key's own successful poll re-enables it in the same/next tick.
1803
+ const pushedUnreconciled = !!a.cloudPushed && !(Array.isArray(a.cloudSources) && a.cloudSources.length > 0);
1804
+ if (!(bySource || pushedUnreconciled)) continue;
1805
+ const idx = findConfigAccount({ accounts: accountManager.accounts }, a);
1806
+ if (idx >= 0) {
1807
+ // Mark a live-only revocation hold so a LATER reconcile driven by an UNRELATED key's
1808
+ // successful pull can't re-enable it (see syncAccountsFromDisk). Cleared only by
1809
+ // clearRevocationHold when an authorizing source's own pull confirms the account.
1810
+ accountManager.accounts[idx]._revHeld = true;
1811
+ if (accountManager.accounts[idx].enabled !== false) accountManager.setEnabled(accountManager.accounts[idx], false);
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ // A successful pull for `source` re-authorizes the accounts that source manages — lift the
1817
+ // revocation hold on exactly those (an account is authorized-by-source once its cloudSources
1818
+ // includes it, which the just-completed merge tags) and re-enable them, respecting a persisted
1819
+ // user/disk disable. A DIFFERENT key's success never reaches accounts it doesn't authorize, so
1820
+ // an account solely managed by a still-revoked key stays held (no revocation bypass).
1821
+ function clearRevocationHold(config, accountManager, source) {
1822
+ if (source == null) return;
1823
+ for (const a of config.accounts || []) {
1824
+ if (!(Array.isArray(a.cloudSources) && a.cloudSources.includes(source))) continue;
1825
+ const idx = findConfigAccount({ accounts: accountManager.accounts }, a);
1826
+ if (idx >= 0 && accountManager.accounts[idx]._revHeld) {
1827
+ delete accountManager.accounts[idx]._revHeld;
1828
+ if (a.enabled !== false) accountManager.setEnabled(accountManager.accounts[idx], true);
1829
+ }
1830
+ }
1831
+ }
1832
+
1833
+ // After a SUCCESSFUL (200) pull with an authoritative allow-set, put a revocation hold on any
1834
+ // freshly-PUSHED account (cloudPushed) that (a) hasn't been reconciled yet (no cloudSources) AND
1835
+ // (b) is NOT in this pull's allow-set. Its post-push reconcile never tagged a source, so the
1836
+ // normal cloudSources-scoped prune (mergePulledAccounts) can't see it — a group-removed push
1837
+ // orphan would otherwise keep proxying on its still-valid token until expiry (revocation bypass).
1838
+ // The hold is REVERSIBLE: if the account actually belongs to ANOTHER key, that key's own pull
1839
+ // tags cloudSources (mergePulledAccounts adds it for allow-set members) AND clearRevocationHold
1840
+ // lifts the hold; if it was genuinely removed, it stays disabled (not serving) until its token
1841
+ // expires to `error`. Accounts already carrying cloudSources are handled by the normal prune, so
1842
+ // this only ever touches the untagged push-orphan window. Needs an authoritative allow-set — an
1843
+ // older server that omits it can't scope safely, so we skip (never disable on an absent allow-set).
1844
+ function holdUnauthorizedPushOrphans(config, accountManager, allowed) {
1845
+ if (!Array.isArray(allowed)) return;
1846
+ const allowSet = new Set(allowed);
1847
+ for (const a of config.accounts || []) {
1848
+ const unreconciled = !!a.cloudPushed && !(Array.isArray(a.cloudSources) && a.cloudSources.length > 0);
1849
+ if (!unreconciled) continue;
1850
+ if (allowSet.has(a.accountUuid) || allowSet.has(a.name)) continue; // this pull authorizes it → it's being reconciled, not orphaned
1851
+ const idx = findConfigAccount({ accounts: accountManager.accounts }, a);
1852
+ if (idx >= 0) {
1853
+ accountManager.accounts[idx]._revHeld = true;
1854
+ if (accountManager.accounts[idx].enabled !== false) accountManager.setEnabled(accountManager.accounts[idx], false);
1855
+ }
1856
+ }
1857
+ }
1858
+
1859
+ // Cloud-config GENERATION — bumped by syncAccountsFromDisk whenever the endpoint or key set
1860
+ // changes. A cloud-sync pull captures the generation at its start and re-checks it (inside the
1861
+ // atomic merge) so a delayed pull can't merge a stale allow-set into a config that has since been
1862
+ // retargeted — including an A→B→A round-trip a plain URL comparison would miss.
1863
+ let _cloudConfigGen = 0;
1864
+ let _cloudIdentityFp = null;
1865
+
1456
1866
  // One cloud-sync pass: re-pull with the stored key and reconcile the live fleet.
1457
- // Returns { stop } — stop=true means the key is revoked and the loop must end.
1458
- // Failure classification (irreversible-mutation 3-way): revoked(403) drop the
1459
- // key's accounts and stop; transient(5xx/network) keep last-known accounts and
1460
- // retry next interval (cloud outage must NOT block the local proxy).
1461
- async function syncFromCloudOnce(config, accountManager, cloud, key) {
1867
+ // Returns { result }: 'ok' (reconciled a successful pull's authoritative allow-set is the
1868
+ // ONLY thing that prunes accounts), 'transient' (5xx/network kept last-known accounts, retry
1869
+ // next interval so a cloud outage never blocks the local proxy), 'auth403' (a 403 the caller
1870
+ // only DISABLES the key's accounts, reversibly; never prunes on a 403), or 'aborted' (the loop
1871
+ // was stopped/retargeted while cloudPull was in flight — bail before mutating so this pass can't
1872
+ // merge OLD-endpoint results into a config that has since been retargeted). `isCurrent` is the
1873
+ // loop's liveness check.
1874
+ async function syncFromCloudOnce(config, accountManager, cloud, key, isCurrent) {
1462
1875
  let pulled, allowed, source;
1463
1876
  try {
1464
1877
  ({ accounts: pulled, allowed, source } = await cloudPull(cloud, key));
1465
1878
  } catch (err) {
1466
- if (err && err.revoked) {
1467
- // Revoke: prune every account this key manages (mergePulledAccounts with an
1468
- // empty allow-set drops this key's source and removes now-unmanaged accounts).
1469
- const src = keySource(key);
1470
- await atomicConfigUpdate(c => { mergePulledAccounts(c, [], src, []); });
1471
- const { removed } = await applyDiskToFleet(config, accountManager); // via the shared chain
1472
- console.error(`[TeamClaude] Cloud auth key revoked — removed ${removed} account(s). The proxy will refuse new requests until you re-pull with a valid key.`);
1473
- return { stop: true };
1474
- }
1879
+ if (err && err.revoked) return { result: 'auth403' }; // HOLD — loop counts persistence
1475
1880
  console.error(`[TeamClaude] Cloud sync skipped (transient — keeping current accounts): ${err.message}`);
1476
- return { stop: false };
1477
- }
1478
- await atomicConfigUpdate(c => { mergePulledAccounts(c, pulled, source, allowed); });
1881
+ return { result: 'transient' };
1882
+ }
1883
+ if (isCurrent && !isCurrent()) return { result: 'aborted' }; // early bail: stopped/retargeted mid-pull
1884
+ // Authoritative re-check INSIDE the serialized write: the loop is NOT stopped on an endpoint/key
1885
+ // retarget (only on interval→0), so `isCurrent` (=!stopped) alone can't catch a concurrent
1886
+ // `cloud pull --url B`. Verify the DISK config still points at the endpoint we pulled FROM,
1887
+ // atomically with the merge — else A's allow-set would be merged into a retargeted-to-B config
1888
+ // (re-adding A's accounts / source-A authorization after a retarget/revocation). resolveCloud(c)
1889
+ // reflects the authoritative disk state including any retarget that just landed.
1890
+ let merged = false;
1891
+ await atomicConfigUpdate(c => {
1892
+ // Re-validate INSIDE the serialized write: the config GENERATION must be unchanged since this
1893
+ // pull started (isCurrent — catches endpoint/key retarget incl. A→B→A) AND the authoritative
1894
+ // disk endpoint must still match the one we pulled FROM. Either mismatch → drop the stale pull.
1895
+ if ((isCurrent && !isCurrent()) || resolveCloud(c).url !== cloud.url) return;
1896
+ mergePulledAccounts(c, pulled, source, allowed);
1897
+ merged = true;
1898
+ });
1899
+ if (!merged) return { result: 'aborted' };
1479
1900
  // Reconcile through the shared serialized chain so a concurrent /reload can't interleave.
1480
1901
  await applyDiskToFleet(config, accountManager);
1481
- return { stop: false };
1902
+ // This source just proved valid AND re-authorized its accounts (merge re-tagged their
1903
+ // cloudSources) — lift any revocation hold on exactly those, so a transiently-403'd key that
1904
+ // recovered resumes serving. Runs AFTER applyDiskToFleet (which kept held accounts disabled).
1905
+ clearRevocationHold(config, accountManager, source);
1906
+ // A group-removed push orphan (cloudPushed, never reconciled) is invisible to the
1907
+ // cloudSources-scoped prune — hold it (reversibly) if this authoritative pull doesn't list it.
1908
+ holdUnauthorizedPushOrphans(config, accountManager, allowed);
1909
+ return { result: 'ok' };
1482
1910
  }
1483
1911
 
1484
1912
  // Start the periodic live-sync loop. Returns a stop() function. The timer is
1485
1913
  // unref'd (never keeps the process alive) and non-overlapping (a slow pull won't
1486
- // stack). On revoke it self-cancels.
1487
- function startCloudSyncLoop(config, accountManager, cloud, key, intervalMs) {
1914
+ // stack). It polls EVERY key that manages accounts here (a multi-key fleet), not just
1915
+ // the last pullKey — otherwise revoking a non-last key would never stop its accounts
1916
+ // from being served (key-based revocation bypass). A revoked key prunes its own
1917
+ // accounts and is dropped; the loop keeps polling the rest and only self-cancels when
1918
+ // no keys remain. For a single-key install this is exactly the old behavior.
1919
+ function startCloudSyncLoop(config, accountManager, key, intervalMs) {
1488
1920
  let timer = null;
1489
1921
  let running = false;
1922
+ let stopped = false; // set by stop() — an in-flight tick checks this after each await so it can't
1923
+ // mutate the (possibly retargeted) config after the loop was torn down.
1924
+ // Seed with the UNION of pullKeys AND the current pullKey — never pullKeys alone. A valid
1925
+ // current key absent from the map (e.g. an older CLI wrote only cloud.pullKey while an
1926
+ // existing config kept an older pullKeys map) would otherwise never be polled, so its
1927
+ // revocation/group changes would never prune its accounts. Set dedups the overlap.
1928
+ const seed = [...(config.cloud?.pullKeys ? Object.values(config.cloud.pullKeys) : []), key, envAuthKey(config)];
1929
+ const activeKeys = new Set(seed.filter(Boolean));
1930
+ const auth403Streak = new Map(); // key -> true while in a 403 hold episode (log-once marker; a 403 never prunes)
1490
1931
  const tick = async () => {
1491
- if (running) return;
1932
+ if (running || stopped) return;
1492
1933
  running = true;
1493
1934
  try {
1494
- const r = await syncFromCloudOnce(config, accountManager, cloud, key);
1495
- if (r.stop && timer) { clearInterval(timer); timer = null; }
1935
+ // Resolve the cloud endpoint FRESH each tick from the (possibly reloaded) config
1936
+ // NEVER capture it once. A live `cloud pull` can change config.cloud.url (e.g. a
1937
+ // server that began pure-local, or re-pointed to a self-hosted cloud); a stale
1938
+ // captured endpoint would keep polling the OLD server and — worse — a 403 from the
1939
+ // wrong endpoint for a still-valid key would be misread as revocation and PRUNE the
1940
+ // live accounts. resolveCloud spreads config.cloud over DEFAULT_CLOUD each call.
1941
+ const cloud = resolveCloud(config);
1942
+ // RECONCILE the poll set to the CURRENT persisted keys each tick — add newly-pulled
1943
+ // keys AND DROP any no longer present. On an endpoint change syncAccountsFromDisk
1944
+ // REPLACES config.cloud.pullKeys (old-endpoint keys removed); an add-only merge would
1945
+ // keep a stale key A in this closure and POST it as x-teamclaude-key to the NEW
1946
+ // (possibly attacker) URL — key exfiltration. Reconciling down closes that.
1947
+ // Include the ambient env auth key (endpoint-bound: envAuthKey returns it ONLY for the
1948
+ // default endpoint, never a custom URL) so an env-only deployment keeps polling for
1949
+ // revocation/group changes — otherwise `current` (built solely from pullKey/pullKeys)
1950
+ // would drop the seeded env key on the first tick and never live-sync it.
1951
+ const envK = envAuthKey(config);
1952
+ const current = new Set([
1953
+ ...(config.cloud?.pullKeys ? Object.values(config.cloud.pullKeys).filter(Boolean) : []),
1954
+ ...(config.cloud?.pullKey ? [config.cloud.pullKey] : []),
1955
+ ...(envK ? [envK] : []),
1956
+ ]);
1957
+ for (const k of [...activeKeys]) if (!current.has(k)) activeKeys.delete(k); // drop stale (endpoint change / revoked)
1958
+ for (const k of current) activeKeys.add(k); // pick up newly-pulled keys
1959
+ for (const k of [...auth403Streak.keys()]) if (!activeKeys.has(k)) auth403Streak.delete(k); // clear 403-hold markers for keys no longer polled
1960
+ for (const k of [...activeKeys]) {
1961
+ if (stopped) return; // loop torn down mid-iteration — start no new work
1962
+ // isCurrent: bail if the loop was stopped OR the cloud config was retargeted since this
1963
+ // tick captured `cloud` — checked by GENERATION (catches an A→B→A round-trip and same-
1964
+ // endpoint key changes a URL comparison would miss). The updater re-checks the generation
1965
+ // against the authoritative disk state too.
1966
+ const genAtStart = _cloudConfigGen;
1967
+ const r = await syncFromCloudOnce(config, accountManager, cloud, k, () => !stopped && _cloudConfigGen === genAtStart);
1968
+ if (stopped) return; // stopped during this key's pull — don't act on a stale result
1969
+ if (r.result === 'auth403') {
1970
+ // A 403 is AMBIGUOUS (a real revocation OR an auth/policy outage, a buggy custom
1971
+ // endpoint, a server regression), so take NO destructive action: DISABLE the key's
1972
+ // accounts (reversible, live-only) and keep polling. If the key recovers, a successful
1973
+ // pull lifts the hold and the accounts resume; if it's genuinely revoked they stay
1974
+ // disabled (not serving) until their tokens expire to `error`. The ONLY thing that
1975
+ // removes a cloud-managed account is the server's authoritative allow-set on a future
1976
+ // SUCCESSFUL (200) pull — never a 403 (irreversible-mutation-uncertain-outcome). Log
1977
+ // once per 403 episode (the map is a per-key "already-logged" marker, reset on any
1978
+ // non-403 outcome below and swept when a key leaves the poll set).
1979
+ disableKeyAccounts(config, accountManager, k);
1980
+ if (!auth403Streak.has(k)) {
1981
+ console.error('[TeamClaude] Cloud pull 403 for a key — its accounts disabled (not serving) until the key recovers or the server drops them from your group on a successful sync. Not pruned: a 403 is never treated as a definitive revocation.');
1982
+ }
1983
+ auth403Streak.set(k, true);
1984
+ } else {
1985
+ // 'ok' or 'transient' — end the 403 episode (a later 403 logs afresh). A successful
1986
+ // pull has already lifted the hold on the accounts its allow-set re-authorized.
1987
+ auth403Streak.delete(k);
1988
+ }
1989
+ }
1990
+ // Do NOT self-cancel when activeKeys empties (all keys revoked): a later
1991
+ // `cloud pull --key NEW` adds a key to config.cloud.pullKeys, and the top of the
1992
+ // next tick re-seeds activeKeys from it — so revocation is still enforced for the
1993
+ // new key. Self-cancelling would leave a re-keyed proxy polling nothing until a
1994
+ // restart (auth-revocation bypass). An idle tick over an empty set is a cheap
1995
+ // no-op; the timer is unref'd and cleared by stop() on process exit.
1496
1996
  } catch (e) {
1497
1997
  console.error(`[TeamClaude] Cloud sync error: ${e.message}`);
1498
1998
  } finally {
@@ -1501,7 +2001,17 @@ function startCloudSyncLoop(config, accountManager, cloud, key, intervalMs) {
1501
2001
  };
1502
2002
  timer = setInterval(tick, intervalMs);
1503
2003
  timer.unref?.();
1504
- return () => { if (timer) { clearInterval(timer); timer = null; } };
2004
+ // Poll ONCE immediately on start, not only after the first full interval. The 403-hold is
2005
+ // live-only, so a restart that lands during a key revocation begins with the account enabled
2006
+ // on disk; an immediate poll re-applies the disable within ~one pull instead of leaving a
2007
+ // serving window up to `intervalMs` wide. The `running` guard makes this safe against the
2008
+ // interval's first fire. (A proxy with live-sync disabled — intervalMs 0 — has opted out of
2009
+ // live revocation enforcement entirely, so there is nothing to poll.)
2010
+ setImmediate(() => { tick().catch(() => { /* tick already logs its own errors */ }); });
2011
+ // stop(): cancel the interval AND set `stopped` so an already-running tick bails at its next
2012
+ // await-boundary check instead of merging a stale pull into a config that has since been
2013
+ // retargeted (endpoint/key change) or after the loop was disabled (cloudSyncIntervalMs=0).
2014
+ return () => { stopped = true; if (timer) { clearInterval(timer); timer = null; } };
1505
2015
  }
1506
2016
 
1507
2017
  // ── self-update (public npm) ────────────────────────────────
@@ -1583,7 +2093,21 @@ async function resolveAccounts(config) {
1583
2093
  const creds = await importCredentials(acct.importFrom);
1584
2094
  // Carry accountUuid through so the live account can be matched UUID-first
1585
2095
  // on sync (otherwise it stays null and a name change misroutes the update).
1586
- accounts.push({ name: acct.name, type: 'oauth', accountUuid: acct.accountUuid, maxConcurrent: acct.maxConcurrent, enabled: acct.enabled, priority: acct.priority, ...creds });
2096
+ // Carry cloud provenance (cloudSources/cloudPushed/pullKey/pullKeys) too: an
2097
+ // imported account can itself be cloud-managed, and the AccountManager derives
2098
+ // the live `cloudManaged` flag from these. Dropping them here would leave an
2099
+ // imported cloud account with cloudManaged=false — so if its config row is
2100
+ // removed by a revocation while it's held in-flight, createCloudRefreshHandler
2101
+ // finds no provenance and falls back to LOCAL rotation, reintroducing the exact
2102
+ // divergent-token poisoning this fix exists to prevent. The live flag survives
2103
+ // config-row removal; the raw fields don't hurt a non-cloud import (undefined).
2104
+ accounts.push({
2105
+ name: acct.name, type: 'oauth', accountUuid: acct.accountUuid,
2106
+ maxConcurrent: acct.maxConcurrent, enabled: acct.enabled, priority: acct.priority,
2107
+ cloudSources: acct.cloudSources, cloudPushed: acct.cloudPushed,
2108
+ pullKey: acct.pullKey, pullKeys: acct.pullKeys,
2109
+ ...creds,
2110
+ });
1587
2111
  console.log(`Imported "${acct.name}" from ${acct.importFrom}`);
1588
2112
  } catch (err) {
1589
2113
  console.error(`Failed to import "${acct.name}": ${err.message}`);
@@ -1616,6 +2140,15 @@ const _autoPushInFlight = new Set();
1616
2140
  * consumer). Never throws — cloud problems must not affect the running proxy.
1617
2141
  */
1618
2142
  async function maybePushRefreshedToken(config, account, newTokens) {
2143
+ // ONLY sync back cloud-managed (shared) accounts. Auto-pushing a purely LOCAL account
2144
+ // would SHARE its freshly locally-rotated token to the cloud WITHOUT marking cloudPushed
2145
+ // provenance — this PC would then stay cloudManaged=false and keep local-rotating the
2146
+ // now-shared token on every future refresh, poisoning the cloud/other-PC chain (the exact
2147
+ // single-authority violation this feature prevents). A local account has no cloud copy to
2148
+ // keep fresh, so there is nothing to sync back. (A cloud-managed account is refreshed
2149
+ // centrally via cloudRefreshToken, so this re-push is a harmless belt-and-suspenders.)
2150
+ const cloudManaged = account.cloudManaged === true || isCloudManaged(config, account);
2151
+ if (!cloudManaged) return;
1619
2152
  const key = account.accountUuid || account.name;
1620
2153
  const session = config.cloud && config.cloud.session;
1621
2154
  // An owner session is USABLE if its access token is still valid OR it carries a
@@ -1627,7 +2160,7 @@ async function maybePushRefreshedToken(config, account, newTokens) {
1627
2160
  (Number(session.expires_at || 0) * 1000) > Date.now() + 30_000 ||
1628
2161
  (session.refresh_token && String(session.refresh_token).length > 0)
1629
2162
  ));
1630
- const pullKey = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
2163
+ const pullKey = config.cloud?.pullKey || envAuthKey(config); // endpoint-bound env key
1631
2164
  const canKey = !!(pullKey && account.accountUuid);
1632
2165
  if (!ownerUsable && !canKey) return; // nowhere to sync back to
1633
2166
  try {
@@ -1843,7 +2376,20 @@ async function cloudLoginCommand(config) {
1843
2376
  console.error('Token login failed — the token is invalid or expired. Re-copy "CLI login" from the web dashboard.');
1844
2377
  process.exit(1);
1845
2378
  }
1846
- await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), url, anonKey, email, session }; });
2379
+ await atomicConfigUpdate(c => {
2380
+ // An ABSENT prior url = the implicit DEFAULT endpoint — treat it as DEFAULT_CLOUD.url so a
2381
+ // legacy/default config (has keys, no explicit url) switching to a custom `--url` is
2382
+ // correctly detected as an endpoint change (else its keys survive and get POSTed there).
2383
+ const endpointChanged = (c.cloud?.url || DEFAULT_CLOUD.url) !== url;
2384
+ c.cloud = { ...(c.cloud || {}), url, anonKey, email, session };
2385
+ // Login to a DIFFERENT endpoint → drop keys issued for the OLD one, or the sync/refresh
2386
+ // loops would POST them to the new URL (key exfiltration). session is set fresh above.
2387
+ if (endpointChanged) { delete c.cloud.pullKey; delete c.cloud.pullKeys; }
2388
+ });
2389
+ // Apply to a running proxy NOW (like cloud pull/logout do). Otherwise the live server keeps
2390
+ // the OLD endpoint/session/key until a manual reload/restart — and on an endpoint change that
2391
+ // means it could keep POSTing the old key to the old URL (or the new one via a stale live key).
2392
+ await noteRunningServerReload(config).catch(() => {});
1847
2393
  console.log(`Logged in to cloud${email ? ` as ${email}` : ''} (token, short-lived ~1h — re-copy from the dashboard when it expires).`);
1848
2394
  return;
1849
2395
  }
@@ -1861,17 +2407,256 @@ async function cloudLoginCommand(config) {
1861
2407
  rl.close();
1862
2408
  }
1863
2409
  const session = await cloudLogin({ url, anonKey }, email, password);
1864
- await atomicConfigUpdate(c => { c.cloud = { ...(c.cloud || {}), url, anonKey, email, session }; });
2410
+ await atomicConfigUpdate(c => {
2411
+ // Same endpoint-change key-clearing as the token branch (the password path had none): a
2412
+ // login that retargets a DIFFERENT endpoint — including a switch away from the implicit
2413
+ // default (absent url) — must DROP keys issued for the OLD one, or the sync/refresh loops
2414
+ // would POST them to the new URL (key exfiltration).
2415
+ const endpointChanged = (c.cloud?.url || DEFAULT_CLOUD.url) !== url;
2416
+ c.cloud = { ...(c.cloud || {}), url, anonKey, email, session };
2417
+ if (endpointChanged) { delete c.cloud.pullKey; delete c.cloud.pullKeys; }
2418
+ });
2419
+ // Apply to a running proxy NOW so it doesn't keep using the old endpoint/session/key.
2420
+ await noteRunningServerReload(config).catch(() => {});
1865
2421
  console.log(`Logged in to cloud as ${email}.`);
1866
2422
  }
1867
2423
 
2424
+ // Tag pushed accounts as cloud-shared (cloudPushed) so this PC — the origin that
2425
+ // authenticated them locally and therefore has no cloudSources — is also recognized
2426
+ // as cloud-managed and refuses a local rotation. Without this, the pusher would keep
2427
+ // local-rotating a token now shared via the cloud and poison it for every puller.
2428
+ // Only a DEFINITIVE pre-commit rejection (HTTP 4xx: bad/expired key, plan limit, bad
2429
+ // request) proves the upload stored nothing. A 5xx / timeout / network error is AMBIGUOUS
2430
+ // — the cloud may have committed the token even though the client saw a failure — so its
2431
+ // mark is NEVER reverted.
2432
+ function isDefinitivePushRejection(err) {
2433
+ const s = err && err.status;
2434
+ return Number.isInteger(s) && s >= 400 && s < 500;
2435
+ }
2436
+
2437
+ // Mark accounts cloud-shared (cloudPushed) BEFORE they are uploaded (protect-then-share),
2438
+ // so there is never a window where the token is in the cloud (shared) but the running proxy
2439
+ // still thinks it's local and would rotate it. It stamps ONLY cloudPushed here — the
2440
+ // key-source revocation tag (cloudSources[keySource(pushKey)]) is added POST-SUCCESS in
2441
+ // reconcileCloudPushedMarks, NOT at mark time: tagging cloudSources on an account not yet in
2442
+ // the cloud would make the 30s live-sync's pull (which doesn't see it yet) treat it as
2443
+ // revoked-from-group and PRUNE it (a premature-delete race).
2444
+ // Returns { gen, owned, pushed, isProtected }: `pushed` = ALL account UUIDs in this push
2445
+ // (used by the post-success reconcile to tag cloudSources — even on accounts already
2446
+ // cloudPushed by a prior push); `owned` = UUIDs whose cloudPushed WE newly stamped (so a
2447
+ // failed/rejected upload reverts only what it added — CAS on gen).
2448
+ async function markAccountsCloudPushed(config, pushedAccounts) {
2449
+ const uuids = new Set(pushedAccounts.map(a => a.accountUuid).filter(Boolean));
2450
+ if (!uuids.size) return null;
2451
+ const gen = `g${Date.now().toString(36)}.${process.pid.toString(36)}.${Math.random().toString(36).slice(2, 10)}`;
2452
+ const owned = [];
2453
+ await atomicConfigUpdate(c => {
2454
+ for (const a of c.accounts || []) {
2455
+ // Stamp cloudPushed only on accounts not already cloud-shared — never overwrite an
2456
+ // existing mark (another push's live generation, or a `true` from a prior share).
2457
+ if (a.accountUuid && uuids.has(a.accountUuid) && !a.cloudPushed) { a.cloudPushed = gen; owned.push(a.accountUuid); }
2458
+ }
2459
+ });
2460
+ // Apply the flag to a RUNNING proxy NOW (onReload → syncAccountsFromDisk updates the
2461
+ // live account.cloudManaged) instead of waiting for its next cloud-sync tick.
2462
+ // `isProtected` is false ONLY when a proxy IS running but failed to reload: then its
2463
+ // live accounts stay cloudManaged=false and would local-rotate the token we're about to
2464
+ // share (poison). The caller must fail closed on that. No server running → protected
2465
+ // (nothing to poison); reload ok → protected.
2466
+ const reload = await noteRunningServerReload(config).catch(() => ({ serverFound: false, reloaded: false }));
2467
+ const isProtected = reload.reloaded || !reload.serverFound;
2468
+ return { gen, owned, pushed: [...uuids], isProtected };
2469
+ }
2470
+
2471
+ // Blind revert: remove ONLY the cloudPushed marks WE stamped (CAS on the exact gen). No
2472
+ // cloudSources involved — mark time never adds it (see markAccountsCloudPushed), so there is
2473
+ // nothing to strip and no risk of removing a source a concurrent pull legitimately owns.
2474
+ async function unmarkAccountsCloudPushed(config, mark) {
2475
+ if (!mark || !mark.owned || !mark.owned.length) return;
2476
+ const owned = new Set(mark.owned);
2477
+ await atomicConfigUpdate(c => {
2478
+ for (const a of c.accounts || []) {
2479
+ if (a.accountUuid && owned.has(a.accountUuid) && a.cloudPushed === mark.gen) delete a.cloudPushed;
2480
+ }
2481
+ });
2482
+ await noteRunningServerReload(config).catch(() => {});
2483
+ }
2484
+
2485
+ // After a SUCCESSFUL push, re-pull with the same key to learn which of the just-pushed
2486
+ // accounts the key can ACTUALLY manage (confirmedUuids), then:
2487
+ // • CONFIRMED (stored) → ADD the key's cloudSources tag now — post-upload, so the account
2488
+ // is already in the cloud and the live-sync's pull returns it (no premature prune). This
2489
+ // is what makes a later revocation of the key prune the account (403 path keys on
2490
+ // cloudSources). Done for EVERY pushed account (incl. ones already cloudPushed by a prior
2491
+ // push), so an already-marked account also gains the successful key's source. If this
2492
+ // reconcile fails transiently the NEXT normal sync tick still sets cloudSources (its pull
2493
+ // tags every returned account) — a ≤one-interval revocation-attribution delay, documented.
2494
+ // • NOT confirmed (out-of-group / not-stored skip) AND we newly marked it (owned) → remove
2495
+ // the cloudPushed mark (CAS on gen), so it isn't left falsely cloud-managed/unrefreshable.
2496
+ async function reconcileCloudPushedMarks(config, mark, confirmedUuids, pushKey) {
2497
+ if (!mark || !mark.pushed || !mark.pushed.length) return;
2498
+ const pushed = new Set(mark.pushed);
2499
+ const owned = new Set(mark.owned || []);
2500
+ const src = pushKey ? keySource(pushKey) : null;
2501
+ let dropped = 0;
2502
+ await atomicConfigUpdate(c => {
2503
+ for (const a of c.accounts || []) {
2504
+ if (!a.accountUuid || !pushed.has(a.accountUuid)) continue;
2505
+ if (confirmedUuids.has(a.accountUuid)) {
2506
+ if (src) { // stored → tag cloudSources for revocation-prune (idempotent, ALL pushed)
2507
+ if (!Array.isArray(a.cloudSources)) a.cloudSources = [];
2508
+ if (!a.cloudSources.includes(src)) a.cloudSources.push(src);
2509
+ }
2510
+ } else if (owned.has(a.accountUuid) && a.cloudPushed === mark.gen) { // not stored + we marked → un-mark
2511
+ delete a.cloudPushed;
2512
+ dropped++;
2513
+ }
2514
+ }
2515
+ });
2516
+ await noteRunningServerReload(config).catch(() => {});
2517
+ if (dropped) console.log(` ${dropped} account(s) not stored under this key — cloud-managed mark reverted (they refresh locally).`);
2518
+ }
2519
+
2520
+ // Revert our marks after a DEFINITIVE 4xx (our push stored nothing). When a key is available,
2521
+ // reconcile via re-pull: a CONCURRENT push may have shared some of these accounts (then the
2522
+ // re-pull confirms them → they're correctly kept + tagged), while the rest (truly not stored)
2523
+ // have their cloudPushed removed. No key → blind cloudPushed-only revert. (The push path now
2524
+ // always has a key, so the no-key branch is a defensive fallback.)
2525
+ async function revertMarksAfterRejection(config, mark, cloud, key) {
2526
+ if (!mark || (!mark.owned?.length && !mark.pushed?.length)) return;
2527
+ if (key) {
2528
+ try {
2529
+ const { accounts: pulledBack } = await cloudPull(cloud, key);
2530
+ const confirmed = new Set((pulledBack || []).map(a => a.accountUuid).filter(Boolean));
2531
+ await reconcileCloudPushedMarks(config, mark, confirmed, key);
2532
+ return;
2533
+ } catch { /* re-pull failed — fall through to blind revert (definitive 4xx: our push stored nothing) */ }
2534
+ }
2535
+ await unmarkAccountsCloudPushed(config, mark);
2536
+ }
2537
+
2538
+ // Fail closed if a running proxy could not be protected before we share the token: revert
2539
+ // our marks and abort. Returns true if the caller should stop (already reverted + logged).
2540
+ async function abortPushIfUnprotected(config, mark) {
2541
+ if (mark && mark.isProtected === false) {
2542
+ await unmarkAccountsCloudPushed(config, mark);
2543
+ console.error('푸시 중단: 실행 중인 프록시에 보호 플래그를 적용하지 못했습니다 (공유 전 보호 실패).');
2544
+ console.error(' `teamclaude restart` 후 다시 시도하세요 — 지금 업로드하면 프록시가 공유 토큰을 로컬 회전시켜 오염될 수 있습니다.');
2545
+ return true;
2546
+ }
2547
+ return false;
2548
+ }
2549
+
2550
+ // Machine-wide push lock: serialize `cloud push` (CLI + TUI) so two pushes never run
2551
+ // concurrently. Concurrency is the root of the push-path races — a second push can share an
2552
+ // account the first stamped (owning no mark), then the first's revert leaves it shared-but-
2553
+ // unmanaged (poison). Serializing eliminates the whole class. Atomic O_EXCL create; a stale
2554
+ // lock (holder pid dead) is stolen. Best-effort release; a lingering lock after a crash is
2555
+ // reclaimed by the next acquirer's stale check.
2556
+ function pushLockPath() { return getConfigPath().replace(/\.json$/, '') + '.push.lock'; }
2557
+ // A LIVE holder PID is never stolen — stealing a legitimately slow/hung push (age-based steal)
2558
+ // would let a second process acquire the lock and push CONCURRENTLY, exactly the stale-token-chain
2559
+ // race the lock prevents (poison is worse than a block). Only a DEAD/unparseable holder is stale.
2560
+ // Known rare edge (documented): if a push is SIGKILLed and its PID is later REUSED by an unrelated
2561
+ // live process (or the recorded pid is an init/PID-1 artifact), the lock blocks push on this
2562
+ // machine until the stale `<config>.push.lock` file is removed manually.
2563
+ function _lockHolderAlive(path) {
2564
+ let holder = null;
2565
+ try { holder = JSON.parse(readFileSync(path, 'utf8')); } catch { return { alive: false, holder: null }; }
2566
+ if (!holder || !holder.pid) return { alive: false, holder };
2567
+ try { process.kill(holder.pid, 0); return { alive: true, holder }; } catch { return { alive: false, holder }; }
2568
+ }
2569
+ function _acquireFileLock() {
2570
+ const path = pushLockPath();
2571
+ const payload = JSON.stringify({ pid: process.pid, at: new Date().toISOString() });
2572
+ for (let attempt = 0; attempt < 2; attempt++) {
2573
+ try {
2574
+ writeFileSync(path, payload, { flag: 'wx', mode: 0o600 }); // atomic exclusive create
2575
+ return path;
2576
+ } catch (err) {
2577
+ if (err.code !== 'EEXIST') throw err;
2578
+ const { alive, holder } = _lockHolderAlive(path);
2579
+ // A LIVE holder is another process (the in-process mutex below guarantees no
2580
+ // concurrent same-process acquire ever reaches here) → contention, don't steal.
2581
+ // A dead holder — OR our own pid, which can only be a LEFTOVER from this process's
2582
+ // already-finished previous push (the mutex serialized it) — is stale → reclaim.
2583
+ if (alive && holder.pid !== process.pid) {
2584
+ throw new Error('Another `teamclaude cloud push` is in progress on this machine — retry when it finishes.');
2585
+ }
2586
+ try { unlinkSync(path); } catch { /* raced with another reclaimer — retry */ }
2587
+ }
2588
+ }
2589
+ throw new Error('Could not acquire the push lock (contention) — retry shortly.');
2590
+ }
2591
+ // In-process serialization: chain acquires on a module-level promise so two pushes in the
2592
+ // SAME process (e.g. two TUI `p` presses before the first completes) serialize instead of
2593
+ // the second stealing the first's live same-pid FILE lock and running concurrently. The
2594
+ // file lock adds cross-process serialization on top. Returns an opaque handle.
2595
+ let _pushMutex = Promise.resolve();
2596
+ async function acquirePushLock() {
2597
+ let releaseMutex;
2598
+ const prev = _pushMutex;
2599
+ _pushMutex = new Promise(res => { releaseMutex = res; });
2600
+ await prev; // wait for any in-process push to finish
2601
+ try {
2602
+ const path = _acquireFileLock();
2603
+ return { path, releaseMutex };
2604
+ } catch (err) {
2605
+ releaseMutex(); // couldn't take the cross-process lock — free the in-process mutex
2606
+ throw err;
2607
+ }
2608
+ }
2609
+ function releasePushLock(handle) {
2610
+ if (!handle) return;
2611
+ try {
2612
+ const holder = JSON.parse(readFileSync(handle.path, 'utf8'));
2613
+ if (holder && holder.pid === process.pid) unlinkSync(handle.path); // only remove OUR lock
2614
+ } catch { /* already gone / corrupt / stolen — nothing to do */ }
2615
+ finally { handle.releaseMutex?.(); } // always free the in-process mutex
2616
+ }
2617
+
2618
+ // Re-read the freshest tokens for the push payload AFTER protection. markAccountsCloudPushed
2619
+ // made these accounts cloudManaged on the running proxy, so it will no longer local-rotate
2620
+ // them — but it MAY have rotated one in the pre-protection window (T0→T1), persisting T1 to
2621
+ // disk. Uploading the pre-protection snapshot (T0) would make the cloud authoritative for a
2622
+ // rotated-away, now-invalid chain (Anthropic invalidates the previous refresh token on
2623
+ // rotation), bricking every consumer. Rebuild from the freshest on-disk token per account.
2624
+ function refreshPushSnapshot(freshCfg, accounts) {
2625
+ const byId = new Map();
2626
+ for (const a of (freshCfg?.accounts || [])) {
2627
+ if (a.accountUuid) byId.set('u:' + a.accountUuid, a);
2628
+ if (!byId.has('n:' + a.name)) byId.set('n:' + a.name, a);
2629
+ }
2630
+ return accounts.map(a => {
2631
+ // A UUID-bearing account matches by UUID ONLY — never fall back to name. Otherwise, if a
2632
+ // concurrent pull/revocation replaced u1 with a DIFFERENT same-name account u2, we'd
2633
+ // upload u2's tokens under stale u1 (credential cross-store / revoked-account resurrect).
2634
+ // Only a legacy UUID-less account falls back to a name match.
2635
+ const f = a.accountUuid ? byId.get('u:' + a.accountUuid) : byId.get('n:' + a.name);
2636
+ // Account no longer on disk → a concurrent `cloud pull`/revocation REMOVED it between
2637
+ // our snapshot and now. OMIT it from the payload rather than re-uploading the stale
2638
+ // original object — that would resurrect/re-share a token the control plane just revoked.
2639
+ if (!f) return null;
2640
+ return f.accessToken
2641
+ ? { ...a, accessToken: f.accessToken, refreshToken: f.refreshToken, expiresAt: f.expiresAt }
2642
+ : a;
2643
+ }).filter(Boolean);
2644
+ }
2645
+
1868
2646
  async function cloudPushCommand(config) {
1869
2647
  const accounts = (config.accounts || []).filter(a => a.type !== 'apikey');
1870
2648
  if (!accounts.length) { console.error('No OAuth accounts to push.'); process.exit(1); }
2649
+ const _pushLock = await acquirePushLock(); // serialize pushes machine-wide (no concurrent-share race)
2650
+ try {
1871
2651
  // Prefer the AUTH KEY: pushing to your own tenant is the key's job — no separate,
1872
2652
  // short-lived owner login required (the key is the credential, same as pull). This
1873
2653
  // CREATES + updates accounts in the key's owner tenant (freshest-wins + plan limits).
1874
- const key = config.cloud?.pullKey || process.env.TEAMCLAUDE_AUTH_KEY;
2654
+ // Env key is endpoint-bound: don't send it (nor the account tokens) to a custom URL.
2655
+ // Fall back to pullKeys too: after the primary pullKey is revoked but another authorized
2656
+ // key remains, push must still work (else a false "no key" refusal = management outage).
2657
+ const key = config.cloud?.pullKey
2658
+ || (config.cloud?.pullKeys && Object.values(config.cloud.pullKeys).find(Boolean))
2659
+ || envAuthKey(config);
1875
2660
  if (key) {
1876
2661
  const usageByUuid = new Map();
1877
2662
  const quotaCache = await readQuotaCache();
@@ -1880,45 +2665,104 @@ async function cloudPushCommand(config) {
1880
2665
  const usage = buildUsageFromQuota(entry);
1881
2666
  if (usage) usageByUuid.set(entry.accountUuid, usage);
1882
2667
  }
1883
- const r = await cloudPush(resolveCloud(config), accounts, usageByUuid, key);
2668
+ // Protect FIRST (mark cloud-shared + propagate to a running proxy), THEN upload — so
2669
+ // the token is never shared-but-unprotected. On a DEFINITIVE 4xx (nothing stored) the
2670
+ // mark is compare-and-swap reverted so an unshared account isn't left unrefreshable;
2671
+ // an ambiguous 5xx/timeout keeps the mark (may be committed → don't re-open poison).
2672
+ const mark = await markAccountsCloudPushed(config, accounts);
2673
+ if (await abortPushIfUnprotected(config, mark)) process.exit(1);
2674
+ // TOCTOU guard: upload the freshest tokens as of AFTER protection, not the pre-mark
2675
+ // snapshot the proxy may have rotated away in the pre-protection window.
2676
+ const pushAccounts = refreshPushSnapshot(await loadConfig(), accounts);
2677
+ let r;
2678
+ try {
2679
+ r = await cloudPush(resolveCloud(config), pushAccounts, usageByUuid, key);
2680
+ } catch (err) {
2681
+ if (isDefinitivePushRejection(err)) await revertMarksAfterRejection(config, mark, resolveCloud(config), key);
2682
+ throw err;
2683
+ }
2684
+ // Reconcile marks to what the key can actually manage (drops not-stored skips; keeps
2685
+ // staler skips, which ARE cloud-stored). Re-pull with the same key; on a transient
2686
+ // re-pull failure keep the marks (push succeeded, and the dominant skip is staler).
2687
+ if (mark && mark.pushed?.length) {
2688
+ try {
2689
+ const { accounts: pulledBack } = await cloudPull(resolveCloud(config), key);
2690
+ const confirmed = new Set((pulledBack || []).map(a => a.accountUuid).filter(Boolean));
2691
+ await reconcileCloudPushedMarks(config, mark, confirmed, key);
2692
+ } catch { /* transient re-pull failure — keep marks (dominant skip=staler is correctly cloud-managed) */ }
2693
+ }
1884
2694
  const usageNote = usageByUuid.size ? `, usage ${r.usage ?? 0}/${usageByUuid.size}` : '';
1885
2695
  console.log(`Pushed to your account via auth key: ${r.pushed} new, ${r.updated} updated, ${r.skipped} skipped${usageNote}.`);
1886
2696
  return;
1887
2697
  }
1888
- // No auth key fall back to an owner login (which also CREATES new accounts) and
1889
- // rides the quota snapshot so the dashboard can show utilization.
1890
- const cloud = await ensureCloudSession(config);
1891
- if (!cloud?.session?.access_token) { console.error('No auth key found and not logged in. Pull once with `teamclaude cloud pull --key <key>`, or `teamclaude cloud login`.'); process.exit(1); }
1892
- const usageByUuid = new Map();
1893
- const quotaCache = await readQuotaCache();
1894
- for (const entry of quotaCache?.accounts || []) {
1895
- if (!entry?.accountUuid) continue;
1896
- const usage = buildUsageFromQuota(entry);
1897
- if (usage) usageByUuid.set(entry.accountUuid, usage);
2698
+ // No auth key REFUSE. An owner-session-only push would mark the accounts cloud-shared
2699
+ // (cloudPushed) but leaves NO key to refresh them: at expiry createCloudRefreshHandler
2700
+ // refuses a local rotation (single-authority) and has no key for the cloud refresh, so the
2701
+ // accounts go permanently unavailable an availability regression on accounts that were
2702
+ // refreshing fine locally before the push. Sharing accounts that can't be kept fresh is
2703
+ // worse than not sharing. Require a key (it's also what keeps them refreshable for every
2704
+ // consumer). The key path above already CREATES accounts + rides the usage snapshot, so no
2705
+ // capability is lost — a key does everything the old owner-session push did, correctly.
2706
+ console.error('cloud push에는 auth key가 필요합니다 — 공유된 계정은 키가 있어야 갱신(refresh)됩니다.');
2707
+ console.error(' (owner 세션만으로 push하면 그 계정들이 만료 시 갱신 불가로 사용 불가가 됩니다 — availability 회귀.)');
2708
+ console.error(' `teamclaude cloud pull --key <key>` 로 키를 받은 뒤 다시 push 하세요 (또는 대시보드에서 키 발급).');
2709
+ process.exit(1);
2710
+ } finally {
2711
+ releasePushLock(_pushLock);
1898
2712
  }
1899
- const r = await cloudPush(cloud, accounts, usageByUuid);
1900
- const usageNote = usageByUuid.size ? `, usage ${r.usage ?? 0}/${usageByUuid.size}` : ' (no quota snapshot — start the server once to measure usage)';
1901
- console.log(`Pushed ${r.pushed} new, updated ${r.updated}, skipped ${r.skipped} (stale/invalid)${usageNote}.`);
1902
2713
  }
1903
2714
 
1904
2715
  async function cloudPullCommand(config) {
1905
2716
  // Consumer side: the auth key IS the credential — no owner `cloud login` needed.
1906
2717
  // resolveCloud() supplies the built-in public endpoint, so this works right after install.
1907
2718
  const cloud = resolveCloud(config);
1908
- const key = argValue('--key') || process.env.TEAMCLAUDE_AUTH_KEY;
1909
- if (!key) { console.error('Auth key required (--key or TEAMCLAUDE_AUTH_KEY).'); process.exit(1); }
2719
+ // Auto-fill the ambient env key ONLY for the DEFAULT endpoint — an explicit --key is
2720
+ // required to pull from a custom/self-hosted URL, so a config pointed at an attacker
2721
+ // endpoint can't silently harvest the env key issued for the real (default) cloud.
2722
+ const key = argValue('--key')
2723
+ || (isDefaultCloudUrl(cloud.url) ? process.env.TEAMCLAUDE_AUTH_KEY : undefined); // trailing-slash tolerant
2724
+ if (!key) { console.error('Auth key required (--key or TEAMCLAUDE_AUTH_KEY; a custom --url needs an explicit --key).'); process.exit(1); }
1910
2725
  const { accounts: pulled, allowed, source } = await cloudPull(cloud, key);
1911
2726
  const updated = await atomicConfigUpdate(c => {
1912
2727
  c._pullSummary = mergePulledAccounts(c, pulled, source, allowed);
1913
2728
  // Persist the endpoint + auth key so a running `server` can re-pull on its own
1914
2729
  // (live sync). The key is the consumer's own credential; config is written 0o600.
1915
- c.cloud = { ...(c.cloud || {}), url: cloud.url, anonKey: cloud.anonKey, pullKey: key };
2730
+ // pullKeys accumulates a { keySource(key) -> key } store across pulls so a
2731
+ // multi-key fleet can refresh each account with the key that pulled it (its
2732
+ // cloudSources), not just this last pullKey (selectRefreshKey).
2733
+ //
2734
+ // 🔒 Keys are scoped to the ENDPOINT they were issued for. If this pull targets a
2735
+ // DIFFERENT cloud URL than the stored one, DROP the old-endpoint keys instead of merging
2736
+ // — otherwise the sync loop would POST keys issued for endpoint A to endpoint B, letting
2737
+ // a malicious/custom endpoint harvest prior auth keys (key exfiltration). Same endpoint →
2738
+ // accumulate (multi-key fleet on one cloud).
2739
+ // An ABSENT prior url = the implicit DEFAULT endpoint — treat it as DEFAULT_CLOUD.url so a
2740
+ // legacy/default config (has keys, no explicit url) pulling from a custom `--url` is detected
2741
+ // as an endpoint change and its old-endpoint keys are dropped (not merged onto the new URL).
2742
+ const endpointChanged = (c.cloud?.url || DEFAULT_CLOUD.url) !== cloud.url;
2743
+ c.cloud = {
2744
+ ...(c.cloud || {}),
2745
+ url: cloud.url, anonKey: cloud.anonKey, pullKey: key,
2746
+ pullKeys: endpointChanged
2747
+ ? { [keySource(key)]: key }
2748
+ : { ...(c.cloud?.pullKeys || {}), [keySource(key)]: key },
2749
+ };
2750
+ // The owner session (JWT) is endpoint-scoped too — drop it on an endpoint change so the
2751
+ // auto-push/refresh loops can't send the OLD endpoint's owner JWT (and account tokens) to
2752
+ // the NEW (possibly attacker) URL.
2753
+ if (endpointChanged) delete c.cloud.session;
1916
2754
  });
1917
2755
  const s = updated._pullSummary || { added: 0, updated: 0, skipped: 0, removed: 0 };
1918
2756
  await atomicConfigUpdate(c => { delete c._pullSummary; });
2757
+ // Apply to a RUNNING proxy NOW: without this the live server keeps stale config until
2758
+ // its next sync tick — and a server that started WITHOUT a key never had a sync/recovery
2759
+ // loop to pick this pull up at all (it would retain errored cloud-managed accounts until
2760
+ // a restart). onReload → syncAccountsFromDisk mirrors the new pullKeys and re-probes
2761
+ // errored cloud-managed accounts with the freshly-authorized key.
2762
+ await noteRunningServerReload(config).catch(() => {});
1919
2763
  const removedNote = s.removed ? `, ${s.removed} removed (revoked from group)` : '';
1920
2764
  console.log(`Pulled ${pulled.length} allowed account(s): +${s.added} added, ${s.updated} updated, ${s.skipped} kept-local (fresher)${removedNote}.`);
1921
- console.log('A running `teamclaude server` will keep this in sync automatically (token/group changes + key revocation apply live).');
2765
+ console.log('A running `teamclaude server` picks this up immediately (token/group changes + key revocation apply live).');
1922
2766
  }
1923
2767
 
1924
2768
  /** Resolve a group by name or id against the cloud's group list. */