Commit Graph

478 Commits

Author SHA1 Message Date
Brian Krabach 43cc75efc8 test: add dispatch test for generate-federation-key subcommand 2026-04-01 14:03:07 -07:00
Brian Krabach 939a20e8b5 fix: migrate test_app.mjs sourceUrl tests to remoteId API
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)
2026-04-01 13:35:26 -07:00
Brian Krabach 38300fcf79 feat: route remote terminal connections through federation proxy
- 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
2026-04-01 13:18:10 -07:00
Brian Krabach 7e28438953 fix: update pollSessions JSDoc and remove zombie backoff test 2026-04-01 13:08:31 -07:00
Brian Krabach dc7c077b6a test: remove stale multi-source pollSessions tests superseded by task-16 2026-04-01 13:01:09 -07:00
Brian Krabach 2de19bd988 fix: clean exit on Ctrl+C in muxplex service logs
subprocess.run with check=True raised CalledProcessError then
KeyboardInterrupt on Ctrl+C, printing an ugly traceback. Fix: remove
check=True, catch KeyboardInterrupt, exit silently.
2026-04-01 13:00:00 -07:00
Brian Krabach 442cfcc051 feat: simplify pollSessions to use federation proxy endpoint 2026-04-01 12:54:08 -07:00
Brian Krabach bdb3f069e5 fix: delete active session returns to dashboard, sidebar/tile delete doesn't navigate
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.
2026-04-01 12:48:38 -07:00
Brian Krabach 0e3ed96c16 refactor: drop unused css=None parameters in 3 CSS test functions
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.
2026-04-01 12:48:00 -07:00
Brian Krabach 56694fde61 style: add debug logging to ws relay exceptions; format test_frontend_css 2026-04-01 12:44:31 -07:00
Brian Krabach 2dda544893 chore: remove untracked planning doc swept up in phase 1 commit 2026-04-01 12:37:53 -07:00
Brian Krabach d0fe9cda67 chore: Phase 1 complete — all backend proxy tests pass 2026-04-01 12:33:17 -07:00
Brian Krabach e726ef9926 feat: redact federation/remote keys from GET /api/settings 2026-04-01 12:19:24 -07:00
Brian Krabach 7e8892c926 feat: add POST /api/federation/generate-key endpoint 2026-04-01 12:12:37 -07:00
Brian Krabach a2763e26f6 fix: add HTTP error handling to federation_connect endpoint
Adds try/except around the outbound http_client.post() call to match the
error-handling pattern established by the federation_sessions sibling endpoint:
- httpx.HTTPStatusError → 502 with upstream status code in detail
- Any other exception (ConnectError, etc.) → 503 with remote URL in detail

Also adds raise_for_status() to surface non-2xx responses explicitly.

Tests added:
- test_federation_connect_returns_503_when_remote_unreachable
- test_federation_connect_returns_502_when_remote_returns_error_status
2026-04-01 12:07:57 -07:00
Brian Krabach e420b84b9f feat: add POST /api/federation/{remote_id}/connect proxy 2026-04-01 11:54:40 -07:00
Brian Krabach 536b487f52 refactor: extract _ws_auth_check helper and fix URL scheme conversion
- Extract shared auth logic into _ws_auth_check(websocket) async helper,
  used by both terminal_ws_proxy and federation_terminal_ws_proxy. Eliminates
  verbatim duplication of the cookie + bearer auth block.
- Fix URL scheme conversion in federation_terminal_ws_proxy: add elif branch
  for http:// so non-http/https URLs fall through to a safe passthrough
  (ws_url = remote_url + "/terminal/ws") instead of silently producing a
  malformed URL like ws://s://host/terminal/ws.

No behavior change for the supported http/https URL inputs.
All 15 tests pass.
2026-04-01 11:49:08 -07:00
Brian Krabach 086ef19aa4 feat: add WS /federation/{remote_id}/terminal/ws proxy endpoint 2026-04-01 11:42:19 -07:00
Brian Krabach 2dad066a5e style: move import hmac to module-level per PEP 8 2026-04-01 11:37:55 -07:00
Brian Krabach d07800c8c5 feat: add Bearer token auth to WebSocket proxy for federation 2026-04-01 11:32:17 -07:00
Brian Krabach 4e23935b94 chore: add pyrightconfig.json to resolve Pyright venv path 2026-04-01 11:27:36 -07:00
Brian Krabach 5f0fcf3e71 test: add True-path coverage and clean up TestClient binding for instance-info
- Add test_instance_info_federation_enabled_true_when_key_exists: writes a
  key file to tmp_path, patches FEDERATION_KEY_PATH, and asserts that
  federation_enabled is True — completing the positive/negative pair for
  task-9's coverage gap (suggested by code quality review).
- Replace 'with TestClient(app) as _:' with 'with TestClient(app):' — the
  discard binding was unnecessary (ruff-clean but stylistically noisy).
2026-04-01 11:23:04 -07:00
Brian Krabach 98d552557a fix: delete command auto-confirms prompts + add request-level logging
Bug 1: amplifier-dev --destroy prompts 'Are you sure? [y/N]'. subprocess.run
had no stdin input, so the prompt hung until timeout. Fix: input='y\n' auto-
confirms interactive prompts in delete_session().

Bug 2: No request-level logging — only startup messages visible in journalctl.
Added INFO-level log lines for session create, delete (command + success/fail),
and connect operations. Set uvicorn log_level='info' so application logs
appear in service logs (journalctl / launchd).

Tests: 5 new tests covering stdin confirmation kwarg, INFO-level logs for
delete/create/connect, and uvicorn log level assertion.
2026-04-01 11:18:57 -07:00
Brian Krabach b4f29dacd0 fix: clean up pre-existing lint in test_api.py and document per-request key read 2026-04-01 11:17:42 -07:00
Brian Krabach e1b0b55bc8 feat: add federation_enabled to instance-info endpoint 2026-04-01 11:10:07 -07:00
Brian Krabach 8609cf7880 feat: add GET /api/federation/sessions proxy endpoint 2026-04-01 10:58:42 -07:00
Brian Krabach 66f58a6f4b docs: comprehensive README rewrite + plans archive note
README now documents all features, 14 config keys, full CLI reference,
keyboard shortcuts, platform support, and project structure. Previous
README covered ~40% of actual functionality. Added docs/plans/README.md
noting these are historical ADRs from the initial build.

Added 9 new README tests verifying all settings keys are documented,
clipboard features, keyboard shortcuts, auth modes, platform support,
ANSI previews, hover preview, and view modes.
2026-04-01 10:57:17 -07:00
Brian Krabach 025f81c3f1 fix: log exception from federation_client aclose for observability 2026-04-01 10:55:33 -07:00
Brian Krabach 8d8d0826a7 refactor: harden lifespan shutdown with try/finally and assert client closed in test
main.py: Wrapped `app.state.federation_client.aclose()` in a `try/finally` block so the poll task cleanup always runs even if `aclose()` raises unexpectedly.

test_api.py: Extended `test_federation_client_exists_on_app_state` to capture the client reference and assert `client_ref.is_closed` after the `TestClient` context exits, verifying the lifespan shutdown closes the client.

🤖 Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-04-01 10:52:22 -07:00
Brian Krabach 9c423d07db fix: promote httpx to production dependencies
httpx is imported unconditionally in muxplex/main.py (module-level import),
so it must be a production dependency — not dev-only. Move httpx>=0.27.0
from [project.optional-dependencies].dev to [project.dependencies] to
prevent ModuleNotFoundError on startup in non-dev deployments.
2026-04-01 10:48:02 -07:00
Brian Krabach 2001b3cba3 fix: reconcile 9 stale HTML/CSS tests with actual UI state
Tests were asserting elements that don't exist in the current HTML
(notifications panel, device-name input, view-scope select). Updated
tests to match the actual settings dialog structure.

- Notification settings (bell-sound, status-text, request-btn) live in
  the sessions panel, not a dedicated notifications panel
- Settings dialog has 4 tabs (display, sessions, new-session, devices),
  not 5 — no notifications tab
- #setting-device-name is in the display panel, not the devices panel
- #setting-view-scope was never implemented; removed stale test
- .tile-bell-count was removed from CSS; dropped that single assertion
2026-04-01 10:44:24 -07:00
Brian Krabach c03c85c76a feat: create httpx.AsyncClient in lifespan for federation 2026-04-01 10:43:31 -07:00
Brian Krabach f09068c1f1 test: verify remote_instances key field round-trips 2026-04-01 10:37:43 -07:00
Brian Krabach 21b669b547 feat: wire federation key into AuthMiddleware at startup 2026-04-01 10:33:32 -07:00
Brian Krabach fca55dc49d feat: add Bearer token auth for federation in AuthMiddleware 2026-04-01 10:22:06 -07:00
Brian Krabach 1f90613030 feat: add 'muxplex generate-federation-key' CLI command 2026-04-01 10:15:38 -07:00
Brian Krabach 8e779b49b9 fix: strengthen load_federation_key() docstring, tests, and env var coverage
- Add docstring to load_federation_key() for module consistency
- Strengthen test_load_federation_key_uses_default_path to call the
  function and assert it returns "" when file absent
- Add test_load_federation_key_uses_env_var_override to cover the
  MUXPLEX_FEDERATION_KEY_FILE env var override path

All 31 tests pass.
2026-04-01 10:12:09 -07:00
Brian Krabach 93a66c0cb9 feat: auto-copy mouse selection + OSC 52 tmux clipboard bridge
Mouse selection: onSelectionChange auto-copies to system clipboard on
every selection change (last fire on mouse-up captures final text).
Matches iTerm2/WezTerm/ttyd native select-to-copy behavior.

OSC 52: Custom parser.registerOscHandler(52, ...) decodes base64
clipboard data from tmux. When tmux copies text (set-clipboard on
in .tmux.conf), it sends OSC 52 to the terminal. We intercept and
write to system clipboard via _copyToClipboard (with execCommand
fallback for HTTP/LAN contexts).

Together: mouse-select auto-copies, and tmux Ctrl+B [ -> select ->
Enter copies to system clipboard via OSC 52.
2026-04-01 10:09:20 -07:00
Brian Krabach afef15b955 feat: add load_federation_key() with env var override 2026-04-01 10:07:55 -07:00
Brian Krabach b884f7affd feat: add federation_key to DEFAULT_SETTINGS 2026-04-01 10:03:11 -07:00
Brian Krabach ff989ae920 feat: native clipboard support — Ctrl+Shift+C to copy, Ctrl+Shift+V to paste
Uses xterm.js attachCustomKeyEventHandler to intercept Ctrl+Shift+C/V.
Copy: getSelection() → navigator.clipboard.writeText (with execCommand
fallback for non-HTTPS contexts). Paste: navigator.clipboard.readText()
→ send to ttyd via WebSocket binary protocol (0x30 prefix).

Ctrl+C/V continue to work as normal terminal keys (SIGINT / literal paste).
The Shift modifier distinguishes clipboard from terminal operations.

Also hoists encodePayload + TextEncoder to module level (_encodePayload,
_encoder) so the clipboard handler can reuse them without duplication.
2026-04-01 09:51:23 -07:00
Brian Krabach 1dcf1fc2f3 fix: trim trailing blank lines BEFORE slice, not after
Sessions with cursor near the top (e.g. a fresh tunnel/ssh session)
have content at rows 1-2 and rows 3-40 blank in a 40-row terminal.
The previous fix trimmed AFTER slice(-20), but slice(-20) of a 40-line
snapshot grabs the last 20 rows — all blank. Trimming after slice then
removed everything, leaving an empty <pre>.

Fix: trim trailing blank lines from the full snapshot first, then
slice the last N lines of what remains. This way a session with 2
content lines at the top is reduced to those 2 lines before slicing,
and both appear in the preview.

Fixes both buildTileHTML (grid tiles) and buildSidebarHTML (sidebar
cards). Tests updated to cover the 40-row terminal scenario.
2026-04-01 08:59:54 -07:00
Brian Krabach 96da24c30b fix: trim trailing blank lines from tile/sidebar snapshot previews
Sessions with cursor near the top (e.g. just a prompt) have 18+ empty
lines below. slice(-20) grabbed all blanks, making previews appear
empty. Fix: pop trailing blank lines after slicing so the pre element
contains only meaningful content. CSS bottom:0 anchors it correctly.

Tests: 4 new tests cover content-at-top trimming and all-blank edge case.
2026-04-01 08:38:16 -07:00
Brian Krabach be79b45787 feat: edge-bar state indicator, stacked sidebar header, × crossfade on hover 2026-04-01 08:33:56 -07:00
Brian Krabach cb264bbaaa feat: postMessage auth token relay for cross-origin federation login
- Backend: AuthMiddleware.dispatch() checks X-Muxplex-Token header
  after cookie auth, allowing cross-origin federation requests to
  authenticate using a session token sent as a header instead of a
  SameSite=Strict cookie.

- Backend: GET /api/auth/token endpoint returns the current session
  token for authenticated requests. Used by login popups to relay
  the token back to the opener window via postMessage.

- Frontend (app.js): api() injects X-Muxplex-Token header for
  cross-origin requests when a token is stored in localStorage under
  muxplex.federation_tokens keyed by origin.

- Frontend (app.js): storeFederationToken() helper stores tokens.
  window.addEventListener('message') handler catches muxplex-auth-token
  postMessages and stores the token, then retriggers a poll so the
  auth_required source transitions to authenticated immediately.

- Frontend (app.js): _saveRemoteInstances() prunes stale tokens for
  URLs no longer in the remote instances list.

- Frontend (index.html): Popup relay script fetches /api/auth/token
  and sends the token to window.opener via postMessage when loaded
  as a login popup (window.opener present).

Tests added:
  test_auth.py: X-Muxplex-Token header authenticates / invalid rejected
  test_api.py: GET /api/auth/token returns token / 401 when unauthed
  test_app.mjs: api() header injection, storeFederationToken storage,
                index.html popup script presence
2026-04-01 08:17:28 -07:00
Brian Krabach 062a815f83 feat: custom scrollbar styling — thin, dark, brand cyan hover
6px wide, rgba(48,54,61) thumb (--border at 60%), cyan hover/active.
Applied globally via * selector — covers sidebar, grid, settings, and
any future scrollable area. Firefox (scrollbar-width/color) + WebKit
(::-webkit-scrollbar) cross-browser.
2026-04-01 07:20:07 -07:00
Brian Krabach 38e2fc45d5 fix: WS proxy checks ttyd liveness before accepting + reset counter on message
Root cause (4th iteration): The WS proxy called websocket.accept() before
verifying ttyd was alive. The browser 'open' event fired immediately, resetting
_reconnectAttempts to 0. Counter bounced 0→1→0→1 forever — the client-side
/connect POST at >= 2 attempts never fired.

Server fix: _ttyd_is_listening() TCP probe (socket.create_connection, <1ms)
before websocket.accept(). If ttyd is dead, auto-spawns it from active_session
in state (kill_ttyd → spawn_ttyd → sleep 0.8s) THEN accepts. Browser 'open'
only fires when ttyd is confirmed reachable.

Client fix: _reconnectAttempts reset moved from 'open' handler to 'message'
handler. First data message proves ttyd is alive and relaying — not just that
the proxy accepted. Belt-and-suspenders: client-side /connect fetch still fires
at attempt >= 2 as a fallback.

Tests added:
- test_ttyd_is_listening_function_exists: function importable from main
- test_ws_proxy_checks_ttyd_before_accepting: source inspection ensures
  _ttyd_is_listening() call appears before await websocket.accept()
- test_ws_proxy_auto_spawns_ttyd_when_dead: verifies spawn_ttyd called with
  active_session when _ttyd_is_listening returns False
- terminal.mjs: _reconnectAttempts = 0 NOT in open handler, IS in message
2026-04-01 06:56:51 -07:00
Brian Krabach a862327e27 fix: settings item ordering, device name width, activity indicator mode, dot position 2026-04-01 06:52:01 -07:00
Brian Krabach 50ecb1025d fix: WebSocket reconnect awaits /connect POST before creating WS
The /connect POST that respawns ttyd was fire-and-forget — fetch fired
and the next line immediately created a new WebSocket. The WS raced
ttyd's startup and lost every time, staying stuck on 'Reconnecting...'.

Fix: extract WS creation into _connectWebSocket(). When
_reconnectAttempts >= 2, chain via .then() after the /connect fetch
+ 800ms settle delay for ttyd to bind its port, then return early to
prevent fallthrough. When < 2, call _connectWebSocket() directly
(normal reconnect — ttyd is still alive).
2026-04-01 06:38:27 -07:00
Brian Krabach e71ac223a9 feat: reorganize settings tabs, right-align device labels, activity dots, sidebar glow, display config toggles 2026-04-01 06:25:18 -07:00