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