Add three tests verifying --tls-cert and --tls-key flag behavior:
- test_main_passes_tls_cert_and_key_flags: verifies exact kwargs forwarded to serve()
- test_main_passes_none_for_unset_tls_flags: verifies None when flags omitted
- test_serve_subcommand_accepts_tls_flags: verifies 'muxplex serve --tls-cert ... --tls-key ...'
All three tests patch muxplex.cli.serve and sys.argv per spec.
CLI implementation was already complete (--tls-cert/--tls-key in _add_serve_flags
and forwarded in main() else block). All 80 tests pass.
- Add tls_cert and tls_key keyword arguments to serve() function
- Resolve TLS params via CLI flag > settings.json > empty string default
- Build ssl_kwargs dict conditionally based on file existence check
- Pass ssl_certfile and ssl_keyfile to uvicorn.run() when TLS active
- Print warning 'TLS <path> not found, falling back to HTTP' when files missing
- Print 'https://' URL when TLS active, 'http://' when not
- Add --tls-cert and --tls-key flags to _add_serve_flags() and propagate to serve()
- Add 5 new TLS tests: ssl params passed, no ssl default, fallback warning,
https URL when active, http URL when inactive
- Update existing serve()-call-signature tests to include tls_cert=None, tls_key=None
Task: task-2-serve-ssl
Design for muxplex setup-tls command with auto-detection:
- Tailscale cert (real LE, universally trusted)
- mkcert (local CA, zero browser warnings)
- Self-signed fallback (works but browser warns)
Covers settings integration, CLI flags, service integration,
doctor diagnostics, and error handling edge cases.
Fixes test_readme_documents_all_settings_keys — federation_key was added
by the federation feature but not documented in README. Also commits the
CLI refactor plan files as historical ADRs.
The GET /api/settings endpoint redacts remote_instances key fields to ""
for security. When the frontend PATCHes any setting (e.g. device_name) it
sends the full remote_instances array back with those empty strings, which
previously overwrote the real keys on disk.
Fix: in patch_settings(), snapshot existing remote keys by URL before
applying the patch. After merging, restore any key that was emptied by
redaction — identified by url match + empty/missing key in patch. Only a
non-empty key in the patch is treated as an intentional key rotation.
Also adds 5 regression tests covering:
- Empty key in patch preserves existing key (core redaction bug)
- Non-empty key in patch overwrites old key (intentional rotation)
- Missing key field in patch preserves existing key
- Multi-remote list: all keys preserved when only one field changes
- New remote with a key is saved correctly
remoteId is an integer index (0, 1, 2...) returned by /api/federation/sessions.
In JavaScript, 0 is falsy, so all || '' patterns converted remoteId=0 to '' —
making the first remote device (ALIENWARE, index 0) appear as a local session.
The symptom: clicking an ALIENWARE session from spark-1 or spark-2 sent
POST /api/sessions/{name}/connect ← local endpoint, session doesn't exist → 404
instead of
POST /api/federation/0/connect/{name} ← correct federation proxy route
Only affected the FIRST remote (index 0). spark-1 ↔ spark-2 worked because
each sees the other as remoteId=1.
Locations fixed in frontend/app.js:
1. buildTileHTML: session.remoteId ? → session.remoteId != null ?
2. buildSidebarHTML: session.remoteId || '' → session.remoteId != null ? ... : ''
3. _previewClickHandler: session && session.remoteId || '' → null-safe ternary
4. openSession _viewingRemoteId: opts.remoteId || '' → null-safe ternary
5. openSession _remoteId: opts.remoteId || '' → null-safe ternary
6. openSession routing: if (_remoteId) → if (_remoteId !== '')
7. closeSession DELETE guard: if (!_viewingRemoteId) → if (_viewingRemoteId === '')
DOM dataset reads (tile.dataset.remoteId, item.dataset.remoteId) are unaffected
because dataset values are always strings — '0' is truthy, so || '' worked there.
Tests added: 5 new tests covering all four code paths (buildTileHTML,
buildSidebarHTML, openSession connect routing, closeSession DELETE skip)
with integer remoteId=0. Total: 291 → 296 tests, 0 failures.
serve() now kills any stale process holding the configured port before
uvicorn tries to bind. Prevents the crash-loop where systemd restarts
muxplex but the old process is still holding port 8088 (observed:
2075+ restarts before manual intervention).
New helper _kill_stale_port_holder(port):
- Runs lsof -ti :<port> to find occupying PIDs
- Sends SIGTERM to all foreign PIDs (skips own PID)
- Waits 1 second for the port to free
- Silently swallows all errors (missing lsof, permission denied)
so a broken environment never prevents startup
Also adds TimeoutStopSec=10 and KillMode=mixed to the systemd unit
template so the old process gets SIGKILL'd if it does not exit on
SIGTERM within 10 seconds — preventing the SIGTERM-ignored zombie
scenario entirely.
- fetch_remote now accepts index i from enumerate() and sets remote_id: int = i
instead of remote.get("id", url), which fell back to the URL string
- asyncio.gather uses enumerate(remote_instances) to pass the integer index
- federation_connect route: remote_id type changed from str to int; FastAPI
now validates path param at framework level, simplifying the handler body
- Update test_federation_sessions_includes_remote_failure_status to expect
integer 0 (not the legacy 'remote-1' id field string)
- Update test_federation_connect_returns_404_for_non_integer_remote_id: now
expects 422 (FastAPI schema validation) instead of 404
- Add test_federation_sessions_remote_id_is_integer_index verifying remoteId
is 0 for first remote and 1 for second
- Remove unused 'import websockets.exceptions' from main.py (no reference
to websockets.exceptions.* anywhere in the file; all WS handlers use
broad Exception catches)
- Add .gitignore with __pycache__/, *.pyc, .venv/, and other standard
Python excludes so bytecode no longer shows as modified in git status
- Untrack all pre-existing __pycache__/*.pyc files via git rm --cached
Remove unused JSONResponse import from starlette.responses in main.py.
All other stale references (CORSMiddleware, federation_tokens, X-Muxplex-Token,
window.opener, etc.) verified absent. Federation proxy endpoints confirmed present.
Revert index.html to state at 046d123 (task-7 completion), undoing the
out-of-scope settings dialog reorganization from 9f51d1b that:
- Removed the notifications tab button and panel
- Moved #setting-bell-sound into sessions panel
- Moved #setting-device-name into display panel
- Moved #notification-status-text and #notification-request-btn into sessions panel
- Removed #setting-view-scope from devices panel
This restores the 5-tab structure (display, sessions, notifications,
new-session, devices) and correct element placement that the Python
HTML tests assert.
Also remove 2 stale test_frontend_js.py tests that expected buildSources
and _sources to exist after task-1 removed them:
- test_save_remote_instances_rebuilds_sources
- test_build_sources_checks_multi_device_enabled
All 770 Python tests now pass (was 10 failures).
- Delete storeFederationToken function from app.js
- Delete window.addEventListener('message',...) handler from app.js
- Delete buildAuthTileHTML function from app.js
- Delete formatLastSeen function from app.js
- Delete buildOfflineTileHTML function from app.js
- Delete openLoginPopup function from app.js
- Simplify api() to same-origin only (no baseUrl/credentials/X-Muxplex-Token)
- Remove federation auth relay <script> block from index.html
- Remove /api/auth/token endpoint from main.py
- Remove CORSMiddleware from main.py
- Remove X-Muxplex-Token header check from auth.py
- Delete corresponding tests and add absence verification tests
api() returns a Response object; chain .then(res => res.json()) before
accessing data.key so the returned key is displayed in #federation-key-display
and settings-key-display--visible is applied.
Newer version has improved Sixel rendering and image lifecycle management.
UMD global (window.ImageAddon) is compatible — no JS code changes needed.
Should improve image replacement behavior (stacking issue with yazi).
Add 4 direct unit tests for buildStatusTileHTML to match coverage
pattern of sibling functions buildAuthTileHTML and buildOfflineTileHTML:
- is exported as a function
- returns article element with correct statusClass
- escapes XSS in deviceName
- renders statusText in badge span
Search: xterm-addon-search (0.13.0) with slide-down search bar. Ctrl+F
opens bar; type to search (live highlight); Enter/Shift+Enter for
next/prev; Escape to close. Bar sits above terminal-container inside
a new .terminal-wrapper flex column.
Image: xterm-addon-image (0.5.0) for inline graphics. Enables Sixel,
iTerm2 IIP, and Kitty graphic protocols — needed for tools like yazi
file manager. Auto-detects escape sequences, no configuration needed.
Negative Python list indices bypass the intended 404 guard — e.g.
remote_id=-1 on a non-empty list silently proxied to the last remote
instead of returning 404.
Add an explicit 'idx < 0' check before the list lookup, consistent
with the sibling federation_terminal_ws_proxy endpoint (line 728).
Also adds test_federation_connect_returns_404_for_negative_remote_id
to document the expected behavior and prevent regression.
Loads xterm-addon-web-links (0.9.0) from CDN. URLs in terminal output
are auto-detected and underlined on hover. Ctrl+Click (Linux/Windows)
or Cmd+Click (macOS) opens the URL in a new browser tab. Plain click
preserved for normal terminal text selection.
- Add _log.warning() to bare except clause in fetch_remote so unexpected
errors (JSON parse failures, AttributeError, etc.) leave a diagnostic
trail instead of silently surfacing as 'unreachable'
- Map HTTPStatusError (non-401/403 HTTP errors like 500, 503, 429) to
'unreachable' instead of 'auth_failed' — semantically correct since
these are connectivity/server errors, not authentication failures
- Fix stale task comment in test_api.py: task-8 → task-5
- Use getattr(app.state, 'federation_client', None) in shutdown to guard
against the theoretical case where AsyncClient constructor raised before
setting app.state.federation_client
- Rename 'Shutdown:' comment to 'Cleanup:' in the finally block to better
describe that the block covers both the poll-task cancellation and the
overall cleanup guarantee
Update 9 test_app.mjs tests that regressed when task-17 migrated
from sourceUrl to remoteId throughout app.js:
- buildTileHTML: assert data-remote-id instead of data-source-url
- buildTileHTML data-session-key: assert data-remote-id
- buildSidebarHTML: assert data-remote-id instead of data-source-url
- buildSidebarHTML empty: assert data-remote-id="" not data-source-url=""
- _previewClickHandler: assert remoteId forwarded not sourceUrl
- openSession with remoteId: assert federation proxy URL instead of remote URL
(no credentials: include - same-origin calls don't need it)
- openSession passes remoteId to _openTerminal: assert fed-abc123
- closeSession remote: use remoteId to set _viewingRemoteId state
- cycleViewMode state pollution fixed as side-effect of above fixes
(failing tests no longer leave document.getElementById mocked)
- connectWebSocket() now accepts remoteId instead of sourceUrl; when
remoteId is set the WebSocket URL is ws://host/federation/{remoteId}/terminal/ws
(same-origin, no cross-origin connections)
- openTerminal() signature updated to accept remoteId parameter
- openSession() in app.js routes remote connect POST to
/api/federation/{remoteId}/connect/{name} instead of the remote URL
- window._openTerminal() call passes remoteId instead of sourceUrl
- _viewingSourceUrl state replaced with _viewingRemoteId
- Tile and sidebar click handlers updated to pass remoteId
- HTML attributes changed from data-source-url to data-remote-id
- Test suite updated: removed cross-origin sourceUrl tests, added
federation proxy path tests
subprocess.run with check=True raised CalledProcessError then
KeyboardInterrupt on Ctrl+C, printing an ugly traceback. Fix: remove
check=True, catch KeyboardInterrupt, exit silently.
Bug 1: killSession() now checks if _viewingSession === name and calls
closeSession() before pollSessions(). Previously the user was stuck in
the expanded terminal view for a dead session after deleting it.
Bug 2: Sidebar item click handler now ignores clicks on .sidebar-delete
buttons. Tile click handler now ignores clicks on .tile-delete buttons.
Previously stopPropagation() in the document-level delete handler fired
too late (after the sidebar-item/tile handlers had already called
openSession), causing the session to be navigated to before being killed.
Tests added:
- killSession closes active session and returns to dashboard
- sidebar click handler ignores clicks on delete button
- tile click handler ignores clicks on tile-delete button
321 JS tests pass, 760 Python tests pass.
test_css_session_grid, test_css_tile_height, and test_css_bell_indicator
each declared a css=None parameter that was immediately overwritten with
read_css(). The parameter was dead API surface — never passed by any caller.
Removed the parameter from all three signatures.