/** * Remote Managed Settings Service * * Manages fetching, caching, and validation of remote-managed settings * for enterprise customers. Uses checksum-based validation to minimize * network traffic and provides graceful degradation on failures. * * Eligibility: * - Console users (API key): All eligible * - OAuth users (Claude.ai): Only Enterprise/C4E and Team subscribers are eligible * - API fails open (non-blocking) - if fetch fails, continues without remote settings * - API returns empty settings for users without managed settings */ import axios from 'axios' import { createHash } from 'crypto' import { open, unlink } from 'fs/promises' import { getOauthConfig, OAUTH_BETA_HEADER } from '../../constants/oauth.js' import { checkAndRefreshOAuthTokenIfNeeded, getAnthropicApiKeyWithSource, getClaudeAIOAuthTokens, } from '../../utils/auth.js' import { registerCleanup } from '../../utils/cleanupRegistry.js' import { logForDebugging } from '../../utils/debug.js' import { classifyAxiosError, getErrnoCode } from '../../utils/errors.js' import { settingsChangeDetector } from '../../utils/settings/changeDetector.js' import { type SettingsJson, SettingsSchema, } from '../../utils/settings/types.js' import { sleep } from '../../utils/sleep.js' import { jsonStringify } from '../../utils/slowOperations.js' import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' import { getRetryDelay } from '../api/withRetry.js' import { checkManagedSettingsSecurity, handleSecurityCheckResult, } from './securityCheck.jsx' import { isRemoteManagedSettingsEligible, resetSyncCache } from './syncCache.js' import { getRemoteManagedSettingsSyncFromCache, getSettingsPath, setSessionCache, } from './syncCacheState.js' import { type RemoteManagedSettingsFetchResult, RemoteManagedSettingsResponseSchema, } from './types.js' // Constants const SETTINGS_TIMEOUT_MS = 10000 // 10 seconds for settings fetch const DEFAULT_MAX_RETRIES = 5 const POLLING_INTERVAL_MS = 60 * 60 * 1000 // 1 hour // Background polling state let pollingIntervalId: ReturnType | null = null // Promise that resolves when initial remote settings loading completes // This allows other systems to wait for remote settings before initializing let loadingCompletePromise: Promise | null = null let loadingCompleteResolve: (() => void) | null = null // Timeout for the loading promise to prevent deadlocks if loadRemoteManagedSettings() is never called // (e.g., in Agent SDK tests that don't go through main.tsx) const LOADING_PROMISE_TIMEOUT_MS = 30000 // 30 seconds /** * Initialize the loading promise for remote managed settings * This should be called early (e.g., in init.ts) to allow other systems * to await remote settings loading even if loadRemoteManagedSettings() * hasn't been called yet. * * Only creates the promise if the user is eligible for remote settings. * Includes a timeout to prevent deadlocks if loadRemoteManagedSettings() is never called. */ export function initializeRemoteManagedSettingsLoadingPromise(): void { if (loadingCompletePromise) { return } if (isRemoteManagedSettingsEligible()) { loadingCompletePromise = new Promise(resolve => { loadingCompleteResolve = resolve // Set a timeout to resolve the promise even if loadRemoteManagedSettings() is never called // This prevents deadlocks in Agent SDK tests and other non-CLI contexts setTimeout(() => { if (loadingCompleteResolve) { logForDebugging( 'Remote settings: Loading promise timed out, resolving anyway', ) loadingCompleteResolve() loadingCompleteResolve = null } }, LOADING_PROMISE_TIMEOUT_MS) }) } } /** * Get the remote settings API endpoint * Uses the OAuth config base API URL */ function getRemoteManagedSettingsEndpoint() { return `${getOauthConfig().BASE_API_URL}/api/claude_code/settings` } /** * Recursively sort all keys in an object to match Python's json.dumps(sort_keys=True) */ function sortKeysDeep(obj: unknown): unknown { if (Array.isArray(obj)) { return obj.map(sortKeysDeep) } if (obj !== null && typeof obj === 'object') { const sorted: Record = {} for (const key of Object.keys(obj).sort()) { sorted[key] = sortKeysDeep((obj as Record)[key]) } return sorted } return obj } /** * Compute checksum from settings content for HTTP caching * Must match server's Python: json.dumps(settings, sort_keys=True, separators=(",", ":")) * Exported for testing to verify compatibility with server-side implementation */ export function computeChecksumFromSettings(settings: SettingsJson): string { const sorted = sortKeysDeep(settings) // No spaces after separators to match Python's separators=(",", ":") const normalized = jsonStringify(sorted) const hash = createHash('sha256').update(normalized).digest('hex') return `sha256:${hash}` } /** * Check if the current user is eligible for remote managed settings * This is the public API for other systems to check eligibility * Used to determine if they should wait for remote settings to load */ export function isEligibleForRemoteManagedSettings(): boolean { return isRemoteManagedSettingsEligible() } /** * Wait for the initial remote settings loading to complete * Returns immediately if: * - User is not eligible for remote settings * - Loading has already completed * - Loading was never started */ export async function waitForRemoteManagedSettingsToLoad(): Promise { if (loadingCompletePromise) { await loadingCompletePromise } } /** * Get auth headers for remote settings without calling getSettings() * This avoids circular dependencies during settings loading * Supports both API key and OAuth authentication */ function getRemoteSettingsAuthHeaders(): { headers: Record error?: string } { // Try API key first (for Console users) // Skip apiKeyHelper to avoid circular dependency with getSettings() // Wrap in try-catch because getAnthropicApiKeyWithSource throws in CI/test environments try { const { key: apiKey } = getAnthropicApiKeyWithSource({ skipRetrievingKeyFromApiKeyHelper: true, }) if (apiKey) { return { headers: { 'x-api-key': apiKey, }, } } } catch { // No API key available - continue to check OAuth } // Fall back to OAuth tokens (for Claude.ai users) const oauthTokens = getClaudeAIOAuthTokens() if (oauthTokens?.accessToken) { return { headers: { Authorization: `Bearer ${oauthTokens.accessToken}`, 'anthropic-beta': OAUTH_BETA_HEADER, }, } } return { headers: {}, error: 'No authentication available', } } /** * Fetch remote settings with retry logic and exponential backoff * Uses existing codebase retry utilities for consistency */ async function fetchWithRetry( cachedChecksum?: string, ): Promise { let lastResult: RemoteManagedSettingsFetchResult | null = null for (let attempt = 1; attempt <= DEFAULT_MAX_RETRIES + 1; attempt++) { lastResult = await fetchRemoteManagedSettings(cachedChecksum) // Return immediately on success if (lastResult.success) { return lastResult } // Don't retry if the error is not retryable (e.g., auth errors) if (lastResult.skipRetry) { return lastResult } // If we've exhausted retries, return the last error if (attempt > DEFAULT_MAX_RETRIES) { return lastResult } // Calculate delay and wait before next retry const delayMs = getRetryDelay(attempt) logForDebugging( `Remote settings: Retry ${attempt}/${DEFAULT_MAX_RETRIES} after ${delayMs}ms`, ) await sleep(delayMs) } // Should never reach here, but TypeScript needs it return lastResult! } /** * Fetch the full remote settings (single attempt, no retries) * Optionally pass a cached checksum for ETag-based caching */ async function fetchRemoteManagedSettings( cachedChecksum?: string, ): Promise { try { // Ensure OAuth token is fresh before fetching settings // This prevents 401 errors from stale cached tokens await checkAndRefreshOAuthTokenIfNeeded() // Use local auth header getter to avoid circular dependency with getSettings() const authHeaders = getRemoteSettingsAuthHeaders() if (authHeaders.error) { // Auth errors should not be retried - return a special flag to skip retries return { success: false, error: `Authentication required for remote settings`, skipRetry: true, } } const endpoint = getRemoteManagedSettingsEndpoint() const headers: Record = { ...authHeaders.headers, 'User-Agent': getClaudeCodeUserAgent(), } // Add If-None-Match header for ETag-based caching if (cachedChecksum) { headers['If-None-Match'] = `"${cachedChecksum}"` } const response = await axios.get(endpoint, { headers, timeout: SETTINGS_TIMEOUT_MS, // Allow 204, 304, and 404 responses without treating them as errors. // 204/404 are returned when no settings exist for the user or the feature flag is off. validateStatus: status => status === 200 || status === 204 || status === 304 || status === 404, }) // Handle 304 Not Modified - cached version is still valid if (response.status === 304) { logForDebugging('Remote settings: Using cached settings (304)') return { success: true, settings: null, // Signal that cache is valid checksum: cachedChecksum, } } // Handle 204 No Content / 404 Not Found - no settings exist or feature flag is off. // Return empty object (not null) so callers don't fall back to cached settings. if (response.status === 204 || response.status === 404) { logForDebugging(`Remote settings: No settings found (${response.status})`) return { success: true, settings: {}, checksum: undefined, } } const parsed = RemoteManagedSettingsResponseSchema().safeParse( response.data, ) if (!parsed.success) { logForDebugging( `Remote settings: Invalid response format - ${parsed.error.message}`, ) return { success: false, error: 'Invalid remote settings format', } } // Full validation of settings structure const settingsValidation = SettingsSchema().safeParse(parsed.data.settings) if (!settingsValidation.success) { logForDebugging( `Remote settings: Settings validation failed - ${settingsValidation.error.message}`, ) return { success: false, error: 'Invalid settings structure', } } logForDebugging('Remote settings: Fetched successfully') return { success: true, settings: settingsValidation.data, checksum: parsed.data.checksum, } } catch (error) { const { kind, status, message } = classifyAxiosError(error) if (status === 404) { // 404 means no remote settings configured return { success: true, settings: {}, checksum: '' } } switch (kind) { case 'auth': // Auth errors (401, 403) should not be retried - the API key doesn't have access return { success: false, error: 'Not authorized for remote settings', skipRetry: true, } case 'timeout': return { success: false, error: 'Remote settings request timeout' } case 'network': return { success: false, error: 'Cannot connect to server' } default: return { success: false, error: message } } } } /** * Save remote settings to file * Stores raw settings JSON (checksum is computed on-demand when needed) */ async function saveSettings(settings: SettingsJson): Promise { try { const path = getSettingsPath() const handle = await open(path, 'w', 0o600) try { await handle.writeFile(jsonStringify(settings, null, 2), { encoding: 'utf-8', }) await handle.datasync() } finally { await handle.close() } logForDebugging(`Remote settings: Saved to ${path}`) } catch (error) { logForDebugging( `Remote settings: Failed to save - ${error instanceof Error ? error.message : 'unknown error'}`, ) // Ignore save errors - we'll refetch on next startup } } /** * Clear all remote settings (session, persistent, and stop polling) */ export async function clearRemoteManagedSettingsCache(): Promise { // Stop background polling stopBackgroundPolling() // Clear session cache resetSyncCache() // Clear loading promise state loadingCompletePromise = null loadingCompleteResolve = null try { const path = getSettingsPath() await unlink(path) } catch { // Ignore errors when clearing file (ENOENT is expected) } } /** * Fetch and load remote settings with file caching * Internal function that handles the full load/fetch logic * Fails open - returns null if fetch fails and no cache exists */ async function fetchAndLoadRemoteManagedSettings(): Promise { if (!isRemoteManagedSettingsEligible()) { return null } // Load cached settings from file const cachedSettings = getRemoteManagedSettingsSyncFromCache() // Compute checksum locally from cached settings for HTTP caching validation const cachedChecksum = cachedSettings ? computeChecksumFromSettings(cachedSettings) : undefined try { // Fetch settings from API with retry logic const result = await fetchWithRetry(cachedChecksum) if (!result.success) { // On fetch failure, use stale file if available (graceful degradation) if (cachedSettings) { logForDebugging( 'Remote settings: Using stale cache after fetch failure', ) setSessionCache(cachedSettings) return cachedSettings } // No cache available - fail open, continue without remote settings return null } // Handle 304 Not Modified - cached settings are still valid if (result.settings === null && cachedSettings) { logForDebugging('Remote settings: Cache still valid (304 Not Modified)') setSessionCache(cachedSettings) return cachedSettings } // Save new settings to file (only if non-empty) const newSettings = result.settings || {} const hasContent = Object.keys(newSettings).length > 0 if (hasContent) { // Check for dangerous settings changes before applying const securityResult = await checkManagedSettingsSecurity( cachedSettings, newSettings, ) if (!handleSecurityCheckResult(securityResult)) { // User rejected - don't apply settings, return cached or null logForDebugging( 'Remote settings: User rejected new settings, using cached settings', ) return cachedSettings } setSessionCache(newSettings) await saveSettings(newSettings) logForDebugging('Remote settings: Applied new settings successfully') return newSettings } // Empty settings (404 response) - delete cached file if it exists // This ensures stale settings don't persist when a user's remote settings are removed setSessionCache(newSettings) try { const path = getSettingsPath() await unlink(path) logForDebugging('Remote settings: Deleted cached file (404 response)') } catch (e) { const code = getErrnoCode(e) if (code !== 'ENOENT') { logForDebugging( `Remote settings: Failed to delete cached file - ${e instanceof Error ? e.message : 'unknown error'}`, ) } } return newSettings } catch { // On any error, use stale file if available (graceful degradation) if (cachedSettings) { logForDebugging('Remote settings: Using stale cache after error') setSessionCache(cachedSettings) return cachedSettings } // No cache available - fail open, continue without remote settings return null } } /** * Load remote settings during CLI initialization * Fails open - if fetch fails, continues without remote settings * Also starts background polling to pick up settings changes mid-session * * This function sets up a promise that other systems can await via * waitForRemoteManagedSettingsToLoad() to ensure they don't initialize * until remote settings have been fetched. */ export async function loadRemoteManagedSettings(): Promise { // Set up the promise for other systems to wait on // Only if the user is eligible for remote settings AND promise not already set up // (initializeRemoteManagedSettingsLoadingPromise may have been called earlier) if (isRemoteManagedSettingsEligible() && !loadingCompletePromise) { loadingCompletePromise = new Promise(resolve => { loadingCompleteResolve = resolve }) } // Cache-first: if we have cached settings on disk, apply them and unblock // waiters immediately. The fetch still runs below; notifyChange fires once, // after the fetch, as before. Saves the ~77ms fetch-wait on print-mode startup. // getRemoteManagedSettingsSyncFromCache has the eligibility guard and populates // the session cache internally — no need to call setSessionCache here. if (getRemoteManagedSettingsSyncFromCache() && loadingCompleteResolve) { loadingCompleteResolve() loadingCompleteResolve = null } try { const settings = await fetchAndLoadRemoteManagedSettings() // Start background polling to pick up settings changes mid-session if (isRemoteManagedSettingsEligible()) { startBackgroundPolling() } // Trigger hot-reload if settings were loaded (new or from cache). // notifyChange resets the settings cache internally before iterating // listeners — env vars, telemetry, and permissions update on next read. if (settings !== null) { settingsChangeDetector.notifyChange('policySettings') } } finally { // Always resolve the promise, even if fetch failed (fail-open) if (loadingCompleteResolve) { loadingCompleteResolve() loadingCompleteResolve = null } } } /** * Refresh remote settings asynchronously (for auth state changes) * This is used when login/logout occurs * Fails open - if fetch fails, continues without remote settings */ export async function refreshRemoteManagedSettings(): Promise { // Clear caches first await clearRemoteManagedSettingsCache() // If not enabled, notify that policy settings changed (to empty) if (!isRemoteManagedSettingsEligible()) { settingsChangeDetector.notifyChange('policySettings') return } // Try to load new settings (fails open if fetch fails) await fetchAndLoadRemoteManagedSettings() logForDebugging('Remote settings: Refreshed after auth change') // Notify listeners. notifyChange resets the settings cache internally; // this triggers hot-reload (AppState update, env var application, etc.) settingsChangeDetector.notifyChange('policySettings') } /** * Background polling callback - fetches settings and triggers hot-reload if changed */ async function pollRemoteSettings(): Promise { if (!isRemoteManagedSettingsEligible()) { return } // Get current cached settings for comparison const prevCache = getRemoteManagedSettingsSyncFromCache() const previousSettings = prevCache ? jsonStringify(prevCache) : null try { await fetchAndLoadRemoteManagedSettings() // Check if settings actually changed const newCache = getRemoteManagedSettingsSyncFromCache() const newSettings = newCache ? jsonStringify(newCache) : null if (newSettings !== previousSettings) { logForDebugging('Remote settings: Changed during background poll') settingsChangeDetector.notifyChange('policySettings') } } catch { // Don't fail closed for background polling - just continue } } /** * Start background polling for remote settings * Polls every hour to pick up settings changes mid-session */ export function startBackgroundPolling(): void { if (pollingIntervalId !== null) { return } if (!isRemoteManagedSettingsEligible()) { return } pollingIntervalId = setInterval(() => { void pollRemoteSettings() }, POLLING_INTERVAL_MS) pollingIntervalId.unref() // Register cleanup to stop polling on shutdown registerCleanup(async () => stopBackgroundPolling()) } /** * Stop background polling for remote settings */ export function stopBackgroundPolling(): void { if (pollingIntervalId !== null) { clearInterval(pollingIntervalId) pollingIntervalId = null } }