Server: add -e flag to tmux capture-pane to preserve escape sequences.
Frontend: ansiToHtml() parser converts SGR codes (bold, italic, dim,
16-color, 256-color) to <span> tags with inline styles. Applied to
dashboard tiles, sidebar items, and hover overlay. No external deps.
Replace side-positioned popover with a viewport-covering overlay (5vh/5vw
margins). pre-wrap + word-break wraps terminal text to fit any viewport.
pointer-events:none on overlay lets mouseleave fire on the original tile.
Click anywhere while preview is active navigates to that session.
Removes repositionPreview() — no JS positioning needed.
Previous approach guarded renderGrid/renderSidebar from running while
preview active, but catch-up renders, stale DOM references, and timer
races caused flicker, wrong positioning, and stale content.
New approach: track _previewSessionName (string, not DOM node). Renders
run normally. After each innerHTML rebuild, liftHoveredTile() re-applies
z-index to the new DOM element. repositionPreview() re-queries DOM each
call. Timer always cleared when entering a new tile. No render guards.
Poll cycle rebuilds innerHTML every ~2s, destroying the hovered tile's
DOM element and tile--previewing class. Fix: renderGrid() and
renderSidebar() early-return when _previewPopover is non-null. On
hidePreview(), a catch-up render fires so no data is stale.
1.5s hover delay (up from 350ms) for deliberate preview. When popover
appears, a semi-transparent dimmer covers the page (z-index 499). The
hovered tile/sidebar-item gets z-index 500 (same as popover) so it
appears above the dimmer alongside the preview. Both cleaned up on
mouseleave via hidePreview().
- Auto-scroll to bottom (prompt area is the valuable part)
- Dynamic width based on available space (fixes 3-column layout)
- 350ms delay (prevents brush-over flicker)
- Extend hover preview to sidebar items (popover to the right)
- Remove fixed min/max-width from CSS, calculate in JS
Desktop only (guarded by ontouchstart check). On mouseenter of a
session tile, shows a floating popover with the full tmux snapshot
(all lines, not the cropped 20-line preview). Positioned right of
tile if space, else left. 100ms delay prevents flicker on brush-over.
Data from _currentSessions (already in memory, zero API calls).
Also calls hidePreview() in openSession() so the popover is cleaned
up when switching to fullscreen view.
_openTerminal(name) was inside a 260ms setTimeout that fired before the
/connect POST completed. The WebSocket connected to the OLD ttyd (still
serving the previous session). Fix: animation runs concurrently with
/connect as before, but _openTerminal only fires after BOTH resolve.
Sequence: start animation + POST concurrently → await POST → await
animation → mount terminal. The new ttyd is guaranteed to be serving
the correct session before the WebSocket connects.
Added timerId null-check so animDone resolves immediately in test envs
where setTimeout is stubbed, preventing test hangs.
BaseHTTPMiddleware only handles HTTP scope — WebSocket connections
bypassed auth entirely. Inline check before websocket.accept() reads
the muxplex_session cookie and closes with code 4001 if invalid.
Localhost (127.0.0.1, ::1) still bypasses as intended.
Add `get_password_path` to top-level imports in main.py and fix the file
password resolution branch to log the actual file path instead of
`load_password.__module__` (which printed "muxplex.auth").
Also adds 4 tests covering all _resolve_auth logging paths:
- test_resolve_auth_pam_mode_logs_pam
- test_resolve_auth_env_password_logs_env
- test_resolve_auth_file_password_logs_file
- test_resolve_auth_generates_password_as_last_resort
- 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).