/ services / settingsSync / index.ts
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  }