Files
muxplex/docs/plans/2026-03-30-multi-device-federation-phase1.md
T
2026-03-31 03:40:57 -07:00

1067 lines
35 KiB
Markdown

# 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": "<device_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.