- Add reset_secret() function that regenerates the signing secret
- Uses secrets.token_urlsafe(32) for secure key generation
- Creates parent directories if they don't exist
- Sets file permissions to 0o600 (owner read/write only)
- Prints warning that all active sessions are now invalid
- Register 'reset-secret' subparser in main() arg parser
- Add dispatch branch for 'reset-secret' command
Tests added:
- test_reset_secret_writes_new_secret: secret file exists with content > 20 chars
- test_reset_secret_sets_0600_permissions: file mode is 0o600
- test_reset_secret_prints_warning: output contains 'invalid' or 'warning'
Co-authored-by: Amplifier <amplifier@sourcegraph.com>
Adds 'show-password' subcommand to the muxplex CLI that:
- Prints 'Auth mode: PAM — no password file used' when MUXPLEX_AUTH
is not 'password' and PAM is available
- Prints 'Password: <pw>' when a password file exists
- Prints guidance to auto-generate when no password file found
Also imports load_password and pam_available at module level in cli.py
to enable testable patching via muxplex.cli namespace.
Tests added:
- test_show_password_prints_password_from_file
- test_show_password_no_file
- test_show_password_pam_mode
Replace login_page() stub with handler that:
- Reads login.html from _FRONTEND_DIR
- Determines username (PAM mode: current user, else empty string)
- Builds mode_data with json.dumps({mode, user})
- Injects <script>window.MUXPLEX_AUTH = {...};</script> before </head>
- Returns HTMLResponse(html)
Move _FRONTEND_DIR definition above routes so login_page() can reference it.
Keep app.mount() at bottom and /auth/mode endpoint.
Add test: test_get_login_injects_muxplex_auth
- Asserts status 200, MUXPLEX_AUTH in text, '"mode"' in text
All 45 tests pass.
The client fixture pre-injects a valid muxplex_session cookie to make
most tests pass auth middleware. Logout tests intentionally create their
own TestClient (without the cookie) to prove the endpoint works
correctly for unauthenticated and expired-session requests.
Co-authored-by: Amplifier <amplifier@sourcegraph.com>
- Add @app.get('/auth/logout') handler in muxplex/main.py after POST /login
- Creates RedirectResponse('/login', status_code=303)
- Calls response.delete_cookie('muxplex_session') to clear the auth cookie
- Add two tests in test_api.py:
- test_logout_redirects_to_login: verifies 303 status and /login in location header
- test_logout_clears_session_cookie: verifies Set-Cookie has muxplex_session with max-age=0
- Route is already exempt from AuthMiddleware via _AUTH_EXEMPT_PATHS in auth.py
Co-authored-by: Amplifier <amplifier@sourcegraph.com>
- Import authenticate_pam and create_session_cookie from muxplex.auth
- Import Form, Request from fastapi; RedirectResponse from starlette.responses
- Add @app.post('/login') handler that reads username and password form fields
- In PAM mode: delegates to authenticate_pam(username, password)
- In password mode: compares password to _auth_password
- On success: creates signed muxplex_session cookie (httponly, samesite=strict),
redirects to / with 303
- On failure: redirects to /login?error=1 with 303
- Add 4 tests covering all branches (password success/failure, PAM success/failure)
Closes task-2-post-login-handler
The server_host check (request.url.hostname) read from the user-controlled
HTTP Host header, allowing any attacker to bypass authentication by sending
'Host: 127.0.0.1'. Remove it entirely — request.client.host is the
socket-level IP and cannot be forged.
Fix the localhost test by introducing _InjectClientMiddleware, a thin ASGI
wrapper that writes a real IP into the scope's 'client' field. This lets the
test exercise the actual localhost check with 127.0.0.1 rather than relying
on the URL hostname.
Also:
- Promote _LOCALHOST_ADDRS to module-level constant (no-op per request)
- Add clarifying comment on auth_header[6:] magic slice
- Replace per-request cookies= kwarg with client.cookies.set() (Starlette deprecation)
- auth.py: replace 'import os as _os' with plain 'import os' inside
authenticate_pam() — the alias convention is for module-level private
names, not function-scoped imports
- test_auth.py: move 'import pwd' from two test functions to module-level
imports, consistent with existing 'import os' placement
- test_auth.py: make wrong-user test robust against running as root —
derives 'wrong_user' dynamically so the test holds in Docker CI
environments where the runner may be root
Replace bare path.parent.mkdir() (no mode, defaults to umask ~0755) with
_config_dir() which always creates ~/.config/muxplex with mode=0o700.
This matches the pattern already used by generate_and_save_password() and
ensures the config directory is never left traversable by other users.
Also update the module docstring to reflect that the module now manages
both passwords and signing secrets.
- Wire _config_dir() into generate_and_save_password() so the helper is no longer dead code; the 0700 directory mode is now enforced correctly
- Trim module docstring to reflect what the file actually contains (password file management only)
Removing terminal WheelEvent dispatch and sidebar scrollTop handlers.
CSS fixes retained (touch-action:pan-y, overscroll-behavior:contain,
flex-shrink:0). Testing mouse-only baseline across all devices before
deciding on the right mobile scroll approach.
Old: variable deltaY*3 per touchmove — Android batches events causing
burst-scroll then pause (jumpy). New: accumulate pixels across events,
fire fixed deltaY=120 per SCROLL_PX pixels. Each scroll step is
identical regardless of when Android delivers touchmove events.
SCROLL_PX=20 → ~40px swipe = 2 scroll clicks. Accumulator resets on
touchstart and touchend. macOS/iPad/mouse-wheel path unchanged.
Without flex-shrink:0, flex compresses all sidebar cards proportionally
when the container is shorter than their total height. Cards visually
fit the screen with no overflow — scrollTop stays 0, touch handler
has nothing to scroll. One property restores the intended overflow.
position:fixed + overflow:hidden on .session-sidebar in mobile overlay
mode prevents Android Chrome from routing native touch-scroll to the
child .sidebar-list (overflow-y:auto). Direct scrollTop manipulation
always works. Mirrors the terminal WheelEvent approach: take JS control
rather than relying on native CSS touch-scroll routing.
scrollLines() only moves xterm.js's local scrollback buffer (outside).
WheelEvent dispatch goes through xterm.js's mouse reporting path —
if tmux mouse mode is on, escape sequences reach the PTY so tmux and
applications (vim, less, etc.) handle the scroll (inside).
Same behavior as mouse wheel on desktop.
Terminal: add touchstart/touchmove/touchend IIFE on #terminal-container.
Vertical swipe delta translated to _term.scrollLines(n). touchmove is
non-passive (required for e.preventDefault to block page scroll while
swiping inside terminal). mouse wheel scroll unaffected (separate wheel
event path in xterm.js).
Sidebar: add touch-action:pan-y + overscroll-behavior:contain to
.sidebar-list. Tells browser explicitly that vertical panning is allowed
and prevents scroll chaining when reaching list ends.
- Mobile idle tiles: display:none → height:36px so session content is
visible (all sessions are 'idle' with zero bells — was blank on iPhone)
- .tile-pre dead CSS: merge font/color rules into .tile-body pre (the
actual HTML selector) so snapshot text renders with monospace + muted
color; update mobile active-tier selector to match
- systemd PATH: strip /mnt/ entries from PATH before writing service
unit file — WSL paths with spaces caused systemd to reject the line
terminal.js sends the ttyd auth token as a TEXT WebSocket frame. The proxy's
client_to_ttyd() called receive_bytes() which fails silently on text frames,
swallows the exception, and exits — auth never reached ttyd, ttyd never
started streaming, resulting in permanent black screen and reconnect loop.
Fix: use receive() and dispatch on message type, handling both bytes and text.
Required fixes:
- main.py: update module docstring, startup comment, and FastAPI title from
'tmux-web coordinator' / 'coordinator service' → 'muxplex' (4 locations)
- test_api.py: replace hasattr/BaseRoute pattern with isinstance(r, (APIRoute,
APIWebSocketRoute)) to satisfy pyright (union-attr error eliminated)
Recommendations applied:
- test_api.py + test_cli.py: auto-formatted with ruff (blank-line normalisation)
- test_cli.py: replace exec(Path('muxplex/__main__.py').read_text()) with
importlib.util.find_spec() to obtain the absolute path — no longer assumes
pytest is invoked from a specific working directory
- README.md: fix architecture table — WebSocket proxy moved from ttyd.py
description to main.py; ttyd.py now correctly described as lifecycle only
All 178 tests pass; python_check clean (0 errors, 0 warnings).
Addresses code quality suggestion from review: requirements.txt was a
redundant secondary source of truth. Dependencies are now exclusively
managed in pyproject.toml with proper runtime/dev separation.
- requirements.txt: replaced with a comment redirecting to pip install -e '[dev]'
- README.md: updated install command from pip install -r requirements.txt
to pip install -e '[dev]'
Before: openTerminal() never closed the previous WebSocket. After A→B→A,
two WS connections to session A both wrote to _term → double keystroke echoes.
Rapid switches triggered a CONNECTING-state send error and reconnect loop.
After: openTerminal() nulls _currentSession, clears reconnect timer, and
closes _ws before opening a new connection. connect() event handlers now
capture the WS instance in a local const and check 'ws !== _ws' on entry
so stale connections silently ignore all events.
Tests: three new regression tests verify:
- openTerminal() calls close() on the previous WS (Bug 1)
- stale open handler does not send on new WS after switch (Bug 2)
- stale close handler does not schedule reconnect after switch (Bug 2)
Three-part consistency defect identified in code review:
1. HTML: Four sidebar elements missing CSS class attributes so all CSS
rules targeting .session-sidebar, .sidebar-toggle-btn,
.sidebar-collapse-btn, and .sidebar-list applied to zero elements.
Added class= attributes to all four.
2. JS: initSidebar(), toggleSidebar(), and bindSidebarClickAway() called
$('sidebar') and $('collapse-btn') — neither ID exists in the DOM.
Corrected to $('session-sidebar') and $('sidebar-collapse-btn').
3. Tests: initSidebar/toggleSidebar mocks matched the wrong IDs, masking
the defect. Updated all six mock conditions to match corrected IDs.
4. Coverage gap: test_html_element_classes did not verify new sidebar
elements carry CSS classes. Added four entries for session-sidebar,
sidebar-toggle-btn, sidebar-collapse-btn, and sidebar-list — these
entries would have caught Defect 1 on Task 1's test run.
All 136 JS + 167 Python tests pass. Zero regressions.