343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
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' });
|
|
}
|
|
},
|
|
);
|
|
};
|