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,
} from '@/lib/runtime-auth';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { isVSCodeRuntime } from '@/lib/desktop';
import type { ToolPopupContent } from './message/types';
import {
extractMarkdownImageCandidates,
@@ -20,6 +21,7 @@ import {
isLocalMarkdownImageSource,
prepareLocalMarkdownImages,
resolveMarkdownImageSource,
resolveWorkspaceMarkdownImageSource,
type PreparedMarkdownImage,
} from './markdown/markdownImageAssets';
@@ -66,8 +68,17 @@ const MarkdownImageThumbnail: React.FC<{
directory: string;
assetAuthReady: boolean;
assetAuthNonce: number;
useWorkspaceFsBridge: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
}> = ({ candidate, preparation, directory, assetAuthReady, assetAuthNonce, onShowPopup }) => {
}> = ({
candidate,
preparation,
directory,
assetAuthReady,
assetAuthNonce,
useWorkspaceFsBridge,
onShowPopup,
}) => {
const { t } = useI18n();
const thumbnailRef = React.useRef<HTMLButtonElement>(null);
const [shouldLoad, setShouldLoad] = React.useState(false);
@@ -94,7 +105,19 @@ const MarkdownImageThumbnail: React.FC<{
}, [shouldLoad]);
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 (preparation?.status !== 'ready') {
setImage({ url: '', status: 'error' });
@@ -114,7 +137,7 @@ const MarkdownImageThumbnail: React.FC<{
setImage({ url: '', status: 'error' });
});
return () => controller.abort();
}, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad]);
}, [assetAuthNonce, assetAuthReady, candidate.source, directory, local, preparation, shouldLoad, useWorkspaceFsBridge]);
const openPreview = React.useCallback(() => {
if (image.status === 'error') {
@@ -185,16 +208,21 @@ export const MarkdownImageGallery: React.FC<{
const [shouldPrepare, setShouldPrepare] = React.useState(false);
const [prepared, setPrepared] = React.useState<Map<string, PreparedMarkdownImage> | null>(null);
const [prepareEpoch, setPrepareEpoch] = React.useState(0);
const useWorkspaceFsBridge = isVSCodeRuntime();
const candidates = React.useMemo(
() => extractMarkdownImageCandidates(contents, MAX_MARKDOWN_IMAGE_COUNT),
[contents],
);
const localSources = React.useMemo(
() => candidates.filter((candidate) => isLocalMarkdownImageSource(candidate.source)).map((candidate) => candidate.source),
[candidates],
const serverPreparationSources = React.useMemo(
() => useWorkspaceFsBridge
? []
: candidates
.filter((candidate) => isLocalMarkdownImageSource(candidate.source))
.map((candidate) => candidate.source),
[candidates, useWorkspaceFsBridge],
);
React.useEffect(() => {
if (localSources.length === 0 || shouldPrepare) return;
if (serverPreparationSources.length === 0 || shouldPrepare) return;
const gallery = galleryRef.current;
if (!gallery || typeof IntersectionObserver === 'undefined') {
setShouldPrepare(true);
@@ -207,13 +235,13 @@ export const MarkdownImageGallery: React.FC<{
}, { rootMargin: '200px' });
observer.observe(gallery);
return () => observer.disconnect();
}, [localSources.length, shouldPrepare]);
}, [serverPreparationSources.length, shouldPrepare]);
React.useEffect(() => {
if (!shouldPrepare || !sessionId || localSources.length === 0) return;
if (!shouldPrepare || !sessionId || serverPreparationSources.length === 0) return;
const controller = new AbortController();
void prepareLocalMarkdownImages({
sources: localSources,
sources: serverPreparationSources,
directory,
sessionId,
messageId,
@@ -223,11 +251,11 @@ export const MarkdownImageGallery: React.FC<{
setPrepared(result);
}).catch(() => {
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();
}, [directory, localSources, messageId, prepareEpoch, sessionId, shouldPrepare]);
}, [directory, messageId, prepareEpoch, serverPreparationSources, sessionId, shouldPrepare]);
React.useEffect(() => {
const nextExpiry = Math.min(...[...(prepared?.values() ?? [])]
@@ -257,6 +285,7 @@ export const MarkdownImageGallery: React.FC<{
directory={directory}
assetAuthReady={assetAuth.ready}
assetAuthNonce={assetAuth.nonce}
useWorkspaceFsBridge={useWorkspaceFsBridge}
onShowPopup={onShowPopup}
/>
))}
@@ -1,7 +1,22 @@
import { describe, expect, mock, test } from 'bun:test';
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;
const body = JSON.parse(String(init?.body)) as { sources: string[] };
return new Response(JSON.stringify({
@@ -19,7 +34,30 @@ const resolver = {
mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch }));
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', () => {
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('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 { 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;
@@ -26,19 +27,60 @@ 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));
if (mimeType === 'image/png') {
return bytes[0] === 0x89 && ascii(1, 4) === 'PNG'
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
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;
}
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> => {
@@ -158,6 +200,52 @@ export const resolveMarkdownImageSource = async (
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,
@@ -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
message-completion area after all message text and above the turn's changed
files. Each muted filename caption includes the shared image-file icon.
HTTP(S) images keep their browser URL. Embedded and workspace-local gallery
images are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Local paths
reuse the existing authenticated `/api/fs/raw` asset URL. Chat
Markdown uses the assistant image-label policy without gallery-specific
link rewriting, completion-state switching, or hidden placeholders. A
HTTP(S) images keep their browser URL. Embedded and workspace-local images
are limited to 10 MiB and validated as PNG/JPEG/GIF/WebP. Chat Markdown uses
the assistant image-label policy without gallery-specific link rewriting,
completion-state switching, or hidden placeholders. A
completed assistant message hydrates at most 12 unique image candidates,
including persisted text parts that omit their optional part-level end time.
A gallery approaching the viewport prepares all local candidates in one
message-level request, while each asset URL loads only when its own thumbnail
approaches the viewport. Mounted historical messages therefore do not
eagerly read every image.
In server-backed runtimes, a gallery approaching the viewport prepares all
local candidates in one message-level request, then reuses the authenticated
`/api/fs/raw` asset route. Each URL loads only when its thumbnail approaches
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
both reuse the pre-existing attachment image preview overlay.
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`
- 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`
- 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
local images in one message-level request. The server fetches the assistant
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
directory. Other absolute paths are accepted only inside
`os.tmpdir()/opencode` after `realpath` resolution.
@@ -27,4 +30,7 @@ different machine.
The routes are OpenChamber-owned and must be registered before the generic
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');
};
const markdownImageSources = (message) => {
const sources = new Set();
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;
// Code examples must never authorize file access, even when they contain image syntax.
let fenced = false;
let fence = null;
for (const line of part.text.split('\n')) {
if (/^\s{0,3}(?:```|~~~)/.test(line)) {
fenced = !fenced;
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 (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);
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;
@@ -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 () => {
const fixture = await createFixture({ markdown: 'No image here.' });
const response = await prepare(fixture.app, fixture.directory, fixture.sources);