diff --git a/muxplex/auth.py b/muxplex/auth.py index d440d33..bc1b636 100644 --- a/muxplex/auth.py +++ b/muxplex/auth.py @@ -145,6 +145,7 @@ _AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout", "/api/instance-inf _STATIC_EXTENSIONS = { ".css", ".js", + ".json", ".svg", ".png", ".ico", diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 8d89919..e07c149 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1720,7 +1720,7 @@ function openSettings() { // Auto-open const autoOpenEl = $('setting-auto-open'); if (autoOpenEl) { - autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true; + autoOpenEl.checked = ss && ss.auto_open_created !== undefined ? !!ss.auto_open_created : true; } // Device name @@ -2288,7 +2288,7 @@ function bindStaticEventListeners() { }); on($('setting-auto-open'), 'change', function() { var el = $('setting-auto-open'); - if (el) patchServerSetting('auto_open', el.checked); + if (el) patchServerSetting('auto_open_created', el.checked); }); // Hidden sessions — delegated handler on container (checkboxes are dynamic) diff --git a/muxplex/main.py b/muxplex/main.py index 05ef95e..3233956 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -18,6 +18,7 @@ import pathlib import pwd import socket import ssl +import shutil import subprocess import sys import time @@ -376,28 +377,98 @@ async def get_sessions() -> list[dict]: @app.post("/api/sessions") async def create_session(payload: CreateSessionPayload) -> dict: - """Create a new tmux session using the new_session_template from settings. + """Create a new session using the new_session_template from settings. - Substitutes {name} in the template with the validated payload name, then - runs the command as a fire-and-forget subprocess (stdout/stderr discarded). + Substitutes ``{name}`` in the template with the validated payload name, + runs the command as an async subprocess, and waits up to 30 seconds for + it to finish. Returns ``{name, ok: True}`` on success or + ``{name, ok: False, error: ...}`` with HTTP 500 on failure so that the + frontend can surface actionable errors instead of silently timing out. - Returns {name: name} regardless of outcome. - Raises HTTP 422 if name is empty or whitespace (handled by Pydantic). + Some session commands (e.g. ``amplifier-workspace``) create the tmux + session and then attempt to *attach* to it, which requires a TTY. When + launched from muxplex (no TTY available) the attach step fails with a + non-zero exit code even though the session was successfully created. To + handle this, when the command exits non-zero we check whether a tmux + session with the requested name now exists -- if it does, we treat it as + a success. """ name = payload.name settings = load_settings() - command = settings["new_session_template"].replace("{name}", name) + template = settings["new_session_template"] + + # Pre-flight: check that the base command is on PATH. + base_cmd = template.split()[0] if template.strip() else "" + if base_cmd and not shutil.which(base_cmd): + _log.error( + "Session command binary not found on PATH: %r (PATH=%s)", + base_cmd, + os.environ.get("PATH", ""), + ) + raise HTTPException( + status_code=500, + detail=f"Command not found: {base_cmd}. " + "Ensure it is installed and in the server's PATH.", + ) + + command = template.replace("{name}", name) _log.info("Creating session '%s' with command: %s", name, command) try: - subprocess.Popen( + proc = await asyncio.create_subprocess_shell( command, - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - except Exception: - _log.warning("Failed to launch new-session command: %r", command) - return {"name": name} + stdout_bytes, stderr_bytes = await asyncio.wait_for( + proc.communicate(), timeout=30 + ) + if proc.returncode != 0: + stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip() + # Some commands (amplifier-workspace) create the session then + # try to attach (which fails without a TTY). If the session + # exists despite the non-zero exit, treat it as success. + sessions = await enumerate_sessions() + if name in sessions: + _log.info( + "Session command exited %d but session '%s' exists -- " + "treating as success (likely a TTY-attach failure)", + proc.returncode, + name, + ) + else: + _log.warning( + "Session command exited %d: %s (stderr: %s)", + proc.returncode, + command, + stderr_text, + ) + raise HTTPException( + status_code=500, + detail=( + f"Session command failed (exit {proc.returncode}): " + f"{stderr_text}" + ) + if stderr_text + else f"Session command failed with exit code {proc.returncode}", + ) + except asyncio.TimeoutError: + _log.info( + "Session command still running after 30s (may be long-lived): %s", + command, + ) + # Long-running session commands (e.g. amplifier-workspace that + # spawns background processes) may outlive the 30s window. This is + # not necessarily an error -- return success and let the frontend + # poll for the session to appear. + except HTTPException: + raise + except Exception as exc: + _log.warning("Failed to launch session command %r: %s", command, exc) + raise HTTPException( + status_code=500, + detail=f"Failed to launch command: {exc}", + ) + return {"name": name, "ok": True} @app.post("/api/sessions/{name}/connect") @@ -584,9 +655,19 @@ async def get_settings() -> dict: @app.patch("/api/settings") async def update_settings(request: Request) -> dict: - """Merge known keys from the request body into settings and return updated settings.""" + """Merge known keys from the request body into settings and return updated settings. + + The response is redacted in the same way as ``GET /api/settings`` so that + sensitive keys are never leaked to the browser. + """ body = await request.json() - return patch_settings(body) + updated = patch_settings(body) + result = copy.deepcopy(updated) + result["federation_key"] = "" + for inst in result.get("remote_instances", []): + if "key" in inst: + inst["key"] = "" + return result @app.get("/api/instance-info") @@ -785,7 +866,9 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, remote_id: int) -> async with websockets.connect( ws_url, subprotocols=[Subprotocol("tty")], - additional_headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {}, + additional_headers={"Authorization": f"Bearer {remote_key}"} + if remote_key + else {}, ssl=ssl_context, ) as remote_ws: @@ -1065,7 +1148,7 @@ async def federation_connect( _log.warning("federation_connect: remote %s unreachable: %s", remote_url, exc) raise HTTPException( status_code=503, - detail=f"Remote unreachable: {remote_url}", + detail=f"Remote unreachable: {remote_url} ({type(exc).__name__}: {exc})", ) @@ -1113,7 +1196,7 @@ async def federation_bell_clear( ) raise HTTPException( status_code=503, - detail=f"Remote unreachable: {remote_url}", + detail=f"Remote unreachable: {remote_url} ({type(exc).__name__}: {exc})", ) @@ -1161,7 +1244,7 @@ async def federation_create_session( ) raise HTTPException( status_code=503, - detail=f"Remote unreachable: {remote_url}", + detail=f"Remote unreachable: {remote_url} ({type(exc).__name__}: {exc})", ) diff --git a/muxplex/sessions.py b/muxplex/sessions.py index 4b3f4d4..7431659 100644 --- a/muxplex/sessions.py +++ b/muxplex/sessions.py @@ -85,7 +85,7 @@ async def enumerate_sessions() -> list[str]: """ try: output = await run_tmux("list-sessions", "-F", "#{session_name}") - except RuntimeError: + except (RuntimeError, FileNotFoundError): return [] names = [line.strip() for line in output.splitlines() if line.strip()] diff --git a/muxplex/settings.py b/muxplex/settings.py index 31da902..1b08d0d 100644 --- a/muxplex/settings.py +++ b/muxplex/settings.py @@ -83,13 +83,18 @@ def patch_settings(patch: dict) -> dict: """ current = load_settings() - # Snapshot existing remote keys by URL *before* applying the patch so we - # can restore them if the patch contains redacted (empty) key values. - existing_remote_keys: dict[str, str] = { - r["url"]: r.get("key", "") - for r in current.get("remote_instances", []) - if r.get("url") + # Snapshot existing remote keys by URL *and* by position *before* applying + # the patch so we can restore them if the patch contains redacted (empty) + # key values. The URL-based lookup handles the common case (name-only edits). + # The position-based fallback handles URL edits (e.g. http -> https) where + # the URL changes but the remote identity is the same. + existing_remotes = current.get("remote_instances", []) + existing_remote_keys_by_url: dict[str, str] = { + r["url"]: r.get("key", "") for r in existing_remotes if r.get("url") } + existing_remote_keys_by_index: list[str] = [ + r.get("key", "") for r in existing_remotes + ] for key in DEFAULT_SETTINGS: if key in patch: @@ -97,10 +102,21 @@ def patch_settings(patch: dict) -> dict: # Restore keys that were stripped by redaction. if "remote_instances" in patch: - for remote in current["remote_instances"]: + for i, remote in enumerate(current["remote_instances"]): + if remote.get("key"): + # Non-empty key in the patch = intentional key rotation, keep it. + continue url = remote.get("url", "") - if not remote.get("key") and url in existing_remote_keys: - remote["key"] = existing_remote_keys[url] + if url in existing_remote_keys_by_url: + # URL unchanged -- restore by exact URL match. + remote["key"] = existing_remote_keys_by_url[url] + elif ( + i < len(existing_remote_keys_by_index) + and existing_remote_keys_by_index[i] + ): + # URL changed (e.g. http -> https) but position is the same -- + # restore by index so editing a URL doesn't erase the key. + remote["key"] = existing_remote_keys_by_index[i] save_settings(current) return current