Add remote favicon proxy regression

This commit is contained in:
Lawrence Chen 2026-03-13 06:15:23 -07:00
parent b0bfabdb6a
commit 50b5969d62
2 changed files with 333 additions and 0 deletions

View file

@ -2037,6 +2037,8 @@ class TerminalController {
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params))
case "debug.browser.address_bar_focused":
return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params))
case "debug.browser.favicon":
return v2Result(id: id, self.v2DebugBrowserFavicon(params: params))
case "debug.sidebar.visible":
return v2Result(id: id, self.v2DebugSidebarVisible(params: params))
case "debug.terminal.is_focused":
@ -2245,6 +2247,7 @@ class TerminalController {
"debug.command_palette.rename_input.selection",
"debug.command_palette.rename_input.select_all",
"debug.browser.address_bar_focused",
"debug.browser.favicon",
"debug.sidebar.visible",
"debug.terminal.is_focused",
"debug.terminal.read_text",
@ -9633,6 +9636,21 @@ class TerminalController {
return .ok(payload)
}
private func v2DebugBrowserFavicon(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let pngData = browserPanel.faviconPNGData
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"has_favicon": pngData != nil,
"png_base64": pngData?.base64EncodedString() ?? "",
"current_url": v2OrNull(browserPanel.currentURL?.absoluteString)
])
}
}
private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)

View file

@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""Regression: remote browser favicon fetches must use the SSH proxy path."""
from __future__ import annotations
import glob
import json
import os
import secrets
import subprocess
import sys
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.sock")
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
if check and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
return proc
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
workspace_id = str(payload.get("workspace_id") or "")
if workspace_id:
return workspace_id
workspace_ref = str(payload.get("workspace_ref") or "")
if workspace_ref.startswith("workspace:"):
with cmux(SOCKET_PATH) as lookup_client:
listed = lookup_client._call("workspace.list", {}) or {}
for row in listed.get("workspaces") or []:
if str(row.get("ref") or "") == workspace_ref:
resolved = str(row.get("id") or "")
if resolved:
return resolved
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
new_ids = sorted(current - before_workspace_ids)
if len(new_ids) == 1:
return new_ids[0]
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 65.0) -> dict:
deadline = time.time() + timeout_s
last = {}
while time.time() < deadline:
last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last.get("remote") or {}
daemon = remote.get("daemon") or {}
proxy = remote.get("proxy") or {}
if (
str(remote.get("state") or "") == "connected"
and str(daemon.get("state") or "") == "ready"
and str(proxy.get("state") or "") == "ready"
):
return last
time.sleep(0.25)
raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}")
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
payload = client._call(
"surface.read_text",
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
) or {}
return str(payload.get("text") or "")
def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if token in _surface_scrollback_text(client, workspace_id, surface_id):
return
time.sleep(0.2)
raise cmuxError(f"Timed out waiting for terminal token: {token}")
def _browser_body_text(client: cmux, surface_id: str) -> str:
payload = client._call(
"browser.eval",
{
"surface_id": surface_id,
"script": "document.body ? (document.body.innerText || '') : ''",
},
) or {}
return str(payload.get("value") or "")
def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None:
deadline = time.time() + timeout_s
last_text = ""
while time.time() < deadline:
try:
last_text = _browser_body_text(client, surface_id)
except cmuxError:
time.sleep(0.2)
continue
if token in last_text:
return
time.sleep(0.2)
raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}")
def _browser_favicon_state(client: cmux, surface_id: str) -> dict:
return dict(client._call("debug.browser.favicon", {"surface_id": surface_id}) or {})
def _wait_browser_favicon(client: cmux, surface_id: str, timeout_s: float = 20.0) -> dict:
deadline = time.time() + timeout_s
last = {}
while time.time() < deadline:
try:
last = _browser_favicon_state(client, surface_id)
except cmuxError:
time.sleep(0.2)
continue
if bool(last.get("has_favicon")) and bool(str(last.get("png_base64") or "")):
return last
time.sleep(0.2)
raise cmuxError(f"Timed out waiting for browser favicon state on {surface_id}: {last}")
def main() -> int:
if not SSH_HOST:
print("SKIP: set CMUX_SSH_TEST_HOST to run remote favicon proxy regression")
return 0
cli = _find_cli_binary()
remote_workspace_id = ""
remote_surface_id = ""
server_script_path = ""
server_log_path = ""
hit_file_path = ""
stamp = secrets.token_hex(4)
page_token = f"CMUX_REMOTE_FAVICON_PAGE_{stamp}"
server_ready_token = f"CMUX_REMOTE_FAVICON_READY_{stamp}"
default_web_port = 23000 + (os.getpid() % 4000)
ssh_web_port = int(os.environ.get("CMUX_SSH_TEST_WEB_PORT", str(default_web_port)))
url = f"http://localhost:{ssh_web_port}/"
png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9WewAAAABJRU5ErkJggg=="
server_script_path = f"/tmp/cmux_remote_favicon_server_{stamp}.py"
server_log_path = f"/tmp/cmux_remote_favicon_server_{stamp}.log"
hit_file_path = f"/tmp/cmux_remote_favicon_hit_{stamp}"
try:
with cmux(SOCKET_PATH) as setup_client:
before_workspace_ids = {wid for _index, wid, _title, _focused in setup_client.list_workspaces()}
ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-favicon-{stamp}"]
if SSH_PORT:
ssh_args.extend(["--port", SSH_PORT])
if SSH_IDENTITY:
ssh_args.extend(["--identity", SSH_IDENTITY])
if SSH_OPTIONS_RAW:
for option in SSH_OPTIONS_RAW.split(","):
trimmed = option.strip()
if trimmed:
ssh_args.extend(["--ssh-option", trimmed])
payload = _run_cli_json(cli, ssh_args)
with cmux(SOCKET_PATH) as client:
remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
_wait_remote_ready(client, remote_workspace_id, timeout_s=65.0)
surfaces = client.list_surfaces(remote_workspace_id)
_must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}")
remote_surface_id = str(surfaces[0][1])
server_script = f"""cat > {server_script_path} <<'PY'
import base64
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
PORT = int(sys.argv[1])
HIT_FILE = sys.argv[2]
PAGE_TOKEN = sys.argv[3]
PNG = base64.b64decode(sys.argv[4].encode("ascii"))
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/favicon.ico"):
with open(HIT_FILE, "w", encoding="utf-8") as f:
f.write("hit\\n")
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(PNG)))
self.end_headers()
self.wfile.write(PNG)
return
body = (
"<!doctype html><html><head>"
"<link rel=\\"icon\\" href=\\"/favicon.ico?via=cmux\\">"
f"</head><body>{{PAGE_TOKEN}}</body></html>"
).replace("{{PAGE_TOKEN}}", PAGE_TOKEN)
data = body.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt, *args):
return
HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
PY
rm -f {hit_file_path} {server_log_path}
python3 {server_script_path} {ssh_web_port} {hit_file_path} {page_token} {png_base64} >{server_log_path} 2>&1 &
for _ in $(seq 1 30); do
if curl -fsS http://localhost:{ssh_web_port}/ | grep -q {page_token}; then
echo {server_ready_token}
break
fi
sleep 0.2
done"""
client._call(
"surface.send_text",
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script},
)
client._call(
"surface.send_key",
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"},
)
_wait_surface_contains(client, remote_workspace_id, remote_surface_id, server_ready_token, timeout_s=12.0)
browser_payload = client._call(
"browser.open_split",
{"workspace_id": remote_workspace_id, "url": url},
) or {}
browser_surface_id = str(browser_payload.get("surface_id") or "")
_must(browser_surface_id, f"browser.open_split returned no surface_id: {browser_payload}")
_wait_browser_contains(client, browser_surface_id, page_token, timeout_s=20.0)
favicon_state = _wait_browser_favicon(client, browser_surface_id, timeout_s=14.0)
_must(bool(favicon_state.get("has_favicon")), f"browser favicon state never became ready: {favicon_state}")
_must(bool(str(favicon_state.get('png_base64') or "")), f"browser favicon PNG payload missing: {favicon_state}")
print("PASS: remote browser favicon state loads for remote localhost pages over the SSH proxy")
return 0
finally:
if remote_surface_id and remote_workspace_id:
try:
cleanup = (
f"pkill -f {server_script_path} >/dev/null 2>&1 || true; "
f"rm -f {server_script_path} {server_log_path} {hit_file_path}"
)
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client._call(
"surface.send_text",
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup},
)
cleanup_client._call(
"surface.send_key",
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"},
)
except Exception: # noqa: BLE001
pass
if __name__ == "__main__":
raise SystemExit(main())