fix(markdown): align image gallery authorization
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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: ``,
|
||||||
|
});
|
||||||
|
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: '\n;
|
||||||
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user