fix(markdown): align image gallery authorization

This commit is contained in:
Bohdan Triapitsyn
2026-08-14 17:27:07 +03:00
parent 90780258cd
commit 12b5857b3f
8 changed files with 397 additions and 47 deletions
@@ -9,6 +9,7 @@ import {
subscribeRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken,
} from '@/lib/runtime-auth'; } from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { isVSCodeRuntime } from '@/lib/desktop';
import type { ToolPopupContent } from './message/types'; import type { ToolPopupContent } from './message/types';
import { import {
extractMarkdownImageCandidates, extractMarkdownImageCandidates,
@@ -20,6 +21,7 @@ import {
isLocalMarkdownImageSource, isLocalMarkdownImageSource,
prepareLocalMarkdownImages, prepareLocalMarkdownImages,
resolveMarkdownImageSource, resolveMarkdownImageSource,
resolveWorkspaceMarkdownImageSource,
type PreparedMarkdownImage, type PreparedMarkdownImage,
} from './markdown/markdownImageAssets'; } from './markdown/markdownImageAssets';
@@ -66,8 +68,17 @@ const MarkdownImageThumbnail: React.FC<{
directory: string; directory: string;
assetAuthReady: boolean; assetAuthReady: boolean;
assetAuthNonce: number; assetAuthNonce: number;
useWorkspaceFsBridge: boolean;
onShowPopup?: (content: ToolPopupContent) => void; onShowPopup?: (content: ToolPopupContent) => void;
}> = ({ candidate, preparation, directory, assetAuthReady, assetAuthNonce, onShowPopup }) => { }> = ({
candidate,
preparation,
directory,
assetAuthReady,
assetAuthNonce,
useWorkspaceFsBridge,
onShowPopup,
}) => {
const { t } = useI18n(); const { t } = useI18n();
const thumbnailRef = React.useRef<HTMLButtonElement>(null); const thumbnailRef = React.useRef<HTMLButtonElement>(null);
const [shouldLoad, setShouldLoad] = React.useState(false); const [shouldLoad, setShouldLoad] = React.useState(false);
@@ -94,7 +105,19 @@ const MarkdownImageThumbnail: React.FC<{
}, [shouldLoad]); }, [shouldLoad]);
React.useEffect(() => { React.useEffect(() => {
if (!shouldLoad || (local && !preparation)) return; if (!shouldLoad || (local && !useWorkspaceFsBridge && !preparation)) return;
if (local && useWorkspaceFsBridge) {
const controller = new AbortController();
setImage({ url: '', status: 'loading' });
void resolveWorkspaceMarkdownImageSource(candidate.source, directory, controller.signal).then((url) => {
if (controller.signal.aborted) return;
setImage({ url, status: 'loading' });
}).catch(() => {
if (controller.signal.aborted) return;
setImage({ url: '', status: 'error' });
});
return () => controller.abort();
}
if (local) { if (local) {
if (preparation?.status !== 'ready') { if (preparation?.status !== 'ready') {
setImage({ url: '', status: 'error' }); setImage({ url: '', status: 'error' });
@@ -114,7 +137,7 @@ const MarkdownImageThumbnail: React.FC<{
setImage({ url: '', status: 'error' }); setImage({ url: '', status: 'error' });
}); });
return () => controller.abort(); return () => controller.abort();
}, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad]); }, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad, useWorkspaceFsBridge]);
const openPreview = React.useCallback(() => { const openPreview = React.useCallback(() => {
if (image.status === 'error') { if (image.status === 'error') {
@@ -185,16 +208,21 @@ export const MarkdownImageGallery: React.FC<{
const [shouldPrepare, setShouldPrepare] = React.useState(false); const [shouldPrepare, setShouldPrepare] = React.useState(false);
const [prepared, setPrepared] = React.useState<Map<string, PreparedMarkdownImage> | null>(null); const [prepared, setPrepared] = React.useState<Map<string, PreparedMarkdownImage> | null>(null);
const [prepareEpoch, setPrepareEpoch] = React.useState(0); const [prepareEpoch, setPrepareEpoch] = React.useState(0);
const useWorkspaceFsBridge = isVSCodeRuntime();
const candidates = React.useMemo( const candidates = React.useMemo(
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT), () => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
[contents], [contents],
); );
const localSources = React.useMemo( const serverPreparationSources = React.useMemo(
() => candidates.filter((candidate) => isLocalMarkdownImageSource(candidate.source)).map((candidate) => candidate.source), () => useWorkspaceFsBridge
[candidates], ? []
: candidates
.filter((candidate) => isLocalMarkdownImageSource(candidate.source))
.map((candidate) => candidate.source),
[candidates, useWorkspaceFsBridge],
); );
React.useEffect(() => { React.useEffect(() => {
if (localSources.length === 0 || shouldPrepare) return; if (serverPreparationSources.length === 0 || shouldPrepare) return;
const gallery = galleryRef.current; const gallery = galleryRef.current;
if (!gallery || typeof IntersectionObserver === 'undefined') { if (!gallery || typeof IntersectionObserver === 'undefined') {
setShouldPrepare(true); setShouldPrepare(true);
@@ -207,13 +235,13 @@ export const MarkdownImageGallery: React.FC<{
}, { rootMargin: '200px' }); }, { rootMargin: '200px' });
observer.observe(gallery); observer.observe(gallery);
return () => observer.disconnect(); return () => observer.disconnect();
}, [localSources.length, shouldPrepare]); }, [serverPreparationSources.length, shouldPrepare]);
React.useEffect(() => { React.useEffect(() => {
if (!shouldPrepare || !sessionId || localSources.length === 0) return; if (!shouldPrepare || !sessionId || serverPreparationSources.length === 0) return;
const controller = new AbortController(); const controller = new AbortController();
void prepareLocalMarkdownImages({ void prepareLocalMarkdownImages({
sources: localSources, sources: serverPreparationSources,
directory, directory,
sessionId, sessionId,
messageId, messageId,
@@ -223,11 +251,11 @@ export const MarkdownImageGallery: React.FC<{
setPrepared(result); setPrepared(result);
}).catch(() => { }).catch(() => {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setPrepared(new Map(localSources.map((source) => [source, { status: 'error' }]))); setPrepared(new Map(serverPreparationSources.map((source) => [source, { status: 'error' }])));
} }
}); });
return () => controller.abort(); return () => controller.abort();
}, [directory, localSources, messageId, prepareEpoch, sessionId, shouldPrepare]); }, [directory, messageId, prepareEpoch, serverPreparationSources, sessionId, shouldPrepare]);
React.useEffect(() => { React.useEffect(() => {
const nextExpiry = Math.min(...[...(prepared?.values() ?? [])] const nextExpiry = Math.min(...[...(prepared?.values() ?? [])]
@@ -257,6 +285,7 @@ export const MarkdownImageGallery: React.FC<{
directory={directory} directory={directory}
assetAuthReady={assetAuth.ready} assetAuthReady={assetAuth.ready}
assetAuthNonce={assetAuth.nonce} assetAuthNonce={assetAuth.nonce}
useWorkspaceFsBridge={useWorkspaceFsBridge}
onShowPopup={onShowPopup} onShowPopup={onShowPopup}
/> />
))} ))}
@@ -1,7 +1,22 @@
import { describe, expect, mock, test } from 'bun:test'; import { describe, expect, mock, test } from 'bun:test';
let requestCount = 0; let requestCount = 0;
const runtimeFetch = mock(async (_path: string, init?: RequestInit) => { 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; requestCount += 1;
const body = JSON.parse(String(init?.body)) as { sources: string[] }; const body = JSON.parse(String(init?.body)) as { sources: string[] };
return new Response(JSON.stringify({ return new Response(JSON.stringify({
@@ -19,7 +34,30 @@ const resolver = {
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch })); mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch }));
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver })); mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => resolver }));
const { getPreparedMarkdownImageUrl, prepareLocalMarkdownImages } = await import('./markdownImageAssets'); 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', () => { describe('Markdown image asset preparation', () => {
test('prepares many images in one message-level request', async () => { test('prepares many images in one message-level request', async () => {
@@ -65,4 +103,17 @@ describe('Markdown image asset preparation', () => {
expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png'); expect(url).toContain('path=%2Ftmp%2Fopencode%2Fimage.png');
expect(url).toContain('outsideFileGrant=grant-1'); 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']);
});
}); });
@@ -1,5 +1,6 @@
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeUrlResolver, type RuntimeUrlResolver } from '@/lib/runtime-url'; 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_MARKDOWN_IMAGE_BYTES = 10 * 1024 * 1024;
const MAX_PREPARE_CACHE_ENTRIES = 1024; const MAX_PREPARE_CACHE_ENTRIES = 1024;
@@ -26,19 +27,60 @@ const throwIfAborted = (signal: AbortSignal): void => {
if (signal.aborted) throw new DOMException('Image load aborted', 'AbortError'); 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 hasImageSignature = async (blob: Blob, mimeType: string): Promise<boolean> => {
const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer()); const bytes = new Uint8Array(await blob.slice(0, 12).arrayBuffer());
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end)); const ascii = (start: number, end: number) => String.fromCharCode(...bytes.slice(start, end));
if (mimeType === 'image/png') { switch (mimeType) {
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG' case 'image/png':
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a; 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;
} }
if (mimeType === 'image/jpeg') return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
if (mimeType === 'image/gif') {
const gif = ascii(0, 6);
return gif === 'GIF87a' || gif === 'GIF89a';
}
return mimeType === 'image/webp' && ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
}; };
const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => { const validateImageBlob = async (blob: Blob, mimeType: string): Promise<void> => {
@@ -158,6 +200,52 @@ export const resolveMarkdownImageSource = async (
throw new Error('Local image has not been prepared'); 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 = ( export const getPreparedMarkdownImageUrl = (
image: Extract<PreparedMarkdownImage, { status: 'ready' }>, image: Extract<PreparedMarkdownImage, { status: 'ready' }>,
directory: string, directory: string,
@@ -63,17 +63,19 @@ Use this doc when you ask an agent to change tool/header/description behavior.
PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the PNG/JPEG/GIF/WebP image candidates into one 100px thumbnail gallery in the
message-completion area after all message text and above the turn's changed message-completion area after all message text and above the turn's changed
files. Each muted filename caption includes the shared image-file icon. files. Each muted filename caption includes the shared image-file icon.
HTTP(S) images keep their browser URL. Embedded and workspace-local gallery HTTP(S) images keep their browser URL. Embedded and workspace-local images
images are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Local paths are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Chat Markdown uses
reuse the existing authenticated `/api/fs/raw` asset URL. Chat the assistant image-label policy without gallery-specific link rewriting,
Markdown uses the assistant image-label policy without gallery-specific completion-state switching, or hidden placeholders. A
link rewriting, completion-state switching, or hidden placeholders. A
completed assistant message hydrates at most 12 unique image candidates, completed assistant message hydrates at most 12 unique image candidates,
including persisted text parts that omit their optional part-level end time. including persisted text parts that omit their optional part-level end time.
A gallery approaching the viewport prepares all local candidates in one In server-backed runtimes, a gallery approaching the viewport prepares all
message-level request, while each asset URL loads only when its own thumbnail local candidates in one message-level request, then reuses the authenticated
approaches the viewport. Mounted historical messages therefore do not `/api/fs/raw` asset route. Each URL loads only when its thumbnail approaches
eagerly read every image. the viewport. VS Code instead loads workspace-contained images through its
local filesystem bridge and never calls the server grant route; OpenCode
temporary-directory images remain unsupported there. Mounted historical
messages therefore do not eagerly read every image.
Gallery clicks do not introduce or alter preview chrome: desktop and mobile Gallery clicks do not introduce or alter preview chrome: desktop and mobile
both reuse the pre-existing attachment image preview overlay. both reuse the pre-existing attachment image preview overlay.
Workspace-external images receive the existing path-bound `outsideFileGrant` Workspace-external images receive the existing path-bound `outsideFileGrant`
+4 -1
View File
@@ -45,7 +45,10 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
- `bridge-localfs-proxy-runtime.ts` - `bridge-localfs-proxy-runtime.ts`
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
- Returns an explicit unsupported response for server-owned Markdown image grants instead of forwarding them to OpenCode. - Workspace-contained Markdown gallery images use these local filesystem
routes without calling the server grant route. Grant requests for OpenCode
temporary-directory images return an explicit unsupported response instead
of being forwarded to OpenCode.
- `bridge-proxy-runtime.ts` - `bridge-proxy-runtime.ts`
- Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies. - Proxy route handlers (`api:proxy`, `api:session:message`) with injected helper dependencies.
@@ -14,6 +14,9 @@ different machine.
- `POST /api/openchamber/sessions/:sessionId/markdown-image-grants` prepares up to 12 - `POST /api/openchamber/sessions/:sessionId/markdown-image-grants` prepares up to 12
local images in one message-level request. The server fetches the assistant local images in one message-level request. The server fetches the assistant
message once and verifies every exact image source before reading files. message once and verifies every exact image source before reading files.
- Authorization recognizes the same common inline and reference-style image
destinations collected by the UI, including balanced parentheses, while
excluding fenced and inline code.
- Relative and workspace-contained absolute paths resolve against the active - Relative and workspace-contained absolute paths resolve against the active
directory. Other absolute paths are accepted only inside directory. Other absolute paths are accepted only inside
`os.tmpdir()/opencode` after `realpath` resolution. `os.tmpdir()/opencode` after `realpath` resolution.
@@ -27,4 +30,7 @@ different machine.
The routes are OpenChamber-owned and must be registered before the generic The routes are OpenChamber-owned and must be registered before the generic
OpenCode proxy. Web, Electron, hosted mobile, and Capacitor use the shared OpenCode proxy. Web, Electron, hosted mobile, and Capacitor use the shared
server implementation. VS Code returns an explicit unsupported response. server implementation. VS Code does not call this route for workspace images;
those use its local filesystem bridge. If called, the grant route returns an
explicit unsupported response because OpenCode temporary images are not
supported there.
@@ -42,25 +42,150 @@ const hasImageSignature = (bytes) => {
|| (header.startsWith('RIFF') && header.slice(8, 12) === 'WEBP'); || (header.startsWith('RIFF') && header.slice(8, 12) === 'WEBP');
}; };
const markdownImageSources = (message) => { const normalizeReferenceLabel = (value) => value.trim().replace(/\s+/g, ' ').toLowerCase();
const sources = new Set();
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 : []) { for (const part of Array.isArray(message?.parts) ? message.parts : []) {
if (part?.type !== 'text' || typeof part.text !== 'string') continue; if (part?.type !== 'text' || typeof part.text !== 'string') continue;
// Code examples must never authorize file access, even when they contain image syntax. let fence = null;
let fenced = false;
for (const line of part.text.split('\n')) { for (const line of part.text.split('\n')) {
if (/^\s{0,3}(?:```|~~~)/.test(line)) { const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
fenced = !fenced; 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; continue;
} }
if (fenced) continue; if (fence) continue;
const visible = line.replace(/`+[^`]*`+/g, ''); lines.push(line.replace(/`+[^`]*`+/g, ''));
const pattern = /(?<!\\)!\[[^\]]*]\(\s*(?:<([^>\n]+)>|([^\s)\n]+))/g; }
let match = pattern.exec(visible); }
while (match) { return lines;
sources.add(match[1] || match[2]); };
match = pattern.exec(visible);
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; return sources;
@@ -129,6 +129,52 @@ describe('session image assets', () => {
]); ]);
}); });
it('authorizes reference-style image syntax using its resolved destination', async () => {
const source = 'reference.png';
const fixture = await createFixture({
sources: [source],
markdown: '![screenshot][result]\n\n[result]: reference.png',
});
await fs.writeFile(path.join(fixture.directory, source), PNG);
const response = await prepare(fixture.app, fixture.directory, fixture.sources);
expect(response.body.results).toEqual([
expect.objectContaining({ source, status: 'ready' }),
]);
});
it('authorizes inline image destinations containing balanced parentheses', async () => {
const source = 'screen(1).png';
const fixture = await createFixture({
sources: [source],
markdown: `![screenshot](${source})`,
});
await fs.writeFile(path.join(fixture.directory, source), PNG);
const response = await prepare(fixture.app, fixture.directory, fixture.sources);
expect(response.body.results).toEqual([
expect.objectContaining({ source, status: 'ready' }),
]);
});
it('requires inline image destinations with titles to close', async () => {
const sources = ['valid.png', 'malformed.png'];
const fixture = await createFixture({
sources,
markdown: '![valid](valid.png "preview")\n![malformed](malformed.png "preview"',
});
await Promise.all(sources.map((source) => fs.writeFile(path.join(fixture.directory, source), PNG)));
const response = await prepare(fixture.app, fixture.directory, sources);
expect(response.body.results).toEqual([
expect.objectContaining({ source: 'valid.png', status: 'ready' }),
{ source: 'malformed.png', status: 'error' },
]);
});
it('rejects a source that the message does not reference', async () => { it('rejects a source that the message does not reference', async () => {
const fixture = await createFixture({ markdown: 'No image here.' }); const fixture = await createFixture({ markdown: 'No image here.' });
const response = await prepare(fixture.app, fixture.directory, fixture.sources); const response = await prepare(fixture.app, fixture.directory, fixture.sources);