diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx index b1b24c7a..f0733abe 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx @@ -280,7 +280,7 @@ describe('CurrentTurnChangeCard – open-with buttons', () => { expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'file') }) - it('clicking index.html open-with opens menu with in-app browser item', async () => { + it('clicking project index.html open-with opens menu with workspace preview item', async () => { renderCard(['/w/proj/index.html']) const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) @@ -288,6 +288,18 @@ describe('CurrentTurnChangeCard – open-with buttons', () => { fireEvent.click(openWithBtn!) }) + expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument() + expect(screen.queryByText('openWith.inAppBrowser')).not.toBeInTheDocument() + }) + + it('clicking built dist index.html open-with opens menu with in-app browser item', async () => { + renderCard(['/w/proj/dist/index.html']) + const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) + + await act(async () => { + fireEvent.click(openWithBtn!) + }) + expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument() }) diff --git a/desktop/src/lib/handlePreviewLink.test.ts b/desktop/src/lib/handlePreviewLink.test.ts index 39fb2c84..78d55496 100644 --- a/desktop/src/lib/handlePreviewLink.test.ts +++ b/desktop/src/lib/handlePreviewLink.test.ts @@ -52,7 +52,7 @@ describe('handlePreviewLink', () => { ) }) - it('routes a relative .html file to openBrowser with the preview-fs (workspace) url', () => { + it('routes a built relative .html file to openBrowser with the preview-fs (workspace) url', () => { const deps = makeDeps() const handled = handlePreviewLink('out/index.html', deps) expect(handled).toBe(true) @@ -64,6 +64,14 @@ describe('handlePreviewLink', () => { expect(deps.openFilePreview).not.toHaveBeenCalled() }) + it('routes a frontend project index.html to workspace preview instead of static browser preview', () => { + const deps = makeDeps() + const handled = handlePreviewLink('todo-app/index.html', deps) + expect(handled).toBe(true) + expect(deps.openFilePreview).toHaveBeenCalledWith('s1', 'todo-app/index.html') + expect(deps.openBrowser).not.toHaveBeenCalled() + }) + it('routes relative previewable docs to openFilePreview with the relative path', () => { const deps = makeDeps() const handled = handlePreviewLink('docs/report.md', deps) diff --git a/desktop/src/lib/handlePreviewLink.ts b/desktop/src/lib/handlePreviewLink.ts index 5e863227..b66eea53 100644 --- a/desktop/src/lib/handlePreviewLink.ts +++ b/desktop/src/lib/handlePreviewLink.ts @@ -1,4 +1,5 @@ import { classifyPreviewLink } from './previewLinkRouter' +import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy' export type PreviewLinkDeps = { sessionId: string @@ -61,6 +62,10 @@ export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean // Absolute paths (incl. file:// → absolute) may live OUTSIDE the session // workspace, so serve them via the $HOME-sandboxed /local-file route. // Relative paths stay workspace-scoped via /preview-fs. + if (!isAbsoluteLocalPath(filePath) && !shouldOfferStaticHtmlPreview(filePath)) { + deps.openFilePreview(deps.sessionId, filePath) + return true + } const url = isAbsoluteLocalPath(filePath) ? localFileUrl(deps.serverBaseUrl, filePath) : previewFsUrl(deps.serverBaseUrl, deps.sessionId, filePath) diff --git a/desktop/src/lib/htmlPreviewPolicy.ts b/desktop/src/lib/htmlPreviewPolicy.ts new file mode 100644 index 00000000..fa81dd1e --- /dev/null +++ b/desktop/src/lib/htmlPreviewPolicy.ts @@ -0,0 +1,36 @@ +const HTML_EXT = /\.(html?|xhtml)$/i +const INDEX_HTML_RE = /(^|\/)index\.html?$/i + +const STATIC_OUTPUT_DIRS = new Set([ + 'build', + 'coverage', + 'dist', + 'docs', + 'lcov-report', + 'out', + 'public', + 'site', + 'storybook-static', +]) + +function normalizePathForPolicy(filePath: string): string { + return filePath + .split(/[?#]/, 1)[0]! + .replace(/\\/g, '/') + .replace(/^\.\//, '') +} + +export function isHtmlFilePath(filePath: string): boolean { + return HTML_EXT.test(normalizePathForPolicy(filePath)) +} + +export function shouldOfferStaticHtmlPreview(filePath: string): boolean { + const normalized = normalizePathForPolicy(filePath) + if (!HTML_EXT.test(normalized)) return false + if (!INDEX_HTML_RE.test(normalized)) return true + + return normalized + .split('/') + .filter(Boolean) + .some((segment) => STATIC_OUTPUT_DIRS.has(segment.toLowerCase())) +} diff --git a/desktop/src/lib/openWithContextForHref.test.ts b/desktop/src/lib/openWithContextForHref.test.ts index 5787648b..436e01ca 100644 --- a/desktop/src/lib/openWithContextForHref.test.ts +++ b/desktop/src/lib/openWithContextForHref.test.ts @@ -58,14 +58,24 @@ describe('openWithContextForWorkspaceFile', () => { expect(result).toEqual({ kind: 'file', absolutePath: '/w/proj/README.md', relPath: 'README.md', previewable: true }) }) - it('index.html rel path → also has inAppBrowserUrl equal to previewFsUrl', () => { - const result = openWithContextForWorkspaceFile('index.html', '/w/proj/index.html', { sessionId: SESSION, serverBaseUrl: BASE }) + it('frontend project index.html rel path → no inAppBrowserUrl because it needs a dev server', () => { + const result = openWithContextForWorkspaceFile('todo-app/index.html', '/w/proj/todo-app/index.html', { sessionId: SESSION, serverBaseUrl: BASE }) expect(result).toEqual({ kind: 'file', - absolutePath: '/w/proj/index.html', - relPath: 'index.html', + absolutePath: '/w/proj/todo-app/index.html', + relPath: 'todo-app/index.html', previewable: true, - inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'index.html'), + }) + }) + + it('built dist index.html rel path → has inAppBrowserUrl equal to previewFsUrl', () => { + const result = openWithContextForWorkspaceFile('todo-app/dist/index.html', '/w/proj/todo-app/dist/index.html', { sessionId: SESSION, serverBaseUrl: BASE }) + expect(result).toEqual({ + kind: 'file', + absolutePath: '/w/proj/todo-app/dist/index.html', + relPath: 'todo-app/dist/index.html', + previewable: true, + inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'todo-app/dist/index.html'), }) }) diff --git a/desktop/src/lib/openWithContextForHref.ts b/desktop/src/lib/openWithContextForHref.ts index 123e02a0..28065b4a 100644 --- a/desktop/src/lib/openWithContextForHref.ts +++ b/desktop/src/lib/openWithContextForHref.ts @@ -1,9 +1,8 @@ import { classifyPreviewLink } from './previewLinkRouter' +import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy' import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from './handlePreviewLink' import type { OpenWithContext } from './openWithItems' -const HTML_EXT = /\.(html?|xhtml)$/i - /** Build an open-with context for a workspace file (we have both its relative + absolute path). */ export function openWithContextForWorkspaceFile( relPath: string, @@ -15,7 +14,7 @@ export function openWithContextForWorkspaceFile( absolutePath, relPath, previewable: true, - inAppBrowserUrl: HTML_EXT.test(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined, + inAppBrowserUrl: shouldOfferStaticHtmlPreview(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined, } } @@ -38,10 +37,14 @@ export function openWithContextForHref( if (c.kind === 'browser-file' && c.path) { // Absolute paths may be outside the session workspace → serve via the // $HOME-sandboxed /local-file route; relative paths stay workspace-scoped. - const inAppBrowserUrl = isAbsoluteLocalPath(c.path) - ? localFileUrl(opts.serverBaseUrl, c.path) - : previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) - return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), inAppBrowserUrl } + const absolutePath = resolveAbsolute(opts.workDir, c.path) + if (isAbsoluteLocalPath(c.path)) { + return { kind: 'file', absolutePath, inAppBrowserUrl: localFileUrl(opts.serverBaseUrl, c.path) } + } + if (shouldOfferStaticHtmlPreview(c.path)) { + return { kind: 'file', absolutePath, inAppBrowserUrl: previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) } + } + return { kind: 'file', absolutePath, relPath: c.path, previewable: true } } return null } diff --git a/src/server/api/__tests__/previewFs.test.ts b/src/server/api/__tests__/previewFs.test.ts index bcb53224..def813df 100644 --- a/src/server/api/__tests__/previewFs.test.ts +++ b/src/server/api/__tests__/previewFs.test.ts @@ -35,6 +35,13 @@ function setupWorkspace() { writeFileSync(path.join(root, 'index.html'), '

ok

') mkdirSync(path.join(root, 'assets')) writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}') + mkdirSync(path.join(root, 'dist', 'assets'), { recursive: true }) + writeFileSync( + path.join(root, 'dist', 'index.html'), + '', + ) + writeFileSync(path.join(root, 'dist', 'assets', 'app.css'), 'body{color:red}') + writeFileSync(path.join(root, 'dist', 'assets', 'app.js'), 'console.log("ok")') writeFileSync(path.join(root, 'clip.mp4'), VIDEO_BYTES) return root } @@ -87,6 +94,17 @@ describe('handlePreviewFs', () => { expect(res.headers.get('content-type')).toBe('text/css; charset=utf-8') }) + it('rewrites root-relative HTML assets to stay under the preview-fs file directory', async () => { + const root = setupWorkspace() + const resolve = async () => root + const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/dist/index.html'), resolve) + expect(res.status).toBe(200) + const body = await res.text() + expect(body).toContain('') + expect(body).toContain('href="/preview-fs/s1/dist/assets/app.css"') + expect(body).toContain('src="/preview-fs/s1/dist/assets/app.js"') + }) + it('blocks path traversal with 403', async () => { const root = setupWorkspace() const resolve = async () => root diff --git a/src/server/api/previewFs.ts b/src/server/api/previewFs.ts index 347a54ab..7dc7a92e 100644 --- a/src/server/api/previewFs.ts +++ b/src/server/api/previewFs.ts @@ -52,6 +52,7 @@ const PREFIX = '/preview-fs/' * against pathological / runaway files — not as a memory limit. */ const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024 +const ROOT_RELATIVE_HTML_ATTR_RE = /\b(src|href)=(["'])\/(?!\/)([^"']*)\2/gi export interface ParsedRange { start: number @@ -149,7 +150,71 @@ export async function handlePreviewFs( return new Response('forbidden', { status: 403 }) } - return serveFileWithRange(target, reqHeaders) + return servePreviewFsFile(target, url.pathname, reqHeaders) +} + +function previewHtmlBasePath(pathname: string): string { + const slash = pathname.lastIndexOf('/') + if (slash < 0) return '/' + return pathname.slice(0, slash + 1) +} + +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +export function rewritePreviewHtml(content: string, basePath: string): string { + const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/` + const rewrittenRootAssets = content.replace( + ROOT_RELATIVE_HTML_ATTR_RE, + (_match, attr: string, quote: string, value: string) => + `${attr}=${quote}${normalizedBase}${value}${quote}`, + ) + + if (/]*>/i, + (head) => `${head}`, + ) +} + +async function servePreviewFsFile( + target: string, + requestPathname: string, + reqHeaders?: Headers, +): Promise { + const ext = path.extname(target).toLowerCase() + if ((ext !== '.html' && ext !== '.htm') || reqHeaders?.has('range')) { + return serveFileWithRange(target, reqHeaders) + } + + let stat: fs.Stats + try { + stat = fs.statSync(target) + } catch { + return new Response('not found', { status: 404 }) + } + if (!stat.isFile()) return new Response('not a file', { status: 404 }) + if (stat.size > MAX_FILE_BYTES) return new Response('too large', { status: 413 }) + + const content = fs.readFileSync(target, 'utf8') + const transformed = rewritePreviewHtml(content, previewHtmlBasePath(requestPathname)) + + return new Response(transformed, { + status: 200, + headers: { + 'Content-Type': contentTypeForPath(target), + 'Content-Length': String(Buffer.byteLength(transformed)), + 'Cache-Control': 'no-cache', + }, + }) } /**