Files
muxplex/muxplex/sessions.py
T
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

141 lines
4.6 KiB
Python

"""
tmux session enumeration and snapshot helpers for the tmux-web muxplex.
In-memory cache:
_session_list — most-recently-enumerated list of session names.
_snapshots — most-recently-captured pane text, keyed by session name.
Public API:
get_session_list() → list[str]
get_snapshots() → dict[str, str]
update_session_cache(names, snapshots) → None
run_tmux(*args) → str (raises RuntimeError on nonzero exit)
enumerate_sessions() → list[str]
capture_pane(name, lines) → str
snapshot_all(names) → dict[str, str]
"""
import asyncio
# ---------------------------------------------------------------------------
# In-memory cache
# ---------------------------------------------------------------------------
_session_list: list[str] = []
_snapshots: dict[str, str] = {}
def get_session_list() -> list[str]:
"""Return a copy of the cached session name list."""
return list(_session_list)
def get_snapshots() -> dict[str, str]:
"""Return a copy of the cached pane-snapshot dict."""
return dict(_snapshots)
def update_session_cache(names: list[str], snapshots: dict[str, str]) -> None:
"""Replace the in-memory caches with fresh data.
Sets _session_list to *names* and _snapshots to the provided *snapshots* dict.
Callers must pass the return value of snapshot_all() as *snapshots*.
"""
global _session_list, _snapshots
_session_list = list(names)
_snapshots = snapshots
# ---------------------------------------------------------------------------
# Subprocess helpers
# ---------------------------------------------------------------------------
async def run_tmux(*args: str) -> str:
"""Run `tmux <args>` in a subprocess and return stdout as a string.
Raises:
RuntimeError: If the process exits with a nonzero return code.
The error message contains the decoded stderr output.
"""
proc = await asyncio.create_subprocess_exec(
"tmux",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout_bytes, stderr_bytes = await proc.communicate()
if proc.returncode != 0:
raise RuntimeError(stderr_bytes.decode("utf-8", errors="replace"))
return stdout_bytes.decode("utf-8", errors="replace")
# ---------------------------------------------------------------------------
# Session enumeration
# ---------------------------------------------------------------------------
async def enumerate_sessions() -> list[str]:
"""Return the list of currently running tmux session names.
Calls ``tmux list-sessions -F #{session_name}``, splits on newlines,
and strips whitespace from each entry.
Returns [] if tmux is not running (RuntimeError from run_tmux).
"""
try:
output = await run_tmux("list-sessions", "-F", "#{session_name}")
except (RuntimeError, FileNotFoundError):
return []
names = [line.strip() for line in output.splitlines() if line.strip()]
return names
# ---------------------------------------------------------------------------
# Pane capture
# ---------------------------------------------------------------------------
async def capture_pane(session_name: str, lines: int = 30) -> str:
"""Capture the last *lines* lines of output from *session_name*.
Returns the captured text, or '' on any error.
"""
try:
return await run_tmux(
"capture-pane",
"-e", # preserve ANSI escape sequences for color rendering
"-p",
"-t",
session_name,
"-S",
f"-{lines}",
)
except RuntimeError:
return ""
async def snapshot_all(names: list[str]) -> dict[str, str]:
"""Capture all sessions concurrently and return a name→text mapping.
Uses asyncio.gather with return_exceptions=True so that individual
failures do not abort the whole batch. Failed sessions map to ''.
Note: this function does not mutate module state — it does not update the module cache.
Callers are responsible for passing the result to update_session_cache.
"""
if not names:
return {}
results = await asyncio.gather(
*[capture_pane(name) for name in names],
return_exceptions=True,
)
snapshots: dict[str, str] = {}
for name, result in zip(names, results):
if isinstance(result, BaseException):
snapshots[name] = ""
else:
snapshots[name] = result
return snapshots