/ tools / BriefTool / upload.ts
upload.ts
  1  /**
  2   * Upload BriefTool attachments to private_api so web viewers can preview them.
  3   *
  4   * When the repl bridge is active, attachment paths are meaningless to a web
  5   * viewer (they're on Claude's machine). We upload to /api/oauth/file_upload —
  6   * the same store MessageComposer/SpaceMessage render from — and stash the
  7   * returned file_uuid alongside the path. Web resolves file_uuid → preview;
  8   * desktop/local try path first.
  9   *
 10   * Best-effort: any failure (no token, bridge off, network error, 4xx) logs
 11   * debug and returns undefined. The attachment still carries {path, size,
 12   * isImage}, so local-terminal and same-machine-desktop render unaffected.
 13   */
 14  
 15  import { feature } from 'bun:bundle'
 16  import axios from 'axios'
 17  import { randomUUID } from 'crypto'
 18  import { readFile } from 'fs/promises'
 19  import { basename, extname } from 'path'
 20  import { z } from 'zod/v4'
 21  
 22  import {
 23    getBridgeAccessToken,
 24    getBridgeBaseUrlOverride,
 25  } from '../../bridge/bridgeConfig.js'
 26  import { getOauthConfig } from '../../constants/oauth.js'
 27  import { logForDebugging } from '../../utils/debug.js'
 28  import { lazySchema } from '../../utils/lazySchema.js'
 29  import { jsonStringify } from '../../utils/slowOperations.js'
 30  
 31  // Matches the private_api backend limit
 32  const MAX_UPLOAD_BYTES = 30 * 1024 * 1024
 33  
 34  const UPLOAD_TIMEOUT_MS = 30_000
 35  
 36  // Backend dispatches on mime: image/* → upload_image_wrapped (writes
 37  // PREVIEW/THUMBNAIL, no ORIGINAL), everything else → upload_generic_file
 38  // (ORIGINAL only, no preview). Only whitelist raster formats the
 39  // transcoder reliably handles — svg/bmp/ico risk a 400, and pdf routes
 40  // to upload_pdf_file_wrapped which also skips ORIGINAL. Dispatch
 41  // viewers use /preview for images and /contents for everything else,
 42  // so images go image/* and the rest go octet-stream.
 43  const MIME_BY_EXT: Record<string, string> = {
 44    '.png': 'image/png',
 45    '.jpg': 'image/jpeg',
 46    '.jpeg': 'image/jpeg',
 47    '.gif': 'image/gif',
 48    '.webp': 'image/webp',
 49  }
 50  
 51  function guessMimeType(filename: string): string {
 52    const ext = extname(filename).toLowerCase()
 53    return MIME_BY_EXT[ext] ?? 'application/octet-stream'
 54  }
 55  
 56  function debug(msg: string): void {
 57    logForDebugging(`[brief:upload] ${msg}`)
 58  }
 59  
 60  /**
 61   * Base URL for uploads. Must match the host the token is valid for.
 62   *
 63   * Subprocess hosts (cowork) pass ANTHROPIC_BASE_URL alongside
 64   * CLAUDE_CODE_OAUTH_TOKEN — prefer that since getOauthConfig() only
 65   * returns staging when USE_STAGING_OAUTH is set, which such hosts don't
 66   * set. Without this a staging token hits api.anthropic.com → 401 → silent
 67   * skip → web viewer sees inert cards with no file_uuid.
 68   */
 69  function getBridgeBaseUrl(): string {
 70    return (
 71      getBridgeBaseUrlOverride() ??
 72      process.env.ANTHROPIC_BASE_URL ??
 73      getOauthConfig().BASE_API_URL
 74    )
 75  }
 76  
 77  // /api/oauth/file_upload returns one of ChatMessage{Image,Blob,Document}FileSchema.
 78  // All share file_uuid; that's the only field we need.
 79  const uploadResponseSchema = lazySchema(() =>
 80    z.object({ file_uuid: z.string() }),
 81  )
 82  
 83  export type BriefUploadContext = {
 84    replBridgeEnabled: boolean
 85    signal?: AbortSignal
 86  }
 87  
 88  /**
 89   * Upload a single attachment. Returns file_uuid on success, undefined otherwise.
 90   * Every early-return is intentional graceful degradation.
 91   */
 92  export async function uploadBriefAttachment(
 93    fullPath: string,
 94    size: number,
 95    ctx: BriefUploadContext,
 96  ): Promise<string | undefined> {
 97    // Positive pattern so bun:bundle eliminates the entire body from
 98    // non-BRIDGE_MODE builds (negative `if (!feature(...)) return` does not).
 99    if (feature('BRIDGE_MODE')) {
100      if (!ctx.replBridgeEnabled) return undefined
101  
102      if (size > MAX_UPLOAD_BYTES) {
103        debug(`skip ${fullPath}: ${size} bytes exceeds ${MAX_UPLOAD_BYTES} limit`)
104        return undefined
105      }
106  
107      const token = getBridgeAccessToken()
108      if (!token) {
109        debug('skip: no oauth token')
110        return undefined
111      }
112  
113      let content: Buffer
114      try {
115        content = await readFile(fullPath)
116      } catch (e) {
117        debug(`read failed for ${fullPath}: ${e}`)
118        return undefined
119      }
120  
121      const baseUrl = getBridgeBaseUrl()
122      const url = `${baseUrl}/api/oauth/file_upload`
123      const filename = basename(fullPath)
124      const mimeType = guessMimeType(filename)
125      const boundary = `----FormBoundary${randomUUID()}`
126  
127      // Manual multipart — same pattern as filesApi.ts. The oauth endpoint takes
128      // a single "file" part (no "purpose" field like the public Files API).
129      const body = Buffer.concat([
130        Buffer.from(
131          `--${boundary}\r\n` +
132            `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` +
133            `Content-Type: ${mimeType}\r\n\r\n`,
134        ),
135        content,
136        Buffer.from(`\r\n--${boundary}--\r\n`),
137      ])
138  
139      try {
140        const response = await axios.post(url, body, {
141          headers: {
142            Authorization: `Bearer ${token}`,
143            'Content-Type': `multipart/form-data; boundary=${boundary}`,
144            'Content-Length': body.length.toString(),
145          },
146          timeout: UPLOAD_TIMEOUT_MS,
147          signal: ctx.signal,
148          validateStatus: () => true,
149        })
150  
151        if (response.status !== 201) {
152          debug(
153            `upload failed for ${fullPath}: status=${response.status} body=${jsonStringify(response.data).slice(0, 200)}`,
154          )
155          return undefined
156        }
157  
158        const parsed = uploadResponseSchema().safeParse(response.data)
159        if (!parsed.success) {
160          debug(
161            `unexpected response shape for ${fullPath}: ${parsed.error.message}`,
162          )
163          return undefined
164        }
165  
166        debug(`uploaded ${fullPath} → ${parsed.data.file_uuid} (${size} bytes)`)
167        return parsed.data.file_uuid
168      } catch (e) {
169        debug(`upload threw for ${fullPath}: ${e}`)
170        return undefined
171      }
172    }
173    return undefined
174  }