/** * Files API client for managing files * * This module provides functionality to download and upload files to Anthropic Public Files API. * Used by the Claude Code agent to download file attachments at session startup. * * API Reference: https://docs.anthropic.com/en/api/files-content */ import axios from 'axios' import { randomUUID } from 'crypto' import * as fs from 'fs/promises' 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, 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, } from '../analytics/index.js' // Files API is currently in beta. oauth-2025-04-20 enables Bearer OAuth // on public-api routes (auth.py: "oauth_auth" not in beta_versions → 404). const FILES_API_BETA_HEADER = 'files-api-2025-04-14,oauth-2025-04-20' const ANTHROPIC_VERSION = '2023-06-01' // API base URL - uses ANTHROPIC_BASE_URL set by env-manager for the appropriate environment // Falls back to public API for standalone usage function getDefaultApiBaseUrl(): string { return ( process.env.ANTHROPIC_BASE_URL || process.env.CLAUDE_CODE_API_BASE_URL || 'https://api.anthropic.com' ) } function logDebugError(message: string): void { logForDebugging(`[files-api] ${message}`, { level: 'error' }) } function logDebug(message: string): void { logForDebugging(`[files-api] ${message}`) } function summarizeFilesApiError(error: unknown): string { const summary: Record = {} 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=: */ export type File = { fileId: string relativePath: string } /** * Configuration for the files API client */ export type FilesApiConfig = { /** OAuth token for authentication (from session JWT) */ oauthToken: string /** Base URL for the API (default: https://api.anthropic.com) */ baseUrl?: string /** Session ID for creating session-specific directories */ sessionId: string } /** * Result of a file download operation */ export type DownloadResult = { fileId: string path: string success: boolean error?: string bytesWritten?: number } const MAX_RETRIES = 3 const BASE_DELAY_MS = 500 const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 // 500MB /** * Result type for retry operations - signals whether to continue retrying */ type RetryResult = { done: true; value: T } | { done: false; error?: string } /** * Executes an operation with exponential backoff retry logic * * @param operation - Operation name for logging * @param attemptFn - Function to execute on each attempt, returns RetryResult * @returns The successful result value * @throws Error if all retries exhausted */ async function retryWithBackoff( operation: string, attemptFn: (attempt: number) => Promise>, ): Promise { let lastError = '' for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { const result = await attemptFn(attempt) if (result.done) { return result.value } lastError = result.error || `${operation} failed` logDebug(`${operation} attempt ${attempt}/${MAX_RETRIES} failed`) if (attempt < MAX_RETRIES) { const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1) logDebug(`Retrying ${operation} in ${delayMs}ms...`) await sleep(delayMs) } } throw new Error(`${lastError} after ${MAX_RETRIES} attempts`) } /** * Downloads a single file from the Anthropic Public Files API * * @param fileId - The file ID (e.g., "file_011CNha8iCJcU1wXNR6q4V8w") * @param config - Files API configuration * @returns The file content as a Buffer */ export async function downloadFile( fileId: string, config: FilesApiConfig, ): Promise { const baseUrl = config.baseUrl || getDefaultApiBaseUrl() const url = `${baseUrl}/v1/files/${fileId}/content` const headers = { Authorization: `Bearer ${config.oauthToken}`, 'anthropic-version': ANTHROPIC_VERSION, 'anthropic-beta': FILES_API_BETA_HEADER, } logDebug(`Downloading file ${fileId} from configured Files API endpoint`) return retryWithBackoff(`Download file ${fileId}`, async () => { try { const response = await axios.get(url, { headers, responseType: 'arraybuffer', timeout: 60000, // 60 second timeout for large files validateStatus: status => status < 500, }) if (response.status === 200) { logDebug(`Downloaded file ${fileId} (${response.data.length} bytes)`) return { done: true, value: Buffer.from(response.data) } } // Non-retriable errors - throw immediately if (response.status === 404) { throw new Error(`File not found: ${fileId}`) } if (response.status === 401) { throw new Error('Authentication failed: invalid or missing API key') } if (response.status === 403) { throw new Error(`Access denied to file: ${fileId}`) } return { done: false, error: `status ${response.status}` } } catch (error) { if (!axios.isAxiosError(error)) { throw error } return { done: false, error: error.message } } }) } /** * Normalizes a relative path, strips redundant prefixes, and builds the full * download path under {basePath}/{session_id}/uploads/. * Returns null if the path is invalid (e.g., path traversal). */ export function buildDownloadPath( basePath: string, sessionId: string, relativePath: string, ): string | null { const normalized = path.normalize(relativePath) if (normalized.startsWith('..')) { logDebugError('Invalid file path rejected: path traversal is not allowed') return null } const uploadsBase = path.join(basePath, sessionId, 'uploads') const redundantPrefixes = [ path.join(basePath, sessionId, 'uploads') + path.sep, path.sep + 'uploads' + path.sep, ] const matchedPrefix = redundantPrefixes.find(p => normalized.startsWith(p)) const cleanPath = matchedPrefix ? normalized.slice(matchedPrefix.length) : normalized return path.join(uploadsBase, cleanPath) } /** * Downloads a file and saves it to the session-specific workspace directory * * @param attachment - The file attachment to download * @param config - Files API configuration * @returns Download result with success/failure status */ export async function downloadAndSaveFile( attachment: File, config: FilesApiConfig, ): Promise { const { fileId, relativePath } = attachment const fullPath = buildDownloadPath(getCwd(), config.sessionId, relativePath) if (!fullPath) { return { fileId, path: '', success: false, error: `Invalid file path: ${relativePath}`, } } try { // Download the file content const content = await downloadFile(fileId, config) // Ensure the parent directory exists const parentDir = path.dirname(fullPath) await fs.mkdir(parentDir, { recursive: true }) // Write the file await fs.writeFile(fullPath, content) logDebug(`Saved file ${fileId} (${content.length} bytes)`) return { fileId, path: fullPath, success: true, bytesWritten: content.length, } } catch (error) { logDebugError( `Failed to download file ${fileId}: ${summarizeFilesApiError(error)}`, ) logError( new Error( `Files API download failed for ${fileId}: ${summarizeFilesApiError( error, )}`, ), ) return { fileId, path: fullPath, success: false, error: errorMessage(error), } } } // Default concurrency limit for parallel downloads const DEFAULT_CONCURRENCY = 5 /** * Execute promises with limited concurrency * * @param items - Items to process * @param fn - Async function to apply to each item * @param concurrency - Maximum concurrent operations * @returns Results in the same order as input items */ async function parallelWithLimit( items: T[], fn: (item: T, index: number) => Promise, concurrency: number, ): Promise { const results: R[] = new Array(items.length) let currentIndex = 0 async function worker(): Promise { while (currentIndex < items.length) { const index = currentIndex++ const item = items[index] if (item !== undefined) { results[index] = await fn(item, index) } } } // Start workers up to the concurrency limit const workers: Promise[] = [] const workerCount = Math.min(concurrency, items.length) for (let i = 0; i < workerCount; i++) { workers.push(worker()) } await Promise.all(workers) return results } /** * Downloads all file attachments for a session in parallel * * @param attachments - List of file attachments to download * @param config - Files API configuration * @param concurrency - Maximum concurrent downloads (default: 5) * @returns Array of download results in the same order as input */ export async function downloadSessionFiles( files: File[], config: FilesApiConfig, concurrency: number = DEFAULT_CONCURRENCY, ): Promise { if (files.length === 0) { return [] } logDebug( `Downloading ${files.length} file(s) for session ${config.sessionId}`, ) const startTime = Date.now() // Download files in parallel with concurrency limit const results = await parallelWithLimit( files, file => downloadAndSaveFile(file, config), concurrency, ) const elapsedMs = Date.now() - startTime const successCount = count(results, r => r.success) logDebug( `Downloaded ${successCount}/${files.length} file(s) in ${elapsedMs}ms`, ) return results } // ============================================================================ // Upload Functions (BYOC mode) // ============================================================================ /** * Result of a file upload operation */ export type UploadResult = | { path: string fileId: string size: number success: true } | { path: string error: string success: false } /** * Upload a single file to the Files API (BYOC mode) * * Size validation is performed after reading the file to avoid TOCTOU race * conditions where the file size could change between initial check and upload. * * @param filePath - Absolute path to the file to upload * @param relativePath - Relative path for the file (used as filename in API) * @param config - Files API configuration * @returns Upload result with success/failure status */ export async function uploadFile( filePath: string, relativePath: string, config: FilesApiConfig, opts?: { signal?: AbortSignal }, ): Promise { const baseUrl = config.baseUrl || getDefaultApiBaseUrl() const url = `${baseUrl}/v1/files` const headers = { Authorization: `Bearer ${config.oauthToken}`, 'anthropic-version': ANTHROPIC_VERSION, 'anthropic-beta': FILES_API_BETA_HEADER, } 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 try { content = await fs.readFile(filePath) } catch (error) { logEvent('tengu_file_upload_failed', { error_type: 'file_read' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { path: relativePath, error: errorMessage(error), success: false, } } const fileSize = content.length if (fileSize > MAX_FILE_SIZE_BYTES) { logEvent('tengu_file_upload_failed', { error_type: 'file_too_large' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { path: relativePath, error: `File exceeds maximum size of ${MAX_FILE_SIZE_BYTES} bytes (actual: ${fileSize})`, success: false, } } // Use crypto.randomUUID for boundary to avoid collisions when uploads start same millisecond const boundary = `----FormBoundary${randomUUID()}` const filename = path.basename(relativePath) // Build the multipart body const bodyParts: Buffer[] = [] // File part bodyParts.push( Buffer.from( `--${boundary}\r\n` + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + `Content-Type: application/octet-stream\r\n\r\n`, ), ) bodyParts.push(content) bodyParts.push(Buffer.from('\r\n')) // Purpose part bodyParts.push( Buffer.from( `--${boundary}\r\n` + `Content-Disposition: form-data; name="purpose"\r\n\r\n` + `user_data\r\n`, ), ) // End boundary bodyParts.push(Buffer.from(`--${boundary}--\r\n`)) const body = Buffer.concat(bodyParts) try { return await retryWithBackoff('Upload session file', async () => { try { const response = await axios.post(url, body, { headers: { ...headers, 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length.toString(), }, timeout: 120000, // 2 minute timeout for uploads signal: opts?.signal, validateStatus: status => status < 500, }) if (response.status === 200 || response.status === 201) { const fileId = response.data?.id if (!fileId) { return { done: false, error: 'Upload succeeded but no file ID returned', } } logDebug(`Uploaded file (${fileSize} bytes)`) return { done: true, value: { path: relativePath, fileId, size: fileSize, success: true as const, }, } } // Non-retriable errors - throw to exit retry loop if (response.status === 401) { logEvent('tengu_file_upload_failed', { error_type: 'auth' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) throw new UploadNonRetriableError( 'Authentication failed: invalid or missing API key', ) } if (response.status === 403) { logEvent('tengu_file_upload_failed', { error_type: 'forbidden' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) throw new UploadNonRetriableError('Access denied for upload') } if (response.status === 413) { logEvent('tengu_file_upload_failed', { error_type: 'size' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) throw new UploadNonRetriableError('File too large for upload') } return { done: false, error: `status ${response.status}` } } catch (error) { // Non-retriable errors propagate up if (error instanceof UploadNonRetriableError) { throw error } if (axios.isCancel(error)) { throw new UploadNonRetriableError('Upload canceled') } // Network errors are retriable if (axios.isAxiosError(error)) { return { done: false, error: error.message } } throw error } }) } catch (error) { if (error instanceof UploadNonRetriableError) { return { path: relativePath, error: error.message, success: false, } } logEvent('tengu_file_upload_failed', { error_type: 'network' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { path: relativePath, error: errorMessage(error), success: false, } } } /** Error class for non-retriable upload failures */ class UploadNonRetriableError extends Error { constructor(message: string) { super(message) this.name = 'UploadNonRetriableError' } } /** * Upload multiple files in parallel with concurrency limit (BYOC mode) * * @param files - Array of files to upload (path and relativePath) * @param config - Files API configuration * @param concurrency - Maximum concurrent uploads (default: 5) * @returns Array of upload results in the same order as input */ export async function uploadSessionFiles( files: Array<{ path: string; relativePath: string }>, config: FilesApiConfig, concurrency: number = DEFAULT_CONCURRENCY, ): Promise { if (files.length === 0) { return [] } logDebug(`Uploading ${files.length} file(s) for session ${config.sessionId}`) const startTime = Date.now() const results = await parallelWithLimit( files, file => uploadFile(file.path, file.relativePath, config), concurrency, ) const elapsedMs = Date.now() - startTime const successCount = count(results, r => r.success) logDebug(`Uploaded ${successCount}/${files.length} file(s) in ${elapsedMs}ms`) return results } // ============================================================================ // List Files Functions (1P/Cloud mode) // ============================================================================ /** * File metadata returned from listFilesCreatedAfter */ export type FileMetadata = { filename: string fileId: string size: number } /** * List files created after a given timestamp (1P/Cloud mode). * Uses the public GET /v1/files endpoint with after_created_at query param. * Handles pagination via after_id cursor when has_more is true. * * @param afterCreatedAt - ISO 8601 timestamp to filter files created after * @param config - Files API configuration * @returns Array of file metadata for files created after the timestamp */ export async function listFilesCreatedAfter( afterCreatedAt: string, config: FilesApiConfig, ): Promise { const baseUrl = config.baseUrl || getDefaultApiBaseUrl() const headers = { Authorization: `Bearer ${config.oauthToken}`, 'anthropic-version': ANTHROPIC_VERSION, 'anthropic-beta': FILES_API_BETA_HEADER, } logDebug(`Listing files created after ${afterCreatedAt}`) const allFiles: FileMetadata[] = [] let afterId: string | undefined // Paginate through results while (true) { const params: Record = { after_created_at: afterCreatedAt, } if (afterId) { params.after_id = afterId } const page = await retryWithBackoff( `List files after ${afterCreatedAt}`, async () => { try { const response = await axios.get(`${baseUrl}/v1/files`, { headers, params, timeout: 60000, validateStatus: status => status < 500, }) if (response.status === 200) { return { done: true, value: response.data } } if (response.status === 401) { logEvent('tengu_file_list_failed', { error_type: 'auth' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) throw new Error('Authentication failed: invalid or missing API key') } if (response.status === 403) { logEvent('tengu_file_list_failed', { error_type: 'forbidden' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) throw new Error('Access denied to list files') } return { done: false, error: `status ${response.status}` } } catch (error) { if (!axios.isAxiosError(error)) { throw error } logEvent('tengu_file_list_failed', { error_type: 'network' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) return { done: false, error: error.message } } }, ) const files = page.data || [] for (const f of files) { allFiles.push({ filename: f.filename, fileId: f.id, size: f.size_bytes, }) } if (!page.has_more) { break } // Use the last file's ID as cursor for next page const lastFile = files.at(-1) if (!lastFile?.id) { break } afterId = lastFile.id } logDebug(`Listed ${allFiles.length} files created after ${afterCreatedAt}`) return allFiles } // ============================================================================ // Parse Functions // ============================================================================ /** * Parse file attachment specs from CLI arguments * Format: : * * @param fileSpecs - Array of file spec strings * @returns Parsed file attachments */ export function parseFileSpecs(fileSpecs: string[]): File[] { const files: File[] = [] // Sandbox-gateway may pass multiple specs as a single space-separated string const expandedSpecs = fileSpecs.flatMap(s => s.split(' ').filter(Boolean)) for (const spec of expandedSpecs) { const colonIndex = spec.indexOf(':') if (colonIndex === -1) { continue } const fileId = spec.substring(0, colonIndex) const relativePath = spec.substring(colonIndex + 1) if (!fileId || !relativePath) { logDebugError('Invalid file spec: missing file_id or relative path') continue } files.push({ fileId, relativePath }) } return files }