/** * Shared attachment validation + resolution for SendUserMessage and * SendUserFile. Lives in BriefTool/ so the dynamic `./upload.js` import * inside the feature('BRIDGE_MODE') guard stays relative and upload.ts * (axios, crypto, auth utils) remains tree-shakeable from non-bridge builds. */ import { feature } from 'bun:bundle' import { stat } from 'fs/promises' import type { ValidationResult } from '../../Tool.js' import { getCwd } from '../../utils/cwd.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { getErrnoCode } from '../../utils/errors.js' import { IMAGE_EXTENSION_REGEX } from '../../utils/imagePaste.js' import { expandPath } from '../../utils/path.js' export type ResolvedAttachment = { path: string size: number isImage: boolean file_uuid?: string } export async function validateAttachmentPaths( rawPaths: string[], ): Promise { const cwd = getCwd() for (const rawPath of rawPaths) { const fullPath = expandPath(rawPath) try { const stats = await stat(fullPath) if (!stats.isFile()) { return { result: false, message: `Attachment "${rawPath}" is not a regular file.`, errorCode: 1, } } } catch (e) { const code = getErrnoCode(e) if (code === 'ENOENT') { return { result: false, message: `Attachment "${rawPath}" does not exist. Current working directory: ${cwd}.`, errorCode: 1, } } if (code === 'EACCES' || code === 'EPERM') { return { result: false, message: `Attachment "${rawPath}" is not accessible (permission denied).`, errorCode: 1, } } throw e } } return { result: true } } export async function resolveAttachments( rawPaths: string[], uploadCtx: { replBridgeEnabled: boolean; signal?: AbortSignal }, ): Promise { // Stat serially (local, fast) to keep ordering deterministic, then upload // in parallel (network, slow). Upload failures resolve undefined — the // attachment still carries {path, size, isImage} for local renderers. const stated: ResolvedAttachment[] = [] for (const rawPath of rawPaths) { const fullPath = expandPath(rawPath) // Single stat — we need size, so this is the operation, not a guard. // validateInput ran before us, but the file could have moved since // (TOCTOU); if it did, let the error propagate so the model sees it. const stats = await stat(fullPath) stated.push({ path: fullPath, size: stats.size, isImage: IMAGE_EXTENSION_REGEX.test(fullPath), }) } // Dynamic import inside the feature() guard so upload.ts (axios, crypto, // zod, auth utils, MIME map) is fully eliminated from non-BRIDGE_MODE // builds. A static import would force module-scope evaluation regardless // of the guard inside uploadBriefAttachment — CLAUDE.md: "helpers defined // outside remain in the build even if never called". if (feature('BRIDGE_MODE')) { // Headless/SDK callers never set appState.replBridgeEnabled (only the TTY // REPL does, at main.tsx init). CLAUDE_CODE_BRIEF_UPLOAD lets a host that // runs the CLI as a subprocess opt in — e.g. the cowork desktop bridge, // which already passes CLAUDE_CODE_OAUTH_TOKEN for auth. const shouldUpload = uploadCtx.replBridgeEnabled || isEnvTruthy(process.env.CLAUDE_CODE_BRIEF_UPLOAD) const { uploadBriefAttachment } = await import('./upload.js') const uuids = await Promise.all( stated.map(a => uploadBriefAttachment(a.path, a.size, { replBridgeEnabled: shouldUpload, signal: uploadCtx.signal, }), ), ) return stated.map((a, i) => uuids[i] === undefined ? a : { ...a, file_uuid: uuids[i] }, ) } return stated }