source dump of claude code
at main 2002 lines 65 kB view raw
1import chalk from 'chalk' 2import { exec } from 'child_process' 3import { execa } from 'execa' 4import { mkdir, stat } from 'fs/promises' 5import memoize from 'lodash-es/memoize.js' 6import { join } from 'path' 7import { CLAUDE_AI_PROFILE_SCOPE } from 'src/constants/oauth.js' 8import { 9 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 10 logEvent, 11} from 'src/services/analytics/index.js' 12import { getModelStrings } from 'src/utils/model/modelStrings.js' 13import { getAPIProvider } from 'src/utils/model/providers.js' 14import { 15 getIsNonInteractiveSession, 16 preferThirdPartyAuthentication, 17} from '../bootstrap/state.js' 18import { 19 getMockSubscriptionType, 20 shouldUseMockSubscription, 21} from '../services/mockRateLimits.js' 22import { 23 isOAuthTokenExpired, 24 refreshOAuthToken, 25 shouldUseClaudeAIAuth, 26} from '../services/oauth/client.js' 27import { getOauthProfileFromOauthToken } from '../services/oauth/getOauthProfile.js' 28import type { OAuthTokens, SubscriptionType } from '../services/oauth/types.js' 29import { 30 getApiKeyFromFileDescriptor, 31 getOAuthTokenFromFileDescriptor, 32} from './authFileDescriptor.js' 33import { 34 maybeRemoveApiKeyFromMacOSKeychainThrows, 35 normalizeApiKeyForConfig, 36} from './authPortable.js' 37import { 38 checkStsCallerIdentity, 39 clearAwsIniCache, 40 isValidAwsStsOutput, 41} from './aws.js' 42import { AwsAuthStatusManager } from './awsAuthStatusManager.js' 43import { clearBetasCaches } from './betas.js' 44import { 45 type AccountInfo, 46 checkHasTrustDialogAccepted, 47 getGlobalConfig, 48 saveGlobalConfig, 49} from './config.js' 50import { logAntError, logForDebugging } from './debug.js' 51import { 52 getClaudeConfigHomeDir, 53 isBareMode, 54 isEnvTruthy, 55 isRunningOnHomespace, 56} from './envUtils.js' 57import { errorMessage } from './errors.js' 58import { execSyncWithDefaults_DEPRECATED } from './execFileNoThrow.js' 59import * as lockfile from './lockfile.js' 60import { logError } from './log.js' 61import { memoizeWithTTLAsync } from './memoize.js' 62import { getSecureStorage } from './secureStorage/index.js' 63import { 64 clearLegacyApiKeyPrefetch, 65 getLegacyApiKeyPrefetchResult, 66} from './secureStorage/keychainPrefetch.js' 67import { 68 clearKeychainCache, 69 getMacOsKeychainStorageServiceName, 70 getUsername, 71} from './secureStorage/macOsKeychainHelpers.js' 72import { 73 getSettings_DEPRECATED, 74 getSettingsForSource, 75} from './settings/settings.js' 76import { sleep } from './sleep.js' 77import { jsonParse } from './slowOperations.js' 78import { clearToolSchemaCache } from './toolSchemaCache.js' 79 80/** Default TTL for API key helper cache in milliseconds (5 minutes) */ 81const 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 */ 91function 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 100export 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 153export 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 208export type ApiKeySource = 209 | 'ANTHROPIC_API_KEY' 210 | 'apiKeyHelper' 211 | '/login managed key' 212 | 'none' 213 214export function getAnthropicApiKey(): null | string { 215 const { key } = getAnthropicApiKeyWithSource() 216 return key 217} 218 219export function hasAnthropicApiKeyAuth(): boolean { 220 const { key, source } = getAnthropicApiKeyWithSource({ 221 skipRetrievingKeyFromApiKeyHelper: true, 222 }) 223 return key !== null && source !== 'none' 224} 225 226export 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 */ 355export 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 */ 366function 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 */ 383function 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 */ 391export 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 */ 408function 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 */ 416export 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 */ 435export 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. 456let _apiKeyHelperCache: { value: string; timestamp: number } | null = null 457let _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 462let _apiKeyHelperEpoch = 0 463 464export function getApiKeyHelperElapsedMs(): number { 465 const startedAt = _apiKeyHelperInflight?.startedAt 466 return startedAt ? Date.now() - startedAt : 0 467} 468 469export 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 501async 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 538async 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 */ 581export function getApiKeyFromApiKeyHelperCached(): string | null { 582 return _apiKeyHelperCache?.value ?? null 583} 584 585export function clearApiKeyHelperCache(): void { 586 _apiKeyHelperEpoch++ 587 _apiKeyHelperCache = null 588 _apiKeyHelperInflight = null 589} 590 591export 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. */ 606const 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 */ 612async 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. 648const AWS_AUTH_REFRESH_TIMEOUT_MS = 3 * 60 * 1000 649 650export 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 */ 705async 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 */ 787export 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 809export function clearAwsCredentialsCache(): void { 810 refreshAndGetAwsCredentials.cache.clear() 811} 812 813/** 814 * Get the configured gcpAuthRefresh from settings 815 */ 816function 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 */ 824export 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. */ 841const 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 */ 847export 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 */ 869const 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 */ 875async 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. 915const GCP_AUTH_REFRESH_TIMEOUT_MS = 3 * 60 * 1000 916 917export 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 */ 974export 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 983export 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 */ 994export 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 */ 1023export 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} */ 1051export 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 1089function isValidApiKey(apiKey: string): boolean { 1090 // Only allow alphanumeric characters, dashes, and underscores 1091 return /^[a-zA-Z0-9-_]+$/.test(apiKey) 1092} 1093 1094export 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 1162export 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 1170export 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 1185async 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 1194export 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 1255export 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 */ 1308export function clearOAuthTokenCache(): void { 1309 getClaudeAIOAuthTokens.cache?.clear?.() 1310 clearKeychainCache() 1311} 1312 1313let 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). 1320async 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. 1343const 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 */ 1360export 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 1373async 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 */ 1399export 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 1425let pendingRefreshCheck: Promise<boolean> | null = null 1426 1427export 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 1447async 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 1564export 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 */ 1580export function hasProfileScope(): boolean { 1581 return ( 1582 getClaudeAIOAuthTokens()?.scopes?.includes(CLAUDE_AI_PROFILE_SCOPE) ?? false 1583 ) 1584} 1585 1586export 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 */ 1615export 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 */ 1623export 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. 1647export 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 1662export 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 1679export function isMaxSubscriber(): boolean { 1680 return getSubscriptionType() === 'max' 1681} 1682 1683export function isTeamSubscriber(): boolean { 1684 return getSubscriptionType() === 'team' 1685} 1686 1687export function isTeamPremiumSubscriber(): boolean { 1688 return ( 1689 getSubscriptionType() === 'team' && 1690 getRateLimitTier() === 'default_claude_max_5x' 1691 ) 1692} 1693 1694export function isEnterpriseSubscriber(): boolean { 1695 return getSubscriptionType() === 'enterprise' 1696} 1697 1698export function isProSubscriber(): boolean { 1699 return getSubscriptionType() === 'pro' 1700} 1701 1702export 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 1714export 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) */ 1732export 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 */ 1743function 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 */ 1751export 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 1766let cachedOtelHeaders: Record<string, string> | null = null 1767let cachedOtelHeadersTimestamp = 0 1768const DEFAULT_OTEL_HEADERS_DEBOUNCE_MS = 29 * 60 * 1000 // 29 minutes 1769 1770export 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 1842function isConsumerPlan(plan: SubscriptionType): plan is 'max' | 'pro' { 1843 return plan === 'max' || plan === 'pro' 1844} 1845 1846export function isConsumerSubscriber(): boolean { 1847 const subscriptionType = getSubscriptionType() 1848 return ( 1849 isClaudeAISubscriber() && 1850 subscriptionType !== null && 1851 isConsumerPlan(subscriptionType) 1852 ) 1853} 1854 1855export type UserAccountInfo = { 1856 subscription?: string 1857 tokenSource?: string 1858 apiKeySource?: ApiKeySource 1859 organization?: string 1860 email?: string 1861} 1862 1863export 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 */ 1911export 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 */ 1923export 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 2002class GcpCredentialsTimeoutError extends Error {}