inboundAttachments.ts 6.1 KB

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