Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
21 kB · 748 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749/** * 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 } from '../../utils/errors.js'import { logError } from '../../utils/log.js'import { sleep } from '../../utils/sleep.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 usagefunction 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}`)}
/** * File specification parsed from CLI args * Format: --file=<file_id>:<relative_path> */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 = 3const BASE_DELAY_MS = 500const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 // 500MB
/** * Result type for retry operations - signals whether to continue retrying */type RetryResult<T> = { 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<T>( operation: string, attemptFn: (attempt: number) => Promise<RetryResult<T>>,): Promise<T> { 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: ${lastError}`, )
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<Buffer> { 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 ${url}`)
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: ${relativePath}. Path must not traverse above workspace`, ) 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<DownloadResult> { 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} to ${fullPath} (${content.length} bytes)`)
return { fileId, path: fullPath, success: true, bytesWritten: content.length, } } catch (error) { logDebugError(`Failed to download file ${fileId}: ${errorMessage(error)}`) if (error instanceof Error) { logError(error) }
return { fileId, path: fullPath, success: false, error: errorMessage(error), } }}
// Default concurrency limit for parallel downloadsconst 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<T, R>( items: T[], fn: (item: T, index: number) => Promise<R>, concurrency: number,): Promise<R[]> { const results: R[] = new Array(items.length) let currentIndex = 0
async function worker(): Promise<void> { 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<void>[] = [] 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<DownloadResult[]> { 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<UploadResult> { 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 ${filePath} as ${relativePath}`)
// 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 file ${relativePath}`, 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 ${filePath} -> ${fileId} (${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<UploadResult[]> { 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<FileMetadata[]> { 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<string, string> = { 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: <file_id>:<relative_path> * * @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: ${spec}. Both file_id and path are required`, ) continue }
files.push({ fileId, relativePath }) }
return files}