35 KiB
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"— anyasync def test_*runs automatically - Fixtures:
autouse=Truefixtures redirect state/settings paths totmp_path - Auth in tests: The
clientfixture intest_api.pycreates aTestClient(app)context manager, generates a real signed cookie viacreate_session_cookie(_auth_secret, _auth_ttl), and sets it onc.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:
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:
rm Caddyfile
Step 3: Run tests to confirm nothing depended on it
Run:
cd muxplex && pytest muxplex/tests/ -x -q
Expected: All tests pass. No test references Caddyfile.
Step 4: Commit
Run:
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:
cat requirements.txt
Expected: A comment saying "Dependencies are now managed in pyproject.toml" and nothing else.
Step 2: Delete the file
Run:
rm requirements.txt
Step 3: Run tests to confirm nothing depended on it
Run:
cd muxplex && pytest muxplex/tests/ -x -q
Expected: All tests pass.
Step 4: Commit
Run:
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:
# ---------------------------------------------------------------------------
# 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:
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:
- Add
import socketto the imports (afterimport json):
import copy
import json
import socket
from pathlib import Path
- Extend
DEFAULT_SETTINGS— add the two new keys at the end of the dict:
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": "",
}
- In
load_settings(), add hostname fallback after the merge loop. Insert these 2 lines right beforereturn result:
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:
cd muxplex && pytest muxplex/tests/test_settings.py -v
Expected: ALL tests pass (existing 8 + new 7 = 15 total).
Step 5: Commit
Run:
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:
# ---------------------------------------------------------------------------
# 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:
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:
_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout"}
to:
_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout", "/api/instance-info"}
Step 4: Run the test to verify it passes
Run:
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:
cd muxplex && pytest muxplex/tests/test_auth.py -v
Expected: All tests pass.
Step 6: Commit
Run:
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/settingsroutes, 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):
# ---------------------------------------------------------------------------
# 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:
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):
@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:
cd muxplex && pytest muxplex/tests/test_api.py -v -k "instance_info"
Expected: All 4 tests PASS.
Step 5: Commit
Run:
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:
# ---------------------------------------------------------------------------
# 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:
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:
- Add the import. Find the existing import block near line 26-30 and add
CORSMiddleware:
from starlette.middleware.cors import CORSMiddleware
Add this line right after the existing from starlette.responses import RedirectResponse import (line 30).
- Add the middleware after the auth middleware block (after line 252). Insert it between the auth middleware and the request/response models section:
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:
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:
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:
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:
"""
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:
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., to0.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:
cd muxplex && pytest muxplex/tests/ -v
Expected: All tests pass across all test files.
Step 4: Commit
Run:
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:
cd muxplex && pytest muxplex/tests/ -v
Expected: All tests pass. Zero failures, zero errors.
Step 2: Verify the deleted files are gone
Run:
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:
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:
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:
git log --oneline -7
Expected: 7 commits in order:
test: expand WebSocket proxy test suitefeat: add CORS middleware for cross-origin federationfeat: add GET /api/instance-info endpointfeat: exempt /api/instance-info from authfeat: add remote_instances and device_name to settingschore: remove stale requirements.txtchore: 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.