syncCache.ts
  1  /**
  2   * Eligibility check for remote managed settings.
  3   *
  4   * The cache state itself lives in syncCacheState.ts (a leaf, no auth import).
  5   * This file keeps isRemoteManagedSettingsEligible — the one function that
  6   * needs auth.ts — plus resetSyncCache wrapped to clear the local eligibility
  7   * mirror alongside the leaf's state.
  8   */
  9  
 10  import { CLAUDE_AI_INFERENCE_SCOPE } from '../../constants/oauth.js'
 11  import {
 12    getAnthropicApiKeyWithSource,
 13    getClaudeAIOAuthTokens,
 14  } from '../../utils/auth.js'
 15  import {
 16    getAPIProvider,
 17    isFirstPartyAnthropicBaseUrl,
 18  } from '../../utils/model/providers.js'
 19  
 20  import {
 21    resetSyncCache as resetLeafCache,
 22    setEligibility,
 23  } from './syncCacheState.js'
 24  
 25  let cached: boolean | undefined
 26  
 27  export function resetSyncCache(): void {
 28    cached = undefined
 29    resetLeafCache()
 30  }
 31  
 32  /**
 33   * Check if the current user is eligible for remote managed settings
 34   *
 35   * Eligibility:
 36   * - Console users (API key): All eligible (must have actual key, not just apiKeyHelper)
 37   * - OAuth users with known subscriptionType: Only Enterprise/C4E and Team
 38   * - OAuth users with subscriptionType === null (externally-injected tokens via
 39   *   CLAUDE_CODE_OAUTH_TOKEN / FD, or keychain tokens missing metadata): Eligible —
 40   *   the API returns empty settings for ineligible orgs, so the cost of a false
 41   *   positive is one round-trip
 42   *
 43   * This is a pre-check to determine if we should query the API.
 44   * The API will return empty settings for users without managed settings.
 45   *
 46   * IMPORTANT: This function must NOT call getSettings() or any function that calls
 47   * getSettings() to avoid circular dependencies during settings loading.
 48   */
 49  export function isRemoteManagedSettingsEligible(): boolean {
 50    if (cached !== undefined) return cached
 51  
 52    // 3p provider users should not hit the settings endpoint
 53    if (getAPIProvider() !== 'firstParty') {
 54      return (cached = setEligibility(false))
 55    }
 56  
 57    // Custom base URL users should not hit the settings endpoint
 58    if (!isFirstPartyAnthropicBaseUrl()) {
 59      return (cached = setEligibility(false))
 60    }
 61  
 62    // Cowork runs in a VM with its own permission model; server-managed settings
 63    // (designed for CLI/CCD) don't apply there, and per-surface settings don't
 64    // exist yet. MDM/file-based managed settings still apply via settings.ts —
 65    // those require physical deployment and a different IT intent.
 66    if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') {
 67      return (cached = setEligibility(false))
 68    }
 69  
 70    // Check OAuth first: most Claude.ai users have no API key in the keychain.
 71    // The API key check spawns `security find-generic-password` (~20-50ms) which
 72    // returns null for OAuth-only users. Checking OAuth first short-circuits
 73    // that subprocess for the common case.
 74    const tokens = getClaudeAIOAuthTokens()
 75  
 76    // Externally-injected tokens (CCD via CLAUDE_CODE_OAUTH_TOKEN, CCR via
 77    // CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR, Agent SDK, CI) carry no
 78    // subscriptionType metadata — getClaudeAIOAuthTokens() constructs them with
 79    // subscriptionType: null. The token itself is valid; let the API decide.
 80    // fetchRemoteManagedSettings handles 204/404 gracefully (returns {}), and
 81    // settings.ts falls through to MDM/file when remote is empty, so ineligible
 82    // orgs pay one round-trip and nothing else changes.
 83    if (tokens?.accessToken && tokens.subscriptionType === null) {
 84      return (cached = setEligibility(true))
 85    }
 86  
 87    if (
 88      tokens?.accessToken &&
 89      tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE) &&
 90      (tokens.subscriptionType === 'enterprise' ||
 91        tokens.subscriptionType === 'team')
 92    ) {
 93      return (cached = setEligibility(true))
 94    }
 95  
 96    // Console users (API key) are eligible if we can get the actual key
 97    // Skip apiKeyHelper to avoid circular dependency with getSettings()
 98    // Wrap in try-catch because getAnthropicApiKeyWithSource throws in CI/test environments
 99    // when no API key is available
100    try {
101      const { key: apiKey } = getAnthropicApiKeyWithSource({
102        skipRetrievingKeyFromApiKeyHelper: true,
103      })
104      if (apiKey) {
105        return (cached = setEligibility(true))
106      }
107    } catch {
108      // No API key available (e.g., CI/test environment)
109    }
110  
111    return (cached = setEligibility(false))
112  }