From b78e92be7303c8dbd045f792251b9f8d0d23afde Mon Sep 17 00:00:00 2001 From: minimax-code dev agent Date: Sun, 27 Sep 2026 20:31:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(webui):=20built-in=20browser=20panel=20?= =?UTF-8?q?=E2=80=94=20sandboxed=20preview=20over=20/api/fs/raw=20(webui-p?= =?UTF-8?q?arity=20slice=2004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the right-panel Built-in Browser surface (the globe icon from refs/ui/02-workspace-shell.jpg), wired against the slice 02 /api/fs/raw route. The panel is intentionally NOT mounted by this slice — the right-panel registry is owned by slice 03 (feat/git-panel); the wiring slice imports the exported BrowserPanel component and adds a 'browser' enum value alongside the existing kinds. Threat model (see webapp/lib/browser-nav.ts#IFRAME_SANDBOX for the full rationale): - iframe sandbox='allow-scripts' ONLY. The five escape-hatch tokens (allow-same-origin, allow-top-navigation, allow-popups, allow-forms, allow-modals) are explicitly omitted; their absence is pinned by browser-nav.test.ts. - iframe src is ALWAYS the same-origin /api/fs/raw?path=… URL, constructed in one place (buildSandboxUrl). A grep for fsRawUrl in components/browser-panel.tsx is the tripwire that catches any future 'open in iframe via filesystem' regression. - address-bar input is rejected at the input layer for any http(s)://, file://, or other scheme-prefixed input — before the path ever reaches the iframe. coerceAddress returns {ok:false, reason:'absolute'} so the panel renders the right bilingual error. Acceptance (pinned by tests): - browser-nav.test.ts pins the IFRAME_SANDBOX literal, the REJECTED_SCHEMES set, the input-layer rejections, the iframe src construction, the back/forward history, and the iframeKey remount discipline. - i18n-browser.test.ts pins the bilingual symmetry of every key in the slice-04 i18n file (no orphan keys, every visible label differs en/zh, fallback to key name on missing entry). - fs-raw-browser-panel.test.js pins the server-side counterpart: workspace-local HTML → 200/text/html, 21 MiB → 413, directory → 400, out-of-root → 403 — the four failure modes the panel depends on. Self-check (isolated instance on 18166/18167, then torn down): - The HTML page served through the panel's iframe src (/api/fs/raw?path=public/index.html) renders correctly with sandbox='allow-scripts'. The counter script inside the sandbox runs (ticks every 250ms); the parent document cannot read the iframe's contentDocument (returns null) and contentWindow .location.href throws SecurityError as expected. - /api/fs/raw?path=/etc/passwd → 403 '工作区越界' (gate enforced) - /api/fs/raw?path=../../etc/passwd → 403 (gate enforced) Gates: pnpm --filter @mavis/webui webapp:typecheck (clean), pnpm typecheck (clean), pnpm --filter @mavis/webui test:webapp (503 pass), pnpm check:source (4627 files, clean). The packages/webui test/lib router-auth-gate pre-existing failure is unrelated to this slice (fails identically on the base commit). --- .../test/routes/fs-raw-browser-panel.test.js | 145 +++++++ .../webui/webapp/components/browser-panel.tsx | 333 +++++++++++++++ packages/webui/webapp/lib/browser-nav.ts | 255 ++++++++++++ packages/webui/webapp/lib/i18n-browser.ts | 100 +++++ .../webui/webapp/test/browser-nav.test.ts | 384 ++++++++++++++++++ .../webui/webapp/test/i18n-browser.test.ts | 115 ++++++ release/public-source.json | 6 + 7 files changed, 1338 insertions(+) create mode 100644 packages/webui/test/routes/fs-raw-browser-panel.test.js create mode 100644 packages/webui/webapp/components/browser-panel.tsx create mode 100644 packages/webui/webapp/lib/browser-nav.ts create mode 100644 packages/webui/webapp/lib/i18n-browser.ts create mode 100644 packages/webui/webapp/test/browser-nav.test.ts create mode 100644 packages/webui/webapp/test/i18n-browser.test.ts 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 ? ( +