Commit Graph

87 Commits

Author SHA1 Message Date
Ken e032d540b0 feat: ttyd process pool + terminal instance cache for instant session switching
Two independent optimizations that eliminate the ~700ms-1.2s lag when switching sessions:

### Backend: ttyd process pool (ttyd.py + main.py)
Instead of killing and respawning a single ttyd process on every session switch, maintain a pool of ttyd processes - one per connected session, each on its own port (7682-7701). Revisiting a session reuses the existing process instantly.

- New pool API: get_or_spawn(), pool_port(), kill_session(), kill_all(), kill_orphans()
- Backward-compat aliases preserved: kill_ttyd(), spawn_ttyd(), kill_orphan_ttyd(), TTYD_PORT
- /connect endpoint now returns in ~0ms for already-connected sessions vs 700ms+ before
- WS proxy reads ?session= query param to route to correct ttyd port
- Per-port PID files for orphan recovery across restarts

### Frontend: terminal instance cache (terminal.js + app.js)
Keep up to 5 xterm.js Terminal instances alive in hidden container divs. Switching to an already-visited session is a CSS display swap + fit() call - no xterm.js teardown/recreation, no WebSocket reconnection, no addon reloading.

- New switchTerminal() entry point with LRU cache (max 5)
- Each cached entry has its own container div, term, WebSocket, addons
- Cache-aware /connect skip: if terminal is cached with live WS, bypass backend entirely
- Animation gate fixed: 0ms for session-to-session switches (was always 260ms)
- Cache cleanup on session delete

### Test changes (minimal)
- test_ttyd.py: Added pool cleanup in fixture for isolation
- test_api.py: Updated 4 tests to mock new pool API
- test_ws_proxy.py: Updated auto-spawn test for pool API

All 1,306 tests pass.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-28 00:48:21 +00:00
Ken b1f4569608 feat: implement all P0 UX improvements - visual hierarchy, readable previews, search filter, default session
CI / test (3.13) (push) Failing after 11m36s
CI / test (3.12) (push) Failing after 11m38s
CI / test (3.11) (push) Failing after 11m40s
P0.1: Visual Hierarchy for Tiles
- Backend: Added last_activity_at to GET /api/sessions by querying tmux list-sessions -F '#{session_activity}'
  Updated sessions.py with tuple return from enumerate_sessions and activity cache
  Updated main.py poll loop to populate both session endpoints with activity data
- Frontend: sessionPriority() now returns 'active' (< 5 min) in addition to 'bell'/'idle'
  Added .session-tile--active (accent left border) and .session-tile--stale (dimmed styling) CSS classes
  Updated session-tile.js and sidebar-item.js to apply priority-based classes
- Fixed P4 hover fade bug: removed opacity:0 rule on .session-tile:hover .tile-meta

P0.2: Readable Tile Previews
- Wired --preview-font-size CSS variable (was set by JS but never consumed by CSS)
  Changed default from 11px to 12px with line-height 1.2 (was 1.0)
- Reduced preview line count from 20 to 12 lines for better readability at larger size

P0.3: Search/Filter Bar
- New <search-filter> Lit component with live input, match count, and clear button
  Replaces empty filter-bar div in index.html with functional search component
  Integrated into renderGrid() for live filtering and bindStaticEventListeners() for re-render on input

P0.4: Default Session / Quick-Resume
- Updated restoreState() to fall back to default_session setting when no active_session in server state
  Added existence check before opening to prevent errors on non-existent sessions

Files changed: sessions.py, main.py, app.js, style.css, session-tile.js, sidebar-item.js, utils.js, index.html
New files: search-filter.js (Lit component)
Test updates: test_api.py, test_frontend_css.py, test_integration.py, test_sessions.py

All 1306 tests pass.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-27 07:30:13 +00:00
Ken 2ec6f73843 feat: mobile UX polish, shell injection fix, and performance optimizations
This commit combines multiple improvements for mobile support, reliability, and performance:

Mobile & Accessibility Improvements:
- Fix touch scrolling on mobile by removing CSS overflow-y:hidden that blocked xterm.js native scroll
- Add beforeinput handler to prevent Android IME double-space-to-period duplication
- Implement mobile control character toolbar (Esc, Tab, Ctrl/Alt toggles, arrows, special chars)
- One-shot modifier toggles bring soft keyboard on demand without showing it for other keys
- Replace 25+ unicode/HTML entity icons with inline SVGs across app for better rendering

Security & Reliability:
- Fix shell injection vulnerability: session names with spaces/special chars now properly quoted
  - Use shlex.quote() in create_session and delete_session endpoints
  - URL-encode session names in bell hook to prevent malformed curl URLs
  - Also hardens against command injection through crafted session names

Performance Optimizations:
- Eliminate 2.4-5.6 second session creation delay (was 3 compounding bottlenecks)
  - Frontend: check for new session immediately (was waiting 2s for interval tick)
  - Backend: eagerly refresh session cache after tmux creation (was waiting for next poll)
  - Connection: poll for ttyd readiness instead of blind 0.8s sleep
- Typical new session now appears in UI in 200-400ms vs previous 4-5 seconds

Infrastructure:
- Improve ttyd port detection with multi-tool fallback (lsof → fuser → ss)
- Add Cloudflare tunnel setup documentation

Test Updates:
- Update frontend tests to match new setTimeout recursion pattern (replaces setInterval)

All 1306 tests pass.

Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-27 01:01:55 +00:00
Brian Krabach c5e146bcf7 feat(ui): version-busting query suffix on all served asset URLs
Browser-tester confirmed on spark-1 that the 7 <script src> tags and all
<link href> tags in the served HTML had no cache-busting suffix.  Because
there is no service worker, the standard HTTP cache would keep serving stale
JS/CSS to browsers that had already loaded a previous release — the root
cause of yesterday's 'is the user seeing stale JS?' investigation.

Fix: index_page() now appends ?v=<muxplex_version> to every static-asset
URL (src= and href= attributes whose path starts with /  and does not begin
with /api/) before returning the HTML response.  The version is read once at
module load from importlib.metadata.version('muxplex') — the same source
used by the doctor command — so it is always in sync with the installed
package.  Starlette's StaticFiles handler ignores query parameters when
serving files from disk, so the versioned URLs continue to resolve to the
same bytes; they just carry a new cache key on every release.

Affected assets (7 <script> + 6 <link> + 1 <img>):
  /vendor/xterm.js, /vendor/xterm-addon-fit.js,
  /vendor/xterm-addon-web-links.js, /vendor/xterm-addon-search.js,
  /vendor/addon-image.js, /app.js, /terminal.js
  /manifest.json, /favicon.ico, /favicon-32.png, /apple-touch-icon.png,
  /vendor/xterm.css, /style.css
  /wordmark-on-dark.svg

New tests in muxplex/tests/test_main.py:
  - test_index_all_asset_urls_have_version_suffix — every <script src> and
    <link href> in GET / carries ?v=<version>
  - test_index_vendor_scripts_each_versioned — all 7 expected script URLs
    are present with the version suffix
  - test_versioned_asset_url_resolves_to_static_file — static handler
    serves the file correctly when the query string is present
2026-05-17 17:22:25 -07:00
Brian Krabach c3a719ab5d chore(release): wire normalize_session_keys + repair stale JS tests
Wire normalize_session_keys import and call into _run_poll_cycle step 13b, running
before the prune step. Use local device id from muxplex.identity.load_device_id().
Best-effort: errors are logged, poll cycle continues.

Fix 10 previously-failing JS tests:
- 8 were stale: regex search windows too narrow, refactored implementations, removed
  UI elements (settings dialog: 4→5 tabs; sessions panel: moved hidden_sessions to Views)
- 2 were DOM drift from feature additions
Each fix has a code comment explaining the change.

Add 3 new end-to-end tests in test_views.py verifying normalize→prune pipeline.

Final test counts: pytest 1266 passing (1263 + 3 new), node --test 396 passing.
2026-05-17 11:38:35 -07:00
Brian Krabach fe0a5e10b2 feat(views): prune stale session keys with local-only bookkeeping (Phase 4)
New pruning.py sidecar module with load_pruning_state / save_pruning_state. The sidecar lives at ~/.config/muxplex/pruning.json and is NEVER in SYNCABLE_KEYS — bookkeeping is per-device, not federated.

prune_stale_keys(settings, live_keys, *, pruning_state, grace_seconds, now) drops keys from hidden_sessions and view.sessions after they have been missing past the configurable grace period (default 24h, syncable via stale_key_grace_hours).

Wired into _run_poll_cycle as step 14 with try/except so failures never abort the cycle.

The prune ACTION (settings change) syncs normally via existing LWW; the bookkeeping does not.

24 new tests pass; total muxplex/tests/ = 1263 passed.
2026-05-17 10:26:20 -07:00
Brian Krabach acdfc9bafd docs: update patch_state docstring to include active_view 2026-04-15 17:03:17 -07:00
Brian Krabach b69a1f8c15 feat: add active_view to StatePatch model and patch_state handler
- Add optional active_view field to StatePatch Pydantic model
- Add active_view handling in patch_state to persist the field
- Add test_patch_state_sets_active_view: verifies PATCH persists active_view
- Add test_patch_state_active_view_defaults_to_all: verifies GET returns 'all' by default

Closes task-1: Add active_view to StatePatch Model
2026-04-15 16:56:43 -07:00
Brian Krabach ab5560a623 fix: address COE verification findings — bell-clear regression, active_view fallback, device_id consistency
- BUG 1: Replace isinstance(active_remote_id, int) guard with _lookup_remote_by_device_id()
  so bell-clear fires correctly when active_remote_id is a UUID string (new format).
  Old code silently skipped the POST for all non-integer active_remote_id values.
  Regression test: test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uuid

- GAP 4: Add _resolveActiveView(activeView, views) helper in app.js.
  Falls back to 'all' when active_view references a deleted/unknown view name.
  Ready for Phase 2 view-switching code to call at read time.

- ISSUE 6: _createDeviceSelect now uses remotes[i].device_id || String(i) for option
  values instead of always String(i), so session creation routes receive correct device_id.

- ISSUE 9: identity.json written with indent=2 + trailing newline, consistent with
  settings.json and state.json formatting conventions.

- ISSUE 11: Fix state.py module docstring — 'tmux-web muxplex' → 'muxplex'.

- ISSUE 12: Settings panel hidden_sessions checkboxes now use s.sessionKey || s.name
  as the checkbox value (and checked state) so remote sessions are stored in
  device_id:name format, consistent with getVisibleSessions() lookups.
2026-04-15 14:42:50 -07:00
Brian Krabach 5510d9d008 feat: tag sessions with device_id-based sessionKey in federation_sessions 2026-04-15 13:48:39 -07:00
Brian Krabach c26c61b7da fix: extract auth_headers to local var to prevent ruff splitting inline guards
Ruff was reformatting the ternary header constructions to multi-line form:
  headers={"Authorization": f"Bearer {remote_key}"}\n  if remote_key\n  else {},

This caused test_federation_auth_headers_guard_empty_key to fail because
the source-inspection test requires 'if' to appear on the same line as
the Authorization header dict.

Fix: assign to a local variable before use so ruff has no reason to split:
  auth_headers = {"Authorization": f"Bearer {key}"} if key else {}

Applied at two sites:
- poll-cycle bell-clear (line ~232)
- federation WS proxy endpoint (line ~1094)
2026-04-15 13:02:32 -07:00
Brian Krabach 665b74b455 style: apply ruff format to main.py (task-9 follow-up) 2026-04-15 12:42:56 -07:00
Brian Krabach 8d7990b1c5 feat: switch federation proxy endpoints to device_id lookup (task-9)
Change all 5 federation proxy URL patterns from {remote_id:int} to
{device_id} (str) and use _lookup_remote_by_device_id() for lookup.

Affected endpoints:
- federation_connect: /api/federation/{device_id}/connect/{session_name}
- federation_bell_clear: /api/federation/{device_id}/sessions/{session_name}/bell/clear
- federation_create_session: /api/federation/{device_id}/sessions
- federation_delete_session: /api/federation/{device_id}/sessions/{session_name}
- federation_terminal_ws_proxy: /federation/{device_id}/terminal/ws

All endpoints now use _lookup_remote_by_device_id(device_id) which
provides integer index fallback for backward compatibility.

Also update existing test to expect 404 instead of 422 for non-integer
device_id (since str type accepts any string, lookup returns None->404).

Tests:
- test_federation_connect_by_device_id: POST with device_id='aaa-111-bbb' returns 200
- test_federation_connect_device_id_not_found: unknown device_id returns 404
2026-04-15 12:16:56 -07:00
Brian Krabach cc9bb81bc9 feat: add _lookup_remote_by_device_id helper for federation proxy
Implements _lookup_remote_by_device_id(device_id: str) -> dict | None
in muxplex/main.py, inserted before the federation WebSocket proxy section.

Logic:
- Load settings and get remote_instances list
- Primary: iterate remotes, return first where remote.get('device_id') == device_id
- Fallback: if device_id parses as integer, use index-based lookup for
  transition compatibility (0 <= idx < len(remotes))
- Return None if not found

Tests added:
- test_lookup_remote_by_device_id_found: two remotes with device_ids
  'aaa-111' and 'bbb-222'; lookup 'bbb-222' returns Desktop remote
- test_lookup_remote_by_device_id_not_found: one remote with device_id
  'aaa-111'; lookup 'zzz-999' returns None

Task: task-8
2026-04-15 11:45:06 -07:00
Brian Krabach ad2c0e1fe2 feat: extend /api/instance-info to return device_id
Add load_device_id import from muxplex.identity and include device_id
in the instance-info response dict. Update endpoint docstring to
mention device identity.

Test: test_instance_info_includes_device_id verifies device_id is
present as a non-empty string when IDENTITY_PATH is redirected to
tmp_path.
2026-04-15 11:20:08 -07:00
Brian Krabach f7519a8058 fix: zero-session devices show 'No sessions' tile + grace period prevents flapping
Two federation UX fixes:

1. Zero-session devices: when remote returns empty sessions list, return
   {status: 'empty'} entry. Frontend renders 'No sessions' status tile
   instead of making the device invisible.

2. Flapping prevention: server-side cache of last-known-good federation
   results per remote. On transient failure, return cached sessions for
   up to 3 consecutive failures before marking unreachable. Eliminates
   the visible on/off/on/off pattern caused by occasional 5-second
   timeouts during 2-second poll cycles.

Tests added:
- test_fetch_remote_returns_empty_status_for_zero_sessions (Python)
- test_fetch_remote_uses_cache_on_transient_failure (Python)
- test_fetch_remote_marks_unreachable_after_grace_period (Python)
- renderGrid shows 'No sessions' status tile for status=empty devices (JS)

Also adds reset_federation_cache autouse fixture to prevent cross-test
contamination from the new module-level _federation_cache dict.
2026-04-13 14:12:18 -07:00
Brian Krabach d541612843 fix: read federation key live on each request and check PUT sync response
Bug 1 (auth.py): AuthMiddleware.dispatch() was using self.federation_key
(set once at startup) for Bearer token validation. If the key was generated
or rotated via POST /api/federation/generate-key after startup, the old
(often empty) value caused all federation auth to silently return 401.

Fix: import load_federation_key from muxplex.settings and call it fresh on
every non-exempt, non-cookie request. Also adds a warning log when a Bearer
token is received but no key is configured on this server.

Bug 2 (main.py): _sync_settings_with_remotes() discarded the PUT response,
silently swallowing 401/500 errors from the remote sync endpoint.

Fix: capture the PUT response as put_resp. Handle 409 (Conflict = remote is
newer) with a debug log; raise_for_status() for any other non-2xx so errors
propagate to the outer except and are logged as warnings.

Tests:
- test_dispatch_calls_load_federation_key_live: pattern test confirming
  load_federation_key() is called inside dispatch()
- test_dispatch_does_not_use_stale_self_federation_key_for_bearer: pattern
  test confirming self.federation_key is gone from the live bearer check
- test_sync_put_response_calls_raise_for_status: pattern test confirming
  raise_for_status() is called on the PUT response in the sync function
- Updated existing bearer tests to monkeypatch load_federation_key so they
  are isolated from any real key file on disk
2026-04-08 22:39:03 -07:00
Brian Krabach 2ebd3b9929 feat: add settings sync logic to poll cycle with 30-second interval
- Add SETTINGS_SYNC_INTERVAL = 15 constant (15 poll cycles × 2s ≈ 30s)
- Add _settings_sync_counter module-level counter
- Add _sync_settings_with_remotes() async function that:
  - GETs /api/settings/sync from each remote instance
  - Adopts remote settings if remote timestamp is newer
  - Pushes local settings via PUT if local timestamp is newer
  - Silently skips 404/405 (older muxplex without sync endpoints)
  - Catches all per-remote errors and logs as warnings
- Wire sync call into _run_poll_cycle() step 13 (runs outside state_lock)
- Add test_settings_sync_poll.py with 10 tests covering all sync behaviours
2026-04-08 21:15:44 -07:00
Brian Krabach a0ad4921cf feat: add PUT /api/settings/sync federation endpoint with newer-wins logic 2026-04-08 21:03:23 -07:00
Brian Krabach 349f49824b feat: add GET /api/settings/sync federation endpoint 2026-04-08 20:50:46 -07:00
Brian Krabach e7379c46c9 chore: fix step numbering in _run_poll_cycle comments 2026-04-08 19:40:04 -07:00
Brian Krabach 8fcb25adee fix: capture exception in federation bell clear warning log
Captures the exception as 'exc' in the except clause and includes it
as the third argument to the warning log message. This ensures that
the failure reason is observable in logs, improving diagnostics for
federation heartbeat failures.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-04-08 19:32:20 -07:00
Brian Krabach f5ae0df9f5 feat: add heartbeat-driven bell clearing for remote sessions in poll cycle
- Add module-level _federation_client reference for use in background poll task
- Assign _federation_client in lifespan startup; clear on shutdown
- Add step 12 to _run_poll_cycle: iterate devices viewing a remote session
  in fullscreen with recent interaction (<60s), fire POST bell/clear to
  the active remote instance with Bearer auth (fire-and-forget, errors logged)
- Add test_poll_cycle_fires_federation_bell_clear_for_remote_session test

Closes task-3 of federation-state-propagation-plan
2026-04-08 18:50:17 -07:00
Brian Krabach 37c2b47ec9 fix: read version from package metadata — single source of truth (pyproject.toml) 2026-04-08 14:36:58 -07:00
Brian Krabach 1b5207b5af chore: release v0.2.0 -- version bump, changelog, FastAPI version fix 2026-04-08 08:32:05 -07:00
Brian Krabach 12c8f8f550 feat: add federation proxy for deleting remote sessions
- Add DELETE /api/federation/{remote_id}/sessions/{session_name} endpoint
  to proxy session deletion to remote instances with Bearer auth
  (follows same pattern as existing create/connect/bell-clear proxies)
- Update killSession() to accept optional remoteId parameter and route
  through federation delete proxy when present
- Update delegated click handler to extract data-remote-id from parent
  tile/sidebar element and pass to killSession() for remote routing
- Now users can delete sessions on remote devices from their local client

Fixes: https://github.com/bkrabach/muxplex/issues (delete remote sessions)
Generated with Amplifier
2026-04-08 08:21:22 -07:00
Brian Krabach 67d68d8cf0 fix: resolve federation, settings, and session creation bugs
Seven bug fixes across five files:

1. auth.py: Add .json to static extension allowlist so /manifest.json
   bypasses auth. Previously, unauthenticated requests got redirected to
   the login page HTML which the browser tried to parse as JSON.

2. sessions.py: Catch FileNotFoundError in enumerate_sessions() alongside
   RuntimeError. If the session command binary is missing from PATH,
   asyncio.create_subprocess_exec raises FileNotFoundError, which was
   unhandled and broke the poll loop.

3. settings.py: Fix federation key erasure when remote instance URLs are
   edited. The key-preservation logic only restored keys by exact URL
   match. Editing a URL (e.g. http:// to https://) changed the lookup
   key, permanently erasing the real federation key. Added position-based
   fallback for same-slot URL edits.

4. main.py: Replace fire-and-forget subprocess.Popen in create_session
   with asyncio.create_subprocess_shell that checks the return code and
   returns proper HTTP 500 errors. Added shutil.which() pre-flight check.
   Commands that exit non-zero but still create the session (e.g.
   amplifier-workspace which tries to attach after create) are detected
   by checking enumerate_sessions() and treated as success.

5. main.py: Redact sensitive keys in PATCH /api/settings response,
   matching the existing GET endpoint behavior.

6. main.py: Include exception type and message in federation 503 error
   responses (connect, bell-clear, create-session).

7. app.js: Fix auto_open vs auto_open_created key mismatch. The settings
   toggle read/wrote 'auto_open' but the backend key is
   'auto_open_created'. The PATCH was silently dropped and the toggle
   was completely non-functional.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-04-08 08:08:54 -07:00
Brian Krabach 12ad92986e fix: consolidate WS proxy Bearer header guard to single line for CI test
The source-inspection test checks each line individually for an 'if'
guard. The multi-line formatting of the 'if remote_key else {}' guard
split it across 3 lines, making the test see an unguarded header.
Consolidated to match the single-line pattern of the other 4 endpoints.
2026-04-07 08:10:14 -07:00
Brian Krabach add9e03718 fix: persist active_remote_id in state for remote session restore
Remote sessions failed to reconnect after page refresh because
restoreState() and renderSheetList() didn't pass remoteId to
openSession(). Fix:

- state.py: add active_remote_id field to empty_state()
- main.py: extend StatePatch model to accept active_session and
  active_remote_id; update PATCH /api/state to selectively update
  only fields explicitly provided (using model_fields_set)
- app.js restoreState(): read state.active_remote_id and pass it
  as remoteId option so page-refresh reconnects remote sessions
- app.js openSession(): fire-and-forget PATCH /api/state to persist
  active_session + active_remote_id after successful connect
- app.js closeSession(): fire-and-forget PATCH /api/state to clear
  both fields so refresh doesn't try to reopen a closed session
- app.js renderSheetList(): add data-remote-id to sheet items and
  pass remoteId in click handler (previously called openSession(name)
  with no remoteId, routing remote sessions to local 404 endpoint)

Fixes: POST /api/sessions/paperclip-adapter/connect 404 when clicking
a remote session from the mobile bottom sheet or after page refresh.
2026-04-06 18:08:19 -07:00
Brian Krabach 50abb4f40b fix: WebSocket federation proxy accepts self-signed TLS certificates
websockets.connect() used default SSL verification, rejecting self-signed
certs on remote instances. Same issue as the httpx fix (2c21dfe) but for
the websockets library. Fix: pass ssl.SSLContext with CERT_NONE when the
remote URL is wss://.
2026-04-06 17:25:25 -07:00
Brian Krabach d10c3b0088 fix: guard Bearer header in WebSocket federation proxy for empty key
The source-inspection test correctly caught one remaining unguarded
Authorization header in the WS proxy additional_headers. Applied the
same 'if remote_key else {}' pattern as the other 4 federation endpoints.
2026-04-06 06:16:10 -07:00
Brian Krabach 39f076502f fix: skip Authorization header when federation key is empty
Empty remote_instances[].key produced 'Bearer ' (illegal header value)
causing httpx to reject the request entirely. Fix: only include the
Authorization header when the key is non-empty. Remotes without keys
still work if their auth allows it (e.g., localhost bypass).

Fixes all 5 sites: fetch_remote GET, connect POST, bell/clear POST,
create-session POST, and WebSocket proxy additional_headers.
2026-04-06 05:25:18 -07:00
Brian Krabach 2c21dfeb3e fix: federation client accepts self-signed TLS certificates
httpx.AsyncClient used default SSL verification, rejecting self-signed
certs from muxplex setup-tls. Remote HTTPS instances (e.g., setup-tls
with self-signed fallback) were unreachable — all federation requests
failed with CERTIFICATE_VERIFY_FAILED. Fix: verify=False on the
federation client. Bearer token auth still protects authorization.
2026-04-06 04:57:23 -07:00
Brian Krabach 97f59fda84 feat: add POST /api/federation/{remote_id}/sessions proxy endpoint
Adds federation_create_session endpoint following the same pattern as
federation_connect and federation_bell_clear. The endpoint:
- Looks up remote by integer index into remote_instances in settings
- Sends POST {remote_url}/api/sessions with Bearer auth header and
  JSON body {name: ...}
- Returns remote's JSON response
- Raises 404 for invalid remote_id, 503 when unreachable, 502 on HTTP error

Adds 4 tests covering:
- Proxy behavior with correct URL, auth header, and JSON body forwarding
- 404 for out-of-range remote_id
- 503 when ConnectError raised
- 502 when remote returns HTTP error status
2026-04-04 07:43:18 -07:00
Brian Krabach 6ee6112744 feat: add federation bell/clear proxy endpoint 2026-04-04 06:52:22 -07:00
Brian Krabach 5f9d6a593e feat: add POST /api/sessions/{name}/bell/clear endpoint 2026-04-04 06:46:31 -07:00
Brian Krabach 42142c0954 feat: inject sessionKey for remote federation sessions
- Remote sessions now include sessionKey field formatted as 'remoteId:name'
- Local sessions are unchanged (no sessionKey) as they never collide
- Prevents name collisions between local and remote sessions with identical names
- Added tests: test_federation_sessions_local_sessions_have_no_session_key
               test_federation_sessions_remote_sessions_have_session_key
2026-04-04 06:36:24 -07:00
Brian Krabach 463cbf4cb3 fix: federation remoteId must be integer index, not URL string
- 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
2026-04-01 20:22:45 -07:00
Brian Krabach a07e5b5383 merge: integrate upstream (clipboard, URL click, search addon, delete fixes) with federation proxy rewrite 2026-04-01 18:55:54 -07:00
Brian Krabach e4ba201cd2 refactor: use module-level socket import in _ttyd_is_listening 2026-04-01 18:34:12 -07:00
Brian Krabach dae5cea16b chore: remove unused websockets.exceptions import, add .gitignore, untrack __pycache__
- 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
2026-04-01 18:30:10 -07:00
Brian Krabach e738701cdd chore: final Phase 2 cleanup — remove stale references and unused imports
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.
2026-04-01 18:17:55 -07:00
Brian Krabach 046d123149 refactor: remove all cross-origin browser-direct federation code
- 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
2026-04-01 17:30:18 -07:00
Brian Krabach 235b4aadfe fix: guard negative remote_id in federation_connect endpoint
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.
2026-04-01 14:38:09 -07:00
Brian Krabach 745e58bae6 style: add 503 warning log and regroup 502 test with federation_connect suite 2026-04-01 14:33:27 -07:00
Brian Krabach b1639826ab fix: improve fetch_remote error handling and diagnostics
- 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
2026-04-01 14:22:29 -07:00
Brian Krabach 489a127056 refactor: defensive getattr guard and clarified cleanup comment in lifespan
- 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
2026-04-01 14:13:45 -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 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