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.
After 9 clipboard commits that kept breaking each other, COE review
determined: ttyd uses ClipboardAddon, not custom handlers. Ctrl+Shift+V
on Linux and Cmd+V on macOS both trigger native browser paste events
that xterm.js catches via its hidden textarea handler. Zero custom
paste code needed. Deleted synthetic ClipboardEvent dispatch, readText
calls, and all paste-related comments. Kept: Ctrl+Shift+C copy,
auto-copy on selection, OSC 52, Ctrl+F search.
Ctrl+Shift+V does not trigger a native browser paste event (unlike Cmd+V
on macOS). Fix: read clipboard via navigator.clipboard.readText(), then
dispatch a synthetic ClipboardEvent on xterm.js's hidden textarea. This
triggers xterm.js's built-in handlePasteEvent which handles CR/LF
normalization, bracketed paste mode, and correct UTF-8 encoding — the
same code path as Cmd+V.
Also replaces the old regression test that asserted Ctrl+Shift+V was
NOT intercepted, replacing it with the correct test for the synthetic
paste approach.
xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8.
Multi-byte UTF-8 characters like box-drawing ─ (U+2500, bytes E2 94 80)
were rendered as â (Latin-1 interpretation of 0xE2). Fix: decode with
TextDecoder before _term.write(), matching ttyd's official client pattern.
Add module-level _decoder (alongside existing _encoder) and use
_decoder.decode(payload) in the type 0x30 message handler.
_term.paste() garbled Unicode box-drawing characters (â instead of ─│┌┐).
Fix: manually apply bracketed paste wrapping (ESC[200~...ESC[201~) for
multiline protection, then send via _encodePayload/TextEncoder for correct
UTF-8 encoding. Combines the multiline fix with the proven encoding path.
Raw WebSocket send bypassed xterm.js's paste pipeline, so multiline
text was sent without bracketed paste markers (ESC[200~...ESC[201~).
The shell treated each newline as Enter, executing lines separately.
Fix: _term.paste(text) goes through the proper pipeline — converts
CR/LF, wraps in bracketed paste markers, then fires onData → WebSocket.
The reconnect logic in connectWebSocket() always called local
/api/sessions/{name}/connect even for remote sessions, causing 404.
Fix: check remoteId (captured from outer scope) and route through
/api/federation/{remoteId}/connect/{name} for remote sessions.
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.
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://.
Our custom handler sent clipboard text via WebSocket, but the browser's
native paste event also fired on xterm.js's hidden textarea, causing
xterm's built-in paste handler to send the same text again. Fix:
e.preventDefault() suppresses the browser paste event.
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.
Clicking an unreachable remote device tile passed empty session name
to openSession(), producing /api/federation/2/connect/ (no session name)
→ 405. Fix: bail early if name is empty or whitespace.
Also added guard in grid click handlers to skip error/status tiles
entirely, preventing clicks on unreachable or auth_failed tiles.
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.
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.
blur handler on the name input was closing the entire new-session UI
when focus moved to the device select. Fixed: check if activeElement
is the sibling select before running cleanup. Same guard added to the
select's blur handler (check if focus returned to input).
Also adds Escape keydown handler on the select to allow cancelling
without returning focus to the input first.
Applied to both showNewSessionInput() and showFabSessionInput().
11 tests calling main() failed in GitHub Actions because ttyd is not
available in standard Ubuntu packages. _check_dependencies() calls
sys.exit(1) when ttyd is missing. Fixed by adding a mock_check_deps
pytest fixture and applying it to all 11 affected tests.
Affected tests:
- test_main_calls_serve_by_default
- test_main_passes_custom_host_and_port
- test_main_default_host_is_localhost
- test_main_passes_auth_flag
- test_main_passes_session_ttl_flag
- test_main_passes_none_for_unset_flags
- test_main_passes_explicit_host_only
- test_main_serve_subcommand_accepts_flags
- test_main_passes_tls_cert_and_key_flags
- test_main_passes_none_for_unset_tls_flags
- test_serve_subcommand_accepts_tls_flags
- Add contents: read permission to publish.yml (required for actions/checkout)
when any permission is explicitly set, unlisted permissions default to none
- Add timeout-minutes: 15 to publish.yml for consistency with ci.yml
- Add docstrings to 3 pyproject metadata test functions for consistency
with the rest of the 2000+ line test file
- Add .github/workflows/ci.yml with correct placement in muxplex repo
- Matrix: Python 3.11, 3.12, 3.13 with fail-fast: false
- Uses uv for Python and dependency management with package caching
- Runs pytest via uv run pytest -v (config in pyproject.toml addopts)
- timeout-minutes: 15 to prevent runaway CI costs
- permissions: contents: read for minimal GITHUB_TOKEN scope
Co-authored-by: Amplifier <amplifier@example.com>
The 5 pre-existing tests used \(\w+\) (single-param regex) but Task 4
changed createNewSession(name) → createNewSession(name, remoteId). Updated
regex to \([\w,\s]+\) to match one or more comma-separated parameters.
- Update showFabSessionInput to call _createDeviceSelect() for optional
device <select> element
- Append select (if present) then input to overlay
- Enter handler reads remoteId from select.value, passes to createNewSession
- Add test: showFabSessionInput creates device select when multi_device_enabled with remotes
- Add _createDeviceSelect() helper function after _createSessionInput()
- Returns null when multi_device_enabled is false or remote_instances is empty
- Creates <select class='new-session-device-select'> with local + remote options
- Pre-selects based on _activeFilterDevice match
- Update showNewSessionInput() to call _createDeviceSelect() and insert select
- cleanup() removes both select and input; restores btn display
- Enter handler reads remoteId from select.value, calls createNewSession(name, remoteId)
- Export _createDeviceSelect for testing
- Add 3 tests covering select creation, integration, and argument passing
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
Update updatePillBell and updateSessionPill to build a viewingKey
that accounts for remote sessions (_viewingRemoteId prefix) and
compare using s.sessionKey || s.name instead of s.name alone.
This prevents false bell suppression when a local session shares a
name with a remote session — the two are now distinguished by their
fully-qualified key (remoteId:name vs plain name).
- 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
Favicon badge: removed crossOrigin='anonymous' from _drawFaviconBadge Image
lazy-init. Same-origin favicons don't need CORS credentials; setting it caused
silent cache-miss failures when the browser had the asset cached without CORS
headers, preventing the canvas from drawing.
Page title: new updatePageTitle() centralizes title logic. Format:
'(N) hostname - muxplex' when N sessions have unseen bells, otherwise
'hostname - muxplex'. Hostname uses device_name setting with fallback
to location.hostname. Called from pollSessions on every tick, from
loadServerSettings callback, and immediately when device_name input changes
(updating _serverSettings.device_name in place first so the helper sees the
latest value without waiting for the debounced PATCH).
Replaced 3 scattered document.title assignments with updatePageTitle().
Updated 3 existing source-pattern tests to reflect the new centralised
approach; added 2 new tests for updatePageTitle existence and pollSessions
call site.
When host is set to network access (not 127.0.0.1) and TLS is not
configured, doctor shows 'Run: muxplex setup-tls' and service install
shows a tip line. Hidden on localhost-only setups since clipboard
works without HTTPS there.
Bug 1: detect_tailscale() looked for DNSName at top level of JSON but
tailscale status --self --json nests it inside Self.DNSName. Fixed to
read from Self first with top-level fallback.
Bug 2: getattr(cert, 'not_valid_after_utc', cert.not_valid_after)
eagerly evaluated the fallback, triggering CryptographyDeprecationWarning.
Replaced with try/except AttributeError pattern in all three occurrences
(generate_self_signed and get_cert_info).
muxplex/tls.py imports cryptography for self-signed cert generation
but it was missing from the declared dependencies. uv tool installs
would fail with ModuleNotFoundError on setup-tls.
- test_readme_tls_cert_has_empty_default: docstring now accurately describes
the assertion (key presence check, not empty-string default verification)
- test_readme_tls_key_has_empty_default: same correction
- test_readme_tls_detection_priority_explanation: replace broad three-way or
(detection/priority/auto-detect) with tighter check requiring both 'Tailscale'
and 'mkcert' to appear alongside priority language, so incidental word matches
can no longer satisfy the assertion
Verifies that generate_mkcert() correctly passes Tailscale hostnames
(spark-1.tail8f3c4e.ts.net, 100.64.0.1) through to the mkcert command
arguments when extra_hostnames is provided.
- fake_run tracks gen_calls only for '-cert-file' invocations
- Creates fake cert/key files to simulate mkcert success
- Asserts result is not None
- Asserts Tailscale hostname appears in mkcert command args
Part of Phase 2 full test sweep (task-7).
- Added existing cert detection at the top of setup_tls() in muxplex/cli.py
- After defining cert_path/key_path, checks if tls_cert and tls_key are both set in settings AND the cert file exists
- If so, calls get_cert_info() and prints 'TLS already configured (expires YYYY-MM-DD).'
- Prompts user with input('Regenerate? [y/N] ') with try/except for EOFError and KeyboardInterrupt (defaults to 'n')
- If user does not answer 'y' or 'yes', prints 'Keeping existing certificates.' and returns early
- Added two new tests in test_cli.py: test_setup_tls_prompts_when_certs_exist and test_setup_tls_regenerates_on_eof
- All 866 tests pass
Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>