216 lines
7.9 KiB
JavaScript
216 lines
7.9 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 markdownImageSources = (message) => {
|
|
const sources = new Set();
|
|
for (const part of Array.isArray(message?.parts) ? message.parts : []) {
|
|
if (part?.type !== 'text' || typeof part.text !== 'string') continue;
|
|
// Code examples must never authorize file access, even when they contain image syntax.
|
|
let fenced = false;
|
|
for (const line of part.text.split('\n')) {
|
|
if (/^\s{0,3}(?:```|~~~)/.test(line)) {
|
|
fenced = !fenced;
|
|
continue;
|
|
}
|
|
if (fenced) continue;
|
|
const visible = line.replace(/`+[^`]*`+/g, '');
|
|
const pattern = /(?<!\\)!\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)\n]+))/g;
|
|
let match = pattern.exec(visible);
|
|
while (match) {
|
|
sources.add(match[1] || match[2]);
|
|
match = pattern.exec(visible);
|
|
}
|
|
}
|
|
}
|
|
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',
|
|
'x-opencode-directory': 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' });
|
|
}
|
|
},
|
|
);
|
|
};
|