import express from 'express'; import { constants as fsConstants } from 'node:fs'; import { mintOutsideFileGrant } from '../fs/routes.js'; const MAX_IMAGE_BYTES = 10 * 1024 * 1024; const MAX_IMAGE_SOURCES = 12; const asString = (value) => typeof value === 'string' ? value.trim() : ''; const isWithin = (target, root, path) => { const relative = path.relative(root, target); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); }; const parseFileSource = (source) => { if (/^file:\/\//i.test(source)) { try { const url = new URL(source); if (url.protocol !== 'file:' || (url.host && url.host !== 'localhost')) return ''; const pathname = decodeURIComponent(url.pathname); return /^\/[A-Za-z]:\//.test(pathname) ? pathname.slice(1) : pathname; } catch { return ''; } } const pathname = source.split(/[?#]/, 1)[0] || ''; try { return decodeURIComponent(pathname); } catch { return pathname; } }; const hasImageSignature = (bytes) => { if (bytes.length >= 8 && bytes[0] === 0x89 && bytes.subarray(1, 4).toString('ascii') === 'PNG' && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) return true; if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return true; const header = bytes.subarray(0, 12).toString('ascii'); return header.startsWith('GIF87a') || header.startsWith('GIF89a') || (header.startsWith('RIFF') && header.slice(8, 12) === 'WEBP'); }; const normalizeReferenceLabel = (value) => value.trim().replace(/\s+/g, ' ').toLowerCase(); const unescapeMarkdownDestination = (value) => value.replace(/\\([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~\\])/g, '$1'); const isEscapedAt = (value, index) => { let slashes = 0; for (let cursor = index - 1; cursor >= 0 && value[cursor] === '\\'; cursor -= 1) slashes += 1; return slashes % 2 === 1; }; const findClosingBracket = (value, start) => { for (let cursor = start; cursor < value.length; cursor += 1) { if (value[cursor] === ']' && !isEscapedAt(value, cursor)) return cursor; } return -1; }; const findInlineImageEnd = (value, start) => { let cursor = start; while (/\s/.test(value[cursor] || '')) cursor += 1; if (value[cursor] === ')') return cursor; const opener = value[cursor]; const closer = opener === '"' ? '"' : opener === "'" ? "'" : opener === '(' ? ')' : ''; if (!closer) return -1; cursor += 1; for (; cursor < value.length; cursor += 1) { if (value[cursor] !== closer || isEscapedAt(value, cursor)) continue; cursor += 1; while (/\s/.test(value[cursor] || '')) cursor += 1; return value[cursor] === ')' ? cursor : -1; } return -1; }; const parseInlineDestination = (value, start) => { let cursor = start; while (/\s/.test(value[cursor] || '')) cursor += 1; if (value[cursor] === '<') { const end = value.indexOf('>', cursor + 1); if (end < 0) return null; const imageEnd = findInlineImageEnd(value, end + 1); return imageEnd < 0 ? null : { source: unescapeMarkdownDestination(value.slice(cursor + 1, end)), end: imageEnd }; } let source = ''; let depth = 0; for (; cursor < value.length; cursor += 1) { const char = value[cursor]; if (char === '\\' && cursor + 1 < value.length) { source += char + value[cursor + 1]; cursor += 1; continue; } if (char === '(') { depth += 1; source += char; continue; } if (char === ')') { if (depth === 0) return { source: unescapeMarkdownDestination(source), end: cursor }; depth -= 1; source += char; continue; } if (/\s/.test(char) && depth === 0) { const imageEnd = findInlineImageEnd(value, cursor); return imageEnd < 0 ? null : { source: unescapeMarkdownDestination(source), end: imageEnd }; } source += char; } return null; }; const parseDefinitionDestination = (value) => { const trimmed = value.trimStart(); if (trimmed.startsWith('<')) { const end = trimmed.indexOf('>', 1); return end < 0 ? '' : unescapeMarkdownDestination(trimmed.slice(1, end)); } const match = /^(?:\\.|\S)+/.exec(trimmed); return match ? unescapeMarkdownDestination(match[0]) : ''; }; const collectMarkdownLinesOutsideCode = (message) => { const lines = []; for (const part of Array.isArray(message?.parts) ? message.parts : []) { if (part?.type !== 'text' || typeof part.text !== 'string') continue; let fence = null; for (const line of part.text.split('\n')) { const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line); if (fenceMatch) { const marker = fenceMatch[1]; if (!fence) { fence = { char: marker[0], size: marker.length }; } else if (marker[0] === fence.char && marker.length >= fence.size) { fence = null; } continue; } if (fence) continue; lines.push(line.replace(/`+[^`]*`+/g, '')); } } return lines; }; const markdownImageSources = (message) => { const sources = new Set(); const markdownLines = collectMarkdownLinesOutsideCode(message); const definitions = new Map(); for (const line of markdownLines) { const match = /^\s{0,3}\[([^\]]+)]\s*:\s*(.*)$/.exec(line); if (!match) continue; const source = parseDefinitionDestination(match[2]); if (source) definitions.set(normalizeReferenceLabel(match[1]), source); } for (const line of markdownLines) { for (let cursor = 0; cursor < line.length; cursor += 1) { if (line[cursor] !== '!' || line[cursor + 1] !== '[' || isEscapedAt(line, cursor)) continue; const altEnd = findClosingBracket(line, cursor + 2); if (altEnd < 0) continue; const alt = line.slice(cursor + 2, altEnd); const next = line[altEnd + 1]; if (next === '(') { const parsed = parseInlineDestination(line, altEnd + 2); if (parsed?.source) sources.add(parsed.source); cursor = parsed?.end ?? altEnd; continue; } let label = alt; if (next === '[') { const labelEnd = findClosingBracket(line, altEnd + 2); if (labelEnd < 0) continue; label = line.slice(altEnd + 2, labelEnd) || alt; cursor = labelEnd; } else { cursor = altEnd; } const source = definitions.get(normalizeReferenceLabel(label)); if (source) sources.add(source); } } return sources; }; const fetchMessage = async ({ sessionId, messageId, directory, buildOpenCodeUrl, getOpenCodeAuthHeaders }) => { const url = new URL(buildOpenCodeUrl( `/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`, '', )); url.searchParams.set('directory', directory); const response = await fetch(url, { headers: { accept: 'application/json', // Percent-encoded to match the SDK wire format; raw non-ASCII values // are rejected by OpenCode. 'x-opencode-directory': encodeURIComponent(directory), ...getOpenCodeAuthHeaders(), }, signal: AbortSignal.timeout(10_000), }); if (response.status === 404) return null; if (!response.ok) throw new Error(`OpenCode returned ${response.status}`); const message = await response.json().catch(() => null); return message?.info && Array.isArray(message.parts) ? message : null; }; const inspectImage = async ({ source, directory, approvedTempRoot, fsPromises, path }) => { const parsed = parseFileSource(source); if (!parsed) return { status: 'error' }; const sourcePath = path.isAbsolute(parsed) ? parsed : path.resolve(directory, parsed); const workspaceRoot = path.resolve(directory); const outsideWorkspace = !isWithin(path.resolve(sourcePath), workspaceRoot, path); const root = outsideWorkspace ? approvedTempRoot : workspaceRoot; try { // Resolve symlinks before comparing roots; lexical prefixes are not an authorization boundary. const [canonicalRoot, canonicalPath] = await Promise.all([ fsPromises.realpath(root), fsPromises.realpath(sourcePath), ]); if (!isWithin(canonicalPath, canonicalRoot, path)) return { status: 'error' }; const handle = await fsPromises.open(canonicalPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); try { const stats = await handle.stat(); if (!stats.isFile() || stats.size > MAX_IMAGE_BYTES) return { status: 'error' }; const header = Buffer.alloc(12); const { bytesRead } = await handle.read(header, 0, header.length, 0); if (!hasImageSignature(header.subarray(0, bytesRead))) return { status: 'error' }; return { status: 'ready', path: outsideWorkspace ? canonicalPath : path.resolve(sourcePath), outsideWorkspace, }; } finally { await handle.close(); } } catch (error) { if (error?.code === 'ENOENT') return { status: 'missing' }; if (error?.code === 'EACCES' || error?.code === 'EPERM' || error?.code === 'ELOOP') { return { status: 'error' }; } throw error; } }; export const registerMarkdownImageGrantRoutes = (app, dependencies) => { const { fsPromises, path, os, crypto, validateDirectoryPath, buildOpenCodeUrl, getOpenCodeAuthHeaders, approvedTempRoot = path.join(os.tmpdir(), 'opencode'), } = dependencies; app.post( '/api/openchamber/sessions/:sessionId/markdown-image-grants', express.json({ limit: '32kb' }), async (req, res) => { const sessionId = asString(req.params.sessionId); const messageId = asString(req.body?.messageId); const sources = Array.isArray(req.body?.sources) ? [...new Set(req.body.sources.map(asString).filter(Boolean))] : []; if (!sessionId || !messageId || sources.length === 0 || sources.length > MAX_IMAGE_SOURCES) { return res.status(400).json({ error: 'sessionId, messageId, and 1-12 sources are required' }); } const validatedDirectory = await validateDirectoryPath(asString(req.body?.directory)); if (!validatedDirectory.ok) { return res.status(400).json({ error: validatedDirectory.error || 'Invalid directory' }); } try { const message = await fetchMessage({ sessionId, messageId, directory: validatedDirectory.directory, buildOpenCodeUrl, getOpenCodeAuthHeaders, }); if (!message || message.info?.id !== messageId || message.info?.role !== 'assistant') { return res.status(404).json({ error: 'Assistant message not found' }); } // Assistant text is authoritative: a remote client cannot mint grants for unreferenced paths. const referenced = markdownImageSources(message); const results = []; for (const source of sources) { if (!referenced.has(source)) { results.push({ source, status: 'error' }); continue; } try { const inspected = await inspectImage({ source, directory: validatedDirectory.directory, approvedTempRoot, fsPromises, path, }); if (inspected.status !== 'ready') { results.push({ source, status: inspected.status }); continue; } // Reuse the existing path-bound raw-file grant instead of creating another asset lifecycle. const grant = inspected.outsideWorkspace ? await mintOutsideFileGrant(inspected.path, { scopes: ['raw'], fsPromises, path, crypto, }) : null; results.push({ source, status: 'ready', path: inspected.path, outsideFileGrant: grant?.outsideFileGrant, expiresAt: grant?.expiresAt, }); } catch { results.push({ source, status: 'error' }); } } return res.json({ results }); } catch (error) { console.warn('[MarkdownImageGrants] failed to prepare images:', error?.message || error); return res.status(503).json({ error: 'Failed to prepare session images' }); } }, ); };