Merge upstream/main into feat/shiki-re-highlighting-performance-dd3a

Conflict: packages/ui/src/components/chat/markdown/markdownCore.ts

main added per-image-mode markdown parsers (`imageMode` threaded through
`parseBlock` and into the block cache key); this branch replaced the
identity-keyed block cache with a content-addressed LRU. Resolution keeps the
content-addressed cache and folds `imageMode` into the content key, so the
`inline` and `label` renderings of the same source cannot answer for each other.
This commit is contained in:
Serhii Dziupin
2026-08-17 16:44:05 +03:00
466 changed files with 25608 additions and 10309 deletions
@@ -57,6 +57,7 @@ const ICONS = {
zoomOut: spriteIcon('subtract'),
fit: spriteIcon('refresh'),
textWrap: spriteIcon('text-wrap'),
image: spriteIcon('file-image'),
} as const;
const ICON_BTN_CLASS =
@@ -66,6 +67,18 @@ const setIconHtml = (el: Element, html: string): void => {
el.innerHTML = html;
};
const decorateImageLabels = (root: HTMLElement): void => {
for (const label of Array.from(root.querySelectorAll<HTMLElement>('[data-openchamber-markdown-image-label="true"]'))) {
if (label.querySelector('[data-openchamber-markdown-image-label-icon]')) continue;
const icon = document.createElement('span');
icon.className = 'inline-flex shrink-0';
icon.setAttribute('aria-hidden', 'true');
icon.setAttribute('data-openchamber-markdown-image-label-icon', 'true');
setIconHtml(icon, ICONS.image);
label.prepend(icon);
}
};
const makeIconButton = (icon: keyof typeof ICONS, title: string, slot: string): HTMLButtonElement => {
const button = document.createElement('button');
button.type = 'button';
@@ -487,6 +500,7 @@ const decorateLinks = (root: HTMLElement, ctx: DecorateContext): void => {
/** Run all idempotent DOM decoration passes over freshly-rendered markdown. */
export const decorateMarkdown = (root: HTMLElement, ctx: DecorateContext): void => {
decorateImageLabels(root);
decorateInlineCode(root);
decorateMermaid(root, ctx);
decorateCodeBlocks(root, ctx);
@@ -1,6 +1,24 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
mock.module('dompurify', () => ({
default: {
isSupported: true,
addHook: () => undefined,
sanitize: (html: string) => html,
},
}));
mock.module('./markdown-worker', () => ({
highlightCodeInWorker: async () => null,
}));
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const {
__markdownImageCandidateCacheForTests,
extractMarkdownImageCandidates,
renderMarkdownSync,
} = await import('./markdownCore');
const { resolveMarkdownImageSource } = await import('./markdownImageAssets');
describe('markdown sanitization', () => {
test('turns raw assistant HTML into inert visible text', () => {
@@ -15,4 +33,157 @@ describe('markdown sanitization', () => {
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script');
expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style');
});
test('allows only local file URLs through the sanitizer policy', () => {
expect(isLocalFileUrl('file:///private/tmp/report%20viewer.html')).toBe(true);
expect(isLocalFileUrl('file://localhost/private/tmp/REPORT.md')).toBe(true);
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
});
});
describe('Markdown images', () => {
test('renders assistant images as icon-ready text without loading the source', () => {
const html = renderMarkdownSync([
'[linked image](packages/vscode/extension.jpg)',
'![image syntax](packages/vscode/extension.jpg)',
].join('\n\n'), 'label');
expect(html).toContain('data-openchamber-markdown-image-label="true"');
expect(html).toContain('extension.jpg');
expect(html).not.toContain('image syntax');
expect(html).not.toContain('<img');
expect(html.match(/<a /g)).toHaveLength(1);
});
test('keeps non-chat Markdown images inline', () => {
const html = renderMarkdownSync([
'[remote link](https://example.test/image.png)',
'![remote image](https://example.test/image.png)',
].join('\n\n'));
expect(html).toContain('<a href="https://example.test/image.png"');
expect(html).toContain('<img src="https://example.test/image.png" alt="remote image">');
expect(html).not.toContain('data-openchamber-markdown-image-label');
});
test('collects image syntax across mixed Markdown and ignores links and code', () => {
const candidates = extractMarkdownImageCandidates([
[
'Before [local link](screens/first%20view.png) and `![code](ignored.png)`.',
'',
'- ![duplicate](screens/first%20view.png)',
'- ![remote](https://example.test/second.webp?size=2)',
'',
'```md',
'![fenced](ignored-too.jpg)',
'```',
].join('\n'),
'After ![third](data:image/png;base64,AAAA).',
]);
expect(candidates).toEqual([
{ source: 'screens/first%20view.png', filename: 'first view.png' },
{ source: 'https://example.test/second.webp?size=2', filename: 'second.webp' },
{ source: 'data:image/png;base64,AAAA', filename: 'third' },
]);
});
test('does not add an ordinary local image link to the gallery', () => {
expect(extractMarkdownImageCandidates(['[download](screens/image.png)'])).toEqual([]);
});
test('limits one finalized message gallery to twelve unique candidates', () => {
const markdown = Array.from({ length: 14 }, (_, index) => `![image ${index}](screens/${index}.png)`).join('\n');
const candidates = extractMarkdownImageCandidates([markdown]);
expect(candidates).toHaveLength(12);
expect(candidates.at(-1)?.source).toBe('screens/11.png');
});
test('reuses extracted candidates across virtualized remounts without changing gallery behavior', () => {
__markdownImageCandidateCacheForTests.reset();
const contents = Array.from({ length: 20 }, (_, index) => `![image ${index}](screens/${index}.png)`);
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
expect(__markdownImageCandidateCacheForTests.stats().scans).toBe(12);
for (let round = 0; round < 1000; round += 1) {
expect(extractMarkdownImageCandidates(contents)).toHaveLength(12);
}
const stats = __markdownImageCandidateCacheForTests.stats();
expect(stats.entries).toBe(12);
expect(stats.scans).toBe(12);
});
test('scans one thousand independent messages once across virtualized remounts', () => {
__markdownImageCandidateCacheForTests.reset();
const messages = Array.from(
{ length: 1000 },
(_, index) => `![image ${index}](screens/${index}.png)`,
);
for (const message of messages) extractMarkdownImageCandidates([message]);
for (const message of messages) extractMarkdownImageCandidates([message]);
const stats = __markdownImageCandidateCacheForTests.stats();
expect(stats.entries).toBe(1000);
expect(stats.scans).toBe(1000);
});
test('gives embedded images without alt text a stable filename', () => {
const source = 'data:image/png;base64,AAAA';
expect(extractMarkdownImageCandidates([`![](${source})`])).toEqual([
{ source, filename: 'image.png' },
]);
expect(renderMarkdownSync(`![](${source})`, 'label')).toContain('image.png');
});
test('bounds cached candidate entries and bytes, and skips oversized individual content', () => {
__markdownImageCandidateCacheForTests.reset();
for (let index = 0; index < 1025; index += 1) {
extractMarkdownImageCandidates([`![image ${index}](screens/${index}.png)`]);
}
const boundedStats = __markdownImageCandidateCacheForTests.stats();
expect(boundedStats.entries).toBe(1024);
expect(boundedStats.bytes <= 2 * 1024 * 1024).toBe(true);
__markdownImageCandidateCacheForTests.reset();
const oversized = `![image](screens/large.png)\n${'x'.repeat(64 * 1024)}`;
extractMarkdownImageCandidates([oversized]);
extractMarkdownImageCandidates([oversized]);
expect(__markdownImageCandidateCacheForTests.stats()).toEqual({ entries: 0, bytes: 0, scans: 2 });
});
test('validates embedded image bytes against the declared MIME type', async () => {
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
const signal = new AbortController().signal;
expect(await resolveMarkdownImageSource(`data:image/png;base64,${png}`, signal)).toBe(`data:image/png;base64,${png}`);
await resolveMarkdownImageSource(`data:image/jpeg;base64,${png}`, signal).then(
() => { throw new Error('Expected mismatched image data to fail'); },
(error: unknown) => expect((error as Error).message).toBe('Unsupported image data'),
);
});
test('does not resolve images after cancellation', async () => {
const controller = new AbortController();
controller.abort();
await resolveMarkdownImageSource('https://example.test/image.png', controller.signal).then(
() => { throw new Error('Expected an aborted image load to fail'); },
(error: unknown) => expect((error as Error).name).toBe('AbortError'),
);
});
test('keeps the existing image renderer outside finalized assistant text', () => {
const html = renderMarkdownSync('![tool image](https://example.test/image.png)');
expect(html).toContain('<img src="https://example.test/image.png"');
expect(html).not.toContain('data-openchamber-markdown-image');
});
});
@@ -1,4 +1,4 @@
import { marked, type Tokens } from 'marked';
import { Marked, marked, type Tokens } from 'marked';
import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
@@ -6,11 +6,169 @@ import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/mess
import { isVSCodeRuntime } from '@/lib/desktop';
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
import { highlightCodeInWorker } from './markdown-worker';
import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
import { escapeRawMarkdownHtml, isLocalFileUrl, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity';
const escapeAttr = (value: string): string =>
value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const LOCAL_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp)(?:[?#].*)?$/i;
const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/;
const URL_SCHEME_RE = /^[A-Za-z][A-Za-z\d+.-]*:/;
export interface MarkdownImageCandidate {
source: string;
filename: string;
}
export type MarkdownImageMode = 'inline' | 'label';
export const MAX_MARKDOWN_IMAGE_COUNT = 12;
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES = 1024;
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES = 2 * 1024 * 1024;
const MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES = 64 * 1024;
type MarkdownImageCandidateCacheEntry = {
candidates: MarkdownImageCandidate[];
bytes: number;
};
const markdownImageCandidateCache = new Map<string, MarkdownImageCandidateCacheEntry>();
let markdownImageCandidateCacheBytes = 0;
let markdownImageCandidateScanCount = 0;
const isLocalMarkdownImageSource = (source: string): boolean => {
if (/^\/\//.test(source) || !LOCAL_IMAGE_EXTENSION_RE.test(source)) return false;
return WINDOWS_ABSOLUTE_PATH_RE.test(source)
|| /^file:\/\//i.test(source)
|| !URL_SCHEME_RE.test(source);
};
const isSupportedMarkdownImageSource = (source: string): boolean => (
/^(?:https?:)?\/\//i.test(source)
|| /^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
|| isLocalMarkdownImageSource(source)
);
const getMarkdownImageFilename = (source: string, fallback: string): string => {
if (/^data:image\/(png|jpeg|gif|webp)/i.test(source)) {
const extension = /^data:image\/([^;,]+)/i.exec(source)?.[1]?.replace('jpeg', 'jpg') ?? 'png';
return fallback.trim() || `image.${extension}`;
}
const path = source.split(/[?#]/, 1)[0]?.replace(/\\/g, '/') ?? '';
const encodedName = path.split('/').filter(Boolean).at(-1) ?? '';
if (!encodedName) return fallback.trim();
try {
return decodeURIComponent(encodedName);
} catch {
return encodedName;
}
};
const estimateMarkdownImageCandidateCacheEntryBytes = (
markdown: string,
candidates: readonly MarkdownImageCandidate[],
): number => (
(markdown.length + candidates.reduce((total, candidate) => total + candidate.source.length + candidate.filename.length, 0)) * 2
);
const scanMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
markdownImageCandidateScanCount += 1;
const candidates: MarkdownImageCandidate[] = [];
const seen = new Set<string>();
const tokens = marked.lexer(markdown);
marked.walkTokens(tokens, (token) => {
if (token.type !== 'image') return;
const source = token.href ?? '';
if (!source || !isSupportedMarkdownImageSource(source) || seen.has(source)) return;
const fallback = typeof token.text === 'string' ? token.text : '';
const filename = getMarkdownImageFilename(source, fallback);
if (!filename) return;
seen.add(source);
candidates.push({ source, filename });
});
return candidates;
};
const getMarkdownImageCandidates = (markdown: string): MarkdownImageCandidate[] => {
const cached = markdownImageCandidateCache.get(markdown);
if (cached) {
markdownImageCandidateCache.delete(markdown);
markdownImageCandidateCache.set(markdown, cached);
return cached.candidates;
}
const candidates = scanMarkdownImageCandidates(markdown);
const bytes = estimateMarkdownImageCandidateCacheEntryBytes(markdown, candidates);
if (bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRY_BYTES) return candidates;
while (
markdownImageCandidateCache.size >= MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_ENTRIES
|| markdownImageCandidateCacheBytes + bytes > MARKDOWN_IMAGE_CANDIDATE_CACHE_MAX_BYTES
) {
const oldest = markdownImageCandidateCache.entries().next().value;
if (!oldest) break;
markdownImageCandidateCache.delete(oldest[0]);
markdownImageCandidateCacheBytes -= oldest[1].bytes;
}
markdownImageCandidateCache.set(markdown, { candidates, bytes });
markdownImageCandidateCacheBytes += bytes;
return candidates;
};
/** @internal Test-only cache instrumentation for deterministic regression tests. */
export const __markdownImageCandidateCacheForTests = {
reset: (): void => {
markdownImageCandidateCache.clear();
markdownImageCandidateCacheBytes = 0;
markdownImageCandidateScanCount = 0;
},
stats: () => ({
entries: markdownImageCandidateCache.size,
bytes: markdownImageCandidateCacheBytes,
scans: markdownImageCandidateScanCount,
}),
};
const renderMarkdownImageLabel = ({
href,
title,
text,
}: {
href: string;
title?: string | null;
text: string;
}): string => {
const label = getMarkdownImageFilename(href ?? '', text);
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<span${titleAttr} class="inline-flex items-center gap-1 align-text-bottom text-muted-foreground" data-openchamber-markdown-image-label="true">${escapeAttr(label)}</span>`;
};
export const extractMarkdownImageCandidates = (
markdownTexts: readonly string[],
limit = MAX_MARKDOWN_IMAGE_COUNT,
): MarkdownImageCandidate[] => {
if (limit <= 0) return [];
const candidates: MarkdownImageCandidate[] = [];
const seen = new Set<string>();
for (const markdown of markdownTexts) {
if (!markdown || candidates.length >= limit) continue;
for (const candidate of getMarkdownImageCandidates(markdown)) {
if (candidates.length >= limit) break;
if (seen.has(candidate.source)) continue;
seen.add(candidate.source);
candidates.push({ ...candidate });
}
}
return candidates;
};
// ---------------------------------------------------------------------------
// Streaming block segmentation (port of OpenCode's markdown-stream)
// ---------------------------------------------------------------------------
@@ -163,7 +321,7 @@ const blockMathExtension = {
},
};
const parser = marked.use({
const createParser = (imageMode: MarkdownImageMode) => new Marked().use({
gfm: true,
breaks: false,
extensions: [inlineMathExtension, blockMathExtension],
@@ -187,9 +345,13 @@ const parser = marked.use({
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
return `<a href="${escapeAttr(target)}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`;
},
...(imageMode === 'label' ? { image: renderMarkdownImageLabel } : {}),
},
});
const inlineImageParser = createParser('inline');
const imageLabelParser = createParser('label');
// ---------------------------------------------------------------------------
// Math (KaTeX) — post-process the parsed HTML, skipping code/pre/kbd content
// ---------------------------------------------------------------------------
@@ -308,6 +470,10 @@ const ensureSanitizeHook = (): void => {
if (sanitizeHookInstalled) return;
if (typeof window === 'undefined' || !DOMPurify.isSupported) return;
sanitizeHookInstalled = true;
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (!(node instanceof HTMLAnchorElement)) return;
if (node.target !== '_blank') return;
@@ -346,14 +512,16 @@ export const markdownBlockCacheKey = (
contentHash: string,
mode: MarkdownBlock['mode'],
highlight: boolean,
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}`;
imageMode: MarkdownImageMode,
): string => `${contentHash}:${mode}:${highlight ? 1 : 0}:${imageMode}`;
/** Test-only: clear the render HTML cache between cases. */
export const resetMarkdownHtmlCacheForTests = (): void => {
htmlCache.clear();
};
const parseBlock = async (block: MarkdownBlock): Promise<string> => {
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = await Promise.resolve(parser.parse(block.src));
const withMath = renderMathExpressions(parsed);
const highlighted = block.highlight ? await highlightCodeBlocks(withMath) : withMath;
@@ -369,8 +537,9 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string): string => {
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
if (!text) return '';
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = parser.parse(text) as string;
const withMath = renderMathExpressions(parsed);
return sanitize(withMath);
@@ -398,6 +567,7 @@ export const renderMarkdownBlocks = async (
text: string,
streaming: boolean,
cacheKey: string,
imageMode: MarkdownImageMode = 'inline',
): Promise<RenderedBlock[]> => {
// Retained for call-site compatibility / debugging; lookup is content-addressed.
void cacheKey;
@@ -407,12 +577,12 @@ export const renderMarkdownBlocks = async (
return Promise.all(
blocks.map(async (block) => {
const contentHash = contentFingerprint(block.raw);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight);
const id = markdownBlockCacheKey(contentHash, block.mode, block.highlight, imageMode);
const cached = htmlCache.get(id);
if (cached !== undefined) {
return { id, html: cached };
}
const html = await parseBlock(block);
const html = await parseBlock(block, imageMode);
htmlCache.set(id, html, utf16Bytes(id) + utf16Bytes(html));
return { id, html };
}),
@@ -151,10 +151,12 @@ describe('markdownCore content-addressed htmlCache (#2769)', () => {
expect(highlightCalls).toBe(afterFirst + 1);
});
test('block cache keys are content-addressed (mode + highlight + hash)', () => {
expect(markdownBlockCacheKey('abc', 'full', true)).toBe('abc:full:1');
expect(markdownBlockCacheKey('abc', 'live', false)).toBe('abc:live:0');
expect(markdownBlockCacheKey('abc', 'full', true)).not.toBe(markdownBlockCacheKey('abc', 'full', false));
test('block cache keys are content-addressed (mode + highlight + imageMode + hash)', () => {
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).toBe('abc:full:1:inline');
expect(markdownBlockCacheKey('abc', 'live', false, 'inline')).toBe('abc:live:0:inline');
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', false, 'inline'));
// Image mode changes the rendered HTML, so it must not share a cache entry.
expect(markdownBlockCacheKey('abc', 'full', true, 'inline')).not.toBe(markdownBlockCacheKey('abc', 'full', true, 'label'));
});
test('multiple code fences in one document highlight concurrently', async () => {
@@ -0,0 +1,119 @@
import { describe, expect, mock, test } from 'bun:test';
let requestCount = 0;
let requestPaths: string[] = [];
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
'base64',
);
const runtimeFetch = mock(async (path: string, init?: RequestInit & { query?: Record<string, unknown> }) => {
requestPaths.push(path);
if (path === '/api/fs/stat') {
return new Response(JSON.stringify({ isFile: true, size: PNG.byteLength }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (path === '/api/fs/raw') {
return new Response(PNG, { status: 200, headers: { 'content-type': 'image/png' } });
}
requestCount += 1;
const body = JSON.parse(String(init?.body)) as { sources: string[] };
return new Response(JSON.stringify({
results: body.sources.map((source) => ({ source, status: 'ready', path: `/repo/${source}` })),
}), { status: 200, headers: { 'content-type': 'application/json' } });
});
const resolver = {
api: () => '',
authenticatedAsset: (path: string, query: Record<string, string | undefined>) => {
const params = new URLSearchParams(Object.entries(query).filter((entry): entry is [string, string] => Boolean(entry[1])));
return `${path}?${params}`;
},
};
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch }));
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver }));
class TestFileReader {
result: string | ArrayBuffer | null = null;
error: DOMException | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
readAsDataURL(blob: Blob) {
void blob.arrayBuffer().then((buffer) => {
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`;
this.onload?.();
}).catch((error) => {
this.error = error as DOMException;
this.onerror?.();
});
}
}
globalThis.FileReader = TestFileReader as unknown as typeof FileReader;
const {
getPreparedMarkdownImageUrl,
prepareLocalMarkdownImages,
resolveWorkspaceMarkdownImageSource,
} = await import('./markdownImageAssets');
describe('Markdown image asset preparation', () => {
test('prepares many images in one message-level request', async () => {
requestCount = 0;
const sources = Array.from({ length: 12 }, (_, index) => `${index}.png`);
const result = await prepareLocalMarkdownImages({
sources,
directory: '/repo',
sessionId: 'ses_batch',
messageId: 'msg_batch',
signal: new AbortController().signal,
});
expect(result.size).toBe(12);
expect(requestCount).toBe(1);
});
test('reuses preparation for one thousand messages after virtualized remounts', async () => {
requestCount = 0;
const requests = Array.from({ length: 1000 }, (_, index) => ({
sources: [`${index}.png`],
directory: '/repo',
sessionId: 'ses_long',
messageId: `msg_${index}`,
signal: new AbortController().signal,
}));
for (const request of requests) await prepareLocalMarkdownImages(request);
for (const request of requests) await prepareLocalMarkdownImages(request);
expect(requestCount).toBe(1000);
});
test('reuses the existing authenticated raw-file asset URL', () => {
const url = getPreparedMarkdownImageUrl({
status: 'ready',
path: '/tmp/opencode/image.png',
outsideFileGrant: 'grant-1',
}, '/repo');
expect(url).toContain('/api/fs/raw?');
expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png');
expect(url).toContain('outsideFileGrant=grant-1');
});
test('loads a workspace image through the local filesystem bridge', async () => {
requestPaths = [];
const url = await resolveWorkspaceMarkdownImageSource(
'screens/image.png',
'/repo',
new AbortController().signal,
);
expect(url.startsWith('data:image/png;base64,')).toBe(true);
expect(requestPaths).toEqual(['/api/fs/stat', '/api/fs/raw']);
});
});
@@ -0,0 +1,260 @@
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeUrlResolver, type RuntimeUrlResolver } from '@/lib/runtime-url';
import { isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
const MAX_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
const MAX_PREPARE_CACHE_ENTRIES = 1024;
const NON_READY_CACHE_MS = 30_000;
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]);
export type PreparedMarkdownImage =
| { status: 'ready'; path: string; outsideFileGrant?: string; expiresAt?: number }
| { status: 'missing' | 'error' };
type PrepareCacheEntry = {
result: Map<string, PreparedMarkdownImage>;
expiresAt: number;
};
const prepareCaches = new WeakMap<RuntimeUrlResolver, Map<string, PrepareCacheEntry>>();
const throwIfAborted = (signal: AbortSignal): void => {
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError');
};
const parseLocalImagePath = (source: string): string => {
let value = source;
if (/^file:\/\//i.test(value)) {
try {
const fileUrl = new URL(value);
if (fileUrl.protocol !== 'file:') return '';
value = fileUrl.host && fileUrl.host !== 'localhost'
? `//${fileUrl.host}${fileUrl.pathname}`
: fileUrl.pathname;
if (/^\/[A-Za-z]:\//.test(value)) value = value.slice(1);
} catch {
return '';
}
}
const path = value.split(/[?#]/, 1)[0] ?? '';
try {
return decodeURIComponent(path);
} catch {
return path;
}
};
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Unable to encode image'));
}
};
reader.onerror = () => reject(reader.error ?? new Error('Unable to encode image'));
reader.readAsDataURL(blob);
});
const hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
switch (mimeType) {
case 'image/png':
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
case 'image/jpeg':
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
case 'image/gif': {
const gif = ascii(0, 6);
return gif === 'GIF87a' || gif === 'GIF89a';
}
case 'image/webp':
return ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
default:
return false;
}
};
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) throw new Error('Unsupported image type');
if (blob.size > MAX_MARKDOWN_IMAGE_BYTES) throw new Error('Image is too large');
if (!await hasImageSignature(blob, mimeType)) throw new Error('Unsupported image data');
};
const validateDataImage = async (source: string): Promise<void> => {
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([\s\S]*)$/i.exec(source);
if (!match?.[1] || match[2] === undefined) throw new Error('Invalid image data URL');
if (match[2].length > Math.ceil(MAX_MARKDOWN_IMAGE_BYTES * 4 / 3) + 4) throw new Error('Image is too large');
let binary: string;
try {
binary = atob(match[2]);
} catch {
throw new Error('Invalid image data URL');
}
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
await validateImageBlob(new Blob([bytes]), match[1].toLowerCase());
};
export const isLocalMarkdownImageSource = (source: string): boolean => (
!/^(?:https?:)?\/\//i.test(source)
&& !/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)
);
export const prepareLocalMarkdownImages = async ({
sources,
directory,
sessionId,
messageId,
signal,
}: {
sources: readonly string[];
directory: string;
sessionId: string;
messageId: string;
signal: AbortSignal;
}): Promise<Map<string, PreparedMarkdownImage>> => {
const resolver = getRuntimeUrlResolver();
let cache = prepareCaches.get(resolver);
if (!cache) {
cache = new Map();
prepareCaches.set(resolver, cache);
}
const key = `${sessionId}\0${messageId}\0${directory}\0${sources.join('\0')}`;
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) {
cache.delete(key);
cache.set(key, cached);
return cached.result;
}
if (cached) cache.delete(key);
const response = await runtimeFetch(
`/api/openchamber/sessions/${encodeURIComponent(sessionId)}/markdown-image-grants`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ directory, messageId, sources }),
signal,
},
);
if (!response.ok) throw new Error(`Unable to prepare images (${response.status})`);
const payload = await response.json() as {
results?: Array<{
source?: string;
status?: string;
path?: string;
outsideFileGrant?: string;
expiresAt?: number;
}>;
};
const prepared = new Map<string, PreparedMarkdownImage>();
for (const result of payload.results ?? []) {
if (!result.source) continue;
if (result.status === 'ready' && result.path) {
prepared.set(result.source, {
status: 'ready',
path: result.path,
outsideFileGrant: result.outsideFileGrant,
expiresAt: result.expiresAt,
});
} else if (result.status === 'missing') {
prepared.set(result.source, { status: 'missing' });
} else {
prepared.set(result.source, { status: 'error' });
}
}
for (const source of sources) {
if (!prepared.has(source)) prepared.set(source, { status: 'error' });
}
while (cache.size >= MAX_PREPARE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!);
const allReady = [...prepared.values()].every((value) => value.status === 'ready');
const grantExpiry = Math.min(...[...prepared.values()]
.filter((value): value is Extract<PreparedMarkdownImage, { status: 'ready' }> => value.status === 'ready')
.map((value) => value.expiresAt ?? Number.POSITIVE_INFINITY));
cache.set(key, {
result: prepared,
expiresAt: allReady ? grantExpiry : Date.now() + NON_READY_CACHE_MS,
});
return prepared;
};
export const resolveMarkdownImageSource = async (
source: string,
signal: AbortSignal,
): Promise<string> => {
throwIfAborted(signal);
if (/^(?:https?:)?\/\//i.test(source)) return source;
if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(source)) {
await validateDataImage(source);
throwIfAborted(signal);
return source;
}
throw new Error('Local image has not been prepared');
};
/**
* VS Code has no OpenChamber server route for message-scoped temporary-file
* grants. Preserve its existing workspace-only gallery path through the local
* filesystem bridge, including the same size and signature validation.
*/
export const resolveWorkspaceMarkdownImageSource = async (
source: string,
directory: string,
signal: AbortSignal,
): Promise<string> => {
throwIfAborted(signal);
const localPath = parseLocalImagePath(source);
const absolutePath = toAbsoluteFilePath(directory, localPath);
if (!directory || !localPath || !isFilePathWithinDirectory(absolutePath, directory)) {
throw new Error('Image path is outside the active workspace');
}
const statResponse = await runtimeFetch('/api/fs/stat', {
query: { path: absolutePath, directory, optional: 'true' },
signal,
});
if (!statResponse.ok) throw new Error(`Unable to inspect image (${statResponse.status})`);
const stat = await statResponse.json() as { isFile?: boolean; size?: number };
if (!stat.isFile) throw new Error('Image path is not a file');
if (typeof stat.size === 'number' && stat.size > MAX_MARKDOWN_IMAGE_BYTES) {
throw new Error('Image is too large');
}
const response = await runtimeFetch('/api/fs/raw', {
query: { path: absolutePath, directory },
signal,
});
if (!response.ok) throw new Error(`Unable to load image (${response.status})`);
const mimeType = (response.headers.get('content-type') ?? '').split(';', 1)[0]?.toLowerCase() ?? '';
const contentLength = Number(response.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_MARKDOWN_IMAGE_BYTES) {
throw new Error('Image is too large');
}
const blob = await response.blob();
await validateImageBlob(blob, mimeType);
throwIfAborted(signal);
return blobToDataUrl(blob);
};
export const getPreparedMarkdownImageUrl = (
image: Extract<PreparedMarkdownImage, { status: 'ready' }>,
directory: string,
): string => getRuntimeUrlResolver().authenticatedAsset(
'/api/fs/raw',
{
path: image.path,
directory,
allowOutsideWorkspace: image.outsideFileGrant ? 'true' : undefined,
outsideFileGrant: image.outsideFileGrant,
},
);
@@ -4,3 +4,12 @@ export const escapeRawMarkdownHtml = (value: string): string =>
/** Active elements forbidden again at the final DOMPurify boundary. */
export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const;
export const isLocalFileUrl = (value: string): boolean => {
try {
const parsed = new URL(value);
return parsed.protocol === 'file:' && (!parsed.hostname || parsed.hostname === 'localhost');
} catch {
return false;
}
};