source dump of claude code
at main 175 lines 6.3 kB view raw
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 13import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' 14import axios from 'axios' 15import { randomUUID } from 'crypto' 16import { mkdir, writeFile } from 'fs/promises' 17import { basename, join } from 'path' 18import { z } from 'zod/v4' 19import { getSessionId } from '../bootstrap/state.js' 20import { logForDebugging } from '../utils/debug.js' 21import { getClaudeConfigHomeDir } from '../utils/envUtils.js' 22import { lazySchema } from '../utils/lazySchema.js' 23import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js' 24 25const DOWNLOAD_TIMEOUT_MS = 30_000 26 27function debug(msg: string): void { 28 logForDebugging(`[bridge:inbound-attach] ${msg}`) 29} 30 31const attachmentSchema = lazySchema(() => 32 z.object({ 33 file_uuid: z.string(), 34 file_name: z.string(), 35 }), 36) 37const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema())) 38 39export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>> 40 41/** Pull file_attachments off a loosely-typed inbound message. */ 42export 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 */ 55function sanitizeFileName(name: string): string { 56 const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_') 57 return base || 'attachment' 58} 59 60function 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 */ 68async 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 */ 123export 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 */ 142export 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 */ 167export 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}