Fix local HTML file routing in open wrapper (#684)

* Route local HTML open targets to cmux browser

* Keep file:// omnibar navigation inside cmux browser

* Load local file URLs via WKWebView file API

* Add browser regression test for local file URL loads

* Address PR feedback on local HTML and file URL handling
This commit is contained in:
Lawrence Chen 2026-02-28 19:08:39 -08:00 committed by GitHub
parent 2e2e190bf4
commit 838d1b07b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 533 additions and 15 deletions

View file

@ -105,6 +105,169 @@ is_http_url() {
return 1
}
is_file_url() {
local value="$1"
case "$value" in
[Ff][Ii][Ll][Ee]://*)
return 0
;;
esac
return 1
}
has_uri_scheme() {
local value="$1"
[[ "$value" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]]
}
is_html_extension() {
local value
value="$(to_lower_ascii "$(trim "$1")")"
case "$value" in
*.html|*.htm)
return 0
;;
esac
return 1
}
is_explicit_local_path() {
local value="$1"
case "$value" in
/*|./*|../*|~|~/*)
return 0
;;
esac
return 1
}
file_url_points_to_html() {
local value="$1"
if [[ -n "$PYTHON3_BIN" ]]; then
"$PYTHON3_BIN" - "$value" <<'PY' >/dev/null 2>&1
import sys
from urllib.parse import unquote, urlsplit
value = sys.argv[1].strip()
if not value:
raise SystemExit(1)
parts = urlsplit(value)
path = unquote(parts.path or "")
lower = path.lower()
if lower.endswith(".html") or lower.endswith(".htm"):
raise SystemExit(0)
raise SystemExit(1)
PY
return $?
fi
local without_fragment="${value%%\#*}"
local without_query="${without_fragment%%\?*}"
local remainder path_part
case "$without_query" in
[Ff][Ii][Ll][Ee]://*)
remainder="${without_query#*://}"
;;
*)
return 1
;;
esac
if [[ "$remainder" == /* ]]; then
path_part="$remainder"
elif [[ "$remainder" == */* ]]; then
path_part="/${remainder#*/}"
else
return 1
fi
is_html_extension "$path_part"
}
path_to_file_url_without_python() {
local raw="$1"
local expanded="$raw"
case "$expanded" in
"~")
expanded="$HOME"
;;
"~/"*)
expanded="$HOME/${expanded#~/}"
;;
esac
local absolute
if [[ "$expanded" == /* ]]; then
absolute="$expanded"
else
absolute="$(pwd)/$expanded"
fi
local directory="$absolute"
local basename=""
if [[ "$absolute" == */* ]]; then
directory="${absolute%/*}"
basename="${absolute##*/}"
fi
local resolved_directory
if resolved_directory="$(cd "$directory" 2>/dev/null && pwd -P)"; then
absolute="$resolved_directory"
if [[ -n "$basename" ]]; then
absolute="$absolute/$basename"
fi
fi
local encoded=""
local length=${#absolute}
local index char hex
local LC_ALL=C
for ((index = 0; index < length; index++)); do
char="${absolute:index:1}"
case "$char" in
[a-zA-Z0-9.~_-]|/)
encoded+="$char"
;;
*)
printf -v hex '%02X' "'$char"
encoded+="%$hex"
;;
esac
done
printf 'file://%s\n' "$encoded"
}
path_to_file_url() {
local raw="$1"
if [[ -n "$PYTHON3_BIN" ]]; then
local converted
if converted="$("$PYTHON3_BIN" - "$raw" <<'PY' 2>/dev/null
import pathlib
import sys
raw = sys.argv[1]
if not raw:
raise SystemExit(1)
path = pathlib.Path(raw).expanduser()
if path.is_absolute():
resolved = path.resolve(strict=False)
else:
resolved = (pathlib.Path.cwd() / path).resolve(strict=False)
sys.stdout.write(resolved.as_uri())
PY
)"; then
printf '%s\n' "$converted"
return 0
fi
fi
path_to_file_url_without_python "$raw"
}
normalize_host() {
local value
value="$(trim "$1")"
@ -212,9 +375,10 @@ if [[ $# -eq 0 ]]; then
fi
# Scan for flags that indicate explicit user intent → pass through.
# Also collect non-flag arguments (potential URLs/files).
# Also collect non-flag arguments and route eligible browser targets to cmux.
passthrough=false
urls=()
cmux_targets=()
passthrough_args=()
for arg in "$@"; do
case "$arg" in
-a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr)
@ -228,17 +392,33 @@ for arg in "$@"; do
;;
*)
if is_http_url "$arg"; then
urls+=("$arg")
cmux_targets+=("$arg")
elif is_file_url "$arg"; then
if file_url_points_to_html "$arg"; then
cmux_targets+=("$arg")
else
# Non-URL, non-flag argument (file path, etc.) → pass through all
passthrough=true
break
passthrough_args+=("$arg")
fi
elif has_uri_scheme "$arg"; then
passthrough_args+=("$arg")
elif is_html_extension "$arg"; then
if is_explicit_local_path "$arg" || [[ -e "$arg" ]]; then
if local_file_url="$(path_to_file_url "$arg")"; then
cmux_targets+=("$local_file_url")
else
passthrough_args+=("$arg")
fi
else
passthrough_args+=("$arg")
fi
else
passthrough_args+=("$arg")
fi
;;
esac
done
if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then
if [[ "$passthrough" == true ]] || [[ ${#cmux_targets[@]} -eq 0 ]]; then
system_open "$@"
fi
@ -269,15 +449,15 @@ fi
# Open each URL in cmux's in-app browser; track failures individually.
failed_urls=()
for url in "${urls[@]}"; do
if ! host_matches_whitelist "$url"; then
for url in "${cmux_targets[@]}"; do
if is_http_url "$url" && ! host_matches_whitelist "$url"; then
failed_urls+=("$url")
continue
fi
"$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
done
# Fall back to system open only for URLs that failed.
if [[ ${#failed_urls[@]} -gt 0 ]]; then
system_open "${failed_urls[@]}"
# Fall back to system open for unmatched args and URLs that failed.
if [[ ${#passthrough_args[@]} -gt 0 ]] || [[ ${#failed_urls[@]} -gt 0 ]]; then
system_open "${passthrough_args[@]}" "${failed_urls[@]}"
fi

View file

@ -394,11 +394,35 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest {
return preparedRequest
}
func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager = .default) -> URL? {
guard fileURL.isFileURL, fileURL.path.hasPrefix("/") else { return nil }
let path = fileURL.path
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue {
return fileURL
}
let parent = fileURL.deletingLastPathComponent()
guard !parent.path.isEmpty, parent.path.hasPrefix("/") else { return nil }
return parent
}
@discardableResult
func browserLoadRequest(_ request: URLRequest, in webView: WKWebView) -> WKNavigation? {
guard let url = request.url else { return nil }
if url.isFileURL {
guard let readAccessURL = browserReadAccessURL(forLocalFileURL: url) else { return nil }
return webView.loadFileURL(url, allowingReadAccessTo: readAccessURL)
}
return webView.load(browserPreparedNavigationRequest(request))
}
private let browserEmbeddedNavigationSchemes: Set<String> = [
"about",
"applewebdata",
"blob",
"data",
"file",
"http",
"https",
"javascript",
@ -1901,7 +1925,7 @@ final class BrowserPanel: Panel, ObservableObject {
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
}
navigationDelegate?.lastAttemptedURL = url
webView.load(browserPreparedNavigationRequest(request))
browserLoadRequest(request, in: webView)
}
/// Navigate with smart URL/search detection
@ -2047,6 +2071,9 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? {
if scheme == "http" || scheme == "https" {
return url
}
if scheme == "file", url.isFileURL, url.path.hasPrefix("/") {
return url
}
return nil
}
@ -3125,7 +3152,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)")
#endif
webView.load(navigationAction.request)
browserLoadRequest(navigationAction.request, in: webView)
decisionHandler(.cancel)
return
}
@ -3288,7 +3315,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
#if DEBUG
dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)")
#endif
webView.load(navigationAction.request)
browserLoadRequest(navigationAction.request, in: webView)
}
}
return nil

View file

@ -7708,6 +7708,58 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase {
}
}
final class BrowserNavigableURLResolutionTests: XCTestCase {
func testResolvesFileSchemeAsNavigableURL() throws {
let resolved = try XCTUnwrap(resolveBrowserNavigableURL("file:///tmp/cmux-local-test.html"))
XCTAssertTrue(resolved.isFileURL)
XCTAssertEqual(resolved.path, "/tmp/cmux-local-test.html")
}
func testRejectsNonWebNonFileScheme() {
XCTAssertNil(resolveBrowserNavigableURL("mailto:test@example.com"))
XCTAssertNil(resolveBrowserNavigableURL("ftp://example.com/file.html"))
}
func testRejectsHostOnlyFileURL() {
XCTAssertNil(resolveBrowserNavigableURL("file://example.html"))
}
}
final class BrowserReadAccessURLTests: XCTestCase {
func testUsesParentDirectoryForFileURL() throws {
let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
let file = dir.appendingPathComponent("sample.html")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
try "<html></html>".write(to: file, atomically: true, encoding: .utf8)
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: file))
XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
}
func testUsesDirectoryURLWhenTargetIsDirectory() throws {
let tempRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = tempRoot.appendingPathComponent("BrowserReadAccessURLTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: dir))
XCTAssertEqual(readAccessURL.standardizedFileURL, dir.standardizedFileURL)
}
func testUsesParentDirectoryWhenFileDoesNotExist() throws {
let missing = URL(fileURLWithPath: "/tmp/\(UUID().uuidString).html")
let readAccessURL = try XCTUnwrap(browserReadAccessURL(forLocalFileURL: missing))
XCTAssertEqual(readAccessURL.standardizedFileURL, missing.deletingLastPathComponent().standardizedFileURL)
}
func testReturnsNilForHostOnlyFileURL() throws {
let hostOnly = try XCTUnwrap(URL(string: "file://example.html"))
XCTAssertNil(browserReadAccessURL(forLocalFileURL: hostOnly))
}
}
final class BrowserExternalNavigationSchemeTests: XCTestCase {
func testCustomAppSchemesOpenExternally() throws {
let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc"))
@ -7726,6 +7778,7 @@ final class BrowserExternalNavigationSchemeTests: XCTestCase {
let http = try XCTUnwrap(URL(string: "http://example.com"))
let about = try XCTUnwrap(URL(string: "about:blank"))
let data = try XCTUnwrap(URL(string: "data:text/plain,hello"))
let file = try XCTUnwrap(URL(string: "file:///tmp/cmux-local-test.html"))
let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000"))
let javascript = try XCTUnwrap(URL(string: "javascript:void(0)"))
let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page"))
@ -7734,6 +7787,7 @@ final class BrowserExternalNavigationSchemeTests: XCTestCase {
XCTAssertFalse(browserShouldOpenURLExternally(http))
XCTAssertFalse(browserShouldOpenURLExternally(about))
XCTAssertFalse(browserShouldOpenURLExternally(data))
XCTAssertFalse(browserShouldOpenURLExternally(file))
XCTAssertFalse(browserShouldOpenURLExternally(blob))
XCTAssertFalse(browserShouldOpenURLExternally(javascript))
XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal))

View file

@ -34,6 +34,8 @@ def run_wrapper(
legacy_open_setting: str | None = None,
whitelist: str | None,
fail_urls: list[str] | None = None,
local_files: list[str] | None = None,
python_bin: str | None = None,
) -> tuple[list[str], list[str], int, str]:
with tempfile.TemporaryDirectory(prefix="cmux-open-wrapper-test-") as td:
tmp = Path(td)
@ -113,6 +115,12 @@ exit 0
""",
)
if local_files:
for relative_path in local_files:
target = tmp / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("<!doctype html><title>fixture</title>", encoding="utf-8")
env = os.environ.copy()
env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock"
env["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.test"
@ -120,6 +128,10 @@ exit 0
env["CMUX_OPEN_WRAPPER_DEFAULTS"] = str(defaults)
env["FAKE_OPEN_LOG"] = str(open_log)
env["FAKE_CMUX_LOG"] = str(cmux_log)
if python_bin is None:
env.pop("CMUX_OPEN_WRAPPER_PYTHON3", None)
else:
env["CMUX_OPEN_WRAPPER_PYTHON3"] = python_bin
if intercept_setting is None:
env.pop("FAKE_DEFAULTS_INTERCEPT_OPEN", None)
@ -143,6 +155,7 @@ exit 0
result = subprocess.run(
["/bin/bash", str(wrapper), *args],
cwd=tmp,
env=env,
capture_output=True,
text=True,
@ -282,6 +295,149 @@ def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None:
expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures)
def test_local_html_file_routes_to_cmux(failures: list[str]) -> None:
filename = "fixtures/hello page.HTML"
open_log, cmux_log, code, stderr = run_wrapper(
args=[filename],
intercept_setting="1",
whitelist="",
local_files=[filename],
)
expect(code == 0, f"local html file: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"local html file: system open should not be called, got {open_log}", failures)
expect(len(cmux_log) == 1, f"local html file: expected exactly one cmux call, got {cmux_log}", failures)
if cmux_log:
expect(
cmux_log[0].startswith("browser open file://"),
f"local html file: expected file:// target, got {cmux_log[0]}",
failures,
)
expect(
"hello%20page.HTML" in cmux_log[0],
f"local html file: expected URL-encoded filename in cmux target, got {cmux_log[0]}",
failures,
)
def test_file_url_html_routes_to_cmux(failures: list[str]) -> None:
url = "file:///tmp/cmux-open-wrapper-fixture.html"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="",
)
expect(code == 0, f"file url html: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"file url html: system open should not be called, got {open_log}", failures)
expect(cmux_log == [f"browser open {url}"], f"file url html: unexpected cmux log {cmux_log}", failures)
def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) -> None:
url = "file:///tmp/cmux-open-wrapper-fixture.html"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="",
python_bin="/definitely/missing/python3",
)
expect(code == 0, f"file url html no-python fallback: wrapper exited {code}: {stderr}", failures)
expect(
open_log == [],
f"file url html no-python fallback: system open should not be called, got {open_log}",
failures,
)
expect(
cmux_log == [f"browser open {url}"],
f"file url html no-python fallback: unexpected cmux log {cmux_log}",
failures,
)
def test_local_html_file_routes_to_cmux_without_python_binary(failures: list[str]) -> None:
filename = "fixtures/no python fallback.html"
open_log, cmux_log, code, stderr = run_wrapper(
args=[filename],
intercept_setting="1",
whitelist="",
local_files=[filename],
python_bin="/definitely/missing/python3",
)
expect(code == 0, f"local html no-python fallback: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"local html no-python fallback: system open should not be called, got {open_log}", failures)
expect(
len(cmux_log) == 1,
f"local html no-python fallback: expected exactly one cmux call, got {cmux_log}",
failures,
)
if cmux_log:
expect(
cmux_log[0].startswith("browser open file://"),
f"local html no-python fallback: expected file:// target, got {cmux_log[0]}",
failures,
)
expect(
"no%20python%20fallback.html" in cmux_log[0],
f"local html no-python fallback: expected URL-encoded filename, got {cmux_log[0]}",
failures,
)
def test_domain_like_html_argument_passthrough(failures: list[str]) -> None:
arg = "example.com/report.html"
open_log, cmux_log, code, stderr = run_wrapper(
args=[arg],
intercept_setting="1",
whitelist="",
)
expect(code == 0, f"domain-like html argument: wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [],
f"domain-like html argument: cmux should not be called, got {cmux_log}",
failures,
)
expect(
open_log == [arg],
f"domain-like html argument: expected system open [{arg}], got {open_log}",
failures,
)
def test_non_file_scheme_html_passthrough(failures: list[str]) -> None:
url = "ftp://example.com/report.html"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="",
)
expect(code == 0, f"non-file scheme html: wrapper exited {code}: {stderr}", failures)
expect(cmux_log == [], f"non-file scheme html: cmux should not be called, got {cmux_log}", failures)
expect(open_log == [url], f"non-file scheme html: expected system open [{url}], got {open_log}", failures)
def test_mailto_html_passthrough(failures: list[str]) -> None:
url = "mailto:help@example.com?subject=report.html"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="",
)
expect(code == 0, f"mailto html: wrapper exited {code}: {stderr}", failures)
expect(cmux_log == [], f"mailto html: cmux should not be called, got {cmux_log}", failures)
expect(open_log == [url], f"mailto html: expected system open [{url}], got {open_log}", failures)
def test_local_non_html_file_passthrough(failures: list[str]) -> None:
filename = "fixtures/readme.md"
open_log, cmux_log, code, stderr = run_wrapper(
args=[filename],
intercept_setting="1",
whitelist="",
local_files=[filename],
)
expect(code == 0, f"local non-html file: wrapper exited {code}: {stderr}", failures)
expect(cmux_log == [], f"local non-html file: cmux should not be called, got {cmux_log}", failures)
expect(open_log == [filename], f"local non-html file: expected system open [{filename}], got {open_log}", failures)
def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None:
url = "https://xn--bcher-kva.example/path"
open_log, cmux_log, code, stderr = run_wrapper(
@ -316,6 +472,14 @@ def main() -> int:
test_legacy_toggle_fallback_passthrough(failures)
test_legacy_toggle_fallback_case_insensitive_passthrough(failures)
test_uppercase_scheme_routes_to_cmux(failures)
test_local_html_file_routes_to_cmux(failures)
test_file_url_html_routes_to_cmux(failures)
test_file_url_html_routes_to_cmux_without_python_binary(failures)
test_local_html_file_routes_to_cmux_without_python_binary(failures)
test_domain_like_html_argument_passthrough(failures)
test_non_file_scheme_html_passthrough(failures)
test_mailto_html_passthrough(failures)
test_local_non_html_file_passthrough(failures)
test_unicode_whitelist_matches_punycode_url(failures)
test_punycode_whitelist_matches_unicode_url(failures)

View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""v2 regression: browser can render local file:// HTML pages."""
import os
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_until(pred, timeout_s: float, label: str) -> None:
deadline = time.time() + timeout_s
last_exc = None
while time.time() < deadline:
try:
if pred():
return
except Exception as exc: # noqa: BLE001
last_exc = exc
time.sleep(0.05)
if last_exc is not None:
raise cmuxError(f"Timed out waiting for {label}: {last_exc}")
raise cmuxError(f"Timed out waiting for {label}")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="cmux-file-url-") as root:
html_path = Path(root) / "local-test.html"
html_path.write_text(
"""
<!doctype html>
<html>
<head><meta charset=\"utf-8\"><title>cmux file url load</title></head>
<body>
<h1 id=\"headline\">local HTML file loaded</h1>
<p id=\"path\">This page is loaded via file://</p>
</body>
</html>
""".strip(),
encoding="utf-8",
)
file_url = html_path.resolve().as_uri()
with cmux(SOCKET_PATH) as c:
opened = c._call("browser.open_split", {"url": "about:blank"}) or {}
sid = str(opened.get("surface_id") or "")
_must(bool(sid), f"browser.open_split returned no surface_id: {opened}")
c._call("browser.navigate", {"surface_id": sid, "url": file_url})
_wait_until(
lambda: str((c._call("browser.get.title", {"surface_id": sid}) or {}).get("title") or "")
== "cmux file url load",
timeout_s=5.0,
label="browser.get.title(file://)",
)
page_text = c._call(
"browser.eval",
{
"surface_id": sid,
"script": "document.body ? (document.body.innerText || '') : ''",
},
) or {}
_must(
"local HTML file loaded" in str(page_text.get("value") or ""),
f"Expected file:// page body text: {page_text}",
)
url_payload = c._call("browser.url.get", {"surface_id": sid}) or {}
actual_url = str(url_payload.get("url") or "")
_must(
actual_url.startswith("file://"),
f"Expected browser.url.get to stay on file:// URL: {url_payload}",
)
print("PASS: browser loads local file:// HTML")
return 0
if __name__ == "__main__":
raise SystemExit(main())