index.ts
  1  /**
  2   * Remote Managed Settings Service
  3   *
  4   * Manages fetching, caching, and validation of remote-managed settings
  5   * for enterprise customers. Uses checksum-based validation to minimize
  6   * network traffic and provides graceful degradation on failures.
  7   *
  8   * Eligibility:
  9   * - Console users (API key): All eligible
 10   * - OAuth users (Claude.ai): Only Enterprise/C4E and Team subscribers are eligible
 11   * - API fails open (non-blocking) - if fetch fails, continues without remote settings
 12   * - API returns empty settings for users without managed settings
 13   */
 14  
 15  import axios from 'axios'
 16  import { createHash } from 'crypto'
 17  import { open, unlink } from 'fs/promises'
 18  import { getOauthConfig, OAUTH_BETA_HEADER } from '../../constants/oauth.js'
 19  import {
 20    checkAndRefreshOAuthTokenIfNeeded,
 21    getAnthropicApiKeyWithSource,
 22    getClaudeAIOAuthTokens,
 23  } from '../../utils/auth.js'
 24  import { registerCleanup } from '../../utils/cleanupRegistry.js'
 25  import { logForDebugging } from '../../utils/debug.js'
 26  import { classifyAxiosError, getErrnoCode } from '../../utils/errors.js'
 27  import { settingsChangeDetector } from '../../utils/settings/changeDetector.js'
 28  import {
 29    type SettingsJson,
 30    SettingsSchema,
 31  } from '../../utils/settings/types.js'
 32  import { sleep } from '../../utils/sleep.js'
 33  import { jsonStringify } from '../../utils/slowOperations.js'
 34  import { getClaudeCodeUserAgent } from '../../utils/userAgent.js'
 35  import { getRetryDelay } from '../api/withRetry.js'
 36  import {
 37    checkManagedSettingsSecurity,
 38    handleSecurityCheckResult,
 39  } from './securityCheck.jsx'
 40  import { isRemoteManagedSettingsEligible, resetSyncCache } from './syncCache.js'
 41  import {
 42    getRemoteManagedSettingsSyncFromCache,
 43    getSettingsPath,
 44    setSessionCache,
 45  } from './syncCacheState.js'
 46  import {
 47    type RemoteManagedSettingsFetchResult,
 48    RemoteManagedSettingsResponseSchema,
 49  } from './types.js'
 50  
 51  // Constants
 52  const SETTINGS_TIMEOUT_MS = 10000 // 10 seconds for settings fetch
 53  const DEFAULT_MAX_RETRIES = 5
 54  const POLLING_INTERVAL_MS = 60 * 60 * 1000 // 1 hour
 55  
 56  // Background polling state
 57  let pollingIntervalId: ReturnType<typeof setInterval> | null = null
 58  
 59  // Promise that resolves when initial remote settings loading completes
 60  // This allows other systems to wait for remote settings before initializing
 61  let loadingCompletePromise: Promise<void> | null = null
 62  let loadingCompleteResolve: (() => void) | null = null
 63  
 64  // Timeout for the loading promise to prevent deadlocks if loadRemoteManagedSettings() is never called
 65  // (e.g., in Agent SDK tests that don't go through main.tsx)
 66  const LOADING_PROMISE_TIMEOUT_MS = 30000 // 30 seconds
 67  
 68  /**
 69   * Initialize the loading promise for remote managed settings
 70   * This should be called early (e.g., in init.ts) to allow other systems
 71   * to await remote settings loading even if loadRemoteManagedSettings()
 72   * hasn't been called yet.
 73   *
 74   * Only creates the promise if the user is eligible for remote settings.
 75   * Includes a timeout to prevent deadlocks if loadRemoteManagedSettings() is never called.
 76   */
 77  export function initializeRemoteManagedSettingsLoadingPromise(): void {
 78    if (loadingCompletePromise) {
 79      return
 80    }
 81  
 82    if (isRemoteManagedSettingsEligible()) {
 83      loadingCompletePromise = new Promise(resolve => {
 84        loadingCompleteResolve = resolve
 85  
 86        // Set a timeout to resolve the promise even if loadRemoteManagedSettings() is never called
 87        // This prevents deadlocks in Agent SDK tests and other non-CLI contexts
 88        setTimeout(() => {
 89          if (loadingCompleteResolve) {
 90            logForDebugging(
 91              'Remote settings: Loading promise timed out, resolving anyway',
 92            )
 93            loadingCompleteResolve()
 94            loadingCompleteResolve = null
 95          }
 96        }, LOADING_PROMISE_TIMEOUT_MS)
 97      })
 98    }
 99  }
100  
101  /**
102   * Get the remote settings API endpoint
103   * Uses the OAuth config base API URL
104   */
105  function getRemoteManagedSettingsEndpoint() {
106    return `${getOauthConfig().BASE_API_URL}/api/claude_code/settings`
107  }
108  
109  /**
110   * Recursively sort all keys in an object to match Python's json.dumps(sort_keys=True)
111   */
112  function sortKeysDeep(obj: unknown): unknown {
113    if (Array.isArray(obj)) {
114      return obj.map(sortKeysDeep)
115    }
116    if (obj !== null && typeof obj === 'object') {
117      const sorted: Record<string, unknown> = {}
118      for (const key of Object.keys(obj).sort()) {
119        sorted[key] = sortKeysDeep((obj as Record<string, unknown>)[key])
120      }
121      return sorted
122    }
123    return obj
124  }
125  
126  /**
127   * Compute checksum from settings content for HTTP caching
128   * Must match server's Python: json.dumps(settings, sort_keys=True, separators=(",", ":"))
129   * Exported for testing to verify compatibility with server-side implementation
130   */
131  export function computeChecksumFromSettings(settings: SettingsJson): string {
132    const sorted = sortKeysDeep(settings)
133    // No spaces after separators to match Python's separators=(",", ":")
134    const normalized = jsonStringify(sorted)
135    const hash = createHash('sha256').update(normalized).digest('hex')
136    return `sha256:${hash}`
137  }
138  
139  /**
140   * Check if the current user is eligible for remote managed settings
141   * This is the public API for other systems to check eligibility
142   * Used to determine if they should wait for remote settings to load
143   */
144  export function isEligibleForRemoteManagedSettings(): boolean {
145    return isRemoteManagedSettingsEligible()
146  }
147  
148  /**
149   * Wait for the initial remote settings loading to complete
150   * Returns immediately if:
151   * - User is not eligible for remote settings
152   * - Loading has already completed
153   * - Loading was never started
154   */
155  export async function waitForRemoteManagedSettingsToLoad(): Promise<void> {
156    if (loadingCompletePromise) {
157      await loadingCompletePromise
158    }
159  }
160  
161  /**
162   * Get auth headers for remote settings without calling getSettings()
163   * This avoids circular dependencies during settings loading
164   * Supports both API key and OAuth authentication
165   */
166  function getRemoteSettingsAuthHeaders(): {
167    headers: Record<string, string>
168    error?: string
169  } {
170    // Try API key first (for Console users)
171    // Skip apiKeyHelper to avoid circular dependency with getSettings()
172    // Wrap in try-catch because getAnthropicApiKeyWithSource throws in CI/test environments
173    try {
174      const { key: apiKey } = getAnthropicApiKeyWithSource({
175        skipRetrievingKeyFromApiKeyHelper: true,
176      })
177      if (apiKey) {
178        return {
179          headers: {
180            'x-api-key': apiKey,
181          },
182        }
183      }
184    } catch {
185      // No API key available - continue to check OAuth
186    }
187  
188    // Fall back to OAuth tokens (for Claude.ai users)
189    const oauthTokens = getClaudeAIOAuthTokens()
190    if (oauthTokens?.accessToken) {
191      return {
192        headers: {
193          Authorization: `Bearer ${oauthTokens.accessToken}`,
194          'anthropic-beta': OAUTH_BETA_HEADER,
195        },
196      }
197    }
198  
199    return {
200      headers: {},
201      error: 'No authentication available',
202    }
203  }
204  
205  /**
206   * Fetch remote settings with retry logic and exponential backoff
207   * Uses existing codebase retry utilities for consistency
208   */
209  async function fetchWithRetry(
210    cachedChecksum?: string,
211  ): Promise<RemoteManagedSettingsFetchResult> {
212    let lastResult: RemoteManagedSettingsFetchResult | null = null
213  
214    for (let attempt = 1; attempt <= DEFAULT_MAX_RETRIES + 1; attempt++) {
215      lastResult = await fetchRemoteManagedSettings(cachedChecksum)
216  
217      // Return immediately on success
218      if (lastResult.success) {
219        return lastResult
220      }
221  
222      // Don't retry if the error is not retryable (e.g., auth errors)
223      if (lastResult.skipRetry) {
224        return lastResult
225      }
226  
227      // If we've exhausted retries, return the last error
228      if (attempt > DEFAULT_MAX_RETRIES) {
229        return lastResult
230      }
231  
232      // Calculate delay and wait before next retry
233      const delayMs = getRetryDelay(attempt)
234      logForDebugging(
235        `Remote settings: Retry ${attempt}/${DEFAULT_MAX_RETRIES} after ${delayMs}ms`,
236      )
237      await sleep(delayMs)
238    }
239  
240    // Should never reach here, but TypeScript needs it
241    return lastResult!
242  }
243  
244  /**
245   * Fetch the full remote settings (single attempt, no retries)
246   * Optionally pass a cached checksum for ETag-based caching
247   */
248  async function fetchRemoteManagedSettings(
249    cachedChecksum?: string,
250  ): Promise<RemoteManagedSettingsFetchResult> {
251    try {
252      // Ensure OAuth token is fresh before fetching settings
253      // This prevents 401 errors from stale cached tokens
254      await checkAndRefreshOAuthTokenIfNeeded()
255  
256      // Use local auth header getter to avoid circular dependency with getSettings()
257      const authHeaders = getRemoteSettingsAuthHeaders()
258      if (authHeaders.error) {
259        // Auth errors should not be retried - return a special flag to skip retries
260        return {
261          success: false,
262          error: `Authentication required for remote settings`,
263          skipRetry: true,
264        }
265      }
266  
267      const endpoint = getRemoteManagedSettingsEndpoint()
268      const headers: Record<string, string> = {
269        ...authHeaders.headers,
270        'User-Agent': getClaudeCodeUserAgent(),
271      }
272  
273      // Add If-None-Match header for ETag-based caching
274      if (cachedChecksum) {
275        headers['If-None-Match'] = `"${cachedChecksum}"`
276      }
277  
278      const response = await axios.get(endpoint, {
279        headers,
280        timeout: SETTINGS_TIMEOUT_MS,
281        // Allow 204, 304, and 404 responses without treating them as errors.
282        // 204/404 are returned when no settings exist for the user or the feature flag is off.
283        validateStatus: status =>
284          status === 200 || status === 204 || status === 304 || status === 404,
285      })
286  
287      // Handle 304 Not Modified - cached version is still valid
288      if (response.status === 304) {
289        logForDebugging('Remote settings: Using cached settings (304)')
290        return {
291          success: true,
292          settings: null, // Signal that cache is valid
293          checksum: cachedChecksum,
294        }
295      }
296  
297      // Handle 204 No Content / 404 Not Found - no settings exist or feature flag is off.
298      // Return empty object (not null) so callers don't fall back to cached settings.
299      if (response.status === 204 || response.status === 404) {
300        logForDebugging(`Remote settings: No settings found (${response.status})`)
301        return {
302          success: true,
303          settings: {},
304          checksum: undefined,
305        }
306      }
307  
308      const parsed = RemoteManagedSettingsResponseSchema().safeParse(
309        response.data,
310      )
311      if (!parsed.success) {
312        logForDebugging(
313          `Remote settings: Invalid response format - ${parsed.error.message}`,
314        )
315        return {
316          success: false,
317          error: 'Invalid remote settings format',
318        }
319      }
320  
321      // Full validation of settings structure
322      const settingsValidation = SettingsSchema().safeParse(parsed.data.settings)
323      if (!settingsValidation.success) {
324        logForDebugging(
325          `Remote settings: Settings validation failed - ${settingsValidation.error.message}`,
326        )
327        return {
328          success: false,
329          error: 'Invalid settings structure',
330        }
331      }
332  
333      logForDebugging('Remote settings: Fetched successfully')
334      return {
335        success: true,
336        settings: settingsValidation.data,
337        checksum: parsed.data.checksum,
338      }
339    } catch (error) {
340      const { kind, status, message } = classifyAxiosError(error)
341      if (status === 404) {
342        // 404 means no remote settings configured
343        return { success: true, settings: {}, checksum: '' }
344      }
345      switch (kind) {
346        case 'auth':
347          // Auth errors (401, 403) should not be retried - the API key doesn't have access
348          return {
349            success: false,
350            error: 'Not authorized for remote settings',
351            skipRetry: true,
352          }
353        case 'timeout':
354          return { success: false, error: 'Remote settings request timeout' }
355        case 'network':
356          return { success: false, error: 'Cannot connect to server' }
357        default:
358          return { success: false, error: message }
359      }
360    }
361  }
362  
363  /**
364   * Save remote settings to file
365   * Stores raw settings JSON (checksum is computed on-demand when needed)
366   */
367  async function saveSettings(settings: SettingsJson): Promise<void> {
368    try {
369      const path = getSettingsPath()
370      const handle = await open(path, 'w', 0o600)
371      try {
372        await handle.writeFile(jsonStringify(settings, null, 2), {
373          encoding: 'utf-8',
374        })
375        await handle.datasync()
376      } finally {
377        await handle.close()
378      }
379      logForDebugging(`Remote settings: Saved to ${path}`)
380    } catch (error) {
381      logForDebugging(
382        `Remote settings: Failed to save - ${error instanceof Error ? error.message : 'unknown error'}`,
383      )
384      // Ignore save errors - we'll refetch on next startup
385    }
386  }
387  
388  /**
389   * Clear all remote settings (session, persistent, and stop polling)
390   */
391  export async function clearRemoteManagedSettingsCache(): Promise<void> {
392    // Stop background polling
393    stopBackgroundPolling()
394  
395    // Clear session cache
396    resetSyncCache()
397  
398    // Clear loading promise state
399    loadingCompletePromise = null
400    loadingCompleteResolve = null
401  
402    try {
403      const path = getSettingsPath()
404      await unlink(path)
405    } catch {
406      // Ignore errors when clearing file (ENOENT is expected)
407    }
408  }
409  
410  /**
411   * Fetch and load remote settings with file caching
412   * Internal function that handles the full load/fetch logic
413   * Fails open - returns null if fetch fails and no cache exists
414   */
415  async function fetchAndLoadRemoteManagedSettings(): Promise<SettingsJson | null> {
416    if (!isRemoteManagedSettingsEligible()) {
417      return null
418    }
419  
420    // Load cached settings from file
421    const cachedSettings = getRemoteManagedSettingsSyncFromCache()
422  
423    // Compute checksum locally from cached settings for HTTP caching validation
424    const cachedChecksum = cachedSettings
425      ? computeChecksumFromSettings(cachedSettings)
426      : undefined
427  
428    try {
429      // Fetch settings from API with retry logic
430      const result = await fetchWithRetry(cachedChecksum)
431  
432      if (!result.success) {
433        // On fetch failure, use stale file if available (graceful degradation)
434        if (cachedSettings) {
435          logForDebugging(
436            'Remote settings: Using stale cache after fetch failure',
437          )
438          setSessionCache(cachedSettings)
439          return cachedSettings
440        }
441        // No cache available - fail open, continue without remote settings
442        return null
443      }
444  
445      // Handle 304 Not Modified - cached settings are still valid
446      if (result.settings === null && cachedSettings) {
447        logForDebugging('Remote settings: Cache still valid (304 Not Modified)')
448        setSessionCache(cachedSettings)
449        return cachedSettings
450      }
451  
452      // Save new settings to file (only if non-empty)
453      const newSettings = result.settings || {}
454      const hasContent = Object.keys(newSettings).length > 0
455  
456      if (hasContent) {
457        // Check for dangerous settings changes before applying
458        const securityResult = await checkManagedSettingsSecurity(
459          cachedSettings,
460          newSettings,
461        )
462        if (!handleSecurityCheckResult(securityResult)) {
463          // User rejected - don't apply settings, return cached or null
464          logForDebugging(
465            'Remote settings: User rejected new settings, using cached settings',
466          )
467          return cachedSettings
468        }
469  
470        setSessionCache(newSettings)
471        await saveSettings(newSettings)
472        logForDebugging('Remote settings: Applied new settings successfully')
473        return newSettings
474      }
475  
476      // Empty settings (404 response) - delete cached file if it exists
477      // This ensures stale settings don't persist when a user's remote settings are removed
478      setSessionCache(newSettings)
479      try {
480        const path = getSettingsPath()
481        await unlink(path)
482        logForDebugging('Remote settings: Deleted cached file (404 response)')
483      } catch (e) {
484        const code = getErrnoCode(e)
485        if (code !== 'ENOENT') {
486          logForDebugging(
487            `Remote settings: Failed to delete cached file - ${e instanceof Error ? e.message : 'unknown error'}`,
488          )
489        }
490      }
491      return newSettings
492    } catch {
493      // On any error, use stale file if available (graceful degradation)
494      if (cachedSettings) {
495        logForDebugging('Remote settings: Using stale cache after error')
496        setSessionCache(cachedSettings)
497        return cachedSettings
498      }
499  
500      // No cache available - fail open, continue without remote settings
501      return null
502    }
503  }
504  
505  /**
506   * Load remote settings during CLI initialization
507   * Fails open - if fetch fails, continues without remote settings
508   * Also starts background polling to pick up settings changes mid-session
509   *
510   * This function sets up a promise that other systems can await via
511   * waitForRemoteManagedSettingsToLoad() to ensure they don't initialize
512   * until remote settings have been fetched.
513   */
514  export async function loadRemoteManagedSettings(): Promise<void> {
515    // Set up the promise for other systems to wait on
516    // Only if the user is eligible for remote settings AND promise not already set up
517    // (initializeRemoteManagedSettingsLoadingPromise may have been called earlier)
518    if (isRemoteManagedSettingsEligible() && !loadingCompletePromise) {
519      loadingCompletePromise = new Promise(resolve => {
520        loadingCompleteResolve = resolve
521      })
522    }
523  
524    // Cache-first: if we have cached settings on disk, apply them and unblock
525    // waiters immediately. The fetch still runs below; notifyChange fires once,
526    // after the fetch, as before. Saves the ~77ms fetch-wait on print-mode startup.
527    // getRemoteManagedSettingsSyncFromCache has the eligibility guard and populates
528    // the session cache internally — no need to call setSessionCache here.
529    if (getRemoteManagedSettingsSyncFromCache() && loadingCompleteResolve) {
530      loadingCompleteResolve()
531      loadingCompleteResolve = null
532    }
533  
534    try {
535      const settings = await fetchAndLoadRemoteManagedSettings()
536  
537      // Start background polling to pick up settings changes mid-session
538      if (isRemoteManagedSettingsEligible()) {
539        startBackgroundPolling()
540      }
541  
542      // Trigger hot-reload if settings were loaded (new or from cache).
543      // notifyChange resets the settings cache internally before iterating
544      // listeners — env vars, telemetry, and permissions update on next read.
545      if (settings !== null) {
546        settingsChangeDetector.notifyChange('policySettings')
547      }
548    } finally {
549      // Always resolve the promise, even if fetch failed (fail-open)
550      if (loadingCompleteResolve) {
551        loadingCompleteResolve()
552        loadingCompleteResolve = null
553      }
554    }
555  }
556  
557  /**
558   * Refresh remote settings asynchronously (for auth state changes)
559   * This is used when login/logout occurs
560   * Fails open - if fetch fails, continues without remote settings
561   */
562  export async function refreshRemoteManagedSettings(): Promise<void> {
563    // Clear caches first
564    await clearRemoteManagedSettingsCache()
565  
566    // If not enabled, notify that policy settings changed (to empty)
567    if (!isRemoteManagedSettingsEligible()) {
568      settingsChangeDetector.notifyChange('policySettings')
569      return
570    }
571  
572    // Try to load new settings (fails open if fetch fails)
573    await fetchAndLoadRemoteManagedSettings()
574    logForDebugging('Remote settings: Refreshed after auth change')
575  
576    // Notify listeners. notifyChange resets the settings cache internally;
577    // this triggers hot-reload (AppState update, env var application, etc.)
578    settingsChangeDetector.notifyChange('policySettings')
579  }
580  
581  /**
582   * Background polling callback - fetches settings and triggers hot-reload if changed
583   */
584  async function pollRemoteSettings(): Promise<void> {
585    if (!isRemoteManagedSettingsEligible()) {
586      return
587    }
588  
589    // Get current cached settings for comparison
590    const prevCache = getRemoteManagedSettingsSyncFromCache()
591    const previousSettings = prevCache ? jsonStringify(prevCache) : null
592  
593    try {
594      await fetchAndLoadRemoteManagedSettings()
595  
596      // Check if settings actually changed
597      const newCache = getRemoteManagedSettingsSyncFromCache()
598      const newSettings = newCache ? jsonStringify(newCache) : null
599      if (newSettings !== previousSettings) {
600        logForDebugging('Remote settings: Changed during background poll')
601        settingsChangeDetector.notifyChange('policySettings')
602      }
603    } catch {
604      // Don't fail closed for background polling - just continue
605    }
606  }
607  
608  /**
609   * Start background polling for remote settings
610   * Polls every hour to pick up settings changes mid-session
611   */
612  export function startBackgroundPolling(): void {
613    if (pollingIntervalId !== null) {
614      return
615    }
616  
617    if (!isRemoteManagedSettingsEligible()) {
618      return
619    }
620  
621    pollingIntervalId = setInterval(() => {
622      void pollRemoteSettings()
623    }, POLLING_INTERVAL_MS)
624    pollingIntervalId.unref()
625  
626    // Register cleanup to stop polling on shutdown
627    registerCleanup(async () => stopBackgroundPolling())
628  }
629  
630  /**
631   * Stop background polling for remote settings
632   */
633  export function stopBackgroundPolling(): void {
634    if (pollingIntervalId !== null) {
635      clearInterval(pollingIntervalId)
636      pollingIntervalId = null
637    }
638  }