source dump of claude code
at main 638 lines 21 kB view raw
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 15import axios from 'axios' 16import { createHash } from 'crypto' 17import { open, unlink } from 'fs/promises' 18import { getOauthConfig, OAUTH_BETA_HEADER } from '../../constants/oauth.js' 19import { 20 checkAndRefreshOAuthTokenIfNeeded, 21 getAnthropicApiKeyWithSource, 22 getClaudeAIOAuthTokens, 23} from '../../utils/auth.js' 24import { registerCleanup } from '../../utils/cleanupRegistry.js' 25import { logForDebugging } from '../../utils/debug.js' 26import { classifyAxiosError, getErrnoCode } from '../../utils/errors.js' 27import { settingsChangeDetector } from '../../utils/settings/changeDetector.js' 28import { 29 type SettingsJson, 30 SettingsSchema, 31} from '../../utils/settings/types.js' 32import { sleep } from '../../utils/sleep.js' 33import { jsonStringify } from '../../utils/slowOperations.js' 34import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 35import { getRetryDelay } from '../api/withRetry.js' 36import { 37 checkManagedSettingsSecurity, 38 handleSecurityCheckResult, 39} from './securityCheck.jsx' 40import { isRemoteManagedSettingsEligible, resetSyncCache } from './syncCache.js' 41import { 42 getRemoteManagedSettingsSyncFromCache, 43 getSettingsPath, 44 setSessionCache, 45} from './syncCacheState.js' 46import { 47 type RemoteManagedSettingsFetchResult, 48 RemoteManagedSettingsResponseSchema, 49} from './types.js' 50 51// Constants 52const SETTINGS_TIMEOUT_MS = 10000 // 10 seconds for settings fetch 53const DEFAULT_MAX_RETRIES = 5 54const POLLING_INTERVAL_MS = 60 * 60 * 1000 // 1 hour 55 56// Background polling state 57let 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 61let loadingCompletePromise: Promise<void> | null = null 62let 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) 66const 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 */ 77export 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 */ 105function 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 */ 112function 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 */ 131export 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 */ 144export 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 */ 155export 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 */ 166function 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 */ 209async 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 */ 248async 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 */ 367async 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 */ 391export 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 */ 415async 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 */ 514export 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 */ 562export 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 */ 584async 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 */ 612export 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 */ 633export function stopBackgroundPolling(): void { 634 if (pollingIntervalId !== null) { 635 clearInterval(pollingIntervalId) 636 pollingIntervalId = null 637 } 638}