Reduce remaining file, LSP, and XAA debug detail
This commit is contained in:
@@ -14,9 +14,10 @@ import * as path from 'path'
|
|||||||
import { count } from '../../utils/array.js'
|
import { count } from '../../utils/array.js'
|
||||||
import { getCwd } from '../../utils/cwd.js'
|
import { getCwd } from '../../utils/cwd.js'
|
||||||
import { logForDebugging } from '../../utils/debug.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 { logError } from '../../utils/log.js'
|
||||||
import { sleep } from '../../utils/sleep.js'
|
import { sleep } from '../../utils/sleep.js'
|
||||||
|
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||||
import {
|
import {
|
||||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
logEvent,
|
logEvent,
|
||||||
@@ -45,6 +46,37 @@ function logDebug(message: string): void {
|
|||||||
logForDebugging(`[files-api] ${message}`)
|
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
|
* File specification parsed from CLI args
|
||||||
* Format: --file=<file_id>:<relative_path>
|
* Format: --file=<file_id>:<relative_path>
|
||||||
@@ -108,9 +140,7 @@ async function retryWithBackoff<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastError = result.error || `${operation} failed`
|
lastError = result.error || `${operation} failed`
|
||||||
logDebug(
|
logDebug(`${operation} attempt ${attempt}/${MAX_RETRIES} failed`)
|
||||||
`${operation} attempt ${attempt}/${MAX_RETRIES} failed: ${lastError}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (attempt < MAX_RETRIES) {
|
if (attempt < MAX_RETRIES) {
|
||||||
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
|
const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1)
|
||||||
@@ -142,7 +172,7 @@ export async function downloadFile(
|
|||||||
'anthropic-beta': FILES_API_BETA_HEADER,
|
'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 () => {
|
return retryWithBackoff(`Download file ${fileId}`, async () => {
|
||||||
try {
|
try {
|
||||||
@@ -191,9 +221,7 @@ export function buildDownloadPath(
|
|||||||
): string | null {
|
): string | null {
|
||||||
const normalized = path.normalize(relativePath)
|
const normalized = path.normalize(relativePath)
|
||||||
if (normalized.startsWith('..')) {
|
if (normalized.startsWith('..')) {
|
||||||
logDebugError(
|
logDebugError('Invalid file path rejected: path traversal is not allowed')
|
||||||
`Invalid file path: ${relativePath}. Path must not traverse above workspace`,
|
|
||||||
)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +271,7 @@ export async function downloadAndSaveFile(
|
|||||||
// Write the file
|
// Write the file
|
||||||
await fs.writeFile(fullPath, content)
|
await fs.writeFile(fullPath, content)
|
||||||
|
|
||||||
logDebug(`Saved file ${fileId} to ${fullPath} (${content.length} bytes)`)
|
logDebug(`Saved file ${fileId} (${content.length} bytes)`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileId,
|
fileId,
|
||||||
@@ -252,10 +280,16 @@ export async function downloadAndSaveFile(
|
|||||||
bytesWritten: content.length,
|
bytesWritten: content.length,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logDebugError(`Failed to download file ${fileId}: ${errorMessage(error)}`)
|
logDebugError(
|
||||||
if (error instanceof Error) {
|
`Failed to download file ${fileId}: ${summarizeFilesApiError(error)}`,
|
||||||
logError(error)
|
)
|
||||||
}
|
logError(
|
||||||
|
new Error(
|
||||||
|
`Files API download failed for ${fileId}: ${summarizeFilesApiError(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileId,
|
fileId,
|
||||||
@@ -390,7 +424,7 @@ export async function uploadFile(
|
|||||||
'anthropic-beta': FILES_API_BETA_HEADER,
|
'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)
|
// Read file content first (outside retry loop since it's not a network operation)
|
||||||
let content: Buffer
|
let content: Buffer
|
||||||
@@ -455,7 +489,7 @@ export async function uploadFile(
|
|||||||
const body = Buffer.concat(bodyParts)
|
const body = Buffer.concat(bodyParts)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await retryWithBackoff(`Upload file ${relativePath}`, async () => {
|
return await retryWithBackoff('Upload session file', async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(url, body, {
|
const response = await axios.post(url, body, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -476,7 +510,7 @@ export async function uploadFile(
|
|||||||
error: 'Upload succeeded but no file ID returned',
|
error: 'Upload succeeded but no file ID returned',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logDebug(`Uploaded file ${filePath} -> ${fileId} (${fileSize} bytes)`)
|
logDebug(`Uploaded file (${fileSize} bytes)`)
|
||||||
return {
|
return {
|
||||||
done: true,
|
done: true,
|
||||||
value: {
|
value: {
|
||||||
@@ -735,9 +769,7 @@ export function parseFileSpecs(fileSpecs: string[]): File[] {
|
|||||||
const relativePath = spec.substring(colonIndex + 1)
|
const relativePath = spec.substring(colonIndex + 1)
|
||||||
|
|
||||||
if (!fileId || !relativePath) {
|
if (!fileId || !relativePath) {
|
||||||
logDebugError(
|
logDebugError('Invalid file spec: missing file_id or relative path')
|
||||||
`Invalid file spec: ${spec}. Both file_id and path are required`,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,34 @@ import type { DiagnosticFile } from '../diagnosticTracking.js'
|
|||||||
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
|
import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
|
||||||
import type { LSPServerManager } from './LSPServerManager.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
|
* Map LSP severity to Claude diagnostic severity
|
||||||
*
|
*
|
||||||
@@ -54,7 +82,9 @@ export function formatDiagnosticsForAttachment(
|
|||||||
const err = toError(error)
|
const err = toError(error)
|
||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
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
|
// Gracefully fallback to original URI - LSP servers may send malformed URIs
|
||||||
uri = params.uri
|
uri = params.uri
|
||||||
@@ -177,14 +207,16 @@ export function registerLSPNotificationHandlers(
|
|||||||
)
|
)
|
||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`,
|
`Invalid diagnostic params from ${serverName}: ${summarizeDiagnosticParamsForDebug(
|
||||||
|
params,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const diagnosticParams = params as PublishDiagnosticsParams
|
const diagnosticParams = params as PublishDiagnosticsParams
|
||||||
logForDebugging(
|
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)
|
// Convert LSP diagnostics to Claude format (can throw on invalid URIs)
|
||||||
@@ -199,7 +231,7 @@ export function registerLSPNotificationHandlers(
|
|||||||
firstFile.diagnostics.length === 0
|
firstFile.diagnostics.length === 0
|
||||||
) {
|
) {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`,
|
`Skipping empty diagnostics from ${serverName}`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -223,9 +255,8 @@ export function registerLSPNotificationHandlers(
|
|||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Error registering LSP diagnostics from ${serverName}: ` +
|
`Error registering LSP diagnostics from ${serverName}: ` +
|
||||||
`URI: ${diagnosticParams.uri}, ` +
|
|
||||||
`Diagnostic count: ${firstFile.diagnostics.length}, ` +
|
`Diagnostic count: ${firstFile.diagnostics.length}, ` +
|
||||||
`Error: ${err.message}`,
|
`Error: ${summarizeLspErrorForDebug(err)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track consecutive failures and warn after 3+
|
// Track consecutive failures and warn after 3+
|
||||||
@@ -234,7 +265,7 @@ export function registerLSPNotificationHandlers(
|
|||||||
lastError: '',
|
lastError: '',
|
||||||
}
|
}
|
||||||
failures.count++
|
failures.count++
|
||||||
failures.lastError = err.message
|
failures.lastError = summarizeLspErrorForDebug(err)
|
||||||
diagnosticFailures.set(serverName, failures)
|
diagnosticFailures.set(serverName, failures)
|
||||||
|
|
||||||
if (failures.count >= 3) {
|
if (failures.count >= 3) {
|
||||||
@@ -251,7 +282,9 @@ export function registerLSPNotificationHandlers(
|
|||||||
const err = toError(error)
|
const err = toError(error)
|
||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Unexpected error processing diagnostics from ${serverName}: ${err.message}`,
|
`Unexpected error processing diagnostics from ${serverName}: ${summarizeLspErrorForDebug(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track consecutive failures and warn after 3+
|
// Track consecutive failures and warn after 3+
|
||||||
@@ -260,7 +293,7 @@ export function registerLSPNotificationHandlers(
|
|||||||
lastError: '',
|
lastError: '',
|
||||||
}
|
}
|
||||||
failures.count++
|
failures.count++
|
||||||
failures.lastError = err.message
|
failures.lastError = summarizeLspErrorForDebug(err)
|
||||||
diagnosticFailures.set(serverName, failures)
|
diagnosticFailures.set(serverName, failures)
|
||||||
|
|
||||||
if (failures.count >= 3) {
|
if (failures.count >= 3) {
|
||||||
@@ -284,13 +317,13 @@ export function registerLSPNotificationHandlers(
|
|||||||
|
|
||||||
registrationErrors.push({
|
registrationErrors.push({
|
||||||
serverName,
|
serverName,
|
||||||
error: err.message,
|
error: summarizeLspErrorForDebug(err),
|
||||||
})
|
})
|
||||||
|
|
||||||
logError(err)
|
logError(err)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Failed to register diagnostics handler for ${serverName}: ` +
|
`Failed to register diagnostics handler for ${serverName}: ` +
|
||||||
`Error: ${err.message}`,
|
`Error: ${summarizeLspErrorForDebug(err)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,24 @@ function redactTokens(raw: unknown): string {
|
|||||||
return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`)
|
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 ────────────────────────────────────────────────────────────
|
// ─── Zod Schemas ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TokenExchangeResponseSchema = lazySchema(() =>
|
const TokenExchangeResponseSchema = lazySchema(() =>
|
||||||
@@ -145,7 +163,7 @@ export async function discoverProtectedResource(
|
|||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
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]) {
|
if (!prm.resource || !prm.authorization_servers?.[0]) {
|
||||||
@@ -154,9 +172,7 @@ export async function discoverProtectedResource(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
|
if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) {
|
||||||
throw new Error(
|
throw new Error('XAA: PRM discovery failed: PRM resource mismatch')
|
||||||
`XAA: PRM discovery failed: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
resource: prm.resource,
|
resource: prm.resource,
|
||||||
@@ -183,22 +199,16 @@ export async function discoverAuthorizationServer(
|
|||||||
fetchFn: opts?.fetchFn ?? defaultFetch,
|
fetchFn: opts?.fetchFn ?? defaultFetch,
|
||||||
})
|
})
|
||||||
if (!meta?.issuer || !meta.token_endpoint) {
|
if (!meta?.issuer || !meta.token_endpoint) {
|
||||||
throw new Error(
|
throw new Error('XAA: AS metadata discovery failed: no valid metadata')
|
||||||
`XAA: AS metadata discovery failed: no valid metadata at ${asUrl}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
|
if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) {
|
||||||
throw new Error(
|
throw new Error('XAA: AS metadata discovery failed: issuer mismatch')
|
||||||
`XAA: AS metadata discovery failed: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS
|
// 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
|
// that self-consistently reports an http:// issuer would pass the mismatch
|
||||||
// check above, then we'd POST id_token + client_secret over plaintext.
|
// check above, then we'd POST id_token + client_secret over plaintext.
|
||||||
if (new URL(meta.token_endpoint).protocol !== 'https:') {
|
if (new URL(meta.token_endpoint).protocol !== 'https:') {
|
||||||
throw new Error(
|
throw new Error('XAA: refusing non-HTTPS token endpoint')
|
||||||
`XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
issuer: meta.issuer,
|
issuer: meta.issuer,
|
||||||
@@ -263,7 +273,7 @@ export async function requestJwtAuthorizationGrant(opts: {
|
|||||||
body: params,
|
body: params,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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.
|
// 4xx → id_token rejected (invalid_grant etc.), clear cache.
|
||||||
// 5xx → IdP outage, id_token may still be valid, preserve it.
|
// 5xx → IdP outage, id_token may still be valid, preserve it.
|
||||||
const shouldClear = res.status < 500
|
const shouldClear = res.status < 500
|
||||||
@@ -278,21 +288,25 @@ export async function requestJwtAuthorizationGrant(opts: {
|
|||||||
} catch {
|
} catch {
|
||||||
// Transient network condition (captive portal, proxy) — don't clear id_token.
|
// Transient network condition (captive portal, proxy) — don't clear id_token.
|
||||||
throw new XaaTokenExchangeError(
|
throw new XaaTokenExchangeError(
|
||||||
`XAA: token exchange returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`,
|
'XAA: token exchange returned non-JSON response (captive portal?)',
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
|
const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange)
|
||||||
if (!exchangeParsed.success) {
|
if (!exchangeParsed.success) {
|
||||||
throw new XaaTokenExchangeError(
|
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,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const result = exchangeParsed.data
|
const result = exchangeParsed.data
|
||||||
if (!result.access_token) {
|
if (!result.access_token) {
|
||||||
throw new XaaTokenExchangeError(
|
throw new XaaTokenExchangeError(
|
||||||
`XAA: token exchange response missing access_token: ${redactTokens(result)}`,
|
`XAA: token exchange response missing access_token: ${summarizeXaaPayload(
|
||||||
|
redactTokens(result),
|
||||||
|
)}`,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -373,7 +387,7 @@ export async function exchangeJwtAuthGrant(opts: {
|
|||||||
body: params,
|
body: params,
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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}`)
|
throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`)
|
||||||
}
|
}
|
||||||
let rawTokens: unknown
|
let rawTokens: unknown
|
||||||
@@ -381,13 +395,15 @@ export async function exchangeJwtAuthGrant(opts: {
|
|||||||
rawTokens = await res.json()
|
rawTokens = await res.json()
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
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)
|
const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens)
|
||||||
if (!tokensParsed.success) {
|
if (!tokensParsed.success) {
|
||||||
throw new Error(
|
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
|
return tokensParsed.data
|
||||||
@@ -431,11 +447,14 @@ export async function performCrossAppAccess(
|
|||||||
): Promise<XaaResult> {
|
): Promise<XaaResult> {
|
||||||
const fetchFn = makeXaaFetch(abortSignal)
|
const fetchFn = makeXaaFetch(abortSignal)
|
||||||
|
|
||||||
logMCPDebug(serverName, `XAA: discovering PRM for ${serverUrl}`)
|
logMCPDebug(serverName, 'XAA: discovering protected resource metadata')
|
||||||
const prm = await discoverProtectedResource(serverUrl, { fetchFn })
|
const prm = await discoverProtectedResource(serverUrl, { fetchFn })
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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
|
// 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 })
|
candidate = await discoverAuthorizationServer(asUrl, { fetchFn })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (abortSignal?.aborted) throw 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
|
continue
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
candidate.grant_types_supported &&
|
candidate.grant_types_supported &&
|
||||||
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
|
!candidate.grant_types_supported.includes(JWT_BEARER_GRANT)
|
||||||
) {
|
) {
|
||||||
asErrors.push(
|
asErrors.push('authorization server does not advertise jwt-bearer grant')
|
||||||
`${asUrl}: does not advertise jwt-bearer grant (supported: ${candidate.grant_types_supported.join(', ')})`,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
asMeta = candidate
|
asMeta = candidate
|
||||||
@@ -466,7 +485,7 @@ export async function performCrossAppAccess(
|
|||||||
}
|
}
|
||||||
if (!asMeta) {
|
if (!asMeta) {
|
||||||
throw new Error(
|
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
|
// Pick auth method from what the AS advertises. We handle
|
||||||
@@ -481,7 +500,7 @@ export async function performCrossAppAccess(
|
|||||||
: 'client_secret_basic'
|
: 'client_secret_basic'
|
||||||
logMCPDebug(
|
logMCPDebug(
|
||||||
serverName,
|
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`)
|
logMCPDebug(serverName, `XAA: exchanging id_token for ID-JAG at IdP`)
|
||||||
|
|||||||
@@ -210,9 +210,7 @@ export async function discoverOidc(
|
|||||||
signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(`XAA IdP: OIDC discovery failed (HTTP ${res.status})`)
|
||||||
`XAA IdP: OIDC discovery failed: HTTP ${res.status} at ${url}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
// Captive portals and proxy auth pages return 200 with HTML. res.json()
|
// Captive portals and proxy auth pages return 200 with HTML. res.json()
|
||||||
// throws a raw SyntaxError before safeParse can give a useful message.
|
// throws a raw SyntaxError before safeParse can give a useful message.
|
||||||
@@ -221,17 +219,15 @@ export async function discoverOidc(
|
|||||||
body = await res.json()
|
body = await res.json()
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
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)
|
const parsed = OpenIdProviderDiscoveryMetadataSchema.safeParse(body)
|
||||||
if (!parsed.success) {
|
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:') {
|
if (new URL(parsed.data.token_endpoint).protocol !== 'https:') {
|
||||||
throw new Error(
|
throw new Error('XAA IdP: refusing non-HTTPS token endpoint')
|
||||||
`XAA IdP: refusing non-HTTPS token endpoint: ${parsed.data.token_endpoint}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return parsed.data
|
return parsed.data
|
||||||
}
|
}
|
||||||
@@ -373,7 +369,7 @@ function waitForCallback(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} 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)
|
const cached = getCachedIdpIdToken(idpIssuer)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
logMCPDebug('xaa', `Using cached id_token for ${idpIssuer}`)
|
logMCPDebug('xaa', 'Using cached id_token for configured IdP')
|
||||||
return cached
|
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 metadata = await discoverOidc(idpIssuer)
|
||||||
const port = opts.callbackPort ?? (await findAvailablePort())
|
const port = opts.callbackPort ?? (await findAvailablePort())
|
||||||
@@ -478,10 +474,7 @@ export async function acquireIdpIdToken(
|
|||||||
: Date.now() + (tokens.expires_in ?? 3600) * 1000
|
: Date.now() + (tokens.expires_in ?? 3600) * 1000
|
||||||
|
|
||||||
saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt)
|
saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt)
|
||||||
logMCPDebug(
|
logMCPDebug('xaa', 'Cached id_token for configured IdP')
|
||||||
'xaa',
|
|
||||||
`Cached id_token for ${idpIssuer} (expires ${new Date(expiresAt).toISOString()})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return tokens.id_token
|
return tokens.id_token
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,47 @@ function debug(msg: string): void {
|
|||||||
logForDebugging(`[brief:upload] ${msg}`)
|
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.
|
* 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 (!ctx.replBridgeEnabled) return undefined
|
||||||
|
|
||||||
if (size > MAX_UPLOAD_BYTES) {
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +157,7 @@ export async function uploadBriefAttachment(
|
|||||||
try {
|
try {
|
||||||
content = await readFile(fullPath)
|
content = await readFile(fullPath)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug(`read failed for ${fullPath}: ${e}`)
|
debug(`read failed before upload: ${summarizeUploadError(e)}`)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,23 +193,23 @@ export async function uploadBriefAttachment(
|
|||||||
|
|
||||||
if (response.status !== 201) {
|
if (response.status !== 201) {
|
||||||
debug(
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = uploadResponseSchema().safeParse(response.data)
|
const parsed = uploadResponseSchema().safeParse(response.data)
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
debug(
|
debug('unexpected upload response shape')
|
||||||
`unexpected response shape for ${fullPath}: ${parsed.error.message}`,
|
|
||||||
)
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(`uploaded ${fullPath} → ${parsed.data.file_uuid} (${size} bytes)`)
|
debug(`uploaded attachment (${size} bytes)`)
|
||||||
return parsed.data.file_uuid
|
return parsed.data.file_uuid
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug(`upload threw for ${fullPath}: ${e}`)
|
debug(`upload threw: ${summarizeUploadError(e)}`)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
ScopedMcpServerConfig,
|
ScopedMcpServerConfig,
|
||||||
} from '../../services/mcp/types.js'
|
} from '../../services/mcp/types.js'
|
||||||
import type { Tool } from '../../Tool.js'
|
import type { Tool } from '../../Tool.js'
|
||||||
import { errorMessage } from '../../utils/errors.js'
|
|
||||||
import { lazySchema } from '../../utils/lazySchema.js'
|
import { lazySchema } from '../../utils/lazySchema.js'
|
||||||
import { logMCPDebug, logMCPError } from '../../utils/log.js'
|
import { logMCPDebug, logMCPError } from '../../utils/log.js'
|
||||||
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
||||||
@@ -29,9 +28,11 @@ export type McpAuthOutput = {
|
|||||||
authUrl?: string
|
authUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigUrl(config: ScopedMcpServerConfig): string | undefined {
|
function summarizeMcpAuthToolError(error: unknown): string {
|
||||||
if ('url' in config) return config.url
|
if (error instanceof Error) {
|
||||||
return undefined
|
return `${error.name} (hasMessage=${error.message.length > 0})`
|
||||||
|
}
|
||||||
|
return `non-Error (${typeof error})`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,12 +51,10 @@ export function createMcpAuthTool(
|
|||||||
serverName: string,
|
serverName: string,
|
||||||
config: ScopedMcpServerConfig,
|
config: ScopedMcpServerConfig,
|
||||||
): Tool<InputSchema, McpAuthOutput> {
|
): Tool<InputSchema, McpAuthOutput> {
|
||||||
const url = getConfigUrl(config)
|
|
||||||
const transport = config.type ?? 'stdio'
|
const transport = config.type ?? 'stdio'
|
||||||
const location = url ? `${transport} at ${url}` : transport
|
|
||||||
|
|
||||||
const description =
|
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. ` +
|
`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.`
|
`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 => {
|
.catch(err => {
|
||||||
logMCPError(
|
logMCPError(
|
||||||
serverName,
|
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 {
|
return {
|
||||||
data: {
|
data: {
|
||||||
status: 'error' as const,
|
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.`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user