Reduce remaining file, LSP, and XAA debug detail

This commit is contained in:
2026-04-04 10:31:29 +08:00
parent 010ded8476
commit 4d506aabf7
6 changed files with 211 additions and 90 deletions

View File

@@ -14,9 +14,10 @@ import * as path from 'path'
import { count } from '../../utils/array.js'
import { getCwd } from '../../utils/cwd.js'
import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
import { errorMessage, getErrnoCode } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { sleep } from '../../utils/sleep.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
@@ -45,6 +46,37 @@ function logDebug(message: string): void {
logForDebugging(`[files-api] ${message}`)
}
function summarizeFilesApiError(error: unknown): string {
const summary: Record<string, boolean | number | string> = {}
if (error instanceof Error) {
summary.errorType = error.constructor.name
summary.errorName = error.name
summary.hasMessage = error.message.length > 0
} else {
summary.errorType = typeof error
summary.hasValue = error !== undefined && error !== null
}
const errno = getErrnoCode(error)
if (errno) {
summary.errno = errno
}
if (axios.isAxiosError(error)) {
summary.errorType = 'AxiosError'
if (error.code) {
summary.axiosCode = error.code
}
if (typeof error.response?.status === 'number') {
summary.httpStatus = error.response.status
}
summary.hasResponseData = error.response?.data !== undefined
}
return jsonStringify(summary)
}
/**
* File specification parsed from CLI args
* Format: --file=<file_id>:<relative_path>
@@ -108,9 +140,7 @@ async function retryWithBackoff<T>(
}
lastError = result.error || `${operation} failed`
logDebug(
`${operation} attempt ${attempt}/${MAX_RETRIES} failed: ${lastError}`,
)
logDebug(`${operation} attempt ${attempt}/${MAX_RETRIES} failed`)
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
@@ -142,7 +172,7 @@ export async function downloadFile(
'anthropic-beta': FILES_API_BETA_HEADER,
}
logDebug(`Downloading file ${fileId} from ${url}`)
logDebug(`Downloading file ${fileId} from configured Files API endpoint`)
return retryWithBackoff(`Download file ${fileId}`, async () => {
try {
@@ -191,9 +221,7 @@ export function buildDownloadPath(
): string | null {
const normalized = path.normalize(relativePath)
if (normalized.startsWith('..')) {
logDebugError(
`Invalid file path: ${relativePath}. Path must not traverse above workspace`,
)
logDebugError('Invalid file path rejected: path traversal is not allowed')
return null
}
@@ -243,7 +271,7 @@ export async function downloadAndSaveFile(
// Write the file
await fs.writeFile(fullPath, content)
logDebug(`Saved file ${fileId} to ${fullPath} (${content.length} bytes)`)
logDebug(`Saved file ${fileId} (${content.length} bytes)`)
return {
fileId,
@@ -252,10 +280,16 @@ export async function downloadAndSaveFile(
bytesWritten: content.length,
}
} catch (error) {
logDebugError(`Failed to download file ${fileId}: ${errorMessage(error)}`)
if (error instanceof Error) {
logError(error)
}
logDebugError(
`Failed to download file ${fileId}: ${summarizeFilesApiError(error)}`,
)
logError(
new Error(
`Files API download failed for ${fileId}: ${summarizeFilesApiError(
error,
)}`,
),
)
return {
fileId,
@@ -390,7 +424,7 @@ export async function uploadFile(
'anthropic-beta': FILES_API_BETA_HEADER,
}
logDebug(`Uploading file ${filePath} as ${relativePath}`)
logDebug('Uploading file to configured Files API endpoint')
// Read file content first (outside retry loop since it's not a network operation)
let content: Buffer
@@ -455,7 +489,7 @@ export async function uploadFile(
const body = Buffer.concat(bodyParts)
try {
return await retryWithBackoff(`Upload file ${relativePath}`, async () => {
return await retryWithBackoff('Upload session file', async () => {
try {
const response = await axios.post(url, body, {
headers: {
@@ -476,7 +510,7 @@ export async function uploadFile(
error: 'Upload succeeded but no file ID returned',
}
}
logDebug(`Uploaded file ${filePath} -> ${fileId} (${fileSize} bytes)`)
logDebug(`Uploaded file (${fileSize} bytes)`)
return {
done: true,
value: {
@@ -735,9 +769,7 @@ export function parseFileSpecs(fileSpecs: string[]): File[] {
const relativePath = spec.substring(colonIndex + 1)
if (!fileId || !relativePath) {
logDebugError(
`Invalid file spec: ${spec}. Both file_id and path are required`,
)
logDebugError('Invalid file spec: missing file_id or relative path')
continue
}

View File

@@ -8,6 +8,34 @@ import type { DiagnosticFile } from '../diagnosticTracking.js'
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
import type { LSPServerManager } from './LSPServerManager.js'
function summarizeLspErrorForDebug(error: unknown): string {
const err = toError(error)
return jsonStringify({
errorType: err.constructor.name,
errorName: err.name,
hasMessage: err.message.length > 0,
})
}
function summarizeDiagnosticParamsForDebug(params: unknown): string {
if (!params || typeof params !== 'object') {
return jsonStringify({
paramsType: typeof params,
hasValue: params !== undefined && params !== null,
})
}
const paramRecord = params as Record<string, unknown>
const diagnostics = paramRecord.diagnostics
return jsonStringify({
keys: Object.keys(paramRecord)
.sort()
.slice(0, 10),
hasUri: typeof paramRecord.uri === 'string',
diagnosticsCount: Array.isArray(diagnostics) ? diagnostics.length : 0,
})
}
/**
* Map LSP severity to Claude diagnostic severity
*
@@ -54,7 +82,9 @@ export function formatDiagnosticsForAttachment(
const err = toError(error)
logError(err)
logForDebugging(
`Failed to convert URI to file path: ${params.uri}. Error: ${err.message}. Using original URI as fallback.`,
`Failed to convert diagnostic URI to file path; using original URI fallback (${summarizeLspErrorForDebug(
err,
)})`,
)
// Gracefully fallback to original URI - LSP servers may send malformed URIs
uri = params.uri
@@ -177,14 +207,16 @@ export function registerLSPNotificationHandlers(
)
logError(err)
logForDebugging(
`Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`,
`Invalid diagnostic params from ${serverName}: ${summarizeDiagnosticParamsForDebug(
params,
)}`,
)
return
}
const diagnosticParams = params as PublishDiagnosticsParams
logForDebugging(
`Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s) for ${diagnosticParams.uri}`,
`Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s)`,
)
// Convert LSP diagnostics to Claude format (can throw on invalid URIs)
@@ -199,7 +231,7 @@ export function registerLSPNotificationHandlers(
firstFile.diagnostics.length === 0
) {
logForDebugging(
`Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`,
`Skipping empty diagnostics from ${serverName}`,
)
return
}
@@ -223,9 +255,8 @@ export function registerLSPNotificationHandlers(
logError(err)
logForDebugging(
`Error registering LSP diagnostics from ${serverName}: ` +
`URI: ${diagnosticParams.uri}, ` +
`Diagnostic count: ${firstFile.diagnostics.length}, ` +
`Error: ${err.message}`,
`Error: ${summarizeLspErrorForDebug(err)}`,
)
// Track consecutive failures and warn after 3+
@@ -234,7 +265,7 @@ export function registerLSPNotificationHandlers(
lastError: '',
}
failures.count++
failures.lastError = err.message
failures.lastError = summarizeLspErrorForDebug(err)
diagnosticFailures.set(serverName, failures)
if (failures.count >= 3) {
@@ -251,7 +282,9 @@ export function registerLSPNotificationHandlers(
const err = toError(error)
logError(err)
logForDebugging(
`Unexpected error processing diagnostics from ${serverName}: ${err.message}`,
`Unexpected error processing diagnostics from ${serverName}: ${summarizeLspErrorForDebug(
err,
)}`,
)
// Track consecutive failures and warn after 3+
@@ -260,7 +293,7 @@ export function registerLSPNotificationHandlers(
lastError: '',
}
failures.count++
failures.lastError = err.message
failures.lastError = summarizeLspErrorForDebug(err)
diagnosticFailures.set(serverName, failures)
if (failures.count >= 3) {
@@ -284,13 +317,13 @@ export function registerLSPNotificationHandlers(
registrationErrors.push({
serverName,
error: err.message,
error: summarizeLspErrorForDebug(err),
})
logError(err)
logForDebugging(
`Failed to register diagnostics handler for ${serverName}: ` +
`Error: ${err.message}`,
`Error: ${summarizeLspErrorForDebug(err)}`,
)
}
}

View File

@@ -96,6 +96,24 @@ function redactTokens(raw: unknown): string {
return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`)
}
function summarizeXaaPayload(raw: unknown): string {
if (typeof raw === 'string') {
return `text(${raw.length} chars)`
}
if (Array.isArray(raw)) {
return `array(${raw.length})`
}
if (raw && typeof raw === 'object') {
return jsonStringify({
payloadType: 'object',
keys: Object.keys(raw as Record<string, unknown>)
.sort()
.slice(0, 10),
})
}
return typeof raw
}
// ─── Zod Schemas ────────────────────────────────────────────────────────────
const TokenExchangeResponseSchema = lazySchema(() =>
@@ -145,7 +163,7 @@ export async function discoverProtectedResource(
)
} catch (e) {
throw new Error(
`XAA: PRM discovery failed: ${e instanceof Error ? e.message : String(e)}`,
`XAA: PRM discovery failed (${e instanceof Error ? e.name : typeof e})`,
)
}
if (!prm.resource || !prm.authorization_servers?.[0]) {
@@ -154,9 +172,7 @@ export async function discoverProtectedResource(
)
}
if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
throw new Error(
`XAA: PRM discovery failed: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`,
)
throw new Error('XAA: PRM discovery failed: PRM resource mismatch')
}
return {
resource: prm.resource,
@@ -183,22 +199,16 @@ export async function discoverAuthorizationServer(
fetchFn: opts?.fetchFn ?? defaultFetch,
})
if (!meta?.issuer || !meta.token_endpoint) {
throw new Error(
`XAA: AS metadata discovery failed: no valid metadata at ${asUrl}`,
)
throw new Error('XAA: AS metadata discovery failed: no valid metadata')
}
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
throw new Error(
`XAA: AS metadata discovery failed: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`,
)
throw new Error('XAA: AS metadata discovery failed: issuer mismatch')
}
// RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS
// that self-consistently reports an http:// issuer would pass the mismatch
// check above, then we'd POST id_token + client_secret over plaintext.
if (new URL(meta.token_endpoint).protocol !== 'https:') {
throw new Error(
`XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`,
)
throw new Error('XAA: refusing non-HTTPS token endpoint')
}
return {
issuer: meta.issuer,
@@ -263,7 +273,7 @@ export async function requestJwtAuthorizationGrant(opts: {
body: params,
})
if (!res.ok) {
const body = redactTokens(await res.text()).slice(0, 200)
const body = summarizeXaaPayload(redactTokens(await res.text()))
// 4xx → id_token rejected (invalid_grant etc.), clear cache.
// 5xx → IdP outage, id_token may still be valid, preserve it.
const shouldClear = res.status < 500
@@ -278,21 +288,25 @@ export async function requestJwtAuthorizationGrant(opts: {
} catch {
// Transient network condition (captive portal, proxy) — don't clear id_token.
throw new XaaTokenExchangeError(
`XAA: token exchange returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`,
'XAA: token exchange returned non-JSON response (captive portal?)',
false,
)
}
const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
if (!exchangeParsed.success) {
throw new XaaTokenExchangeError(
`XAA: token exchange response did not match expected shape: ${redactTokens(rawExchange)}`,
`XAA: token exchange response did not match expected shape: ${summarizeXaaPayload(
redactTokens(rawExchange),
)}`,
true,
)
}
const result = exchangeParsed.data
if (!result.access_token) {
throw new XaaTokenExchangeError(
`XAA: token exchange response missing access_token: ${redactTokens(result)}`,
`XAA: token exchange response missing access_token: ${summarizeXaaPayload(
redactTokens(result),
)}`,
true,
)
}
@@ -373,7 +387,7 @@ export async function exchangeJwtAuthGrant(opts: {
body: params,
})
if (!res.ok) {
const body = redactTokens(await res.text()).slice(0, 200)
const body = summarizeXaaPayload(redactTokens(await res.text()))
throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`)
}
let rawTokens: unknown
@@ -381,13 +395,15 @@ export async function exchangeJwtAuthGrant(opts: {
rawTokens = await res.json()
} catch {
throw new Error(
`XAA: jwt-bearer grant returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`,
'XAA: jwt-bearer grant returned non-JSON response (captive portal?)',
)
}
const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens)
if (!tokensParsed.success) {
throw new Error(
`XAA: jwt-bearer response did not match expected shape: ${redactTokens(rawTokens)}`,
`XAA: jwt-bearer response did not match expected shape: ${summarizeXaaPayload(
redactTokens(rawTokens),
)}`,
)
}
return tokensParsed.data
@@ -431,11 +447,14 @@ export async function performCrossAppAccess(
): Promise<XaaResult> {
const fetchFn = makeXaaFetch(abortSignal)
logMCPDebug(serverName, `XAA: discovering PRM for ${serverUrl}`)
logMCPDebug(serverName, 'XAA: discovering protected resource metadata')
const prm = await discoverProtectedResource(serverUrl, { fetchFn })
logMCPDebug(
serverName,
`XAA: discovered resource=${prm.resource} ASes=[${prm.authorization_servers.join(', ')}]`,
`XAA: discovered protected resource metadata ${jsonStringify({
hasResource: Boolean(prm.resource),
authorizationServerCount: prm.authorization_servers.length,
})}`,
)
// Try each advertised AS in order. grant_types_supported is OPTIONAL per
@@ -449,16 +468,16 @@ export async function performCrossAppAccess(
candidate = await discoverAuthorizationServer(asUrl, { fetchFn })
} catch (e) {
if (abortSignal?.aborted) throw e
asErrors.push(`${asUrl}: ${e instanceof Error ? e.message : String(e)}`)
asErrors.push(
`authorization server discovery failed (${e instanceof Error ? e.name : typeof e})`,
)
continue
}
if (
candidate.grant_types_supported &&
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
) {
asErrors.push(
`${asUrl}: does not advertise jwt-bearer grant (supported: ${candidate.grant_types_supported.join(', ')})`,
)
asErrors.push('authorization server does not advertise jwt-bearer grant')
continue
}
asMeta = candidate
@@ -466,7 +485,7 @@ export async function performCrossAppAccess(
}
if (!asMeta) {
throw new Error(
`XAA: no authorization server supports jwt-bearer. Tried: ${asErrors.join('; ')}`,
`XAA: no authorization server supports jwt-bearer (${asErrors.length} candidates tried)`,
)
}
// Pick auth method from what the AS advertises. We handle
@@ -481,7 +500,7 @@ export async function performCrossAppAccess(
: 'client_secret_basic'
logMCPDebug(
serverName,
`XAA: AS issuer=${asMeta.issuer} token_endpoint=${asMeta.token_endpoint} auth_method=${authMethod}`,
`XAA: selected authorization server (auth_method=${authMethod})`,
)
logMCPDebug(serverName, `XAA: exchanging id_token for ID-JAG at IdP`)

View File

@@ -210,9 +210,7 @@ export async function discoverOidc(
signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS),
})
if (!res.ok) {
throw new Error(
`XAA IdP: OIDC discovery failed: HTTP ${res.status} at ${url}`,
)
throw new Error(`XAA IdP: OIDC discovery failed (HTTP ${res.status})`)
}
// Captive portals and proxy auth pages return 200 with HTML. res.json()
// throws a raw SyntaxError before safeParse can give a useful message.
@@ -221,17 +219,15 @@ export async function discoverOidc(
body = await res.json()
} catch {
throw new Error(
`XAA IdP: OIDC discovery returned non-JSON at ${url} (captive portal or proxy?)`,
'XAA IdP: OIDC discovery returned non-JSON response (captive portal or proxy?)',
)
}
const parsed = OpenIdProviderDiscoveryMetadataSchema.safeParse(body)
if (!parsed.success) {
throw new Error(`XAA IdP: invalid OIDC metadata: ${parsed.error.message}`)
throw new Error('XAA IdP: invalid OIDC metadata')
}
if (new URL(parsed.data.token_endpoint).protocol !== 'https:') {
throw new Error(
`XAA IdP: refusing non-HTTPS token endpoint: ${parsed.data.token_endpoint}`,
)
throw new Error('XAA IdP: refusing non-HTTPS token endpoint')
}
return parsed.data
}
@@ -373,7 +369,7 @@ function waitForCallback(
),
)
} else {
rejectOnce(new Error(`XAA IdP: callback server failed: ${err.message}`))
rejectOnce(new Error('XAA IdP: callback server failed'))
}
})
@@ -405,11 +401,11 @@ export async function acquireIdpIdToken(
const cached = getCachedIdpIdToken(idpIssuer)
if (cached) {
logMCPDebug('xaa', `Using cached id_token for ${idpIssuer}`)
logMCPDebug('xaa', 'Using cached id_token for configured IdP')
return cached
}
logMCPDebug('xaa', `No cached id_token for ${idpIssuer}; starting OIDC login`)
logMCPDebug('xaa', 'No cached id_token for configured IdP; starting OIDC login')
const metadata = await discoverOidc(idpIssuer)
const port = opts.callbackPort ?? (await findAvailablePort())
@@ -478,10 +474,7 @@ export async function acquireIdpIdToken(
: Date.now() + (tokens.expires_in ?? 3600) * 1000
saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt)
logMCPDebug(
'xaa',
`Cached id_token for ${idpIssuer} (expires ${new Date(expiresAt).toISOString()})`,
)
logMCPDebug('xaa', 'Cached id_token for configured IdP')
return tokens.id_token
}

View File

@@ -57,6 +57,47 @@ function debug(msg: string): void {
logForDebugging(`[brief:upload] ${msg}`)
}
function summarizeUploadError(error: unknown): string {
const summary: Record<string, boolean | number | string> = {}
if (error instanceof Error) {
summary.errorType = error.constructor.name
summary.errorName = error.name
summary.hasMessage = error.message.length > 0
} else {
summary.errorType = typeof error
summary.hasValue = error !== undefined && error !== null
}
if (axios.isAxiosError(error)) {
summary.errorType = 'AxiosError'
if (error.code) {
summary.axiosCode = error.code
}
if (typeof error.response?.status === 'number') {
summary.httpStatus = error.response.status
}
summary.hasResponseData = error.response?.data !== undefined
}
return jsonStringify(summary)
}
function summarizeUploadResponse(data: unknown): string {
if (data === undefined) return 'undefined'
if (data === null) return 'null'
if (Array.isArray(data)) return `array(${data.length})`
if (typeof data === 'object') {
return jsonStringify({
responseType: 'object',
keys: Object.keys(data as Record<string, unknown>)
.sort()
.slice(0, 10),
})
}
return typeof data
}
/**
* Base URL for uploads. Must match the host the token is valid for.
*
@@ -100,7 +141,9 @@ export async function uploadBriefAttachment(
if (!ctx.replBridgeEnabled) return undefined
if (size > MAX_UPLOAD_BYTES) {
debug(`skip ${fullPath}: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`)
debug(
`skip attachment upload: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`,
)
return undefined
}
@@ -114,7 +157,7 @@ export async function uploadBriefAttachment(
try {
content = await readFile(fullPath)
} catch (e) {
debug(`read failed for ${fullPath}: ${e}`)
debug(`read failed before upload: ${summarizeUploadError(e)}`)
return undefined
}
@@ -150,23 +193,23 @@ export async function uploadBriefAttachment(
if (response.status !== 201) {
debug(
`upload failed for ${fullPath}: status=${response.status} body=${jsonStringify(response.data).slice(0, 200)}`,
`upload failed: status=${response.status} response=${summarizeUploadResponse(
response.data,
)}`,
)
return undefined
}
const parsed = uploadResponseSchema().safeParse(response.data)
if (!parsed.success) {
debug(
`unexpected response shape for ${fullPath}: ${parsed.error.message}`,
)
debug('unexpected upload response shape')
return undefined
}
debug(`uploaded ${fullPath}${parsed.data.file_uuid} (${size} bytes)`)
debug(`uploaded attachment (${size} bytes)`)
return parsed.data.file_uuid
} catch (e) {
debug(`upload threw for ${fullPath}: ${e}`)
debug(`upload threw: ${summarizeUploadError(e)}`)
return undefined
}
}

View File

@@ -15,7 +15,6 @@ import type {
ScopedMcpServerConfig,
} from '../../services/mcp/types.js'
import type { Tool } from '../../Tool.js'
import { errorMessage } from '../../utils/errors.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { logMCPDebug, logMCPError } from '../../utils/log.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
@@ -29,9 +28,11 @@ export type McpAuthOutput = {
authUrl?: string
}
function getConfigUrl(config: ScopedMcpServerConfig): string | undefined {
if ('url' in config) return config.url
return undefined
function summarizeMcpAuthToolError(error: unknown): string {
if (error instanceof Error) {
return `${error.name} (hasMessage=${error.message.length > 0})`
}
return `non-Error (${typeof error})`
}
/**
@@ -50,12 +51,10 @@ export function createMcpAuthTool(
serverName: string,
config: ScopedMcpServerConfig,
): Tool<InputSchema, McpAuthOutput> {
const url = getConfigUrl(config)
const transport = config.type ?? 'stdio'
const location = url ? `${transport} at ${url}` : transport
const description =
`The \`${serverName}\` MCP server (${location}) is installed but requires authentication. ` +
`The \`${serverName}\` MCP server (${transport}) is installed but requires authentication. ` +
`Call this tool to start the OAuth flow — you'll receive an authorization URL to share with the user. ` +
`Once the user completes authorization in their browser, the server's real tools will become available automatically.`
@@ -167,7 +166,9 @@ export function createMcpAuthTool(
.catch(err => {
logMCPError(
serverName,
`OAuth flow failed after tool-triggered start: ${errorMessage(err)}`,
`OAuth flow failed after tool-triggered start: ${summarizeMcpAuthToolError(
err,
)}`,
)
})
@@ -199,7 +200,7 @@ export function createMcpAuthTool(
return {
data: {
status: 'error' as const,
message: `Failed to start OAuth flow for ${serverName}: ${errorMessage(err)}. Ask the user to run /mcp and authenticate manually.`,
message: `Failed to start OAuth flow for ${serverName}. Ask the user to run /mcp and authenticate manually.`,
},
}
}