/ utils / auth.ts
auth.ts
   1  import chalk from 'chalk'
   2  import { exec } from 'child_process'
   3  import { execa } from 'execa'
   4  import { mkdir, stat } from 'fs/promises'
   5  import memoize from 'lodash-es/memoize.js'
   6  import { join } from 'path'
   7  import { CLAUDE_AI_PROFILE_SCOPE } from 'src/constants/oauth.js'
   8  import {
   9    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  10    logEvent,
  11  } from 'src/services/analytics/index.js'
  12  import { getModelStrings } from 'src/utils/model/modelStrings.js'
  13  import { getAPIProvider } from 'src/utils/model/providers.js'
  14  import {
  15    getIsNonInteractiveSession,
  16    preferThirdPartyAuthentication,
  17  } from '../bootstrap/state.js'
  18  import {
  19    getMockSubscriptionType,
  20    shouldUseMockSubscription,
  21  } from '../services/mockRateLimits.js'
  22  import {
  23    isOAuthTokenExpired,
  24    refreshOAuthToken,
  25    shouldUseClaudeAIAuth,
  26  } from '../services/oauth/client.js'
  27  import { getOauthProfileFromOauthToken } from '../services/oauth/getOauthProfile.js'
  28  import type { OAuthTokens, SubscriptionType } from '../services/oauth/types.js'
  29  import {
  30    getApiKeyFromFileDescriptor,
  31    getOAuthTokenFromFileDescriptor,
  32  } from './authFileDescriptor.js'
  33  import {
  34    maybeRemoveApiKeyFromMacOSKeychainThrows,
  35    normalizeApiKeyForConfig,
  36  } from './authPortable.js'
  37  import {
  38    checkStsCallerIdentity,
  39    clearAwsIniCache,
  40    isValidAwsStsOutput,
  41  } from './aws.js'
  42  import { AwsAuthStatusManager } from './awsAuthStatusManager.js'
  43  import { clearBetasCaches } from './betas.js'
  44  import {
  45    type AccountInfo,
  46    checkHasTrustDialogAccepted,
  47    getGlobalConfig,
  48    saveGlobalConfig,
  49  } from './config.js'
  50  import { logAntError, logForDebugging } from './debug.js'
  51  import {
  52    getClaudeConfigHomeDir,
  53    isBareMode,
  54    isEnvTruthy,
  55    isRunningOnHomespace,
  56  } from './envUtils.js'
  57  import { errorMessage } from './errors.js'
  58  import { execSyncWithDefaults_DEPRECATED } from './execFileNoThrow.js'
  59  import * as lockfile from './lockfile.js'
  60  import { logError } from './log.js'
  61  import { memoizeWithTTLAsync } from './memoize.js'
  62  import { getSecureStorage } from './secureStorage/index.js'
  63  import {
  64    clearLegacyApiKeyPrefetch,
  65    getLegacyApiKeyPrefetchResult,
  66  } from './secureStorage/keychainPrefetch.js'
  67  import {
  68    clearKeychainCache,
  69    getMacOsKeychainStorageServiceName,
  70    getUsername,
  71  } from './secureStorage/macOsKeychainHelpers.js'
  72  import {
  73    getSettings_DEPRECATED,
  74    getSettingsForSource,
  75  } from './settings/settings.js'
  76  import { sleep } from './sleep.js'
  77  import { jsonParse } from './slowOperations.js'
  78  import { clearToolSchemaCache } from './toolSchemaCache.js'
  79  
  80  /** Default TTL for API key helper cache in milliseconds (5 minutes) */
  81  const DEFAULT_API_KEY_HELPER_TTL = 5 * 60 * 1000
  82  
  83  /**
  84   * CCR and Claude Desktop spawn the CLI with OAuth and should never fall back
  85   * to the user's ~/.claude/settings.json API-key config (apiKeyHelper,
  86   * env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN). Those settings exist for
  87   * the user's terminal CLI, not managed sessions. Without this guard, a user
  88   * who runs `claude` in their terminal with an API key sees every CCD session
  89   * also use that key — and fail if it's stale/wrong-org.
  90   */
  91  function isManagedOAuthContext(): boolean {
  92    return (
  93      isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ||
  94      process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop'
  95    )
  96  }
  97  
  98  /** Whether we are supporting direct 1P auth. */
  99  // this code is closely related to getAuthTokenSource
 100  export function isAnthropicAuthEnabled(): boolean {
 101    // --bare: API-key-only, never OAuth.
 102    if (isBareMode()) return false
 103  
 104    // `claude ssh` remote: ANTHROPIC_UNIX_SOCKET tunnels API calls through a
 105    // local auth-injecting proxy. The launcher sets CLAUDE_CODE_OAUTH_TOKEN as a
 106    // placeholder iff the local side is a subscriber (so the remote includes the
 107    // oauth-2025 beta header to match what the proxy will inject). The remote's
 108    // ~/.claude settings (apiKeyHelper, settings.env.ANTHROPIC_API_KEY) MUST NOT
 109    // flip this — they'd cause a header mismatch with the proxy and a bogus
 110    // "invalid x-api-key" from the API. See src/ssh/sshAuthProxy.ts.
 111    if (process.env.ANTHROPIC_UNIX_SOCKET) {
 112      return !!process.env.CLAUDE_CODE_OAUTH_TOKEN
 113    }
 114  
 115    const is3P =
 116      isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
 117      isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
 118      isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
 119  
 120    // Check if user has configured an external API key source
 121    // This allows externally-provided API keys to work (without requiring proxy configuration)
 122    const settings = getSettings_DEPRECATED() || {}
 123    const apiKeyHelper = settings.apiKeyHelper
 124    const hasExternalAuthToken =
 125      process.env.ANTHROPIC_AUTH_TOKEN ||
 126      apiKeyHelper ||
 127      process.env.CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR
 128  
 129    // Check if API key is from an external source (not managed by /login)
 130    const { source: apiKeySource } = getAnthropicApiKeyWithSource({
 131      skipRetrievingKeyFromApiKeyHelper: true,
 132    })
 133    const hasExternalApiKey =
 134      apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper'
 135  
 136    // Disable Anthropic auth if:
 137    // 1. Using 3rd party services (Bedrock/Vertex/Foundry)
 138    // 2. User has an external API key (regardless of proxy configuration)
 139    // 3. User has an external auth token (regardless of proxy configuration)
 140    // this may cause issues if users have complex proxy / gateway "client-side creds" auth scenarios,
 141    // e.g. if they want to set X-Api-Key to a gateway key but use Anthropic OAuth for the Authorization
 142    // if we get reports of that, we should probably add an env var to force OAuth enablement
 143    const shouldDisableAuth =
 144      is3P ||
 145      (hasExternalAuthToken && !isManagedOAuthContext()) ||
 146      (hasExternalApiKey && !isManagedOAuthContext())
 147  
 148    return !shouldDisableAuth
 149  }
 150  
 151  /** Where the auth token is being sourced from, if any. */
 152  // this code is closely related to isAnthropicAuthEnabled
 153  export function getAuthTokenSource() {
 154    // --bare: API-key-only. apiKeyHelper (from --settings) is the only
 155    // bearer-token-shaped source allowed. OAuth env vars, FD tokens, and
 156    // keychain are ignored.
 157    if (isBareMode()) {
 158      if (getConfiguredApiKeyHelper()) {
 159        return { source: 'apiKeyHelper' as const, hasToken: true }
 160      }
 161      return { source: 'none' as const, hasToken: false }
 162    }
 163  
 164    if (process.env.ANTHROPIC_AUTH_TOKEN && !isManagedOAuthContext()) {
 165      return { source: 'ANTHROPIC_AUTH_TOKEN' as const, hasToken: true }
 166    }
 167  
 168    if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
 169      return { source: 'CLAUDE_CODE_OAUTH_TOKEN' as const, hasToken: true }
 170    }
 171  
 172    // Check for OAuth token from file descriptor (or its CCR disk fallback)
 173    const oauthTokenFromFd = getOAuthTokenFromFileDescriptor()
 174    if (oauthTokenFromFd) {
 175      // getOAuthTokenFromFileDescriptor has a disk fallback for CCR subprocesses
 176      // that can't inherit the pipe FD. Distinguish by env var presence so the
 177      // org-mismatch message doesn't tell the user to unset a variable that
 178      // doesn't exist. Call sites fall through correctly — the new source is
 179      // !== 'none' (cli/handlers/auth.ts → oauth_token) and not in the
 180      // isEnvVarToken set (auth.ts:1844 → generic re-login message).
 181      if (process.env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR) {
 182        return {
 183          source: 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR' as const,
 184          hasToken: true,
 185        }
 186      }
 187      return {
 188        source: 'CCR_OAUTH_TOKEN_FILE' as const,
 189        hasToken: true,
 190      }
 191    }
 192  
 193    // Check if apiKeyHelper is configured without executing it
 194    // This prevents security issues where arbitrary code could execute before trust is established
 195    const apiKeyHelper = getConfiguredApiKeyHelper()
 196    if (apiKeyHelper && !isManagedOAuthContext()) {
 197      return { source: 'apiKeyHelper' as const, hasToken: true }
 198    }
 199  
 200    const oauthTokens = getClaudeAIOAuthTokens()
 201    if (shouldUseClaudeAIAuth(oauthTokens?.scopes) && oauthTokens?.accessToken) {
 202      return { source: 'claude.ai' as const, hasToken: true }
 203    }
 204  
 205    return { source: 'none' as const, hasToken: false }
 206  }
 207  
 208  export type ApiKeySource =
 209    | 'ANTHROPIC_API_KEY'
 210    | 'apiKeyHelper'
 211    | '/login managed key'
 212    | 'none'
 213  
 214  export function getAnthropicApiKey(): null | string {
 215    const { key } = getAnthropicApiKeyWithSource()
 216    return key
 217  }
 218  
 219  export function hasAnthropicApiKeyAuth(): boolean {
 220    const { key, source } = getAnthropicApiKeyWithSource({
 221      skipRetrievingKeyFromApiKeyHelper: true,
 222    })
 223    return key !== null && source !== 'none'
 224  }
 225  
 226  export function getAnthropicApiKeyWithSource(
 227    opts: { skipRetrievingKeyFromApiKeyHelper?: boolean } = {},
 228  ): {
 229    key: null | string
 230    source: ApiKeySource
 231  } {
 232    // --bare: hermetic auth. Only ANTHROPIC_API_KEY env or apiKeyHelper from
 233    // the --settings flag. Never touches keychain, config file, or approval
 234    // lists. 3P (Bedrock/Vertex/Foundry) uses provider creds, not this path.
 235    if (isBareMode()) {
 236      if (process.env.ANTHROPIC_API_KEY) {
 237        return { key: process.env.ANTHROPIC_API_KEY, source: 'ANTHROPIC_API_KEY' }
 238      }
 239      if (getConfiguredApiKeyHelper()) {
 240        return {
 241          key: opts.skipRetrievingKeyFromApiKeyHelper
 242            ? null
 243            : getApiKeyFromApiKeyHelperCached(),
 244          source: 'apiKeyHelper',
 245        }
 246      }
 247      return { key: null, source: 'none' }
 248    }
 249  
 250    // On homespace, don't use ANTHROPIC_API_KEY (use Console key instead)
 251    // https://anthropic.slack.com/archives/C08428WSLKV/p1747331773214779
 252    const apiKeyEnv = isRunningOnHomespace()
 253      ? undefined
 254      : process.env.ANTHROPIC_API_KEY
 255  
 256    // Always check for direct environment variable when the user ran claude --print.
 257    // This is useful for CI, etc.
 258    if (preferThirdPartyAuthentication() && apiKeyEnv) {
 259      return {
 260        key: apiKeyEnv,
 261        source: 'ANTHROPIC_API_KEY',
 262      }
 263    }
 264  
 265    if (isEnvTruthy(process.env.CI) || process.env.NODE_ENV === 'test') {
 266      // Check for API key from file descriptor first
 267      const apiKeyFromFd = getApiKeyFromFileDescriptor()
 268      if (apiKeyFromFd) {
 269        return {
 270          key: apiKeyFromFd,
 271          source: 'ANTHROPIC_API_KEY',
 272        }
 273      }
 274  
 275      if (
 276        !apiKeyEnv &&
 277        !process.env.CLAUDE_CODE_OAUTH_TOKEN &&
 278        !process.env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR
 279      ) {
 280        throw new Error(
 281          'ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN env var is required',
 282        )
 283      }
 284  
 285      if (apiKeyEnv) {
 286        return {
 287          key: apiKeyEnv,
 288          source: 'ANTHROPIC_API_KEY',
 289        }
 290      }
 291  
 292      // OAuth token is present but this function returns API keys only
 293      return {
 294        key: null,
 295        source: 'none',
 296      }
 297    }
 298    // Check for ANTHROPIC_API_KEY before checking the apiKeyHelper or /login-managed key
 299    if (
 300      apiKeyEnv &&
 301      getGlobalConfig().customApiKeyResponses?.approved?.includes(
 302        normalizeApiKeyForConfig(apiKeyEnv),
 303      )
 304    ) {
 305      return {
 306        key: apiKeyEnv,
 307        source: 'ANTHROPIC_API_KEY',
 308      }
 309    }
 310  
 311    // Check for API key from file descriptor
 312    const apiKeyFromFd = getApiKeyFromFileDescriptor()
 313    if (apiKeyFromFd) {
 314      return {
 315        key: apiKeyFromFd,
 316        source: 'ANTHROPIC_API_KEY',
 317      }
 318    }
 319  
 320    // Check for apiKeyHelper — use sync cache, never block
 321    const apiKeyHelperCommand = getConfiguredApiKeyHelper()
 322    if (apiKeyHelperCommand) {
 323      if (opts.skipRetrievingKeyFromApiKeyHelper) {
 324        return {
 325          key: null,
 326          source: 'apiKeyHelper',
 327        }
 328      }
 329      // Cache may be cold (helper hasn't finished yet). Return null with
 330      // source='apiKeyHelper' rather than falling through to keychain —
 331      // apiKeyHelper must win. Callers needing a real key must await
 332      // getApiKeyFromApiKeyHelper() first (client.ts, useApiKeyVerification do).
 333      return {
 334        key: getApiKeyFromApiKeyHelperCached(),
 335        source: 'apiKeyHelper',
 336      }
 337    }
 338  
 339    const apiKeyFromConfigOrMacOSKeychain = getApiKeyFromConfigOrMacOSKeychain()
 340    if (apiKeyFromConfigOrMacOSKeychain) {
 341      return apiKeyFromConfigOrMacOSKeychain
 342    }
 343  
 344    return {
 345      key: null,
 346      source: 'none',
 347    }
 348  }
 349  
 350  /**
 351   * Get the configured apiKeyHelper from settings.
 352   * In bare mode, only the --settings flag source is consulted — apiKeyHelper
 353   * from ~/.claude/settings.json or project settings is ignored.
 354   */
 355  export function getConfiguredApiKeyHelper(): string | undefined {
 356    if (isBareMode()) {
 357      return getSettingsForSource('flagSettings')?.apiKeyHelper
 358    }
 359    const mergedSettings = getSettings_DEPRECATED() || {}
 360    return mergedSettings.apiKeyHelper
 361  }
 362  
 363  /**
 364   * Check if the configured apiKeyHelper comes from project settings (projectSettings or localSettings)
 365   */
 366  function isApiKeyHelperFromProjectOrLocalSettings(): boolean {
 367    const apiKeyHelper = getConfiguredApiKeyHelper()
 368    if (!apiKeyHelper) {
 369      return false
 370    }
 371  
 372    const projectSettings = getSettingsForSource('projectSettings')
 373    const localSettings = getSettingsForSource('localSettings')
 374    return (
 375      projectSettings?.apiKeyHelper === apiKeyHelper ||
 376      localSettings?.apiKeyHelper === apiKeyHelper
 377    )
 378  }
 379  
 380  /**
 381   * Get the configured awsAuthRefresh from settings
 382   */
 383  function getConfiguredAwsAuthRefresh(): string | undefined {
 384    const mergedSettings = getSettings_DEPRECATED() || {}
 385    return mergedSettings.awsAuthRefresh
 386  }
 387  
 388  /**
 389   * Check if the configured awsAuthRefresh comes from project settings
 390   */
 391  export function isAwsAuthRefreshFromProjectSettings(): boolean {
 392    const awsAuthRefresh = getConfiguredAwsAuthRefresh()
 393    if (!awsAuthRefresh) {
 394      return false
 395    }
 396  
 397    const projectSettings = getSettingsForSource('projectSettings')
 398    const localSettings = getSettingsForSource('localSettings')
 399    return (
 400      projectSettings?.awsAuthRefresh === awsAuthRefresh ||
 401      localSettings?.awsAuthRefresh === awsAuthRefresh
 402    )
 403  }
 404  
 405  /**
 406   * Get the configured awsCredentialExport from settings
 407   */
 408  function getConfiguredAwsCredentialExport(): string | undefined {
 409    const mergedSettings = getSettings_DEPRECATED() || {}
 410    return mergedSettings.awsCredentialExport
 411  }
 412  
 413  /**
 414   * Check if the configured awsCredentialExport comes from project settings
 415   */
 416  export function isAwsCredentialExportFromProjectSettings(): boolean {
 417    const awsCredentialExport = getConfiguredAwsCredentialExport()
 418    if (!awsCredentialExport) {
 419      return false
 420    }
 421  
 422    const projectSettings = getSettingsForSource('projectSettings')
 423    const localSettings = getSettingsForSource('localSettings')
 424    return (
 425      projectSettings?.awsCredentialExport === awsCredentialExport ||
 426      localSettings?.awsCredentialExport === awsCredentialExport
 427    )
 428  }
 429  
 430  /**
 431   * Calculate TTL in milliseconds for the API key helper cache
 432   * Uses CLAUDE_CODE_API_KEY_HELPER_TTL_MS env var if set and valid,
 433   * otherwise defaults to 5 minutes
 434   */
 435  export function calculateApiKeyHelperTTL(): number {
 436    const envTtl = process.env.CLAUDE_CODE_API_KEY_HELPER_TTL_MS
 437  
 438    if (envTtl) {
 439      const parsed = parseInt(envTtl, 10)
 440      if (!Number.isNaN(parsed) && parsed >= 0) {
 441        return parsed
 442      }
 443      logForDebugging(
 444        `Found CLAUDE_CODE_API_KEY_HELPER_TTL_MS env var, but it was not a valid number. Got ${envTtl}`,
 445        { level: 'error' },
 446      )
 447    }
 448  
 449    return DEFAULT_API_KEY_HELPER_TTL
 450  }
 451  
 452  // Async API key helper with sync cache for non-blocking reads.
 453  // Epoch bumps on clearApiKeyHelperCache() — orphaned executions check their
 454  // captured epoch before touching module state so a settings-change or 401-retry
 455  // mid-flight can't clobber the newer cache/inflight.
 456  let _apiKeyHelperCache: { value: string; timestamp: number } | null = null
 457  let _apiKeyHelperInflight: {
 458    promise: Promise<string | null>
 459    // Only set on cold launches (user is waiting); null for SWR background refreshes.
 460    startedAt: number | null
 461  } | null = null
 462  let _apiKeyHelperEpoch = 0
 463  
 464  export function getApiKeyHelperElapsedMs(): number {
 465    const startedAt = _apiKeyHelperInflight?.startedAt
 466    return startedAt ? Date.now() - startedAt : 0
 467  }
 468  
 469  export async function getApiKeyFromApiKeyHelper(
 470    isNonInteractiveSession: boolean,
 471  ): Promise<string | null> {
 472    if (!getConfiguredApiKeyHelper()) return null
 473    const ttl = calculateApiKeyHelperTTL()
 474    if (_apiKeyHelperCache) {
 475      if (Date.now() - _apiKeyHelperCache.timestamp < ttl) {
 476        return _apiKeyHelperCache.value
 477      }
 478      // Stale — return stale value now, refresh in the background.
 479      // `??=` banned here by eslint no-nullish-assign-object-call (bun bug).
 480      if (!_apiKeyHelperInflight) {
 481        _apiKeyHelperInflight = {
 482          promise: _runAndCache(
 483            isNonInteractiveSession,
 484            false,
 485            _apiKeyHelperEpoch,
 486          ),
 487          startedAt: null,
 488        }
 489      }
 490      return _apiKeyHelperCache.value
 491    }
 492    // Cold cache — deduplicate concurrent calls
 493    if (_apiKeyHelperInflight) return _apiKeyHelperInflight.promise
 494    _apiKeyHelperInflight = {
 495      promise: _runAndCache(isNonInteractiveSession, true, _apiKeyHelperEpoch),
 496      startedAt: Date.now(),
 497    }
 498    return _apiKeyHelperInflight.promise
 499  }
 500  
 501  async function _runAndCache(
 502    isNonInteractiveSession: boolean,
 503    isCold: boolean,
 504    epoch: number,
 505  ): Promise<string | null> {
 506    try {
 507      const value = await _executeApiKeyHelper(isNonInteractiveSession)
 508      if (epoch !== _apiKeyHelperEpoch) return value
 509      if (value !== null) {
 510        _apiKeyHelperCache = { value, timestamp: Date.now() }
 511      }
 512      return value
 513    } catch (e) {
 514      if (epoch !== _apiKeyHelperEpoch) return ' '
 515      const detail = e instanceof Error ? e.message : String(e)
 516      // biome-ignore lint/suspicious/noConsole: user-configured script failed; must be visible without --debug
 517      console.error(chalk.red(`apiKeyHelper failed: ${detail}`))
 518      logForDebugging(`Error getting API key from apiKeyHelper: ${detail}`, {
 519        level: 'error',
 520      })
 521      // SWR path: a transient failure shouldn't replace a working key with
 522      // the ' ' sentinel — keep serving the stale value and bump timestamp
 523      // so we don't hammer-retry every call.
 524      if (!isCold && _apiKeyHelperCache && _apiKeyHelperCache.value !== ' ') {
 525        _apiKeyHelperCache = { ..._apiKeyHelperCache, timestamp: Date.now() }
 526        return _apiKeyHelperCache.value
 527      }
 528      // Cold cache or prior error — cache ' ' so callers don't fall back to OAuth
 529      _apiKeyHelperCache = { value: ' ', timestamp: Date.now() }
 530      return ' '
 531    } finally {
 532      if (epoch === _apiKeyHelperEpoch) {
 533        _apiKeyHelperInflight = null
 534      }
 535    }
 536  }
 537  
 538  async function _executeApiKeyHelper(
 539    isNonInteractiveSession: boolean,
 540  ): Promise<string | null> {
 541    const apiKeyHelper = getConfiguredApiKeyHelper()
 542    if (!apiKeyHelper) {
 543      return null
 544    }
 545  
 546    if (isApiKeyHelperFromProjectOrLocalSettings()) {
 547      const hasTrust = checkHasTrustDialogAccepted()
 548      if (!hasTrust && !isNonInteractiveSession) {
 549        const error = new Error(
 550          `Security: apiKeyHelper executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
 551        )
 552        logAntError('apiKeyHelper invoked before trust check', error)
 553        logEvent('tengu_apiKeyHelper_missing_trust11', {})
 554        return null
 555      }
 556    }
 557  
 558    const result = await execa(apiKeyHelper, {
 559      shell: true,
 560      timeout: 10 * 60 * 1000,
 561      reject: false,
 562    })
 563    if (result.failed) {
 564      // reject:false — execa resolves on exit≠0/timeout, stderr is on result
 565      const why = result.timedOut ? 'timed out' : `exited ${result.exitCode}`
 566      const stderr = result.stderr?.trim()
 567      throw new Error(stderr ? `${why}: ${stderr}` : why)
 568    }
 569    const stdout = result.stdout?.trim()
 570    if (!stdout) {
 571      throw new Error('did not return a value')
 572    }
 573    return stdout
 574  }
 575  
 576  /**
 577   * Sync cache reader — returns the last fetched apiKeyHelper value without executing.
 578   * Returns stale values to match SWR semantics of the async reader.
 579   * Returns null only if the async fetch hasn't completed yet.
 580   */
 581  export function getApiKeyFromApiKeyHelperCached(): string | null {
 582    return _apiKeyHelperCache?.value ?? null
 583  }
 584  
 585  export function clearApiKeyHelperCache(): void {
 586    _apiKeyHelperEpoch++
 587    _apiKeyHelperCache = null
 588    _apiKeyHelperInflight = null
 589  }
 590  
 591  export function prefetchApiKeyFromApiKeyHelperIfSafe(
 592    isNonInteractiveSession: boolean,
 593  ): void {
 594    // Skip if trust not yet accepted — the inner _executeApiKeyHelper check
 595    // would catch this too, but would fire a false-positive analytics event.
 596    if (
 597      isApiKeyHelperFromProjectOrLocalSettings() &&
 598      !checkHasTrustDialogAccepted()
 599    ) {
 600      return
 601    }
 602    void getApiKeyFromApiKeyHelper(isNonInteractiveSession)
 603  }
 604  
 605  /** Default STS credentials are one hour. We manually manage invalidation, so not too worried about this being accurate. */
 606  const DEFAULT_AWS_STS_TTL = 60 * 60 * 1000
 607  
 608  /**
 609   * Run awsAuthRefresh to perform interactive authentication (e.g., aws sso login)
 610   * Streams output in real-time for user visibility
 611   */
 612  async function runAwsAuthRefresh(): Promise<boolean> {
 613    const awsAuthRefresh = getConfiguredAwsAuthRefresh()
 614  
 615    if (!awsAuthRefresh) {
 616      return false // Not configured, treat as success
 617    }
 618  
 619    // SECURITY: Check if awsAuthRefresh is from project settings
 620    if (isAwsAuthRefreshFromProjectSettings()) {
 621      // Check if trust has been established for this project
 622      const hasTrust = checkHasTrustDialogAccepted()
 623      if (!hasTrust && !getIsNonInteractiveSession()) {
 624        const error = new Error(
 625          `Security: awsAuthRefresh executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
 626        )
 627        logAntError('awsAuthRefresh invoked before trust check', error)
 628        logEvent('tengu_awsAuthRefresh_missing_trust', {})
 629        return false
 630      }
 631    }
 632  
 633    try {
 634      logForDebugging('Fetching AWS caller identity for AWS auth refresh command')
 635      await checkStsCallerIdentity()
 636      logForDebugging(
 637        'Fetched AWS caller identity, skipping AWS auth refresh command',
 638      )
 639      return false
 640    } catch {
 641      // only actually do the refresh if caller-identity calls
 642      return refreshAwsAuth(awsAuthRefresh)
 643    }
 644  }
 645  
 646  // Timeout for AWS auth refresh command (3 minutes).
 647  // Long enough for browser-based SSO flows, short enough to prevent indefinite hangs.
 648  const AWS_AUTH_REFRESH_TIMEOUT_MS = 3 * 60 * 1000
 649  
 650  export function refreshAwsAuth(awsAuthRefresh: string): Promise<boolean> {
 651    logForDebugging('Running AWS auth refresh command')
 652    // Start tracking authentication status
 653    const authStatusManager = AwsAuthStatusManager.getInstance()
 654    authStatusManager.startAuthentication()
 655  
 656    return new Promise(resolve => {
 657      const refreshProc = exec(awsAuthRefresh, {
 658        timeout: AWS_AUTH_REFRESH_TIMEOUT_MS,
 659      })
 660      refreshProc.stdout!.on('data', data => {
 661        const output = data.toString().trim()
 662        if (output) {
 663          // Add output to status manager for UI display
 664          authStatusManager.addOutput(output)
 665          // Also log for debugging
 666          logForDebugging(output, { level: 'debug' })
 667        }
 668      })
 669  
 670      refreshProc.stderr!.on('data', data => {
 671        const error = data.toString().trim()
 672        if (error) {
 673          authStatusManager.setError(error)
 674          logForDebugging(error, { level: 'error' })
 675        }
 676      })
 677  
 678      refreshProc.on('close', (code, signal) => {
 679        if (code === 0) {
 680          logForDebugging('AWS auth refresh completed successfully')
 681          authStatusManager.endAuthentication(true)
 682          void resolve(true)
 683        } else {
 684          const timedOut = signal === 'SIGTERM'
 685          const message = timedOut
 686            ? chalk.red(
 687                'AWS auth refresh timed out after 3 minutes. Run your auth command manually in a separate terminal.',
 688              )
 689            : chalk.red(
 690                'Error running awsAuthRefresh (in settings or ~/.claude.json):',
 691              )
 692          // biome-ignore lint/suspicious/noConsole:: intentional console output
 693          console.error(message)
 694          authStatusManager.endAuthentication(false)
 695          void resolve(false)
 696        }
 697      })
 698    })
 699  }
 700  
 701  /**
 702   * Run awsCredentialExport to get credentials and set environment variables
 703   * Expects JSON output containing AWS credentials
 704   */
 705  async function getAwsCredsFromCredentialExport(): Promise<{
 706    accessKeyId: string
 707    secretAccessKey: string
 708    sessionToken: string
 709  } | null> {
 710    const awsCredentialExport = getConfiguredAwsCredentialExport()
 711  
 712    if (!awsCredentialExport) {
 713      return null
 714    }
 715  
 716    // SECURITY: Check if awsCredentialExport is from project settings
 717    if (isAwsCredentialExportFromProjectSettings()) {
 718      // Check if trust has been established for this project
 719      const hasTrust = checkHasTrustDialogAccepted()
 720      if (!hasTrust && !getIsNonInteractiveSession()) {
 721        const error = new Error(
 722          `Security: awsCredentialExport executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
 723        )
 724        logAntError('awsCredentialExport invoked before trust check', error)
 725        logEvent('tengu_awsCredentialExport_missing_trust', {})
 726        return null
 727      }
 728    }
 729  
 730    try {
 731      logForDebugging(
 732        'Fetching AWS caller identity for credential export command',
 733      )
 734      await checkStsCallerIdentity()
 735      logForDebugging(
 736        'Fetched AWS caller identity, skipping AWS credential export command',
 737      )
 738      return null
 739    } catch {
 740      // only actually do the export if caller-identity calls
 741      try {
 742        logForDebugging('Running AWS credential export command')
 743        const result = await execa(awsCredentialExport, {
 744          shell: true,
 745          reject: false,
 746        })
 747        if (result.exitCode !== 0 || !result.stdout) {
 748          throw new Error('awsCredentialExport did not return a valid value')
 749        }
 750  
 751        // Parse the JSON output from aws sts commands
 752        const awsOutput = jsonParse(result.stdout.trim())
 753  
 754        if (!isValidAwsStsOutput(awsOutput)) {
 755          throw new Error(
 756            'awsCredentialExport did not return valid AWS STS output structure',
 757          )
 758        }
 759  
 760        logForDebugging('AWS credentials retrieved from awsCredentialExport')
 761        return {
 762          accessKeyId: awsOutput.Credentials.AccessKeyId,
 763          secretAccessKey: awsOutput.Credentials.SecretAccessKey,
 764          sessionToken: awsOutput.Credentials.SessionToken,
 765        }
 766      } catch (e) {
 767        const message = chalk.red(
 768          'Error getting AWS credentials from awsCredentialExport (in settings or ~/.claude.json):',
 769        )
 770        if (e instanceof Error) {
 771          // biome-ignore lint/suspicious/noConsole:: intentional console output
 772          console.error(message, e.message)
 773        } else {
 774          // biome-ignore lint/suspicious/noConsole:: intentional console output
 775          console.error(message, e)
 776        }
 777        return null
 778      }
 779    }
 780  }
 781  
 782  /**
 783   * Refresh AWS authentication and get credentials with cache clearing
 784   * This combines runAwsAuthRefresh, getAwsCredsFromCredentialExport, and clearAwsIniCache
 785   * to ensure fresh credentials are always used
 786   */
 787  export const refreshAndGetAwsCredentials = memoizeWithTTLAsync(
 788    async (): Promise<{
 789      accessKeyId: string
 790      secretAccessKey: string
 791      sessionToken: string
 792    } | null> => {
 793      // First run auth refresh if needed
 794      const refreshed = await runAwsAuthRefresh()
 795  
 796      // Get credentials from export
 797      const credentials = await getAwsCredsFromCredentialExport()
 798  
 799      // Clear AWS INI cache to ensure fresh credentials are used
 800      if (refreshed || credentials) {
 801        await clearAwsIniCache()
 802      }
 803  
 804      return credentials
 805    },
 806    DEFAULT_AWS_STS_TTL,
 807  )
 808  
 809  export function clearAwsCredentialsCache(): void {
 810    refreshAndGetAwsCredentials.cache.clear()
 811  }
 812  
 813  /**
 814   * Get the configured gcpAuthRefresh from settings
 815   */
 816  function getConfiguredGcpAuthRefresh(): string | undefined {
 817    const mergedSettings = getSettings_DEPRECATED() || {}
 818    return mergedSettings.gcpAuthRefresh
 819  }
 820  
 821  /**
 822   * Check if the configured gcpAuthRefresh comes from project settings
 823   */
 824  export function isGcpAuthRefreshFromProjectSettings(): boolean {
 825    const gcpAuthRefresh = getConfiguredGcpAuthRefresh()
 826    if (!gcpAuthRefresh) {
 827      return false
 828    }
 829  
 830    const projectSettings = getSettingsForSource('projectSettings')
 831    const localSettings = getSettingsForSource('localSettings')
 832    return (
 833      projectSettings?.gcpAuthRefresh === gcpAuthRefresh ||
 834      localSettings?.gcpAuthRefresh === gcpAuthRefresh
 835    )
 836  }
 837  
 838  /** Short timeout for the GCP credentials probe. Without this, when no local
 839   *  credential source exists (no ADC file, no env var), google-auth-library falls
 840   *  through to the GCE metadata server which hangs ~12s outside GCP. */
 841  const GCP_CREDENTIALS_CHECK_TIMEOUT_MS = 5_000
 842  
 843  /**
 844   * Check if GCP credentials are currently valid by attempting to get an access token.
 845   * This uses the same authentication chain that the Vertex SDK uses.
 846   */
 847  export async function checkGcpCredentialsValid(): Promise<boolean> {
 848    try {
 849      // Dynamically import to avoid loading google-auth-library unnecessarily
 850      const { GoogleAuth } = await import('google-auth-library')
 851      const auth = new GoogleAuth({
 852        scopes: ['https://www.googleapis.com/auth/cloud-platform'],
 853      })
 854      const probe = (async () => {
 855        const client = await auth.getClient()
 856        await client.getAccessToken()
 857      })()
 858      const timeout = sleep(GCP_CREDENTIALS_CHECK_TIMEOUT_MS).then(() => {
 859        throw new GcpCredentialsTimeoutError('GCP credentials check timed out')
 860      })
 861      await Promise.race([probe, timeout])
 862      return true
 863    } catch {
 864      return false
 865    }
 866  }
 867  
 868  /** Default GCP credential TTL - 1 hour to match typical ADC token lifetime */
 869  const DEFAULT_GCP_CREDENTIAL_TTL = 60 * 60 * 1000
 870  
 871  /**
 872   * Run gcpAuthRefresh to perform interactive authentication (e.g., gcloud auth application-default login)
 873   * Streams output in real-time for user visibility
 874   */
 875  async function runGcpAuthRefresh(): Promise<boolean> {
 876    const gcpAuthRefresh = getConfiguredGcpAuthRefresh()
 877  
 878    if (!gcpAuthRefresh) {
 879      return false // Not configured, treat as success
 880    }
 881  
 882    // SECURITY: Check if gcpAuthRefresh is from project settings
 883    if (isGcpAuthRefreshFromProjectSettings()) {
 884      // Check if trust has been established for this project
 885      // Pass true to indicate this is a dangerous feature that requires trust
 886      const hasTrust = checkHasTrustDialogAccepted()
 887      if (!hasTrust && !getIsNonInteractiveSession()) {
 888        const error = new Error(
 889          `Security: gcpAuthRefresh executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
 890        )
 891        logAntError('gcpAuthRefresh invoked before trust check', error)
 892        logEvent('tengu_gcpAuthRefresh_missing_trust', {})
 893        return false
 894      }
 895    }
 896  
 897    try {
 898      logForDebugging('Checking GCP credentials validity for auth refresh')
 899      const isValid = await checkGcpCredentialsValid()
 900      if (isValid) {
 901        logForDebugging(
 902          'GCP credentials are valid, skipping auth refresh command',
 903        )
 904        return false
 905      }
 906    } catch {
 907      // Credentials check failed, proceed with refresh
 908    }
 909  
 910    return refreshGcpAuth(gcpAuthRefresh)
 911  }
 912  
 913  // Timeout for GCP auth refresh command (3 minutes).
 914  // Long enough for browser-based auth flows, short enough to prevent indefinite hangs.
 915  const GCP_AUTH_REFRESH_TIMEOUT_MS = 3 * 60 * 1000
 916  
 917  export function refreshGcpAuth(gcpAuthRefresh: string): Promise<boolean> {
 918    logForDebugging('Running GCP auth refresh command')
 919    // Start tracking authentication status. AwsAuthStatusManager is cloud-provider-agnostic
 920    // despite the name — print.ts emits its updates as generic SDK 'auth_status' messages.
 921    const authStatusManager = AwsAuthStatusManager.getInstance()
 922    authStatusManager.startAuthentication()
 923  
 924    return new Promise(resolve => {
 925      const refreshProc = exec(gcpAuthRefresh, {
 926        timeout: GCP_AUTH_REFRESH_TIMEOUT_MS,
 927      })
 928      refreshProc.stdout!.on('data', data => {
 929        const output = data.toString().trim()
 930        if (output) {
 931          // Add output to status manager for UI display
 932          authStatusManager.addOutput(output)
 933          // Also log for debugging
 934          logForDebugging(output, { level: 'debug' })
 935        }
 936      })
 937  
 938      refreshProc.stderr!.on('data', data => {
 939        const error = data.toString().trim()
 940        if (error) {
 941          authStatusManager.setError(error)
 942          logForDebugging(error, { level: 'error' })
 943        }
 944      })
 945  
 946      refreshProc.on('close', (code, signal) => {
 947        if (code === 0) {
 948          logForDebugging('GCP auth refresh completed successfully')
 949          authStatusManager.endAuthentication(true)
 950          void resolve(true)
 951        } else {
 952          const timedOut = signal === 'SIGTERM'
 953          const message = timedOut
 954            ? chalk.red(
 955                'GCP auth refresh timed out after 3 minutes. Run your auth command manually in a separate terminal.',
 956              )
 957            : chalk.red(
 958                'Error running gcpAuthRefresh (in settings or ~/.claude.json):',
 959              )
 960          // biome-ignore lint/suspicious/noConsole:: intentional console output
 961          console.error(message)
 962          authStatusManager.endAuthentication(false)
 963          void resolve(false)
 964        }
 965      })
 966    })
 967  }
 968  
 969  /**
 970   * Refresh GCP authentication if needed.
 971   * This function checks if credentials are valid and runs the refresh command if not.
 972   * Memoized with TTL to avoid excessive refresh attempts.
 973   */
 974  export const refreshGcpCredentialsIfNeeded = memoizeWithTTLAsync(
 975    async (): Promise<boolean> => {
 976      // Run auth refresh if needed
 977      const refreshed = await runGcpAuthRefresh()
 978      return refreshed
 979    },
 980    DEFAULT_GCP_CREDENTIAL_TTL,
 981  )
 982  
 983  export function clearGcpCredentialsCache(): void {
 984    refreshGcpCredentialsIfNeeded.cache.clear()
 985  }
 986  
 987  /**
 988   * Prefetches GCP credentials only if workspace trust has already been established.
 989   * This allows us to start the potentially slow GCP commands early for trusted workspaces
 990   * while maintaining security for untrusted ones.
 991   *
 992   * Returns void to prevent misuse - use refreshGcpCredentialsIfNeeded() to actually refresh.
 993   */
 994  export function prefetchGcpCredentialsIfSafe(): void {
 995    // Check if gcpAuthRefresh is configured
 996    const gcpAuthRefresh = getConfiguredGcpAuthRefresh()
 997  
 998    if (!gcpAuthRefresh) {
 999      return
1000    }
1001  
1002    // Check if gcpAuthRefresh is from project settings
1003    if (isGcpAuthRefreshFromProjectSettings()) {
1004      // Only prefetch if trust has already been established
1005      const hasTrust = checkHasTrustDialogAccepted()
1006      if (!hasTrust && !getIsNonInteractiveSession()) {
1007        // Don't prefetch - wait for trust to be established first
1008        return
1009      }
1010    }
1011  
1012    // Safe to prefetch - either not from project settings or trust already established
1013    void refreshGcpCredentialsIfNeeded()
1014  }
1015  
1016  /**
1017   * Prefetches AWS credentials only if workspace trust has already been established.
1018   * This allows us to start the potentially slow AWS commands early for trusted workspaces
1019   * while maintaining security for untrusted ones.
1020   *
1021   * Returns void to prevent misuse - use refreshAndGetAwsCredentials() to actually retrieve credentials.
1022   */
1023  export function prefetchAwsCredentialsAndBedRockInfoIfSafe(): void {
1024    // Check if either AWS command is configured
1025    const awsAuthRefresh = getConfiguredAwsAuthRefresh()
1026    const awsCredentialExport = getConfiguredAwsCredentialExport()
1027  
1028    if (!awsAuthRefresh && !awsCredentialExport) {
1029      return
1030    }
1031  
1032    // Check if either command is from project settings
1033    if (
1034      isAwsAuthRefreshFromProjectSettings() ||
1035      isAwsCredentialExportFromProjectSettings()
1036    ) {
1037      // Only prefetch if trust has already been established
1038      const hasTrust = checkHasTrustDialogAccepted()
1039      if (!hasTrust && !getIsNonInteractiveSession()) {
1040        // Don't prefetch - wait for trust to be established first
1041        return
1042      }
1043    }
1044  
1045    // Safe to prefetch - either not from project settings or trust already established
1046    void refreshAndGetAwsCredentials()
1047    getModelStrings()
1048  }
1049  
1050  /** @private Use {@link getAnthropicApiKey} or {@link getAnthropicApiKeyWithSource} */
1051  export const getApiKeyFromConfigOrMacOSKeychain = memoize(
1052    (): { key: string; source: ApiKeySource } | null => {
1053      if (isBareMode()) return null
1054      // TODO: migrate to SecureStorage
1055      if (process.platform === 'darwin') {
1056        // keychainPrefetch.ts fires this read at main.tsx top-level in parallel
1057        // with module imports. If it completed, use that instead of spawning a
1058        // sync `security` subprocess here (~33ms).
1059        const prefetch = getLegacyApiKeyPrefetchResult()
1060        if (prefetch) {
1061          if (prefetch.stdout) {
1062            return { key: prefetch.stdout, source: '/login managed key' }
1063          }
1064          // Prefetch completed with no key — fall through to config, not keychain.
1065        } else {
1066          const storageServiceName = getMacOsKeychainStorageServiceName()
1067          try {
1068            const result = execSyncWithDefaults_DEPRECATED(
1069              `security find-generic-password -a $USER -w -s "${storageServiceName}"`,
1070            )
1071            if (result) {
1072              return { key: result, source: '/login managed key' }
1073            }
1074          } catch (e) {
1075            logError(e)
1076          }
1077        }
1078      }
1079  
1080      const config = getGlobalConfig()
1081      if (!config.primaryApiKey) {
1082        return null
1083      }
1084  
1085      return { key: config.primaryApiKey, source: '/login managed key' }
1086    },
1087  )
1088  
1089  function isValidApiKey(apiKey: string): boolean {
1090    // Only allow alphanumeric characters, dashes, and underscores
1091    return /^[a-zA-Z0-9-_]+$/.test(apiKey)
1092  }
1093  
1094  export async function saveApiKey(apiKey: string): Promise<void> {
1095    if (!isValidApiKey(apiKey)) {
1096      throw new Error(
1097        'Invalid API key format. API key must contain only alphanumeric characters, dashes, and underscores.',
1098      )
1099    }
1100  
1101    // Store as primary API key
1102    await maybeRemoveApiKeyFromMacOSKeychain()
1103    let savedToKeychain = false
1104    if (process.platform === 'darwin') {
1105      try {
1106        // TODO: migrate to SecureStorage
1107        const storageServiceName = getMacOsKeychainStorageServiceName()
1108        const username = getUsername()
1109  
1110        // Convert to hexadecimal to avoid any escaping issues
1111        const hexValue = Buffer.from(apiKey, 'utf-8').toString('hex')
1112  
1113        // Use security's interactive mode (-i) with -X (hexadecimal) option
1114        // This ensures credentials never appear in process command-line arguments
1115        // Process monitors only see "security -i", not the password
1116        const command = `add-generic-password -U -a "${username}" -s "${storageServiceName}" -X "${hexValue}"\n`
1117  
1118        await execa('security', ['-i'], {
1119          input: command,
1120          reject: false,
1121        })
1122  
1123        logEvent('tengu_api_key_saved_to_keychain', {})
1124        savedToKeychain = true
1125      } catch (e) {
1126        logError(e)
1127        logEvent('tengu_api_key_keychain_error', {
1128          error: errorMessage(
1129            e,
1130          ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1131        })
1132        logEvent('tengu_api_key_saved_to_config', {})
1133      }
1134    } else {
1135      logEvent('tengu_api_key_saved_to_config', {})
1136    }
1137  
1138    const normalizedKey = normalizeApiKeyForConfig(apiKey)
1139  
1140    // Save config with all updates
1141    saveGlobalConfig(current => {
1142      const approved = current.customApiKeyResponses?.approved ?? []
1143      return {
1144        ...current,
1145        // Only save to config if keychain save failed or not on darwin
1146        primaryApiKey: savedToKeychain ? current.primaryApiKey : apiKey,
1147        customApiKeyResponses: {
1148          ...current.customApiKeyResponses,
1149          approved: approved.includes(normalizedKey)
1150            ? approved
1151            : [...approved, normalizedKey],
1152          rejected: current.customApiKeyResponses?.rejected ?? [],
1153        },
1154      }
1155    })
1156  
1157    // Clear memo cache
1158    getApiKeyFromConfigOrMacOSKeychain.cache.clear?.()
1159    clearLegacyApiKeyPrefetch()
1160  }
1161  
1162  export function isCustomApiKeyApproved(apiKey: string): boolean {
1163    const config = getGlobalConfig()
1164    const normalizedKey = normalizeApiKeyForConfig(apiKey)
1165    return (
1166      config.customApiKeyResponses?.approved?.includes(normalizedKey) ?? false
1167    )
1168  }
1169  
1170  export async function removeApiKey(): Promise<void> {
1171    await maybeRemoveApiKeyFromMacOSKeychain()
1172  
1173    // Also remove from config instead of returning early, for older clients
1174    // that set keys before we supported keychain.
1175    saveGlobalConfig(current => ({
1176      ...current,
1177      primaryApiKey: undefined,
1178    }))
1179  
1180    // Clear memo cache
1181    getApiKeyFromConfigOrMacOSKeychain.cache.clear?.()
1182    clearLegacyApiKeyPrefetch()
1183  }
1184  
1185  async function maybeRemoveApiKeyFromMacOSKeychain(): Promise<void> {
1186    try {
1187      await maybeRemoveApiKeyFromMacOSKeychainThrows()
1188    } catch (e) {
1189      logError(e)
1190    }
1191  }
1192  
1193  // Function to store OAuth tokens in secure storage
1194  export function saveOAuthTokensIfNeeded(tokens: OAuthTokens): {
1195    success: boolean
1196    warning?: string
1197  } {
1198    if (!shouldUseClaudeAIAuth(tokens.scopes)) {
1199      logEvent('tengu_oauth_tokens_not_claude_ai', {})
1200      return { success: true }
1201    }
1202  
1203    // Skip saving inference-only tokens (they come from env vars)
1204    if (!tokens.refreshToken || !tokens.expiresAt) {
1205      logEvent('tengu_oauth_tokens_inference_only', {})
1206      return { success: true }
1207    }
1208  
1209    const secureStorage = getSecureStorage()
1210    const storageBackend =
1211      secureStorage.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
1212  
1213    try {
1214      const storageData = secureStorage.read() || {}
1215      const existingOauth = storageData.claudeAiOauth
1216  
1217      storageData.claudeAiOauth = {
1218        accessToken: tokens.accessToken,
1219        refreshToken: tokens.refreshToken,
1220        expiresAt: tokens.expiresAt,
1221        scopes: tokens.scopes,
1222        // Profile fetch in refreshOAuthToken swallows errors and returns null on
1223        // transient failures (network, 5xx, rate limit). Don't clobber a valid
1224        // stored subscription with null — fall back to the existing value.
1225        subscriptionType:
1226          tokens.subscriptionType ?? existingOauth?.subscriptionType ?? null,
1227        rateLimitTier:
1228          tokens.rateLimitTier ?? existingOauth?.rateLimitTier ?? null,
1229      }
1230  
1231      const updateStatus = secureStorage.update(storageData)
1232  
1233      if (updateStatus.success) {
1234        logEvent('tengu_oauth_tokens_saved', { storageBackend })
1235      } else {
1236        logEvent('tengu_oauth_tokens_save_failed', { storageBackend })
1237      }
1238  
1239      getClaudeAIOAuthTokens.cache?.clear?.()
1240      clearBetasCaches()
1241      clearToolSchemaCache()
1242      return updateStatus
1243    } catch (error) {
1244      logError(error)
1245      logEvent('tengu_oauth_tokens_save_exception', {
1246        storageBackend,
1247        error: errorMessage(
1248          error,
1249        ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1250      })
1251      return { success: false, warning: 'Failed to save OAuth tokens' }
1252    }
1253  }
1254  
1255  export const getClaudeAIOAuthTokens = memoize((): OAuthTokens | null => {
1256    // --bare: API-key-only. No OAuth env tokens, no keychain, no credentials file.
1257    if (isBareMode()) return null
1258  
1259    // Check for force-set OAuth token from environment variable
1260    if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
1261      // Return an inference-only token (unknown refresh and expiry)
1262      return {
1263        accessToken: process.env.CLAUDE_CODE_OAUTH_TOKEN,
1264        refreshToken: null,
1265        expiresAt: null,
1266        scopes: ['user:inference'],
1267        subscriptionType: null,
1268        rateLimitTier: null,
1269      }
1270    }
1271  
1272    // Check for OAuth token from file descriptor
1273    const oauthTokenFromFd = getOAuthTokenFromFileDescriptor()
1274    if (oauthTokenFromFd) {
1275      // Return an inference-only token (unknown refresh and expiry)
1276      return {
1277        accessToken: oauthTokenFromFd,
1278        refreshToken: null,
1279        expiresAt: null,
1280        scopes: ['user:inference'],
1281        subscriptionType: null,
1282        rateLimitTier: null,
1283      }
1284    }
1285  
1286    try {
1287      const secureStorage = getSecureStorage()
1288      const storageData = secureStorage.read()
1289      const oauthData = storageData?.claudeAiOauth
1290  
1291      if (!oauthData?.accessToken) {
1292        return null
1293      }
1294  
1295      return oauthData
1296    } catch (error) {
1297      logError(error)
1298      return null
1299    }
1300  })
1301  
1302  /**
1303   * Clears all OAuth token caches. Call this on 401 errors to ensure
1304   * the next token read comes from secure storage, not stale in-memory caches.
1305   * This handles the case where the local expiration check disagrees with the
1306   * server (e.g., due to clock corrections after token was issued).
1307   */
1308  export function clearOAuthTokenCache(): void {
1309    getClaudeAIOAuthTokens.cache?.clear?.()
1310    clearKeychainCache()
1311  }
1312  
1313  let lastCredentialsMtimeMs = 0
1314  
1315  // Cross-process staleness: another CC instance may write fresh tokens to
1316  // disk (refresh or /login), but this process's memoize caches forever.
1317  // Without this, terminal 1's /login fixes terminal 1; terminal 2's /login
1318  // then revokes terminal 1 server-side, and terminal 1's memoize never
1319  // re-reads — infinite /login regress (CC-1096, GH#24317).
1320  async function invalidateOAuthCacheIfDiskChanged(): Promise<void> {
1321    try {
1322      const { mtimeMs } = await stat(
1323        join(getClaudeConfigHomeDir(), '.credentials.json'),
1324      )
1325      if (mtimeMs !== lastCredentialsMtimeMs) {
1326        lastCredentialsMtimeMs = mtimeMs
1327        clearOAuthTokenCache()
1328      }
1329    } catch {
1330      // ENOENT — macOS keychain path (file deleted on migration). Clear only
1331      // the memoize so it delegates to the keychain cache's 30s TTL instead
1332      // of caching forever on top. `security find-generic-password` is
1333      // ~15ms; bounded to once per 30s by the keychain cache.
1334      getClaudeAIOAuthTokens.cache?.clear?.()
1335    }
1336  }
1337  
1338  // In-flight dedup: when N claude.ai proxy connectors hit 401 with the same
1339  // token simultaneously (common at startup — #20930), only one should clear
1340  // caches and re-read the keychain. Without this, each call's clearOAuthTokenCache()
1341  // nukes readInFlight in macOsKeychainStorage and triggers a fresh spawn —
1342  // sync spawns stacked to 800ms+ of blocked render frames.
1343  const pending401Handlers = new Map<string, Promise<boolean>>()
1344  
1345  /**
1346   * Handle a 401 "OAuth token has expired" error from the API.
1347   *
1348   * This function forces a token refresh when the server says the token is expired,
1349   * even if our local expiration check disagrees (which can happen due to clock
1350   * issues when the token was issued).
1351   *
1352   * Safety: We compare the failed token with what's in keychain. If another tab
1353   * already refreshed (different token in keychain), we use that instead of
1354   * refreshing again. Concurrent calls with the same failedAccessToken are
1355   * deduplicated to a single keychain read.
1356   *
1357   * @param failedAccessToken - The access token that was rejected with 401
1358   * @returns true if we now have a valid token, false otherwise
1359   */
1360  export function handleOAuth401Error(
1361    failedAccessToken: string,
1362  ): Promise<boolean> {
1363    const pending = pending401Handlers.get(failedAccessToken)
1364    if (pending) return pending
1365  
1366    const promise = handleOAuth401ErrorImpl(failedAccessToken).finally(() => {
1367      pending401Handlers.delete(failedAccessToken)
1368    })
1369    pending401Handlers.set(failedAccessToken, promise)
1370    return promise
1371  }
1372  
1373  async function handleOAuth401ErrorImpl(
1374    failedAccessToken: string,
1375  ): Promise<boolean> {
1376    // Clear caches and re-read from keychain (async — sync read blocks ~100ms/call)
1377    clearOAuthTokenCache()
1378    const currentTokens = await getClaudeAIOAuthTokensAsync()
1379  
1380    if (!currentTokens?.refreshToken) {
1381      return false
1382    }
1383  
1384    // If keychain has a different token, another tab already refreshed - use it
1385    if (currentTokens.accessToken !== failedAccessToken) {
1386      logEvent('tengu_oauth_401_recovered_from_keychain', {})
1387      return true
1388    }
1389  
1390    // Same token that failed - force refresh, bypassing local expiration check
1391    return checkAndRefreshOAuthTokenIfNeeded(0, true)
1392  }
1393  
1394  /**
1395   * Reads OAuth tokens asynchronously, avoiding blocking keychain reads.
1396   * Delegates to the sync memoized version for env var / file descriptor tokens
1397   * (which don't hit the keychain), and only uses async for storage reads.
1398   */
1399  export async function getClaudeAIOAuthTokensAsync(): Promise<OAuthTokens | null> {
1400    if (isBareMode()) return null
1401  
1402    // Env var and FD tokens are sync and don't hit the keychain
1403    if (
1404      process.env.CLAUDE_CODE_OAUTH_TOKEN ||
1405      getOAuthTokenFromFileDescriptor()
1406    ) {
1407      return getClaudeAIOAuthTokens()
1408    }
1409  
1410    try {
1411      const secureStorage = getSecureStorage()
1412      const storageData = await secureStorage.readAsync()
1413      const oauthData = storageData?.claudeAiOauth
1414      if (!oauthData?.accessToken) {
1415        return null
1416      }
1417      return oauthData
1418    } catch (error) {
1419      logError(error)
1420      return null
1421    }
1422  }
1423  
1424  // In-flight promise for deduplicating concurrent calls
1425  let pendingRefreshCheck: Promise<boolean> | null = null
1426  
1427  export function checkAndRefreshOAuthTokenIfNeeded(
1428    retryCount = 0,
1429    force = false,
1430  ): Promise<boolean> {
1431    // Deduplicate concurrent non-retry, non-force calls
1432    if (retryCount === 0 && !force) {
1433      if (pendingRefreshCheck) {
1434        return pendingRefreshCheck
1435      }
1436  
1437      const promise = checkAndRefreshOAuthTokenIfNeededImpl(retryCount, force)
1438      pendingRefreshCheck = promise.finally(() => {
1439        pendingRefreshCheck = null
1440      })
1441      return pendingRefreshCheck
1442    }
1443  
1444    return checkAndRefreshOAuthTokenIfNeededImpl(retryCount, force)
1445  }
1446  
1447  async function checkAndRefreshOAuthTokenIfNeededImpl(
1448    retryCount: number,
1449    force: boolean,
1450  ): Promise<boolean> {
1451    const MAX_RETRIES = 5
1452  
1453    await invalidateOAuthCacheIfDiskChanged()
1454  
1455    // First check if token is expired with cached value
1456    // Skip this check if force=true (server already told us token is bad)
1457    const tokens = getClaudeAIOAuthTokens()
1458    if (!force) {
1459      if (!tokens?.refreshToken || !isOAuthTokenExpired(tokens.expiresAt)) {
1460        return false
1461      }
1462    }
1463  
1464    if (!tokens?.refreshToken) {
1465      return false
1466    }
1467  
1468    if (!shouldUseClaudeAIAuth(tokens.scopes)) {
1469      return false
1470    }
1471  
1472    // Re-read tokens async to check if they're still expired
1473    // Another process might have refreshed them
1474    getClaudeAIOAuthTokens.cache?.clear?.()
1475    clearKeychainCache()
1476    const freshTokens = await getClaudeAIOAuthTokensAsync()
1477    if (
1478      !freshTokens?.refreshToken ||
1479      !isOAuthTokenExpired(freshTokens.expiresAt)
1480    ) {
1481      return false
1482    }
1483  
1484    // Tokens are still expired, try to acquire lock and refresh
1485    const claudeDir = getClaudeConfigHomeDir()
1486    await mkdir(claudeDir, { recursive: true })
1487  
1488    let release
1489    try {
1490      logEvent('tengu_oauth_token_refresh_lock_acquiring', {})
1491      release = await lockfile.lock(claudeDir)
1492      logEvent('tengu_oauth_token_refresh_lock_acquired', {})
1493    } catch (err) {
1494      if ((err as { code?: string }).code === 'ELOCKED') {
1495        // Another process has the lock, let's retry if we haven't exceeded max retries
1496        if (retryCount < MAX_RETRIES) {
1497          logEvent('tengu_oauth_token_refresh_lock_retry', {
1498            retryCount: retryCount + 1,
1499          })
1500          // Wait a bit before retrying
1501          await sleep(1000 + Math.random() * 1000)
1502          return checkAndRefreshOAuthTokenIfNeededImpl(retryCount + 1, force)
1503        }
1504        logEvent('tengu_oauth_token_refresh_lock_retry_limit_reached', {
1505          maxRetries: MAX_RETRIES,
1506        })
1507        return false
1508      }
1509      logError(err)
1510      logEvent('tengu_oauth_token_refresh_lock_error', {
1511        error: errorMessage(
1512          err,
1513        ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1514      })
1515      return false
1516    }
1517    try {
1518      // Check one more time after acquiring lock
1519      getClaudeAIOAuthTokens.cache?.clear?.()
1520      clearKeychainCache()
1521      const lockedTokens = await getClaudeAIOAuthTokensAsync()
1522      if (
1523        !lockedTokens?.refreshToken ||
1524        !isOAuthTokenExpired(lockedTokens.expiresAt)
1525      ) {
1526        logEvent('tengu_oauth_token_refresh_race_resolved', {})
1527        return false
1528      }
1529  
1530      logEvent('tengu_oauth_token_refresh_starting', {})
1531      const refreshedTokens = await refreshOAuthToken(lockedTokens.refreshToken, {
1532        // For Claude.ai subscribers, omit scopes so the default
1533        // CLAUDE_AI_OAUTH_SCOPES applies — this allows scope expansion
1534        // (e.g. adding user:file_upload) on refresh without re-login.
1535        scopes: shouldUseClaudeAIAuth(lockedTokens.scopes)
1536          ? undefined
1537          : lockedTokens.scopes,
1538      })
1539      saveOAuthTokensIfNeeded(refreshedTokens)
1540  
1541      // Clear the cache after refreshing token
1542      getClaudeAIOAuthTokens.cache?.clear?.()
1543      clearKeychainCache()
1544      return true
1545    } catch (error) {
1546      logError(error)
1547  
1548      getClaudeAIOAuthTokens.cache?.clear?.()
1549      clearKeychainCache()
1550      const currentTokens = await getClaudeAIOAuthTokensAsync()
1551      if (currentTokens && !isOAuthTokenExpired(currentTokens.expiresAt)) {
1552        logEvent('tengu_oauth_token_refresh_race_recovered', {})
1553        return true
1554      }
1555  
1556      return false
1557    } finally {
1558      logEvent('tengu_oauth_token_refresh_lock_releasing', {})
1559      await release()
1560      logEvent('tengu_oauth_token_refresh_lock_released', {})
1561    }
1562  }
1563  
1564  export function isClaudeAISubscriber(): boolean {
1565    if (!isAnthropicAuthEnabled()) {
1566      return false
1567    }
1568  
1569    return shouldUseClaudeAIAuth(getClaudeAIOAuthTokens()?.scopes)
1570  }
1571  
1572  /**
1573   * Check if the current OAuth token has the user:profile scope.
1574   *
1575   * Real /login tokens always include this scope. Env-var and file-descriptor
1576   * tokens (service keys) hardcode scopes to ['user:inference'] only. Use this
1577   * to gate calls to profile-scoped endpoints so service key sessions don't
1578   * generate 403 storms against /api/oauth/profile, bootstrap, etc.
1579   */
1580  export function hasProfileScope(): boolean {
1581    return (
1582      getClaudeAIOAuthTokens()?.scopes?.includes(CLAUDE_AI_PROFILE_SCOPE) ?? false
1583    )
1584  }
1585  
1586  export function is1PApiCustomer(): boolean {
1587    // 1P API customers are users who are NOT:
1588    // 1. Claude.ai subscribers (Max, Pro, Enterprise, Team)
1589    // 2. Vertex AI users
1590    // 3. AWS Bedrock users
1591    // 4. Foundry users
1592  
1593    // Exclude Vertex, Bedrock, and Foundry customers
1594    if (
1595      isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
1596      isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
1597      isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
1598    ) {
1599      return false
1600    }
1601  
1602    // Exclude Claude.ai subscribers
1603    if (isClaudeAISubscriber()) {
1604      return false
1605    }
1606  
1607    // Everyone else is an API customer (OAuth API customers, direct API key users, etc.)
1608    return true
1609  }
1610  
1611  /**
1612   * Gets OAuth account information when Anthropic auth is enabled.
1613   * Returns undefined when using external API keys or third-party services.
1614   */
1615  export function getOauthAccountInfo(): AccountInfo | undefined {
1616    return isAnthropicAuthEnabled() ? getGlobalConfig().oauthAccount : undefined
1617  }
1618  
1619  /**
1620   * Checks if overage/extra usage provisioning is allowed for this organization.
1621   * This mirrors the logic in apps/claude-ai `useIsOverageProvisioningAllowed` hook as closely as possible.
1622   */
1623  export function isOverageProvisioningAllowed(): boolean {
1624    const accountInfo = getOauthAccountInfo()
1625    const billingType = accountInfo?.billingType
1626  
1627    // Must be a Claude subscriber with a supported subscription type
1628    if (!isClaudeAISubscriber() || !billingType) {
1629      return false
1630    }
1631  
1632    // only allow Stripe and mobile billing types to purchase extra usage
1633    if (
1634      billingType !== 'stripe_subscription' &&
1635      billingType !== 'stripe_subscription_contracted' &&
1636      billingType !== 'apple_subscription' &&
1637      billingType !== 'google_play_subscription'
1638    ) {
1639      return false
1640    }
1641  
1642    return true
1643  }
1644  
1645  // Returns whether the user has Opus access at all, regardless of whether they
1646  // are a subscriber or PayG.
1647  export function hasOpusAccess(): boolean {
1648    const subscriptionType = getSubscriptionType()
1649  
1650    return (
1651      subscriptionType === 'max' ||
1652      subscriptionType === 'enterprise' ||
1653      subscriptionType === 'team' ||
1654      subscriptionType === 'pro' ||
1655      // subscriptionType === null covers both API users and the case where
1656      // subscribers do not have subscription type populated. For those
1657      // subscribers, when in doubt, we should not limit their access to Opus.
1658      subscriptionType === null
1659    )
1660  }
1661  
1662  export function getSubscriptionType(): SubscriptionType | null {
1663    // Check for mock subscription type first (ANT-only testing)
1664    if (shouldUseMockSubscription()) {
1665      return getMockSubscriptionType()
1666    }
1667  
1668    if (!isAnthropicAuthEnabled()) {
1669      return null
1670    }
1671    const oauthTokens = getClaudeAIOAuthTokens()
1672    if (!oauthTokens) {
1673      return null
1674    }
1675  
1676    return oauthTokens.subscriptionType ?? null
1677  }
1678  
1679  export function isMaxSubscriber(): boolean {
1680    return getSubscriptionType() === 'max'
1681  }
1682  
1683  export function isTeamSubscriber(): boolean {
1684    return getSubscriptionType() === 'team'
1685  }
1686  
1687  export function isTeamPremiumSubscriber(): boolean {
1688    return (
1689      getSubscriptionType() === 'team' &&
1690      getRateLimitTier() === 'default_claude_max_5x'
1691    )
1692  }
1693  
1694  export function isEnterpriseSubscriber(): boolean {
1695    return getSubscriptionType() === 'enterprise'
1696  }
1697  
1698  export function isProSubscriber(): boolean {
1699    return getSubscriptionType() === 'pro'
1700  }
1701  
1702  export function getRateLimitTier(): string | null {
1703    if (!isAnthropicAuthEnabled()) {
1704      return null
1705    }
1706    const oauthTokens = getClaudeAIOAuthTokens()
1707    if (!oauthTokens) {
1708      return null
1709    }
1710  
1711    return oauthTokens.rateLimitTier ?? null
1712  }
1713  
1714  export function getSubscriptionName(): string {
1715    const subscriptionType = getSubscriptionType()
1716  
1717    switch (subscriptionType) {
1718      case 'enterprise':
1719        return 'Claude Enterprise'
1720      case 'team':
1721        return 'Claude Team'
1722      case 'max':
1723        return 'Claude Max'
1724      case 'pro':
1725        return 'Claude Pro'
1726      default:
1727        return 'Claude API'
1728    }
1729  }
1730  
1731  /** Check if using third-party services (Bedrock or Vertex or Foundry) */
1732  export function isUsing3PServices(): boolean {
1733    return !!(
1734      isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
1735      isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
1736      isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)
1737    )
1738  }
1739  
1740  /**
1741   * Get the configured otelHeadersHelper from settings
1742   */
1743  function getConfiguredOtelHeadersHelper(): string | undefined {
1744    const mergedSettings = getSettings_DEPRECATED() || {}
1745    return mergedSettings.otelHeadersHelper
1746  }
1747  
1748  /**
1749   * Check if the configured otelHeadersHelper comes from project settings (projectSettings or localSettings)
1750   */
1751  export function isOtelHeadersHelperFromProjectOrLocalSettings(): boolean {
1752    const otelHeadersHelper = getConfiguredOtelHeadersHelper()
1753    if (!otelHeadersHelper) {
1754      return false
1755    }
1756  
1757    const projectSettings = getSettingsForSource('projectSettings')
1758    const localSettings = getSettingsForSource('localSettings')
1759    return (
1760      projectSettings?.otelHeadersHelper === otelHeadersHelper ||
1761      localSettings?.otelHeadersHelper === otelHeadersHelper
1762    )
1763  }
1764  
1765  // Cache for debouncing otelHeadersHelper calls
1766  let cachedOtelHeaders: Record<string, string> | null = null
1767  let cachedOtelHeadersTimestamp = 0
1768  const DEFAULT_OTEL_HEADERS_DEBOUNCE_MS = 29 * 60 * 1000 // 29 minutes
1769  
1770  export function getOtelHeadersFromHelper(): Record<string, string> {
1771    const otelHeadersHelper = getConfiguredOtelHeadersHelper()
1772  
1773    if (!otelHeadersHelper) {
1774      return {}
1775    }
1776  
1777    // Return cached headers if still valid (debounce)
1778    const debounceMs = parseInt(
1779      process.env.CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS ||
1780        DEFAULT_OTEL_HEADERS_DEBOUNCE_MS.toString(),
1781    )
1782    if (
1783      cachedOtelHeaders &&
1784      Date.now() - cachedOtelHeadersTimestamp < debounceMs
1785    ) {
1786      return cachedOtelHeaders
1787    }
1788  
1789    if (isOtelHeadersHelperFromProjectOrLocalSettings()) {
1790      // Check if trust has been established for this project
1791      const hasTrust = checkHasTrustDialogAccepted()
1792      if (!hasTrust) {
1793        return {}
1794      }
1795    }
1796  
1797    try {
1798      const result = execSyncWithDefaults_DEPRECATED(otelHeadersHelper, {
1799        timeout: 30000, // 30 seconds - allows for auth service latency
1800      })
1801        ?.toString()
1802        .trim()
1803      if (!result) {
1804        throw new Error('otelHeadersHelper did not return a valid value')
1805      }
1806  
1807      const headers = jsonParse(result)
1808      if (
1809        typeof headers !== 'object' ||
1810        headers === null ||
1811        Array.isArray(headers)
1812      ) {
1813        throw new Error(
1814          'otelHeadersHelper must return a JSON object with string key-value pairs',
1815        )
1816      }
1817  
1818      // Validate all values are strings
1819      for (const [key, value] of Object.entries(headers)) {
1820        if (typeof value !== 'string') {
1821          throw new Error(
1822            `otelHeadersHelper returned non-string value for key "${key}": ${typeof value}`,
1823          )
1824        }
1825      }
1826  
1827      // Cache the result
1828      cachedOtelHeaders = headers as Record<string, string>
1829      cachedOtelHeadersTimestamp = Date.now()
1830  
1831      return cachedOtelHeaders
1832    } catch (error) {
1833      logError(
1834        new Error(
1835          `Error getting OpenTelemetry headers from otelHeadersHelper (in settings): ${errorMessage(error)}`,
1836        ),
1837      )
1838      throw error
1839    }
1840  }
1841  
1842  function isConsumerPlan(plan: SubscriptionType): plan is 'max' | 'pro' {
1843    return plan === 'max' || plan === 'pro'
1844  }
1845  
1846  export function isConsumerSubscriber(): boolean {
1847    const subscriptionType = getSubscriptionType()
1848    return (
1849      isClaudeAISubscriber() &&
1850      subscriptionType !== null &&
1851      isConsumerPlan(subscriptionType)
1852    )
1853  }
1854  
1855  export type UserAccountInfo = {
1856    subscription?: string
1857    tokenSource?: string
1858    apiKeySource?: ApiKeySource
1859    organization?: string
1860    email?: string
1861  }
1862  
1863  export function getAccountInformation() {
1864    const apiProvider = getAPIProvider()
1865    // Only provide account info for first-party Anthropic API
1866    if (apiProvider !== 'firstParty') {
1867      return undefined
1868    }
1869    const { source: authTokenSource } = getAuthTokenSource()
1870    const accountInfo: UserAccountInfo = {}
1871    if (
1872      authTokenSource === 'CLAUDE_CODE_OAUTH_TOKEN' ||
1873      authTokenSource === 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR'
1874    ) {
1875      accountInfo.tokenSource = authTokenSource
1876    } else if (isClaudeAISubscriber()) {
1877      accountInfo.subscription = getSubscriptionName()
1878    } else {
1879      accountInfo.tokenSource = authTokenSource
1880    }
1881    const { key: apiKey, source: apiKeySource } = getAnthropicApiKeyWithSource()
1882    if (apiKey) {
1883      accountInfo.apiKeySource = apiKeySource
1884    }
1885  
1886    // We don't know the organization if we're relying on an external API key or auth token
1887    if (
1888      authTokenSource === 'claude.ai' ||
1889      apiKeySource === '/login managed key'
1890    ) {
1891      // Get organization name from OAuth account info
1892      const orgName = getOauthAccountInfo()?.organizationName
1893      if (orgName) {
1894        accountInfo.organization = orgName
1895      }
1896    }
1897    const email = getOauthAccountInfo()?.emailAddress
1898    if (
1899      (authTokenSource === 'claude.ai' ||
1900        apiKeySource === '/login managed key') &&
1901      email
1902    ) {
1903      accountInfo.email = email
1904    }
1905    return accountInfo
1906  }
1907  
1908  /**
1909   * Result of org validation — either success or a descriptive error.
1910   */
1911  export type OrgValidationResult =
1912    | { valid: true }
1913    | { valid: false; message: string }
1914  
1915  /**
1916   * Validate that the active OAuth token belongs to the organization required
1917   * by `forceLoginOrgUUID` in managed settings. Returns a result object
1918   * rather than throwing so callers can choose how to surface the error.
1919   *
1920   * Fails closed: if `forceLoginOrgUUID` is set and we cannot determine the
1921   * token's org (network error, missing profile data), validation fails.
1922   */
1923  export async function validateForceLoginOrg(): Promise<OrgValidationResult> {
1924    // `claude ssh` remote: real auth lives on the local machine and is injected
1925    // by the proxy. The placeholder token can't be validated against the profile
1926    // endpoint. The local side already ran this check before establishing the session.
1927    if (process.env.ANTHROPIC_UNIX_SOCKET) {
1928      return { valid: true }
1929    }
1930  
1931    if (!isAnthropicAuthEnabled()) {
1932      return { valid: true }
1933    }
1934  
1935    const requiredOrgUuid =
1936      getSettingsForSource('policySettings')?.forceLoginOrgUUID
1937    if (!requiredOrgUuid) {
1938      return { valid: true }
1939    }
1940  
1941    // Ensure the access token is fresh before hitting the profile endpoint.
1942    // No-op for env-var tokens (refreshToken is null).
1943    await checkAndRefreshOAuthTokenIfNeeded()
1944  
1945    const tokens = getClaudeAIOAuthTokens()
1946    if (!tokens) {
1947      return { valid: true }
1948    }
1949  
1950    // Always fetch the authoritative org UUID from the profile endpoint.
1951    // Even keychain-sourced tokens verify server-side: the cached org UUID
1952    // in ~/.claude.json is user-writable and cannot be trusted.
1953    const { source } = getAuthTokenSource()
1954    const isEnvVarToken =
1955      source === 'CLAUDE_CODE_OAUTH_TOKEN' ||
1956      source === 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR'
1957  
1958    const profile = await getOauthProfileFromOauthToken(tokens.accessToken)
1959    if (!profile) {
1960      // Fail closed — we can't verify the org
1961      return {
1962        valid: false,
1963        message:
1964          `Unable to verify organization for the current authentication token.\n` +
1965          `This machine requires organization ${requiredOrgUuid} but the profile could not be fetched.\n` +
1966          `This may be a network error, or the token may lack the user:profile scope required for\n` +
1967          `verification (tokens from 'claude setup-token' do not include this scope).\n` +
1968          `Try again, or obtain a full-scope token via 'claude auth login'.`,
1969      }
1970    }
1971  
1972    const tokenOrgUuid = profile.organization.uuid
1973    if (tokenOrgUuid === requiredOrgUuid) {
1974      return { valid: true }
1975    }
1976  
1977    if (isEnvVarToken) {
1978      const envVarName =
1979        source === 'CLAUDE_CODE_OAUTH_TOKEN'
1980          ? 'CLAUDE_CODE_OAUTH_TOKEN'
1981          : 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR'
1982      return {
1983        valid: false,
1984        message:
1985          `The ${envVarName} environment variable provides a token for a\n` +
1986          `different organization than required by this machine's managed settings.\n\n` +
1987          `Required organization: ${requiredOrgUuid}\n` +
1988          `Token organization:   ${tokenOrgUuid}\n\n` +
1989          `Remove the environment variable or obtain a token for the correct organization.`,
1990      }
1991    }
1992  
1993    return {
1994      valid: false,
1995      message:
1996        `Your authentication token belongs to organization ${tokenOrgUuid},\n` +
1997        `but this machine requires organization ${requiredOrgUuid}.\n\n` +
1998        `Please log in with the correct organization: claude auth login`,
1999    }
2000  }
2001  
2002  class GcpCredentialsTimeoutError extends Error {}