feat(server): serve absolute local files via /local-file so file:// links open in the in-app browser

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 23:46:34 +08:00
parent 1d9f4c9cb7
commit 8ddac90c11
9 changed files with 393 additions and 14 deletions

View File

@ -1,5 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { handlePreviewLink, type PreviewLinkDeps } from './handlePreviewLink'
import {
handlePreviewLink,
isAbsoluteLocalPath,
localFileUrl,
previewFsUrl,
type PreviewLinkDeps,
} from './handlePreviewLink'
function makeDeps(overrides?: Partial<PreviewLinkDeps>): PreviewLinkDeps {
return {
@ -22,21 +28,42 @@ describe('handlePreviewLink', () => {
expect(deps.openExternal).not.toHaveBeenCalled()
})
it('routes absolute html file paths to openBrowser with the preview-fs url', () => {
it('routes absolute html file paths to openBrowser with the local-file url', () => {
const deps = makeDeps()
const handled = handlePreviewLink('/Users/x/index.html', deps)
expect(handled).toBe(true)
// previewFsUrl preserves the leading slash, yielding an intentional `//`
// between <sessionId> and the absolute path so the server resolves it as
// an absolute-within-workspace path.
// Absolute paths may live outside the session workspace, so they go through
// the $HOME-sandboxed /local-file route, NOT /preview-fs.
expect(deps.openBrowser).toHaveBeenCalledWith(
's1',
'http://127.0.0.1:8787/preview-fs/s1//Users/x/index.html',
'http://127.0.0.1:8787/local-file/Users/x/index.html',
)
expect(deps.openFilePreview).not.toHaveBeenCalled()
expect(deps.openExternal).not.toHaveBeenCalled()
})
it('routes file:// urls to openBrowser with the local-file url', () => {
const deps = makeDeps()
const handled = handlePreviewLink('file:///Users/x/page.html', deps)
expect(handled).toBe(true)
expect(deps.openBrowser).toHaveBeenCalledWith(
's1',
'http://127.0.0.1:8787/local-file/Users/x/page.html',
)
})
it('routes a 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)
// Relative → stays workspace-scoped via /preview-fs.
expect(deps.openBrowser).toHaveBeenCalledWith(
's1',
'http://127.0.0.1:8787/preview-fs/s1/out/index.html',
)
expect(deps.openFilePreview).not.toHaveBeenCalled()
})
it('routes relative previewable docs to openFilePreview with the relative path', () => {
const deps = makeDeps()
const handled = handlePreviewLink('docs/report.md', deps)
@ -64,3 +91,48 @@ describe('handlePreviewLink', () => {
expect(deps.openExternal).not.toHaveBeenCalled()
})
})
describe('isAbsoluteLocalPath', () => {
it('treats POSIX leading-slash paths as absolute', () => {
expect(isAbsoluteLocalPath('/Users/x/page.html')).toBe(true)
})
it('treats Windows drive paths as absolute (both slash styles)', () => {
expect(isAbsoluteLocalPath('C:\\Users\\x\\page.html')).toBe(true)
expect(isAbsoluteLocalPath('C:/Users/x/page.html')).toBe(true)
})
it('treats relative paths as not absolute', () => {
expect(isAbsoluteLocalPath('out/index.html')).toBe(false)
expect(isAbsoluteLocalPath('./page.html')).toBe(false)
})
})
describe('localFileUrl', () => {
it('appends a POSIX absolute path after /local-file, preserving slashes', () => {
expect(localFileUrl('http://127.0.0.1:8787', '/Users/x/page.html')).toBe(
'http://127.0.0.1:8787/local-file/Users/x/page.html',
)
})
it('encodes spaces/unicode per segment but keeps separators', () => {
expect(localFileUrl('http://127.0.0.1:8787', '/Users/x/with space/p.html')).toBe(
'http://127.0.0.1:8787/local-file/Users/x/with%20space/p.html',
)
})
it('normalizes Windows backslashes and keeps a leading slash', () => {
expect(localFileUrl('http://127.0.0.1:8787', 'C:\\proj\\page.html')).toBe(
'http://127.0.0.1:8787/local-file/C%3A/proj/page.html',
)
})
it('trims a trailing slash on the base', () => {
expect(localFileUrl('http://127.0.0.1:8787/', '/a/b.html')).toBe(
'http://127.0.0.1:8787/local-file/a/b.html',
)
})
})
describe('previewFsUrl (unchanged, regression guard)', () => {
it('still builds a workspace-scoped preview-fs url for relative paths', () => {
expect(previewFsUrl('http://127.0.0.1:8787', 's1', 'out/index.html')).toBe(
'http://127.0.0.1:8787/preview-fs/s1/out/index.html',
)
})
})

View File

@ -21,6 +21,34 @@ export function previewFsUrl(base: string, sessionId: string, filePath: string):
return `${base.replace(/\/$/, '')}/preview-fs/${encodeURIComponent(sessionId)}/${filePath.replace(/^\/+/, '/')}`
}
/** True for POSIX absolute (`/...`) or Windows drive (`X:\` / `X:/`) paths. */
export function isAbsoluteLocalPath(p: string): boolean {
return p.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(p)
}
/**
* Build a `/local-file/<absolute-path>` URL for the local server so an absolute
* file outside the session workspace can open in the in-app browser.
*
* The path is appended PATH-style (not as a query param) so relative asset URLs
* inside served HTML resolve against the same directory. Each path segment is
* `encodeURIComponent`-escaped (so spaces / unicode survive) while the `/`
* separators are preserved. A Windows drive path (`C:\proj\page.html`) has its
* backslashes normalized to `/`; the leading separator is always present so the
* server can re-root the path.
*/
export function localFileUrl(base: string, absPath: string): string {
const withForwardSlashes = absPath.replace(/\\/g, '/')
const withLeadingSlash = withForwardSlashes.startsWith('/')
? withForwardSlashes
: `/${withForwardSlashes}`
const encoded = withLeadingSlash
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/')
return `${base.replace(/\/$/, '')}/local-file${encoded}`
}
/** Returns true if handled (caller should preventDefault). */
export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean {
const cls = classifyPreviewLink(href)
@ -28,9 +56,17 @@ export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean
case 'browser-localhost':
deps.openBrowser(deps.sessionId, cls.url!)
return true
case 'browser-file':
deps.openBrowser(deps.sessionId, previewFsUrl(deps.serverBaseUrl, deps.sessionId, cls.path!))
case 'browser-file': {
const filePath = cls.path!
// 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.
const url = isAbsoluteLocalPath(filePath)
? localFileUrl(deps.serverBaseUrl, filePath)
: previewFsUrl(deps.serverBaseUrl, deps.sessionId, filePath)
deps.openBrowser(deps.sessionId, url)
return true
}
case 'file-preview':
deps.openFilePreview(deps.sessionId, cls.path!)
return true

View File

@ -6,7 +6,7 @@ vi.mock('./desktopRuntime', () => ({
}))
import { openWithContextForHref, openWithContextForWorkspaceFile } from './openWithContextForHref'
import { previewFsUrl } from './handlePreviewLink'
import { localFileUrl, previewFsUrl } from './handlePreviewLink'
const BASE = 'http://127.0.0.1:4321'
const SESSION = 's1'
@ -27,12 +27,12 @@ describe('openWithContextForHref', () => {
expect(result).toEqual({ kind: 'file', absolutePath: '/w/docs/a.md', relPath: 'docs/a.md', previewable: true })
})
it('absolute path in browser-file → inAppBrowserUrl via previewFsUrl', () => {
it('absolute path in browser-file → inAppBrowserUrl via localFileUrl ($HOME-sandboxed route)', () => {
const result = openWithContextForHref('/x/p.html', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toEqual({
kind: 'file',
absolutePath: '/x/p.html',
inAppBrowserUrl: previewFsUrl(BASE, SESSION, '/x/p.html'),
inAppBrowserUrl: localFileUrl(BASE, '/x/p.html'),
})
})

View File

@ -1,5 +1,5 @@
import { classifyPreviewLink } from './previewLinkRouter'
import { previewFsUrl } from './handlePreviewLink'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from './handlePreviewLink'
import type { OpenWithContext } from './openWithItems'
const HTML_EXT = /\.(html?|xhtml)$/i
@ -36,7 +36,12 @@ export function openWithContextForHref(
return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), relPath: c.path, previewable: true }
}
if (c.kind === 'browser-file' && c.path) {
return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), inAppBrowserUrl: previewFsUrl(opts.serverBaseUrl, opts.sessionId, 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 }
}
return null
}

View File

@ -0,0 +1,132 @@
import { afterAll, describe, expect, it } from 'bun:test'
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
import { tmpdir, homedir } from 'node:os'
import * as path from 'node:path'
import { handleLocalFile, reconstructAbsolutePath } from '../localFile'
// Deterministic 256-byte payload (bytes 0..255) so range slices are checkable.
const VIDEO_BYTES = Uint8Array.from({ length: 256 }, (_, i) => i)
// Build the work area under $HOME so it is inside isAllowedFilesystemPath's
// allow-list (HOME / tmp / registered roots). tmpdir() on macOS resolves to
// /private/tmp via realpath which is also allowed, but $HOME is the most
// portable choice across platforms for an "inside the sandbox" fixture.
const SANDBOX_ROOTS = mkdtempSync(path.join(homedir(), '.lf-test-'))
afterAll(() => {
rmSync(SANDBOX_ROOTS, { recursive: true, force: true })
})
function setupFiles() {
const root = mkdtempSync(path.join(SANDBOX_ROOTS, 'proj-'))
writeFileSync(path.join(root, 'page.html'), '<h1>ok</h1>')
mkdirSync(path.join(root, 'assets'))
writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}')
writeFileSync(path.join(root, 'clip.mp4'), VIDEO_BYTES)
writeFileSync(path.join(root, 'with space.html'), '<h1>spaced</h1>')
return root
}
/** Build a /local-file/<abs> URL exactly the way the desktop helper does. */
function localFileRequestUrl(absPath: string): URL {
const withForwardSlashes = absPath.replace(/\\/g, '/')
const withLeading = withForwardSlashes.startsWith('/')
? withForwardSlashes
: `/${withForwardSlashes}`
const encoded = withLeading
.split('/')
.map((s) => encodeURIComponent(s))
.join('/')
return new URL(`http://127.0.0.1/local-file${encoded}`)
}
describe('reconstructAbsolutePath', () => {
it('re-roots a POSIX path (leading slash consumed by the prefix)', () => {
expect(reconstructAbsolutePath('Users/me/page.html')).toBe('/Users/me/page.html')
})
it('decodes percent-encoded segments', () => {
expect(reconstructAbsolutePath('Users/me/with%20space.html')).toBe('/Users/me/with space.html')
})
it('keeps a Windows drive path absolute', () => {
expect(reconstructAbsolutePath('C:/Users/me/page.html')).toBe('C:/Users/me/page.html')
})
it('returns null for an empty remainder', () => {
expect(reconstructAbsolutePath('')).toBeNull()
})
})
describe('handleLocalFile', () => {
it('serves an in-sandbox .html with text/html + Accept-Ranges', async () => {
const root = setupFiles()
const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'page.html')))
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8')
expect(res.headers.get('accept-ranges')).toBe('bytes')
expect(await res.text()).toBe('<h1>ok</h1>')
})
it('serves nested assets with the right content-type', async () => {
const root = setupFiles()
const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'assets', 'a.css')))
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('text/css; charset=utf-8')
})
it('serves a file whose name contains a space', async () => {
const root = setupFiles()
const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'with space.html')))
expect(res.status).toBe(200)
expect(await res.text()).toBe('<h1>spaced</h1>')
})
it('honours a closed byte-range with 206 + Content-Range', async () => {
const root = setupFiles()
const res = await handleLocalFile(
localFileRequestUrl(path.join(root, 'clip.mp4')),
new Headers({ Range: 'bytes=0-9' }),
)
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 0-9/256')
expect(res.headers.get('content-length')).toBe('10')
expect(res.headers.get('accept-ranges')).toBe('bytes')
const body = new Uint8Array(await res.arrayBuffer())
expect(body.length).toBe(10)
expect(body).toEqual(VIDEO_BYTES.slice(0, 10))
})
it('serves video content-type + Accept-Ranges on a full 200', async () => {
const root = setupFiles()
const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'clip.mp4')))
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('video/mp4')
expect(res.headers.get('accept-ranges')).toBe('bytes')
expect(res.headers.get('content-length')).toBe('256')
})
it('rejects a path OUTSIDE the sandbox with 403', async () => {
const res = await handleLocalFile(localFileRequestUrl('/etc/hosts'))
expect(res.status).toBe(403)
})
it('rejects /etc/passwd with 403 (sandbox escape)', async () => {
const res = await handleLocalFile(localFileRequestUrl('/etc/passwd'))
expect(res.status).toBe(403)
})
it('404s a missing in-sandbox file', async () => {
const root = setupFiles()
const res = await handleLocalFile(localFileRequestUrl(path.join(root, 'does-not-exist.html')))
expect(res.status).toBe(404)
})
it('403s when the prefix was stripped by URL normalization (traversal)', async () => {
// `..` collapsing removes the /local-file/ prefix → treated as escape.
const res = await handleLocalFile(new URL('http://127.0.0.1/local-file/../../etc/passwd'))
expect(res.status).toBe(403)
})
it('400s when no path follows the prefix', async () => {
const res = await handleLocalFile(new URL('http://127.0.0.1/local-file/'))
expect(res.status).toBe(400)
})
})

View File

@ -52,7 +52,7 @@ function isVcsMetadataDirectoryName(name: string): boolean {
return VCS_METADATA_DIRECTORY_NAMES.has(name.toLowerCase())
}
function isAllowedFilesystemPath(targetPath: string): boolean {
export function isAllowedFilesystemPath(targetPath: string): boolean {
const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath))
const homeDir = path.resolve(os.homedir())

View File

@ -0,0 +1,92 @@
import * as path from 'node:path'
import { isAllowedFilesystemPath } from './filesystem.js'
import { serveFileWithRange } from './previewFs.js'
import { normalizeDriveRootPathForPlatform } from '../services/windowsDrivePath.js'
const PREFIX = '/local-file/'
/**
* Reconstruct the absolute filesystem path encoded in a `/local-file/...`
* request pathname.
*
* The request path *after* the `/local-file/` prefix IS the target's absolute
* path with its leading separator dropped by the prefix slice. We URL-decode
* each segment (so spaces / unicode names survive) and re-add the root:
*
* - POSIX: `Users/me/page.html` `/Users/me/page.html`
* - Windows drive: `C:/me/page.html` `C:/me/page.html` (already rooted)
* - Windows drive (with leading `/`, e.g. from `file:///C:/...`):
* `C:/me/page.html` arrives the same way once the prefix is sliced.
*
* The WHATWG URL parser collapses `..` segments before this handler runs, so a
* traversal such as `/local-file/../../etc/passwd` arrives with its pathname
* normalized to `/etc/passwd` i.e. the `/local-file/` prefix is gone. We
* treat any request that lost the prefix as a sandbox escape (handled by the
* caller returning 403); here we only reconstruct, the sandbox check is the
* `isAllowedFilesystemPath` gate in {@link handleLocalFile}.
*
* Returns `null` when the remainder is empty.
*/
export function reconstructAbsolutePath(rest: string): string | null {
if (!rest) return null
const decoded = rest
.split('/')
.map((segment) => {
try {
return decodeURIComponent(segment)
} catch {
return segment
}
})
.join('/')
if (!decoded) return null
// Windows drive form: `C:/...` or `C:\...` is already absolute.
if (/^[a-zA-Z]:[\\/]/.test(decoded) || /^[a-zA-Z]:$/.test(decoded)) {
return decoded
}
// POSIX: the leading `/` was consumed by the `/local-file/` prefix slice.
return `/${decoded}`
}
/**
* Serve a single ABSOLUTE local file by path so `file://` links and AI-emitted
* absolute paths can open in the in-app browser.
*
* URL shape: `/local-file/<absolute-path>` where the path after the prefix is
* the on-disk absolute path (its leading separator dropped by the prefix, or a
* `C:/...` drive form on Windows). This is PATH-based, not query-param based,
* so relative asset URLs (`./app.css`, `img/logo.png`) inside served HTML
* resolve against the same `/local-file/...` directory.
*
* Security: the resolved path is gated by {@link isAllowedFilesystemPath} the
* same `$HOME` / `/tmp` / `/private/tmp` / registered-roots allow-list used by
* the filesystem image endpoint. Anything outside is rejected with 403, so
* `/etc/passwd` and friends stay denied. Streaming + byte-range handling is
* shared with `/preview-fs` via {@link serveFileWithRange}, so HTML, CSS, JS,
* images, fonts, video, and text all serve with the correct `Content-Type`,
* `Accept-Ranges`, and 206 partial responses.
*/
export async function handleLocalFile(
url: URL,
reqHeaders?: Headers,
): Promise<Response> {
if (!url.pathname.startsWith(PREFIX)) {
return new Response('forbidden', { status: 403 })
}
const rest = url.pathname.slice(PREFIX.length)
const absPath = reconstructAbsolutePath(rest)
if (!absPath) return new Response('bad request', { status: 400 })
const resolved = path.resolve(normalizeDriveRootPathForPlatform(absPath))
if (!isAllowedFilesystemPath(resolved)) {
return new Response('forbidden', { status: 403 })
}
return serveFileWithRange(resolved, reqHeaders)
}

View File

@ -149,6 +149,23 @@ export async function handlePreviewFs(
return new Response('forbidden', { status: 403 })
}
return serveFileWithRange(target, reqHeaders)
}
/**
* Stream a single resolved absolute file as an HTTP response, honouring a
* `Range` header (206 partial / 416 unsatisfiable) and falling back to a full
* 200 otherwise. The body is streamed straight from disk via `Bun.file(...)`,
* never buffered into memory, so this is safe for large media.
*
* Callers are responsible for any path-sandboxing BEFORE invoking this it
* trusts `target` to already be authorised. It returns 404 when the path is
* missing or not a regular file, and 413 above {@link MAX_FILE_BYTES}.
*/
export async function serveFileWithRange(
target: string,
reqHeaders?: Headers,
): Promise<Response> {
let stat: fs.Stats
try {
stat = fs.statSync(target)

View File

@ -16,6 +16,7 @@ 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 { handleLocalFile } from './api/localFile.js'
import { sessionService } from './services/sessionService.js'
import { conversationService } from './services/conversationService.js'
import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js'
@ -307,6 +308,30 @@ export function startServer(port = PORT, host = HOST) {
return withCors(response, cors)
}
// Local filesystem — serve an ABSOLUTE local file ($HOME/tmp/registered
// roots sandbox) so `file://` links / AI-emitted absolute paths open in
// the in-app browser. Gated identically to /preview-fs above.
if (url.pathname.startsWith('/local-file/')) {
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 handleLocalFile(url, req.headers)
return withCors(response, cors)
}
// REST API
if (url.pathname.startsWith('/api/')) {
if (cors.rejected) {