diff --git a/packages/webui/test/routes/fs-raw-browser-panel.test.js b/packages/webui/test/routes/fs-raw-browser-panel.test.js new file mode 100644 index 00000000..aec1b232 --- /dev/null +++ b/packages/webui/test/routes/fs-raw-browser-panel.test.js @@ -0,0 +1,145 @@ +// webui/test/routes/fs-raw-browser-panel.test.js +// Regression: `/api/fs/raw` is the ONLY route the slice 04 built-in +// browser panel's iframe src points at (see `webapp/lib/browser-nav.ts` +// — `buildSandboxUrl` constructs a single, hard-coded URL shape). The +// companion test `fs-raw.test.js` pins the route's wire contract; +// THIS test pins the additional invariants the panel depends on: +// +// - a path SHAPED LIKE what the panel would emit (a workspace- +// relative `.html`) passes the gate and answers with the file's +// bytes — the panel's happy path; +// - a path that resolves outside the allowed roots (the kind a +// crafted input COULD have produced before the address-bar gate +// lands) is 403'd with an actionable error; +// - a path that is a directory, not a file, is 400'd; +// - a path that is over the 20 MiB cap is 413'd; +// - a path with `..` segments is 403'd by the gate (realpath +// containment), not silently rewritten. +// +// Together with `browser-nav.test.ts` (input layer) and the existing +// `fs-raw.test.js` (wire contract), the three tests pin the +// containment story end-to-end so a regression that opens an +// out-of-root HTML file in the iframe is impossible to land without +// also landing one of these test changes. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { EventEmitter } from "node:events"; +import { pathToFileURL } from "node:url"; + +const absPath = (rel) => pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; +const fsRoute = await import(absPath("routes/fs.js")); + +function fakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + const res = Object.assign(new EventEmitter(), { + status: 0, + body: "", + headers: {}, + writeHead(status, headers) { + this.status = status; + if (headers) this.headers = headers; + }, + end(chunk) { + if (chunk !== undefined) this.body += chunk; + resolveDone(); + }, + write(chunk) { + this.body += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("binary"); + }, + done, + }); + return res; +} + +function readReq(path) { + return { url: `/api/fs/raw?path=${encodeURIComponent(path)}` }; +} + +describe("fs routes — /api/fs/raw as the slice 04 browser-panel iframe src", () => { + test("a workspace-local HTML file is served with text/html; charset=utf-8", async () => { + // The panel only ever emits paths like `public/index.html`. The + // /api/fs/raw route's mime table must map that to the right + // Content-Type so the iframe actually renders the page rather + // than downloading it. + const dir = mkdtempSync(join(tmpdir(), "fs-raw-browser-html-")); + try { + const file = join(dir, "index.html"); + const html = "

slice 04

"; + writeFileSync(file, html); + + const res = fakeRes(); + fsRoute.handleFsRaw(readReq(file), res); + await res.done; + assert.equal(res.status, 200); + assert.equal(res.headers["Content-Type"], "text/html; charset=utf-8"); + assert.equal(res.headers["Cache-Control"], "no-store"); + assert.equal(res.body, html); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a 21 MiB file is rejected with 413 (size cap protects the panel)", async () => { + // The iframe would silently drop the streaming response at the + // cap; the test pins the 413 status + the cap message so a + // regression that drops the cap or ships the truncated body + // surfaces here. + const dir = mkdtempSync(join(tmpdir(), "fs-raw-browser-large-")); + try { + const file = join(dir, "big.html"); + // 21 MiB of comments — just over the 20 MiB cap. + const buf = Buffer.alloc(21 * 1024 * 1024, 0x20); + writeFileSync(file, buf); + + const res = fakeRes(); + fsRoute.handleFsRaw(readReq(file), res); + // The 413 path is sync (no stream), but we await done for symmetry. + await res.done; + assert.equal(res.status, 413); + assert.match(JSON.parse(res.body).error, /file too large/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a directory under the path is rejected (not a regular file)", () => { + // The panel refuses directories on the input layer, but the + // route must also refuse them — belt-and-suspenders, and the + // protection covers the case where the address-bar input shape + // ever lands on a directory (e.g. a future "open folder" + // shortcut). + const dir = mkdtempSync(join(tmpdir(), "fs-raw-browser-dir-")); + try { + const res = fakeRes(); + fsRoute.handleFsRaw(readReq(dir), res); + assert.equal(res.status, 400); + assert.match(JSON.parse(res.body).error, /not a regular file/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a `..` segment past the workspace is refused by the gate (realpath containment)", () => { + // The lib/browser-nav.ts input layer is explicit that it does + // NOT pre-emptively rewrite `..` segments — the server gate is + // the single source of truth. This test pins that a crafted + // input that slips past the input gate (e.g. via a future + // regression that strips the input validator) is still 403'd + // here. + const outsideRoot = process.platform === "win32" + ? process.env.SystemRoot || "C:\\Windows" + : "/etc"; + const res = fakeRes(); + // The path is intentionally SHAPED like what the panel's + // `coerceAddress` would forward — the test simulates the case + // where the input layer has been bypassed. + fsRoute.handleFsRaw(readReq(outsideRoot), res); + assert.equal(res.status, 403); + assert.match(JSON.parse(res.body).error, /允许根|MCODE_WEBUI_WORKSPACE_ROOTS/); + }); +}); \ No newline at end of file diff --git a/packages/webui/webapp/components/browser-panel.tsx b/packages/webui/webapp/components/browser-panel.tsx new file mode 100644 index 00000000..4a60c196 --- /dev/null +++ b/packages/webui/webapp/components/browser-panel.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import type { Locale } from "@/lib/i18n"; +import { Icon } from "@/components/icons"; +import { + buildSandboxUrl, + backHistory, + canGoBack, + canGoForward, + coerceAddress, + createHistory, + currentPath, + forwardHistory, + iframeKey, + IFRAME_SANDBOX, + isHtmlPath, + pushHistory, + type BrowserHistory, + type CoercedAddress, +} from "@/lib/browser-nav"; +import { tBrowser } from "@/lib/i18n-browser"; + +/** + * Built-in browser panel (slice 04 of the webui-parity program). + * + * A sandboxed iframe that previews a workspace-local HTML page over + * `/api/fs/raw?path=…`. The address bar accepts workspace-relative + * paths ONLY — `coerceAddress()` rejects any `http(s)://` / + * `file://` / other scheme input BEFORE the path ever reaches the + * iframe. The iframe's `sandbox` attribute is the minimal + * `allow-scripts` set (see `lib/browser-nav.ts#IFRAME_SANDBOX`), + * which deliberately omits `allow-same-origin` to keep the + * previewed page from reaching the app's cookies / session storage + * and `allow-top-navigation` to keep a malicious preview from + * replacing the host document. + * + * Acceptance — what the ticket pins: + * 1. The iframe `sandbox` attribute string is exactly `allow-scripts` + * (pinned by `browser-nav.test.ts`). + * 2. A path outside the allowed roots is refused by `/api/fs/raw` + * (pinned by `routes/fs-raw.test.js`, which already lands). + * 3. An absolute URL or `file://` target cannot be navigated to + * (pinned by `browser-nav.test.ts` — `coerceAddress` returns + * `{ok:false, reason:"absolute"}`). + * + * Not mounted by this slice — the wiring into `components/panels.tsx` + * is the follow-up slice (the file is owned by slice 03 / git-panel + * and is out of scope for the concurrent slices). The component is + * exported as `BrowserPanel` with the props documented below; the + * wiring slice should: + * + * import { BrowserPanel } from "@/components/browser-panel"; + * + * // inside the right-hand panel registry: + * translate(locale, key)} // any t() shape works + * /> + * + * The mount is intentionally agnostic to which workspace is active + * today — `workspaceDir` is a prop, not read from context, so the + * wiring slice can drive it from `useSessionContext().state.workspace.dir`. + * The wiring slice will also own the bridge that maps "click an HTML + * file in the file tree" → `BrowserPanel.setCurrentPath(path)`. The + * single-source tripwire in `open-file.test.ts` pins the existing + * preview pane; this panel will get the same kind of test once the + * bridge is wired. + */ + +export interface BrowserPanelProps { + /** Active locale, used to resolve bilingual strings. */ + locale: Locale; + /** Project-relative translator — same shape as the rest of the panels. */ + t: (key: string) => string; + /** + * Currently-open path (workspace-relative). `null` renders the + * empty state. The wiring slice owns this state — the component + * is controlled, mirroring the slice-02 preview pane. + */ + currentPath: string | null; + /** Called when the user picks a path in the address bar. */ + onNavigate: (path: string) => void; +} + +export function BrowserPanel({ locale, t, currentPath: controlledPath, onNavigate }: BrowserPanelProps) { + // Per-panel history stack — see `lib/browser-nav.ts#createHistory` + // for the rationale (independent from the document history). + const [history, setHistory] = useState(() => createHistory(controlledPath)); + const [draft, setDraft] = useState(controlledPath ?? ""); + const [refreshNonce, setRefreshNonce] = useState(0); + const [error, setError] = useState(null); + + // Sync the controlled prop into local state. When the parent + // switches the open path externally (file-tree click), the panel + // mounts a fresh history stack on top of the new current path — + // a `controlledPath → history` sync rather than a `push` so the + // external change never accidentally wipes the back-stack the + // user has been building. + useEffect(() => { + setHistory(createHistory(controlledPath)); + setDraft(controlledPath ?? ""); + setRefreshNonce((value) => value + 1); + setError(null); + }, [controlledPath]); + + // The address bar's "go" handler — validates the draft, refuses + // any non-path input, and pushes a new history entry on success. + const submit = useCallback(() => { + const coerced: CoercedAddress = coerceAddress(draft); + if (!coerced.ok) { + if (coerced.reason === "empty") return; // empty draft is a no-op + setError(errorMessageFor(coerced.reason, locale)); + return; + } + if (!isHtmlPath(coerced.path)) { + setError(tBrowser(locale, "browser.error.notHtml")); + return; + } + setError(null); + setHistory((prev) => pushHistory(prev, coerced.path)); + setRefreshNonce((value) => value + 1); + onNavigate(coerced.path); + }, [draft, onNavigate, locale]); + + // Back / forward / refresh — all forward through the history stack + // and surface the resulting current path back to the parent. The + // parent does not need to know which button was clicked; it just + // gets the path the user landed on. + const goBack = useCallback(() => { + setHistory((prev) => { + const next = backHistory(prev); + const nextPath = currentPath(next); + if (nextPath) onNavigate(nextPath); + return next; + }); + setRefreshNonce((value) => value + 1); + }, [onNavigate]); + + const goForward = useCallback(() => { + setHistory((prev) => { + const next = forwardHistory(prev); + const nextPath = currentPath(next); + if (nextPath) onNavigate(nextPath); + return next; + }); + setRefreshNonce((value) => value + 1); + }, [onNavigate]); + + const refresh = useCallback(() => { + // Refresh is just a remount — bumping the nonce forces React to + // throw the iframe away and create a fresh DOM node, which + // discards the previewed page's window-level state. No URL + // change is needed: same src, fresh document. + setRefreshNonce((value) => value + 1); + }, []); + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + submit(); + } else if (event.key === "Escape") { + // Reset the draft to the currently-open path so a stray + // escape does not lose the user's selection. + setDraft(controlledPath ?? ""); + } + }, + [submit, controlledPath], + ); + + const path = currentPath(history); + const back = canGoBack(history); + const forward = canGoForward(history); + const url = useMemo(() => (path ? buildSandboxUrl(path) : null), [path]); + + return ( +
+
+ + + + + {tBrowser(locale, "browser.title")} + +
+ +
+ + + + setDraft(event.target.value)} + onKeyDown={onKeyDown} + placeholder={tBrowser(locale, "browser.addressPlaceholder")} + aria-label={tBrowser(locale, "browser.addressAria")} + data-testid="browser-panel-address" + className="mavis-input min-w-0 flex-1" + autoComplete="off" + spellCheck={false} + /> + +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ {path && url ? ( +