teamclaude-cloud 1.8.1 → 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/package.json +1 -1
- package/src/account-manager.js +51 -1
- package/src/cloud.js +181 -7
- package/src/index.js +961 -117
- package/src/server.js +84 -13
- package/src/tui.js +7 -0
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,
|
|
10
|
-
import { cloudLogin, cloudValidateOwner, cloudRefreshSession, cloudPush, cloudPushUsage, cloudReportUsage, cloudPull,
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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:
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -943,22 +1094,59 @@ async function accountsCommand() {
|
|
|
943
1094
|
return;
|
|
944
1095
|
}
|
|
945
1096
|
|
|
946
|
-
// Refresh
|
|
947
|
-
|
|
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
|
|
953
|
-
|
|
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
|
-
|
|
1114
|
+
refreshed.push({ account: a, newTokens, parentRefreshToken });
|
|
957
1115
|
} catch {
|
|
958
|
-
// refresh failed
|
|
1116
|
+
// refresh failed (or cloud-managed + cloud unreachable — deliberately NOT rotated
|
|
1117
|
+
// locally) — fetchProfile will report the specific error
|
|
959
1118
|
}
|
|
960
1119
|
}));
|
|
961
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1422
|
-
|
|
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 {
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
|
|
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 {
|
|
1477
|
-
}
|
|
1478
|
-
|
|
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
|
-
|
|
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).
|
|
1487
|
-
|
|
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
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 => {
|
|
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 => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1889
|
-
//
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
1909
|
-
|
|
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
|
-
|
|
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`
|
|
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. */
|