{"version":3,"file":"prepare-attachment-part.mjs","names":["resolvePath"],"sources":["../../../../../../../ai/src/utils/prepare-attachment-part.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { extname, isAbsolute, relative, resolve as resolvePath } from \"node:path\";\nimport type { AttachmentPolicy } from \"../contracts/attachment-policy.type\";\nimport type { Attachment } from \"../contracts/attachment.type\";\nimport type { ContentPart } from \"../contracts/content-part.type\";\nimport { InvalidRequestError, OutboundPolicyError } from \"../errors\";\nimport { fetchTextWithPolicy } from \"../security/outbound-policy\";\nimport { resolveAttachment } from \"./resolve-attachment\";\n\nconst IMAGE_EXTENSIONS_TO_MEDIA_TYPE: Record<string, string> = {\n  \".png\": \"image/png\",\n  \".jpg\": \"image/jpeg\",\n  \".jpeg\": \"image/jpeg\",\n  \".webp\": \"image/webp\",\n  \".gif\": \"image/gif\",\n};\n\nconst TEXT_EXTENSIONS = new Set([\".txt\"]);\n\nconst AUDIO_EXTENSIONS_TO_MEDIA_TYPE: Record<string, string> = {\n  \".mp3\": \"audio/mpeg\",\n  \".wav\": \"audio/wav\",\n  \".m4a\": \"audio/mp4\",\n  \".ogg\": \"audio/ogg\",\n  \".weba\": \"audio/webm\",\n};\n\nconst PDF_EXTENSIONS = new Set([\".pdf\"]);\n\ntype AttachmentKind = \"image\" | \"text\" | \"pdf\" | \"audio\";\n\n/**\n * Convert a user-supplied `Attachment` into a provider-ready\n * `ContentPart` the model adapter can consume without doing any I/O of\n * its own.\n *\n * Kind resolution:\n * - Tagged `{ type: \"image\", source }` / `{ type: \"text\", source }`\n *   trusts the caller's intent.\n * - Shorthand (raw string / `StorageFileShape`) infers from the file\n *   extension. Image extensions (`.png`/`.jpg`/`.jpeg`/`.webp`/`.gif`)\n *   map to `\"image\"`. `.txt` maps to `\"text\"`. Anything else throws\n *   `InvalidRequestError` — silent inference on ambiguous inputs\n *   causes silent bugs.\n *\n * Local paths are read from disk; images are base64-encoded inline,\n * text files are read as UTF-8 strings and returned as a `text`\n * `ContentPart`. Remote URLs for image attachments are passed through\n * unchanged; remote URLs for text attachments are fetched so the\n * adapter never needs network access.\n *\n * @example\n * await prepareAttachmentPart(\"./photo.png\");\n * // → { type: \"image\", source: { base64: \"...\", mediaType: \"image/png\" } }\n *\n * @example\n * await prepareAttachmentPart({ type: \"text\", source: \"./notes.txt\" });\n * // → { type: \"text\", text: \"<file contents>\" }\n *\n * **Trust boundary (S1).** Attachment references are often user-controlled,\n * so server-side I/O is policy-gated by `policy` ({@link AttachmentPolicy}):\n * remote text fetches are default-deny and, when enabled, run through the\n * shared `OutboundPolicy` (scheme/host/private-IP/max-bytes/timeout); local\n * reads honor an `allowedRoots` sandbox; bare-string local paths warn\n * (staged deprecation). URL *image* attachments are passed to the provider\n * untouched (never fetched here).\n */\nexport async function prepareAttachmentPart(\n  attachment: Attachment,\n  policy?: AttachmentPolicy,\n): Promise<ContentPart> {\n  const kind = resolveKind(attachment);\n  const bareString = typeof attachment === \"string\";\n\n  if (kind === \"text\") {\n    return prepareTextPart(attachment, policy, bareString);\n  }\n\n  if (kind === \"image\") {\n    return prepareImagePart(attachment, policy, bareString);\n  }\n\n  return prepareBinaryPart(attachment, kind, policy, bareString);\n}\n\n/**\n * Decide whether the attachment is text or image. Tagged forms win\n * immediately; for shorthand we inspect the extension. Throws if the\n * shorthand doesn't look like anything we recognize.\n */\nfunction resolveKind(attachment: Attachment): AttachmentKind {\n  if (isTaggedAttachment(attachment)) {\n    return attachment.type;\n  }\n\n  const path = extractPath(attachment);\n  const extension = path ? extname(stripQuery(path)).toLowerCase() : \"\";\n\n  if (IMAGE_EXTENSIONS_TO_MEDIA_TYPE[extension]) {\n    return \"image\";\n  }\n\n  if (TEXT_EXTENSIONS.has(extension)) {\n    return \"text\";\n  }\n\n  if (PDF_EXTENSIONS.has(extension)) {\n    return \"pdf\";\n  }\n\n  if (AUDIO_EXTENSIONS_TO_MEDIA_TYPE[extension]) {\n    return \"audio\";\n  }\n\n  throw new InvalidRequestError(\n    \"Cannot infer attachment type from input — pass an explicit `{ type: 'image' | 'text' | 'pdf' | 'audio', source: ... }` or use a recognized extension (.png, .jpg, .jpeg, .webp, .gif, .txt, .pdf, .mp3, .wav, .m4a, .ogg, .weba)\",\n  );\n}\n\n/**\n * Produce a `pdf` / `audio` ContentPart (A2). URLs pass through; local\n * paths are read and base64-encoded with a media type inferred from the\n * kind (`application/pdf`) or extension (audio); inline base64 passes\n * through. Same `AttachmentPolicy` gating as image/text reads.\n */\nasync function prepareBinaryPart(\n  attachment: Attachment,\n  kind: \"pdf\" | \"audio\",\n  policy: AttachmentPolicy | undefined,\n  bareString: boolean,\n): Promise<ContentPart> {\n  const resolved = resolveAttachment(attachment);\n\n  if (resolved.type === \"url\") {\n    return { type: kind, source: { url: resolved.value } };\n  }\n\n  if (resolved.type === \"base64\") {\n    return { type: kind, source: { base64: resolved.value, mediaType: resolved.mediaType } };\n  }\n\n  const mediaType =\n    kind === \"pdf\" ? \"application/pdf\" : inferAudioMediaType(resolved.value);\n\n  if (!mediaType) {\n    throw new InvalidRequestError(\n      `Cannot infer media type for ${kind} path \"${resolved.value}\" — use a recognized extension or pass ` +\n        `\\`{ type: '${kind}', source: { base64, mediaType } }\\``,\n      { context: { path: resolved.value } },\n    );\n  }\n\n  enforceLocalPathPolicy(resolved.value, bareString, policy);\n  const bytes = await readFile(resolved.value);\n\n  return { type: kind, source: { base64: bytes.toString(\"base64\"), mediaType } };\n}\n\n/** Infer an audio media type from a path's extension. */\nfunction inferAudioMediaType(path: string): string | undefined {\n  return AUDIO_EXTENSIONS_TO_MEDIA_TYPE[extname(stripQuery(path)).toLowerCase()];\n}\n\n/**\n * Produce an `image` ContentPart. URLs pass through; paths are\n * read from disk and base64-encoded with an inferred media type.\n * Inline base64 attachments pass through unchanged.\n */\nasync function prepareImagePart(\n  attachment: Attachment,\n  policy: AttachmentPolicy | undefined,\n  bareString: boolean,\n): Promise<ContentPart> {\n  const inferredMediaType = isTaggedAttachment(attachment)\n    ? undefined\n    : inferImageMediaType(attachment);\n\n  const resolved = resolveAttachment(attachment);\n\n  if (resolved.type === \"url\") {\n    // URL images are handed to the provider as a URL — the provider\n    // fetches them, not us — so there's no server-side SSRF surface here.\n    return { type: \"image\", source: { url: resolved.value } };\n  }\n\n  if (resolved.type === \"base64\") {\n    return {\n      type: \"image\",\n      source: { base64: resolved.value, mediaType: resolved.mediaType },\n    };\n  }\n\n  const mediaType = inferredMediaType ?? inferImageMediaType(resolved.value);\n\n  if (!mediaType) {\n    throw new InvalidRequestError(\n      `Cannot infer media type for path \"${resolved.value}\" — use a recognized image extension or pass ` +\n        \"`{ type: 'image', source: { base64, mediaType } }`\",\n      { context: { path: resolved.value } },\n    );\n  }\n\n  enforceLocalPathPolicy(resolved.value, bareString, policy);\n  const bytes = await readFile(resolved.value);\n\n  return {\n    type: \"image\",\n    source: { base64: bytes.toString(\"base64\"), mediaType },\n  };\n}\n\n/**\n * Produce a `text` ContentPart. URLs are fetched as UTF-8, paths are\n * read from disk as UTF-8, inline base64 is decoded to UTF-8. The\n * result joins the conversation as an additional text part the model\n * sees before responding.\n */\nasync function prepareTextPart(\n  attachment: Attachment,\n  policy: AttachmentPolicy | undefined,\n  bareString: boolean,\n): Promise<ContentPart> {\n  const resolved = resolveAttachment(attachment);\n\n  if (resolved.type === \"url\") {\n    // Default-deny: a remote text attachment is a server-side fetch of\n    // user-controlled input — refuse unless the app explicitly opted in,\n    // then run it through the shared OutboundPolicy (scheme/host/private-\n    // IP/max-bytes/timeout).\n    if (!policy?.allowRemoteFetch) {\n      throw new OutboundPolicyError(\n        `remote text attachment fetch is disabled by default — set \\`attachmentPolicy.allowRemoteFetch: true\\` (with an \\`outbound\\` policy) to fetch \"${resolved.value}\"`,\n        { context: { url: resolved.value } },\n      );\n    }\n\n    const result = await fetchTextWithPolicy(resolved.value, policy.outbound ?? {});\n\n    if (!result.ok) {\n      throw new InvalidRequestError(\n        `Failed to fetch text attachment \"${resolved.value}\" — status ${result.status}`,\n        { context: { url: resolved.value, status: result.status } },\n      );\n    }\n\n    return { type: \"text\", text: result.text };\n  }\n\n  if (resolved.type === \"base64\") {\n    const decoded = Buffer.from(resolved.value, \"base64\").toString(\"utf8\");\n\n    return { type: \"text\", text: decoded };\n  }\n\n  enforceLocalPathPolicy(resolved.value, bareString, policy);\n  const bytes = await readFile(resolved.value, \"utf8\");\n\n  return { type: \"text\", text: bytes };\n}\n\n/** Process-lifetime flag so the bare-string deprecation warns at most once. */\nlet warnedBareLocalPath = false;\n\n/**\n * Enforce the local-file half of {@link AttachmentPolicy} (S1):\n *\n * - **Bare-string local paths** are staged for deprecation. With\n *   `allowBareLocalPaths: false` they hard-deny now; otherwise they warn\n *   once (outside tests) — the typed `StorageFile.absolutePath` route is\n *   the supported way to read a local file.\n * - **`allowedRoots` sandbox** — when set, the resolved path must live\n *   inside one of the roots, else the read is refused.\n */\nfunction enforceLocalPathPolicy(\n  path: string,\n  bareString: boolean,\n  policy: AttachmentPolicy | undefined,\n): void {\n  if (bareString) {\n    if (policy?.allowBareLocalPaths === false) {\n      throw new OutboundPolicyError(\n        `local file attachment via a bare string path (\"${path}\") is disabled — pass a typed \\`{ type, source: { absolutePath } }\\` StorageFile, or set \\`attachmentPolicy.allowBareLocalPaths: true\\``,\n        { context: { path } },\n      );\n    }\n\n    if (!warnedBareLocalPath && !process.env.VITEST && process.env.NODE_ENV !== \"test\") {\n      warnedBareLocalPath = true;\n      console.warn(\n        \"[warlock-ai] reading a local file attachment from a bare string path is deprecated and will be denied by default in a future minor. \" +\n          \"Pass a typed `{ type, source: { absolutePath } }` StorageFile and confine reads with `attachmentPolicy.allowedRoots`.\",\n      );\n    }\n  }\n\n  const roots = policy?.allowedRoots;\n  if (roots && roots.length > 0) {\n    const target = resolvePath(path);\n    const inside = roots.some(root => {\n      const rel = relative(resolvePath(root), target);\n      return rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel));\n    });\n\n    if (!inside) {\n      throw new OutboundPolicyError(\n        `local file attachment \"${path}\" is outside the allowed roots`,\n        { context: { path, allowedRoots: roots } },\n      );\n    }\n  }\n}\n\nfunction isTaggedAttachment(\n  attachment: Attachment,\n): attachment is Extract<Attachment, { type: string }> {\n  return (\n    typeof attachment === \"object\" &&\n    attachment !== null &&\n    \"type\" in attachment\n  );\n}\n\nfunction inferImageMediaType(input: unknown): string | undefined {\n  const path = extractPath(input);\n\n  if (!path) {\n    return undefined;\n  }\n\n  const extension = extname(stripQuery(path)).toLowerCase();\n\n  return IMAGE_EXTENSIONS_TO_MEDIA_TYPE[extension];\n}\n\nfunction extractPath(input: unknown): string | undefined {\n  if (typeof input === \"string\") {\n    return input;\n  }\n\n  if (typeof input === \"object\" && input !== null) {\n    const storage = input as { url?: string; absolutePath?: string };\n    return storage.url ?? storage.absolutePath;\n  }\n\n  return undefined;\n}\n\nfunction stripQuery(path: string): string {\n  const queryIndex = path.indexOf(\"?\");\n\n  return queryIndex === -1 ? path : path.slice(0, queryIndex);\n}\n"],"mappings":"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"}