axios+telemetry cleanup

This commit is contained in:
2026-04-02 15:19:11 +03:00
parent a3cbca1e11
commit 7e1eac8002
100 changed files with 3048 additions and 4491 deletions

View File

@@ -11,6 +11,7 @@ import {
} from 'src/services/analytics/index.js'
import { getModelStrings } from 'src/utils/model/modelStrings.js'
import { getAPIProvider } from 'src/utils/model/providers.js'
import { FEEDBACK_CHANNEL } from 'src/constants/product.js'
import {
getIsNonInteractiveSession,
preferThirdPartyAuthentication,
@@ -547,7 +548,7 @@ async function _executeApiKeyHelper(
const hasTrust = checkHasTrustDialogAccepted()
if (!hasTrust && !isNonInteractiveSession) {
const error = new Error(
`Security: apiKeyHelper executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
`Security: apiKeyHelper executed before workspace trust is confirmed. If you see this message, post in ${FEEDBACK_CHANNEL}.`,
)
logAntError('apiKeyHelper invoked before trust check', error)
logEvent('tengu_apiKeyHelper_missing_trust11', {})
@@ -622,7 +623,7 @@ async function runAwsAuthRefresh(): Promise<boolean> {
const hasTrust = checkHasTrustDialogAccepted()
if (!hasTrust && !getIsNonInteractiveSession()) {
const error = new Error(
`Security: awsAuthRefresh executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
`Security: awsAuthRefresh executed before workspace trust is confirmed. If you see this message, post in ${FEEDBACK_CHANNEL}.`,
)
logAntError('awsAuthRefresh invoked before trust check', error)
logEvent('tengu_awsAuthRefresh_missing_trust', {})
@@ -719,7 +720,7 @@ async function getAwsCredsFromCredentialExport(): Promise<{
const hasTrust = checkHasTrustDialogAccepted()
if (!hasTrust && !getIsNonInteractiveSession()) {
const error = new Error(
`Security: awsCredentialExport executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
`Security: awsCredentialExport executed before workspace trust is confirmed. If you see this message, post in ${FEEDBACK_CHANNEL}.`,
)
logAntError('awsCredentialExport invoked before trust check', error)
logEvent('tengu_awsCredentialExport_missing_trust', {})
@@ -886,7 +887,7 @@ async function runGcpAuthRefresh(): Promise<boolean> {
const hasTrust = checkHasTrustDialogAccepted()
if (!hasTrust && !getIsNonInteractiveSession()) {
const error = new Error(
`Security: gcpAuthRefresh executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
`Security: gcpAuthRefresh executed before workspace trust is confirmed. If you see this message, post in ${FEEDBACK_CHANNEL}.`,
)
logAntError('gcpAuthRefresh invoked before trust check', error)
logEvent('tengu_gcpAuthRefresh_missing_trust', {})

View File

@@ -1,4 +1,3 @@
import axios from 'axios'
import { constants as fsConstants } from 'fs'
import { access, writeFile } from 'fs/promises'
import { homedir } from 'os'
@@ -16,6 +15,7 @@ import { ClaudeError, getErrnoCode, isENOENT } from './errors.js'
import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
import { getFsImplementation } from './fsOperations.js'
import { gracefulShutdownSync } from './gracefulShutdown.js'
import { isHttpError, nativeRequest } from './http.js'
import { logError } from './log.js'
import { gte, lt } from './semver.js'
import { getInitialSettings } from './settings/settings.js'
@@ -79,11 +79,11 @@ export async function assertMinVersion(): Promise<void> {
if (
versionConfig.minVersion &&
lt(MACRO.VERSION, versionConfig.minVersion)
lt('0.1.0-alpha', versionConfig.minVersion)
) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`
It looks like your version of Claude Code (${MACRO.VERSION}) needs an update.
It looks like your version of Claude Code (0.1.0-alpha) needs an update.
A newer version (${versionConfig.minVersion} or higher) is required to continue.
To update, please run:
@@ -325,7 +325,7 @@ export async function getLatestVersion(
// which could be maliciously crafted to redirect to an attacker's registry
const result = await execFileNoThrowWithCwd(
'npm',
['view', `${MACRO.PACKAGE_URL}@${npmTag}`, 'version', '--prefer-online'],
['view', `@anthropic-ai/claude-code@${npmTag}`, 'version', '--prefer-online'],
{ abortSignal: AbortSignal.timeout(5000), cwd: homedir() },
)
if (result.code !== 0) {
@@ -356,7 +356,7 @@ export async function getNpmDistTags(): Promise<NpmDistTags> {
// Run from home directory to avoid reading project-level .npmrc
const result = await execFileNoThrowWithCwd(
'npm',
['view', MACRO.PACKAGE_URL, 'dist-tags', '--json', '--prefer-online'],
['view', '@anthropic-ai/claude-code', 'dist-tags', '--json', '--prefer-online'],
{ abortSignal: AbortSignal.timeout(5000), cwd: homedir() },
)
@@ -385,7 +385,8 @@ export async function getLatestVersionFromGcs(
channel: ReleaseChannel,
): Promise<string | null> {
try {
const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, {
const response = await nativeRequest<string>(`${GCS_BUCKET_URL}/${channel}`, {
method: 'GET',
timeout: 5000,
responseType: 'text',
})
@@ -425,14 +426,14 @@ export async function getVersionHistory(limit: number): Promise<string[]> {
// Use native package URL when available to ensure we only show versions
// that have native binaries (not all JS package versions have native builds)
const packageUrl = MACRO.NATIVE_PACKAGE_URL ?? MACRO.PACKAGE_URL
const packageUrl = '@anthropic-ai/claude-code'
// Run from home directory to avoid reading project-level .npmrc
const result = await execFileNoThrowWithCwd(
'npm',
['view', packageUrl, 'versions', '--json', '--prefer-online'],
// Longer timeout for version list
{ abortSignal: AbortSignal.timeout(30000), cwd: homedir() },
{ abortSignal: (AbortSignal as any).timeout(30000), cwd: homedir() },
)
if (result.code !== 0) {
@@ -464,7 +465,7 @@ export async function installGlobalPackage(
logEvent('tengu_auto_updater_lock_contention', {
pid: process.pid,
currentVersion:
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
'0.1.0-alpha' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return 'in_progress'
}
@@ -476,7 +477,7 @@ export async function installGlobalPackage(
logError(new Error('Windows NPM detected in WSL environment'))
logEvent('tengu_auto_updater_windows_npm_in_wsl', {
currentVersion:
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
'0.1.0-alpha' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`
@@ -500,8 +501,8 @@ To fix this issue:
// Use specific version if provided, otherwise use latest
const packageSpec = specificVersion
? `${MACRO.PACKAGE_URL}@${specificVersion}`
: MACRO.PACKAGE_URL
? `@anthropic-ai/claude-code@${specificVersion}`
: '@anthropic-ai/claude-code'
// Run from home directory to avoid reading project-level .npmrc/.bunfig.toml
// which could be maliciously crafted to redirect to an attacker's registry

View File

@@ -1,4 +1,3 @@
import axios from 'axios'
import { getOauthConfig } from 'src/constants/oauth.js'
import { getOrganizationUUID } from 'src/services/oauth/client.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js'
@@ -12,6 +11,7 @@ import { logForDebugging } from '../../debug.js'
import { detectCurrentRepository } from '../../detectRepository.js'
import { errorMessage } from '../../errors.js'
import { findGitRoot, getIsClean } from '../../git.js'
import { isHttpError, nativeRequest } from '../../http.js'
import { getOAuthHeaders } from '../../teleport/api.js'
import { fetchEnvironments } from '../../teleport/environments.js'
@@ -105,7 +105,7 @@ export async function checkGithubAppInstalled(
logForDebugging(`Checking GitHub app installation for ${owner}/${repo}`)
const response = await axios.get<{
const response = await nativeRequest<{
repo: {
name: string
owner: { login: string }
@@ -116,6 +116,7 @@ export async function checkGithubAppInstalled(
relay_enabled: boolean
} | null
}>(url, {
method: 'GET',
headers,
timeout: 15000,
signal,
@@ -142,8 +143,8 @@ export async function checkGithubAppInstalled(
return false
} catch (error) {
// 4XX errors typically mean app is not installed or repo not accessible
if (axios.isAxiosError(error)) {
const status = error.response?.status
if (isHttpError(error)) {
const status = error.status
if (status && status >= 400 && status < 500) {
logForDebugging(
`checkGithubAppInstalled: Got ${status} error, app likely not installed on ${owner}/${repo}`,
@@ -183,7 +184,8 @@ export async function checkGithubTokenSynced(): Promise<boolean> {
logForDebugging('Checking if GitHub token is synced via web-setup')
const response = await axios.get(url, {
const response = await nativeRequest<any>(url, {
method: 'GET',
headers,
timeout: 15000,
})
@@ -195,8 +197,8 @@ export async function checkGithubTokenSynced(): Promise<boolean> {
)
return synced
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status
if (isHttpError(error)) {
const status = error.status
if (status && status >= 400 && status < 500) {
logForDebugging(
`checkGithubTokenSynced: Got ${status}, token not synced`,

View File

@@ -6,6 +6,7 @@ import { isRunningWithBun } from './bundledMode.js'
import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
import { findExecutable } from './findExecutable.js'
import { getFsImplementation } from './fsOperations.js'
import { nativeRequest } from './http.js'
import { which } from './which.js'
type Platform = 'win32' | 'darwin' | 'linux'
@@ -27,9 +28,10 @@ export const getGlobalClaudeFile = memoize((): string => {
const hasInternetAccess = memoize(async (): Promise<boolean> => {
try {
const { default: axiosClient } = await import('axios')
await axiosClient.head('http://1.1.1.1', {
signal: AbortSignal.timeout(1000),
await nativeRequest('http://1.1.1.1', {
method: 'HEAD',
timeout: 1000,
responseType: 'none',
})
return true
} catch {

View File

@@ -10,7 +10,7 @@
* log.ts has NO heavy dependencies - events are queued until this sink is attached.
*/
import axios from 'axios'
import { isHttpError } from './http.js'
import { dirname, join } from 'path'
import { getSessionId } from '../bootstrap/state.js'
import { createBufferedWriter } from './bufferedWriter.js'
@@ -152,18 +152,20 @@ function extractServerMessage(data: unknown): string | undefined {
function logErrorImpl(error: Error): void {
const errorStr = error.stack || error.message
// Enrich axios errors with request URL, status, and server message for debugging
// Enrich HTTP errors with request URL, status, and server message for debugging
let context = ''
if (axios.isAxiosError(error) && error.config?.url) {
const parts = [`url=${error.config.url}`]
if (error.response?.status !== undefined) {
parts.push(`status=${error.response.status}`)
if (isHttpError(error) && error.message) {
const parts: string[] = []
if (error.status !== undefined) {
parts.push(`status=${error.status}`)
}
const serverMessage = extractServerMessage(error.response?.data)
const serverMessage = extractServerMessage(error.data)
if (serverMessage) {
parts.push(`body=${serverMessage}`)
}
context = `[${parts.join(',')}] `
if (parts.length > 0) {
context = `[${parts.join(',')}] `
}
}
logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' })

View File

@@ -194,43 +194,33 @@ export function isFsInaccessible(e: unknown): e is NodeJS.ErrnoException {
)
}
export type AxiosErrorKind =
export type HttpErrorKind =
| 'auth' // 401/403 — caller typically sets skipRetry
| 'timeout' // ECONNABORTED
| 'timeout' // 408 or ECONNABORTED
| 'network' // ECONNREFUSED/ENOTFOUND
| 'http' // other axios error (may have status)
| 'other' // not an axios error
| 'http' // other http error (may have status)
| 'other' // not an http error
/**
* Classify a caught error from an axios request into one of a few buckets.
* Replaces the ~20-line isAxiosError → 401/403 → ECONNABORTED → ECONNREFUSED
* chain duplicated across sync-style services (settingsSync, policyLimits,
* remoteManagedSettings, teamMemorySync).
*
* Checks the `.isAxiosError` marker property directly (same as
* axios.isAxiosError()) to keep this module dependency-free.
* Classify a caught error from a request into one of a few buckets.
*/
export function classifyAxiosError(e: unknown): {
kind: AxiosErrorKind
export function classifyHttpError(e: unknown): {
kind: HttpErrorKind
status?: number
message: string
} {
const message = errorMessage(e)
if (
!e ||
typeof e !== 'object' ||
!('isAxiosError' in e) ||
!e.isAxiosError
) {
if (!e || typeof e !== 'object' || !('name' in e) || e.name !== 'HttpError') {
return { kind: 'other', message }
}
const err = e as {
response?: { status?: number }
status?: number
code?: string
}
const status = err.response?.status
const status = err.status
if (status === 401 || status === 403) return { kind: 'auth', status, message }
if (err.code === 'ECONNABORTED') return { kind: 'timeout', status, message }
if (status === 408 || err.code === 'ECONNABORTED')
return { kind: 'timeout', status, message }
if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
return { kind: 'network', status, message }
}

View File

@@ -1,4 +1,3 @@
import axios from 'axios'
import { getOauthConfig, OAUTH_BETA_HEADER } from 'src/constants/oauth.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
import {
@@ -20,6 +19,7 @@ import { isInBundledMode } from './bundledMode.js'
import { getGlobalConfig, saveGlobalConfig } from './config.js'
import { logForDebugging } from './debug.js'
import { isEnvTruthy } from './envUtils.js'
import { isHttpError, nativeRequest } from './http.js'
import {
getDefaultMainLoopModelSetting,
isOpus1mMergeEnabled,
@@ -376,7 +376,10 @@ async function fetchFastModeStatus(
}
: { 'x-api-key': auth.apiKey }
const response = await axios.get<FastModeResponse>(endpoint, { headers })
const response = await nativeRequest<FastModeResponse>(endpoint, {
method: 'GET',
headers,
})
return response.data
}
@@ -465,11 +468,11 @@ export async function prefetchFastModeStatus(): Promise<void> {
status = await fetchWithCurrentAuth()
} catch (err) {
const isAuthError =
axios.isAxiosError(err) &&
(err.response?.status === 401 ||
(err.response?.status === 403 &&
typeof err.response?.data === 'string' &&
err.response.data.includes('OAuth token has been revoked')))
isHttpError(err) &&
(err.status === 401 ||
(err.status === 403 &&
typeof err.data === 'string' &&
(err.data as string).includes('OAuth token has been revoked')))
if (isAuthError) {
const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken
if (failedAccessToken) {

View File

@@ -62,6 +62,8 @@ export function computeFingerprint(
return hash.slice(0, 3)
}
import { VERSION } from 'src/constants/product.js'
/**
* Computes fingerprint from the first user message.
*
@@ -72,5 +74,5 @@ export function computeFingerprintFromMessages(
messages: (UserMessage | AssistantMessage)[],
): string {
const firstMessageText = extractFirstMessageText(messages)
return computeFingerprint(firstMessageText, MACRO.VERSION)
return computeFingerprint(firstMessageText, VERSION)
}

View File

@@ -1,8 +1,8 @@
import axios from 'axios'
import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'
import { Agent } from 'undici'
import { createCombinedAbortSignal } from '../combinedAbortSignal.js'
import { logForDebugging } from '../debug.js'
import { errorMessage } from '../errors.js'
import { nativeRequest } from '../http.js'
import { getProxyUrl, shouldBypassProxy } from '../proxy.js'
// Import as namespace so spyOn works in tests (direct imports bypass spies)
import * as settingsModule from '../settings/settings.js'
@@ -122,7 +122,7 @@ function interpolateEnvVars(
*/
export async function execHttpHook(
hook: HttpHook,
_hookEvent: HookEvent,
_hookEvent: string,
jsonInput: string,
signal?: AbortSignal,
): Promise<{
@@ -186,34 +186,39 @@ export async function execHttpHook(
getProxyUrl() !== undefined &&
!shouldBypassProxy(hook.url)
let dispatcher: Agent | undefined
if (sandboxProxy) {
logForDebugging(
`Hooks: HTTP hook POST to ${hook.url} (via sandbox proxy :${sandboxProxy.port})`,
)
// For sandbox proxy, we'd ideally use a custom dispatcher, but for now
// assume global dispatcher or handled separately.
// Axios implementation used `proxy: sandboxProxy`.
} else if (envProxyActive) {
logForDebugging(
`Hooks: HTTP hook POST to ${hook.url} (via env-var proxy)`,
)
} else {
logForDebugging(`Hooks: HTTP hook POST to ${hook.url}`)
}
const response = await axios.post<string>(hook.url, jsonInput, {
headers,
signal: combinedSignal,
responseType: 'text',
validateStatus: () => true,
maxRedirects: 0,
// Explicit false prevents axios's own env-var proxy detection; when an
// env-var proxy is configured, the global axios interceptor installed
// by configureGlobalAgents() handles it via httpsAgent instead.
proxy: sandboxProxy ?? false,
// SSRF guard: validate resolved IPs, block private/link-local ranges
// (but allow loopback for local dev). Skipped when any proxy is in
// use — the proxy performs DNS for the target, and applying the
// guard would instead validate the proxy's own IP, breaking
// connections to corporate proxies on private networks.
lookup: sandboxProxy || envProxyActive ? undefined : ssrfGuardedLookup,
dispatcher = new Agent({
connect: {
lookup: ssrfGuardedLookup as any,
},
})
}
const response = await nativeRequest<string>(hook.url, {
method: 'POST',
headers,
body: jsonInput,
signal: combinedSignal,
responseType: 'text',
dispatcher,
})
cleanup()
@@ -224,7 +229,7 @@ export async function execHttpHook(
)
return {
ok: response.status >= 200 && response.status < 300,
ok: true,
statusCode: response.status,
body,
}

View File

@@ -1,7 +1,12 @@
import type { AddressFamily, LookupAddress as AxiosLookupAddress } from 'axios'
import { lookup as dnsLookup } from 'dns'
import { isIP } from 'net'
export type AddressFamily = 4 | 6
export type LookupAddress = {
address: string
family: AddressFamily
}
/**
* SSRF guard for HTTP hooks.
*
@@ -210,16 +215,14 @@ function extractMappedIPv4(addr: string): string | null {
* rebinding window between validation and connection.
*
* IP literals in the hostname are validated directly without DNS.
*
* Signature matches axios's `lookup` config option (not Node's dns.lookup).
*/
export function ssrfGuardedLookup(
hostname: string,
options: object,
options: any,
callback: (
err: Error | null,
address: AxiosLookupAddress | AxiosLookupAddress[],
family?: AddressFamily,
address: any,
family?: number,
) => void,
): void {
const wantsAll = 'all' in options && options.all === true

View File

@@ -2,7 +2,6 @@
* HTTP utility constants and helpers
*/
import axios from 'axios'
import { OAUTH_BETA_HEADER } from '../constants/oauth.js'
import {
getAnthropicApiKey,
@@ -31,7 +30,7 @@ export function getUserAgent(): string {
// so the read picks up the same setWorkload() value as getAttributionHeader.
const workload = getWorkload()
const workloadSuffix = workload ? `, workload/${workload}` : ''
return `claude-cli/${MACRO.VERSION} (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})`
return `claude-cli/0.1.0-alpha (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})`
}
export function getMCPUserAgent(): string {
@@ -46,7 +45,7 @@ export function getMCPUserAgent(): string {
parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`)
}
const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : ''
return `claude-code/${MACRO.VERSION}${suffix}`
return `claude-code/0.1.0-alpha${suffix}`
}
// User-Agent for WebFetch requests to arbitrary sites. `Claude-User` is
@@ -98,6 +97,118 @@ export function getAuthHeaders(): AuthHeaders {
}
}
export class HttpError extends Error {
constructor(
message: string,
public status?: number,
public data?: any,
public headers?: Record<string, string>,
public code?: string,
) {
super(message)
this.name = 'HttpError'
}
}
export function isHttpError(error: unknown): error is HttpError {
return error instanceof HttpError
}
export type NativeRequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
headers?: Record<string, string>
body?: any
timeout?: number
signal?: AbortSignal
responseType?: 'json' | 'arraybuffer' | 'text' | 'none'
dispatcher?: any // undici.Dispatcher
}
export async function nativeRequest<T>(
url: string,
options: NativeRequestOptions = {},
): Promise<{ data: T; status: number; headers: Record<string, string> }> {
const {
method = 'GET',
headers = {},
body,
timeout,
responseType = 'json',
dispatcher,
} = options
const controller = new AbortController()
const timeoutId = timeout
? setTimeout(() => controller.abort(), timeout)
: null
try {
const fetchOptions: RequestInit = {
method,
headers: {
...headers,
},
signal: options.signal || controller.signal,
...(dispatcher ? { dispatcher } : {}),
} as RequestInit
if (body) {
if (
body instanceof Buffer ||
body instanceof Uint8Array ||
body instanceof Blob ||
body instanceof FormData
) {
fetchOptions.body = body as any
} else {
if (!fetchOptions.headers) fetchOptions.headers = {}
;(fetchOptions.headers as any)['Content-Type'] = 'application/json'
fetchOptions.body = JSON.stringify(body)
}
}
const response = await fetch(url, fetchOptions)
let responseData: any
if (responseType === 'arraybuffer') {
responseData = await response.arrayBuffer()
} else if (responseType === 'text') {
responseData = await response.text()
} else if (responseType === 'none') {
responseData = null
} else {
responseData = await response.json().catch(() => null)
}
const responseHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
responseHeaders[key] = value
})
if (!response.ok) {
throw new HttpError(
`HTTP Error ${response.status}`,
response.status,
responseData,
responseHeaders,
)
}
return {
data: responseData as T,
status: response.status,
headers: responseHeaders,
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new HttpError('Request aborted/timeout', 408, null, {}, 'ECONNABORTED')
}
throw error
} finally {
if (timeoutId) clearTimeout(timeoutId)
}
}
/**
* Wrapper that handles OAuth 401 errors by force-refreshing the token and
* retrying once. Addresses clock drift scenarios where the local expiration
@@ -119,14 +230,14 @@ export async function withOAuth401Retry<T>(
try {
return await request()
} catch (err) {
if (!axios.isAxiosError(err)) throw err
const status = err.response?.status
if (!isHttpError(err)) throw err
const status = err.status
const isAuthError =
status === 401 ||
(opts?.also403Revoked &&
status === 403 &&
typeof err.response?.data === 'string' &&
err.response.data.includes('OAuth token has been revoked'))
typeof err.data === 'string' &&
err.data.includes('OAuth token has been revoked'))
if (!isAuthError) throw err
const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken
if (!failedAccessToken) throw err

View File

@@ -1,6 +1,6 @@
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import axios from 'axios'
import { execa } from 'execa'
import { chmod, writeFile } from 'fs/promises'
import capitalize from 'lodash-es/capitalize.js'
import memoize from 'lodash-es/memoize.js'
import { createConnection } from 'net'
@@ -23,6 +23,7 @@ import {
} from './execFileNoThrow.js'
import { getFsImplementation } from './fsOperations.js'
import { getAncestorPidsAsync } from './genericProcessUtils.js'
import { isHttpError, nativeRequest } from './http.js'
import { isJetBrainsPluginInstalledCached } from './jetbrains.js'
import { logError } from './log.js'
import { getPlatform } from './platform.js'
@@ -925,7 +926,7 @@ function getInstallationEnv(): NodeJS.ProcessEnv | undefined {
}
function getClaudeCodeVersion() {
return MACRO.VERSION
return '0.1.0-alpha'
}
async function getInstalledVSCodeExtensionVersion(
@@ -1424,10 +1425,12 @@ async function installFromArtifactory(command: string): Promise<string> {
'https://artifactory.infra.ant.dev/artifactory/armorcode-claude-code-internal/claude-vscode-releases/stable'
try {
const versionResponse = await axios.get(versionUrl, {
const versionResponse = await nativeRequest<string>(versionUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${authToken}`,
},
responseType: 'text',
})
const version = versionResponse.data.trim()
@@ -1443,20 +1446,16 @@ async function installFromArtifactory(command: string): Promise<string> {
)
try {
const vsixResponse = await axios.get(vsixUrl, {
const vsixResponse = await nativeRequest<ArrayBuffer>(vsixUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${authToken}`,
},
responseType: 'stream',
responseType: 'arraybuffer',
})
// Write the downloaded file to disk
const writeStream = getFsImplementation().createWriteStream(tempVsixPath)
await new Promise<void>((resolve, reject) => {
vsixResponse.data.pipe(writeStream)
writeStream.on('finish', resolve)
writeStream.on('error', reject)
})
await writeFile(tempVsixPath, Buffer.from(vsixResponse.data))
// Install the .vsix file
// Add delay to prevent code command crashes
@@ -1484,7 +1483,7 @@ async function installFromArtifactory(command: string): Promise<string> {
}
}
} catch (error) {
if (axios.isAxiosError(error)) {
if (isHttpError(error)) {
throw new Error(
`Failed to fetch extension version from artifactory: ${error.message}`,
)

View File

@@ -7,7 +7,7 @@
*/
import { feature } from 'bun:bundle'
import axios from 'axios'
import { isHttpError, nativeRequest } from '../http.js'
import { createHash } from 'crypto'
import { chmod, writeFile } from 'fs/promises'
import { join } from 'path'
@@ -77,24 +77,25 @@ export async function getLatestVersionFromBinaryRepo(
authConfig?: { auth: { username: string; password: string } },
): Promise<string> {
const startTime = Date.now()
try {
const response = await axios.get(`${baseUrl}/${channel}`, {
timeout: 30000,
responseType: 'text',
...authConfig,
})
const latencyMs = Date.now() - startTime
logEvent('tengu_version_check_success', {
latency_ms: latencyMs,
})
return response.data.trim()
} catch (error) {
const latencyMs = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : String(error)
let httpStatus: number | undefined
if (axios.isAxiosError(error) && error.response) {
httpStatus = error.response.status
}
try {
const response = await nativeRequest<string>(`${baseUrl}/${channel}`, {
timeout: 30000,
responseType: 'text',
...(authConfig?.auth ? {
headers: {
Authorization: `Basic ${Buffer.from(`${authConfig.auth.username}:${authConfig.auth.password}`).toString('base64')}`,
},
} : {}),
})
const latencyMs = Date.now() - startTime
logEvent('tengu_version_check_success', {
latency_ms: latencyMs,
})
return response.data.trim()
} catch (error) {
const latencyMs = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : String(error)
const httpStatus = isHttpError(error) ? error.status : undefined
logEvent('tengu_version_check_failure', {
latency_ms: latencyMs,
@@ -318,22 +319,18 @@ async function downloadAndVerifyBinary(
// Start the stall timer before the request
resetStallTimer()
const response = await axios.get(binaryUrl, {
const response = await nativeRequest<ArrayBuffer>(binaryUrl, {
timeout: 5 * 60000, // 5 minute total timeout
responseType: 'arraybuffer',
signal: controller.signal,
onDownloadProgress: () => {
// Reset stall timer on each chunk of data received
resetStallTimer()
},
...requestConfig,
...(requestConfig?.headers ? { headers: requestConfig.headers } : {}),
})
clearStallTimer()
// Verify checksum
const hash = createHash('sha256')
hash.update(response.data)
hash.update(Buffer.from(response.data))
const actualChecksum = hash.digest('hex')
if (actualChecksum !== expectedChecksum) {
@@ -351,8 +348,8 @@ async function downloadAndVerifyBinary(
} catch (error) {
clearStallTimer()
// Check if this was a stall timeout (axios wraps abort signals in CanceledError)
const isStallTimeout = axios.isCancel(error)
// Check if this was a stall timeout (abort signal fires)
const isStallTimeout = error instanceof Error && error.name === 'AbortError'
if (isStallTimeout) {
lastError = new StallTimeoutError()
@@ -403,22 +400,23 @@ export async function downloadVersionFromBinaryRepo(
// Fetch manifest to get checksum
let manifest
try {
const manifestResponse = await axios.get(
const manifestResponse = await nativeRequest(
`${baseUrl}/${version}/manifest.json`,
{
timeout: 10000,
responseType: 'json',
...authConfig,
...(authConfig?.auth ? {
headers: {
Authorization: `Basic ${Buffer.from(`${authConfig.auth.username}:${authConfig.auth.password}`).toString('base64')}`,
},
} : {}),
},
)
manifest = manifestResponse.data
} catch (error) {
const latencyMs = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : String(error)
let httpStatus: number | undefined
if (axios.isAxiosError(error) && error.response) {
httpStatus = error.response.status
}
const httpStatus = isHttpError(error) ? error.status : undefined
logEvent('tengu_binary_manifest_fetch_failure', {
latency_ms: latencyMs,
@@ -466,10 +464,7 @@ export async function downloadVersionFromBinaryRepo(
} catch (error) {
const latencyMs = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : String(error)
let httpStatus: number | undefined
if (axios.isAxiosError(error) && error.response) {
httpStatus = error.response.status
}
const httpStatus = isHttpError(error) ? error.status : undefined
logEvent('tengu_binary_download_failure', {
latency_ms: latencyMs,

View File

@@ -1,23 +1,7 @@
/**
* Telemetry for plugin/marketplace fetches that hit the network.
*
* Added for inc-5046 (GitHub complained about claude-plugins-official load).
* Before this, fetch operations only had logForDebugging — no way to measure
* actual network volume. This surfaces what's hitting GitHub vs GCS vs
* user-hosted so we can see the GCS migration take effect and catch future
* hot-path regressions before GitHub emails us again.
*
* Volume: these fire at startup (install-counts 24h-TTL)
* and on explicit user action (install/update). NOT per-interaction. Similar
* envelope to tengu_binary_download_*.
* Telemetry for plugin/marketplace fetches - DISABLED.
*/
import {
logEvent,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString,
} from '../../services/analytics/index.js'
import { OFFICIAL_MARKETPLACE_NAME } from './officialMarketplace.js'
export type PluginFetchSource =
| 'install_counts'
| 'marketplace_clone'
@@ -28,82 +12,32 @@ export type PluginFetchSource =
export type PluginFetchOutcome = 'success' | 'failure' | 'cache_hit'
// Allowlist of public hosts we report by name. Anything else (enterprise
// git, self-hosted, internal) is bucketed as 'other' — we don't want
// internal hostnames (git.mycorp.internal) landing in telemetry. Bounded
// cardinality also keeps the dashboard host-breakdown tractable.
const KNOWN_PUBLIC_HOSTS = new Set([
'github.com',
'raw.githubusercontent.com',
'objects.githubusercontent.com',
'gist.githubusercontent.com',
'gitlab.com',
'bitbucket.org',
'codeberg.org',
'dev.azure.com',
'ssh.dev.azure.com',
'storage.googleapis.com', // GCS — where Dickson's migration points
])
/**
* Extract hostname from a URL or git spec and bucket to the allowlist.
* Handles `https://host/...`, `git@host:path`, `ssh://host/...`.
* Returns a known public host, 'other' (parseable but not allowlisted —
* don't leak private hostnames), or 'unknown' (unparseable / local path).
* Extract hostname from a URL or git spec - DISABLED.
*/
function extractHost(urlOrSpec: string): string {
let host: string
const scpMatch = /^[^@/]+@([^:/]+):/.exec(urlOrSpec)
if (scpMatch) {
host = scpMatch[1]!
} else {
try {
host = new URL(urlOrSpec).hostname
} catch {
return 'unknown'
}
}
const normalized = host.toLowerCase()
return KNOWN_PUBLIC_HOSTS.has(normalized) ? normalized : 'other'
function extractHost(_urlOrSpec: string): string {
return 'unknown'
}
/**
* True if the URL/spec points at anthropics/claude-plugins-official — the
* repo GitHub complained about. Lets the dashboard separate "our problem"
* traffic from user-configured marketplaces.
* True if the URL/spec points at anthropics/claude-plugins-official - DISABLED.
*/
function isOfficialRepo(urlOrSpec: string): boolean {
return urlOrSpec.includes(`anthropics/${OFFICIAL_MARKETPLACE_NAME}`)
function isOfficialRepo(_urlOrSpec: string): boolean {
return false
}
export function logPluginFetch(
source: PluginFetchSource,
urlOrSpec: string | undefined,
outcome: PluginFetchOutcome,
durationMs: number,
errorKind?: string,
_source: PluginFetchSource,
_urlOrSpec: string | undefined,
_outcome: PluginFetchOutcome,
_durationMs: number,
_errorKind?: string,
): void {
// String values are bounded enums / hostname-only — no code, no paths,
// no raw error messages. Same privacy envelope as tengu_web_fetch_host.
logEvent('tengu_plugin_remote_fetch', {
source: source as SafeString,
host: (urlOrSpec ? extractHost(urlOrSpec) : 'unknown') as SafeString,
is_official: urlOrSpec ? isOfficialRepo(urlOrSpec) : false,
outcome: outcome as SafeString,
duration_ms: Math.round(durationMs),
...(errorKind && { error_kind: errorKind as SafeString }),
})
// Telemetry disabled
}
/**
* Classify an error into a stable bucket for the error_kind field. Keeps
* cardinality bounded — raw error messages would explode dashboard grouping.
*
* Handles both axios Error objects (Node.js error codes like ENOTFOUND) and
* git stderr strings (human phrases like "Could not resolve host"). DNS
* checked BEFORE timeout because gitClone's error enhancement at
* marketplaceManager.ts:~950 rewrites DNS failures to include the word
* "timeout" — ordering the other way would misclassify git DNS as timeout.
* Classify an error into a stable bucket for the error_kind field.
*/
export function classifyFetchError(error: unknown): string {
const msg = String((error as { message?: unknown })?.message ?? error)
@@ -125,9 +59,6 @@ export function classifyFetchError(error: unknown): string {
if (/403|401|authentication|permission denied/i.test(msg)) return 'auth'
if (/404|not found|repository not found/i.test(msg)) return 'not_found'
if (/certificate|SSL|TLS|unable to get local issuer/i.test(msg)) return 'tls'
// Schema validation throws "Invalid response format" (install_counts) —
// distinguish from true unknowns so the dashboard can
// see "server sent garbage" separately.
if (/Invalid response format|Invalid marketplace schema/i.test(msg)) {
return 'invalid_schema'
}

View File

@@ -8,13 +8,13 @@
* Cache location: ~/.claude/plugins/install-counts-cache.json
*/
import axios from 'axios'
import { randomBytes } from 'crypto'
import { readFile, rename, unlink, writeFile } from 'fs/promises'
import { join } from 'path'
import { logForDebugging } from '../debug.js'
import { errorMessage, getErrnoCode } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
import { nativeRequest } from '../http.js'
import { logError } from '../log.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { classifyFetchError, logPluginFetch } from './fetchTelemetry.js'
@@ -188,7 +188,8 @@ async function fetchInstallCountsFromGitHub(): Promise<
const started = performance.now()
try {
const response = await axios.get<GitHubStatsResponse>(INSTALL_COUNTS_URL, {
const response = await nativeRequest<GitHubStatsResponse>(INSTALL_COUNTS_URL, {
method: 'GET',
timeout: 10000,
})

View File

@@ -18,7 +18,6 @@
* └── marketplace.json
*/
import axios from 'axios'
import { writeFile } from 'fs/promises'
import isEqual from 'lodash-es/isEqual.js'
import memoize from 'lodash-es/memoize.js'
@@ -36,6 +35,7 @@ import {
import { execFileNoThrow, execFileNoThrowWithCwd } from '../execFileNoThrow.js'
import { getFsImplementation } from '../fsOperations.js'
import { gitExe } from '../git.js'
import { isHttpError, nativeRequest } from '../http.js'
import { logError } from '../log.js'
import {
getInitialSettings,
@@ -1279,7 +1279,8 @@ async function cacheMarketplaceFromUrl(
let response
const fetchStarted = performance.now()
try {
response = await axios.get(url, {
response = await nativeRequest(url, {
method: 'GET',
timeout: 10000,
headers,
})
@@ -1291,20 +1292,15 @@ async function cacheMarketplaceFromUrl(
performance.now() - fetchStarted,
classifyFetchError(error),
)
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
throw new Error(
`Could not connect to ${redactedUrl}. Please check your internet connection and verify the URL is correct.\n\nTechnical details: ${error.message}`,
)
}
if (error.code === 'ETIMEDOUT') {
if (isHttpError(error)) {
if (error.message?.includes('timeout')) {
throw new Error(
`Request timed out while downloading marketplace from ${redactedUrl}. The server may be slow or unreachable.\n\nTechnical details: ${error.message}`,
)
}
if (error.response) {
if (error.status) {
throw new Error(
`HTTP ${error.response.status} error while downloading marketplace from ${redactedUrl}. The marketplace file may not exist at this URL.\n\nTechnical details: ${error.message}`,
`HTTP ${error.status} error while downloading marketplace from ${redactedUrl}. The marketplace file may not exist at this URL.\n\nTechnical details: ${error.message}`,
)
}
}

View File

@@ -2,7 +2,6 @@ import type {
McpbManifest,
McpbUserConfigurationOption,
} from '@anthropic-ai/mcpb'
import axios from 'axios'
import { createHash } from 'crypto'
import { chmod, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
@@ -12,6 +11,7 @@ import { parseAndValidateManifestFromBytes } from '../dxt/helpers.js'
import { parseZipModes, unzipFile } from '../dxt/zip.js'
import { errorMessage, getErrnoCode, isENOENT, toError } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
import { nativeRequest } from '../http.js'
import { logError } from '../log.js'
import { getSecureStorage } from '../secureStorage/index.js'
import {
@@ -492,18 +492,10 @@ async function downloadMcpb(
const started = performance.now()
let fetchTelemetryFired = false
try {
const response = await axios.get(url, {
timeout: 120000, // 2 minute timeout
const response = await nativeRequest<ArrayBuffer>(url, {
method: 'GET',
responseType: 'arraybuffer',
maxRedirects: 5, // Follow redirects (like curl -L)
onDownloadProgress: progressEvent => {
if (progressEvent.total && onProgress) {
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
)
onProgress(`Downloading... ${percent}%`)
}
},
timeout: 120000, // 2 minute timeout
})
const data = new Uint8Array(response.data)

View File

@@ -8,7 +8,6 @@
* when there's a new SHA. Callers decide fallback behavior on failure.
*/
import axios from 'axios'
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
import { dirname, join, resolve, sep } from 'path'
import { waitForScrollIdle } from '../../bootstrap/state.js'
@@ -17,6 +16,7 @@ import { logEvent } from '../../services/analytics/index.js'
import { logForDebugging } from '../debug.js'
import { parseZipModes, unzipFile } from '../dxt/zip.js'
import { errorMessage, getErrnoCode } from '../errors.js'
import { isHttpError, nativeRequest } from '../http.js'
type SafeString = AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
@@ -78,7 +78,8 @@ export async function fetchOfficialMarketplaceFromGcs(
try {
// 1. Latest pointer — ~40 bytes, backend sets Cache-Control: no-cache,
// max-age=300. Cheap enough to hit every startup.
const latest = await axios.get(`${GCS_BASE}/latest`, {
const latest = await nativeRequest<string>(`${GCS_BASE}/latest`, {
method: 'GET',
responseType: 'text',
timeout: 10_000,
})
@@ -104,7 +105,8 @@ export async function fetchOfficialMarketplaceFromGcs(
// 3. Download zip and extract to a staging dir, then atomic-swap into
// place. Crash mid-extract leaves a .staging dir (next run rm's it)
// rather than a half-written installLocation.
const zipResp = await axios.get(`${GCS_BASE}/${sha}.zip`, {
const zipResp = await nativeRequest<ArrayBuffer>(`${GCS_BASE}/${sha}.zip`, {
method: 'GET',
responseType: 'arraybuffer',
timeout: 60_000,
})
@@ -194,9 +196,9 @@ const KNOWN_FS_CODES = new Set([
* (disk full, permission denied) before flipping the git-fallback kill switch.
*/
export function classifyGcsError(e: unknown): string {
if (axios.isAxiosError(e)) {
if (e.code === 'ECONNABORTED') return 'timeout'
if (e.response) return `http_${e.response.status}`
if (isHttpError(e)) {
if (e.message?.includes('timeout')) return 'timeout'
if (e.status) return `http_${e.status}`
return 'network'
}
const code = getErrnoCode(e)

View File

@@ -1,8 +1,3 @@
// @aws-sdk/credential-provider-node and @smithy/node-http-handler are imported
// dynamically in getAWSClientProxyConfig() to defer ~929KB of AWS SDK.
// undici is lazy-required inside getProxyAgent/configureGlobalAgents to defer
// ~1.5MB when no HTTPS_PROXY/mTLS env vars are set (the common case).
import axios, { type AxiosInstance } from 'axios'
import type { LookupOptions } from 'dns'
import type { Agent } from 'http'
import { HttpsProxyAgent, type HttpsProxyAgentOptions } from 'https-proxy-agent'
@@ -160,37 +155,6 @@ function createHttpsProxyAgent(
return new HttpsProxyAgent(proxyUrl, { ...agentOptions, ...extra })
}
/**
* Axios instance with its own proxy agent. Same NO_PROXY/mTLS/CA
* resolution as the global interceptor, but agent options stay
* scoped to this instance.
*/
export function createAxiosInstance(
extra: HttpsProxyAgentOptions<string> = {},
): AxiosInstance {
const proxyUrl = getProxyUrl()
const mtlsAgent = getMTLSAgent()
const instance = axios.create({ proxy: false })
if (!proxyUrl) {
if (mtlsAgent) instance.defaults.httpsAgent = mtlsAgent
return instance
}
const proxyAgent = createHttpsProxyAgent(proxyUrl, extra)
instance.interceptors.request.use(config => {
if (config.url && shouldBypassProxy(config.url)) {
config.httpsAgent = mtlsAgent
config.httpAgent = mtlsAgent
} else {
config.httpsAgent = proxyAgent
config.httpAgent = proxyAgent
}
return config
})
return instance
}
/**
* Get or create a memoized proxy agent for the given URI
* Now respects NO_PROXY environment variable
@@ -319,63 +283,21 @@ export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean }): {
}
/**
* Configure global HTTP agents for both axios and undici
* This ensures all HTTP requests use the proxy and/or mTLS if configured
* Configure global undici dispatcher
* This ensures all native fetch requests use the proxy and/or mTLS if configured.
* Axios configuration has been removed as it is deprecated in favor of native fetch.
*/
let proxyInterceptorId: number | undefined
export function configureGlobalAgents(): void {
const proxyUrl = getProxyUrl()
const mtlsAgent = getMTLSAgent()
// Eject previous interceptor to avoid stacking on repeated calls
if (proxyInterceptorId !== undefined) {
axios.interceptors.request.eject(proxyInterceptorId)
proxyInterceptorId = undefined
}
// Reset proxy-related defaults so reconfiguration is clean
axios.defaults.proxy = undefined
axios.defaults.httpAgent = undefined
axios.defaults.httpsAgent = undefined
if (proxyUrl) {
// workaround for https://github.com/axios/axios/issues/4531
axios.defaults.proxy = false
// Create proxy agent with mTLS options if available
const proxyAgent = createHttpsProxyAgent(proxyUrl)
// Add axios request interceptor to handle NO_PROXY
proxyInterceptorId = axios.interceptors.request.use(config => {
// Check if URL should bypass proxy based on NO_PROXY
if (config.url && shouldBypassProxy(config.url)) {
// Bypass proxy - use mTLS agent if configured, otherwise undefined
if (mtlsAgent) {
config.httpsAgent = mtlsAgent
config.httpAgent = mtlsAgent
} else {
// Remove any proxy agents to use direct connection
delete config.httpsAgent
delete config.httpAgent
}
} else {
// Use proxy agent
config.httpsAgent = proxyAgent
config.httpAgent = proxyAgent
}
return config
})
// Set global dispatcher that now respects NO_PROXY via EnvHttpProxyAgent
// eslint-disable-next-line @typescript-eslint/no-require-imports
;(require('undici') as typeof undici).setGlobalDispatcher(
getProxyAgent(proxyUrl),
)
} else if (mtlsAgent) {
// No proxy but mTLS is configured
axios.defaults.httpsAgent = mtlsAgent
// Set undici global dispatcher with mTLS
const mtlsOptions = getTLSFetchOptions()
if (mtlsOptions.dispatcher) {

View File

@@ -1,4 +1,3 @@
import axios from 'axios'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import { coerce } from 'semver'
@@ -6,6 +5,7 @@ import { getIsNonInteractiveSession } from '../bootstrap/state.js'
import { getGlobalConfig, saveGlobalConfig } from './config.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { toError } from './errors.js'
import { nativeRequest } from './http.js'
import { logError } from './log.js'
import { isEssentialTrafficOnly } from './privacyLevel.js'
import { gt } from './semver.js'
@@ -90,7 +90,9 @@ export async function fetchAndStoreChangelog(): Promise<void> {
return
}
const response = await axios.get(RAW_CHANGELOG_URL)
const response = await nativeRequest<string>(RAW_CHANGELOG_URL, {
method: 'GET',
})
if (response.status === 200) {
const changelogContent = response.data
@@ -286,23 +288,9 @@ export function getAllReleaseNotes(
*/
export async function checkForReleaseNotes(
lastSeenVersion: string | null | undefined,
currentVersion: string = MACRO.VERSION,
currentVersion: string = '0.1.0-alpha',
): Promise<{ hasReleaseNotes: boolean; releaseNotes: string[] }> {
// For Ant builds, use VERSION_CHANGELOG bundled at build time
if (process.env.USER_TYPE === 'ant') {
const changelog = MACRO.VERSION_CHANGELOG
if (changelog) {
const commits = changelog.trim().split('\n').filter(Boolean)
return {
hasReleaseNotes: commits.length > 0,
releaseNotes: commits,
}
}
return {
hasReleaseNotes: false,
releaseNotes: [],
}
}
// Release notes check
// Ensure the in-memory cache is populated for subsequent sync reads
const cachedChangelog = await getStoredChangelog()
@@ -334,23 +322,8 @@ export async function checkForReleaseNotes(
*/
export function checkForReleaseNotesSync(
lastSeenVersion: string | null | undefined,
currentVersion: string = MACRO.VERSION,
currentVersion: string = '0.1.0-alpha',
): { hasReleaseNotes: boolean; releaseNotes: string[] } {
// For Ant builds, use VERSION_CHANGELOG bundled at build time
if (process.env.USER_TYPE === 'ant') {
const changelog = MACRO.VERSION_CHANGELOG
if (changelog) {
const commits = changelog.trim().split('\n').filter(Boolean)
return {
hasReleaseNotes: commits.length > 0,
releaseNotes: commits,
}
}
return {
hasReleaseNotes: false,
releaseNotes: [],
}
}
const releaseNotes = getRecentReleaseNotes(currentVersion, lastSeenVersion)
return {

View File

@@ -1,252 +1,34 @@
import type { Attributes, HrTime } from '@opentelemetry/api'
import { AggregationTemporality, type PushMetricExporter } from '@opentelemetry/sdk-metrics'
import { type ExportResult, ExportResultCode } from '@opentelemetry/core'
import {
AggregationTemporality,
type MetricData,
type DataPoint as OTelDataPoint,
type PushMetricExporter,
type ResourceMetrics,
} from '@opentelemetry/sdk-metrics'
import axios from 'axios'
import { checkMetricsEnabled } from 'src/services/api/metricsOptOut.js'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { getSubscriptionType, isClaudeAISubscriber } from '../auth.js'
import { checkHasTrustDialogAccepted } from '../config.js'
import { logForDebugging } from '../debug.js'
import { errorMessage, toError } from '../errors.js'
import { getAuthHeaders } from '../http.js'
import { logError } from '../log.js'
import { jsonStringify } from '../slowOperations.js'
import { getClaudeCodeUserAgent } from '../userAgent.js'
type DataPoint = {
attributes: Record<string, string>
value: number
timestamp: string
}
type Metric = {
name: string
description?: string
unit?: string
data_points: DataPoint[]
}
type InternalMetricsPayload = {
resource_attributes: Record<string, string>
metrics: Metric[]
}
/**
* BigQuery Metrics Exporter - Stubbed
*
* This exporter is stubbed to ensure no metrics or telemetry data
* is ever transmitted to external services.
*/
export class BigQueryMetricsExporter implements PushMetricExporter {
private readonly endpoint: string
private readonly timeout: number
private pendingExports: Promise<void>[] = []
private isShutdown = false
constructor(options: { timeout?: number } = {}) {
const defaultEndpoint = 'https://api.anthropic.com/api/claude_code/metrics'
if (
process.env.USER_TYPE === 'ant' &&
process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT
) {
this.endpoint =
process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT +
'/api/claude_code/metrics'
} else {
this.endpoint = defaultEndpoint
}
this.timeout = options.timeout || 5000
constructor(_options: { timeout?: number } = {}) {
// No-op
}
async export(
metrics: ResourceMetrics,
_metrics: any,
resultCallback: (result: ExportResult) => void,
): Promise<void> {
if (this.isShutdown) {
resultCallback({
code: ExportResultCode.FAILED,
error: new Error('Exporter has been shutdown'),
})
return
}
const exportPromise = this.doExport(metrics, resultCallback)
this.pendingExports.push(exportPromise)
// Clean up completed exports
void exportPromise.finally(() => {
const index = this.pendingExports.indexOf(exportPromise)
if (index > -1) {
void this.pendingExports.splice(index, 1)
}
})
}
private async doExport(
metrics: ResourceMetrics,
resultCallback: (result: ExportResult) => void,
): Promise<void> {
try {
// Skip if trust not established in interactive mode
// This prevents triggering apiKeyHelper before trust dialog
const hasTrust =
checkHasTrustDialogAccepted() || getIsNonInteractiveSession()
if (!hasTrust) {
logForDebugging(
'BigQuery metrics export: trust not established, skipping',
)
resultCallback({ code: ExportResultCode.SUCCESS })
return
}
// Check organization-level metrics opt-out
const metricsStatus = await checkMetricsEnabled()
if (!metricsStatus.enabled) {
logForDebugging('Metrics export disabled by organization setting')
resultCallback({ code: ExportResultCode.SUCCESS })
return
}
const payload = this.transformMetricsForInternal(metrics)
const authResult = getAuthHeaders()
if (authResult.error) {
logForDebugging(`Metrics export failed: ${authResult.error}`)
resultCallback({
code: ExportResultCode.FAILED,
error: new Error(authResult.error),
})
return
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': getClaudeCodeUserAgent(),
...authResult.headers,
}
const response = await axios.post(this.endpoint, payload, {
timeout: this.timeout,
headers,
})
logForDebugging('BigQuery metrics exported successfully')
logForDebugging(
`BigQuery API Response: ${jsonStringify(response.data, null, 2)}`,
)
resultCallback({ code: ExportResultCode.SUCCESS })
} catch (error) {
logForDebugging(`BigQuery metrics export failed: ${errorMessage(error)}`)
logError(error)
resultCallback({
code: ExportResultCode.FAILED,
error: toError(error),
})
}
}
private transformMetricsForInternal(
metrics: ResourceMetrics,
): InternalMetricsPayload {
const attrs = metrics.resource.attributes
const resourceAttributes: Record<string, string> = {
'service.name': (attrs['service.name'] as string) || 'claude-code',
'service.version': (attrs['service.version'] as string) || 'unknown',
'os.type': (attrs['os.type'] as string) || 'unknown',
'os.version': (attrs['os.version'] as string) || 'unknown',
'host.arch': (attrs['host.arch'] as string) || 'unknown',
'aggregation.temporality':
this.selectAggregationTemporality() === AggregationTemporality.DELTA
? 'delta'
: 'cumulative',
}
// Only add wsl.version if it exists (omit instead of default)
if (attrs['wsl.version']) {
resourceAttributes['wsl.version'] = attrs['wsl.version'] as string
}
// Add customer type and subscription type
if (isClaudeAISubscriber()) {
resourceAttributes['user.customer_type'] = 'claude_ai'
const subscriptionType = getSubscriptionType()
if (subscriptionType) {
resourceAttributes['user.subscription_type'] = subscriptionType
}
} else {
resourceAttributes['user.customer_type'] = 'api'
}
const transformed = {
resource_attributes: resourceAttributes,
metrics: metrics.scopeMetrics.flatMap(scopeMetric =>
scopeMetric.metrics.map(metric => ({
name: metric.descriptor.name,
description: metric.descriptor.description,
unit: metric.descriptor.unit,
data_points: this.extractDataPoints(metric),
})),
),
}
return transformed
}
private extractDataPoints(metric: MetricData): DataPoint[] {
const dataPoints = metric.dataPoints || []
return dataPoints
.filter(
(point): point is OTelDataPoint<number> =>
typeof point.value === 'number',
)
.map(point => ({
attributes: this.convertAttributes(point.attributes),
value: point.value,
timestamp: this.hrTimeToISOString(
point.endTime || point.startTime || [Date.now() / 1000, 0],
),
}))
// Always report success but do nothing
resultCallback({ code: ExportResultCode.SUCCESS })
}
async shutdown(): Promise<void> {
this.isShutdown = true
await this.forceFlush()
logForDebugging('BigQuery metrics exporter shutdown complete')
// No-op
}
async forceFlush(): Promise<void> {
await Promise.all(this.pendingExports)
logForDebugging('BigQuery metrics exporter flush complete')
}
private convertAttributes(
attributes: Attributes | undefined,
): Record<string, string> {
const result: Record<string, string> = {}
if (attributes) {
for (const [key, value] of Object.entries(attributes)) {
if (value !== undefined && value !== null) {
result[key] = String(value)
}
}
}
return result
}
private hrTimeToISOString(hrTime: HrTime): string {
const [seconds, nanoseconds] = hrTime
const date = new Date(seconds * 1000 + nanoseconds / 1000000)
return date.toISOString()
// No-op
}
selectAggregationTemporality(): AggregationTemporality {
// DO NOT CHANGE THIS TO CUMULATIVE
// It would mess up the aggregation of metrics
// for CC Productivity metrics dashboard
return AggregationTemporality.DELTA
}
}

View File

@@ -5,6 +5,7 @@ import { getOrCreateUserID } from './config.js'
import { envDynamic } from './envDynamic.js'
import { isEnvTruthy } from './envUtils.js'
import { toTaggedId } from './taggedId.js'
import { VERSION } from 'src/constants/product.js'
// Default configuration for metrics cardinality
const METRICS_CARDINALITY_DEFAULTS = {
@@ -38,7 +39,7 @@ export function getTelemetryAttributes(): Attributes {
attributes['session.id'] = sessionId
}
if (shouldIncludeAttribute('OTEL_METRICS_INCLUDE_VERSION')) {
attributes['app.version'] = MACRO.VERSION
attributes['app.version'] = VERSION
}
// Only include OAuth account data when actively using OAuth authentication

View File

@@ -1,4 +1,4 @@
import axios from 'axios';
import { isHttpError, nativeRequest } from './http.js';
import chalk from 'chalk';
import { randomUUID } from 'crypto';
import React from 'react';
@@ -604,7 +604,7 @@ export async function teleportFromSessionsAPI(sessionId: string, orgUUID: string
const err = toError(error);
// Handle 404 specifically
if (axios.isAxiosError(error) && error.response?.status === 404) {
if (isHttpError(error) && error.status === 404) {
logEvent('tengu_teleport_error_session_not_found_404', {
sessionId: sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
@@ -659,11 +659,9 @@ export async function pollRemoteSessionEvents(sessionId: string, afterId: string
const sdkMessages: SDKMessage[] = [];
let cursor = afterId;
for (let page = 0; page < MAX_EVENT_PAGES; page++) {
const eventsResponse = await axios.get(eventsUrl, {
const eventsUrlWithCursor = cursor ? `${eventsUrl}?after_id=${encodeURIComponent(cursor)}` : eventsUrl;
const eventsResponse = await nativeRequest<EventsResponse>(eventsUrlWithCursor, {
headers,
params: cursor ? {
after_id: cursor
} : undefined,
timeout: 30000
});
if (eventsResponse.status !== 200) {
@@ -878,7 +876,9 @@ export async function teleportToRemote(options: {
environment_id: options.environmentId
};
logForDebugging(`[teleportToRemote] explicit env ${options.environmentId}, ${Object.keys(envVars).length} env vars, ${seedBundleFileId ? `bundle=${seedBundleFileId}` : `source=${gitSource?.url ?? 'none'}@${options.branchName ?? 'default'}`}`);
const response = await axios.post(url, requestBody, {
const response = await nativeRequest<SessionResource>(url, {
method: 'POST',
body: requestBody,
headers,
signal
});
@@ -1161,7 +1161,9 @@ export async function teleportToRemote(options: {
logForDebugging(`Creating session with payload: ${jsonStringify(requestBody, null, 2)}`);
// Make API call
const response = await axios.post(url, requestBody, {
const response = await nativeRequest<SessionResource>(url, {
method: 'POST',
body: requestBody,
headers,
signal
});
@@ -1209,10 +1211,11 @@ export async function archiveRemoteSession(sessionId: string): Promise<void> {
};
const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`;
try {
const resp = await axios.post(url, {}, {
const resp = await nativeRequest(url, {
method: 'POST',
body: {},
headers,
timeout: 10000,
validateStatus: s => s < 500
timeout: 10000
});
if (resp.status === 200 || resp.status === 409) {
logForDebugging(`[archiveRemoteSession] archived ${sessionId}`);

View File

@@ -1,4 +1,3 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { randomUUID } from 'crypto'
import { getOauthConfig } from 'src/constants/oauth.js'
import { getOrganizationUUID } from 'src/services/oauth/client.js'
@@ -7,6 +6,7 @@ import { getClaudeAIOAuthTokens } from '../auth.js'
import { logForDebugging } from '../debug.js'
import { parseGitHubRepository } from '../detectRepository.js'
import { errorMessage, toError } from '../errors.js'
import { isHttpError, nativeRequest } from '../http.js'
import { lazySchema } from '../lazySchema.js'
import { logError } from '../log.js'
import { sleep } from '../sleep.js'
@@ -19,40 +19,40 @@ const MAX_TELEPORT_RETRIES = TELEPORT_RETRY_DELAYS.length
export const CCR_BYOC_BETA = 'ccr-byoc-2025-07-29'
/**
* Checks if an axios error is a transient network error that should be retried
* Checks if an error is a transient network error that should be retried
*/
export function isTransientNetworkError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false
if (isHttpError(error)) {
// Retry on server errors (5xx)
return !!error.status && error.status >= 500
}
// Retry on network errors (no response received)
if (!error.response) {
return true
// Treat generic Error as transient?
// Native fetch throws generic Error for network issues
if (error instanceof Error) {
const msg = error.message.toLowerCase()
return msg.includes('network') || msg.includes('timeout') || msg.includes('aborted')
}
// Retry on server errors (5xx)
if (error.response.status >= 500) {
return true
}
// Don't retry on client errors (4xx) - they're not transient
return false
}
/**
* Makes an axios GET request with automatic retry for transient network errors
* Makes a native GET request with automatic retry for transient network errors
* Uses exponential backoff: 2s, 4s, 8s, 16s (4 retries = 5 total attempts)
*/
export async function axiosGetWithRetry<T>(
export async function nativeGetWithRetry<T>(
url: string,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
options: { headers?: Record<string, string> } = {},
): Promise<{ data: T; status: number }> {
let lastError: unknown
for (let attempt = 0; attempt <= MAX_TELEPORT_RETRIES; attempt++) {
try {
return await axios.get<T>(url, config)
return await nativeRequest<T>(url, {
method: 'GET',
...options,
})
} catch (error) {
lastError = error
@@ -215,12 +215,12 @@ export async function fetchCodeSessionsFromSessionsAPI(): Promise<
'x-organization-uuid': orgUUID,
}
const response = await axiosGetWithRetry<ListSessionsResponse>(url, {
const response = await nativeGetWithRetry<ListSessionsResponse>(url, {
headers,
})
if (response.status !== 200) {
throw new Error(`Failed to fetch code sessions: ${response.statusText}`)
throw new Error(`Failed to fetch code sessions: ${response.status}`)
}
// Transform SessionResource[] to CodeSession[] format
@@ -298,10 +298,10 @@ export async function fetchSession(
'x-organization-uuid': orgUUID,
}
const response = await axios.get<SessionResource>(url, {
const response = await nativeRequest<SessionResource>(url, {
method: 'GET',
headers,
timeout: 15000,
validateStatus: status => status < 500,
})
if (response.status !== 200) {
@@ -319,7 +319,7 @@ export async function fetchSession(
throw new Error(
apiMessage ||
`Failed to fetch session: ${response.status} ${response.statusText}`,
`Failed to fetch session: ${response.status}`,
)
}
@@ -393,9 +393,10 @@ export async function sendEventToRemoteSession(
)
// The endpoint may block until the CCR worker is ready. Observed ~2.6s
// in normal cases; allow a generous margin for cold-start containers.
const response = await axios.post(url, requestBody, {
const response = await nativeRequest<any>(url, {
method: 'POST',
body: requestBody,
headers,
validateStatus: status => status < 500,
timeout: 30000,
})
@@ -439,14 +440,11 @@ export async function updateSessionTitle(
logForDebugging(
`[updateSessionTitle] Updating title for session ${sessionId}: "${title}"`,
)
const response = await axios.patch(
url,
{ title },
{
headers,
validateStatus: status => status < 500,
},
)
const response = await nativeRequest<any>(url, {
method: 'PATCH',
body: { title },
headers,
})
if (response.status === 200) {
logForDebugging(

View File

@@ -1,8 +1,8 @@
import axios from 'axios'
import { getOauthConfig } from 'src/constants/oauth.js'
import { getOrganizationUUID } from 'src/services/oauth/client.js'
import { getClaudeAIOAuthTokens } from '../auth.js'
import { toError } from '../errors.js'
import { nativeRequest } from '../http.js'
import { logError } from '../log.js'
import { getOAuthHeaders } from './api.js'
@@ -50,15 +50,14 @@ export async function fetchEnvironments(): Promise<EnvironmentResource[]> {
'x-organization-uuid': orgUUID,
}
const response = await axios.get<EnvironmentListResponse>(url, {
const response = await nativeRequest<EnvironmentListResponse>(url, {
method: 'GET',
headers,
timeout: 15000,
})
if (response.status !== 200) {
throw new Error(
`Failed to fetch environments: ${response.status} ${response.statusText}`,
)
throw new Error(`Failed to fetch environments: ${response.status}`)
}
return response.data.environments
@@ -86,9 +85,9 @@ export async function createDefaultCloudEnvironment(
}
const url = `${getOauthConfig().BASE_API_URL}/v1/environment_providers/cloud/create`
const response = await axios.post<EnvironmentResource>(
url,
{
const response = await nativeRequest<EnvironmentResource>(url, {
method: 'POST',
body: {
name,
kind: 'anthropic_cloud',
description: '',
@@ -107,14 +106,12 @@ export async function createDefaultCloudEnvironment(
},
},
},
{
headers: {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
},
timeout: 15000,
headers: {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
},
)
timeout: 15000,
})
return response.data
}

View File

@@ -1,4 +1,5 @@
import { execa } from 'execa'
import { VERSION } from 'src/constants/product.js'
import memoize from 'lodash-es/memoize.js'
import { getSessionId } from '../bootstrap/state.js'
import {
@@ -105,7 +106,7 @@ export const getCoreUserData = memoize(
deviceId,
sessionId: getSessionId(),
email: getEmail(),
appVersion: MACRO.VERSION,
appVersion: VERSION,
platform: getHostPlatformForAnalytics(),
organizationUuid,
accountUuid,

View File

@@ -5,6 +5,8 @@
* import without pulling in auth.ts and its transitive dependency tree.
*/
import { VERSION } from 'src/constants/product.js'
export function getClaudeCodeUserAgent(): string {
return `claude-code/${MACRO.VERSION}`
return `claude-code/${VERSION}`
}