Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions plugins/vision-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,15 @@ export default (async () => {
const seqDir = path.join(TMP_DIR, `image${seq}`)
const filePath = path.join(seqDir, name)
// P1-3: write failures degrade to a skip (don't throw, don't break the turn)
if (!(await Bun.file(filePath).exists())) {
let fileExists = false
try {
await fs.access(filePath)
fileExists = true
} catch {}
if (!fileExists) {
try {
// H2 fix: ensure seqDir exists before writing — Bun.write may not
// auto-create intermediate directories depending on version.
await fs.mkdir(seqDir, { recursive: true }).catch(() => {})
await Bun.write(filePath, Buffer.from(base64, "base64"))
await fs.writeFile(filePath, Buffer.from(base64, "base64"))
} catch (err) {
console.error(`[vision-helper] Failed to write ${filePath}:`, err)
continue
Expand Down
40 changes: 28 additions & 12 deletions tools/vision.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { tool } from "@opencode-ai/plugin"
import { tmpdir } from "os"
import path from "path"
import { promises as fs } from "fs"

const TMP_DIR = path.join(tmpdir(), "opencode-vision")
const TMP_DIR_RESOLVED = path.resolve(TMP_DIR)
Expand All @@ -11,6 +12,19 @@ const MAX_FILE_SIZE = 50 * 1024 * 1024
// Allowlist image extensions accepted by the external VLM path.
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"])

function mimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
switch (ext) {
case ".png": return "image/png"
case ".jpg":
case ".jpeg": return "image/jpeg"
case ".gif": return "image/gif"
case ".webp": return "image/webp"
case ".bmp": return "image/bmp"
default: return "image/png"
}
}

/**
* Path sandbox: only read image files created under TMP_DIR.
* This prevents prompt injection from exfiltrating sensitive local files by
Expand Down Expand Up @@ -92,10 +106,14 @@ Override with VISION_API_TYPE=openai|minimax.`,
for (const candidate of candidates) {
const safe = sandboxPath(candidate)
if (!safe) continue
const file = Bun.file(safe)
if (!(await file.exists())) continue
if (file.size > MAX_FILE_SIZE) {
rejected.push(`${safe} (too large: ${(file.size / 1024 / 1024).toFixed(1)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB)`)
let stat: { size: number }
try {
stat = await fs.stat(safe)
} catch {
continue
}
if (stat.size > MAX_FILE_SIZE) {
rejected.push(`${safe} (too large: ${(stat.size / 1024 / 1024).toFixed(1)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB)`)
found = null
break
}
Expand Down Expand Up @@ -167,10 +185,9 @@ async function callOpenAI(apiKey: string, baseUrl: string, resolved: string[], q
}

for (const filePath of resolved) {
const file = Bun.file(filePath)
const mime = file.type || "image/png"
const buffer = await file.arrayBuffer()
const base64 = Buffer.from(buffer).toString("base64")
const buffer = await fs.readFile(filePath)
const mime = mimeType(filePath)
const base64 = buffer.toString("base64")
content.push({ type: "image_url", image_url: { url: `data:${mime};base64,${base64}` } })
}

Expand Down Expand Up @@ -235,10 +252,9 @@ async function callMiniMax(apiKey: string, baseUrl: string, resolved: string[],

const descriptions: string[] = []
for (const filePath of resolved) {
const file = Bun.file(filePath)
const mime = file.type || "image/png"
const buffer = await file.arrayBuffer()
const base64 = Buffer.from(buffer).toString("base64")
const buffer = await fs.readFile(filePath)
const mime = mimeType(filePath)
const base64 = buffer.toString("base64")
const imageUrl = `data:${mime};base64,${base64}`

const prompt = question || "Please describe this image in detail"
Expand Down