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()