init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+638
View File
@@ -0,0 +1,638 @@
/**
* 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<typeof setInterval> | 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<void> | 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<string, unknown> = {}
for (const key of Object.keys(obj).sort()) {
sorted[key] = sortKeysDeep((obj as Record<string, unknown>)[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<void> {
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<string, string>
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<RemoteManagedSettingsFetchResult> {
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<RemoteManagedSettingsFetchResult> {
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<string, string> = {
...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<void> {
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<void> {
// 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<SettingsJson | null> {
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<void> {
// 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<void> {
// 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<void> {
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
}
}
File diff suppressed because one or more lines are too long
+112
View File
@@ -0,0 +1,112 @@
/**
* Eligibility check for remote managed settings.
*
* The cache state itself lives in syncCacheState.ts (a leaf, no auth import).
* This file keeps isRemoteManagedSettingsEligible — the one function that
* needs auth.ts — plus resetSyncCache wrapped to clear the local eligibility
* mirror alongside the leaf's state.
*/
import { CLAUDE_AI_INFERENCE_SCOPE } from '../../constants/oauth.js'
import {
getAnthropicApiKeyWithSource,
getClaudeAIOAuthTokens,
} from '../../utils/auth.js'
import {
getAPIProvider,
isFirstPartyAnthropicBaseUrl,
} from '../../utils/model/providers.js'
import {
resetSyncCache as resetLeafCache,
setEligibility,
} from './syncCacheState.js'
let cached: boolean | undefined
export function resetSyncCache(): void {
cached = undefined
resetLeafCache()
}
/**
* Check if the current user is eligible for remote managed settings
*
* Eligibility:
* - Console users (API key): All eligible (must have actual key, not just apiKeyHelper)
* - OAuth users with known subscriptionType: Only Enterprise/C4E and Team
* - OAuth users with subscriptionType === null (externally-injected tokens via
* CLAUDE_CODE_OAUTH_TOKEN / FD, or keychain tokens missing metadata): Eligible —
* the API returns empty settings for ineligible orgs, so the cost of a false
* positive is one round-trip
*
* This is a pre-check to determine if we should query the API.
* The API will return empty settings for users without managed settings.
*
* IMPORTANT: This function must NOT call getSettings() or any function that calls
* getSettings() to avoid circular dependencies during settings loading.
*/
export function isRemoteManagedSettingsEligible(): boolean {
if (cached !== undefined) return cached
// 3p provider users should not hit the settings endpoint
if (getAPIProvider() !== 'firstParty') {
return (cached = setEligibility(false))
}
// Custom base URL users should not hit the settings endpoint
if (!isFirstPartyAnthropicBaseUrl()) {
return (cached = setEligibility(false))
}
// Cowork runs in a VM with its own permission model; server-managed settings
// (designed for CLI/CCD) don't apply there, and per-surface settings don't
// exist yet. MDM/file-based managed settings still apply via settings.ts —
// those require physical deployment and a different IT intent.
if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') {
return (cached = setEligibility(false))
}
// Check OAuth first: most Claude.ai users have no API key in the keychain.
// The API key check spawns `security find-generic-password` (~20-50ms) which
// returns null for OAuth-only users. Checking OAuth first short-circuits
// that subprocess for the common case.
const tokens = getClaudeAIOAuthTokens()
// Externally-injected tokens (CCD via CLAUDE_CODE_OAUTH_TOKEN, CCR via
// CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR, Agent SDK, CI) carry no
// subscriptionType metadata — getClaudeAIOAuthTokens() constructs them with
// subscriptionType: null. The token itself is valid; let the API decide.
// fetchRemoteManagedSettings handles 204/404 gracefully (returns {}), and
// settings.ts falls through to MDM/file when remote is empty, so ineligible
// orgs pay one round-trip and nothing else changes.
if (tokens?.accessToken && tokens.subscriptionType === null) {
return (cached = setEligibility(true))
}
if (
tokens?.accessToken &&
tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE) &&
(tokens.subscriptionType === 'enterprise' ||
tokens.subscriptionType === 'team')
) {
return (cached = setEligibility(true))
}
// Console users (API key) are eligible if we can get the actual key
// Skip apiKeyHelper to avoid circular dependency with getSettings()
// Wrap in try-catch because getAnthropicApiKeyWithSource throws in CI/test environments
// when no API key is available
try {
const { key: apiKey } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
if (apiKey) {
return (cached = setEligibility(true))
}
} catch {
// No API key available (e.g., CI/test environment)
}
return (cached = setEligibility(false))
}
@@ -0,0 +1,96 @@
/**
* Leaf state module for the remote-managed-settings sync cache.
*
* Split from syncCache.ts to break the settings.ts → syncCache.ts → auth.ts →
* settings.ts cycle. auth.ts sits inside the large settings SCC; importing it
* from settings.ts's own dependency chain pulls hundreds of modules into the
* eagerly-evaluated SCC at startup.
*
* This module imports only leaves (path, envUtils, file, json, types,
* settings/settingsCache — also a leaf, only type-imports validation). settings.ts
* reads the cache from here. syncCache.ts keeps isRemoteManagedSettingsEligible
* (the auth-touching part) and re-exports everything from here for callers that
* don't care about the cycle.
*
* Eligibility is a tri-state here: undefined (not yet determined — return
* null), false (ineligible — return null), true (proceed). managedEnv.ts
* calls isRemoteManagedSettingsEligible() just before the policySettings
* read — after userSettings/flagSettings env vars are applied, so the check
* sees config-provided CLAUDE_CODE_USE_BEDROCK/ANTHROPIC_BASE_URL. That call
* computes once and mirrors the result here via setEligibility(). Every
* subsequent read hits the cached bool instead of re-running the auth chain.
*/
import { join } from 'path'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { readFileSync } from '../../utils/fileRead.js'
import { stripBOM } from '../../utils/jsonRead.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import type { SettingsJson } from '../../utils/settings/types.js'
import { jsonParse } from '../../utils/slowOperations.js'
const SETTINGS_FILENAME = 'remote-settings.json'
let sessionCache: SettingsJson | null = null
let eligible: boolean | undefined
export function setSessionCache(value: SettingsJson | null): void {
sessionCache = value
}
export function resetSyncCache(): void {
sessionCache = null
eligible = undefined
}
export function setEligibility(v: boolean): boolean {
eligible = v
return v
}
export function getSettingsPath(): string {
return join(getClaudeConfigHomeDir(), SETTINGS_FILENAME)
}
// sync IO — settings pipeline is sync. fileRead and jsonRead are leaves;
// file.ts and json.ts both sit in the settings SCC.
function loadSettings(): SettingsJson | null {
try {
const content = readFileSync(getSettingsPath())
const data: unknown = jsonParse(stripBOM(content))
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return null
}
return data as SettingsJson
} catch {
return null
}
}
export function getRemoteManagedSettingsSyncFromCache(): SettingsJson | null {
if (eligible !== true) return null
if (sessionCache) return sessionCache
const cachedSettings = loadSettings()
if (cachedSettings) {
sessionCache = cachedSettings
// Remote settings just became available for the first time. Any merged
// getSettings_DEPRECATED() result cached before this moment is missing
// the policySettings layer (the `eligible !== true` guard above returned
// null). Flush so the next merged read re-merges with this layer visible.
//
// Fires at most once: subsequent calls hit `if (sessionCache)` above.
// When called from loadSettingsFromDisk() (settings.ts:546), the merged
// cache is still null (setSessionSettingsCache runs at :732 after
// loadSettingsFromDisk returns) — no-op. The async-fetch arm (index.ts
// setSessionCache + notifyChange) already handles its own reset.
//
// gh-23085: isBridgeEnabled() at main.tsx Commander-definition time
// (before preAction → init() → isRemoteManagedSettingsEligible()) reached
// getSettings_DEPRECATED() at auth.ts:115. The try/catch in bridgeEnabled
// swallowed the later getGlobalConfig() throw, but the merged settings
// cache was already poisoned. See managedSettingsHeadless.int.test.ts.
resetSettingsCache()
return cachedSettings
}
return null
}
+31
View File
@@ -0,0 +1,31 @@
import { z } from 'zod/v4'
import { lazySchema } from '../../utils/lazySchema.js'
import type { SettingsJson } from '../../utils/settings/types.js'
/**
* Schema for the remotely managed settings response.
* Note: Uses permissive z.record() instead of SettingsSchema to avoid circular dependency.
* Full validation is performed in index.ts after parsing using SettingsSchema.safeParse().
*/
export const RemoteManagedSettingsResponseSchema = lazySchema(() =>
z.object({
uuid: z.string(), // Settings UUID
checksum: z.string(),
settings: z.record(z.string(), z.unknown()) as z.ZodType<SettingsJson>,
}),
)
export type RemoteManagedSettingsResponse = z.infer<
ReturnType<typeof RemoteManagedSettingsResponseSchema>
>
/**
* Result of fetching remotely managed settings
*/
export type RemoteManagedSettingsFetchResult = {
success: boolean
settings?: SettingsJson | null // null means 304 Not Modified (cache is valid)
checksum?: string
error?: string
skipRetry?: boolean // If true, don't retry on failure (e.g., auth errors)
}