feat(server): serve sandboxed workspace files via /preview-fs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 12:32:34 +08:00
parent a0d5e7c40c
commit 36a3e4c018
3 changed files with 138 additions and 2 deletions

View File

@ -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'), '<h1>ok</h1>')
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('<h1>ok</h1>')
})
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)
})
})

View File

@ -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<string, string> = {
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<string | null>
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/<sessionId>/<relPath>` where `<relPath>` 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<Response> {
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',
},
})
}

View File

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