/ bridge / inboundAttachments.ts
inboundAttachments.ts
  1  /**
  2   * Resolve file_uuid attachments on inbound bridge user messages.
  3   *
  4   * Web composer uploads via cookie-authed /api/{org}/upload, sends file_uuid
  5   * alongside the message. Here we fetch each via GET /api/oauth/files/{uuid}/content
  6   * (oauth-authed, same store), write to ~/.claude/uploads/{sessionId}/, and
  7   * return @path refs to prepend. Claude's Read tool takes it from there.
  8   *
  9   * Best-effort: any failure (no token, network, non-2xx, disk) logs debug and
 10   * skips that attachment. The message still reaches Claude, just without @path.
 11   */
 12  
 13  import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
 14  import axios from 'axios'
 15  import { randomUUID } from 'crypto'
 16  import { mkdir, writeFile } from 'fs/promises'
 17  import { basename, join } from 'path'
 18  import { z } from 'zod/v4'
 19  import { getSessionId } from '../bootstrap/state.js'
 20  import { logForDebugging } from '../utils/debug.js'
 21  import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
 22  import { lazySchema } from '../utils/lazySchema.js'
 23  import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js'
 24  
 25  const DOWNLOAD_TIMEOUT_MS = 30_000
 26  
 27  function debug(msg: string): void {
 28    logForDebugging(`[bridge:inbound-attach] ${msg}`)
 29  }
 30  
 31  const attachmentSchema = lazySchema(() =>
 32    z.object({
 33      file_uuid: z.string(),
 34      file_name: z.string(),
 35    }),
 36  )
 37  const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema()))
 38  
 39  export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>>
 40  
 41  /** Pull file_attachments off a loosely-typed inbound message. */
 42  export function extractInboundAttachments(msg: unknown): InboundAttachment[] {
 43    if (typeof msg !== 'object' || msg === null || !('file_attachments' in msg)) {
 44      return []
 45    }
 46    const parsed = attachmentsArraySchema().safeParse(msg.file_attachments)
 47    return parsed.success ? parsed.data : []
 48  }
 49  
 50  /**
 51   * Strip path components and keep only filename-safe chars. file_name comes
 52   * from the network (web composer), so treat it as untrusted even though the
 53   * composer controls it.
 54   */
 55  function sanitizeFileName(name: string): string {
 56    const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_')
 57    return base || 'attachment'
 58  }
 59  
 60  function uploadsDir(): string {
 61    return join(getClaudeConfigHomeDir(), 'uploads', getSessionId())
 62  }
 63  
 64  /**
 65   * Fetch + write one attachment. Returns the absolute path on success,
 66   * undefined on any failure.
 67   */
 68  async function resolveOne(att: InboundAttachment): Promise<string | undefined> {
 69    const token = getBridgeAccessToken()
 70    if (!token) {
 71      debug('skip: no oauth token')
 72      return undefined
 73    }
 74  
 75    let data: Buffer
 76    try {
 77      // getOauthConfig() (via getBridgeBaseUrl) throws on a non-allowlisted
 78      // CLAUDE_CODE_CUSTOM_OAUTH_URL — keep it inside the try so a bad
 79      // FedStart URL degrades to "no @path" instead of crashing print.ts's
 80      // reader loop (which has no catch around the await).
 81      const url = `${getBridgeBaseUrl()}/api/oauth/files/${encodeURIComponent(att.file_uuid)}/content`
 82      const response = await axios.get(url, {
 83        headers: { Authorization: `Bearer ${token}` },
 84        responseType: 'arraybuffer',
 85        timeout: DOWNLOAD_TIMEOUT_MS,
 86        validateStatus: () => true,
 87      })
 88      if (response.status !== 200) {
 89        debug(`fetch ${att.file_uuid} failed: status=${response.status}`)
 90        return undefined
 91      }
 92      data = Buffer.from(response.data)
 93    } catch (e) {
 94      debug(`fetch ${att.file_uuid} threw: ${e}`)
 95      return undefined
 96    }
 97  
 98    // uuid-prefix makes collisions impossible across messages and within one
 99    // (same filename, different files). 8 chars is enough — this isn't security.
100    const safeName = sanitizeFileName(att.file_name)
101    const prefix = (
102      att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8)
103    ).replace(/[^a-zA-Z0-9_-]/g, '_')
104    const dir = uploadsDir()
105    const outPath = join(dir, `${prefix}-${safeName}`)
106  
107    try {
108      await mkdir(dir, { recursive: true })
109      await writeFile(outPath, data)
110    } catch (e) {
111      debug(`write ${outPath} failed: ${e}`)
112      return undefined
113    }
114  
115    debug(`resolved ${att.file_uuid} → ${outPath} (${data.length} bytes)`)
116    return outPath
117  }
118  
119  /**
120   * Resolve all attachments on an inbound message to a prefix string of
121   * @path refs. Empty string if none resolved.
122   */
123  export async function resolveInboundAttachments(
124    attachments: InboundAttachment[],
125  ): Promise<string> {
126    if (attachments.length === 0) return ''
127    debug(`resolving ${attachments.length} attachment(s)`)
128    const paths = await Promise.all(attachments.map(resolveOne))
129    const ok = paths.filter((p): p is string => p !== undefined)
130    if (ok.length === 0) return ''
131    // Quoted form — extractAtMentionedFiles truncates unquoted @refs at the
132    // first space, which breaks any home dir with spaces (/Users/John Smith/).
133    return ok.map(p => `@"${p}"`).join(' ') + ' '
134  }
135  
136  /**
137   * Prepend @path refs to content, whichever form it's in.
138   * Targets the LAST text block — processUserInputBase reads inputString
139   * from processedBlocks[processedBlocks.length - 1], so putting refs in
140   * block[0] means they're silently ignored for [text, image] content.
141   */
142  export function prependPathRefs(
143    content: string | Array<ContentBlockParam>,
144    prefix: string,
145  ): string | Array<ContentBlockParam> {
146    if (!prefix) return content
147    if (typeof content === 'string') return prefix + content
148    const i = content.findLastIndex(b => b.type === 'text')
149    if (i !== -1) {
150      const b = content[i]!
151      if (b.type === 'text') {
152        return [
153          ...content.slice(0, i),
154          { ...b, text: prefix + b.text },
155          ...content.slice(i + 1),
156        ]
157      }
158    }
159    // No text block — append one at the end so it's last.
160    return [...content, { type: 'text', text: prefix.trimEnd() }]
161  }
162  
163  /**
164   * Convenience: extract + resolve + prepend. No-op when the message has no
165   * file_attachments field (fast path — no network, returns same reference).
166   */
167  export async function resolveAndPrepend(
168    msg: unknown,
169    content: string | Array<ContentBlockParam>,
170  ): Promise<string | Array<ContentBlockParam>> {
171    const attachments = extractInboundAttachments(msg)
172    if (attachments.length === 0) return content
173    const prefix = await resolveInboundAttachments(attachments)
174    return prependPathRefs(content, prefix)
175  }