/ tools / BriefTool / attachments.ts
attachments.ts
  1  /**
  2   * Shared attachment validation + resolution for SendUserMessage and
  3   * SendUserFile. Lives in BriefTool/ so the dynamic `./upload.js` import
  4   * inside the feature('BRIDGE_MODE') guard stays relative and upload.ts
  5   * (axios, crypto, auth utils) remains tree-shakeable from non-bridge builds.
  6   */
  7  
  8  import { feature } from 'bun:bundle'
  9  import { stat } from 'fs/promises'
 10  
 11  import type { ValidationResult } from '../../Tool.js'
 12  
 13  import { getCwd } from '../../utils/cwd.js'
 14  import { isEnvTruthy } from '../../utils/envUtils.js'
 15  import { getErrnoCode } from '../../utils/errors.js'
 16  import { IMAGE_EXTENSION_REGEX } from '../../utils/imagePaste.js'
 17  import { expandPath } from '../../utils/path.js'
 18  
 19  export type ResolvedAttachment = {
 20    path: string
 21    size: number
 22    isImage: boolean
 23    file_uuid?: string
 24  }
 25  
 26  export async function validateAttachmentPaths(
 27    rawPaths: string[],
 28  ): Promise<ValidationResult> {
 29    const cwd = getCwd()
 30    for (const rawPath of rawPaths) {
 31      const fullPath = expandPath(rawPath)
 32      try {
 33        const stats = await stat(fullPath)
 34        if (!stats.isFile()) {
 35          return {
 36            result: false,
 37            message: `Attachment "${rawPath}" is not a regular file.`,
 38            errorCode: 1,
 39          }
 40        }
 41      } catch (e) {
 42        const code = getErrnoCode(e)
 43        if (code === 'ENOENT') {
 44          return {
 45            result: false,
 46            message: `Attachment "${rawPath}" does not exist. Current working directory: ${cwd}.`,
 47            errorCode: 1,
 48          }
 49        }
 50        if (code === 'EACCES' || code === 'EPERM') {
 51          return {
 52            result: false,
 53            message: `Attachment "${rawPath}" is not accessible (permission denied).`,
 54            errorCode: 1,
 55          }
 56        }
 57        throw e
 58      }
 59    }
 60    return { result: true }
 61  }
 62  
 63  export async function resolveAttachments(
 64    rawPaths: string[],
 65    uploadCtx: { replBridgeEnabled: boolean; signal?: AbortSignal },
 66  ): Promise<ResolvedAttachment[]> {
 67    // Stat serially (local, fast) to keep ordering deterministic, then upload
 68    // in parallel (network, slow). Upload failures resolve undefined — the
 69    // attachment still carries {path, size, isImage} for local renderers.
 70    const stated: ResolvedAttachment[] = []
 71    for (const rawPath of rawPaths) {
 72      const fullPath = expandPath(rawPath)
 73      // Single stat — we need size, so this is the operation, not a guard.
 74      // validateInput ran before us, but the file could have moved since
 75      // (TOCTOU); if it did, let the error propagate so the model sees it.
 76      const stats = await stat(fullPath)
 77      stated.push({
 78        path: fullPath,
 79        size: stats.size,
 80        isImage: IMAGE_EXTENSION_REGEX.test(fullPath),
 81      })
 82    }
 83    // Dynamic import inside the feature() guard so upload.ts (axios, crypto,
 84    // zod, auth utils, MIME map) is fully eliminated from non-BRIDGE_MODE
 85    // builds. A static import would force module-scope evaluation regardless
 86    // of the guard inside uploadBriefAttachment — CLAUDE.md: "helpers defined
 87    // outside remain in the build even if never called".
 88    if (feature('BRIDGE_MODE')) {
 89      // Headless/SDK callers never set appState.replBridgeEnabled (only the TTY
 90      // REPL does, at main.tsx init). CLAUDE_CODE_BRIEF_UPLOAD lets a host that
 91      // runs the CLI as a subprocess opt in — e.g. the cowork desktop bridge,
 92      // which already passes CLAUDE_CODE_OAUTH_TOKEN for auth.
 93      const shouldUpload =
 94        uploadCtx.replBridgeEnabled ||
 95        isEnvTruthy(process.env.CLAUDE_CODE_BRIEF_UPLOAD)
 96      const { uploadBriefAttachment } = await import('./upload.js')
 97      const uuids = await Promise.all(
 98        stated.map(a =>
 99          uploadBriefAttachment(a.path, a.size, {
100            replBridgeEnabled: shouldUpload,
101            signal: uploadCtx.signal,
102          }),
103        ),
104      )
105      return stated.map((a, i) =>
106        uuids[i] === undefined ? a : { ...a, file_uuid: uuids[i] },
107      )
108    }
109    return stated
110  }