axios+telemetry cleanup
This commit is contained in:
@@ -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', {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
125
utils/http.ts
125
utils/http.ts
@@ -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
|
||||
|
||||
23
utils/ide.ts
23
utils/ide.ts
@@ -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}`,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user