# Multi-Device Federation — Phase 1: Cleanup + Backend Foundation > **Execution:** Use the subagent-driven-development workflow to implement this plan. **Goal:** Prepare the backend for multi-device federation by removing dead files, extending settings with federation fields, adding a public `/api/instance-info` route, enabling CORS, and expanding WebSocket proxy test coverage. **Architecture:** The browser-side federation model requires each muxplex instance to (a) know its own display name and version, (b) allow cross-origin requests, and (c) expose a lightweight public metadata endpoint. This phase adds those backend primitives without touching the frontend. The existing `settings.py` defaults-merge pattern absorbs the new fields with zero migration. **Tech Stack:** Python 3.11+, FastAPI, pytest, Starlette `CORSMiddleware` **Design doc:** `docs/plans/2026-03-30-multi-device-federation-design.md` --- ## Orientation — What You Need to Know ### Project layout ``` muxplex/ ← project root (run all commands from here) ├── pyproject.toml ← deps, pytest config (asyncio_mode = "auto") ├── Caddyfile ← DEAD FILE — deleting in Task 1 ├── requirements.txt ← DEAD FILE — deleting in Task 2 └── muxplex/ ← Python package ├── main.py ← FastAPI app, routes, WebSocket proxy ├── settings.py ← DEFAULT_SETTINGS dict, load/save/patch ├── auth.py ← AuthMiddleware, _AUTH_EXEMPT_PATHS set ├── ttyd.py ← TTYD_PORT = 7682 └── tests/ ├── test_api.py ← 1068 lines, uses `client` fixture w/ auth cookie ├── test_settings.py ← 144 lines, redirect_settings_path fixture ├── test_auth.py ← 449 lines, _make_test_app pattern └── test_ws_proxy.py ← 28 lines — expanding in Task 7 ``` ### Test conventions - **Run tests:** `cd muxplex && pytest muxplex/tests/ -v` (or a specific file/test) - **Async mode:** `asyncio_mode = "auto"` — any `async def test_*` runs automatically - **Fixtures:** `autouse=True` fixtures redirect state/settings paths to `tmp_path` - **Auth in tests:** The `client` fixture in `test_api.py` creates a `TestClient(app)` context manager, generates a real signed cookie via `create_session_cookie(_auth_secret, _auth_ttl)`, and sets it on `c.cookies` - **Monkeypatching:** Patches at the **import site** — e.g. `monkeypatch.setattr("muxplex.main.get_session_list", ...)`, not `"muxplex.sessions.get_session_list"` ### Key constants you'll encounter | Constant | Location | Value | |----------|----------|-------| | `_AUTH_EXEMPT_PATHS` | `muxplex/auth.py:137` | `{"/login", "/auth/mode", "/auth/logout"}` | | `SETTINGS_PATH` | `muxplex/settings.py:11` | `Path.home() / ".config" / "muxplex" / "settings.json"` | | `DEFAULT_SETTINGS` | `muxplex/settings.py:13-20` | 6-key dict (sort_order, hidden_sessions, etc.) | | `TTYD_PORT` | `muxplex/ttyd.py:32` | `7682` | | App version | `muxplex/main.py:191` | `"0.1.0"` | --- ## Task 1: Delete Caddyfile **Files:** - Delete: `Caddyfile` (project root) **Why:** Dead artifact. The WebSocket proxy is fully built into FastAPI (`main.py` lines 506-559). The Caddyfile was from an earlier architecture and does nothing. **Step 1: Verify the file exists and is what we expect** Run: ```bash cat Caddyfile ``` Expected: An 11-line Caddy config with `:8088`, `/terminal/*` reverse proxy rules. Confirms this is the dead artifact, not something secretly important. **Step 2: Delete the file** Run: ```bash rm Caddyfile ``` **Step 3: Run tests to confirm nothing depended on it** Run: ```bash cd muxplex && pytest muxplex/tests/ -x -q ``` Expected: All tests pass. No test references Caddyfile. **Step 4: Commit** Run: ```bash git add Caddyfile && git commit -m "chore: remove dead Caddyfile The WebSocket proxy is built into FastAPI (main.py). Caddy is no longer used. Removing the stale config to avoid confusion." ``` --- ## Task 2: Delete requirements.txt **Files:** - Delete: `requirements.txt` (project root) **Why:** Contains only a 6-line comment pointing to `pyproject.toml`. It's a lie on disk — anyone running `pip install -r requirements.txt` gets nothing. **Step 1: Verify contents** Run: ```bash cat requirements.txt ``` Expected: A comment saying "Dependencies are now managed in pyproject.toml" and nothing else. **Step 2: Delete the file** Run: ```bash rm requirements.txt ``` **Step 3: Run tests to confirm nothing depended on it** Run: ```bash cd muxplex && pytest muxplex/tests/ -x -q ``` Expected: All tests pass. **Step 4: Commit** Run: ```bash git add requirements.txt && git commit -m "chore: remove stale requirements.txt Dependencies live in pyproject.toml. The file contained only a comment pointing there — removing to avoid stale breadcrumbs." ``` --- ## Task 3: Extend DEFAULT_SETTINGS with federation fields **Files:** - Modify: `muxplex/settings.py` (lines 1-20) - Test: `muxplex/tests/test_settings.py` **What:** Add two new keys to `DEFAULT_SETTINGS`: - `"remote_instances": []` — list of `{url, name}` dicts for peer instances - `"device_name": ""` — this instance's display name, defaults to hostname at read time The `device_name` default is `""` in the dict (so JSON serialization is clean), but `load_settings()` replaces `""` with `socket.gethostname()` after loading — this way the hostname is always current even if the machine is renamed. **Step 1: Write the failing tests** Add these tests at the bottom of `muxplex/tests/test_settings.py`: ```python # --------------------------------------------------------------------------- # Federation field tests # --------------------------------------------------------------------------- def test_defaults_include_remote_instances(): """DEFAULT_SETTINGS must include remote_instances as an empty list.""" assert "remote_instances" in DEFAULT_SETTINGS assert DEFAULT_SETTINGS["remote_instances"] == [] def test_defaults_include_device_name(): """DEFAULT_SETTINGS must include device_name as an empty string.""" assert "device_name" in DEFAULT_SETTINGS assert DEFAULT_SETTINGS["device_name"] == "" def test_load_returns_hostname_when_device_name_empty(monkeypatch): """load_settings() fills empty device_name with the system hostname.""" monkeypatch.setattr("muxplex.settings.socket.gethostname", lambda: "my-laptop") result = load_settings() assert result["device_name"] == "my-laptop" def test_load_preserves_explicit_device_name(redirect_settings_path, monkeypatch): """load_settings() keeps a user-set device_name (does not overwrite with hostname).""" import json monkeypatch.setattr("muxplex.settings.socket.gethostname", lambda: "my-laptop") redirect_settings_path.write_text(json.dumps({"device_name": "Work PC"})) result = load_settings() assert result["device_name"] == "Work PC" def test_remote_instances_round_trip(redirect_settings_path): """remote_instances survive a save/load cycle unchanged.""" instances = [ {"url": "http://workstation:8088", "name": "Workstation"}, {"url": "https://devserver:8088", "name": "Dev Server"}, ] save_settings({"remote_instances": instances}) result = load_settings() assert result["remote_instances"] == instances def test_device_name_round_trip(redirect_settings_path, monkeypatch): """An explicit device_name survives a save/load cycle.""" monkeypatch.setattr("muxplex.settings.socket.gethostname", lambda: "fallback-host") save_settings({"device_name": "My Server"}) result = load_settings() assert result["device_name"] == "My Server" def test_load_does_not_mutate_default_remote_instances(): """Mutating loaded remote_instances must not corrupt DEFAULT_SETTINGS.""" result = load_settings() result["remote_instances"].append({"url": "http://evil:8088", "name": "Evil"}) assert DEFAULT_SETTINGS["remote_instances"] == [] result2 = load_settings() assert result2["remote_instances"] == [] ``` **Step 2: Run tests to verify they fail** Run: ```bash cd muxplex && pytest muxplex/tests/test_settings.py -v -k "federation or remote_instances or device_name or hostname" ``` Expected: All 7 new tests FAIL with `KeyError: 'remote_instances'` or `KeyError: 'device_name'` or `AssertionError`. **Step 3: Implement the settings changes** In `muxplex/settings.py`, make these changes: 1. Add `import socket` to the imports (after `import json`): ```python import copy import json import socket from pathlib import Path ``` 2. Extend `DEFAULT_SETTINGS` — add the two new keys at the end of the dict: ```python DEFAULT_SETTINGS: dict = { "default_session": None, "sort_order": "manual", "hidden_sessions": [], "window_size_largest": False, "auto_open_created": True, "new_session_template": "tmux new-session -d -s {name}", "remote_instances": [], "device_name": "", } ``` 3. In `load_settings()`, add hostname fallback **after** the merge loop. Insert these 2 lines right before `return result`: ```python def load_settings() -> dict: """Load settings from disk, merging saved values over defaults. Returns DEFAULT_SETTINGS if the file does not exist or contains corrupt JSON. Unknown keys in the file are ignored. """ result = copy.deepcopy(DEFAULT_SETTINGS) try: text = SETTINGS_PATH.read_text() data = json.loads(text) for key in DEFAULT_SETTINGS: if key in data: result[key] = data[key] except (FileNotFoundError, json.JSONDecodeError): pass # Hostname fallback: empty device_name → current hostname if not result["device_name"]: result["device_name"] = socket.gethostname() return result ``` **Step 4: Run tests to verify they pass** Run: ```bash cd muxplex && pytest muxplex/tests/test_settings.py -v ``` Expected: ALL tests pass (existing 8 + new 7 = 15 total). **Step 5: Commit** Run: ```bash cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add remote_instances and device_name to settings Extends DEFAULT_SETTINGS with: - remote_instances: [] — list of {url, name} for peer muxplex instances - device_name: '' — this instance's display name, falls back to hostname The hostname fallback happens at load time so it stays current even if the machine is renamed. Explicit values are preserved." ``` --- ## Task 4: Add /api/instance-info to auth exempt paths **Files:** - Modify: `muxplex/auth.py` (line 137) - Test: `muxplex/tests/test_auth.py` **What:** Add `"/api/instance-info"` to the `_AUTH_EXEMPT_PATHS` set so the endpoint can be called without authentication. This must happen before the route itself is created (Task 5), because the route handler assumes no auth. **Step 1: Write the failing test** Add this test at the end of `muxplex/tests/test_auth.py`: ```python # --------------------------------------------------------------------------- # Auth exempt paths — federation # --------------------------------------------------------------------------- def test_middleware_instance_info_path_excluded(): """/api/instance-info is excluded from auth (public metadata endpoint).""" app = _make_test_app() @app.get("/api/instance-info") async def instance_info(): return PlainTextResponse("info") client = TestClient(app, base_url="http://192.168.1.1") response = client.get("/api/instance-info") assert response.status_code == 200 ``` **Step 2: Run the test to verify it fails** Run: ```bash cd muxplex && pytest muxplex/tests/test_auth.py::test_middleware_instance_info_path_excluded -v ``` Expected: FAIL — response is 307 redirect to `/login` because `/api/instance-info` is not in the exempt set yet. **Step 3: Add the path to the exempt set** In `muxplex/auth.py`, change line 137 from: ```python _AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout"} ``` to: ```python _AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout", "/api/instance-info"} ``` **Step 4: Run the test to verify it passes** Run: ```bash cd muxplex && pytest muxplex/tests/test_auth.py::test_middleware_instance_info_path_excluded -v ``` Expected: PASS. **Step 5: Run full auth test suite to check for regressions** Run: ```bash cd muxplex && pytest muxplex/tests/test_auth.py -v ``` Expected: All tests pass. **Step 6: Commit** Run: ```bash cd muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat: exempt /api/instance-info from auth The instance-info endpoint is public metadata (like a health check). Remote instances need to call it without a session cookie to discover peer names and verify reachability." ``` --- ## Task 5: Add GET /api/instance-info route **Files:** - Modify: `muxplex/main.py` (add route after the `/api/settings` routes, before the WebSocket proxy section) - Test: `muxplex/tests/test_api.py` **What:** `GET /api/instance-info` returns `{"name": "", "version": "0.1.0"}`. No auth required (handled by Task 4). The route reads `device_name` from `load_settings()` and the version from `app.version`. **Step 1: Write the failing tests** Add these tests in `muxplex/tests/test_api.py`, after the `PATCH /api/settings` test section (after line ~968): ```python # --------------------------------------------------------------------------- # GET /api/instance-info # --------------------------------------------------------------------------- def test_instance_info_returns_200(client): """GET /api/instance-info returns HTTP 200.""" response = client.get("/api/instance-info") assert response.status_code == 200 def test_instance_info_returns_name_and_version(client, tmp_path, monkeypatch): """GET /api/instance-info returns JSON with name and version keys.""" import muxplex.settings as settings_mod monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") monkeypatch.setattr("muxplex.settings.socket.gethostname", lambda: "test-host") response = client.get("/api/instance-info") assert response.status_code == 200 data = response.json() assert data["name"] == "test-host" assert data["version"] == "0.1.0" def test_instance_info_uses_explicit_device_name(client, tmp_path, monkeypatch): """GET /api/instance-info uses explicit device_name from settings.""" import json import muxplex.settings as settings_mod settings_path = tmp_path / "settings.json" monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) settings_path.write_text(json.dumps({"device_name": "My Workstation"})) response = client.get("/api/instance-info") assert response.status_code == 200 data = response.json() assert data["name"] == "My Workstation" def test_instance_info_no_auth_required(monkeypatch): """GET /api/instance-info succeeds without a session cookie (non-localhost).""" monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password") with TestClient(app) as c: # Do NOT set any auth cookie response = c.get( "/api/instance-info", headers={"Accept": "application/json"}, ) assert response.status_code == 200 data = response.json() assert "name" in data assert "version" in data ``` **Step 2: Run tests to verify they fail** Run: ```bash cd muxplex && pytest muxplex/tests/test_api.py -v -k "instance_info" ``` Expected: All 4 tests FAIL with 404 (route doesn't exist yet) or `KeyError`. **Step 3: Add the route to main.py** In `muxplex/main.py`, add this route **after** the `PATCH /api/settings` route (after line 498) and **before** the WebSocket proxy section (line 501 comment): ```python @app.get("/api/instance-info") async def instance_info() -> dict: """Return this instance's display name and version. Public endpoint (no auth required) — used by remote instances to discover peer names and verify reachability. """ settings = load_settings() return {"name": settings["device_name"], "version": app.version} ``` Place it between the existing `update_settings` function and the `# WebSocket proxy` comment block. No new imports needed — `load_settings` is already imported at line 61. **Step 4: Run tests to verify they pass** Run: ```bash cd muxplex && pytest muxplex/tests/test_api.py -v -k "instance_info" ``` Expected: All 4 tests PASS. **Step 5: Commit** Run: ```bash cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add GET /api/instance-info endpoint Returns {name, version} for this muxplex instance. No auth required — remote instances call this to discover peer display names and verify reachability. The name comes from settings.device_name (falls back to hostname)." ``` --- ## Task 6: Add CORS middleware **Files:** - Modify: `muxplex/main.py` (add middleware after auth middleware) - Test: `muxplex/tests/test_api.py` **What:** Add Starlette's `CORSMiddleware` to the FastAPI app so browsers on one muxplex instance can make cross-origin requests to another. Allow all origins — these are private network tools, not public APIs. **Step 1: Write the failing tests** Add these tests in `muxplex/tests/test_api.py`, right after the instance-info tests you added in Task 5: ```python # --------------------------------------------------------------------------- # CORS middleware # --------------------------------------------------------------------------- def test_cors_preflight_returns_200(client): """An OPTIONS preflight request returns 200 with CORS headers.""" response = client.options( "/api/sessions", headers={ "Origin": "http://workstation:8088", "Access-Control-Request-Method": "GET", }, ) assert response.status_code == 200 assert response.headers["access-control-allow-origin"] == "*" def test_cors_allows_any_origin(client): """A normal GET with an Origin header gets Access-Control-Allow-Origin: *.""" response = client.get( "/api/sessions", headers={"Origin": "http://some-other-host:8088"}, ) assert response.status_code == 200 assert response.headers["access-control-allow-origin"] == "*" def test_cors_allows_credentials(client): """CORS response includes Access-Control-Allow-Credentials: true.""" response = client.get( "/api/sessions", headers={"Origin": "http://workstation:8088"}, ) assert response.status_code == 200 assert response.headers.get("access-control-allow-credentials") == "true" ``` **Step 2: Run tests to verify they fail** Run: ```bash cd muxplex && pytest muxplex/tests/test_api.py -v -k "cors" ``` Expected: All 3 tests FAIL — no `access-control-allow-origin` header in responses. The `test_cors_preflight_returns_200` may also get a 405 or redirect. **Step 3: Add CORS middleware to main.py** In `muxplex/main.py`: 1. Add the import. Find the existing import block near line 26-30 and add `CORSMiddleware`: ```python from starlette.middleware.cors import CORSMiddleware ``` Add this line right after the existing `from starlette.responses import RedirectResponse` import (line 30). 2. Add the middleware **after** the auth middleware block (after line 252). Insert it between the auth middleware and the request/response models section: ```python app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) ``` > **Important:** FastAPI middleware executes in reverse registration order (last-added runs first). By adding CORS **after** auth, CORS preflight (OPTIONS) will be handled before the auth check, which is correct — preflight requests don't carry cookies. **Step 4: Run tests to verify they pass** Run: ```bash cd muxplex && pytest muxplex/tests/test_api.py -v -k "cors" ``` Expected: All 3 CORS tests PASS. **Step 5: Run full test suite to check for regressions** Run: ```bash cd muxplex && pytest muxplex/tests/ -v ``` Expected: All tests pass. CORS middleware is additive — it only adds headers, never blocks existing functionality. **Step 6: Commit** Run: ```bash cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add CORS middleware for cross-origin federation Adds Starlette CORSMiddleware with allow_origins=['*'] so browsers on one muxplex instance can fetch sessions from another. Permissive CORS is appropriate — these are private network tools, not public APIs." ``` --- ## Task 7: Expand WebSocket proxy tests — bidirectional relay **Files:** - Modify: `muxplex/tests/test_ws_proxy.py` **What:** Add tests for bidirectional message relay (browser → ttyd and ttyd → browser). This requires mocking the `websockets.connect` call to intercept what gets sent to ttyd. **Step 1: Write the test file preamble and relay tests** Replace the **entire contents** of `muxplex/tests/test_ws_proxy.py` with the following. The existing regression test is preserved as-is at the top: ```python """ Tests for the WebSocket proxy in muxplex/main.py. The proxy at /terminal/ws bridges the browser WebSocket to a backend ttyd WebSocket at ws://localhost:7682/ws. These tests mock the ttyd side using a real asyncio server so we can verify bidirectional relay, close propagation, auth rejection, and concurrent sessions. """ import asyncio import inspect from unittest.mock import AsyncMock, patch import pytest from fastapi.testclient import TestClient from starlette.websockets import WebSocketDisconnect from muxplex.auth import create_session_cookie from muxplex.main import app, _auth_secret, _auth_ttl, terminal_ws_proxy # --------------------------------------------------------------------------- # autouse fixture — redirect state/PID files, mock startup side-effects # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def patch_startup_and_state(tmp_path, monkeypatch): """Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op.""" tmp_state_dir = tmp_path / "state" tmp_state_path = tmp_state_dir / "state.json" monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) tmp_pid_dir = tmp_path / "ttyd" tmp_pid_path = tmp_pid_dir / "ttyd.pid" monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir) monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path) async def _mock_kill_orphan(): return False monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan) async def noop_poll_loop() -> None: pass monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_authed_client() -> TestClient: """Create a TestClient with a valid session cookie.""" c = TestClient(app) cookie = create_session_cookie(_auth_secret, _auth_ttl) c.cookies.set("muxplex_session", cookie) return c class FakeTtydWs: """A fake ttyd WebSocket connection for mocking websockets.connect(). Stores messages sent to ttyd and yields pre-loaded responses back. """ def __init__(self, responses=None): self.sent = [] # messages sent TO ttyd self._responses = list(responses or []) self._closed = False async def send(self, message): self.sent.append(message) async def close(self): self._closed = True def __aiter__(self): return self async def __anext__(self): if self._responses: return self._responses.pop(0) raise StopAsyncIteration async def __aenter__(self): return self async def __aexit__(self, *args): self._closed = True # --------------------------------------------------------------------------- # Regression test (preserved from original) # --------------------------------------------------------------------------- def test_terminal_ws_proxy_does_not_use_receive_bytes(): """Regression: receive_bytes() silently drops TEXT frames (like the ttyd auth token). terminal.js sends {"AuthToken": ""} as a TEXT WebSocket frame. The original proxy used receive_bytes() which fails on text frames, swallowed the exception, and exited — meaning ttyd never received the auth token, never started streaming, resulting in a permanent black screen and reconnect loop. The proxy MUST use receive() and dispatch on message type to handle both binary and text frames correctly. """ source = inspect.getsource(terminal_ws_proxy) assert "receive_bytes" not in source, ( "client_to_ttyd must not use receive_bytes() — silently drops text frames " 'like the ttyd auth token {"AuthToken": ""}' ) assert ".receive()" in source, ( "client_to_ttyd must use receive() to handle both text and binary frames" ) # --------------------------------------------------------------------------- # Auth rejection tests # --------------------------------------------------------------------------- def test_ws_auth_rejection_no_cookie(): """WebSocket from non-localhost without a cookie is closed with code 4001.""" with TestClient(app) as c: with pytest.raises(WebSocketDisconnect) as exc_info: with c.websocket_connect("/terminal/ws"): pass assert exc_info.value.code == 4001 def test_ws_auth_rejection_invalid_cookie(): """WebSocket from non-localhost with a tampered cookie is closed with code 4001.""" with TestClient(app) as c: c.cookies.set("muxplex_session", "tampered.invalid.cookie") with pytest.raises(WebSocketDisconnect) as exc_info: with c.websocket_connect("/terminal/ws"): pass assert exc_info.value.code == 4001 # --------------------------------------------------------------------------- # Bidirectional relay tests # --------------------------------------------------------------------------- def test_browser_text_relayed_to_ttyd(): """A text message from the browser is forwarded to ttyd.""" fake_ttyd = FakeTtydWs() with ( patch("muxplex.main.websockets.connect", return_value=fake_ttyd), TestClient(app) as c, ): cookie = create_session_cookie(_auth_secret, _auth_ttl) c.cookies.set("muxplex_session", cookie) with c.websocket_connect("/terminal/ws") as ws: ws.send_text('{"AuthToken": ""}') # Give the relay task a moment to forward import time time.sleep(0.1) assert '{"AuthToken": ""}' in fake_ttyd.sent def test_browser_bytes_relayed_to_ttyd(): """A binary message from the browser is forwarded to ttyd.""" fake_ttyd = FakeTtydWs() with ( patch("muxplex.main.websockets.connect", return_value=fake_ttyd), TestClient(app) as c, ): cookie = create_session_cookie(_auth_secret, _auth_ttl) c.cookies.set("muxplex_session", cookie) with c.websocket_connect("/terminal/ws") as ws: ws.send_bytes(b"\x01hello") import time time.sleep(0.1) assert b"\x01hello" in fake_ttyd.sent def test_ttyd_text_relayed_to_browser(): """A text message from ttyd is forwarded to the browser.""" fake_ttyd = FakeTtydWs(responses=["terminal output"]) with ( patch("muxplex.main.websockets.connect", return_value=fake_ttyd), TestClient(app) as c, ): cookie = create_session_cookie(_auth_secret, _auth_ttl) c.cookies.set("muxplex_session", cookie) with c.websocket_connect("/terminal/ws") as ws: msg = ws.receive_text() assert msg == "terminal output" def test_ttyd_bytes_relayed_to_browser(): """A binary message from ttyd is forwarded to the browser.""" fake_ttyd = FakeTtydWs(responses=[b"\x01binary-output"]) with ( patch("muxplex.main.websockets.connect", return_value=fake_ttyd), TestClient(app) as c, ): cookie = create_session_cookie(_auth_secret, _auth_ttl) c.cookies.set("muxplex_session", cookie) with c.websocket_connect("/terminal/ws") as ws: msg = ws.receive_bytes() assert msg == b"\x01binary-output" # --------------------------------------------------------------------------- # Close propagation tests # --------------------------------------------------------------------------- def test_ttyd_close_propagates_to_browser(): """When ttyd has no more messages, the browser WebSocket closes cleanly.""" # FakeTtydWs with no responses will exhaust immediately → proxy should close fake_ttyd = FakeTtydWs(responses=[]) with ( patch("muxplex.main.websockets.connect", return_value=fake_ttyd), TestClient(app) as c, ): cookie = create_session_cookie(_auth_secret, _auth_ttl) c.cookies.set("muxplex_session", cookie) # The websocket_connect should terminate without hanging try: with c.websocket_connect("/terminal/ws") as ws: # Try to receive — should get disconnect since ttyd has no data ws.receive_text() except (WebSocketDisconnect, Exception): pass # Expected — connection closed after ttyd exhausted # --------------------------------------------------------------------------- # ttyd unreachable test # --------------------------------------------------------------------------- def test_ttyd_unreachable_closes_browser_ws(): """When ttyd is unreachable, the browser WebSocket is closed (not hung).""" with ( patch( "muxplex.main.websockets.connect", side_effect=OSError("Connection refused"), ), TestClient(app) as c, ): cookie = create_session_cookie(_auth_secret, _auth_ttl) c.cookies.set("muxplex_session", cookie) try: with c.websocket_connect("/terminal/ws") as ws: ws.receive_text() except (WebSocketDisconnect, Exception): pass # Expected — proxy closes browser WS when ttyd is unreachable # --------------------------------------------------------------------------- # Concurrent sessions test # --------------------------------------------------------------------------- def test_concurrent_ws_sessions(): """Two simultaneous WebSocket proxy sessions don't interfere.""" fake_ttyd_1 = FakeTtydWs(responses=["session-1-output"]) fake_ttyd_2 = FakeTtydWs(responses=["session-2-output"]) call_count = 0 def mock_connect(*args, **kwargs): nonlocal call_count call_count += 1 return fake_ttyd_1 if call_count == 1 else fake_ttyd_2 with ( patch("muxplex.main.websockets.connect", side_effect=mock_connect), TestClient(app) as c, ): cookie = create_session_cookie(_auth_secret, _auth_ttl) c.cookies.set("muxplex_session", cookie) # Open first connection and read its message with c.websocket_connect("/terminal/ws") as ws1: msg1 = ws1.receive_text() assert msg1 == "session-1-output" # Open second connection and read its message with c.websocket_connect("/terminal/ws") as ws2: msg2 = ws2.receive_text() assert msg2 == "session-2-output" ``` **Step 2: Run the new tests** Run: ```bash cd muxplex && pytest muxplex/tests/test_ws_proxy.py -v ``` Expected: All tests PASS. These tests use mocking to simulate ttyd, so they don't require a real ttyd process. > **Note:** If any relay tests fail due to timing, the `time.sleep(0.1)` calls may need a slight increase (e.g., to `0.2`). The TestClient's synchronous WebSocket interface introduces small timing gaps between send and the async relay task. **Step 3: Run the full test suite** Run: ```bash cd muxplex && pytest muxplex/tests/ -v ``` Expected: All tests pass across all test files. **Step 4: Commit** Run: ```bash cd muxplex && git add muxplex/tests/test_ws_proxy.py && git commit -m "test: expand WebSocket proxy test suite Replaces the 28-line regression test with a comprehensive suite covering: - Bidirectional message relay (text + binary, both directions) - Close propagation (ttyd exhaustion → browser close) - Auth rejection (no cookie → 4001, invalid cookie → 4001) - ttyd unreachable (OSError → browser WS closed) - Concurrent sessions (two proxies don't interfere) Uses FakeTtydWs mock to simulate the ttyd WebSocket without needing a real ttyd process." ``` --- ## Task 8: Final verification **No files changed.** This task is a sanity check that everything works together. **Step 1: Run the entire test suite** Run: ```bash cd muxplex && pytest muxplex/tests/ -v ``` Expected: All tests pass. Zero failures, zero errors. **Step 2: Verify the deleted files are gone** Run: ```bash ls -la Caddyfile requirements.txt 2>&1 ``` Expected: `No such file or directory` for both. **Step 3: Verify the new fields are in settings** Run: ```bash cd muxplex && python -c "from muxplex.settings import DEFAULT_SETTINGS; print(sorted(DEFAULT_SETTINGS.keys()))" ``` Expected output includes `device_name` and `remote_instances` among the 8 keys. **Step 4: Verify instance-info route exists** Run: ```bash cd muxplex && python -c "from muxplex.main import app; print([r.path for r in app.routes if hasattr(r, 'path') and 'instance' in r.path])" ``` Expected: `['/api/instance-info']` **Step 5: Check git log** Run: ```bash git log --oneline -7 ``` Expected: 7 commits in order: 1. `test: expand WebSocket proxy test suite` 2. `feat: add CORS middleware for cross-origin federation` 3. `feat: add GET /api/instance-info endpoint` 4. `feat: exempt /api/instance-info from auth` 5. `feat: add remote_instances and device_name to settings` 6. `chore: remove stale requirements.txt` 7. `chore: remove dead Caddyfile` --- ## Summary | Task | What | Tests Added | |------|------|-------------| | 1 | Delete `Caddyfile` | — (deletion) | | 2 | Delete `requirements.txt` | — (deletion) | | 3 | Extend `DEFAULT_SETTINGS` with `remote_instances` + `device_name` | 7 tests in `test_settings.py` | | 4 | Add `/api/instance-info` to auth exempt paths | 1 test in `test_auth.py` | | 5 | Add `GET /api/instance-info` route | 4 tests in `test_api.py` | | 6 | Add CORS middleware | 3 tests in `test_api.py` | | 7 | Expand `test_ws_proxy.py` to full suite | 11 tests (replaces 1) | | 8 | Final verification | — (sanity check) | **Total new tests:** 26 **Files modified:** 5 (`settings.py`, `main.py`, `auth.py`, `test_settings.py`, `test_api.py`, `test_ws_proxy.py`) **Files deleted:** 2 (`Caddyfile`, `requirements.txt`) After this phase the backend is fully federation-ready: settings accept remote instances, each instance knows its name, CORS is enabled, and a public metadata endpoint exists. The frontend is untouched — that's Phase 2.