From 36a3e4c0187247c29ee7429ef56621b6ba09ff61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 12:32:34 +0800 Subject: [PATCH] feat(server): serve sandboxed workspace files via /preview-fs Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server/api/__tests__/previewFs.test.ts | 45 ++++++++++++++- src/server/api/previewFs.ts | 65 ++++++++++++++++++++++ src/server/index.ts | 30 +++++++++- 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/server/api/__tests__/previewFs.test.ts b/src/server/api/__tests__/previewFs.test.ts index 38b2df37..f8d47814 100644 --- a/src/server/api/__tests__/previewFs.test.ts +++ b/src/server/api/__tests__/previewFs.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test' -import { contentTypeForPath } from '../previewFs' +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { contentTypeForPath, handlePreviewFs } from '../previewFs' describe('contentTypeForPath', () => { it('maps common web asset extensions', () => { @@ -14,3 +17,43 @@ describe('contentTypeForPath', () => { expect(contentTypeForPath('/x/file.bin')).toBe('application/octet-stream') }) }) + +function setupWorkspace() { + const root = mkdtempSync(path.join(tmpdir(), 'pfs-')) + writeFileSync(path.join(root, 'index.html'), '

ok

') + mkdirSync(path.join(root, 'assets')) + writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}') + return root +} + +describe('handlePreviewFs', () => { + it('serves an in-workspace file with content-type', async () => { + const root = setupWorkspace() + const resolve = async (id: string) => (id === 's1' ? root : null) + const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/index.html'), resolve) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8') + expect(await res.text()).toBe('

ok

') + }) + + it('serves nested assets', async () => { + const root = setupWorkspace() + const resolve = async () => root + const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/assets/a.css'), resolve) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/css; charset=utf-8') + }) + + it('blocks path traversal with 403', async () => { + const root = setupWorkspace() + const resolve = async () => root + const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/../../etc/passwd'), resolve) + expect(res.status).toBe(403) + }) + + it('404 when session has no workdir', async () => { + const resolve = async () => null + const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/sX/index.html'), resolve) + expect(res.status).toBe(404) + }) +}) diff --git a/src/server/api/previewFs.ts b/src/server/api/previewFs.ts index 61b3e676..9d9dfe9a 100644 --- a/src/server/api/previewFs.ts +++ b/src/server/api/previewFs.ts @@ -1,4 +1,9 @@ +import * as fs from 'node:fs' import * as path from 'node:path' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from '../services/windowsDrivePath.js' const CONTENT_TYPES: Record = { html: 'text/html; charset=utf-8', @@ -24,3 +29,63 @@ export function contentTypeForPath(filePath: string): string { const ext = path.extname(filePath).slice(1).toLowerCase() return CONTENT_TYPES[ext] ?? 'application/octet-stream' } + +export type ResolveWorkDir = (sessionId: string) => Promise + +const PREFIX = '/preview-fs/' +const MAX_FILE_BYTES = 50 * 1024 * 1024 + +/** + * Serve a single file from a session's sandboxed workspace directory. + * + * URL shape: `/preview-fs//` where `` may itself + * contain `/` separators. The WHATWG URL parser collapses `..` segments before + * this handler runs, so a traversal attempt such as + * `/preview-fs/s1/../../etc/passwd` arrives with its pathname normalized to + * `/etc/passwd` — i.e. the `/preview-fs/` prefix is gone. We treat any request + * that lost the prefix as a sandbox escape and return 403. Requests that keep + * the prefix are additionally re-validated against the resolved work-dir root. + */ +export async function handlePreviewFs( + url: URL, + resolveWorkDir: ResolveWorkDir, +): Promise { + if (!url.pathname.startsWith(PREFIX)) { + return new Response('forbidden', { status: 403 }) + } + + const rest = url.pathname.slice(PREFIX.length) + const slash = rest.indexOf('/') + if (slash <= 0) return new Response('bad request', { status: 400 }) + + const sessionId = decodeURIComponent(rest.slice(0, slash)) + const relRaw = decodeURIComponent(rest.slice(slash + 1)) + + const workDir = await resolveWorkDir(sessionId) + if (!workDir) return new Response('no workdir', { status: 404 }) + + const root = path.resolve(normalizeDriveRootPathForPlatform(workDir)) + const target = path.resolve(root, relRaw) + if (!isSameOrInsidePathForPlatform(target, root)) { + return new Response('forbidden', { status: 403 }) + } + + 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 data = fs.readFileSync(target) + return new Response(data, { + status: 200, + headers: { + 'Content-Type': contentTypeForPath(target), + 'Content-Length': String(stat.size), + 'Cache-Control': 'no-cache', + }, + }) +} diff --git a/src/server/index.ts b/src/server/index.ts index 5b924d78..b4d8a5f3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,6 +15,9 @@ import { handleProxyRequest } from './proxy/handler.js' import { ProviderService } from './services/providerService.js' import { handleHahaOAuthCallback } from './api/haha-oauth.js' import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js' +import { handlePreviewFs } from './api/previewFs.js' +import { sessionService } from './services/sessionService.js' +import { conversationService } from './services/conversationService.js' import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js' import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js' import { enableConfigs } from '../utils/config.js' @@ -275,6 +278,32 @@ export function startServer(port = PORT, host = HOST) { return handleHahaOpenAIOAuthCallback(url) } + // Preview filesystem — serve sandboxed workspace files for a session. + if (url.pathname.startsWith('/preview-fs/')) { + if (cors.rejected) { + return corsRejectedResponse(cors) + } + + if (authRequired) { + const authError = await requireH5Token(req) + if (authError) { + return withCors(authError, cors) + } + } else if (forceAuth) { + const authError = await requireAuth(req) + if (authError) { + return withCors(authError, cors) + } + } + + const response = await handlePreviewFs(url, async (sessionId) => + conversationService.getSessionWorkDir(sessionId) || + (await sessionService.getSessionWorkDir(sessionId)) || + null, + ) + return withCors(response, cors) + } + // REST API if (url.pathname.startsWith('/api/')) { if (cors.rejected) { @@ -396,7 +425,6 @@ export function startServer(port = PORT, host = HOST) { } // ─── Graceful shutdown: kill all CLI subprocesses on exit ──────────────────── -import { conversationService } from './services/conversationService.js' function cleanupAllSessions() { const active = conversationService.getActiveSessions()