index.ts
1 /** 2 * Settings Sync Service 3 * 4 * Syncs user settings and memory files across Claude Code environments. 5 * 6 * - Interactive CLI: Uploads local settings to remote (incremental, only changed entries) 7 * - CCR: Downloads remote settings to local before plugin installation 8 * 9 * Backend API: anthropic/anthropic#218817 10 */ 11 12 import { feature } from 'bun:bundle' 13 import axios from 'axios' 14 import { mkdir, readFile, stat, writeFile } from 'fs/promises' 15 import pickBy from 'lodash-es/pickBy.js' 16 import { dirname } from 'path' 17 import { getIsInteractive } from '../../bootstrap/state.js' 18 import { 19 CLAUDE_AI_INFERENCE_SCOPE, 20 getOauthConfig, 21 OAUTH_BETA_HEADER, 22 } from '../../constants/oauth.js' 23 import { 24 checkAndRefreshOAuthTokenIfNeeded, 25 getClaudeAIOAuthTokens, 26 } from '../../utils/auth.js' 27 import { clearMemoryFileCaches } from '../../utils/claudemd.js' 28 import { getMemoryPath } from '../../utils/config.js' 29 import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' 30 import { classifyAxiosError } from '../../utils/errors.js' 31 import { getRepoRemoteHash } from '../../utils/git.js' 32 import { 33 getAPIProvider, 34 isFirstPartyAnthropicBaseUrl, 35 } from '../../utils/model/providers.js' 36 import { markInternalWrite } from '../../utils/settings/internalWrites.js' 37 import { getSettingsFilePathForSource } from '../../utils/settings/settings.js' 38 import { resetSettingsCache } from '../../utils/settings/settingsCache.js' 39 import { sleep } from '../../utils/sleep.js' 40 import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 41 import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 42 import { logEvent } from '../analytics/index.js' 43 import { getRetryDelay } from '../api/withRetry.js' 44 import { 45 type SettingsSyncFetchResult, 46 type SettingsSyncUploadResult, 47 SYNC_KEYS, 48 UserSyncDataSchema, 49 } from './types.js' 50 51 const SETTINGS_SYNC_TIMEOUT_MS = 10000 // 10 seconds 52 const DEFAULT_MAX_RETRIES = 3 53 const MAX_FILE_SIZE_BYTES = 500 * 1024 // 500 KB per file (matches backend limit) 54 55 /** 56 * Upload local settings to remote (interactive CLI only). 57 * Called from main.tsx preAction. 58 * Runs in background - caller should not await unless needed. 59 */ 60 export async function uploadUserSettingsInBackground(): Promise<void> { 61 try { 62 if ( 63 !feature('UPLOAD_USER_SETTINGS') || 64 !getFeatureValue_CACHED_MAY_BE_STALE( 65 'tengu_enable_settings_sync_push', 66 false, 67 ) || 68 !getIsInteractive() || 69 !isUsingOAuth() 70 ) { 71 logForDiagnosticsNoPII('info', 'settings_sync_upload_skipped') 72 logEvent('tengu_settings_sync_upload_skipped_ineligible', {}) 73 return 74 } 75 76 logForDiagnosticsNoPII('info', 'settings_sync_upload_starting') 77 const result = await fetchUserSettings() 78 if (!result.success) { 79 logForDiagnosticsNoPII('warn', 'settings_sync_upload_fetch_failed') 80 logEvent('tengu_settings_sync_upload_fetch_failed', {}) 81 return 82 } 83 84 const projectId = await getRepoRemoteHash() 85 const localEntries = await buildEntriesFromLocalFiles(projectId) 86 const remoteEntries = result.isEmpty ? {} : result.data!.content.entries 87 const changedEntries = pickBy( 88 localEntries, 89 (value, key) => remoteEntries[key] !== value, 90 ) 91 92 const entryCount = Object.keys(changedEntries).length 93 if (entryCount === 0) { 94 logForDiagnosticsNoPII('info', 'settings_sync_upload_no_changes') 95 logEvent('tengu_settings_sync_upload_skipped', {}) 96 return 97 } 98 99 const uploadResult = await uploadUserSettings(changedEntries) 100 if (uploadResult.success) { 101 logForDiagnosticsNoPII('info', 'settings_sync_upload_success') 102 logEvent('tengu_settings_sync_upload_success', { entryCount }) 103 } else { 104 logForDiagnosticsNoPII('warn', 'settings_sync_upload_failed') 105 logEvent('tengu_settings_sync_upload_failed', { entryCount }) 106 } 107 } catch { 108 // Fail-open: log unexpected errors but don't block startup 109 logForDiagnosticsNoPII('error', 'settings_sync_unexpected_error') 110 } 111 } 112 113 // Cached so the fire-and-forget at runHeadless entry and the await in 114 // installPluginsAndApplyMcpInBackground share one fetch. 115 let downloadPromise: Promise<boolean> | null = null 116 117 /** Test-only: clear the cached download promise between tests. */ 118 export function _resetDownloadPromiseForTesting(): void { 119 downloadPromise = null 120 } 121 122 /** 123 * Download settings from remote for CCR mode. 124 * Fired fire-and-forget at the top of print.ts runHeadless(); awaited in 125 * installPluginsAndApplyMcpInBackground before plugin install. First call 126 * starts the fetch; subsequent calls join it. 127 * Returns true if settings were applied, false otherwise. 128 */ 129 export function downloadUserSettings(): Promise<boolean> { 130 if (downloadPromise) { 131 return downloadPromise 132 } 133 downloadPromise = doDownloadUserSettings() 134 return downloadPromise 135 } 136 137 /** 138 * Force a fresh download, bypassing the cached startup promise. 139 * Called by /reload-plugins in CCR so mid-session settings changes 140 * (enabledPlugins, extraKnownMarketplaces) pushed from the user's local 141 * CLI are picked up before the plugin-cache sweep. 142 * 143 * No retries: user-initiated command, one attempt + fail-open. The user 144 * can re-run /reload-plugins to retry. Startup path keeps DEFAULT_MAX_RETRIES. 145 * 146 * Caller is responsible for firing settingsChangeDetector.notifyChange 147 * when this returns true — applyRemoteEntriesToLocal uses markInternalWrite 148 * to suppress detection (correct for startup, but mid-session needs 149 * applySettingsChange to run). Kept out of this module to avoid the 150 * settingsSync → changeDetector cycle edge. 151 */ 152 export function redownloadUserSettings(): Promise<boolean> { 153 downloadPromise = doDownloadUserSettings(0) 154 return downloadPromise 155 } 156 157 async function doDownloadUserSettings( 158 maxRetries = DEFAULT_MAX_RETRIES, 159 ): Promise<boolean> { 160 if (feature('DOWNLOAD_USER_SETTINGS')) { 161 try { 162 if ( 163 !getFeatureValue_CACHED_MAY_BE_STALE('tengu_strap_foyer', false) || 164 !isUsingOAuth() 165 ) { 166 logForDiagnosticsNoPII('info', 'settings_sync_download_skipped') 167 logEvent('tengu_settings_sync_download_skipped', {}) 168 return false 169 } 170 171 logForDiagnosticsNoPII('info', 'settings_sync_download_starting') 172 const result = await fetchUserSettings(maxRetries) 173 if (!result.success) { 174 logForDiagnosticsNoPII('warn', 'settings_sync_download_fetch_failed') 175 logEvent('tengu_settings_sync_download_fetch_failed', {}) 176 return false 177 } 178 179 if (result.isEmpty) { 180 logForDiagnosticsNoPII('info', 'settings_sync_download_empty') 181 logEvent('tengu_settings_sync_download_empty', {}) 182 return false 183 } 184 185 const entries = result.data!.content.entries 186 const projectId = await getRepoRemoteHash() 187 const entryCount = Object.keys(entries).length 188 logForDiagnosticsNoPII('info', 'settings_sync_download_applying', { 189 entryCount, 190 }) 191 await applyRemoteEntriesToLocal(entries, projectId) 192 logEvent('tengu_settings_sync_download_success', { entryCount }) 193 return true 194 } catch { 195 // Fail-open: log error but don't block CCR startup 196 logForDiagnosticsNoPII('error', 'settings_sync_download_error') 197 logEvent('tengu_settings_sync_download_error', {}) 198 return false 199 } 200 } 201 return false 202 } 203 204 /** 205 * Check if user is authenticated with first-party OAuth. 206 * Required for settings sync in both CLI (upload) and CCR (download) modes. 207 * 208 * Only checks user:inference (not user:profile) — CCR's file-descriptor token 209 * hardcodes scopes to ['user:inference'] only, so requiring profile would make 210 * download a no-op there. Upload is independently guarded by getIsInteractive(). 211 */ 212 function isUsingOAuth(): boolean { 213 if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) { 214 return false 215 } 216 217 const tokens = getClaudeAIOAuthTokens() 218 return Boolean( 219 tokens?.accessToken && tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE), 220 ) 221 } 222 223 function getSettingsSyncEndpoint(): string { 224 return `${getOauthConfig().BASE_API_URL}/api/claude_code/user_settings` 225 } 226 227 function getSettingsSyncAuthHeaders(): { 228 headers: Record<string, string> 229 error?: string 230 } { 231 const oauthTokens = getClaudeAIOAuthTokens() 232 if (oauthTokens?.accessToken) { 233 return { 234 headers: { 235 Authorization: `Bearer ${oauthTokens.accessToken}`, 236 'anthropic-beta': OAUTH_BETA_HEADER, 237 }, 238 } 239 } 240 241 return { 242 headers: {}, 243 error: 'No OAuth token available', 244 } 245 } 246 247 async function fetchUserSettingsOnce(): Promise<SettingsSyncFetchResult> { 248 try { 249 await checkAndRefreshOAuthTokenIfNeeded() 250 251 const authHeaders = getSettingsSyncAuthHeaders() 252 if (authHeaders.error) { 253 return { 254 success: false, 255 error: authHeaders.error, 256 skipRetry: true, 257 } 258 } 259 260 const headers: Record<string, string> = { 261 ...authHeaders.headers, 262 'User-Agent': getClaudeCodeUserAgent(), 263 } 264 265 const endpoint = getSettingsSyncEndpoint() 266 const response = await axios.get(endpoint, { 267 headers, 268 timeout: SETTINGS_SYNC_TIMEOUT_MS, 269 validateStatus: status => status === 200 || status === 404, 270 }) 271 272 // 404 means no settings exist yet 273 if (response.status === 404) { 274 logForDiagnosticsNoPII('info', 'settings_sync_fetch_empty') 275 return { 276 success: true, 277 isEmpty: true, 278 } 279 } 280 281 const parsed = UserSyncDataSchema().safeParse(response.data) 282 if (!parsed.success) { 283 logForDiagnosticsNoPII('warn', 'settings_sync_fetch_invalid_format') 284 return { 285 success: false, 286 error: 'Invalid settings sync response format', 287 } 288 } 289 290 logForDiagnosticsNoPII('info', 'settings_sync_fetch_success') 291 return { 292 success: true, 293 data: parsed.data, 294 isEmpty: false, 295 } 296 } catch (error) { 297 const { kind, message } = classifyAxiosError(error) 298 switch (kind) { 299 case 'auth': 300 return { 301 success: false, 302 error: 'Not authorized for settings sync', 303 skipRetry: true, 304 } 305 case 'timeout': 306 return { success: false, error: 'Settings sync request timeout' } 307 case 'network': 308 return { success: false, error: 'Cannot connect to server' } 309 default: 310 return { success: false, error: message } 311 } 312 } 313 } 314 315 async function fetchUserSettings( 316 maxRetries = DEFAULT_MAX_RETRIES, 317 ): Promise<SettingsSyncFetchResult> { 318 let lastResult: SettingsSyncFetchResult | null = null 319 320 for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { 321 lastResult = await fetchUserSettingsOnce() 322 323 if (lastResult.success) { 324 return lastResult 325 } 326 327 if (lastResult.skipRetry) { 328 return lastResult 329 } 330 331 if (attempt > maxRetries) { 332 return lastResult 333 } 334 335 const delayMs = getRetryDelay(attempt) 336 logForDiagnosticsNoPII('info', 'settings_sync_retry', { 337 attempt, 338 maxRetries, 339 delayMs, 340 }) 341 await sleep(delayMs) 342 } 343 344 return lastResult! 345 } 346 347 async function uploadUserSettings( 348 entries: Record<string, string>, 349 ): Promise<SettingsSyncUploadResult> { 350 try { 351 await checkAndRefreshOAuthTokenIfNeeded() 352 353 const authHeaders = getSettingsSyncAuthHeaders() 354 if (authHeaders.error) { 355 return { 356 success: false, 357 error: authHeaders.error, 358 } 359 } 360 361 const headers: Record<string, string> = { 362 ...authHeaders.headers, 363 'User-Agent': getClaudeCodeUserAgent(), 364 'Content-Type': 'application/json', 365 } 366 367 const endpoint = getSettingsSyncEndpoint() 368 const response = await axios.put( 369 endpoint, 370 { entries }, 371 { 372 headers, 373 timeout: SETTINGS_SYNC_TIMEOUT_MS, 374 }, 375 ) 376 377 logForDiagnosticsNoPII('info', 'settings_sync_uploaded', { 378 entryCount: Object.keys(entries).length, 379 }) 380 return { 381 success: true, 382 checksum: response.data?.checksum, 383 lastModified: response.data?.lastModified, 384 } 385 } catch (error) { 386 logForDiagnosticsNoPII('warn', 'settings_sync_upload_error') 387 return { 388 success: false, 389 error: error instanceof Error ? error.message : 'Unknown error', 390 } 391 } 392 } 393 394 /** 395 * Try to read a file for sync, with size limit and error handling. 396 * Returns null if file doesn't exist, is empty, or exceeds size limit. 397 */ 398 async function tryReadFileForSync(filePath: string): Promise<string | null> { 399 try { 400 const stats = await stat(filePath) 401 if (stats.size > MAX_FILE_SIZE_BYTES) { 402 logForDiagnosticsNoPII('info', 'settings_sync_file_too_large') 403 return null 404 } 405 406 const content = await readFile(filePath, 'utf8') 407 // Check for empty/whitespace-only without allocating a trimmed copy 408 if (!content || /^\s*$/.test(content)) { 409 return null 410 } 411 412 return content 413 } catch { 414 return null 415 } 416 } 417 418 async function buildEntriesFromLocalFiles( 419 projectId: string | null, 420 ): Promise<Record<string, string>> { 421 const entries: Record<string, string> = {} 422 423 // Global user settings 424 const userSettingsPath = getSettingsFilePathForSource('userSettings') 425 if (userSettingsPath) { 426 const content = await tryReadFileForSync(userSettingsPath) 427 if (content) { 428 entries[SYNC_KEYS.USER_SETTINGS] = content 429 } 430 } 431 432 // Global user memory 433 const userMemoryPath = getMemoryPath('User') 434 const userMemoryContent = await tryReadFileForSync(userMemoryPath) 435 if (userMemoryContent) { 436 entries[SYNC_KEYS.USER_MEMORY] = userMemoryContent 437 } 438 439 // Project-specific files (only if we have a project ID from git remote) 440 if (projectId) { 441 // Project local settings 442 const localSettingsPath = getSettingsFilePathForSource('localSettings') 443 if (localSettingsPath) { 444 const content = await tryReadFileForSync(localSettingsPath) 445 if (content) { 446 entries[SYNC_KEYS.projectSettings(projectId)] = content 447 } 448 } 449 450 // Project local memory 451 const localMemoryPath = getMemoryPath('Local') 452 const localMemoryContent = await tryReadFileForSync(localMemoryPath) 453 if (localMemoryContent) { 454 entries[SYNC_KEYS.projectMemory(projectId)] = localMemoryContent 455 } 456 } 457 458 return entries 459 } 460 461 async function writeFileForSync( 462 filePath: string, 463 content: string, 464 ): Promise<boolean> { 465 try { 466 const parentDir = dirname(filePath) 467 if (parentDir) { 468 await mkdir(parentDir, { recursive: true }) 469 } 470 471 await writeFile(filePath, content, 'utf8') 472 logForDiagnosticsNoPII('info', 'settings_sync_file_written') 473 return true 474 } catch { 475 logForDiagnosticsNoPII('warn', 'settings_sync_file_write_failed') 476 return false 477 } 478 } 479 480 /** 481 * Apply remote entries to local files (CCR pull pattern). 482 * Only writes files that match expected keys. 483 * 484 * After writing, invalidates relevant caches: 485 * - resetSettingsCache() for settings files 486 * - clearMemoryFileCaches() for memory files (CLAUDE.md) 487 */ 488 async function applyRemoteEntriesToLocal( 489 entries: Record<string, string>, 490 projectId: string | null, 491 ): Promise<void> { 492 let appliedCount = 0 493 let settingsWritten = false 494 let memoryWritten = false 495 496 // Helper to check size limit (defense-in-depth, matches backend limit) 497 const exceedsSizeLimit = (content: string, _path: string): boolean => { 498 const sizeBytes = Buffer.byteLength(content, 'utf8') 499 if (sizeBytes > MAX_FILE_SIZE_BYTES) { 500 logForDiagnosticsNoPII('info', 'settings_sync_file_too_large', { 501 sizeBytes, 502 maxBytes: MAX_FILE_SIZE_BYTES, 503 }) 504 return true 505 } 506 return false 507 } 508 509 // Apply global user settings 510 const userSettingsContent = entries[SYNC_KEYS.USER_SETTINGS] 511 if (userSettingsContent) { 512 const userSettingsPath = getSettingsFilePathForSource('userSettings') 513 if ( 514 userSettingsPath && 515 !exceedsSizeLimit(userSettingsContent, userSettingsPath) 516 ) { 517 // Mark as internal write to prevent spurious change detection 518 markInternalWrite(userSettingsPath) 519 if (await writeFileForSync(userSettingsPath, userSettingsContent)) { 520 appliedCount++ 521 settingsWritten = true 522 } 523 } 524 } 525 526 // Apply global user memory 527 const userMemoryContent = entries[SYNC_KEYS.USER_MEMORY] 528 if (userMemoryContent) { 529 const userMemoryPath = getMemoryPath('User') 530 if (!exceedsSizeLimit(userMemoryContent, userMemoryPath)) { 531 if (await writeFileForSync(userMemoryPath, userMemoryContent)) { 532 appliedCount++ 533 memoryWritten = true 534 } 535 } 536 } 537 538 // Apply project-specific files (only if project ID matches) 539 if (projectId) { 540 const projectSettingsKey = SYNC_KEYS.projectSettings(projectId) 541 const projectSettingsContent = entries[projectSettingsKey] 542 if (projectSettingsContent) { 543 const localSettingsPath = getSettingsFilePathForSource('localSettings') 544 if ( 545 localSettingsPath && 546 !exceedsSizeLimit(projectSettingsContent, localSettingsPath) 547 ) { 548 // Mark as internal write to prevent spurious change detection 549 markInternalWrite(localSettingsPath) 550 if (await writeFileForSync(localSettingsPath, projectSettingsContent)) { 551 appliedCount++ 552 settingsWritten = true 553 } 554 } 555 } 556 557 const projectMemoryKey = SYNC_KEYS.projectMemory(projectId) 558 const projectMemoryContent = entries[projectMemoryKey] 559 if (projectMemoryContent) { 560 const localMemoryPath = getMemoryPath('Local') 561 if (!exceedsSizeLimit(projectMemoryContent, localMemoryPath)) { 562 if (await writeFileForSync(localMemoryPath, projectMemoryContent)) { 563 appliedCount++ 564 memoryWritten = true 565 } 566 } 567 } 568 } 569 570 // Invalidate caches so subsequent reads pick up new content 571 if (settingsWritten) { 572 resetSettingsCache() 573 } 574 if (memoryWritten) { 575 clearMemoryFileCaches() 576 } 577 578 logForDiagnosticsNoPII('info', 'settings_sync_applied', { 579 appliedCount, 580 }) 581 }