diff --git a/README.md b/README.md new file mode 100644 index 0000000..09d272f --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# csshx + +This repository contains two related cluster-SSH tools: + +| | path | language | platform | +|-|-|-|-| +| **Original csshX** (Gavin Brock's 2011 release) | [`csshX`](csshX) | Perl 5 | macOS only | +| **csshx-latest** — modern rewrite | [`csshx-latest/`](csshx-latest/) | Python 3.10+ (stdlib only) | macOS / Linux | + +If you want the classic, terminal-coupled, Perl version of csshX, run +[`./csshX --man`](csshX). The historical install/usage notes live in +[`README.txt`](README.txt). + +The rest of this file is a quick-start for the new +[`csshx-latest/`](csshx-latest/) rewrite. For the full architecture +diagram, backend support matrix, and design notes, see +[`csshx-latest/README.md`](csshx-latest/README.md). + +--- + +## csshx-latest — quick start + +A modern, terminal-agnostic cluster-SSH tool. PTY end-to-end (no +TIOCSTI), with pluggable backends for WaveTerm, tmux, iTerm2, +Terminal.app, Kitty, WezTerm, and a Manual fallback that prints attach +commands you can paste into any terminal. + +### Install + +```bash +cd csshx-latest/ +python3 -m venv .venv && source .venv/bin/activate +pip install -e '.[test]' +``` + +Or with `uv`: + +```bash +cd csshx-latest/ +uv venv && uv pip install -e '.[test]' +``` + +### Run + +```bash +csshx-latest web01 web02 web03 +csshx-latest --launcher tmux web0{1..5} +csshx-latest --login deploy --ssh-args "-i ~/.ssh/cluster_key" host1 host2 +csshx-latest --launcher manual host1 host2 # prints attach commands +``` + +Type in the master TUI to broadcast to every host at once. Click any +host's terminal block to type to just that host. Press **Ctrl-Q** to +exit. + +`--launcher` choices: `auto` (default), `waveterm`, `tmux`, `iterm2`, +`terminal`, `kitty`, `wezterm`, `manual`. With `auto`, csshx-latest +auto-detects via `$TERM_PROGRAM`, `$KITTY_PID`, `$TMUX`, etc., and +falls back to `manual` if it doesn't recognize the environment (no +surprise tmux sessions). + +### Tests + +```bash +cd csshx-latest/ +pytest -q +``` + +The package itself is Unix-only (uses `pty`, `termios`, `tty`, +`fcntl`); tests assume a Unix-like host. + +### License + +The original Perl csshX is released under the same terms as Perl +itself (Artistic + GPL — see [`README.txt`](README.txt)). The +`csshx-latest/` rewrite is MIT. diff --git a/csshX b/csshX index a9d6015..94b9529 100755 --- a/csshX +++ b/csshX @@ -516,35 +516,33 @@ sub open_window { my $cmd = join ' ', map { s/(["'])/\\$1/g; "'$_'" } @args; # don't exec if debugging so we can see errors - unless ($config->debug) { - if (get_shell =~ /fish$/) { - $cmd = "clear; and exec $cmd" unless $config->debug; - } else { - $cmd = "clear && exec $cmd" unless $config->debug; - } - } + $cmd = "clear && exec $cmd" unless $config->debug; # Hide the command from any shell history $cmd = 'history -d $(($HISTCMD-1)) && '.$cmd if get_shell =~ m{/(ba)?sh$}; # TODO - (t)csh, ksh, zsh my $tabobj = $terminal->doScript_in_($cmd, undef) || return; - - # Get the window and tab IDs from the Apple Event itself - my $tab_ed = $tabobj->qualifiedSpecifier; # Undocumented call - my $tab_id = $tab_ed->descriptorForKeyword_(OSType 'seld')->int32Value-1; - my $win_ed = $tab_ed->descriptorForKeyword_(OSType 'from'); - my $win_id = $win_ed->descriptorForKeyword_(OSType 'seld')->int32Value.''; - - # Create an object unless we were passed one - my $obj = ref $pack ? $pack : $pack->SUPER::new(); - $obj->set_windowid($win_id); - $obj->set_tabid($tab_id); - - return $obj; + my $tty = $tabobj->tty->UTF8String || return; + + my $windows = $terminal->windows; + # Quickly check if the tty even exists, since the next code is REALLY slow + #return unless grep { $tty eq $_ } @{Foundation::perlRefFromObjectRef $windows->valueForKey_("tty")}; + for (my $n=0; $n<$windows->count; $n++) { + my $window = $windows->objectAtIndex_($n); + my $tabs = $window->tabs; + for (my $m=0; $m<$tabs->count; $m++) { + my $tab = $tabs->objectAtIndex_($m); + if ($tab->tty && ($tab->tty->UTF8String eq $tty)) { + my $obj = ref $pack ? $pack : $pack->SUPER::new(); + $obj->set_windowid("".$window->id); + $obj->set_tabid($m); + return $obj; + } + } + } } - sub set_windowid { *{$_[0]}->{windowid} = $_[1]; } sub windowid { *{$_[0]}->{windowid}; } @@ -1190,7 +1188,7 @@ sub parse_user_host_port { package CsshX::Launcher; use base qw(CsshX::Socket::Selectable); -use POSIX qw(tmpnam); +use File::Temp qw(tmpnam); use FindBin qw($Bin $Script);; sub new { diff --git a/csshX.iterm b/csshX.iterm index 8ddac58..b3b67ed 100755 --- a/csshX.iterm +++ b/csshX.iterm @@ -1144,7 +1144,7 @@ sub parse_user_host_port { package CsshX::Launcher; use base qw(CsshX::Socket::Selectable); -use POSIX qw(tmpnam); +use File::Temp qw(tmpnam); use FindBin qw($Bin $Script);; sub new { diff --git a/csshx-latest/.gitignore b/csshx-latest/.gitignore new file mode 100644 index 0000000..33a8319 --- /dev/null +++ b/csshx-latest/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ +venv/ +.eggs/ +*.egg-info/ +build/ +dist/ +.coverage +.tox/ diff --git a/csshx-latest/AGENTS.md b/csshx-latest/AGENTS.md new file mode 100644 index 0000000..5ed03b9 --- /dev/null +++ b/csshx-latest/AGENTS.md @@ -0,0 +1,499 @@ +# AGENTS.md + +Guide for any contributor (human or AI agent) picking up `csshx-latest`. + +Author: Aditya Kapadia. + +--- + +## Project at a glance + +A modern, terminal-agnostic cluster-SSH tool — a rewrite of the Perl +[csshX](https://github.com/brockgr/csshx). Async Python 3.9+, stdlib +only (no runtime deps), real PTYs, pluggable terminal launchers, token- +authenticated UNIX sockets, with master + slaves tiled together on +every backend that can address the master window. + +**Status:** v0.2.0 Beta. 266 tests passing in ~3.8s. Safe for daily +use on trusted networks with up to 16 hosts (raise `--max-hosts` for +more). + +``` +csshx_latest/ +├── __main__.py CLI entry, argparse (also: --action, --command-key) +├── orchestrator.py Top-level run loop, preflight, reap, reconnect +├── master.py Back-compat shim re-exporting orchestrator names +├── slave.py One ssh subprocess + PTY + data/control sockets +├── broadcaster.py Routes master keystrokes to enabled slaves +├── tui.py Raw-mode stdin reader + Ctrl-T command mode +├── auth.py 32-byte hex token + AUTH handshake + token file +├── attach.py Stdlib attach client (run by spawned blocks) +├── terminal.py raw-mode CM, winsize ioctls, xterm.js mode resets +├── hosts.py Brace expansion + cluster alias resolution +├── config.py ~/.config/csshx-latest/config.toml or ~/.csshrc +├── launcher.py Launcher Protocol + auto-detect + Color enum +├── logging_setup.py stderr formatter +├── action.py One-shot --action mode (fan-out ssh exec) +└── launchers/ One file per backend + ├── waveterm.py + ├── tmux.py + ├── iterm2.py + ├── apple_terminal.py + ├── kitty.py + ├── wezterm.py + └── manual.py +``` + +--- + +## Conventions + +- **Authorship:** every module starts with `Author: Aditya Kapadia.` in + the docstring. New modules follow the same pattern. +- **No AI / process narration** in comments. Comments explain *why*, + not *what changed*. +- **Files stay under 600 LOC.** Current largest is `orchestrator.py` + at ~430. Split before crossing 600. +- **Zen of Python.** Flat over nested; explicit over implicit; one + obvious way to do it. +- **Stdlib only at runtime.** Tests may use pytest; nothing else. +- **All sync subprocess calls run through `asyncio.to_thread`** when + invoked from the event loop (osascript, tmux, wsh can block 100ms+). +- **Launcher subprocesses use `capture=True` by default** so probes + for legacy / removed CLI subcommands don't leak stderr into the + user's terminal. + +### Test layout + +- `tests/test_.py` — pure unit tests with mocked subprocess. +- `tests/test_slave_bridge.py` — pipe-pair smoke tests of the bridge. +- `tests/test_slave_control_socket.py` — real PTY + control socket. +- `tests/test_integration_pty.py` — real PTY + fork + cat as fake ssh. +- `tests/test_launcher_conformance.py` — Protocol shape + signature + arity + every `Color` state, parametrized over `_LAUNCHERS`. +- `tests/conftest.py` — shared fixtures (`short_socket_dir`, + `harmless_pid`, `stdio_devnull`). + +Run: `uv run pytest -q`. Target: < 4 seconds wall-clock. + +--- + +## DONE in v0.2.0 + +### Critical fixes (production safety) + +| ID | What | Where | +| --- | --- | --- | +| C1 | Bounded reap with SIGKILL fallback (no more hang on shutdown) | `orchestrator._kill_and_reap` | +| C2 | `StrictHostKeyChecking=accept-new` injected unless user overrode | `orchestrator.maybe_inject_strict_host_key_opts` | +| C3 | `--max-hosts 16` cap to prevent fork-bomb typos | `orchestrator.run_master` + `__main__` | +| C4 | Broadcaster logs per-slave write failures (no more silent drops) | `broadcaster.broadcast` | +| C5 | WaveTerm export parser uses `shlex.split` (all quote forms safe) | `launchers.waveterm._parse_bash_exports` | + +### Must-have features + +| ID | What | Where | +| --- | --- | --- | +| M1 | Per-slave focus toggle: `Ctrl-T 1..9` direct, `Ctrl-T i ` prompt | `tui._handle_command_byte`, `tui._consume_index_prompt_byte` | +| M2 | iTerm2 + Terminal.app actually close panes on shutdown (track ids) | `launchers.iterm2`, `launchers.apple_terminal` | +| M3 | `Launcher.start(total)` lifecycle hook replaces env smuggle | `launcher.Launcher`, every concrete launcher | +| M4 | TOML or `~/.csshrc` cluster aliases (recursive, cycle-safe) | `config.py` + `hosts.expand_hosts` | +| M5 | Concurrent tcp/22 preflight, `--strict` and `--no-preflight` flags | `orchestrator.preflight_hosts` | +| M6 | Per-block SIGWINCH via dedicated control socket (`WINSZ rows cols`) | `slave._apply_control_line`, `attach._push_winsize` | +| M7 | Real-PTY integration test with cat as fake ssh | `tests/test_integration_pty.py` | +| M8 | `--reconnect` with exponential backoff (1, 2, 4, 8, 16 s) | `orchestrator._attempt_reconnect` | + +### Extras delivered along the way + +- Alphabetic brace expansion: `host-{a..c}`. +- Tile-after-every-spawn so panes stay balanced as blocks are added. +- Stdlib attach client is now the default (socat couldn't handle the + dual data + control socket protocol). +- Always-on per-master SIGWINCH propagation joined by per-block + SIGWINCH via the control socket. +- `pyproject.toml` bumped to `0.2.0`, status `4 - Beta`, author Aditya. +- README rewritten with the new flag table, cluster examples, and key + bindings. + +### Polish landed in v0.2.0 (priority items 1–15) + +| # | Where | +| --- | --- | +| 1. `set_color` hook wired to ENABLED/DISABLED/DEAD per block | `launcher.Color`, every launcher's `set_color`, `orchestrator._color_for` | +| 2. `--action 'uptime' h1 h2` one-shot fan-out + summary | `action.py`, `__main__.main` | +| 3. Backend conformance harness (skip when binary not on PATH) | `tests/test_launcher_conformance.py` | +| 4. `Slave.max_writers` cap on the data socket | `slave.handle_data_client` | +| 5. Python floor widened to 3.9 (`asyncio.to_thread` is the floor) | `pyproject.toml` | +| 6. Startup help hint: "press Ctrl-T for the command menu" | `tui.tui_loop` | +| 7. ANSI-colored status footer (green/red/dim, tty-only) | `tui.render_status` | +| 8. `--command-key ^T / 0x14 / single-char` configurable prefix | `tui.parse_command_key`, `__main__`, `tui._handle_command_byte` | +| 9. Cancel-on-printable echoes the byte back into broadcast | `tui._handle_command_byte` | +| 10. `master.py` shim trimmed to a plain re-export header | `master.py` | +| 11. `[project.urls]` filled in | `pyproject.toml` | +| 12. `_temporary_umask` now asserts main-thread instead of "trust me" | `slave._temporary_umask` | +| 13. WaveTerm `wsh token` still bash-parsed; tracked for JSON later | `launchers/waveterm.py` (no change) | +| 14. `--reconnect` retitles block "[reconnecting]" + paints DEAD | `orchestrator._attempt_reconnect` | +| 15. `slave.py` docstring documents WINSZ + reserved future verbs | `slave.py` | + +### Polish landed post-v0.2.0 + +| # | What | Where | +| --- | --- | --- | +| 16. **Master + slaves are tiled together** on Terminal.app and iTerm2 — previously only slaves were rearranged | `launchers.apple_terminal.AppleTerminalLauncher.{start,tile}`, `launchers.iterm2.ITerm2Launcher.open_block` | +| 17. **Terminal.app windows no longer slide under the Dock** — tiling now happens inside a "usable area" rectangle (desktop minus Dock + edge inset) and each cell has a small gap so neighbours aren't flush | `launchers.apple_terminal.{DOCK_RESERVE, EDGE_MARGIN, WINDOW_GAP, _get_usable_bounds, tile}` | +| 18. **Visible broadcast-state color** on Apple Terminal and iTerm2 — `set_color` now writes the tab/session's `background color` (16-bit RGB) on every toggle. Previously both backends were silent no-ops, so the user couldn't see ENABLED/DISABLED/DEAD changes | `launchers.apple_terminal.{_TAB_BG, set_color}`, `launchers.iterm2.{_SESSION_BG, set_color}` | +| 19. **Closing a slave's terminal block now actually ends the session** — attach client emits a new `BYE` control verb on SIGHUP/SIGTERM/SIGINT and on stdin EOF. The master flips `slave.user_closed`, SIGTERMs the ssh pid, the PTY-EOF chain fires `on_dead` exactly once, the status footer updates, the block repaints DEAD, and `--reconnect` honors the close (no respawn) | `slave.{Slave.user_closed, _handle_bye, _apply_control_line}`, `attach.{_send_bye, on_terminating_signal, main}`, `orchestrator._should_reconnect` | + +#### Master co-tiling — implementation notes + +- **Terminal.app** (`AppleTerminalLauncher`): each block opens in its + own Terminal window, so the master TUI's window was previously + excluded from `tile()`'s grid. Fix: `start(total)` runs + `tell application "Terminal" to return id of front window` and + stores the result in `self._master_window_id`. `tile()` prepends + that id to the cells list before computing the grid, so the master + ends up at cell 0 (top-left). If the capture fails (Finder denied, + AppleScript returns non-digit output), the master id is left empty + and `tile()` falls back to the v0.2.0 slaves-only layout. The + capture has to run in `start()`, not the constructor — by the time + the first `open_block` `activate`s Terminal, the front window has + shifted to the new slave. +- **iTerm2** (`ITerm2Launcher`): v0.2.0 created a new window for the + first block (`create window with default profile command "…"`), + parking the master TUI in a sibling window iTerm2's auto-tile + couldn't reach. Fix: every block — including the first — now uses + `split vertically with default profile command "…"` of + `current session of current window` (the master's session). iTerm2 + rebalances all panes on every split, so master + slaves shrink in + lockstep. The `_first` flag is gone. +- **tmux** ≤ `PANE_THRESHOLD` (4) hosts: master is one of the panes + in the active window; `select-layout tiled` already includes it. + No change needed. +- **tmux** > `PANE_THRESHOLD` hosts: master stays in its original + window; slaves get a dedicated `csshx` window so they don't get + squeezed into vertical ribbons. This is by design — adding the + master back into the slave window would defeat the threshold. +- **WezTerm**: every block is `wezterm cli spawn` from the active + pane (the master); WezTerm balances them automatically. No change + needed. +- **WaveTerm**: `wsh setlayout tiled` rearranges the whole tab + (master + every block opened with `wsh run`). No change needed. +- **Kitty**: slaves are tabs (`@ launch --type=tab`), the master is + in its own tab. Tabs are visually separate by design. No co-tiling. +- **Manual**: prints attach commands; the user arranges them in + whatever terminal they like. + +#### Terminal.app sizing — Dock reservation + per-cell gap + +`_get_desktop_bounds()` returns Finder's `bounds of window of desktop`, +which is the FULL screen rectangle — Finder does NOT subtract the +Dock. Tiling to that rectangle slides the bottom row of windows under +the Dock. Fix: `_get_usable_bounds()` shrinks the rectangle by: + +- `EDGE_MARGIN = 8` on every side (small inset so windows don't sit + flush against the menu bar or screen borders). +- `DOCK_RESERVE = 90` on the bottom (covers the default Dock size + + buffer). We don't query the actual Dock size because that requires + Accessibility permission via System Events and can prompt the user + the first time. + +`tile()` then divides the usable rectangle into a near-square +`rows × cols` grid and shrinks each cell by `WINDOW_GAP = 6` on the +right and bottom so adjacent windows have visible breathing room. The +math is in `csshx_latest/launchers/apple_terminal.py:_get_usable_bounds` +and `:tile`. The constants are deliberately module-level so a future +"too cramped on a 4K display" tweak is a one-line change. + +#### User-closed slave — the `BYE` control verb + +ssh runs in the master's PTY, NOT inside the visible terminal block. +That decoupling is what lets attach clients reconnect, but it has a +nasty corollary: closing a slave's Terminal.app window / iTerm2 pane +/ tmux pane just kills the attach client; ssh keeps running until +something else ends it. The user sees the visible block disappear but +the status footer keeps reporting the slave as "alive" — stale and +confusing. + +Fix (post-v0.2.0): a new `BYE` control verb. The wiring lives in +three files: + +- `csshx_latest/attach.py:_send_bye` writes `BYE\n` on the control + socket. It's invoked from (a) signal handlers for `SIGHUP` / + `SIGTERM` / `SIGINT` (Terminal.app, iTerm2, systemd / launchctl, + Ctrl-C), (b) the stdin-EOF branch (tmux `kill-pane`, Kitty tab + close), and (c) the `KeyboardInterrupt` fall-through. A `bye_sent` + flag keeps it idempotent so the master never sees more than one + `BYE` from a single client. `_send_bye` swallows `OSError` so it's + safe to call from a signal handler even if the control socket is + half-closed. + +- `csshx_latest/slave.py:_handle_bye` is the master side. It sets + `slave.user_closed = True` and sends `SIGTERM` to `slave.pid`. We + deliberately do NOT call `on_dead` directly — instead we let ssh + exit, the PTY return EOF, and the existing `pty_to_sockets` finally + block fire `on_dead` exactly once. That keeps a single path for + every kind of slave death (natural ssh exit, network drop, BYE). + Idempotent: a second BYE is a no-op. + +- `csshx_latest/orchestrator.py:_should_reconnect` is the new gate. + Both conditions have to hold: `--reconnect` is on AND `user_closed + is False`. Without this guard, BYE would mark the slave dead, the + existing reconnect path would re-spawn ssh one backoff cycle later, + and the slave the user just closed would silently resurrect. + +Closing the master TUI's own window is unaffected — that's the +process running the TUI itself, not an attach client, so no `BYE` is +ever sent. + +#### Apple Terminal / iTerm2 color — `background color` of tab/session + +Apple Terminal does NOT expose a `color` attribute on tabs, but it +DOES expose `background color` (a 16-bit RGB triple). Same for +iTerm2 sessions. We write that property on every `set_color` call. + +The palette is deliberately low-saturation — earlier iterations used +full-strength `(0, 24576, 0)` green / `(24576, 0, 0)` red which were +visually fatiguing after a few minutes. Current values: + +- ENABLED → dim sage `(12288, 17408, 14336)` — faint cool green +- DISABLED → dim slate `(14336, 14336, 15360)` — barely-tinted neutral +- DEAD → dim mauve `(18432, 13312, 14336)` — faint warm red + +All three live in roughly the same lightness band so foreground text +contrast stays consistent across states. The palette lives at module +scope (`_TAB_BG` / `_SESSION_BG`) so retuning is a one-line change. +Tests pin the *contract* (distinct entries, valid 16-bit range, +present for every Color state) without pinning the specific hex values. The write is per- +tab/per-session and does NOT modify the user's saved profile. Both +implementations no-op silently when the captured id is missing +(degraded handle) and wrap the actual write in an AppleScript `try` +block so a stale id during shutdown can't break callers. + +### Test coverage delta + +| | Pre-v0.2.0 | v0.2.0 | Post-co-tiling | Post-sizing+color | Post-BYE | Post-palette | +| --- | --- | --- | --- | --- | --- | --- | +| Test files | 16 | 26 | 26 | 26 | 26 | 26 | +| Tests | 111 | 244 | 266 | 270 | 280 | 282 | +| Wall clock | 0.45s | ~3.7s | ~3.8s | ~3.8s | ~3.8s | ~3.8s | + +New test modules in v0.2.0: `test_config.py`, `test_orchestrator.py`, +`test_tui_focus_toggle.py`, `test_slave_control_socket.py`, +`test_integration_pty.py`, `test_waveterm_export_parser.py`, +`test_action.py`, `test_command_key.py`, `test_color_state.py`, +`test_status_footer.py`, `test_slave_max_writers.py`, +`test_launcher_conformance.py`. + +Post-co-tiling additions: 4 new tests in `test_launcher_apple_terminal.py` +covering `start()` capture, non-digit rejection, master placed at cell 0, +slaves-only fallback when capture fails, and single-master-fills-desktop +edge case. + +Post-sizing+color additions: existing Apple Terminal bounds-assertion +tests were updated to the new usable-rectangle math (cells now sit +inside `(EDGE_MARGIN, EDGE_MARGIN, screen_w - EDGE_MARGIN, screen_h - +DOCK_RESERVE - EDGE_MARGIN)` with a `WINDOW_GAP` shrink per cell). The +old "set_color is a silent no-op for every state" test was replaced +with `test_set_color_emits_background_color_per_state` (verifies the +matched window id and the per-state RGB triple appear in the +AppleScript body) plus `test_set_color_is_noop_without_window_id` +(verifies the safety fallback). A new `test_usable_bounds_subtracts_ +dock_and_edge_margins` pins the helper directly. iTerm2 gained +`test_set_color_writes_session_background_per_state` and +`test_set_color_is_noop_without_session_id` for the parallel change. + +--- + +## PENDING + +All v0.2.0 priority items 1–15 above are now DONE, plus the post-v0.2.0 +master co-tiling, Dock-aware sizing, per-tab/session color hooks, and +user-closed-block → BYE → graceful slave shutdown. Nothing blocking +daily use. Next pass ideas: + +- **Adopt 3.11+ `TaskGroup`** behind a version check to parallelize + the ~50ms-per-host launcher round-trips during startup. +- **Switch WaveTerm `wsh token` parsing to a JSON variant** if/when + WaveTerm exposes one, retiring the `shlex.split` of bash output. +- **Persist the broadcast-toggle state across runs** so a habitual + "Ctrl-T b once" user starts with everyone OFF. +- **Per-block scrollback divider** on reconnect so users can see where + the new ssh session began. +- **Optional dedicated master strip** for Terminal.app — instead of + giving the master one cell of the grid, reserve a bottom strip for + it (matching the original Perl csshX layout). Today the master gets + equal real estate at cell 0. + +--- + +## Architecture notes (for future spelunkers) + +### Two sockets per slave + +Each slave exposes two AUTH-gated UNIX sockets: + +- `slave-N.sock` (data): bidirectional bytes. PTY output fans out; + client keystrokes flow in. Per-slave scrollback (64 KiB cap, trimmed + on newline boundaries) replays to every newly authenticated client. +- `slave-N.ctl` (control): line-oriented. Today only `WINSZ rows cols + [xpixel ypixel]`. Future: `BELL`, `FOCUS`, etc. Unknown verbs are + silently dropped so older attach clients survive newer slaves. + +The stdlib attach client (`csshx_latest.attach`) opens both; +socat-based attach is no longer supported because it can't multiplex +two sockets cleanly. + +### Tokens never appear in argv + +`make_token()` returns a fresh 32-byte hex string per slave per run. +`write_token_file` creates the file under `O_CREAT | O_WRONLY | O_TRUNC` +with mode `0600`, then re-chmods (in case the file pre-existed). The +spawned attach process gets the token's *file path* on argv, never the +token itself, so `ps` listings from other UIDs can't harvest it. +`authenticate()` uses `secrets.compare_digest` for the comparison and +caps the handshake at `HANDSHAKE_TIMEOUT = 2.0` seconds. + +### Why an `asyncio.Lock` per slave + +The master broadcaster AND the focused block can both write into the +same PTY simultaneously. Without a per-slave `write_lock`, an ANSI +escape sequence from the broadcaster could interleave with a keystroke +from the block, producing garbage on the remote shell. The lock makes +each write atomic at the PTY level. + +### Why a `state_lock` separately + +The PTY reader task does *three* things every iteration: extend +scrollback, snapshot the current writers, and queue the chunk to each. +The `state_lock` makes those atomic with respect to a new client +authenticating and joining the writer list, so we can never duplicate +or drop a chunk. It also guards the `max_writers` cap on the data +socket so a leaked token can't accumulate attaches faster than the +reader notices. + +### Reconnect path + +`on_dead` callback fires from inside the PTY reader task when ssh +exits. With `--reconnect`, it schedules `_attempt_reconnect` on the +loop via `run_coroutine_threadsafe`. That coroutine: + +1. Retitles the block ` [reconnecting]` and repaints DEAD. +2. Sleeps for the next backoff value. +3. Re-probes tcp/22. +4. Re-spawns `ssh` with the SAME token and socket paths. +5. Rebinds `slave.pty_master` and `slave.pid`, clears `dead`. +6. Re-runs the bridge. +7. On success: restores the original title and paints ENABLED / + DISABLED according to `_color_for(slave)`. + +The visible block keeps its socket connection — it never noticed the +underlying ssh died. The token file is re-written to the same path +(in case the file unlink was racy). + +### Tile after every spawn + +The orchestrator calls `launcher.tile(handles)` after every +`open_block`, not just once at the end. Without this, tmux's +default split halves the most-recent pane, producing visibly lopsided +layouts during launch. Backends with auto-tiling (iTerm2, WezTerm) +expose `tile` as a no-op; the orchestrator still calls it for the +same reason a no-op is cheap and the contract stays uniform. + +### Terminal-mode resets + +`terminal.reset_terminal_modes` emits a soft DECSTR (`\e[!p`) plus +explicit per-mode disables (bracketed paste, application keypad, +mouse tracking, focus reporting, modifyOtherKeys, …) before +`raw_mode` engages and again on exit. This is *essential* on +xterm.js-based terminals (WaveTerm, VSCode) where p10k's instant +prompt otherwise leaves modifyOtherKeys enabled and every keystroke +becomes `\e[27;;~` — broadcast as-is, the remote shell +sees garbage. Apple Terminal is more permissive, which is why the +breakage was WaveTerm-specific. + +### Async launcher dispatch + +Concrete launchers are synchronous — they `subprocess.run` an +`osascript` / `wsh` / `tmux` / `kitty @` / `wezterm cli` and block +until it returns. Calling them straight from the event loop freezes +the TUI for the duration of every block-open (e.g. ~200 ms per host +on macOS osascript calls). `_open_block` / `_close_block` / `_tile` +/ `_set_color` / `_set_title` all run their target through +`asyncio.to_thread` so the loop stays responsive. + +### Color taxonomy + +`launcher.Color` is the three-state enum (`ENABLED`, `DISABLED`, +`DEAD`) every launcher's `set_color` paints. The orchestrator's +`_color_for(slave)` is the single source of truth for the +slave-state → color mapping; broadcaster `on_state_change` and +`on_dead` both push the result through `launcher.set_color` so toggle +feedback is instant. Launchers without a native paint API (Apple +Terminal, WezTerm) silently no-op. + +--- + +## Common tasks + +### Add a new terminal backend + +1. New file under `csshx_latest/launchers/your_backend.py`. +2. Implement: `name`, `start(total)`, `open_block(cmd, title)`, + `close_block(handle)`, `tile(handles)`, `set_title(handle, title)`, + `set_color(handle, color)`. A no-op `set_color` is fine if the + backend has no native paint API. +3. Register in `launcher._LAUNCHERS`. The CLI choice list updates + automatically. +4. Add auto-detect rule in `launcher.detect_launcher` if your backend + sets a recognizable env var. +5. **Decide how the master tiles with slaves** (see "Master co-tiling + — implementation notes" above). If the backend uses split panes + from the current pane, you get co-tiling for free. If it uses + separate windows, capture the master window/pane id in `start()` + and include it in `tile()`. +6. Tests: copy any existing `tests/test_launcher_*.py` and adapt. + The conformance harness will exercise your backend automatically. + +### Add a new control-socket command + +1. Extend the grammar comment in `slave._apply_control_line`. +2. Parse the new command in that function; ignore unknown commands + silently so older attach clients don't break newer slaves. +3. If the command needs to flow from the attach client, add a tiny + sender in `attach.py` (see `_push_winsize` for a template). + +### Add a new TUI command-mode key + +1. Add the dispatch in `tui._handle_command_byte`. +2. If your new command needs follow-up input (like the `i` index + prompt), extend `_CommandState` and add a per-byte handler. +3. Update the help in `_render_help`. +4. Add a test in `tests/test_tui_focus_toggle.py` (or a sibling file). + +### Make `--command-key` accept a new syntax + +`tui.parse_command_key` is the only parser. Accepted forms today: + +- `^X` / `^x` for Ctrl-X (A–Z only) +- `0x14` hex byte (0–255) +- a single printable character + +Add the new form there, then extend `tests/test_command_key.py`. + +--- + +## What this project will NEVER do + +- **Run on Windows.** `pty`, `termios`, `tty`, `fcntl` are Unix-only. + Windows users use WSL. +- **Re-introduce TIOCSTI.** It's deprecated, removed in newer kernels, + and a known privilege-escalation vector. +- **Auto-spawn a multiplexer.** `detect_launcher` falls back to + `manual` (which just prints attach commands) rather than starting + tmux/screen behind your back. +- **Embed the AUTH token in argv.** Always read from a `0600` file + inside a `0700` directory so `ps` can't leak it. +- **Cache or persist credentials.** Tokens are per-run, generated + fresh, never written outside the run's socket dir. +- **Block on a stuck ssh during shutdown.** SIGTERM → 2 s poll → + SIGKILL is the bounded path; `os.waitpid(pid, 0)` is forbidden. diff --git a/csshx-latest/README.md b/csshx-latest/README.md new file mode 100644 index 0000000..d4786cb --- /dev/null +++ b/csshx-latest/README.md @@ -0,0 +1,310 @@ +# csshx-latest + +A modern, terminal-agnostic cluster-SSH tool — a spiritual successor to +[csshX](https://github.com/brockgr/csshx) built on real PTYs and a +pluggable launcher layer instead of the old TIOCSTI keystroke-injection +hack. + +Author: Aditya Kapadia. + +## What it is + +- **N terminal blocks** — one per SSH host. Click a block and type to + send keystrokes to just that host. +- **1 master TUI** — runs in your current terminal. Every keystroke is + broadcast to every enabled slave at once. +- **Master + slaves are tiled together** on backends that can address + the master window (Terminal.app, iTerm2, tmux ≤ 4 hosts, WezTerm) — + every spawn rearranges all of them in lockstep, just like the + original Perl csshX did. +- **PTY end-to-end** — no TIOCSTI, works on modern macOS/Linux. +- **Pluggable backends** — WaveTerm, tmux, iTerm2, Terminal.app, Kitty, + WezTerm, plus a `manual` fallback that works in any terminal by + printing attach commands for you to paste. +- **Auto-detect** which terminal you're in. Falls back to manual if it + doesn't recognize the environment. +- **One-shot action mode** — `--action 'uname -a' host1 host2 …` fans + the command out concurrently, prints a per-host summary, exits. + No TUI, no launcher; equivalent to the original csshX's + `--remote_command`. +- **Stdlib-only Python 3.9+** — zero hard runtime dependencies. + +## Install + +```bash +cd csshx-latest/ +uv venv && uv pip install -e '.[test]' +``` + +Python 3.9 is the floor (`asyncio.to_thread` is required). Tested on +3.9 – 3.13. + +## Usage + +```bash +csshx-latest web01 web02 web03 +csshx-latest --launcher tmux web0{1..5} +csshx-latest --login deploy --ssh-args "-i ~/.ssh/cluster_key" host1 host2 +csshx-latest --launcher manual host1 host2 # prints attach commands +csshx-latest --reconnect --strict web0{1..10} # safer mode +csshx-latest production-cluster # uses ~/.csshrc alias +csshx-latest --action 'uptime' web0{1..3} # one-shot fan-out +``` + +### CLI flags + +| Flag | Default | What it does | +| --- | --- | --- | +| `--launcher` | `auto` | Pick a backend: `auto`, `waveterm`, `tmux`, `iterm2`, `terminal`, `kitty`, `wezterm`, `manual`. | +| `--login` | (ssh default) | Username, forwarded as `-l`. | +| `--ssh-args` | `""` | Extra arguments forwarded to ssh (single quoted string). | +| `--max-hosts` | `16` | Refuse to start above this many hosts. Saves you from typo fork-bombs. | +| `--strict` | off | Abort if any host fails the tcp/22 preflight (default: warn and skip). | +| `--no-preflight` | off | Skip the tcp/22 reachability check entirely. | +| `--reconnect` | off | Re-spawn ssh with exponential backoff (1s, 2s, 4s, 8s, 16s) on slave death. | +| `--action CMD` | (interactive) | One-shot mode: run `CMD` via ssh on every host concurrently, print a per-host summary, exit. | +| `--action-timeout` | `60.0` | Per-host ssh timeout in `--action` mode. | +| `--command-key` | `^T` | Master TUI command-mode prefix. Accepts `^X`, `0x14`, or a single literal byte. | +| `--debug` | off | Verbose logging to stderr. | +| `--version` | — | Print version and exit. | + +## Host expansion + +Three layers, applied in this order to each CLI argument: + +1. **Cluster alias** — replaced with the alias's host list (recursive, + cycle-safe). +2. **Brace expansion** — bash-style: + - numeric: `web0{1..5}` → `web01 web02 web03 web04 web05` + (width preserved from the lower bound) + - alphabetic: `host-{a..c}` → `host-a host-b host-c` + - alternation: `api-{a,b,c}` → `api-a api-b api-c` + - nested / combined: `{prod,stage}-web{1..2}` → 4 hosts +3. **TCP/22 preflight** (unless `--no-preflight`) — unreachable hosts + are warned & dropped (or abort the run with `--strict`). + +## Cluster aliases + +Two config sources, first-match wins: + +1. `~/.config/csshx-latest/config.toml` (preferred; respects + `$XDG_CONFIG_HOME`): + + ```toml + [clusters] + web = ["web01", "web02", "web03"] + db = "db1 db2" + production = ["web", "db"] # clusters can reference clusters + ``` + +2. `~/.csshrc` (original csshX format): + + ``` + cluster web = web01 web02 web03 + cluster db = db1 db2 + cluster production = web db + ``` + +Any token on the command line that matches a cluster name is expanded +recursively before brace expansion runs. + +## Master TUI keys + +The command-mode prefix is `Ctrl-T` by default and can be changed with +`--command-key` (e.g. `--command-key ^A`). + +| Key | Action | +| --- | --- | +| (any byte) | Broadcast to every enabled slave | +| `Ctrl-Q` | Quit | +| `Ctrl-T` then `b` | Toggle broadcast for ALL alive slaves | +| `Ctrl-T` then `1..9` | Toggle broadcast for that specific slave | +| `Ctrl-T` then `i`, digits, Enter | Toggle slave by index (for 10+ hosts) | +| `Ctrl-T` then `l` | List slaves and their state | +| `Ctrl-T` then `q` | Quit | +| `Ctrl-T` then `?` | Help | +| `Ctrl-T` then `Ctrl-T` | Send a literal `Ctrl-T` to slaves | +| `Ctrl-T` then any unbound printable | Cancel command mode AND broadcast that letter (so a typo never silently vanishes) | +| `Ctrl-T` then any unbound control byte (Esc, Ctrl-C, …) | Cancel command mode silently | + +SIGINT / SIGTERM / SIGHUP also shut down cleanly. SIGWINCH on the +master propagates the new window size to every slave PTY via +TIOCSWINSZ; each individual terminal block also reports its own +resizes back through its dedicated control socket. + +A one-line status footer is printed to stderr and updated on every +toggle: + +``` +[csshx-latest] hosts: 4 enabled: 3 dead: 1 (Ctrl-Q quit, Ctrl-T menu) +``` + +When stderr is a TTY the `enabled` / `dead` counters are colorized +(green / red / dim) so a broken host is visible at a glance. + +## Architecture + +``` + master process + ---------------------------------------------------------------- + raw stdin --> Broadcaster --> Slave[1] PTY --> ssh host1 + Slave[2] PTY --> ssh host2 + Slave[N] PTY --> ssh hostN + + per slave: + PTY master fd + data socket (0600, AUTH-gated) -- bidirectional bytes + control socket(0600, AUTH-gated) -- WINSZ ... + per-fd write_lock -- escape sequences stay whole + per-slave scrollback (64 KiB) -- replayed to new attach clients + on_dead callback -- drives --reconnect / repaint + + per launcher: + BlockHandle (backend, data{...}) + start(total) / open_block / close_block / tile / set_title / + set_color +``` + +Output flows one way (PTY → data socket → terminal block). Input +arrives from two writers: the master broadcaster *and* whichever +terminal block is focused. A per-slave `asyncio.Lock` (`write_lock`) +serializes PTY writes so an escape sequence can never get torn between +them. A separate `state_lock` keeps the PTY reader's +extend-scrollback-then-fan-out cycle atomic against a new attach +client joining the writer list. + +Each socket is gated by a 32-byte hex token; clients have 2 seconds +to send `AUTH \n` or they're dropped. Sockets live in +`$XDG_RUNTIME_DIR/csshx-/` (or `/tmp/csshx-/` on macOS), +with the directory at mode `0700` and each socket at `0600`. Tokens +are read from `0600` files inside the run dir — never embedded in +argv, so `ps` listings can't leak them. + +### Two sockets per slave + +Each slave exposes two AUTH-gated UNIX sockets: + +- `slave-N.sock` (data): bidirectional bytes. PTY output fans out; + client keystrokes flow in. Per-slave scrollback (64 KiB) is + replayed to each new client after AUTH succeeds. +- `slave-N.ctl` (control): line-oriented ASCII. Supported verbs: + - `WINSZ [ ]` — sent on every local + SIGWINCH so the individual block can resize the remote PTY + independently of the master. + - `BYE` — sent by the attach client when its visible terminal block + is destroyed (SIGHUP from the terminal emulator, or stdin EOF from + a pane kill). The master flips `slave.user_closed`, sends + `SIGTERM` to that slave's ssh pid, and the existing PTY-EOF path + repaints the block DEAD and updates the status footer. + `--reconnect` honors `user_closed` and does NOT respawn — a slave + the user explicitly closed stays closed. + + Unknown verbs are silently ignored so older attach clients don't + break newer slaves. + +The bundled stdlib attach client (`python3 -m csshx_latest.attach`) +multiplexes both sockets. socat-based attach is no longer supported +because it can't handle the dual-socket protocol. + +### Reconnect + +`--reconnect` schedules an exponential-backoff retry (1, 2, 4, 8, 16 +seconds; max 5 attempts) whenever a slave's ssh exits. The block's +title is retitled ` [reconnecting]` and painted with the DEAD +color during retries; on success the block keeps its socket +connection and the title/color are restored — the slave's terminal +block never noticed the underlying ssh died. + +## Safety defaults + +- **TCP-22 preflight**: every host gets a 1-second TCP probe before + ssh forks. Unreachable hosts are warned & skipped (or aborted with + `--strict`). No more screens full of timed-out panes when your VPN + is down. Probes run concurrently. Disable with `--no-preflight`. +- **`StrictHostKeyChecking=accept-new`** is injected unless your + `--ssh-args` already specifies a value. First-connect prompts no + longer fan out across every broadcast slave. +- **`--max-hosts 16`** hard cap. Raise explicitly if you really need + more. +- **Bounded reap**: on shutdown we send SIGTERM, poll-wait up to 2s, + then SIGKILL. The master can never hang on a stuck ssh. +- **`max_writers` per slave** caps simultaneous authenticated data + clients (default 4). A leaked token can't be used to attach + indefinitely. + +## Backend support matrix + +| Backend | Open | Close | Tile | Title | Color | Master tiled with slaves? | Platform | +| --- | --- | --- | --- | --- | --- | --- | --- | +| WaveTerm | yes (`wsh run`) | yes (`wsh deleteblock`) | yes (probes `setlayout` / `layout` / `tile`) | yes (`wsh settitle`) | yes (`wsh setbg`, lazy-probed) | yes (`wsh setlayout tiled` rearranges the whole tab) | mac / linux / win | +| tmux | yes (`split-window`) | yes (`kill-pane`) | yes (`select-layout tiled`) | yes (`select-pane -T`) | yes (`select-pane -P bg=…`) | yes when hosts ≤ `PANE_THRESHOLD=4` (master is part of the same window); no when > 4 (master stays in original window, slaves get a dedicated `csshx` window so they aren't squeezed into ribbons) | anywhere with tmux | +| iTerm2 | yes (split current session) | yes (by session id) | auto-balanced | yes (by session id) | yes (`set background color of session`, 16-bit RGB) | yes — every block splits the master's session so iTerm2 auto-balances master + slaves on every spawn | macOS | +| Terminal.app | yes (per-block window) | yes (by window id) | grid via `set bounds`, inside a usable area that excludes the Dock + screen edges with a small gap between cells | yes (by tty id) | yes (`set background color of tab 1`, 16-bit RGB) | yes — `start()` captures the front window id and `tile()` includes it as cell 0 of the grid | macOS | +| Kitty | yes (`@ launch --type=tab`) | yes (`@ close-window` by id) | yes (`@ goto-layout grid`) | yes (`@ set-window-title`) | yes (`@ set-tab-color`, kitty ≥ 0.20) | no — slaves get their own tabs; the master TUI's tab is independent | mac / linux | +| WezTerm | yes (`cli spawn`) | yes (`cli kill-pane`) | auto-balanced | yes (`cli set-tab-title`) | no | yes — splits from the master's pane so WezTerm balances them together | mac / linux / win | +| Manual | print only | n/a | n/a | n/a | n/a | n/a | anywhere | + +Notes: + +- **Kitty** requires `allow_remote_control yes` in `kitty.conf`. The + launcher raises on construction if the `kitty` CLI isn't on PATH. +- **WaveTerm** widgets configured with `controller: cmd` only get a + `WAVETERM_SWAPTOKEN` in their env — the launcher swaps it to + `WAVETERM_JWT` via `wsh token` so `wsh run` / `wsh layout` / `wsh + deleteblock` / `wsh settitle` actually authenticate. The token-swap + output is parsed with `shlex.split` so future quoting changes don't + silently break the swap. The launcher also resolves `wsh` from + WaveTerm's known install locations if it isn't on PATH. +- **WaveTerm** tiling tries `wsh setlayout tiled`, then `wsh layout + tiled`, then `wsh tile` — the first one that exits 0 is cached so + the launcher degrades quietly if the wsh CLI grammar drifts. +- **Color hooks** (`set_color`) push ENABLED → dim sage, DISABLED → + dim slate, DEAD → dim mauve on every toggle. Backends without a + native paint API silently no-op. On Terminal.app and iTerm2 the tint + is written to the tab/session's ``background color`` (16-bit RGB) + and stays scoped to that block — it does not modify the user's + saved profile. The palette is intentionally low-saturation so a wall + of slave windows isn't fatiguing to look at; retune by editing + ``_TAB_BG`` (`launchers/apple_terminal.py`) or ``_SESSION_BG` + (`launchers/iterm2.py`). +- **Terminal.app tiling** reserves space for the Dock and inserts a + small gap between cells (`DOCK_RESERVE`, `EDGE_MARGIN`, `WINDOW_GAP` + in `csshx_latest/launchers/apple_terminal.py`) so windows never + slide under the Dock or sit flush against neighbours / screen edges. +- The orchestrator calls `launcher.tile()` after every spawn so panes + stay balanced as blocks are added — not just once at the end. + +## What's different from the original csshX + +| | csshX (Perl) | csshx-latest | +| --- | --- | --- | +| Keystroke delivery | TIOCSTI (deprecated / removed on modern systems) | Real PTYs | +| Terminal coupling | Hard-coded Terminal.app + iTerm | Pluggable Launcher protocol | +| Detection | macOS-only | macOS, Linux, WSL | +| Auth | None | 32-byte token per socket, constant-time compare, file-based (token never in argv) | +| Per-slave typing | Hidden window per slave | Authenticated, bidirectional UNIX socket | +| Per-slave focus toggle | Action menu | `Ctrl-T ` (or `Ctrl-T i` for 10+) | +| Connectivity preflight | Optional ping | Built-in concurrent tcp/22 probe | +| Reconnect | none | `--reconnect` with exponential backoff | +| Per-block SIGWINCH | n/a | Dedicated control socket per slave | +| Config | `~/.csshrc` only | TOML preferred, `~/.csshrc` fallback | +| One-shot fan-out | `--remote_command` | `--action` (same semantics, with per-host timeout) | + +## Run the tests + +```bash +uv run pytest -q +``` + +280+ tests cover the broadcaster, the AUTH handshake + token-file +round-trip, the launcher auto-detect matrix, every concrete launcher +(subprocess mocked), launcher conformance against the Protocol +(structural shape, signature arity, every `Color` state), the TUI +command mode (including per-slave focus toggle, configurable command +key, status footer), the orchestrator's preflight / kill-and-reap / +max-hosts cap, the slave control socket's `WINSZ` grammar with a real +PTY pair, action-mode fan-out + summary rendering, and an end-to-end +real-PTY integration test that uses `cat` as a fake ssh. + +The package itself can't run on Windows — `pty`, `termios`, `tty`, +and `fcntl` are Unix-only. Windows users should use WSL. diff --git a/csshx-latest/csshx_latest/__init__.py b/csshx-latest/csshx_latest/__init__.py new file mode 100644 index 0000000..80af851 --- /dev/null +++ b/csshx-latest/csshx_latest/__init__.py @@ -0,0 +1,3 @@ +"""csshx-latest: modern, terminal-agnostic cluster-SSH.""" + +__version__ = "0.2.0" diff --git a/csshx-latest/csshx_latest/__main__.py b/csshx-latest/csshx_latest/__main__.py new file mode 100644 index 0000000..56884b7 --- /dev/null +++ b/csshx-latest/csshx_latest/__main__.py @@ -0,0 +1,169 @@ +"""Command-line entry point for ``csshx-latest``. + +Author: Aditya Kapadia. + +Argument-parsing only -- the real work happens in +:func:`csshx_latest.orchestrator.run_master`. +""" +from __future__ import annotations + +import argparse +import asyncio +import sys +from importlib import metadata +from typing import Optional + +from csshx_latest.action import DEFAULT_TIMEOUT as ACTION_TIMEOUT, run_action +from csshx_latest.config import load_clusters +from csshx_latest.hosts import expand_hosts +from csshx_latest.launcher import available_launcher_names, detect_launcher +from csshx_latest.logging_setup import configure_logging +from csshx_latest.orchestrator import DEFAULT_MAX_HOSTS, run_master +from csshx_latest.tui import parse_command_key + + +def _version() -> str: + """Look up the installed package version; ``unknown`` if not installed.""" + try: + return metadata.version("csshx-latest") + except metadata.PackageNotFoundError: + return "unknown" + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="csshx-latest", + description="Modern, terminal-agnostic cluster-SSH (csshX rewrite).", + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}") + parser.add_argument( + "hosts", + nargs="+", + help=( + "Hosts to ssh to. Supports brace expansion: 'web0{1..5}', " + "'host-{a..c}', and 'api-{a,b,c}'. Cluster names from " + "~/.config/csshx-latest/config.toml or ~/.csshrc are expanded too." + ), + ) + parser.add_argument( + "--ssh-args", + default="", + help="Extra arguments forwarded to ssh, as a single quoted string.", + ) + parser.add_argument("--login", default=None, help="Username (passed to ssh -l).") + parser.add_argument( + "--launcher", + default="auto", + choices=available_launcher_names(), + help="Terminal backend (default: auto-detect).", + ) + parser.add_argument( + "--max-hosts", + type=int, + default=DEFAULT_MAX_HOSTS, + help=f"Refuse to start above this many hosts (default: {DEFAULT_MAX_HOSTS}).", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Abort if any host fails the tcp/22 preflight (default: skip them).", + ) + parser.add_argument( + "--no-preflight", + action="store_true", + help="Skip the tcp/22 reachability check entirely.", + ) + parser.add_argument( + "--reconnect", + action="store_true", + help="Re-spawn ssh with exponential backoff when a slave's connection drops.", + ) + parser.add_argument( + "--action", + default=None, + help=( + "One-shot mode: run the given command via ssh on every host " + "concurrently, print a per-host summary, and exit (no TUI). " + "Equivalent to csshX's --remote_command." + ), + ) + parser.add_argument( + "--action-timeout", + type=float, + default=ACTION_TIMEOUT, + help=f"Per-host ssh timeout in --action mode (default: {ACTION_TIMEOUT}s).", + ) + parser.add_argument( + "--command-key", + default="^T", + help=( + "Master command-mode prefix. Accepts ^X (Ctrl-X), ^A, ... " + "or a raw byte like 0x14. Default: ^T." + ), + ) + parser.add_argument( + "--debug", + action="store_true", + help="Verbose logging to stderr.", + ) + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + """Parse args and run the master event loop. Returns the exit code.""" + import shlex as _shlex + + parser = _build_parser() + args = parser.parse_args(argv) + + configure_logging(args.debug) + + clusters = load_clusters() + expanded = expand_hosts(args.hosts, clusters=clusters) + if not expanded: + parser.error("no hosts after brace / cluster expansion") + + ssh_extra = _shlex.split(args.ssh_args) if args.ssh_args else [] + + if args.action: + # One-shot mode bypasses the TUI / launcher entirely. + try: + return asyncio.run( + run_action( + expanded, + ssh_extra, + args.login, + args.action, + timeout=args.action_timeout, + ) + ) + except KeyboardInterrupt: + return 130 + + try: + command_key = parse_command_key(args.command_key) + except ValueError as exc: + parser.error(f"invalid --command-key: {exc}") + + launcher = detect_launcher(args.launcher) + + try: + return asyncio.run( + run_master( + expanded, + ssh_extra, + args.login, + launcher, + max_hosts=args.max_hosts, + strict_preflight=args.strict, + reconnect=args.reconnect, + skip_preflight=args.no_preflight, + command_key=command_key, + ) + ) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/csshx-latest/csshx_latest/action.py b/csshx-latest/csshx_latest/action.py new file mode 100644 index 0000000..ed405e8 --- /dev/null +++ b/csshx-latest/csshx_latest/action.py @@ -0,0 +1,137 @@ +"""One-shot action mode: broadcast a command, collect per-host output, exit. + +Author: Aditya Kapadia. + +Equivalent to the original Perl csshX's ``--remote_command`` option, +adapted for scripted use: + + csshx-latest --action 'uname -a' web0{1..3} + +prints a per-host summary table on stdout and exits. There is no TUI, +no PTY, no Launcher — each host gets its own ``ssh `` +subprocess, all run concurrently. The exit code is the maximum +per-host return code (so a single non-zero remote command surfaces). + +Unlike interactive mode, we do *not* allocate a PTY; remote programs +that need one (vim, ncurses tools) won't behave correctly here. That's +intentional: action mode is for non-interactive ops scripts. +""" +from __future__ import annotations + +import asyncio +import logging +import shlex +import sys +from dataclasses import dataclass +from typing import Optional + +log = logging.getLogger(__name__) + +#: Per-host hard timeout. Avoids one stuck host stalling the whole run. +DEFAULT_TIMEOUT = 60.0 + + +@dataclass +class ActionResult: + """Per-host outcome of an action invocation.""" + + host: str + returncode: int + stdout: str + stderr: str + timed_out: bool = False + + +async def _run_one( + host: str, + ssh_args: list[str], + login: Optional[str], + command: str, + timeout: float, +) -> ActionResult: + """Run ``ssh ``; capture stdout/stderr/returncode.""" + argv = ["ssh", *ssh_args] + if login: + argv += ["-l", login] + # ``-o BatchMode=yes`` so a host that would otherwise prompt for a + # password fails fast instead of stalling the whole action run. + if not any("BatchMode" in a for a in ssh_args): + argv += ["-o", "BatchMode=yes"] + argv += [host, command] + + try: + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except OSError as exc: + return ActionResult(host=host, returncode=-1, stdout="", stderr=f"spawn failed: {exc}") + + try: + stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + try: + await proc.wait() + except Exception: # pragma: no cover - defensive + pass + return ActionResult(host=host, returncode=-1, stdout="", stderr="timeout", timed_out=True) + + return ActionResult( + host=host, + returncode=proc.returncode if proc.returncode is not None else -1, + stdout=stdout_b.decode("utf-8", errors="replace"), + stderr=stderr_b.decode("utf-8", errors="replace"), + ) + + +async def run_action( + hosts: list[str], + ssh_args: list[str], + login: Optional[str], + command: str, + *, + timeout: float = DEFAULT_TIMEOUT, +) -> int: + """Broadcast ``command`` over ssh to every host concurrently. + + Returns the max per-host return code (0 iff every host succeeded). + Prints a header + per-host block + a final summary table to stdout. + """ + if not hosts: + sys.stderr.write("no hosts\n") + return 2 + if not command.strip(): + sys.stderr.write("empty --action command\n") + return 2 + + results = await asyncio.gather( + *(_run_one(h, ssh_args, login, command, timeout) for h in hosts) + ) + _print_report(command, results) + worst = max((r.returncode for r in results), default=0) + # asyncio sometimes returns negative codes; clamp to 1 so the + # process-exit value stays a meaningful shell-style number. + return 0 if worst == 0 else (worst if worst > 0 else 1) + + +def _print_report(command: str, results: list[ActionResult]) -> None: + """Render the per-host body + a compact final summary.""" + sys.stdout.write(f"# csshx-latest --action {shlex.quote(command)}\n") + sys.stdout.write(f"# {len(results)} host(s)\n\n") + for r in results: + sys.stdout.write(f"--- {r.host} (rc={r.returncode}{' TIMEOUT' if r.timed_out else ''})\n") + if r.stdout: + sys.stdout.write(r.stdout if r.stdout.endswith("\n") else r.stdout + "\n") + if r.stderr: + for line in r.stderr.splitlines(): + sys.stdout.write(f" [stderr] {line}\n") + sys.stdout.write("\n") + ok = sum(1 for r in results if r.returncode == 0) + failed = len(results) - ok + sys.stdout.write(f"# summary: {ok} ok, {failed} failed\n") + sys.stdout.flush() + + +__all__ = ["ActionResult", "DEFAULT_TIMEOUT", "run_action"] diff --git a/csshx-latest/csshx_latest/attach.py b/csshx-latest/csshx_latest/attach.py new file mode 100644 index 0000000..2158719 --- /dev/null +++ b/csshx-latest/csshx_latest/attach.py @@ -0,0 +1,300 @@ +"""Stdlib attach client used by every spawned terminal block. + +Author: Aditya Kapadia. + +Connects to a slave's two UNIX sockets (data + control), performs the +AUTH handshake on each, then shuttles bytes between the user's TTY +and the data socket. SIGWINCH on the local TTY pushes +``WINSZ rows cols xpixel ypixel`` lines onto the control socket so +the slave can resize its PTY and the remote ssh side learns the new +geometry. + +Run as a module so spawned terminal blocks can launch it without any +extra dependency:: + + python3 -m csshx_latest.attach + +The control socket path is derived from the data socket by replacing +the trailing ``.sock`` with ``.ctl``. The master always creates the +two together so this is reliable. + +The token is read at runtime from ```` rather than passed +on the command line so that ``ps`` listings can't be used by another +local user to harvest the AUTH token. The token file is created by +the master at mode ``0600`` inside a ``0700`` directory. + +Closing the visible terminal block +---------------------------------- + +When the user closes the spawned terminal window/pane/tab, this +process either receives ``SIGHUP``/``SIGTERM``/``SIGINT`` from the +terminal emulator (Apple Terminal, iTerm2) or reads ``EOF`` on its +controlling TTY (tmux pane kill, Kitty tab close). In every case we +send a best-effort ``BYE\\n`` line on the control socket before +exiting so the master can mark the slave dead and update its status +footer. Without this, ssh keeps running attached to the master's PTY +and the user sees a stale "alive" count for a host they thought they +closed. +""" +from __future__ import annotations + +import errno +import io +import os +import select +import signal +import socket +import struct +import sys +from typing import Optional + +BUFSIZE = 4096 + + +def _read_token(token_path: str) -> str: + """Read the token from disk and strip surrounding whitespace.""" + with open(token_path, "r", encoding="ascii") as fh: + return fh.read().strip() + + +def _ctl_path_for(data_path: str) -> str: + """Derive the control socket path from the data socket path.""" + if data_path.endswith(".sock"): + return data_path[: -len(".sock")] + ".ctl" + return data_path + ".ctl" + + +def _resolve_io_fds() -> tuple[int, int, bool]: + """Return ``(in_fd, out_fd, owns_fds)`` for shuttling bytes.""" + def _stdin_fd() -> int: + try: + return sys.stdin.fileno() + except (AttributeError, io.UnsupportedOperation, OSError, ValueError): + raise + + def _stdout_fd() -> int: + try: + return sys.stdout.fileno() + except (AttributeError, io.UnsupportedOperation, OSError, ValueError): + raise + + try: + return _stdin_fd(), _stdout_fd(), False + except Exception: + pass + + try: + in_fd = os.open("/dev/tty", os.O_RDONLY | os.O_NOCTTY) + out_fd = os.open("/dev/tty", os.O_WRONLY | os.O_NOCTTY) + return in_fd, out_fd, True + except OSError: + pass + + in_fd = os.open(os.devnull, os.O_RDONLY) + out_fd = os.open(os.devnull, os.O_WRONLY) + return in_fd, out_fd, True + + +def _connect_auth(path: str, token: str) -> socket.socket: + """Open a UNIX socket, send AUTH, return the connected socket.""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(path) + sock.sendall(f"AUTH {token}\n".encode("ascii")) + return sock + + +def _get_winsize(fd: int) -> tuple[int, int, int, int]: + """Read TIOCGWINSZ from ``fd``; fall back to (24, 80, 0, 0).""" + try: + import fcntl + import termios + except ImportError: # pragma: no cover - non-unix + return (24, 80, 0, 0) + try: + packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8) + return struct.unpack("HHHH", packed) + except OSError: + return (24, 80, 0, 0) + + +def _push_winsize(ctl_sock: socket.socket, in_fd: int) -> None: + """Send a WINSZ line for the current ``in_fd`` geometry.""" + rows, cols, xp, yp = _get_winsize(in_fd) + if rows <= 0 or cols <= 0: + return + try: + ctl_sock.sendall(f"WINSZ {rows} {cols} {xp} {yp}\n".encode("ascii")) + except OSError: + pass + + +def _send_bye(ctl_sock: Optional[socket.socket]) -> None: + """Best-effort ``BYE`` on the control socket. + + Safe to call from a signal handler: ``sendall`` of a ~4 byte line + on a UNIX domain socket is far below ``PIPE_BUF``, atomic, and + non-blocking enough to never re-enter the runtime. Idempotent at + the master side (``_handle_bye`` no-ops the second time). + """ + if ctl_sock is None: + return + try: + ctl_sock.sendall(b"BYE\n") + except OSError: + pass + + +def main(argv: list[str]) -> int: + """Entry point. Returns the process exit code.""" + if len(argv) != 3: + sys.stderr.write( + "usage: python3 -m csshx_latest.attach \n" + ) + return 2 + path, token_path = argv[1], argv[2] + + try: + token = _read_token(token_path) + except OSError as exc: + sys.stderr.write(f"read token {token_path}: {exc}\n") + return 1 + + try: + data_sock = _connect_auth(path, token) + except OSError as exc: + sys.stderr.write(f"connect {path}: {exc}\n") + return 1 + + ctl_sock: Optional[socket.socket] = None + try: + ctl_sock = _connect_auth(_ctl_path_for(path), token) + except OSError: + ctl_sock = None + + in_fd, out_fd, owns_fds = _resolve_io_fds() + + saved = None + if os.isatty(in_fd): + import termios + import tty + saved = termios.tcgetattr(in_fd) + tty.setraw(in_fd) + + resize_pending = {"flag": False} + + def on_sigwinch(_signo, _frame) -> None: + resize_pending["flag"] = True + + if ctl_sock is not None and hasattr(signal, "SIGWINCH"): + try: + signal.signal(signal.SIGWINCH, on_sigwinch) + except (OSError, ValueError): + pass + _push_winsize(ctl_sock, in_fd) + + # Terminal emulators send SIGHUP when the user closes the visible + # block (Terminal.app, iTerm2). systemd / launchctl can deliver + # SIGTERM. Ctrl-C inside a pre-raw-mode interrupt window arrives + # as SIGINT. In every case the master needs to know this slave's + # session is over — push BYE then re-raise the default action so + # we still exit promptly. The handler is intentionally tiny and + # signal-safe (no allocation beyond sendall's own buffers). + bye_sent = {"flag": False} + + def on_terminating_signal(signo, _frame) -> None: + if not bye_sent["flag"]: + bye_sent["flag"] = True + _send_bye(ctl_sock) + # Restore default disposition and re-raise so the OS does the + # right thing (default SIGHUP/SIGTERM/SIGINT all terminate). + try: + signal.signal(signo, signal.SIG_DFL) + os.kill(os.getpid(), signo) + except OSError: + pass + + for _signo_name in ("SIGHUP", "SIGTERM", "SIGINT"): + sig = getattr(signal, _signo_name, None) + if sig is None: + continue + try: + signal.signal(sig, on_terminating_signal) + except (OSError, ValueError): + pass + + watch_in = True + received_any = False + try: + while True: + if resize_pending["flag"] and ctl_sock is not None: + resize_pending["flag"] = False + _push_winsize(ctl_sock, in_fd) + + watches = [data_sock] + if watch_in: + watches.append(in_fd) + try: + r, _, _ = select.select(watches, [], [], 0.5) + except OSError as exc: + if exc.errno == errno.EINTR: + continue + raise + if data_sock in r: + data = data_sock.recv(BUFSIZE) + if not data: + if not received_any: + sys.stderr.write( + "csshx-latest: AUTH rejected -- the master closed the " + "socket before sending any data. Check that the token " + "embedded in the attach command matches the one the " + "master generated for this slave.\n" + ) + return 1 + return 0 + received_any = True + os.write(out_fd, data) + if watch_in and in_fd in r: + try: + data = os.read(in_fd, BUFSIZE) + except OSError: + data = b"" + if not data: + # Stdin EOF means the terminal block went away + # without giving us a signal (e.g. tmux ``kill-pane``, + # Kitty tab close). Mirror the signal-handler path + # so the master always learns about the closure. + if not bye_sent["flag"]: + bye_sent["flag"] = True + _send_bye(ctl_sock) + watch_in = False + try: + data_sock.shutdown(socket.SHUT_WR) + except OSError: + pass + else: + data_sock.sendall(data) + except KeyboardInterrupt: + if not bye_sent["flag"]: + bye_sent["flag"] = True + _send_bye(ctl_sock) + return 130 + finally: + if saved is not None: + import termios + termios.tcsetattr(in_fd, termios.TCSADRAIN, saved) + if owns_fds: + for fd in (in_fd, out_fd): + try: + os.close(fd) + except OSError: + pass + data_sock.close() + if ctl_sock is not None: + try: + ctl_sock.close() + except OSError: + pass + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main(sys.argv)) diff --git a/csshx-latest/csshx_latest/auth.py b/csshx-latest/csshx_latest/auth.py new file mode 100644 index 0000000..77f7dc1 --- /dev/null +++ b/csshx-latest/csshx_latest/auth.py @@ -0,0 +1,66 @@ +"""Token generation, persistence, and authentication handshake. + +Each slave socket created by the master is gated by a 32-byte hex +token. Connecting clients must send ``AUTH \\n`` as the first +line within :data:`HANDSHAKE_TIMEOUT` seconds; otherwise their +connection is dropped. This prevents other local users from injecting +keystrokes into your SSH sessions. + +The token itself is persisted to a file at mode ``0600`` inside the +master's ``0700`` socket directory. Spawned terminal blocks pass the +token's *file path* (never the token itself) on their command line — +``ps`` listings only reveal the file path, and the file mode keeps the +contents off-limits to other UIDs. +""" +from __future__ import annotations + +import asyncio +import os +import secrets + +TOKEN_BYTES = 32 +HANDSHAKE_TIMEOUT = 2.0 + + +def make_token() -> str: + """Return a fresh 64-character hex token (32 bytes of entropy).""" + return secrets.token_hex(TOKEN_BYTES) + + +def write_token_file(path: str, token: str) -> None: + """Persist ``token`` to ``path`` with mode ``0600``. + + Uses ``os.open`` with ``O_CREAT | O_WRONLY | O_TRUNC`` and an + explicit mode so the file is never world-readable, even briefly. + """ + fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + try: + os.write(fd, token.encode("ascii")) + finally: + os.close(fd) + # Belt-and-suspenders: an existing file with looser permissions + # won't have its mode reset by O_CREAT (mode arg is ignored on + # already-existing files), so re-chmod explicitly. + os.chmod(path, 0o600) + + +async def authenticate(reader: asyncio.StreamReader, expected: str) -> bool: + """Read the first line and validate the AUTH handshake. + + Returns True iff the client sent ``AUTH \\n`` (``\\r`` is + tolerated) within :data:`HANDSHAKE_TIMEOUT` seconds. Uses a + constant-time comparison for the token. + """ + try: + line = await asyncio.wait_for(reader.readline(), timeout=HANDSHAKE_TIMEOUT) + except asyncio.TimeoutError: + return False + if not line: + return False + try: + text = line.decode("ascii", errors="strict").rstrip("\r\n") + except UnicodeDecodeError: + return False + if not text.startswith("AUTH "): + return False + return secrets.compare_digest(text[5:], expected) diff --git a/csshx-latest/csshx_latest/broadcaster.py b/csshx-latest/csshx_latest/broadcaster.py new file mode 100644 index 0000000..028ed77 --- /dev/null +++ b/csshx-latest/csshx_latest/broadcaster.py @@ -0,0 +1,94 @@ +"""The Broadcaster: routes master keystrokes to enabled, alive slaves. + +Author: Aditya Kapadia. + +Pure logic, owns no fds. Kept in its own module so the broadcast +routing has a clear test surface separate from the TUI loop and the +orchestrator that wires everything together. +""" +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass, field +from typing import Callable, Optional + +from csshx_latest.slave import Slave, write_to_slave + +log = logging.getLogger(__name__) + + +@dataclass +class Broadcaster: + """Routes bytes to enabled slaves. + + ``on_state_change`` is fired (synchronously) for every slave whose + ``enabled`` flag flips via :meth:`toggle` or :meth:`set_all_enabled`. + The orchestrator wires this to push state colors to the launcher so + the user gets immediate visual feedback when broadcast is toggled. + The callback runs on whatever thread / loop called the toggle — + keep it cheap and non-blocking. + """ + + slaves: list[Slave] = field(default_factory=list) + on_state_change: Optional[Callable[[Slave], None]] = None + + def add(self, s: Slave) -> None: + """Register a slave with the broadcaster.""" + self.slaves.append(s) + + def enabled_indices(self) -> list[int]: + """Indices of slaves that currently receive broadcast bytes.""" + return [s.index for s in self.slaves if s.enabled and not s.dead] + + def alive_indices(self) -> list[int]: + """Indices of slaves that are still connected (ssh hasn't exited).""" + return [s.index for s in self.slaves if not s.dead] + + def _notify(self, s: Slave) -> None: + if self.on_state_change is None: + return + try: + self.on_state_change(s) + except Exception: # pragma: no cover - defensive + log.exception("on_state_change for slave %s raised", s.index) + + def toggle(self, index: int) -> bool: + """Flip the ``enabled`` flag of the slave with the given index. + + Returns the new ``enabled`` value. Raises ``KeyError`` if no + slave has that index. + """ + for s in self.slaves: + if s.index == index: + s.enabled = not s.enabled + self._notify(s) + return s.enabled + raise KeyError(index) + + def set_all_enabled(self, enabled: bool) -> None: + """Enable / disable broadcast to every (alive) slave at once.""" + for s in self.slaves: + if not s.dead and s.enabled != enabled: + s.enabled = enabled + self._notify(s) + + async def broadcast(self, data: bytes) -> None: + """Write ``data`` to every enabled, alive slave concurrently. + + Per-slave failures are logged at WARNING -- they're treated as + non-fatal because the dead-slave detection path will set + ``dead=True`` and stop subsequent writes, but a silent failure + here would otherwise leave the user wondering why a host stopped + responding. + """ + targets = [s for s in self.slaves if s.enabled and not s.dead] + if not targets: + return + results = await asyncio.gather( + *(write_to_slave(s, data) for s in targets), + return_exceptions=True, + ) + for slave, result in zip(targets, results): + if isinstance(result, BaseException): + log.warning("broadcast to slave %d (%s) failed: %r", slave.index, slave.host, result) diff --git a/csshx-latest/csshx_latest/config.py b/csshx-latest/csshx_latest/config.py new file mode 100644 index 0000000..a7c1181 --- /dev/null +++ b/csshx-latest/csshx_latest/config.py @@ -0,0 +1,129 @@ +"""Cluster alias configuration. + +Author: Aditya Kapadia. + +Two config sources are supported, in priority order: + +1. ``$XDG_CONFIG_HOME/csshx-latest/config.toml`` (or + ``~/.config/csshx-latest/config.toml``), with a ``[clusters]`` + table mapping cluster name → list of hostnames. + +2. ``~/.csshrc`` in the original csshX format: + ``cluster = host1 host2 host3`` lines, ``#`` for comments. + +The first source that exists wins. Either format is fine; the TOML +flavor is preferred for new setups, the ``~/.csshrc`` path is kept so +users migrating from the Perl csshX don't have to rewrite their config. +""" +from __future__ import annotations + +import logging +import os +import shlex +from typing import Optional + +try: + import tomllib # Python 3.11+ +except ModuleNotFoundError: # pragma: no cover + tomllib = None # type: ignore[assignment] + +log = logging.getLogger(__name__) + +Clusters = dict[str, list[str]] + + +def _toml_path() -> str: + base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") + return os.path.join(base, "csshx-latest", "config.toml") + + +def _csshrc_path() -> str: + return os.path.expanduser("~/.csshrc") + + +def _load_toml(path: str) -> Clusters: + if tomllib is None: + log.debug("tomllib unavailable; skipping %s", path) + return {} + try: + with open(path, "rb") as fh: + doc = tomllib.load(fh) + except (OSError, ValueError) as exc: + log.warning("could not parse %s: %s", path, exc) + return {} + raw = doc.get("clusters", {}) + if not isinstance(raw, dict): + log.warning("%s: [clusters] must be a table, got %r", path, type(raw).__name__) + return {} + out: Clusters = {} + for name, hosts in raw.items(): + if isinstance(hosts, str): + out[name] = shlex.split(hosts) + elif isinstance(hosts, list): + out[name] = [str(h) for h in hosts] + else: + log.warning("%s: cluster %r ignored (must be string or list)", path, name) + return out + + +def _load_csshrc(path: str) -> Clusters: + try: + with open(path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + except OSError as exc: + log.debug("could not read %s: %s", path, exc) + return {} + out: Clusters = {} + for raw in lines: + line = raw.strip() + if not line or line.startswith("#"): + continue + if not line.startswith("cluster "): + continue + body = line[len("cluster "):].lstrip() + if "=" not in body: + continue + name, _, rest = body.partition("=") + name = name.strip() + if not name: + continue + out[name] = shlex.split(rest) + return out + + +def load_clusters(toml_path: Optional[str] = None, csshrc_path: Optional[str] = None) -> Clusters: + """Return cluster aliases from the first source that exists. + + ``toml_path`` / ``csshrc_path`` override the default lookup; useful + in tests. Missing files return an empty mapping, never raise. + """ + tp = toml_path if toml_path is not None else _toml_path() + rp = csshrc_path if csshrc_path is not None else _csshrc_path() + if os.path.isfile(tp): + return _load_toml(tp) + if os.path.isfile(rp): + return _load_csshrc(rp) + return {} + + +def expand_clusters(tokens: list[str], clusters: Clusters) -> list[str]: + """Replace any token that matches a cluster name with its host list. + + Cluster references are resolved recursively so a cluster can list + another cluster's name. A cycle short-circuits at the first repeat + so a misconfigured config doesn't hang the CLI. + """ + out: list[str] = [] + for tok in tokens: + out.extend(_resolve(tok, clusters, seen=set())) + return out + + +def _resolve(name: str, clusters: Clusters, seen: set[str]) -> list[str]: + if name not in clusters or name in seen: + return [name] + seen = seen | {name} + out: list[str] = [] + for child in clusters[name]: + out.extend(_resolve(child, clusters, seen)) + return out diff --git a/csshx-latest/csshx_latest/hosts.py b/csshx-latest/csshx_latest/hosts.py new file mode 100644 index 0000000..fbb925c --- /dev/null +++ b/csshx-latest/csshx_latest/hosts.py @@ -0,0 +1,92 @@ +"""Brace-expansion and cluster-alias resolution for host arguments. + +Author: Aditya Kapadia. + +Brace expansion mirrors bash so the CLI behaves the same regardless of +the user's shell. Cluster aliases come from :mod:`csshx_latest.config` +and are expanded *before* brace expansion so a cluster can list +brace-pattern hosts. + +Supported brace forms: + +* numeric range: ``web0{1..5}`` → ``web01 web02 web03 web04 web05`` + (width is preserved from the lower bound's literal text); +* alphabetic range: ``host-{a..c}`` → ``host-a host-b host-c``; +* alternation: ``api-{a,b,c}`` → ``api-a api-b api-c``. + +Patterns can be nested and combined: ``{prod,stage}-web{1..2}`` yields +4 hosts. Inputs without braces are returned unchanged. +""" +from __future__ import annotations + +import re +from typing import Optional + +from csshx_latest.config import Clusters, expand_clusters + +_BRACE_RE = re.compile(r"\{([^{}]+)\}") + + +def expand_hosts(args: list[str], clusters: Optional[Clusters] = None) -> list[str]: + """Resolve cluster aliases, then brace-expand, then return the flat list. + + Inputs with no braces are returned unchanged. Empty alternation + elements are kept (so ``foo{,bar}`` yields ``foo`` and ``foobar``, + matching bash's behavior). ``clusters`` defaults to no aliases. + """ + tokens = expand_clusters(args, clusters) if clusters else list(args) + out: list[str] = [] + for a in tokens: + out.extend(_expand_one(a)) + return out + + +def _expand_one(s: str) -> list[str]: + """Expand a single token; recurses for nested braces.""" + m = _BRACE_RE.search(s) + if not m: + return [s] + prefix, suffix = s[: m.start()], s[m.end() :] + inner = m.group(1) + pieces = _expand_inner(inner) + out: list[str] = [] + for piece in pieces: + # Recurse: ``suffix`` (and any later prefix) may have more braces. + for tail in _expand_one(suffix): + out.append(f"{prefix}{piece}{tail}") + return out + + +def _expand_inner(inner: str) -> list[str]: + """Expand the contents of one ``{...}`` group. + + Handles numeric ranges (``N..M``) first because they're the more + constrained form; anything else is treated as comma-separated + alternation. + """ + range_match = re.fullmatch(r"(-?\d+)\.\.(-?\d+)", inner) + if range_match: + lo_s, hi_s = range_match.group(1), range_match.group(2) + lo, hi = int(lo_s), int(hi_s) + # Preserve zero-padding width from the lower bound's literal text. + width = 0 + if lo_s.startswith("0") or lo_s.startswith("-0"): + width = len(lo_s.lstrip("-")) + step = 1 if hi >= lo else -1 + items: list[str] = [] + for n in range(lo, hi + step, step): + if width: + sign = "-" if n < 0 else "" + items.append(f"{sign}{abs(n):0{width}d}") + else: + items.append(str(n)) + return items + alpha_match = re.fullmatch(r"([A-Za-z])\.\.([A-Za-z])", inner) + if alpha_match: + lo_c, hi_c = alpha_match.group(1), alpha_match.group(2) + step = 1 if ord(hi_c) >= ord(lo_c) else -1 + return [chr(c) for c in range(ord(lo_c), ord(hi_c) + step, step)] + # Comma alternation. Split on every comma (no nested brace splitting — + # outer recursion in _expand_one handles nesting after the outer brace + # is consumed). + return inner.split(",") diff --git a/csshx-latest/csshx_latest/launcher.py b/csshx-latest/csshx_latest/launcher.py new file mode 100644 index 0000000..349a05a --- /dev/null +++ b/csshx-latest/csshx_latest/launcher.py @@ -0,0 +1,173 @@ +"""Launcher Protocol and environment-based auto-detection. + +Author: Aditya Kapadia. + +A Launcher knows how to ask one specific terminal application (Wave, +iTerm2, tmux, ...) to open a new visible block running an arbitrary +command, and optionally to tile/title the resulting blocks. + +Concrete launchers live under :mod:`csshx_latest.launchers`. They are +imported lazily in :func:`_by_name` so that selecting one backend +doesn't pay the import cost of the others. + +Lifecycle +--------- + +The orchestrator calls launcher methods in this order:: + + start(total) # once, before any blocks open + open_block(...) # once per host + tile(handles) # after every open_block AND on resize + close_block(handle) # once per host, on shutdown + +``start`` lets a launcher know up-front how many blocks it will be +asked to open; that's how the tmux launcher decides between +splitting the current pane and carving out a new window. The default +implementation is a no-op. +""" +from __future__ import annotations + +import enum +import os +import shutil +from dataclasses import dataclass, field +from typing import Any, Optional, Protocol, runtime_checkable + + +class Color(enum.Enum): + """Per-block state colors — mirrors the original csshX color taxonomy. + + The Perl csshX used four states (``selected``, ``disabled``, + ``master``, ``setbounds``); for a TUI without a separate master + window we only need three: + + * :attr:`ENABLED` — broadcast is on (csshX ``selected``). + * :attr:`DISABLED` — broadcast is off but the host is alive + (csshX ``disabled``). + * :attr:`DEAD` — ssh has exited. + + Concrete launchers translate these into their native paint + primitives (tmux ``select-pane -P``, wsh ``setbg``, kitty + ``set-tab-color``, etc.). Launchers without a paint API treat + every value as a no-op. + """ + + ENABLED = "enabled" + DISABLED = "disabled" + DEAD = "dead" + + +@dataclass +class BlockHandle: + """Opaque handle returned by :meth:`Launcher.open_block`. + + ``data`` is a per-backend bag of identifiers (pane id, window id, + block id, ...) that the same backend uses to later close, retitle, + or tile the block. + """ + + backend: str + data: dict[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class Launcher(Protocol): + """Pluggable terminal-backend interface.""" + + name: str + + def start(self, total: int) -> None: + """Notify the launcher how many blocks will be opened in total.""" + ... + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Open a visible block and run ``attach_cmd`` inside it.""" + ... + + def close_block(self, handle: BlockHandle) -> None: + """Close a block previously returned by :meth:`open_block`.""" + ... + + def tile(self, handles: list[BlockHandle]) -> None: + """Arrange the given blocks in a tiled layout. May be a no-op.""" + ... + + def set_title(self, handle: BlockHandle, title: str) -> None: + """Rename a block. May be a no-op.""" + ... + + def set_color(self, handle: BlockHandle, color: Color) -> None: + """Paint a block to reflect its broadcast state. May be a no-op.""" + ... + + +# (module, class) pairs keyed by the public launcher name. The keys of +# this dict are the single source of truth for ``--launcher`` choices -- +# ``__main__.py`` reads them so the CLI never drifts out of sync with +# what's actually available. +_LAUNCHERS: dict[str, tuple[str, str]] = { + "waveterm": ("csshx_latest.launchers.waveterm", "WaveTermLauncher"), + "tmux": ("csshx_latest.launchers.tmux", "TmuxLauncher"), + "iterm2": ("csshx_latest.launchers.iterm2", "ITerm2Launcher"), + "terminal": ("csshx_latest.launchers.apple_terminal", "AppleTerminalLauncher"), + "kitty": ("csshx_latest.launchers.kitty", "KittyLauncher"), + "wezterm": ("csshx_latest.launchers.wezterm", "WezTermLauncher"), + "manual": ("csshx_latest.launchers.manual", "ManualLauncher"), +} + + +def available_launcher_names() -> list[str]: + """Return the sorted list of valid ``--launcher`` choices, plus ``auto``.""" + return ["auto", *sorted(_LAUNCHERS)] + + +def _by_name(name: str) -> Launcher: + """Instantiate the launcher class registered under ``name``.""" + if name not in _LAUNCHERS: + raise ValueError(f"unknown launcher: {name!r}") + mod_name, cls_name = _LAUNCHERS[name] + import importlib + mod = importlib.import_module(mod_name) + return getattr(mod, cls_name)() + + +def detect_launcher(name: Optional[str] = None) -> Launcher: + """Return a Launcher instance. + + If ``name`` is given (and not ``"auto"``), use that launcher + explicitly. Otherwise inspect environment variables in priority + order: + + 1. ``$TMUX`` -- tmux is checked *first* because a tmux session + running inside iTerm or Kitty leaves both ``TMUX`` *and* + ``TERM_PROGRAM``/``KITTY_PID`` set; the user's foreground + multiplexer is tmux, which is what should host the panes. + 2. WaveTerm (``TERM_PROGRAM=waveterm`` + ``wsh`` on PATH). + 3. iTerm2 (``TERM_PROGRAM=iTerm.app``). + 4. Apple Terminal.app (``TERM_PROGRAM=Apple_Terminal``). + 5. Kitty (``KITTY_PID`` set + ``kitty`` on PATH). + 6. WezTerm (``TERM_PROGRAM=WezTerm`` + ``wezterm`` on PATH). + + Falls back to the Manual launcher if nothing is recognized -- never + silently picks tmux without ``$TMUX``, never auto-spawns a new + multiplexer. + """ + if name and name != "auto": + return _by_name(name) + + if os.environ.get("TMUX") and shutil.which("tmux"): + return _by_name("tmux") + + term_program = os.environ.get("TERM_PROGRAM", "") + + if term_program == "waveterm" and shutil.which("wsh"): + return _by_name("waveterm") + if term_program == "iTerm.app": + return _by_name("iterm2") + if term_program == "Apple_Terminal": + return _by_name("terminal") + if os.environ.get("KITTY_PID") and shutil.which("kitty"): + return _by_name("kitty") + if term_program == "WezTerm" and shutil.which("wezterm"): + return _by_name("wezterm") + return _by_name("manual") diff --git a/csshx-latest/csshx_latest/launchers/__init__.py b/csshx-latest/csshx_latest/launchers/__init__.py new file mode 100644 index 0000000..d7c471b --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/__init__.py @@ -0,0 +1 @@ +"""Concrete Launcher implementations (one per terminal backend).""" diff --git a/csshx-latest/csshx_latest/launchers/apple_terminal.py b/csshx-latest/csshx_latest/launchers/apple_terminal.py new file mode 100644 index 0000000..50240a7 --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/apple_terminal.py @@ -0,0 +1,351 @@ +"""Apple Terminal.app launcher via ``osascript``. + +Author: Aditya Kapadia. + +Terminal.app's only AppleScript hook for launching commands is +``do script``, which feeds the typed text into the new tab's login +shell. With Powerlevel10k as the user's default shell, the long +.zshrc + instant-prompt initialization can swallow / reorder the +attach command's keystrokes. To avoid that, we prefix the attach +command with ``exec /bin/sh -c '...'`` -- zsh's first parsed line +exec-replaces itself with ``/bin/sh`` running our command, so the +prompt never gets a chance to render and no shell init code runs. + +Each block opens in its OWN Terminal window (not a tab in a shared +window) so :meth:`tile` can position blocks independently by setting +``bounds`` on each window. The window id is captured into +:attr:`BlockHandle.data["window_id"]` at open time so subsequent +tile / close / set_title calls don't have to scan all windows. + +Master window +------------- + +The TUI runs in the Terminal window the user invoked ``csshx-latest`` +from. :meth:`start` captures its id (``id of front window``) before +any slave window opens, so :meth:`tile` can include it as the first +cell of the grid. The result: master + slaves all get rearranged +together every time a slave block is added, matching the original +Perl csshX behavior. If the capture fails (Finder denied, AppleScript +returns garbage), the master is silently excluded and slaves are +tiled as before -- no regression. + +Tiling mirrors the original Perl csshX layout: compute the usable +desktop area (Finder's ``bounds of window of desktop`` minus the +Dock and a small edge margin) and divide it into a near-square grid +of ``rows × cols`` cells, packing windows left-to-right, top-to- +bottom, with the master always at cell 0 (top-left). Each cell is +shrunk by :data:`WINDOW_GAP` pixels on its right and bottom so +adjacent windows aren't flush against each other. The math is in +:func:`_grid_for` / :func:`_get_usable_bounds`. + +Color hook +---------- + +Terminal.app does NOT expose a per-tab "color" attribute in +AppleScript, but it does expose ``background color`` on tabs +(16-bit RGB). :meth:`set_color` writes that property so the user +gets a visible cue when broadcast is toggled. The palette is +deliberately low-saturation (see :data:`_TAB_BG`) so a wall of +slave windows isn't fatiguing to look at: ENABLED → dim sage, +DISABLED → dim slate, DEAD → dim mauve. The change is per-tab and +does not persist into the user's saved profile. +""" +from __future__ import annotations + +import logging +import math +import shlex +import subprocess + +from csshx_latest.launcher import BlockHandle, Color + +log = logging.getLogger(__name__) + +# Pixels reserved at the bottom of the screen for the Dock. Finder's +# ``bounds of window of desktop`` returns the full screen rectangle and +# does NOT subtract the Dock, so windows tiled to that rectangle slide +# under the Dock. 90px covers the default Dock size + a small buffer. +# Querying the actual Dock size requires Accessibility permission and +# can prompt the user, so we use a conservative fixed reserve instead. +DOCK_RESERVE = 90 + +# Small inset on every screen edge so windows don't sit flush against +# the menu bar or screen borders. +EDGE_MARGIN = 8 + +# Pixels of space between adjacent tiled windows. Each cell is shrunk +# by this amount on its right and bottom edges so neighbours don't +# touch each other. +WINDOW_GAP = 6 + +# Terminal.app's ``background color`` of a tab is 16-bit RGB (0..65535). +# Subtle low-saturation tints rather than full-strength primaries — the +# eye picks up the hue difference at a glance without the slab of green +# / red being uncomfortable to look at for hours. All three live in the +# same lightness band (~12k-19k of 65535) so foreground text contrast +# stays roughly the same on every state. +# +# - ENABLED → dim sage (#303E37-ish) — faint cool green wash +# - DISABLED → dim slate (#38383C-ish) — barely-tinted neutral +# - DEAD → dim mauve (#483438-ish) — faint warm red wash +_TAB_BG: dict[Color, tuple[int, int, int]] = { + Color.ENABLED: (12288, 17408, 14336), + Color.DISABLED: (14336, 14336, 15360), + Color.DEAD: (18432, 13312, 14336), +} + + +def _escape(s: str) -> str: + return s.replace("\\", "\\\\").replace('"', '\\"') + + +def _osascript(script: str) -> subprocess.CompletedProcess: + return subprocess.run(["osascript", "-e", script], check=False, capture_output=True, text=True) + + +def _grid_for(n: int) -> tuple[int, int]: + """Return ``(rows, cols)`` for a near-square grid holding ``n`` blocks.""" + if n <= 0: + return (0, 0) + cols = max(1, int(math.ceil(math.sqrt(n)))) + rows = max(1, int(math.ceil(n / cols))) + return (rows, cols) + + +def _get_desktop_bounds() -> tuple[int, int, int, int]: + """Return the usable desktop rectangle ``(left, top, right, bottom)``. + + Uses Finder's ``bounds of window of desktop``, which excludes the + menu bar but includes the Dock area on macOS. Falls back to a + sane default if the AppleScript call fails (e.g. Finder denied, + headless test). + """ + result = _osascript( + 'tell application "Finder" to get bounds of window of desktop' + ) + text = (result.stdout or "").strip() + try: + parts = [int(p.strip()) for p in text.split(",")] + if len(parts) == 4: + return (parts[0], parts[1], parts[2], parts[3]) + except ValueError: + pass + log.debug("Finder desktop bounds unavailable; falling back to 1920x1080") + return (0, 0, 1920, 1080) + + +def _get_usable_bounds() -> tuple[int, int, int, int]: + """Return the desktop rectangle minus Dock and edge insets. + + Subtracts :data:`DOCK_RESERVE` from the bottom so windows don't + slide under the Dock, and :data:`EDGE_MARGIN` from every side so + windows don't sit flush against the menu bar or screen borders. + """ + left, top, right, bottom = _get_desktop_bounds() + return ( + left + EDGE_MARGIN, + top + EDGE_MARGIN, + right - EDGE_MARGIN, + bottom - DOCK_RESERVE - EDGE_MARGIN, + ) + + +class AppleTerminalLauncher: + """Open each block as its own Terminal.app window and tile them.""" + + name = "terminal" + + def __init__(self) -> None: + # Captured at start() so tile() can include the TUI's own window in + # the grid alongside slave windows. Empty string means "no capture + # yet" or "capture failed" -- tile() falls back to slaves-only. + self._master_window_id: str = "" + + def start(self, total: int) -> None: + """Capture the front window id (the master TUI) before any slave opens. + + Done here -- not at construction -- so the orchestrator's + single ``launcher.start(total)`` call lands while the TUI's + Terminal window is still the frontmost. After the first + :meth:`open_block` runs, ``activate`` will have shifted focus + to a freshly-spawned slave window and ``front window`` would + no longer point at the master. + """ + result = _osascript( + 'tell application "Terminal" to return id of front window as text' + ) + text = (result.stdout or "").strip() + if result.returncode != 0 or not text or not text.isdigit(): + log.debug( + "could not capture master Terminal window id (rc=%d, stdout=%r): " + "tile() will arrange slave windows only", + result.returncode, + text, + ) + return + self._master_window_id = text + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Open a fresh Terminal window running ``attach_cmd``. + + AppleScript's ``do script`` without a target opens in the + frontmost window's last tab (or a brand-new window if none + exists). To guarantee a separate window per block we create + the window explicitly with ``make new window``, then run the + command in its single tab. The window's ``id`` is captured so + tiling can address it directly without scanning. + """ + cmd_str = " ".join(shlex.quote(a) for a in attach_cmd) + wrapped = f"exec /bin/sh -c {shlex.quote(cmd_str)}" + cmd_esc = _escape(wrapped) + title_esc = _escape(title) + script = ( + 'tell application "Terminal"\n' + ' activate\n' + f' set newTab to do script "{cmd_esc}"\n' + ' set newWin to window 1\n' + f' set custom title of newTab to "{title_esc}"\n' + ' return (id of newWin as text) & "\\n" & (tty of newTab)\n' + 'end tell\n' + ) + result = _osascript(script) + out_lines = [ + ln.strip() for ln in (result.stdout or "").splitlines() if ln.strip() + ] + window_id = out_lines[0] if len(out_lines) >= 1 else "" + tty_id = out_lines[1] if len(out_lines) >= 2 else "" + if result.returncode != 0: + log.warning( + "Terminal.app open_block exited %d: %s", + result.returncode, + result.stderr.strip(), + ) + return BlockHandle( + backend=self.name, + data={"title": title, "tty": tty_id, "window_id": window_id}, + ) + + def close_block(self, handle: BlockHandle) -> None: + """Close the window matching the captured id; fall back to tty match.""" + window_id = handle.data.get("window_id") + if window_id: + wid_esc = _escape(str(window_id)) + _osascript( + 'tell application "Terminal"\n' + f' try\n' + f' close (every window whose id is {wid_esc})\n' + f' end try\n' + 'end tell\n' + ) + return + tty_id = handle.data.get("tty") + if not tty_id: + return + tty_esc = _escape(tty_id) + _osascript( + 'tell application "Terminal"\n' + ' repeat with w in windows\n' + ' repeat with t in tabs of w\n' + f' if tty of t is "{tty_esc}" then close t\n' + ' end repeat\n' + ' end repeat\n' + 'end tell\n' + ) + + def tile(self, handles: list[BlockHandle]) -> None: + """Lay out the master + captured slave windows in a near-square grid. + + Windows pack left-to-right, top-to-bottom: with 4 blocks (1 + master + 3 slaves) you get 2×2; with 2 blocks you get 1×2 + (side-by-side); with 3 you get 2 rows where the bottom row is + half-empty. Slave windows without a captured ``window_id`` + (open_block fell back) are skipped silently so a partial + failure doesn't break the rest. + + When ``start()`` successfully captured the master window's id, + it's placed at cell 0 (top-left) so the user keeps clear focus + on where they're typing. When the master capture failed, only + slave windows are tiled — the original behavior. + """ + windowed = [h for h in handles if h.data.get("window_id")] + cells: list[str] = [] + if self._master_window_id: + cells.append(self._master_window_id) + cells.extend(str(h.data["window_id"]) for h in windowed) + if not cells: + return + left, top, right, bottom = _get_usable_bounds() + width = max(0, right - left) + height = max(0, bottom - top) + rows, cols = _grid_for(len(cells)) + if rows == 0 or cols == 0 or width == 0 or height == 0: + return + cell_w = width // cols + cell_h = height // rows + lines = ['tell application "Terminal"'] + for i, wid in enumerate(cells): + r = i // cols + c = i % cols + x1 = left + c * cell_w + y1 = top + r * cell_h + # Shrink each cell by WINDOW_GAP on right/bottom so adjacent + # windows have visible breathing room. + x2 = x1 + cell_w - WINDOW_GAP + y2 = y1 + cell_h - WINDOW_GAP + wid_esc = _escape(wid) + lines.append( + f' try\n' + f' set bounds of (first window whose id is {wid_esc}) ' + f'to {{{x1}, {y1}, {x2}, {y2}}}\n' + f' end try' + ) + lines.append('end tell') + result = _osascript("\n".join(lines)) + if result.returncode != 0: + log.warning( + "Terminal.app tile exited %d: %s", + result.returncode, + result.stderr.strip(), + ) + + def set_title(self, handle: BlockHandle, title: str) -> None: + """Rename the tab matched by tty id.""" + tty_id = handle.data.get("tty") + if not tty_id: + return + tty_esc = _escape(tty_id) + title_esc = _escape(title) + _osascript( + 'tell application "Terminal"\n' + ' repeat with w in windows\n' + ' repeat with t in tabs of w\n' + f' if tty of t is "{tty_esc}" then set custom title of t to "{title_esc}"\n' + ' end repeat\n' + ' end repeat\n' + 'end tell\n' + ) + + def set_color(self, handle: BlockHandle, color: Color) -> None: + """Tint the slave's tab so broadcast state is visible. + + Writes ``background color`` of the window's first tab via + AppleScript. The value is a 16-bit RGB triple from + :data:`_TAB_BG`. Silently no-ops if we never captured a window + id (open_block fell back) or if the color isn't in the palette. + Errors are swallowed inside an AppleScript ``try`` block so a + stale window id during shutdown can't break callers. + """ + wid = handle.data.get("window_id") + rgb = _TAB_BG.get(color) + if not wid or not rgb: + return + r, g, b = rgb + wid_esc = _escape(str(wid)) + _osascript( + 'tell application "Terminal"\n' + f' try\n' + f' set background color of tab 1 of ' + f'(first window whose id is {wid_esc}) to {{{r}, {g}, {b}}}\n' + f' end try\n' + 'end tell\n' + ) diff --git a/csshx-latest/csshx_latest/launchers/iterm2.py b/csshx-latest/csshx_latest/launchers/iterm2.py new file mode 100644 index 0000000..b1ead43 --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/iterm2.py @@ -0,0 +1,186 @@ +"""iTerm2 launcher via ``osascript`` and iTerm's AppleScript dictionary. + +Author: Aditya Kapadia. + +Every block (the first included) is a *split* of the master TUI's +current session, so master + slaves end up sharing one iTerm2 window +and iTerm2's auto-balanced split panes give all of them equal real +estate. Each open shifts the slaves smaller and the master smaller in +lockstep, exactly the visual rearrangement the original Perl csshX +provided on Terminal.app. + +Every split passes the attach command as the new session's +``command``, so iTerm executes it directly via ``execvp`` and the +user's interactive login shell never runs. That sidesteps p10k / +oh-my-zsh swallowing the attach command's keystrokes. + +Session ids returned by ``id of newSession`` are captured into +``BlockHandle.data`` so :meth:`close_block` can actually close the +pane on shutdown instead of leaving a dead socket sitting visible. +iTerm2 auto-balances split panes whenever a new one is added, so +:meth:`tile` itself stays a no-op. +""" +from __future__ import annotations + +import logging +import shlex +import subprocess + +from csshx_latest.launcher import BlockHandle, Color + +log = logging.getLogger(__name__) + +# iTerm2's session ``background color`` accepts a 16-bit RGB triple +# (0..65535). Same low-saturation palette as Apple Terminal so the +# visual cue feels consistent across backends and doesn't fatigue the +# eye after staring at a wall of slave panes for an hour: +# ENABLED → dim sage (faint cool green wash) +# DISABLED → dim slate (barely-tinted neutral) +# DEAD → dim mauve (faint warm red wash) +_SESSION_BG: dict[Color, tuple[int, int, int]] = { + Color.ENABLED: (12288, 17408, 14336), + Color.DISABLED: (14336, 14336, 15360), + Color.DEAD: (18432, 13312, 14336), +} + + +def _osascript(script: str) -> subprocess.CompletedProcess: + return subprocess.run(["osascript", "-e", script], check=False, capture_output=True, text=True) + + +def _escape(s: str) -> str: + """Escape backslashes and double-quotes for embedding in an AppleScript literal.""" + return s.replace("\\", "\\\\").replace('"', '\\"') + + +class ITerm2Launcher: + """Open each block as an iTerm2 split pane via AppleScript.""" + + name = "iterm2" + + def start(self, total: int) -> None: + """No-op: iTerm2 split panes balance automatically.""" + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Split the master's current session and run ``attach_cmd`` inside it. + + Every block — including the first — is created with ``split + vertically with default profile command ...``. That places the + new slave alongside the master TUI in the same iTerm2 window, + so iTerm2's automatic pane balancing rearranges master and + slaves together on every spawn. (v1.0 created a brand-new + window for the first block, which left the master orphaned + in its own window — slaves were tiled, master wasn't.) + """ + cmd_str = " ".join(shlex.quote(a) for a in attach_cmd) + cmd_esc = _escape(cmd_str) + title_esc = _escape(title) + + script = ( + 'tell application "iTerm"\n' + ' activate\n' + ' tell current session of current window\n' + ' set newSession to (split vertically with default profile ' + f' command "{cmd_esc}")\n' + ' end tell\n' + ' tell newSession\n' + f' set name to "{title_esc}"\n' + ' end tell\n' + ' return (id of current window) & "|" & (id of newSession)\n' + 'end tell\n' + ) + result = _osascript(script) + window_id, session_id = _parse_ids(result.stdout) + if result.returncode != 0: + log.warning("iTerm2 open_block exited %d: %s", result.returncode, result.stderr.strip()) + return BlockHandle( + backend=self.name, + data={"title": title, "window_id": window_id, "session_id": session_id}, + ) + + def close_block(self, handle: BlockHandle) -> None: + """Close the captured session id. No-op if iTerm2 didn't return one.""" + session_id = handle.data.get("session_id") + if not session_id: + return + sid_esc = _escape(session_id) + _osascript( + 'tell application "iTerm"\n' + ' repeat with w in windows\n' + ' repeat with t in tabs of w\n' + ' repeat with s in sessions of t\n' + f' if id of s is "{sid_esc}" then close s\n' + ' end repeat\n' + ' end repeat\n' + ' end repeat\n' + 'end tell\n' + ) + + def tile(self, handles: list[BlockHandle]) -> None: + """No-op: iTerm2 evenly balances split panes automatically.""" + + def set_title(self, handle: BlockHandle, title: str) -> None: + """Rename the captured session if we got an id.""" + session_id = handle.data.get("session_id") + title_esc = _escape(title) + if not session_id: + _osascript( + 'tell application "iTerm" to tell current session of current window ' + f'to set name to "{title_esc}"' + ) + return + sid_esc = _escape(session_id) + _osascript( + 'tell application "iTerm"\n' + ' repeat with w in windows\n' + ' repeat with t in tabs of w\n' + ' repeat with s in sessions of t\n' + f' if id of s is "{sid_esc}" then set name of s to "{title_esc}"\n' + ' end repeat\n' + ' end repeat\n' + ' end repeat\n' + 'end tell\n' + ) + + + def set_color(self, handle: BlockHandle, color: Color) -> None: + """Tint the slave session so broadcast state is visible. + + Writes ``background color`` of the matched session via + AppleScript using a 16-bit RGB triple from :data:`_SESSION_BG`. + Silently no-ops if we never captured a session id (open_block + fell back). Errors are swallowed inside an AppleScript ``try`` + block so a stale id during shutdown can't break callers. + """ + session_id = handle.data.get("session_id") + rgb = _SESSION_BG.get(color) + if not session_id or not rgb: + return + r, g, b = rgb + sid_esc = _escape(session_id) + _osascript( + 'tell application "iTerm"\n' + ' repeat with w in windows\n' + ' repeat with t in tabs of w\n' + ' repeat with s in sessions of t\n' + f' if id of s is "{sid_esc}" then\n' + f' try\n' + f' set background color of s to {{{r}, {g}, {b}}}\n' + f' end try\n' + f' end if\n' + ' end repeat\n' + ' end repeat\n' + ' end repeat\n' + 'end tell\n' + ) + + +def _parse_ids(stdout: str) -> tuple[str, str]: + """Parse ``"window_id|session_id"`` from osascript output.""" + if not stdout: + return ("", "") + line = stdout.strip().splitlines()[-1] if stdout.strip() else "" + if "|" not in line: + return ("", "") + win, _, sess = line.partition("|") + return (win.strip(), sess.strip()) diff --git a/csshx-latest/csshx_latest/launchers/kitty.py b/csshx-latest/csshx_latest/launchers/kitty.py new file mode 100644 index 0000000..c98a5d9 --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/kitty.py @@ -0,0 +1,116 @@ +"""Kitty launcher — uses ``kitty @`` remote control. + +Requires ``allow_remote_control yes`` in ``kitty.conf`` (or the +equivalent ``--listen-on`` flag). The constructor surfaces a clear +error if the kitty CLI isn't on PATH; runtime failures from +``kitty @ launch`` are reported with kitty's own stderr included so +config issues are easy to diagnose. + +v1.0 used ``--type=window``, which opened a fresh OS window per host. +With ten ssh targets that meant ten OS-level windows — useless. v1.1 +defaults to ``--type=tab`` so all blocks live as tabs of the user's +current kitty OS window, exactly like every other launcher. +``--keep-focus`` keeps the master TUI focused so the user can keep +typing without juggling windows. +""" +from __future__ import annotations + +import shutil +import subprocess + +from csshx_latest.launcher import BlockHandle, Color + +#: Hex backgrounds for the per-tab color tint. Same palette family +#: as the tmux launcher to keep the visual language consistent across +#: backends. +_TAB_BG: dict[Color, str] = { + Color.ENABLED: "#0e3d0e", # dark green + Color.DISABLED: "#3a3a3a", # neutral grey + Color.DEAD: "#4d1414", # dark red +} + + +class KittyLauncher: + """Open each block as a new kitty tab. Tile via ``goto-layout grid``.""" + + name = "kitty" + + def __init__(self) -> None: + if not shutil.which("kitty"): + raise RuntimeError( + "kitty CLI not found on PATH. Install kitty and ensure " + "'allow_remote_control yes' is set in kitty.conf." + ) + + @staticmethod + def _run(args: list[str], capture: bool = False) -> subprocess.CompletedProcess: + return subprocess.run(args, check=False, capture_output=capture, text=True) + + def start(self, total: int) -> None: + """No-op: kitty's grid layout adapts as tabs are added.""" + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Spawn a new kitty tab via ``kitty @ launch --type=tab``.""" + out = self._run( + [ + "kitty", + "@", + "launch", + "--type=tab", + "--keep-focus", + "--tab-title", + title, + "--title", + title, + *attach_cmd, + ], + capture=True, + ) + if out.returncode != 0: + raise RuntimeError( + "kitty @ launch failed — make sure 'allow_remote_control yes' " + f"is set in kitty.conf. stderr: {(out.stderr or '').strip()}" + ) + # kitty prints the window id of the new tab's first window. We use + # that for close-window; matching by id is more reliable than by + # title (title can be customized after the fact). + window_id = (out.stdout or "").strip() + return BlockHandle(backend=self.name, data={"window_id": window_id, "title": title}) + + def close_block(self, handle: BlockHandle) -> None: + """Close the window via ``kitty @ close-window --match id:``.""" + wid = handle.data.get("window_id") + if not wid: + return + self._run(["kitty", "@", "close-window", "--match", f"id:{wid}"]) + + def tile(self, handles: list[BlockHandle]) -> None: + """Switch the active tab to kitty's ``grid`` layout.""" + self._run(["kitty", "@", "goto-layout", "grid"]) + + def set_title(self, handle: BlockHandle, title: str) -> None: + """Rename the window via ``kitty @ set-window-title``.""" + wid = handle.data.get("window_id") + if not wid: + return + self._run(["kitty", "@", "set-window-title", "--match", f"id:{wid}", title]) + + def set_color(self, handle: BlockHandle, color: Color) -> None: + """Tint the containing tab via ``kitty @ set-tab-color``. + + Requires kitty >= 0.20. A non-zero exit (e.g. older kitty) + is silently ignored — the rest of the broadcast still works, + the user just doesn't get the visual hint. + """ + wid = handle.data.get("window_id") + if not wid: + return + hex_bg = _TAB_BG.get(color) + if not hex_bg: + return + self._run([ + "kitty", "@", "set-tab-color", + "--match", f"window_id:{wid}", + f"active_bg={hex_bg}", + f"inactive_bg={hex_bg}", + ]) diff --git a/csshx-latest/csshx_latest/launchers/manual.py b/csshx-latest/csshx_latest/launchers/manual.py new file mode 100644 index 0000000..2fd4d3c --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/manual.py @@ -0,0 +1,45 @@ +"""Universal fallback launcher: prints attach commands for the user to paste. + +Used when no specific terminal backend was recognized. Prints a +numbered list of attach commands to stdout — the user copies each one +into a tab/pane/window of any terminal they like. +""" +from __future__ import annotations + +import shlex +import sys + +from csshx_latest.launcher import BlockHandle, Color + + +class ManualLauncher: + """Print attach commands; tile/title/close are no-ops.""" + + name = "manual" + + def __init__(self) -> None: + self._counter = 0 + + def start(self, total: int) -> None: + """No-op: the manual launcher doesn't need a host-count hint.""" + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Print ``[N] # `` to stdout.""" + self._counter += 1 + n = self._counter + cmd_str = " ".join(shlex.quote(a) for a in attach_cmd) + sys.stdout.write(f"[{n}] {cmd_str} # {title}\n") + sys.stdout.flush() + return BlockHandle(backend=self.name, data={"index": n, "title": title}) + + def close_block(self, handle: BlockHandle) -> None: + """No-op: the user runs the attach command themselves.""" + + def tile(self, handles: list[BlockHandle]) -> None: + """No-op: nothing to tile when blocks are user-driven.""" + + def set_title(self, handle: BlockHandle, title: str) -> None: + """No-op: titles are whatever the user's terminal already shows.""" + + def set_color(self, handle: BlockHandle, color: Color) -> None: + """No-op: the manual launcher has no UI surface to paint.""" diff --git a/csshx-latest/csshx_latest/launchers/tmux.py b/csshx-latest/csshx_latest/launchers/tmux.py new file mode 100644 index 0000000..b7dd368 --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/tmux.py @@ -0,0 +1,135 @@ +"""Tmux launcher -- spawn each block as a pane in the active session. + +Author: Aditya Kapadia. + +Detects the ambient ``$TMUX`` session via ``detect_launcher``; the +launcher itself just shells out to ``tmux``. + +Window vs. pane policy +---------------------- + +With more than :data:`PANE_THRESHOLD` hosts, splitting the current +pane over and over leaves each ssh session squeezed into a vertical +ribbon that's unusable. Above the threshold, the first block instead +opens a fresh ``tmux new-window`` (still attached to the same session), +and subsequent blocks split inside that dedicated window. This keeps +the user's original window untouched, gives every host enough columns +to matter, and survives the eventual ``select-layout tiled``. + +The host count comes from :meth:`start`, called by the orchestrator +before any block opens. +""" +from __future__ import annotations + +import shlex +import subprocess +from typing import Optional + +from csshx_latest.launcher import BlockHandle, Color + +#: Above this many hosts, open a dedicated tmux window rather than +#: splitting the current pane. 4 is the largest count where a 2x2 split +#: stays readable on a typical 1080p / 1440p display. +PANE_THRESHOLD = 4 + +#: 256-color codes for per-pane border / background paint. +#: Mirrors the original csshX's "subtle dark tint" palette: dark green +#: for enabled, neutral grey for disabled, dark red for dead. These +#: stay readable against any reasonable terminal theme. +_COLOR_BG: dict[Color, str] = { + Color.ENABLED: "colour22", # dark green + Color.DISABLED: "colour237", # dark grey + Color.DEAD: "colour52", # dark red +} + + +class TmuxLauncher: + """Open each block as a tmux pane; isolate large clusters in a new window.""" + + name = "tmux" + + def __init__(self, target: Optional[str] = None, pane_threshold: int = PANE_THRESHOLD) -> None: + self._target = target + self._pane_threshold = pane_threshold + self._window_target: Optional[str] = None + self._opened = 0 + self._total = 0 + + @staticmethod + def _run(args: list[str], capture: bool = False) -> subprocess.CompletedProcess: + return subprocess.run(args, check=False, capture_output=capture, text=True) + + def start(self, total: int) -> None: + """Record the total host count so :meth:`open_block` can route the first split.""" + self._total = total + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Run ``tmux split-window`` (or ``new-window`` for the first of many).""" + cmd_str = " ".join(shlex.quote(a) for a in attach_cmd) + + if ( + self._opened == 0 + and self._total > self._pane_threshold + and not self._target + ): + new_cmd = ["tmux", "new-window", "-P", "-F", "#{pane_id}", "-n", "csshx"] + new_cmd.append(cmd_str) + out = self._run(new_cmd, capture=True) + pane_id = (out.stdout or "").strip() + self._window_target = pane_id or None + else: + split_cmd = ["tmux", "split-window", "-P", "-F", "#{pane_id}"] + anchor = self._target or self._window_target + if anchor: + split_cmd += ["-t", anchor] + split_cmd.append(cmd_str) + out = self._run(split_cmd, capture=True) + pane_id = (out.stdout or "").strip() + + if title and pane_id: + self._run(["tmux", "select-pane", "-t", pane_id, "-T", title]) + + if pane_id: + self._run(["tmux", "select-layout", "-t", pane_id, "tiled"]) + + self._opened += 1 + return BlockHandle(backend=self.name, data={"pane_id": pane_id, "title": title}) + + def close_block(self, handle: BlockHandle) -> None: + """Kill the pane opened for this block. Silent if already gone.""" + pane_id = handle.data.get("pane_id") + if not pane_id: + return + self._run(["tmux", "kill-pane", "-t", pane_id]) + + def tile(self, handles: list[BlockHandle]) -> None: + """Apply ``tiled`` layout to whichever window holds the panes.""" + if not handles: + return + first = handles[0].data.get("pane_id") + if not first: + return + self._run(["tmux", "select-layout", "-t", first, "tiled"]) + + def set_title(self, handle: BlockHandle, title: str) -> None: + """Rename a pane via ``tmux select-pane -T``.""" + pane_id = handle.data.get("pane_id") + if not pane_id: + return + self._run(["tmux", "select-pane", "-t", pane_id, "-T", title]) + + def set_color(self, handle: BlockHandle, color: Color) -> None: + """Tint the pane border + status to reflect broadcast state. + + Uses ``tmux select-pane -P bg=<colour>``, which paints the pane + body's "padding" / border tint without touching the remote + shell's ANSI state. The original csshX painted the AppKit + window title bar; we do the closest tmux equivalent. + """ + pane_id = handle.data.get("pane_id") + if not pane_id: + return + bg = _COLOR_BG.get(color) + if not bg: + return + self._run(["tmux", "select-pane", "-t", pane_id, "-P", f"bg={bg}"]) diff --git a/csshx-latest/csshx_latest/launchers/waveterm.py b/csshx-latest/csshx_latest/launchers/waveterm.py new file mode 100644 index 0000000..d827b5c --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/waveterm.py @@ -0,0 +1,249 @@ +"""WaveTerm launcher — opens and tiles blocks via the ``wsh`` CLI. + +The ``wsh`` subcommand grammar has churned across WaveTerm releases — +``setlayout`` was renamed, ``tile`` came and went, etc. :meth:`tile` +tries the known incantations in order and caches the first one that +exits 0 for the rest of the run, so we don't pay the cost of probing +(or risk the user seeing stderr from a stale grammar) on every tile. +""" +from __future__ import annotations + +import logging +import os +import shlex +import shutil +import subprocess +from typing import Optional + +from csshx_latest.launcher import BlockHandle, Color + +log = logging.getLogger(__name__) + +#: Ordered list of ``wsh`` subcommands that have meant "tile the current +#: tab" across WaveTerm versions. Probed left-to-right on first call. +_TILE_VARIANTS: tuple[tuple[str, ...], ...] = ( + ("setlayout", "tiled"), + ("layout", "tiled"), + ("tile",), +) + +#: Hex backgrounds per state. WaveTerm's ``wsh setbg`` accepts a CSS +#: color; we use the same palette family as tmux/kitty so the visual +#: cue stays consistent across backends. +_BG_HEX: dict[Color, str] = { + Color.ENABLED: "#0e3d0e", + Color.DISABLED: "#3a3a3a", + Color.DEAD: "#4d1414", +} + +#: Fallback locations to search for the ``wsh`` binary when it isn't on PATH +#: (e.g. when csshx-latest is launched directly via a WaveTerm widget's +#: ``controller: cmd``, which execvp's without a login shell so PATH is the +#: bare system default). +_WSH_FALLBACK_PATHS: tuple[str, ...] = ( + os.path.expanduser("~/Library/Application Support/waveterm/bin/wsh"), + "/Applications/Wave.app/Contents/Resources/app/bin/wsh", +) + + +def _resolve_wsh() -> str: + """Locate ``wsh``, preferring PATH then known WaveTerm install locations. + + Returns the resolved absolute path or the literal string ``"wsh"`` as a + last-resort so callers still get a meaningful FileNotFoundError if the + binary genuinely isn't installed. + """ + found = shutil.which("wsh") + if found: + return found + for candidate in _WSH_FALLBACK_PATHS: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return "wsh" + + +def _swap_waveterm_token(wsh_path: str) -> bool: + """Exchange ``WAVETERM_SWAPTOKEN`` for ``WAVETERM_JWT`` via ``wsh token``. + + WaveTerm widgets configured with ``controller: cmd`` (i.e. csshx-latest + is the block's direct exec, no shell init) get only ``WAVETERM_SWAPTOKEN`` + in their env — never the post-swap ``WAVETERM_JWT`` that ``wsh run`` / + ``wsh layout`` / ``wsh deleteblock`` / ``wsh settitle`` need to authenticate + against the Wave daemon. Shell controllers swap it themselves via the + ``wave-init`` script; under ``cmd`` we have to do it. + + ``wsh token <swaptoken> bash`` emits a bash init script of the form + ``export WAVETERM_JWT="..." \n export WAVETERM_BLOCKID="..." \n …``. + We parse those exports and merge them into ``os.environ`` so the + subsequent ``wsh`` subprocesses inherit a fully authenticated env. + + Returns ``True`` iff we successfully extracted *and exported* at least + ``WAVETERM_JWT``. No-ops (returning ``True``) when the env already has + ``WAVETERM_JWT`` set (i.e. running under ``controller: shell`` or from + an interactive WaveTerm prompt that already swapped). + """ + if os.environ.get("WAVETERM_JWT"): + return True # already swapped — nothing to do + swap = os.environ.get("WAVETERM_SWAPTOKEN") + if not swap: + return False + try: + proc = subprocess.run( + [wsh_path, "token", swap, "bash"], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError) as exc: + log.warning("wsh token swap failed to invoke: %s", exc) + return False + if proc.returncode != 0: + log.warning("wsh token swap exited %d: %s", proc.returncode, proc.stderr.strip()) + return False + exported = _parse_bash_exports(proc.stdout) + if "WAVETERM_JWT" not in exported: + log.warning("wsh token output missing WAVETERM_JWT; got keys=%s", sorted(exported)) + return False + os.environ.update(exported) + return True + + +def _parse_bash_exports(script: str) -> dict[str, str]: + """Pull ``export KEY=VALUE`` lines out of a bash init script. + + Uses :func:`shlex.split` (POSIX mode) to handle quoting so future + JWT formats with escapes don't silently break the swap. Any line + that doesn't parse as a single ``KEY=VALUE`` token after the + ``export`` keyword is skipped. + """ + out: dict[str, str] = {} + for raw in script.splitlines(): + line = raw.strip() + if not line.startswith("export "): + continue + body = line[len("export "):].lstrip() + try: + tokens = shlex.split(body, posix=True, comments=True) + except ValueError: + continue + if not tokens: + continue + first = tokens[0] + if "=" not in first: + continue + key, _, val = first.partition("=") + if not key or not (key[0].isalpha() or key[0] == "_"): + continue + if not key.replace("_", "").isalnum(): + continue + out[key] = val + return out + + +class WaveTermLauncher: + """Open each block via ``wsh run`` and tile via the closest available subcommand.""" + + name = "waveterm" + + def __init__(self) -> None: + self._counter = 0 + self._tile_cmd: Optional[tuple[str, ...]] = None + self._tile_probed = False + # ``setbg`` was added in a recent ``wsh`` release. We probe once + # (lazy on first :meth:`set_color` call) and cache the result so + # older WaveTerm installs aren't spammed with unknown-command + # errors. ``None`` = not probed; ``False`` = unsupported here; + # ``True`` = supported, keep calling. + self._setbg_supported: Optional[bool] = None + self._wsh = _resolve_wsh() + _swap_waveterm_token(self._wsh) + + def start(self, total: int) -> None: + """No-op: WaveTerm tile decisions are made per call, not up-front.""" + + @staticmethod + def _run(args: list[str], capture: bool = True) -> subprocess.CompletedProcess: + # capture=True by default so legacy wsh probes (setlayout/layout/tile, + # deleteblock, settitle) don't spam the user's terminal on modern wsh + # builds where those subcommands have been renamed or removed. + return subprocess.run(args, check=False, capture_output=capture, text=True) + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Spawn a new Wave block running ``attach_cmd``. + + Logs ``wsh`` failures at WARNING — the original behavior silently + swallowed stderr, which made it impossible to diagnose missing + ``WAVETERM_*`` env vars or auth failures from a widget that runs + csshx-latest under ``controller: cmd``. + """ + self._counter += 1 + out = self._run([self._wsh, "run", "--", *attach_cmd], capture=True) + if out.returncode != 0: + log.warning( + "wsh run for %s exited %d; stderr=%r stdout=%r", + title, out.returncode, out.stderr, out.stdout, + ) + block_id = "" + if out.stdout: + tail = out.stdout.strip().splitlines() + if tail: + block_id = tail[-1] + return BlockHandle( + backend=self.name, + data={"block_id": block_id, "title": title, "index": self._counter}, + ) + + def close_block(self, handle: BlockHandle) -> None: + """Delete the block (no-op if we never captured an id).""" + block_id = handle.data.get("block_id") + if not block_id: + return + self._run([self._wsh, "deleteblock", "-b", block_id]) + + def tile(self, handles: list[BlockHandle]) -> None: + """Run the cached ``wsh`` tile subcommand; probe + cache on first call.""" + if not self._tile_probed: + for attempt in _TILE_VARIANTS: + r = self._run([self._wsh, *attempt]) + if r.returncode == 0: + self._tile_cmd = attempt + break + self._tile_probed = True + return # The successful probe already tiled — don't double-run. + + if self._tile_cmd: + self._run([self._wsh, *self._tile_cmd]) + + def set_title(self, handle: BlockHandle, title: str) -> None: + """Rename a block via ``wsh settitle``.""" + block_id = handle.data.get("block_id") + if not block_id: + return + self._run([self._wsh, "settitle", "-b", block_id, title]) + + def set_color(self, handle: BlockHandle, color: Color) -> None: + """Tint the block background via ``wsh setbg`` (best-effort). + + Lazily probes once: if the local ``wsh`` doesn't ship ``setbg``, + we cache that and silently no-op every subsequent call so we + never burn a subprocess on a known-unsupported command. + """ + block_id = handle.data.get("block_id") + if not block_id: + return + if self._setbg_supported is False: + return + hex_bg = _BG_HEX.get(color) + if not hex_bg: + return + out = self._run([self._wsh, "setbg", "-b", block_id, hex_bg]) + if out.returncode != 0: + if self._setbg_supported is None: + log.debug( + "wsh setbg unsupported (exit=%d, stderr=%r); disabling for this run", + out.returncode, (out.stderr or "").strip(), + ) + self._setbg_supported = False + return + self._setbg_supported = True diff --git a/csshx-latest/csshx_latest/launchers/wezterm.py b/csshx-latest/csshx_latest/launchers/wezterm.py new file mode 100644 index 0000000..dc5d04b --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/wezterm.py @@ -0,0 +1,47 @@ +"""WezTerm launcher via ``wezterm cli spawn``.""" +from __future__ import annotations + +import subprocess + +from csshx_latest.launcher import BlockHandle, Color + + +class WezTermLauncher: + """Open each block as a new WezTerm pane via ``wezterm cli``.""" + + name = "wezterm" + + @staticmethod + def _run(args: list[str], capture: bool = False) -> subprocess.CompletedProcess: + return subprocess.run(args, check=False, capture_output=capture, text=True) + + def start(self, total: int) -> None: + """No-op: WezTerm balances panes automatically.""" + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Spawn a new pane and stamp the tab title with ``host``.""" + out = self._run(["wezterm", "cli", "spawn", "--", *attach_cmd], capture=True) + pane_id = (out.stdout or "").strip() + if title and pane_id: + self._run(["wezterm", "cli", "set-tab-title", "--pane-id", pane_id, title]) + return BlockHandle(backend=self.name, data={"pane_id": pane_id, "title": title}) + + def close_block(self, handle: BlockHandle) -> None: + """Kill the pane via ``wezterm cli kill-pane``.""" + pane_id = handle.data.get("pane_id") + if not pane_id: + return + self._run(["wezterm", "cli", "kill-pane", "--pane-id", pane_id]) + + def tile(self, handles: list[BlockHandle]) -> None: + """No-op: WezTerm tiles split panes evenly when they are created.""" + + def set_title(self, handle: BlockHandle, title: str) -> None: + """Rename the tab containing this pane.""" + pane_id = handle.data.get("pane_id") + if not pane_id: + return + self._run(["wezterm", "cli", "set-tab-title", "--pane-id", pane_id, title]) + + def set_color(self, handle: BlockHandle, color: Color) -> None: + """WezTerm has no CLI hook for per-pane background tint.""" diff --git a/csshx-latest/csshx_latest/logging_setup.py b/csshx-latest/csshx_latest/logging_setup.py new file mode 100644 index 0000000..3970a7e --- /dev/null +++ b/csshx-latest/csshx_latest/logging_setup.py @@ -0,0 +1,33 @@ +"""Project-wide logging configuration. + +Logging is opt-in (``--debug`` on the CLI). When enabled, every module +that does ``log = logging.getLogger(__name__)`` writes structured lines +to stderr with timestamps and module names so it's clear where a +message originates. The default is WARNING — quiet enough to keep the +TUI clean, loud enough to surface real problems. +""" +from __future__ import annotations + +import logging +import sys + + +def configure_logging(debug: bool = False) -> None: + """Install a stderr handler with a sensible format. + + Safe to call more than once; the root handler is replaced rather + than appended so repeated calls during tests don't multiply output. + """ + level = logging.DEBUG if debug else logging.WARNING + root = logging.getLogger() + for h in list(root.handlers): + root.removeHandler(h) + handler = logging.StreamHandler(stream=sys.stderr) + handler.setFormatter( + logging.Formatter( + fmt="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + ) + root.addHandler(handler) + root.setLevel(level) diff --git a/csshx-latest/csshx_latest/master.py b/csshx-latest/csshx_latest/master.py new file mode 100644 index 0000000..f81d969 --- /dev/null +++ b/csshx-latest/csshx_latest/master.py @@ -0,0 +1,30 @@ +"""Backward-compatibility shim. + +Author: Aditya Kapadia. + +The master module used to bundle the broadcaster, the TUI loop, the +attach-command builder, and the top-level orchestration in one file. +Those have since been split across :mod:`csshx_latest.broadcaster`, +:mod:`csshx_latest.tui`, and :mod:`csshx_latest.orchestrator` so each +piece can be unit-tested in isolation. Anything that used to import +from ``csshx_latest.master`` keeps working -- every public name is +re-exported here. +""" +from __future__ import annotations + +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.orchestrator import ( + attach_command, + make_socket_dir, + run_master, +) +from csshx_latest.tui import render_status, tui_loop + +__all__ = [ + "Broadcaster", + "attach_command", + "make_socket_dir", + "render_status", + "run_master", + "tui_loop", +] diff --git a/csshx-latest/csshx_latest/orchestrator.py b/csshx-latest/csshx_latest/orchestrator.py new file mode 100644 index 0000000..a999025 --- /dev/null +++ b/csshx-latest/csshx_latest/orchestrator.py @@ -0,0 +1,449 @@ +"""Top-level orchestration: spawn slaves, open blocks, run TUI, tear down. + +Author: Aditya Kapadia. + +Lives in its own module (instead of being stuffed into ``master.py``) +so the broadcaster, the TUI, and the orchestration glue can each be +tested in isolation. ``master.py`` is a thin shim that re-exports the +same names for backward compatibility. + +Async launcher dispatch +----------------------- + +Concrete launchers are synchronous -- they ``subprocess.run`` an +``osascript`` / ``wsh`` / ``tmux`` command and block until it returns. +Calling them straight from the event loop freezes the TUI for the +duration of every block-open (e.g. ~200ms per host on macOS osascript +calls). ``_open_block`` / ``_close_block`` / ``_tile`` run these +through ``asyncio.to_thread`` so the loop stays responsive. + +Preflight +--------- + +Before forking any ssh subprocess we open a 1s TCP connection to +``<host>:22`` for each host concurrently. Hosts that refuse or time +out are dropped (warn) or abort the run (``--strict``). Saves the user +from a screen full of dead panes when their VPN is down. + +Reconnect +--------- + +With ``--reconnect``, a slave whose ssh exits gets re-spawned with +exponential backoff (1s, 2s, 4s, ..., capped at 30s; max 5 attempts). +The block stays put; we just rebind the PTY behind it. +""" +from __future__ import annotations + +import asyncio +import logging +import os +import signal +import socket +import sys +import tempfile +import time +from typing import Optional + +from csshx_latest.auth import make_token, write_token_file +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.launcher import BlockHandle, Color, Launcher +from csshx_latest.slave import ( + Slave, + run_slave_bridge, + shutdown_slave, + spawn_slave, +) +from csshx_latest.terminal import get_winsize +from csshx_latest.tui import KEY_COMMAND_PREFIX, render_status, tui_loop + +log = logging.getLogger(__name__) + +#: Hard ceiling on hosts per run. Above this the orchestrator refuses +#: unless ``--max-hosts`` was raised. 16 keeps the most extreme accidents +#: (``web{1..1000}`` typos) from forking until fd exhaustion. +DEFAULT_MAX_HOSTS = 16 + +#: TCP connect timeout for the preflight check (seconds). +PREFLIGHT_TIMEOUT = 1.0 + +#: ssh-options injected when the user didn't override -o StrictHostKeyChecking. +#: ``accept-new`` auto-trusts unknown hosts but still rejects mismatches, +#: so first-connect prompts don't fan out across every broadcast slave. +_DEFAULT_SSH_OPTS = ("-o", "StrictHostKeyChecking=accept-new") + +#: Reconnect schedule (seconds between attempts). After the last entry we stop. +_RECONNECT_BACKOFF = (1.0, 2.0, 4.0, 8.0, 16.0) + + +def make_socket_dir() -> str: + """Create a 0700 directory for slave sockets + token files.""" + xdg = os.environ.get("XDG_RUNTIME_DIR") + base = xdg if xdg and os.path.isdir(xdg) else tempfile.gettempdir() + path = os.path.join(base, f"csshx-{os.getpid()}") + os.makedirs(path, mode=0o700, exist_ok=True) + os.chmod(path, 0o700) + return path + + +def attach_command(sock_path: str, token_path: str) -> list[str]: + """Build the attach command for a terminal block. + + Always uses the bundled stdlib attach client. It handles the dual + data + control socket protocol (SIGWINCH per-block resize lives on + the control channel) which a single ``socat`` invocation cannot. + The token is read from ``token_path`` at runtime so the literal + token never appears in any process's argv. + """ + return [sys.executable, "-m", "csshx_latest.attach", sock_path, token_path] + + +def maybe_inject_strict_host_key_opts(ssh_args: list[str]) -> list[str]: + """Prepend ``-o StrictHostKeyChecking=accept-new`` if the user didn't set it. + + Detecting "user set it" means: any token after ``-o`` mentions + ``StrictHostKeyChecking``. We don't try to parse arbitrary ssh-arg + grammars; we just look for the substring. + """ + if any("StrictHostKeyChecking" in a for a in ssh_args): + return list(ssh_args) + return [*_DEFAULT_SSH_OPTS, *ssh_args] + + +async def _open_block(launcher: Launcher, attach_cmd: list[str], title: str) -> BlockHandle: + return await asyncio.to_thread(launcher.open_block, attach_cmd, title) + + +async def _close_block(launcher: Launcher, handle: BlockHandle) -> None: + try: + await asyncio.to_thread(launcher.close_block, handle) + except Exception: + log.exception("close_block failed for %s", handle) + + +async def _tile(launcher: Launcher, handles: list[BlockHandle]) -> None: + if not handles: + return + try: + await asyncio.to_thread(launcher.tile, handles) + except Exception as exc: + log.warning("tile() failed: %s", exc) + + +async def _start_launcher(launcher: Launcher, total: int) -> None: + try: + await asyncio.to_thread(launcher.start, total) + except Exception as exc: + log.warning("launcher.start failed: %s", exc) + + +def _color_for(slave: Slave) -> Color: + """Map slave state to its visual color (mirrors original csshX).""" + if slave.dead: + return Color.DEAD + if slave.enabled: + return Color.ENABLED + return Color.DISABLED + + +def _should_reconnect(slave: Slave, reconnect_enabled: bool) -> bool: + """Return True iff a dead slave should be auto-respawned. + + Two conditions both have to hold: + + * the user passed ``--reconnect`` on the CLI, AND + * the slave was NOT killed by the user closing its visible terminal + block. A ``BYE`` on the control socket flips ``user_closed`` and + we honor that: the user explicitly ended this session, so resurrecting + it would be surprising. + """ + return reconnect_enabled and not slave.user_closed + + +async def _set_color(launcher: Launcher, slave: Slave) -> None: + """Push the slave's current state color to its block (best-effort).""" + if slave.handle is None: + return + try: + await asyncio.to_thread(launcher.set_color, slave.handle, _color_for(slave)) + except Exception as exc: + log.debug("set_color slave %d failed: %s", slave.index, exc) + + +async def _set_title(launcher: Launcher, slave: Slave, title: str) -> None: + """Push a per-block title rename (best-effort).""" + if slave.handle is None: + return + try: + await asyncio.to_thread(launcher.set_title, slave.handle, title) + except Exception as exc: + log.debug("set_title slave %d failed: %s", slave.index, exc) + + +def _master_winsize() -> tuple[int, int, int, int]: + """Best-effort: read the controlling tty's current size for slave init.""" + fd: Optional[int] = None + try: + if sys.stdin.isatty(): + fd = sys.stdin.fileno() + except (AttributeError, ValueError, OSError): + fd = None + if fd is None: + return (24, 80, 0, 0) + return get_winsize(fd) + + +async def _probe_host(host: str, port: int = 22, timeout: float = PREFLIGHT_TIMEOUT) -> bool: + """Return True if a TCP connection to ``host:port`` opens within ``timeout``. + + The host token may include a ``user@`` prefix; strip it for the + connect. Hostnames that don't resolve count as unreachable. + """ + target = host.split("@", 1)[1] if "@" in host else host + try: + coro = asyncio.open_connection(target, port) + reader, writer = await asyncio.wait_for(coro, timeout=timeout) + except (OSError, asyncio.TimeoutError, socket.gaierror): + return False + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return True + + +async def preflight_hosts(hosts: list[str], strict: bool) -> list[str]: + """Drop unreachable hosts (warn) or abort the run (``strict``). + + Probes every host concurrently. With ``strict=True`` an unreachable + host raises ``RuntimeError`` so the master never starts. With + ``strict=False`` the unreachable hosts are skipped and the rest + proceed. + """ + if not hosts: + return hosts + results = await asyncio.gather(*(_probe_host(h) for h in hosts)) + ok = [h for h, alive in zip(hosts, results) if alive] + dead = [h for h, alive in zip(hosts, results) if not alive] + for h in dead: + log.warning("preflight: %s is unreachable on tcp/22", h) + sys.stderr.write(f"warning: {h} unreachable on tcp/22 -- skipping\n") + if dead and strict: + raise RuntimeError(f"--strict: refusing to start, unreachable: {' '.join(dead)}") + return ok + + +def _kill_and_reap(pid: int, grace: float = 2.0) -> None: + """Poll-reap a child after SIGTERM; SIGKILL if the grace window expires. + + Replaces the unbounded ``waitpid(pid, 0)`` that could hang forever + if ssh refused to exit. + """ + if pid <= 0: + return + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + try: + done, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return + except OSError: + return + if done != 0: + return + time.sleep(0.05) + try: + os.kill(pid, signal.SIGKILL) + except OSError: + return + try: + os.waitpid(pid, 0) + except (ChildProcessError, OSError): + pass + + +async def _attempt_reconnect( + slave: Slave, + ssh_args: list[str], + login: Optional[str], + winsize: tuple[int, int, int, int], + launcher: Optional[Launcher] = None, +) -> None: + """Re-spawn ssh for a dead slave with exponential backoff. + + When ``launcher`` is passed, the block's title is updated to + ``<host> [reconnecting]`` during retry attempts and restored to + ``<host>`` on success — same visual feedback the original csshX + provided via its master-status line. + """ + if launcher is not None: + await _set_title(launcher, slave, f"{slave.host} [reconnecting]") + await _set_color(launcher, slave) # DEAD because slave.dead is True + for attempt, delay in enumerate(_RECONNECT_BACKOFF, start=1): + log.info("reconnect %s: attempt %d in %.1fs", slave.host, attempt, delay) + await asyncio.sleep(delay) + if not await _probe_host(slave.host): + log.info("reconnect %s: still unreachable", slave.host) + continue + try: + fresh = await spawn_slave( + index=slave.index, + host=slave.host, + sock_dir=os.path.dirname(slave.sock_path), + ssh_args=ssh_args, + login=login, + token=slave.token, + initial_winsize=winsize, + ) + except Exception as exc: + log.warning("reconnect %s: spawn failed: %s", slave.host, exc) + continue + slave.pty_master = fresh.pty_master + slave.pid = fresh.pid + slave.dead = False + write_token_file(slave.token_path, slave.token) + try: + await run_slave_bridge(slave) + except Exception as exc: + log.warning("reconnect %s: bridge failed: %s", slave.host, exc) + continue + if launcher is not None: + await _set_title(launcher, slave, slave.host) + await _set_color(launcher, slave) + sys.stderr.write(f"\r[csshx-latest] {slave.host} reconnected\r\n") + sys.stderr.flush() + return + log.info("reconnect %s: giving up after %d attempts", slave.host, len(_RECONNECT_BACKOFF)) + + +async def run_master( + hosts: list[str], + ssh_args: list[str], + login: Optional[str], + launcher: Launcher, + *, + max_hosts: int = DEFAULT_MAX_HOSTS, + strict_preflight: bool = False, + reconnect: bool = False, + skip_preflight: bool = False, + command_key: bytes = KEY_COMMAND_PREFIX, +) -> int: + """Top-level entry: spawn slaves, run the TUI, tear down on exit.""" + if len(hosts) > max_hosts: + sys.stderr.write( + f"refusing to start: {len(hosts)} hosts exceeds --max-hosts={max_hosts}. " + "Raise the cap explicitly or trim the host list.\n" + ) + return 2 + + if not skip_preflight: + try: + hosts = await preflight_hosts(hosts, strict_preflight) + except RuntimeError as exc: + sys.stderr.write(f"{exc}\n") + return 2 + if not hosts: + sys.stderr.write("no reachable hosts after preflight\n") + return 2 + + ssh_args = maybe_inject_strict_host_key_opts(ssh_args) + + sock_dir = make_socket_dir() + bcast = Broadcaster() + handles: list[BlockHandle] = [] + winsize = _master_winsize() + loop = asyncio.get_running_loop() + + # Live re-paint on every toggle (Ctrl-T 1..9 / Ctrl-T b). The + # callback is fired from the TUI's event-loop thread, so a + # bare ``create_task`` is enough — no thread bridge needed. + def _on_state_change(s: Slave) -> None: + try: + loop.create_task(_set_color(launcher, s)) + except RuntimeError: # pragma: no cover - loop closed + pass + + bcast.on_state_change = _on_state_change + + def on_slave_dead(s: Slave) -> None: + if s.user_closed: + log.info("slave %d (%s) exited (user closed block)", s.index, s.host) + else: + log.info("slave %d (%s) exited", s.index, s.host) + try: + render_status(bcast) + except Exception: # pragma: no cover - defensive + pass + # PTY reader runs on the loop thread, so this fires on the loop; + # schedule the repaint without threadsafe bridging. + try: + loop.create_task(_set_color(launcher, s)) + except RuntimeError: # pragma: no cover - loop closed + pass + # User-initiated close (via ``BYE`` on the control socket) must + # NOT trigger a reconnect — the user just told us this session + # is done. Without this guard, --reconnect would silently + # re-spawn ssh and the slave the user just closed would resurrect. + if _should_reconnect(s, reconnect): + asyncio.run_coroutine_threadsafe( + _attempt_reconnect(s, ssh_args, login, winsize, launcher), loop + ) + + await _start_launcher(launcher, len(hosts)) + + try: + for i, host in enumerate(hosts, start=1): + token = make_token() + slave = await spawn_slave( + index=i, + host=host, + sock_dir=sock_dir, + ssh_args=ssh_args, + login=login, + token=token, + initial_winsize=winsize, + ) + slave.on_dead = on_slave_dead + write_token_file(slave.token_path, token) + await run_slave_bridge(slave) + bcast.add(slave) + attach = attach_command(slave.sock_path, slave.token_path) + handle = await _open_block(launcher, attach, host) + slave.handle = handle + handles.append(handle) + await _tile(launcher, handles) + # Paint the initial ENABLED color now that we have a handle. + await _set_color(launcher, slave) + + await _tile(launcher, handles) + await tui_loop(bcast) + finally: + await asyncio.gather( + *(_close_block(launcher, h) for h in handles), + return_exceptions=True, + ) + for s in bcast.slaves: + shutdown_slave(s) + for s in bcast.slaves: + _kill_and_reap(s.pid) + try: + os.rmdir(sock_dir) + except OSError as exc: + log.debug("rmdir %s skipped: %s", sock_dir, exc) + return 0 + + +__all__ = [ + "Broadcaster", + "DEFAULT_MAX_HOSTS", + "attach_command", + "make_socket_dir", + "maybe_inject_strict_host_key_opts", + "preflight_hosts", + "render_status", + "run_master", + "tui_loop", +] + + +_signal = signal diff --git a/csshx-latest/csshx_latest/slave.py b/csshx-latest/csshx_latest/slave.py new file mode 100644 index 0000000..9f3bdeb --- /dev/null +++ b/csshx-latest/csshx_latest/slave.py @@ -0,0 +1,446 @@ +"""One SSH slave: PTY + ssh subprocess + UNIX-socket bridge. + +Author: Aditya Kapadia. + +The master forks ``ssh <host>`` attached to a fresh PTY and exposes +that PTY through two UNIX domain sockets, both gated by the same AUTH +token: + +* ``slave-N.sock`` -- the data socket. PTY bytes flow out, keystrokes + flow in. Bytes that arrive *before* the terminal block has connected + are kept in a per-slave scrollback buffer and replayed to each new + client immediately after AUTH succeeds. + +* ``slave-N.ctl`` -- the control socket. After AUTH it accepts + line-oriented ASCII commands, one per line. Supported commands:: + + WINSZ <rows> <cols> [<xpixel> <ypixel>] + BYE + + ``WINSZ`` applies ``TIOCSWINSZ`` to the PTY master so the remote + ssh side learns the new size when the *individual* terminal block + (not just the master) is resized. + + ``BYE`` signals "the user closed this block." The slave is marked + ``user_closed`` and the ssh pid is sent ``SIGTERM`` so the remote + session ends. The natural PTY-EOF path then fires ``on_dead`` so + the status footer's alive/dead counters update and (with + ``--reconnect``) the retry schedule is suppressed because the + termination was user-initiated. + + The grammar is intentionally forward-compatible: ``_apply_control_line`` + silently ignores any unknown command verb so an older attach client + never breaks when newer slaves grow new ones. Reserved future verbs + include ``BELL``, ``FOCUS``, ``RESIZE`` (a structured rename of + ``WINSZ``) — when adding a new verb, follow the WINSZ shape: + ``VERB <arg1> [<arg2> ...]\\n``, ASCII only, no quoting. + +Input direction (data socket -> PTY) accepts bytes from the focused +terminal block AND from the master's broadcaster, both serialized +through a per-slave ``write_lock`` so individual escape sequences are +never torn apart by interleaving writes. + +This module also handles: + +* a TOCTOU-safe ``start_unix_server`` (sockets created under ``umask + 0o077`` so they're mode 0600 from the moment they exist); +* dead-slave detection -- when the PTY reader sees EOF (ssh exited), + the slave is marked ``dead`` and an optional ``on_dead`` callback is + invoked; +* clean child reaping -- ``shutdown_slave`` calls ``waitpid`` after + ``SIGTERM`` so the parent doesn't accumulate ``<defunct>`` zombies. +""" +from __future__ import annotations + +import asyncio +import logging +import os +import signal +import socket +import threading +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Any, Callable, Iterator, Optional + +from csshx_latest.auth import authenticate +from csshx_latest.terminal import set_winsize + +log = logging.getLogger(__name__) + +#: Per-slave cap on simultaneous authenticated data-socket clients. +#: A legitimate run has one (the spawned terminal block). Allowing a +#: handful supports re-attach + a side-channel for tooling; rejecting +#: beyond that contains the blast radius of a leaked token. +DEFAULT_MAX_WRITERS = 4 + + +@dataclass +class Slave: + """State for one SSH connection.""" + + index: int + host: str + sock_path: str + token: str + pty_master: int + pid: int + token_path: str = "" + ctl_sock_path: str = "" + enabled: bool = True + dead: bool = False + #: Set to True when the visible terminal block is destroyed and its + #: attach client sent ``BYE``. Used by the orchestrator to suppress + #: ``--reconnect`` for slaves the user explicitly killed. + user_closed: bool = False + max_writers: int = DEFAULT_MAX_WRITERS + write_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + server: Optional[asyncio.AbstractServer] = field(default=None, repr=False) + ctl_server: Optional[asyncio.AbstractServer] = field(default=None, repr=False) + pty_reader_task: Optional[asyncio.Task] = field(default=None, repr=False) + connected_writers: list[asyncio.StreamWriter] = field(default_factory=list, repr=False) + scrollback: bytearray = field(default_factory=bytearray, repr=False) + scrollback_max: int = 65536 + state_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + on_dead: Optional[Callable[["Slave"], None]] = field(default=None, repr=False) + #: Opaque ``BlockHandle`` returned by ``launcher.open_block``. Stored + #: here so dead-slave / reconnect / state-change paths can repaint + #: the block without the orchestrator threading the mapping + #: separately. ``Any`` (not ``BlockHandle``) to avoid an import cycle. + handle: Optional[Any] = field(default=None, repr=False) + + +@contextmanager +def _temporary_umask(mask: int) -> Iterator[None]: + """Set process umask to ``mask`` for the duration of the block. + + Process-global; not thread-safe. The assertion enforces the + invariant the rest of the orchestrator relies on (single-threaded + slave setup on the event loop). If a future change starts running + socket creation through ``asyncio.to_thread`` the assertion will + fire and force introduction of a real lock rather than producing a + silent race. + """ + assert threading.current_thread() is threading.main_thread(), ( + "_temporary_umask is process-global; must be called on the main thread" + ) + prev = os.umask(mask) + try: + yield + finally: + os.umask(prev) + + +def _trim_scrollback(buf: bytearray, max_size: int) -> None: + """Trim ``buf`` so ``len(buf) <= max_size`` without splitting on an escape.""" + excess = len(buf) - max_size + if excess <= 0: + return + nl = buf.find(b"\n", excess) + if nl == -1: + cut = excess + else: + cut = nl + 1 + del buf[:cut] + + +async def spawn_slave( + index: int, + host: str, + sock_dir: str, + ssh_args: list[str], + login: Optional[str], + token: str, + initial_winsize: Optional[tuple[int, int, int, int]] = None, +) -> Slave: + """Fork ``ssh <host>`` attached to a new PTY and return its :class:`Slave`.""" + import pty + pty_master, pty_slave = pty.openpty() + if initial_winsize is None: + initial_winsize = (24, 80, 0, 0) + rows, cols, xp, yp = initial_winsize + set_winsize(pty_master, rows, cols, xp, yp) + + cmd = ["ssh", *ssh_args] + if login: + cmd += ["-l", login] + cmd.append(host) + + pid = os.fork() + if pid == 0: # pragma: no cover - child path + try: + os.setsid() + os.close(pty_master) + os.dup2(pty_slave, 0) + os.dup2(pty_slave, 1) + os.dup2(pty_slave, 2) + if pty_slave > 2: + os.close(pty_slave) + os.execvp(cmd[0], cmd) + except Exception as exc: + os.write(2, f"slave spawn failed: {exc}\n".encode()) + os._exit(127) + os.close(pty_slave) + + sock_path = os.path.join(sock_dir, f"slave-{index}.sock") + ctl_path = os.path.join(sock_dir, f"slave-{index}.ctl") + token_path = os.path.join(sock_dir, f"slave-{index}.token") + return Slave( + index=index, + host=host, + sock_path=sock_path, + ctl_sock_path=ctl_path, + token=token, + token_path=token_path, + pty_master=pty_master, + pid=pid, + ) + + +async def run_slave_bridge(slave: Slave) -> None: + """Start the data + control sockets and the PTY-fanout task for ``slave``.""" + loop = asyncio.get_running_loop() + + async def handle_data_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + if not await authenticate(reader, slave.token): + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return + async with slave.state_lock: + # Bound the writer fan-out so a leaked token can't be + # used to attach indefinitely. Reject *after* AUTH so the + # check itself isn't a probe oracle. + if len(slave.connected_writers) >= slave.max_writers: + log.warning( + "slave %d (%s): rejecting attach -- max_writers=%d reached", + slave.index, slave.host, slave.max_writers, + ) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return + if slave.scrollback: + writer.write(bytes(slave.scrollback)) + slave.connected_writers.append(writer) + try: + await writer.drain() + except Exception: + pass + try: + while True: + data = await reader.read(4096) + if not data: + break + if slave.dead: + break + async with slave.write_lock: + _write_all(slave.pty_master, data) + finally: + try: + slave.connected_writers.remove(writer) + except ValueError: + pass + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + async def handle_ctl_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + if not await authenticate(reader, slave.token): + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return + try: + while True: + line = await reader.readline() + if not line: + break + _apply_control_line(slave, line) + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + with _temporary_umask(0o077): + server = await asyncio.start_unix_server(handle_data_client, path=slave.sock_path) + if not slave.ctl_sock_path: + slave.ctl_sock_path = _derive_ctl_path(slave.sock_path) + ctl_server = await asyncio.start_unix_server(handle_ctl_client, path=slave.ctl_sock_path) + for path in (slave.sock_path, slave.ctl_sock_path): + try: + os.chmod(path, 0o600) + except OSError: + pass + slave.server = server + slave.ctl_server = ctl_server + + async def pty_to_sockets() -> None: + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + pipe = os.fdopen(slave.pty_master, "rb", buffering=0, closefd=False) + transport, _ = await loop.connect_read_pipe(lambda: protocol, pipe) + try: + while True: + data = await reader.read(4096) + if not data: + break + async with slave.state_lock: + slave.scrollback.extend(data) + _trim_scrollback(slave.scrollback, slave.scrollback_max) + writers = list(slave.connected_writers) + for w in writers: + try: + w.write(data) + except Exception: + try: + slave.connected_writers.remove(w) + except ValueError: + pass + for w in writers: + try: + await w.drain() + except Exception: + try: + slave.connected_writers.remove(w) + except ValueError: + pass + finally: + transport.close() + slave.dead = True + log.debug("slave %s (%s) PTY reached EOF -- marking dead", slave.index, slave.host) + if slave.on_dead is not None: + try: + slave.on_dead(slave) + except Exception: # pragma: no cover - defensive + log.exception("on_dead callback for slave %s raised", slave.index) + + slave.pty_reader_task = asyncio.create_task(pty_to_sockets()) + + +def _derive_ctl_path(data_path: str) -> str: + """Derive the control socket path from the data socket path.""" + if data_path.endswith(".sock"): + return data_path[: -len(".sock")] + ".ctl" + return data_path + ".ctl" + + +def _apply_control_line(slave: Slave, line: bytes) -> None: + """Parse and apply a single control-socket line. + + Supported grammar:: + + WINSZ <rows> <cols> [<xpixel> <ypixel>] + BYE + + Anything else is ignored (with a debug log) so the protocol can grow + without breaking older attach clients. + """ + try: + text = line.decode("ascii", errors="strict").strip() + except UnicodeDecodeError: + return + if not text: + return + parts = text.split() + if parts[0] == "BYE" and len(parts) == 1: + _handle_bye(slave) + return + if parts[0] != "WINSZ" or len(parts) not in (3, 5): + log.debug("slave %s: unknown control line %r", slave.index, text) + return + try: + rows = int(parts[1]) + cols = int(parts[2]) + xp = int(parts[3]) if len(parts) == 5 else 0 + yp = int(parts[4]) if len(parts) == 5 else 0 + except ValueError: + log.debug("slave %s: malformed WINSZ %r", slave.index, text) + return + if rows <= 0 or cols <= 0: + return + set_winsize(slave.pty_master, rows, cols, xp, yp) + + +def _handle_bye(slave: Slave) -> None: + """Treat ``BYE`` as user-initiated shutdown of this slave's session. + + Marks ``user_closed`` (so ``--reconnect`` skips retries) and sends + ``SIGTERM`` to the remote ssh pid. The natural PTY-EOF chain in + :func:`run_slave_bridge` then fires ``on_dead`` exactly once, so + the status footer repaints and the launcher's set_color hook + flips the block to DEAD without any extra plumbing. + + Idempotent: a second BYE on an already-dead slave is a no-op. + """ + if slave.user_closed: + return + slave.user_closed = True + log.debug("slave %s (%s) BYE received -- terminating ssh pid %d", + slave.index, slave.host, slave.pid) + if slave.pid > 0 and not slave.dead: + try: + os.kill(slave.pid, signal.SIGTERM) + except OSError as exc: + log.debug("slave %s SIGTERM failed: %s", slave.index, exc) + + +async def write_to_slave(slave: Slave, data: bytes) -> None: + """Write ``data`` to ``slave``'s PTY iff the slave is alive and enabled.""" + if not slave.enabled or slave.dead: + return + async with slave.write_lock: + try: + _write_all(slave.pty_master, data) + except OSError as exc: + slave.dead = True + log.debug("write to slave %s failed (%s) -- marking dead", slave.index, exc) + + +def _write_all(fd: int, data: bytes) -> None: + """``os.write`` until every byte has been delivered (handles short writes).""" + view = memoryview(data) + while view: + n = os.write(fd, view) + if n == 0: + break + view = view[n:] + + +def shutdown_slave(slave: Slave) -> None: + """Tear down a slave: stop servers, kill ssh, reap, close fds, unlink files.""" + if slave.server is not None: + slave.server.close() + if slave.ctl_server is not None: + slave.ctl_server.close() + if slave.pty_reader_task is not None and not slave.pty_reader_task.done(): + slave.pty_reader_task.cancel() + if slave.pid > 0: + try: + os.kill(slave.pid, signal.SIGTERM) + except OSError: + pass + try: + os.waitpid(slave.pid, os.WNOHANG) + except (ChildProcessError, OSError): + pass + try: + os.close(slave.pty_master) + except OSError: + pass + for path in (slave.sock_path, slave.ctl_sock_path, slave.token_path): + if not path: + continue + try: + os.unlink(path) + except OSError: + pass diff --git a/csshx-latest/csshx_latest/terminal.py b/csshx-latest/csshx_latest/terminal.py new file mode 100644 index 0000000..c707db5 --- /dev/null +++ b/csshx-latest/csshx_latest/terminal.py @@ -0,0 +1,153 @@ +"""Terminal helpers: raw-mode context manager and PTY winsize ioctls. + +These are tiny wrappers around termios / fcntl that hide the boilerplate +and degrade to no-ops on platforms without those modules (e.g. Windows), +so the package can at least be imported there. +""" +from __future__ import annotations + +import os +import struct +import sys +from contextlib import contextmanager +from typing import Iterator, Optional + +try: + import fcntl + import termios + import tty + _UNIX = True +except ImportError: # pragma: no cover - non-unix + _UNIX = False + + +def get_winsize(fd: int) -> tuple[int, int, int, int]: + """Return ``(rows, cols, xpixel, ypixel)`` for ``fd``. + + Falls back to ``(24, 80, 0, 0)`` if the ioctl fails or the platform + doesn't support TIOCGWINSZ. + """ + if not _UNIX: + return (24, 80, 0, 0) + try: + packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8) + return struct.unpack("HHHH", packed) + except OSError: + return (24, 80, 0, 0) + + +def set_winsize(fd: int, rows: int, cols: int, xpixel: int = 0, ypixel: int = 0) -> None: + """Set the window size on a PTY master ``fd`` via TIOCSWINSZ.""" + if not _UNIX: + return + packed = struct.pack("HHHH", rows, cols, xpixel, ypixel) + try: + fcntl.ioctl(fd, termios.TIOCSWINSZ, packed) + except OSError: + pass + + +# ANSI sequences to disable terminal modes that prompt frameworks like +# Powerlevel10k commonly leave enabled. xterm.js (WaveTerm, VSCode) honors +# these strictly; Apple Terminal is more permissive, which is why the +# breakage was WaveTerm-specific. +# +# Leading ``\e[!p`` is a DECSTR ("soft terminal reset") — clears most +# DEC private modes in one shot WITHOUT clearing the screen. The +# specific disables below are belt-and-suspenders for terminals that +# don't implement DECSTR (or implement it partially): +# +# \e[?2004l bracketed paste mode (otherwise input is wrapped in 200~/201~) +# \e> normal keypad (otherwise digits/Enter send SS3 sequences) +# \e[?1l normal cursor keys (otherwise arrows send SS3 not CSI) +# \e[?1000l X11 mouse: button events +# \e[?1002l X11 mouse: button-event tracking +# \e[?1003l X11 mouse: any-event tracking +# \e[?1004l focus reporting (otherwise focus in/out emits CSI I / CSI O) +# \e[?1006l SGR mouse encoding +# \e[?1015l urxvt mouse encoding +# \e[>4;0m disable xterm modifyOtherKeys (THIS was the WaveTerm killer: +# with this on, plain letter keys are encoded as +# ``\e[27;<mod>;<key>~`` extended sequences — broadcast to +# ssh, the remote shell sees garbage) +# \e[>1;0m disable modifyCursorKeys +# \e[>2;0m disable modifyFunctionKeys +# \e[?25h ensure cursor is visible (some prompts hide it) +_TERM_MODE_RESET = ( + b"\x1b[!p" + b"\x1b[?2004l" + b"\x1b>" + b"\x1b[?1l" + b"\x1b[?1000l" + b"\x1b[?1002l" + b"\x1b[?1003l" + b"\x1b[?1004l" + b"\x1b[?1006l" + b"\x1b[?1015l" + b"\x1b[>4;0m" + b"\x1b[>1;0m" + b"\x1b[>2;0m" + b"\x1b[?25h" +) + + +def reset_terminal_modes(fd: Optional[int] = None) -> None: + """Emit ANSI sequences that undo the modes prompt frameworks set. + + Safe to call on a non-tty (writes are silent or fail-closed). The + sequences are no-ops on terminals that don't implement them, so + there's no harm sending them everywhere — and they're essential on + xterm.js-based terminals (WaveTerm, VSCode) where p10k's bracketed + paste / application-keypad state otherwise garbles every keystroke + csshx-latest's TUI sees. + """ + if not _UNIX: + return + if fd is None: + try: + fd = sys.stdout.fileno() + except (AttributeError, ValueError, OSError): + return + if not os.isatty(fd): + return + try: + os.write(fd, _TERM_MODE_RESET) + except OSError: + pass + + +@contextmanager +def raw_mode(fd: Optional[int] = None) -> Iterator[None]: + """Put ``fd`` (default stdin) into termios raw mode; restore on exit. + + Also flushes any lingering terminal-mode state (bracketed paste, + application keypad, etc.) before going raw, so a prompt framework + like Powerlevel10k that left modes enabled in the parent shell + doesn't garble the bytes the TUI is about to read. No-ops on + non-Unix or when the fd is not a TTY. + """ + if not _UNIX: + yield + return + if fd is None: + fd = sys.stdin.fileno() + if not os.isatty(fd): + yield + return + saved = termios.tcgetattr(fd) + # Flush whatever the previous shell left buffered in the input queue + # (e.g. p10k's instant-prompt feedback the user couldn't see) before + # we go raw — otherwise the first broadcast cycle would replay it. + try: + termios.tcflush(fd, termios.TCIFLUSH) + except termios.error: + pass + reset_terminal_modes() + try: + tty.setraw(fd) + yield + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + # Re-emit the resets on exit so the user's next prompt isn't + # left in a half-raw state if csshx-latest crashed mid-loop. + reset_terminal_modes() diff --git a/csshx-latest/csshx_latest/tui.py b/csshx-latest/csshx_latest/tui.py new file mode 100644 index 0000000..95c3a69 --- /dev/null +++ b/csshx-latest/csshx_latest/tui.py @@ -0,0 +1,358 @@ +"""Master TUI: raw-mode stdin, status line, and command-mode dispatch. + +Author: Aditya Kapadia. + +Command mode (configurable prefix, default ``Ctrl-T``, then one key): + +* ``b`` -- toggle broadcast for ALL alive slaves +* ``1`` ... ``9`` -- toggle broadcast for that single slave +* ``i`` -- prompt for a slave index (for clusters with 10+ hosts) +* ``l`` -- list slaves with their state +* ``q`` -- quit +* ``?`` -- show command-mode help +* ``<prefix>`` (typed twice) -- send a literal prefix byte to slaves +* printable letter not in the dispatch -- cancel command mode AND + broadcast that letter (so typo doesn't silently vanish) +* control byte (Esc, Ctrl-C, ...) -- cancel command mode silently +""" +from __future__ import annotations + +import asyncio +import logging +import os +import signal +import sys + +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.terminal import get_winsize, raw_mode, set_winsize + +log = logging.getLogger(__name__) + +KEY_QUIT = b"\x11" # Ctrl-Q +KEY_COMMAND_PREFIX = b"\x14" # Ctrl-T (default prefix) +KEY_INDEX_PROMPT = b"i" + +#: ANSI escape codes for the colored status footer. Skipped when the +#: footer destination (stderr) isn't a TTY. +_ANSI_GREEN = "\x1b[32m" +_ANSI_RED = "\x1b[31m" +_ANSI_DIM = "\x1b[2m" +_ANSI_RESET = "\x1b[0m" + + +def parse_command_key(spec: str) -> bytes: + """Parse a ``--command-key`` spec into a single byte. + + Accepts: + + * ``^X`` / ``^x`` (Ctrl-X), where X is an ASCII letter + * ``0x14`` hex literal + * a single literal printable character + + Raises ``ValueError`` on anything else. + """ + if not spec: + raise ValueError("empty") + s = spec.strip() + if len(s) == 2 and s[0] == "^": + ch = s[1].upper() + if not ("A" <= ch <= "Z"): + raise ValueError(f"^X requires A-Z, got {s!r}") + return bytes([ord(ch) - 0x40]) + if s.lower().startswith("0x"): + try: + v = int(s, 16) + except ValueError: + raise ValueError(f"bad hex: {s!r}") + if not 0 <= v <= 0xFF: + raise ValueError(f"hex out of byte range: {s!r}") + return bytes([v]) + if len(s) == 1: + return s.encode("ascii", errors="strict") + raise ValueError(f"unrecognized command-key spec: {s!r}") + + +def _key_label(prefix: bytes) -> str: + """Render ``b"\\x14"`` as ``Ctrl-T`` for the help / status lines.""" + if not prefix or len(prefix) != 1: + return repr(prefix) + b = prefix[0] + if 1 <= b <= 26: + return f"Ctrl-{chr(b + 0x40)}" + return repr(prefix) + + +def render_status(bcast: Broadcaster, command_key: bytes = KEY_COMMAND_PREFIX) -> None: + """Write a one-line status footer to stderr. + + Colorizes the ``enabled`` / ``dead`` counters when stderr is a tty + so the eye can spot a broken host in a wall of text. + """ + total = len(bcast.slaves) + enabled = len(bcast.enabled_indices()) + dead = sum(1 for s in bcast.slaves if s.dead) + tty = sys.stderr.isatty() + if tty: + en_s = f"{_ANSI_GREEN}{enabled}{_ANSI_RESET}" if enabled else f"{_ANSI_DIM}0{_ANSI_RESET}" + dead_s = f"{_ANSI_RED}{dead}{_ANSI_RESET}" if dead else f"{_ANSI_DIM}0{_ANSI_RESET}" + else: + en_s = str(enabled) + dead_s = str(dead) + sys.stderr.write( + f"\r[csshx-latest] hosts: {total} enabled: {en_s} " + f"dead: {dead_s} (Ctrl-Q quit, {_key_label(command_key)} menu)\r\n" + ) + sys.stderr.flush() + + +def _write_msg(msg: str) -> None: + sys.stderr.write("\r" + msg + "\r\n") + sys.stderr.flush() + + +def _render_help(command_key: bytes = KEY_COMMAND_PREFIX) -> None: + label = _key_label(command_key) + _write_msg("--- csshx-latest command mode ---") + _write_msg(" b toggle broadcast for ALL alive slaves") + _write_msg(" 1..9 toggle broadcast for that single slave") + _write_msg(" i prompt for a slave index (for 10+ hosts)") + _write_msg(" l list slaves and their state") + _write_msg(" q quit") + _write_msg(" ? show this help") + _write_msg(f" {label:<7} send a literal {label}") + _write_msg(" (other) cancel command mode (printable echoes)") + + +def _render_list(bcast: Broadcaster) -> None: + _write_msg(f"--- {len(bcast.slaves)} slaves ---") + for s in bcast.slaves: + state = "DEAD" if s.dead else ("ON" if s.enabled else "off") + _write_msg(f" [{s.index:>3}] {s.host:<30} {state}") + + +def _toggle_slave(bcast: Broadcaster, index: int) -> None: + try: + new_state = bcast.toggle(index) + except KeyError: + _write_msg(f"no slave with index {index}") + return + _write_msg(f"slave [{index}] -> {'ON' if new_state else 'off'}") + + +class _CommandState: + """State machine for command mode: prefix -> dispatch / index-prompt.""" + + def __init__(self) -> None: + self.in_command = False + self.in_index_prompt = False + self.index_buffer = bytearray() + + def reset(self) -> None: + self.in_command = False + self.in_index_prompt = False + self.index_buffer.clear() + + +async def _handle_command_byte( + bcast: Broadcaster, + byte: int, + quit_event: asyncio.Event, + command_key: bytes = KEY_COMMAND_PREFIX, +) -> bytes: + """Apply one command-mode keystroke. + + Returns any bytes that should still be broadcast. Two cases push + bytes back into the broadcast stream: + + * the user typed the prefix twice -> send a literal prefix byte + * the user typed a printable letter that isn't bound -> cancel + command mode AND broadcast that letter (so a typo never silently + vanishes, matching the original csshX behavior) + """ + ch = bytes([byte]) + if ch == command_key: + return command_key + if ch == b"b": + any_enabled = any(s.enabled for s in bcast.slaves if not s.dead) + bcast.set_all_enabled(not any_enabled) + _write_msg(f"broadcast -> {'OFF' if any_enabled else 'ON'} for all alive slaves") + render_status(bcast, command_key) + return b"" + if ch in (b"1", b"2", b"3", b"4", b"5", b"6", b"7", b"8", b"9"): + _toggle_slave(bcast, int(ch)) + render_status(bcast, command_key) + return b"" + if ch == b"l": + _render_list(bcast) + render_status(bcast, command_key) + return b"" + if ch == b"q": + _write_msg("quitting...") + quit_event.set() + return b"" + if ch == b"?": + _render_help(command_key) + render_status(bcast, command_key) + return b"" + # Printable ASCII that wasn't bound: cancel command mode and let the + # byte through so the user's typo lands in the broadcast stream + # instead of silently disappearing. Control bytes (Esc, Ctrl-C, etc.) + # cancel silently. + if 0x20 <= byte <= 0x7E: + _write_msg(f"(command-mode cancelled; broadcasting {ch!r})") + render_status(bcast, command_key) + return ch + _write_msg("(command-mode cancelled)") + render_status(bcast, command_key) + return b"" + + +async def tui_loop( + bcast: Broadcaster, command_key: bytes = KEY_COMMAND_PREFIX +) -> None: + """Read stdin in raw mode and broadcast keystrokes; render a status line.""" + if not sys.stdin.isatty(): + await asyncio.Event().wait() + return + + loop = asyncio.get_running_loop() + quit_event = asyncio.Event() + + def on_sigwinch() -> None: + rows, cols, xp, yp = get_winsize(sys.stdin.fileno()) + for s in bcast.slaves: + set_winsize(s.pty_master, rows, cols, xp, yp) + + def on_quit_signal() -> None: + quit_event.set() + + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + try: + loop.add_signal_handler(sig, on_quit_signal) + except (NotImplementedError, RuntimeError): + pass + try: + loop.add_signal_handler(signal.SIGWINCH, on_sigwinch) + except (NotImplementedError, RuntimeError, AttributeError): + pass + + on_sigwinch() + # One-line startup hint so first-time users discover the menu prefix + # without reading docs. Skipped if stderr isn't a TTY (logs, pipes). + if sys.stderr.isatty(): + _write_msg( + f"[csshx-latest] press {_key_label(command_key)} for the command menu, " + "Ctrl-Q to quit." + ) + render_status(bcast, command_key) + + with raw_mode(): + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + pipe = os.fdopen(sys.stdin.fileno(), "rb", buffering=0, closefd=False) + transport, _ = await loop.connect_read_pipe(lambda: protocol, pipe) + + state = _CommandState() + + async def reader_task() -> None: + while True: + data = await reader.read(64) + if not data: + quit_event.set() + return + if KEY_QUIT in data: + quit_event.set() + return + if ( + not state.in_command + and not state.in_index_prompt + and command_key not in data + ): + await bcast.broadcast(data) + continue + await _drain_with_command_handling( + data, bcast, state, quit_event, command_key + ) + + task = asyncio.create_task(reader_task()) + try: + await quit_event.wait() + finally: + task.cancel() + transport.close() + + +async def _drain_with_command_handling( + data: bytes, + bcast: Broadcaster, + state: _CommandState, + quit_event: asyncio.Event, + command_key: bytes = KEY_COMMAND_PREFIX, +) -> None: + """Walk a chunk byte-by-byte when command / index-prompt mode is live.""" + buf = bytearray() + for b in data: + if state.in_index_prompt: + if buf: + await bcast.broadcast(bytes(buf)) + buf.clear() + _consume_index_prompt_byte(b, bcast, state, command_key) + continue + if state.in_command: + if buf: + await bcast.broadcast(bytes(buf)) + buf.clear() + if bytes([b]) == KEY_INDEX_PROMPT: + state.in_command = False + state.in_index_prompt = True + state.index_buffer.clear() + _write_msg("index: (type digits, Enter to apply, Esc to cancel)") + continue + extra = await _handle_command_byte(bcast, b, quit_event, command_key) + state.in_command = False + if extra: + buf.extend(extra) + continue + if bytes([b]) == command_key: + if buf: + await bcast.broadcast(bytes(buf)) + buf.clear() + state.in_command = True + _write_msg("command mode (press ? for help)") + continue + buf.append(b) + if buf: + await bcast.broadcast(bytes(buf)) + + +def _consume_index_prompt_byte( + b: int, + bcast: Broadcaster, + state: _CommandState, + command_key: bytes = KEY_COMMAND_PREFIX, +) -> None: + """Process one byte while we're collecting digits for the index prompt.""" + if b in (0x1B, 0x03): + _write_msg("(index prompt cancelled)") + state.reset() + render_status(bcast, command_key) + return + if b in (ord("\r"), ord("\n")): + if not state.index_buffer: + _write_msg("(no index given)") + else: + try: + idx = int(state.index_buffer.decode("ascii")) + except ValueError: + _write_msg("(not a number)") + else: + _toggle_slave(bcast, idx) + state.reset() + render_status(bcast, command_key) + return + if b in (0x7F, 0x08): + if state.index_buffer: + state.index_buffer.pop() + return + if 0x30 <= b <= 0x39: + state.index_buffer.append(b) diff --git a/csshx-latest/pyproject.toml b/csshx-latest/pyproject.toml new file mode 100644 index 0000000..fa87d82 --- /dev/null +++ b/csshx-latest/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "csshx-latest" +version = "0.2.0" +description = "Modern, terminal-agnostic cluster-SSH (csshX rewrite)." +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} +authors = [{name = "Aditya Kapadia"}] +keywords = ["ssh", "cluster", "csshx", "terminal", "broadcast"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Networking", + "Topic :: System :: Systems Administration", +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/akapadia/csshx-latest" +Issues = "https://github.com/akapadia/csshx-latest/issues" +Source = "https://github.com/akapadia/csshx-latest" + +[project.optional-dependencies] +test = ["pytest>=7"] + +[project.scripts] +csshx-latest = "csshx_latest.__main__:main" + +[tool.setuptools.packages.find] +include = ["csshx_latest*"] diff --git a/csshx-latest/tests/__init__.py b/csshx-latest/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/csshx-latest/tests/conftest.py b/csshx-latest/tests/conftest.py new file mode 100644 index 0000000..8bc1ae5 --- /dev/null +++ b/csshx-latest/tests/conftest.py @@ -0,0 +1,90 @@ +"""Shared pytest fixtures. + +* ``short_socket_dir`` — pytest's ``tmp_path`` lives under + ``/private/var/folders/.../pytest-of-USER/pytest-N/test_name0/``, + whose path length routinely blows past macOS's 104-byte ``sun_path`` + limit and crashes ``bind()`` on AF_UNIX sockets. + ``tempfile.mkdtemp(prefix="csshx-")`` gives us a path under ``/tmp`` + (or wherever ``$TMPDIR`` points) that's short enough to leave room + for a filename below it. + +* ``harmless_pid`` — ``shutdown_slave`` calls ``os.kill(pid, SIGTERM)``, + and ``os.kill(0, ...)`` on POSIX signals every process in the + caller's process group — i.e. pytest itself. Bridge tests must never + pass ``pid=0`` to a Slave. This fixture spawns a short-lived + ``time.sleep`` subprocess so the test gets a real, isolated PID, and + reaps it on teardown. + +* ``stdio_devnull`` — ``attach.main()`` calls ``sys.stdin.fileno()`` and + ``sys.stdout.fileno()`` to drive its ``select()`` loop. pytest's + default ``capsys`` capture replaces those with StringIO mocks that + don't expose real fds, so calls to ``.fileno()`` raise. This fixture + swaps in real file objects opened on ``os.devnull`` before the test + body runs and closes them on teardown. ``sys.stderr`` is left alone + so ``capsys.readouterr().err`` keeps working for assertions. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile + +import pytest + + +@pytest.fixture +def short_socket_dir(): + """Yield a tempdir whose paths fit in macOS's 104-byte ``sun_path``.""" + d = tempfile.mkdtemp(prefix="csshx-") + try: + yield d + finally: + shutil.rmtree(d, ignore_errors=True) + + +@pytest.fixture +def harmless_pid(): + """Yield the PID of a short-lived sleep subprocess; reap on teardown.""" + proc = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + yield proc.pid + finally: + try: + proc.kill() + except OSError: + pass + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: # pragma: no cover + pass + + +@pytest.fixture +def stdio_devnull(monkeypatch, capsys): + """Replace ``sys.stdin``/``sys.stdout`` with real fds on ``os.devnull``. + + Required by tests that call ``attach.main()`` because pytest's + ``capsys`` capture leaves ``sys.stdin``/``sys.stdout`` without a usable + ``.fileno()``. ``sys.stderr`` is left untouched so ``capsys`` still + captures the error messages we assert on. + + The ``capsys`` dependency forces pytest to set up its capture wrappers + *before* this fixture monkey-patches sys.stdin/sys.stdout — otherwise + capsys overrides our devnull file objects and ``.fileno()`` blows up. + """ + fin = open(os.devnull, "rb") + fout = open(os.devnull, "wb") + monkeypatch.setattr("sys.stdin", fin) + monkeypatch.setattr("sys.stdout", fout) + try: + yield + finally: + fin.close() + fout.close() diff --git a/csshx-latest/tests/test_action.py b/csshx-latest/tests/test_action.py new file mode 100644 index 0000000..01dfbed --- /dev/null +++ b/csshx-latest/tests/test_action.py @@ -0,0 +1,137 @@ +"""Tests for ``csshx_latest.action.run_action`` (one-shot broadcast). + +Action mode replaces the TUI with a fan-out ssh-exec: every host runs +the same command concurrently and we print a per-host summary. The +external ``ssh`` binary is stubbed via ``asyncio.create_subprocess_exec`` +so the tests are hermetic. +""" +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from csshx_latest.action import ActionResult, run_action + + +class _FakeProc: + """Subset of ``asyncio.subprocess.Process`` used by ``run_action``.""" + + def __init__(self, rc: int, stdout: bytes = b"", stderr: bytes = b"") -> None: + self.returncode = rc + self._stdout = stdout + self._stderr = stderr + self.killed = False + + async def communicate(self) -> tuple[bytes, bytes]: + return self._stdout, self._stderr + + def kill(self) -> None: # pragma: no cover - timeout-only path + self.killed = True + + async def wait(self) -> int: # pragma: no cover - timeout-only path + return self.returncode + + +def _patch_subprocess(monkeypatch, recipe: dict[str, _FakeProc]) -> list[list[str]]: + """Patch ``asyncio.create_subprocess_exec`` to return canned procs by host. + + Returns the captured argv list so tests can assert on the ssh args. + """ + captured: list[list[str]] = [] + + async def fake_create(*args: Any, **_kwargs: Any) -> _FakeProc: + argv = list(args) + captured.append(argv) + # The host is the second-to-last argv element (last is the + # remote command), and the recipe is keyed by host. + host = argv[-2] + if host not in recipe: + raise AssertionError(f"unexpected host in argv: {host} ({argv})") + return recipe[host] + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create) + return captured + + +def test_action_zero_hosts_returns_two(capsys): + """No hosts at all → exit 2 with a stderr message.""" + rc = asyncio.run(run_action([], [], None, "uname -a")) + assert rc == 2 + assert "no hosts" in capsys.readouterr().err + + +def test_action_empty_command_returns_two(capsys): + """Whitespace-only command is rejected; exit 2.""" + rc = asyncio.run(run_action(["h1"], [], None, " ")) + assert rc == 2 + assert "empty --action command" in capsys.readouterr().err + + +def test_action_all_hosts_succeed_returns_zero(monkeypatch, capsys): + """Every host rc=0 → run_action returns 0 and the report mentions success.""" + _patch_subprocess( + monkeypatch, + { + "h1": _FakeProc(0, stdout=b"ok1\n"), + "h2": _FakeProc(0, stdout=b"ok2\n"), + }, + ) + rc = asyncio.run(run_action(["h1", "h2"], [], None, "uname -a")) + assert rc == 0 + out = capsys.readouterr().out + assert "ok1" in out and "ok2" in out + assert "2 ok, 0 failed" in out + + +def test_action_one_host_fails_propagates_worst_rc(monkeypatch): + """A single non-zero remote rc surfaces as the master's exit code.""" + _patch_subprocess( + monkeypatch, + { + "ok": _FakeProc(0), + "bad": _FakeProc(7, stderr=b"boom\n"), + }, + ) + rc = asyncio.run(run_action(["ok", "bad"], [], None, "true")) + assert rc == 7 + + +def test_action_injects_batchmode_when_user_did_not(monkeypatch): + """Action mode auto-adds BatchMode=yes so a host that would prompt fails fast.""" + captured = _patch_subprocess(monkeypatch, {"h": _FakeProc(0)}) + asyncio.run(run_action(["h"], [], None, "true")) + flat = " ".join(captured[0]) + assert "BatchMode=yes" in flat + + +def test_action_respects_user_provided_batchmode(monkeypatch): + """If the user already passed -o BatchMode=no, we don't double-set it.""" + captured = _patch_subprocess(monkeypatch, {"h": _FakeProc(0)}) + asyncio.run( + run_action( + ["h"], + ["-o", "BatchMode=no"], + None, + "true", + ) + ) + # The injected BatchMode=yes must NOT appear (only the user's no). + bm_tokens = [a for a in captured[0] if "BatchMode" in a] + assert bm_tokens == ["BatchMode=no"] + + +def test_action_passes_login_as_dash_l(monkeypatch): + """``--login alice`` should become ``ssh -l alice <host> <cmd>``.""" + captured = _patch_subprocess(monkeypatch, {"h": _FakeProc(0)}) + asyncio.run(run_action(["h"], [], "alice", "id")) + argv = captured[0] + li = argv.index("-l") + assert argv[li + 1] == "alice" + + +def test_action_result_dataclass_defaults(): + """ActionResult.timed_out defaults to False.""" + r = ActionResult(host="h", returncode=0, stdout="", stderr="") + assert r.timed_out is False diff --git a/csshx-latest/tests/test_attach.py b/csshx-latest/tests/test_attach.py new file mode 100644 index 0000000..5c7e0f4 --- /dev/null +++ b/csshx-latest/tests/test_attach.py @@ -0,0 +1,260 @@ +"""Tests for the fallback attach client. + +Covers: + +* the new "token file" contract (the token is read from a file path, + not embedded in argv, so ``ps`` can't be used to harvest it); +* the "AUTH rejected" exit path (master closes the socket before + sending data → diagnostic to stderr + exit 1, so the user notices + the problem rather than seeing the spawned block silently die); +* the happy-path exit code (master sends some bytes then closes → + client returns 0); +* argv / connect-error handling. +""" +from __future__ import annotations + +import os +import socket +import sys +import threading + +import pytest + +if sys.platform == "win32": # pragma: no cover - skip path + pytest.skip("AF_UNIX socket tests skip on Windows", allow_module_level=True) + +from csshx_latest import attach + + +def _start_unix_server(sock_path: str, on_accept) -> tuple[socket.socket, threading.Thread]: + """Bind a Unix socket and run ``on_accept(conn)`` in a daemon thread.""" + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(sock_path) + srv.listen(1) + + def loop() -> None: + try: + conn, _ = srv.accept() + except OSError: + return + try: + on_accept(conn) + finally: + try: + conn.close() + except OSError: + pass + + t = threading.Thread(target=loop, daemon=True) + t.start() + return srv, t + + +def _write_token(token_dir: str, token: str) -> str: + """Persist a token to a 0600 file inside ``token_dir`` and return its path.""" + path = os.path.join(token_dir, "tok") + with open(path, "w", encoding="ascii") as fh: + fh.write(token) + os.chmod(path, 0o600) + return path + + +def test_auth_rejection_returns_1_with_clear_stderr( + short_socket_dir, stdio_devnull, capsys +): + """Server closes immediately after reading AUTH → client must exit 1.""" + sock_path = os.path.join(short_socket_dir, "rejecting.sock") + token_path = _write_token(short_socket_dir, "BAD_TOKEN") + + auth_sent = threading.Event() + + def reject(conn: socket.socket) -> None: + # Drain whatever AUTH bytes the client sends so its sendall completes, + # then close without ever writing back. This is exactly what the real + # server does on a bad token. + try: + conn.recv(4096) + except OSError: + pass + auth_sent.set() + + srv, t = _start_unix_server(sock_path, reject) + try: + rc = attach.main(["attach", sock_path, token_path]) + finally: + srv.close() + t.join(timeout=2) + + err = capsys.readouterr().err + assert rc == 1 + assert "AUTH rejected" in err + + +def test_clean_eof_after_data_returns_0(short_socket_dir, stdio_devnull, capsys): + """Server sends some bytes then closes → client exits 0 (normal disconnect).""" + sock_path = os.path.join(short_socket_dir, "happy.sock") + token_path = _write_token(short_socket_dir, "TOKEN") + + def serve(conn: socket.socket) -> None: + try: + conn.recv(4096) # consume AUTH line + conn.sendall(b"hello from master\n") + except OSError: + pass + + srv, t = _start_unix_server(sock_path, serve) + try: + rc = attach.main(["attach", sock_path, token_path]) + finally: + srv.close() + t.join(timeout=2) + + err = capsys.readouterr().err + assert rc == 0 + assert "AUTH rejected" not in err + + +def test_bad_argv_returns_2(capsys): + rc = attach.main(["attach"]) + assert rc == 2 + assert "usage:" in capsys.readouterr().err + + +def test_connect_failure_returns_1(short_socket_dir, capsys): + """Connecting to a nonexistent socket prints an error and returns 1.""" + sock_path = os.path.join(short_socket_dir, "does-not-exist.sock") + token_path = _write_token(short_socket_dir, "tok") + rc = attach.main(["attach", sock_path, token_path]) + err = capsys.readouterr().err + assert rc == 1 + assert "connect" in err + + +def test_missing_token_file_returns_1(short_socket_dir, capsys): + """Token file missing → exit 1 with a clear diagnostic, no socket attempt.""" + sock_path = os.path.join(short_socket_dir, "any.sock") + bogus_token_path = os.path.join(short_socket_dir, "does-not-exist.tok") + rc = attach.main(["attach", sock_path, bogus_token_path]) + err = capsys.readouterr().err + assert rc == 1 + assert "token" in err + + +def test_send_bye_writes_bye_line_to_ctl_sock(): + """``_send_bye`` writes the literal ``BYE\\n`` to its argument socket.""" + parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + try: + attach._send_bye(parent) + # Drain whatever the kernel buffered. ``BYE\n`` is 4 bytes; one recv + # is enough on a freshly-created stream socket. + got = child.recv(64) + assert got == b"BYE\n" + finally: + parent.close() + child.close() + + +def test_send_bye_is_silent_on_closed_socket(): + """``_send_bye`` must swallow OSError so it can run from a signal handler.""" + parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + child.close() + parent.close() + # Must not raise even though the underlying fd is dead. + attach._send_bye(parent) + + +def test_send_bye_handles_none_ctl_sock(): + """``_send_bye(None)`` is a silent no-op (control socket may have failed).""" + attach._send_bye(None) # must not raise + + +def test_stdin_eof_emits_bye_on_ctl_socket( + short_socket_dir, stdio_devnull, capsys +): + """Closing the visible block (stdin EOF) must push ``BYE`` to the master. + + This is the contract that makes the alive/dead counter update when + a user closes a Terminal.app window / iTerm2 pane / tmux pane. We + stand up both a data and a control AF_UNIX socket; the data socket + sends a byte (so the client moves past the "AUTH rejected" early- + exit guard), and the control socket records everything that + arrives. With ``stdio_devnull`` the client's stdin reads EOF on the + first iteration, the EOF branch fires ``_send_bye``, and we should + see ``BYE\\n`` on the control socket. + """ + sock_path = os.path.join(short_socket_dir, "happy.sock") + ctl_path = os.path.join(short_socket_dir, "happy.ctl") + token_path = _write_token(short_socket_dir, "TOKEN") + + def serve_data(conn: socket.socket) -> None: + try: + conn.recv(4096) # AUTH + conn.sendall(b"hello\n") # arms received_any so EOF returns 0 + except OSError: + pass + + ctl_received: list[bytes] = [] + ctl_done = threading.Event() + + def serve_ctl(conn: socket.socket) -> None: + try: + conn.recv(4096) # AUTH + # Loop briefly to collect post-AUTH lines. + conn.settimeout(2.0) + while True: + try: + chunk = conn.recv(4096) + except (OSError, socket.timeout): + break + if not chunk: + break + ctl_received.append(chunk) + finally: + ctl_done.set() + + srv_d, t_d = _start_unix_server(sock_path, serve_data) + srv_c, t_c = _start_unix_server(ctl_path, serve_ctl) + try: + rc = attach.main(["attach", sock_path, token_path]) + finally: + srv_d.close() + srv_c.close() + ctl_done.wait(timeout=2) + t_d.join(timeout=2) + t_c.join(timeout=2) + + assert rc == 0 + blob = b"".join(ctl_received) + # The control socket must have seen the BYE line we send on stdin EOF. + # WINSZ probes may also be sent (depending on devnull's ioctl support) + # but BYE is the one we care about. + assert b"BYE\n" in blob, f"expected BYE in ctl traffic, got: {blob!r}" + + +def test_token_file_contents_are_used(short_socket_dir, stdio_devnull, capsys): + """The bytes sent on AUTH must come from the token file, not from argv.""" + sock_path = os.path.join(short_socket_dir, "auth.sock") + secret = "this-is-the-actual-token-7f3a" + token_path = _write_token(short_socket_dir, secret) + + received_lines: list[bytes] = [] + + def capture(conn: socket.socket) -> None: + try: + data = conn.recv(4096) + except OSError: + data = b"" + received_lines.append(data) + # Close without sending → client should exit 1 (AUTH rejected), + # but the test only cares about what was *sent* in AUTH. + + srv, t = _start_unix_server(sock_path, capture) + try: + attach.main(["attach", sock_path, token_path]) + finally: + srv.close() + t.join(timeout=2) + + assert received_lines, "server received nothing on the socket" + assert received_lines[0].startswith(b"AUTH ") + assert secret.encode("ascii") in received_lines[0] diff --git a/csshx-latest/tests/test_auth.py b/csshx-latest/tests/test_auth.py new file mode 100644 index 0000000..f5b21dc --- /dev/null +++ b/csshx-latest/tests/test_auth.py @@ -0,0 +1,63 @@ +"""Tests for the AUTH handshake on slave sockets.""" +from __future__ import annotations + +import asyncio + +import pytest + +from csshx_latest import auth + + +def _run_handshake(payload: bytes, expected: str) -> bool: + """Feed ``payload`` into a StreamReader and run the AUTH handshake.""" + async def go() -> bool: + r = asyncio.StreamReader() + r.feed_data(payload) + r.feed_eof() + return await auth.authenticate(r, expected) + + return asyncio.run(go()) + + +def test_make_token_uniqueness_and_shape(): + a = auth.make_token() + b = auth.make_token() + assert a != b + assert len(a) == 64 + assert all(c in "0123456789abcdef" for c in a) + + +def test_authenticate_correct_token(): + token = "abc123" + assert _run_handshake(f"AUTH {token}\n".encode(), token) is True + + +def test_authenticate_tolerates_crlf(): + token = "deadbeef" + assert _run_handshake(f"AUTH {token}\r\n".encode(), token) is True + + +def test_authenticate_wrong_token(): + assert _run_handshake(b"AUTH nope\n", "abc") is False + + +def test_authenticate_malformed_no_prefix(): + assert _run_handshake(b"hello world\n", "abc") is False + + +def test_authenticate_empty_input(): + assert _run_handshake(b"", "abc") is False + + +def test_authenticate_non_ascii_input(): + assert _run_handshake(b"AUTH \xff\xfe\n", "abc") is False + + +def test_authenticate_times_out_on_silent_client(monkeypatch): + monkeypatch.setattr(auth, "HANDSHAKE_TIMEOUT", 0.05) + + async def go() -> bool: + r = asyncio.StreamReader() + return await auth.authenticate(r, "abc") + + assert asyncio.run(go()) is False diff --git a/csshx-latest/tests/test_broadcaster.py b/csshx-latest/tests/test_broadcaster.py new file mode 100644 index 0000000..41c2add --- /dev/null +++ b/csshx-latest/tests/test_broadcaster.py @@ -0,0 +1,87 @@ +"""Tests for the broadcast routing logic. + +Uses a real ``os.pipe`` as a stand-in for a PTY master fd so we can +verify which slaves received what bytes without forking ssh. +""" +from __future__ import annotations + +import asyncio +import os + +import pytest + +pytest.importorskip("fcntl", reason="broadcaster tests require Unix pipe semantics") + +from csshx_latest.master import Broadcaster +from csshx_latest.slave import Slave + + +def _slave(index: int, *, enabled: bool = True) -> tuple[Slave, int]: + """Return a Slave whose pty_master is the write end of a fresh pipe.""" + r, w = os.pipe() + s = Slave( + index=index, + host=f"host{index}", + sock_path=f"/tmp/fake-{index}", + token="t", + pty_master=w, + pid=0, + enabled=enabled, + ) + return s, r + + +def _drain(fd: int) -> bytes: + import fcntl + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + try: + return os.read(fd, 1024) + except BlockingIOError: + return b"" + + +def test_enabled_indices_filters(): + b = Broadcaster() + s1, _ = _slave(1, enabled=True) + s2, _ = _slave(2, enabled=False) + s3, _ = _slave(3, enabled=True) + for s in (s1, s2, s3): + b.add(s) + assert b.enabled_indices() == [1, 3] + + +def test_toggle_flips_enabled(): + b = Broadcaster() + s1, _ = _slave(1, enabled=True) + b.add(s1) + b.toggle(1) + assert s1.enabled is False + b.toggle(1) + assert s1.enabled is True + + +def test_toggle_unknown_index_raises(): + b = Broadcaster() + with pytest.raises(KeyError): + b.toggle(999) + + +def test_broadcast_writes_only_to_enabled_slaves(): + b = Broadcaster() + s1, r1 = _slave(1, enabled=True) + s2, r2 = _slave(2, enabled=False) + s3, r3 = _slave(3, enabled=True) + for s in (s1, s2, s3): + b.add(s) + + asyncio.run(b.broadcast(b"hello")) + + assert _drain(r1) == b"hello" + assert _drain(r2) == b"" + assert _drain(r3) == b"hello" + + +def test_broadcast_with_no_slaves_does_not_raise(): + b = Broadcaster() + asyncio.run(b.broadcast(b"anything")) diff --git a/csshx-latest/tests/test_color_state.py b/csshx-latest/tests/test_color_state.py new file mode 100644 index 0000000..5af9ebf --- /dev/null +++ b/csshx-latest/tests/test_color_state.py @@ -0,0 +1,90 @@ +"""Tests for the slave-state → ``Color`` mapping and broadcaster repaint hook. + +The orchestrator's ``_color_for`` function is the single source of +truth for "what color should this block be right now?". A regression +here would mean a dead host paints green or an enabled host paints +red — both visually confusing. + +The broadcaster fires ``on_state_change`` whenever a slave's enabled +flag is flipped via :meth:`toggle` or :meth:`set_all_enabled`. The +orchestrator wires this to schedule a ``set_color`` repaint, so we +test the callback contract directly. +""" +from __future__ import annotations + +import pytest + +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.launcher import Color +from csshx_latest.orchestrator import _color_for +from csshx_latest.slave import Slave + + +def _slave(idx: int, *, enabled: bool = True, dead: bool = False) -> Slave: + return Slave( + index=idx, host=f"h{idx}", sock_path=f"/tmp/s{idx}", + token="t", pty_master=-1, pid=0, enabled=enabled, dead=dead, + ) + + +@pytest.mark.parametrize( + "enabled,dead,expected", + [ + (True, False, Color.ENABLED), + (False, False, Color.DISABLED), + (True, True, Color.DEAD), + (False, True, Color.DEAD), + ], +) +def test_color_for_maps_state_to_color(enabled, dead, expected): + """Dead always wins over enabled; otherwise enabled → green, off → grey.""" + assert _color_for(_slave(1, enabled=enabled, dead=dead)) is expected + + +def test_toggle_fires_on_state_change_for_that_slave_only(): + """Toggling slave 2 must invoke the callback once, with slave 2.""" + s1, s2 = _slave(1, enabled=False), _slave(2, enabled=False) + b = Broadcaster() + b.add(s1) + b.add(s2) + fired: list[int] = [] + b.on_state_change = lambda s: fired.append(s.index) + + b.toggle(2) + + assert fired == [2] + assert s2.enabled is True + assert s1.enabled is False + + +def test_set_all_enabled_only_fires_for_actual_changes(): + """Slaves already in the target state shouldn't trigger a repaint.""" + s1 = _slave(1, enabled=True) # already on + s2 = _slave(2, enabled=False) # will flip on + s3 = _slave(3, enabled=False, dead=True) # dead — excluded + b = Broadcaster() + for s in (s1, s2, s3): + b.add(s) + fired: list[int] = [] + b.on_state_change = lambda s: fired.append(s.index) + + b.set_all_enabled(True) + + assert fired == [2] + assert s1.enabled is True + assert s2.enabled is True + assert s3.enabled is False + + +def test_on_state_change_callback_exception_is_swallowed(): + """A buggy callback must not break ``toggle`` semantics.""" + s1 = _slave(1, enabled=False) + b = Broadcaster() + b.add(s1) + b.on_state_change = lambda _s: (_ for _ in ()).throw(RuntimeError("boom")) + + # Should not raise; the toggle still completes. + new_state = b.toggle(1) + + assert new_state is True + assert s1.enabled is True diff --git a/csshx-latest/tests/test_command_key.py b/csshx-latest/tests/test_command_key.py new file mode 100644 index 0000000..67f5a4a --- /dev/null +++ b/csshx-latest/tests/test_command_key.py @@ -0,0 +1,104 @@ +"""Tests for ``--command-key`` parsing + the doubled-prefix echo path. + +The parser accepts three forms (``^X``, ``0xNN``, single char); we +exercise each plus error cases. We also confirm that when the user +chooses a non-default prefix, the dispatch still recognizes a doubled +press of *that* prefix as a literal-send (so the orchestrated UX +matches the documented behavior). +""" +from __future__ import annotations + +import asyncio + +import pytest + +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.slave import Slave +from csshx_latest.tui import _handle_command_byte, parse_command_key + + +@pytest.mark.parametrize( + "spec,expected", + [ + ("^T", b"\x14"), + ("^a", b"\x01"), # case-insensitive + ("0x14", b"\x14"), + ("0X1B", b"\x1b"), + ("a", b"a"), + ("/", b"/"), + ], +) +def test_parse_command_key_accepts_all_documented_forms(spec, expected): + assert parse_command_key(spec) == expected + + +@pytest.mark.parametrize( + "spec", + [ + "", + "^1", # not a letter + "^!", # not a letter + "0xZZ", # invalid hex + "0x100", # outside byte range + "abc", # too long + ], +) +def test_parse_command_key_rejects_invalid(spec): + with pytest.raises(ValueError): + parse_command_key(spec) + + +def _slave(idx: int) -> Slave: + return Slave( + index=idx, host=f"h{idx}", sock_path=f"/tmp/s{idx}", + token="t", pty_master=-1, pid=0, + ) + + +def test_doubled_custom_prefix_echoes_that_prefix_byte(): + """When the user picks Ctrl-A, two Ctrl-A presses must echo a Ctrl-A.""" + bcast = Broadcaster() + bcast.add(_slave(1)) + custom = b"\x01" # Ctrl-A + + extra = asyncio.run( + _handle_command_byte(bcast, custom[0], asyncio.Event(), command_key=custom) + ) + + assert extra == custom + + +def test_doubled_default_prefix_still_echoes_ctrl_t(): + """With no override the default Ctrl-T behavior is preserved.""" + bcast = Broadcaster() + bcast.add(_slave(1)) + + extra = asyncio.run( + _handle_command_byte(bcast, 0x14, asyncio.Event()) + ) + + assert extra == b"\x14" + + +def test_unknown_printable_echoes_back_through_dispatch(): + """``Ctrl-T x`` cancels command mode and broadcasts the ``x``. + + This is the original csshX behavior: a stray letter never gets + silently swallowed. + """ + bcast = Broadcaster() + bcast.add(_slave(1)) + + extra = asyncio.run(_handle_command_byte(bcast, ord("x"), asyncio.Event())) + + assert extra == b"x" + + +def test_unknown_control_byte_cancels_without_echo(): + """Esc / Ctrl-C inside command mode cancel silently.""" + bcast = Broadcaster() + bcast.add(_slave(1)) + + extra = asyncio.run(_handle_command_byte(bcast, 0x03, asyncio.Event())) + + assert extra == b"" diff --git a/csshx-latest/tests/test_config.py b/csshx-latest/tests/test_config.py new file mode 100644 index 0000000..6180417 --- /dev/null +++ b/csshx-latest/tests/test_config.py @@ -0,0 +1,87 @@ +"""Tests for ``csshx_latest.config`` (cluster alias loading).""" +from __future__ import annotations + +import os + +import pytest + +from csshx_latest.config import expand_clusters, load_clusters + + +def test_toml_clusters_preferred_over_csshrc(tmp_path): + toml = tmp_path / "config.toml" + toml.write_text('[clusters]\nweb = ["web01", "web02"]\n') + csshrc = tmp_path / ".csshrc" + csshrc.write_text("cluster web = ignored\n") + + clusters = load_clusters(toml_path=str(toml), csshrc_path=str(csshrc)) + assert clusters == {"web": ["web01", "web02"]} + + +def test_csshrc_fallback_when_toml_missing(tmp_path): + csshrc = tmp_path / ".csshrc" + csshrc.write_text( + "# comment line\n" + "cluster web = web01 web02 web03\n" + "cluster db = db1 db2\n" + "\n" + "ignored line\n" + ) + clusters = load_clusters( + toml_path=str(tmp_path / "nonexistent.toml"), + csshrc_path=str(csshrc), + ) + assert clusters == {"web": ["web01", "web02", "web03"], "db": ["db1", "db2"]} + + +def test_missing_both_files_returns_empty(tmp_path): + assert load_clusters( + toml_path=str(tmp_path / "no.toml"), + csshrc_path=str(tmp_path / "no.rc"), + ) == {} + + +def test_toml_accepts_string_value(tmp_path): + """``hosts = "h1 h2 h3"`` is split on whitespace via shlex.""" + toml = tmp_path / "config.toml" + toml.write_text('[clusters]\nweb = "web01 web02 web03"\n') + clusters = load_clusters(toml_path=str(toml), csshrc_path=str(tmp_path / "nope")) + assert clusters == {"web": ["web01", "web02", "web03"]} + + +def test_expand_clusters_resolves_nested_alias(): + clusters = { + "all": ["web", "db"], + "web": ["web01", "web02"], + "db": ["db1"], + } + assert expand_clusters(["all"], clusters) == ["web01", "web02", "db1"] + + +def test_expand_clusters_short_circuits_cycle(): + clusters = {"a": ["b"], "b": ["a"]} + # Should not hang. Resolves a -> b -> (sees a in seen) -> emits literal "a". + out = expand_clusters(["a"], clusters) + assert out == ["a"] + + +def test_expand_clusters_passes_unknown_through(): + assert expand_clusters(["host1"], {"web": ["web01"]}) == ["host1"] + + +def test_expand_clusters_no_clusters_arg_returns_input(): + """An empty / None clusters dict means no expansion happens.""" + from csshx_latest.hosts import expand_hosts + + assert expand_hosts(["h1", "h2"]) == ["h1", "h2"] + + +def test_xdg_config_home_is_respected(monkeypatch, tmp_path, capsys): + """``$XDG_CONFIG_HOME`` overrides the default ``~/.config`` lookup.""" + cfg_dir = tmp_path / "cfg" / "csshx-latest" + cfg_dir.mkdir(parents=True) + (cfg_dir / "config.toml").write_text('[clusters]\nweb = ["x"]\n') + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg")) + monkeypatch.setenv("HOME", str(tmp_path)) + # Use real default-resolution path so we exercise _toml_path. + assert load_clusters() == {"web": ["x"]} diff --git a/csshx-latest/tests/test_dead_slave.py b/csshx-latest/tests/test_dead_slave.py new file mode 100644 index 0000000..53bbf70 --- /dev/null +++ b/csshx-latest/tests/test_dead_slave.py @@ -0,0 +1,92 @@ +"""Tests for dead-slave detection and the broadcaster's exclusion of dead slaves. + +Covers the behavior added in v1.1: + +* a slave with ``dead=True`` is not in ``enabled_indices``; +* ``write_to_slave`` is a silent no-op on a dead slave (so a stale fd + doesn't raise EBADF and crash the broadcast); +* ``set_all_enabled`` skips dead slaves (their ``enabled`` flag is + meaningless after their ssh has exited). +""" +from __future__ import annotations + +import asyncio +import os + +import pytest + +pytest.importorskip("fcntl", reason="dead-slave tests require Unix pipe semantics") + +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.slave import Slave, write_to_slave + + +def _slave(index: int, *, enabled: bool = True, dead: bool = False) -> tuple[Slave, int]: + r, w = os.pipe() + s = Slave( + index=index, + host=f"h{index}", + sock_path=f"/tmp/s{index}", + token="t", + pty_master=w, + pid=0, + enabled=enabled, + dead=dead, + ) + return s, r + + +def test_dead_slave_excluded_from_enabled_indices(): + b = Broadcaster() + s1, _ = _slave(1, enabled=True, dead=False) + s2, _ = _slave(2, enabled=True, dead=True) + b.add(s1) + b.add(s2) + assert b.enabled_indices() == [1] + + +def test_dead_slave_excluded_from_alive_indices(): + b = Broadcaster() + s1, _ = _slave(1, dead=False) + s2, _ = _slave(2, dead=True) + b.add(s1) + b.add(s2) + assert b.alive_indices() == [1] + + +def test_write_to_slave_is_noop_for_dead_slave(): + """A dead slave's pipe should never be written to, even if enabled.""" + s, r = _slave(1, enabled=True, dead=True) + + asyncio.run(write_to_slave(s, b"should-not-arrive")) + + import fcntl + flags = fcntl.fcntl(r, fcntl.F_GETFL) + fcntl.fcntl(r, fcntl.F_SETFL, flags | os.O_NONBLOCK) + try: + got = os.read(r, 1024) + except BlockingIOError: + got = b"" + assert got == b"" + + +def test_set_all_enabled_skips_dead_slaves(): + """``b`` (toggle all) must leave dead slaves' enabled flag alone.""" + b = Broadcaster() + s_alive, _ = _slave(1, enabled=False, dead=False) + s_dead, _ = _slave(2, enabled=True, dead=True) + b.add(s_alive) + b.add(s_dead) + + b.set_all_enabled(True) + + assert s_alive.enabled is True + # Dead slave's flag is irrelevant after ssh exited; we don't pretend + # to bring it back to life by flipping it. + assert s_dead.enabled is True + + b.set_all_enabled(False) + assert s_alive.enabled is False + # Same idea — set_all_enabled(False) must not "mark dead" or change + # state on the already-dead slave; it just no-ops. + assert s_dead.dead is True diff --git a/csshx-latest/tests/test_detect_launcher.py b/csshx-latest/tests/test_detect_launcher.py new file mode 100644 index 0000000..75632bd --- /dev/null +++ b/csshx-latest/tests/test_detect_launcher.py @@ -0,0 +1,85 @@ +"""Tests for env-var based launcher auto-detection.""" +from __future__ import annotations + +import shutil + +import pytest + +from csshx_latest import launcher as launcher_mod +from csshx_latest.launcher import detect_launcher + + +@pytest.fixture +def clean_env(monkeypatch): + """Strip any host env vars that might bias detection.""" + for k in ("TERM_PROGRAM", "KITTY_PID", "TMUX"): + monkeypatch.delenv(k, raising=False) + + +def _which(found: dict[str, str]): + return lambda c: found.get(c) + + +def test_explicit_choice_overrides_env(monkeypatch, clean_env): + monkeypatch.setenv("TMUX", "/tmp/t,1,1") + monkeypatch.setattr(shutil, "which", _which({"tmux": "/usr/bin/tmux"})) + l = detect_launcher("manual") + assert l.name == "manual" + + +def test_waveterm_when_term_program_and_wsh(monkeypatch, clean_env): + monkeypatch.setenv("TERM_PROGRAM", "waveterm") + monkeypatch.setattr(shutil, "which", _which({"wsh": "/usr/bin/wsh"})) + assert detect_launcher().name == "waveterm" + + +def test_waveterm_skipped_when_wsh_missing(monkeypatch, clean_env): + monkeypatch.setenv("TERM_PROGRAM", "waveterm") + monkeypatch.setattr(shutil, "which", _which({})) + assert detect_launcher().name == "manual" + + +def test_iterm2_term_program(monkeypatch, clean_env): + monkeypatch.setenv("TERM_PROGRAM", "iTerm.app") + monkeypatch.setattr(shutil, "which", _which({})) + assert detect_launcher().name == "iterm2" + + +def test_apple_terminal_term_program(monkeypatch, clean_env): + monkeypatch.setenv("TERM_PROGRAM", "Apple_Terminal") + monkeypatch.setattr(shutil, "which", _which({})) + assert detect_launcher().name == "terminal" + + +def test_kitty_when_pid_set_and_kitty_on_path(monkeypatch, clean_env): + monkeypatch.setenv("KITTY_PID", "123") + monkeypatch.setattr(shutil, "which", _which({"kitty": "/usr/bin/kitty"})) + assert detect_launcher().name == "kitty" + + +def test_wezterm_when_term_program_and_wezterm_on_path(monkeypatch, clean_env): + monkeypatch.setenv("TERM_PROGRAM", "WezTerm") + monkeypatch.setattr(shutil, "which", _which({"wezterm": "/usr/bin/wezterm"})) + assert detect_launcher().name == "wezterm" + + +def test_tmux_only_when_tmux_env_set(monkeypatch, clean_env): + monkeypatch.setenv("TMUX", "/tmp/tmux,123,4") + monkeypatch.setattr(shutil, "which", _which({"tmux": "/usr/bin/tmux"})) + assert detect_launcher().name == "tmux" + + +def test_falls_back_to_manual_when_nothing_recognized(monkeypatch, clean_env): + monkeypatch.setattr(shutil, "which", _which({})) + assert detect_launcher().name == "manual" + + +def test_does_not_pick_tmux_silently_without_tmux_env(monkeypatch, clean_env): + """Even with tmux on PATH, $TMUX must be set — no surprise sessions.""" + monkeypatch.setattr(shutil, "which", _which({"tmux": "/usr/bin/tmux"})) + assert detect_launcher().name == "manual" + + +def test_unknown_explicit_name_raises(monkeypatch, clean_env): + with pytest.raises(ValueError): + detect_launcher("nope") diff --git a/csshx-latest/tests/test_hosts.py b/csshx-latest/tests/test_hosts.py new file mode 100644 index 0000000..65a38a8 --- /dev/null +++ b/csshx-latest/tests/test_hosts.py @@ -0,0 +1,61 @@ +"""Tests for ``csshx_latest.hosts.expand_hosts``. + +These tests pin the exact bash-compatible behaviors the CLI promises: +numeric ranges with width preservation, alternation, nesting, and +graceful pass-through of inputs that don't use braces. +""" +from __future__ import annotations + +from csshx_latest.hosts import expand_hosts + + +def test_no_braces_returns_input_unchanged(): + assert expand_hosts(["a", "b.example.com"]) == ["a", "b.example.com"] + + +def test_numeric_range_basic(): + assert expand_hosts(["web{1..3}"]) == ["web1", "web2", "web3"] + + +def test_numeric_range_preserves_zero_padding(): + """A literal ``01`` in the lower bound forces 2-digit zero-padded output.""" + assert expand_hosts(["web{01..05}"]) == [ + "web01", + "web02", + "web03", + "web04", + "web05", + ] + + +def test_numeric_range_descending(): + assert expand_hosts(["h{3..1}"]) == ["h3", "h2", "h1"] + + +def test_alternation_basic(): + assert expand_hosts(["api-{a,b,c}"]) == ["api-a", "api-b", "api-c"] + + +def test_alternation_keeps_empty_elements(): + """``foo{,bar}`` matches bash: yields ``foo`` then ``foobar``.""" + assert expand_hosts(["foo{,bar}"]) == ["foo", "foobar"] + + +def test_nested_alternation_and_range(): + """``{prod,stage}-web{1..2}`` should produce the full 2x2 cartesian product.""" + result = expand_hosts(["{prod,stage}-web{1..2}"]) + assert result == [ + "prod-web1", + "prod-web2", + "stage-web1", + "stage-web2", + ] + + +def test_multiple_args_flatten(): + """Each arg is expanded independently; results are concatenated in order.""" + assert expand_hosts(["a{1..2}", "b{x,y}"]) == ["a1", "a2", "bx", "by"] + + +def test_empty_arg_list(): + assert expand_hosts([]) == [] diff --git a/csshx-latest/tests/test_integration_pty.py b/csshx-latest/tests/test_integration_pty.py new file mode 100644 index 0000000..4f5a5ef --- /dev/null +++ b/csshx-latest/tests/test_integration_pty.py @@ -0,0 +1,101 @@ +"""End-to-end test: real PTY + a cat child standing in for ssh. + +This is the integration coverage the unit tests can't give us. We +spawn a real PTY with ``cat`` as the child, wire it up to a real +``Slave`` via ``run_slave_bridge``, connect through the attach client, +and assert that bytes written to the data socket reach cat and are +echoed back. +""" +from __future__ import annotations + +import asyncio +import os +import pty +import sys + +import pytest + +pytest.importorskip("fcntl", reason="PTY integration needs Unix") +if sys.platform == "win32": # pragma: no cover + pytest.skip("PTY is Unix-only", allow_module_level=True) + +from csshx_latest.auth import write_token_file +from csshx_latest.slave import Slave, run_slave_bridge, shutdown_slave + + +def test_pty_bytes_round_trip_through_socket(short_socket_dir): + """Write 'ping\\n' to the data socket -> cat echoes 'ping\\n' back.""" + sock_path = os.path.join(short_socket_dir, "slave.sock") + ctl_path = os.path.join(short_socket_dir, "slave.ctl") + token_path = os.path.join(short_socket_dir, "slave.token") + write_token_file(token_path, "TOK") + + pty_master, pty_slave = pty.openpty() + pid = os.fork() + if pid == 0: + try: + os.setsid() + os.close(pty_master) + os.dup2(pty_slave, 0) + os.dup2(pty_slave, 1) + os.dup2(pty_slave, 2) + if pty_slave > 2: + os.close(pty_slave) + os.execvp("cat", ["cat"]) + except Exception: + os._exit(127) + os.close(pty_slave) + + slave = Slave( + index=1, + host="localhost", + sock_path=sock_path, + ctl_sock_path=ctl_path, + token="TOK", + token_path=token_path, + pty_master=pty_master, + pid=pid, + ) + + async def go() -> bytes: + await run_slave_bridge(slave) + reader, writer = await asyncio.open_unix_connection(sock_path) + writer.write(b"AUTH TOK\n") + writer.write(b"ping\n") + await writer.drain() + # cat will echo "ping\n" back; read until we see it. + collected = bytearray() + for _ in range(50): + try: + chunk = await asyncio.wait_for(reader.read(64), timeout=0.1) + except asyncio.TimeoutError: + continue + if not chunk: + break + collected.extend(chunk) + if b"ping" in collected: + break + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return bytes(collected) + + try: + out = asyncio.run(go()) + finally: + shutdown_slave(slave) + try: + os.waitpid(pid, 0) + except (ChildProcessError, OSError): + pass + + assert b"ping" in out + + +def test_alpha_brace_expansion(): + """``host-{a..c}`` expands to host-a host-b host-c.""" + from csshx_latest.hosts import expand_hosts + + assert expand_hosts(["host-{a..c}"]) == ["host-a", "host-b", "host-c"] diff --git a/csshx-latest/tests/test_launcher_apple_terminal.py b/csshx-latest/tests/test_launcher_apple_terminal.py new file mode 100644 index 0000000..a55fd16 --- /dev/null +++ b/csshx-latest/tests/test_launcher_apple_terminal.py @@ -0,0 +1,516 @@ +"""Tests for the Apple Terminal launcher (osascript mocked). + +Three contracts pinned here: + +1. The p10k fix: every ``do script`` body must start with + ``exec /bin/sh -c '...'`` so the user's interactive shell never + gets a chance to swallow the attach command. Without this, zsh + + Powerlevel10k's instant-prompt corrupts the first keystrokes. + +2. Per-block window tiling: each block opens in its own Terminal + window (not a tab), and :meth:`tile` lays the windows out in a + near-square grid via AppleScript ``set bounds`` — the same scheme + the original Perl csshX used. + +3. **Master + slave co-tiling:** :meth:`start` captures the front + Terminal window id (the master TUI), and :meth:`tile` includes + that window as cell 0 of the grid so master and slaves are + rearranged together. If the capture fails, slaves are tiled and + the master is left alone (no regression vs. v0.2.0). +""" +from __future__ import annotations + +import subprocess + +import pytest + +from csshx_latest.launcher import BlockHandle, Color +from csshx_latest.launchers import apple_terminal as term_mod + + +@pytest.fixture +def fake_osascript(monkeypatch): + """Capture every osascript invocation; canned-respond for known queries.""" + scripts: list[str] = [] + # The launcher reads two kinds of values from osascript stdout: + # * Finder desktop bounds (left,top,right,bottom) — for tile() + # * open_block: "<window_id>\n<tty>" + # The fixture supplies these via a side-channel mutated per-test. + canned = {"desktop": "0, 0, 1600, 1000", "open_block": "1234\n/dev/ttys001"} + + def runner(args, check=False, capture_output=False, text=False): + if args[:2] != ["osascript", "-e"]: + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + script = args[2] + scripts.append(script) + if "bounds of window of desktop" in script: + return subprocess.CompletedProcess(args, 0, stdout=canned["desktop"], stderr="") + if "do script" in script: + return subprocess.CompletedProcess(args, 0, stdout=canned["open_block"], stderr="") + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(term_mod.subprocess, "run", runner) + return scripts, canned + + +def test_open_block_wraps_attach_in_exec_sh(fake_osascript): + """The do-script body must start with ``exec /bin/sh -c`` (p10k fix).""" + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/a"], "web01") + + assert len(scripts) == 1 + s = scripts[0] + assert "exec /bin/sh -c" in s + assert 'set custom title of newTab to "web01"' in s + assert "do script" in s + + +def test_open_block_captures_window_id_and_tty(fake_osascript): + """``BlockHandle.data`` must record window_id (for tile) and tty (for title).""" + scripts, canned = fake_osascript + canned["open_block"] = "55501\n/dev/ttys017" + l = term_mod.AppleTerminalLauncher() + + h = l.open_block(["echo", "hi"], "host-x") + + assert h.backend == "terminal" + assert h.data["title"] == "host-x" + assert h.data["window_id"] == "55501" + assert h.data["tty"] == "/dev/ttys017" + + +def test_close_block_targets_captured_window_id(fake_osascript): + """Close should address the captured window by id, not scan all tabs.""" + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + h = l.open_block(["echo"], "h") + scripts.clear() + + l.close_block(h) + + assert len(scripts) == 1 + assert "every window whose id is" in scripts[0] + + +def test_close_block_falls_back_to_tty_when_no_window_id(): + """If open_block didn't capture window_id, close_block scans tabs by tty.""" + sent: list[str] = [] + + def runner(args, check=False, capture_output=False, text=False): + if args[:2] == ["osascript", "-e"]: + sent.append(args[2]) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + import builtins + import unittest.mock + + with unittest.mock.patch.object(term_mod.subprocess, "run", runner): + l = term_mod.AppleTerminalLauncher() + h = BlockHandle(backend="terminal", data={"tty": "/dev/ttys009"}) + l.close_block(h) + + assert len(sent) == 1 + assert "/dev/ttys009" in sent[0] + assert "every window whose id" not in sent[0] + + +def test_close_block_with_no_identifiers_is_noop(fake_osascript): + """A handle with neither window_id nor tty silently no-ops.""" + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + h = BlockHandle(backend="terminal", data={}) + scripts.clear() + + l.close_block(h) + + assert scripts == [] + + +def test_tile_with_no_handles_is_noop(fake_osascript): + """No handles ⇒ no osascript call (don't even ask Finder).""" + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + scripts.clear() + + l.tile([]) + + assert scripts == [] + + +def test_tile_skips_handles_without_window_id(fake_osascript): + """Degraded handles (open_block failed) must not crash tile.""" + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + h = BlockHandle(backend="terminal", data={"tty": "/dev/ttys001"}) + scripts.clear() + + l.tile([h]) + + # No window_id → tile filters everything out → no osascript at all. + assert scripts == [] + + +def test_tile_two_blocks_lays_side_by_side(fake_osascript): + """2 blocks on a 1600×1000 desktop tile into two columns inside usable bounds. + + Usable area = desktop minus EDGE_MARGIN (8) on every side and + DOCK_RESERVE (90) on the bottom: (8, 8, 1592, 902). 2 cols of + width 792 each, then each cell shrinks by WINDOW_GAP (6) on the + right/bottom for breathing room. + """ + scripts, canned = fake_osascript + canned["desktop"] = "0, 0, 1600, 1000" + l = term_mod.AppleTerminalLauncher() + h1 = BlockHandle(backend="terminal", data={"window_id": "100", "tty": ""}) + h2 = BlockHandle(backend="terminal", data={"window_id": "200", "tty": ""}) + scripts.clear() + + l.tile([h1, h2]) + + # The tile call batches: one Finder probe + one tell-Terminal block. + bounds_script = [s for s in scripts if "set bounds of" in s] + assert len(bounds_script) == 1, scripts + body = bounds_script[0] + # Left column 8..794, right column 800..1586; top 8, bottom 896. + assert "{8, 8, 794, 896}" in body + assert "{800, 8, 1586, 896}" in body + assert 'first window whose id is 100' in body + assert 'first window whose id is 200' in body + + +def test_tile_four_blocks_makes_two_by_two_grid(fake_osascript): + """4 blocks on a 1600×1000 desktop tile into a 2×2 grid inside usable bounds.""" + scripts, canned = fake_osascript + canned["desktop"] = "0, 0, 1600, 1000" + l = term_mod.AppleTerminalLauncher() + handles = [ + BlockHandle(backend="terminal", data={"window_id": str(i), "tty": ""}) + for i in range(4) + ] + scripts.clear() + + l.tile(handles) + + bounds_script = [s for s in scripts if "set bounds of" in s] + body = bounds_script[0] + # Usable (8, 8, 1592, 902); 2×2 cells of 792×447 each, then -6 gap. + assert "{8, 8, 794, 449}" in body + assert "{800, 8, 1586, 449}" in body + assert "{8, 455, 794, 896}" in body + assert "{800, 455, 1586, 896}" in body + + +def test_grid_for_returns_near_square_shapes(): + """The grid math should pick a near-square layout for any block count.""" + assert term_mod._grid_for(1) == (1, 1) + assert term_mod._grid_for(2) == (1, 2) + assert term_mod._grid_for(3) == (2, 2) + assert term_mod._grid_for(4) == (2, 2) + assert term_mod._grid_for(5) == (2, 3) + assert term_mod._grid_for(9) == (3, 3) + assert term_mod._grid_for(10) == (3, 4) + # Empty edge case: 0 blocks → 0 grid (no division-by-zero downstream). + assert term_mod._grid_for(0) == (0, 0) + + +def test_set_title_uses_captured_tty(fake_osascript): + """``set_title`` finds the tab via the captured tty id.""" + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + h = l.open_block(["echo"], "h") + scripts.clear() + + l.set_title(h, "new-title") + + assert len(scripts) == 1 + assert "set custom title of t" in scripts[0] + assert 'new-title' in scripts[0] + + +def test_special_chars_in_title_are_escaped(fake_osascript): + """A title containing a double-quote must not break the AppleScript string.""" + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + l.open_block(["echo"], 'evil"title') + + assert 'evil\\"title' in scripts[0] + + +def test_tile_one_block_fills_whole_desktop(fake_osascript): + """A single block on a 1600×1000 desktop fills the usable rectangle.""" + scripts, canned = fake_osascript + canned["desktop"] = "0, 0, 1600, 1000" + l = term_mod.AppleTerminalLauncher() + h = BlockHandle(backend="terminal", data={"window_id": "42", "tty": ""}) + scripts.clear() + + l.tile([h]) + + bounds_script = [s for s in scripts if "set bounds of" in s] + assert len(bounds_script) == 1 + # Usable (8, 8, 1592, 902) minus 6px gap on right/bottom → (8, 8, 1586, 896). + assert "{8, 8, 1586, 896}" in bounds_script[0] + assert "first window whose id is 42" in bounds_script[0] + + +def test_tile_three_blocks_uses_two_by_two_grid_with_gap(fake_osascript): + """3 blocks → 2×2 grid (4th cell empty); first three cells filled.""" + scripts, canned = fake_osascript + canned["desktop"] = "0, 0, 1600, 1000" + l = term_mod.AppleTerminalLauncher() + handles = [ + BlockHandle(backend="terminal", data={"window_id": str(i), "tty": ""}) + for i in range(3) + ] + scripts.clear() + + l.tile(handles) + + body = [s for s in scripts if "set bounds of" in s][0] + # Top-left, top-right, bottom-left filled (same cells as the 4-block grid). + assert "{8, 8, 794, 449}" in body + assert "{800, 8, 1586, 449}" in body + assert "{8, 455, 794, 896}" in body + # Only three windows referenced. + assert body.count("first window whose id is") == 3 + + +def test_tile_nine_blocks_uses_three_by_three_grid(fake_osascript): + """9 blocks on a 1500×900 desktop tile into a 3×3 grid inside usable bounds.""" + scripts, canned = fake_osascript + canned["desktop"] = "0, 0, 1500, 900" + l = term_mod.AppleTerminalLauncher() + handles = [ + BlockHandle(backend="terminal", data={"window_id": str(i), "tty": ""}) + for i in range(9) + ] + scripts.clear() + + l.tile(handles) + + body = [s for s in scripts if "set bounds of" in s][0] + # Usable (8, 8, 1492, 802); 3×3 cells of 494×264 each, minus 6px gaps. + # Spot-check the four corners + the center cell. + assert "{8, 8, 496, 266}" in body # top-left + assert "{996, 8, 1484, 266}" in body # top-right + assert "{502, 272, 990, 530}" in body # center + assert "{8, 536, 496, 794}" in body # bottom-left + assert "{996, 536, 1484, 794}" in body # bottom-right + + +def test_set_color_emits_background_color_per_state(fake_osascript): + """``set_color`` writes a per-state RGB triple to the tab's background color. + + The exact RGB values come from :data:`_TAB_BG` in + ``apple_terminal.py``; we read them out of the module rather than + duplicating the magic numbers so a future palette retune only has + to touch one place. The contract this test pins is the *shape* of + the AppleScript and the *one-to-one mapping* between Color states + and palette entries — not the specific hex values. + """ + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + h = BlockHandle(backend="terminal", data={"window_id": "77", "tty": "/dev/ttys001"}) + scripts.clear() + + l.set_color(h, Color.ENABLED) + l.set_color(h, Color.DISABLED) + l.set_color(h, Color.DEAD) + + assert len(scripts) == 3 + # All three scripts target the same captured window via id. + assert all("first window whose id is 77" in s for s in scripts) + assert all("set background color of tab 1" in s for s in scripts) + # Each script must carry exactly the RGB the palette declares for + # its state — three different triples, one per state. + for script, color in zip(scripts, (Color.ENABLED, Color.DISABLED, Color.DEAD)): + r, g, b = term_mod._TAB_BG[color] + assert f"{{{r}, {g}, {b}}}" in script + + +def test_tab_bg_palette_is_distinct_and_in_range(): + """All three Color states map to distinct, valid 16-bit RGB triples. + + Pins the *contract* (distinguishability + valid hardware range) so + the user can tell ENABLED / DISABLED / DEAD apart at a glance. + Does NOT pin the specific hex values — palette tuning stays free. + """ + palette = term_mod._TAB_BG + assert set(palette.keys()) == {Color.ENABLED, Color.DISABLED, Color.DEAD} + triples = list(palette.values()) + # Distinct: the user must be able to tell the three states apart. + assert len(set(triples)) == 3 + # Valid 16-bit RGB: AppleScript silently clamps out-of-range and + # negative values would crash the call. + for r, g, b in triples: + assert 0 <= r <= 65535 + assert 0 <= g <= 65535 + assert 0 <= b <= 65535 + + +def test_set_color_is_noop_without_window_id(fake_osascript): + """No captured window_id → set_color silently no-ops (degraded handle).""" + scripts, _ = fake_osascript + l = term_mod.AppleTerminalLauncher() + h = BlockHandle(backend="terminal", data={"tty": "/dev/ttys001"}) + scripts.clear() + + l.set_color(h, Color.ENABLED) + + assert scripts == [] + + +def test_usable_bounds_subtracts_dock_and_edge_margins(monkeypatch): + """Usable area drops EDGE_MARGIN on every side and DOCK_RESERVE on the bottom.""" + def runner(args, check=False, capture_output=False, text=False): + return subprocess.CompletedProcess(args, 0, stdout="0, 0, 1600, 1000", stderr="") + + monkeypatch.setattr(term_mod.subprocess, "run", runner) + left, top, right, bottom = term_mod._get_usable_bounds() + assert left == term_mod.EDGE_MARGIN + assert top == term_mod.EDGE_MARGIN + assert right == 1600 - term_mod.EDGE_MARGIN + assert bottom == 1000 - term_mod.DOCK_RESERVE - term_mod.EDGE_MARGIN + + +def test_desktop_bounds_falls_back_when_finder_fails(monkeypatch): + """If Finder returns garbage, we fall back to a 1920×1080 default.""" + + def runner(args, check=False, capture_output=False, text=False): + return subprocess.CompletedProcess(args, 0, stdout="oops not numbers", stderr="") + + monkeypatch.setattr(term_mod.subprocess, "run", runner) + assert term_mod._get_desktop_bounds() == (0, 0, 1920, 1080) + + +# ----------------------------------------------------------------- +# Master-window co-tiling tests. +# +# These verify the fix for "slaves get rearranged, master doesn't": +# start() captures the front window id (the master TUI's window) and +# tile() includes it in the grid. +# ----------------------------------------------------------------- + + +@pytest.fixture +def fake_osascript_with_master(monkeypatch): + """Like ``fake_osascript`` but also canned-responds to the master capture.""" + scripts: list[str] = [] + canned = { + "desktop": "0, 0, 1600, 1000", + "open_block": "1234\n/dev/ttys001", + "master_window_id": "9001", + } + + def runner(args, check=False, capture_output=False, text=False): + if args[:2] != ["osascript", "-e"]: + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + script = args[2] + scripts.append(script) + if "bounds of window of desktop" in script: + return subprocess.CompletedProcess(args, 0, stdout=canned["desktop"], stderr="") + if "do script" in script: + return subprocess.CompletedProcess(args, 0, stdout=canned["open_block"], stderr="") + if "id of front window" in script: + return subprocess.CompletedProcess( + args, 0, stdout=canned["master_window_id"], stderr="" + ) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(term_mod.subprocess, "run", runner) + return scripts, canned + + +def test_start_captures_front_window_id(fake_osascript_with_master): + """``start()`` must query and remember the master Terminal window's id.""" + scripts, _ = fake_osascript_with_master + l = term_mod.AppleTerminalLauncher() + + l.start(total=3) + + capture_scripts = [s for s in scripts if "id of front window" in s] + assert len(capture_scripts) == 1 + assert l._master_window_id == "9001" + + +def test_start_ignores_non_numeric_capture(fake_osascript_with_master): + """Non-digit stdout from osascript must not poison the master id.""" + scripts, canned = fake_osascript_with_master + canned["master_window_id"] = "missing value" + l = term_mod.AppleTerminalLauncher() + + l.start(total=1) + + # Capture attempted, but the result was rejected. + assert any("id of front window" in s for s in scripts) + assert l._master_window_id == "" + + +def test_tile_includes_master_at_top_left(fake_osascript_with_master): + """When start() captured the master, tile() puts it at cell 0 of the grid.""" + scripts, canned = fake_osascript_with_master + canned["desktop"] = "0, 0, 1600, 1000" + canned["master_window_id"] = "9001" + l = term_mod.AppleTerminalLauncher() + l.start(total=3) # 1 master + 2 slaves coming + h1 = BlockHandle(backend="terminal", data={"window_id": "100", "tty": ""}) + h2 = BlockHandle(backend="terminal", data={"window_id": "200", "tty": ""}) + scripts.clear() + + l.tile([h1, h2]) + + body = [s for s in scripts if "set bounds of" in s][0] + # 3 cells (master + 2 slaves) → 2×2 grid of 792×447 inside usable bounds. + # Master at cell 0 (top-left), slave-100 at cell 1 (top-right), + # slave-200 at cell 2 (bottom-left). + assert "first window whose id is 9001" in body # master included + assert body.index("first window whose id is 9001") < body.index( + "first window whose id is 100" + ) + assert "{8, 8, 794, 449}" in body # master cell + assert "{800, 8, 1586, 449}" in body # first slave cell + assert "{8, 455, 794, 896}" in body # second slave cell + # All three windows are addressed exactly once. + assert body.count("first window whose id is") == 3 + + +def test_tile_falls_back_to_slaves_only_when_master_capture_failed( + fake_osascript_with_master, +): + """If start() couldn't capture the master, tile() keeps the v0.2.0 behavior.""" + scripts, canned = fake_osascript_with_master + canned["master_window_id"] = "" # simulate Finder-denied / failed capture + canned["desktop"] = "0, 0, 1600, 1000" + l = term_mod.AppleTerminalLauncher() + l.start(total=2) # capture is attempted but yields nothing + assert l._master_window_id == "" + h1 = BlockHandle(backend="terminal", data={"window_id": "100", "tty": ""}) + h2 = BlockHandle(backend="terminal", data={"window_id": "200", "tty": ""}) + scripts.clear() + + l.tile([h1, h2]) + + body = [s for s in scripts if "set bounds of" in s][0] + # 2 cells (slaves only) → 1×2 columns inside usable bounds. + assert "{8, 8, 794, 896}" in body + assert "{800, 8, 1586, 896}" in body + assert body.count("first window whose id is") == 2 + assert "9001" not in body # master not present + + +def test_tile_with_only_master_uses_full_desktop(fake_osascript_with_master): + """Edge case: tile([]) but master captured → master gets the whole desktop.""" + scripts, canned = fake_osascript_with_master + canned["master_window_id"] = "9001" + canned["desktop"] = "0, 0, 1600, 1000" + l = term_mod.AppleTerminalLauncher() + l.start(total=0) + scripts.clear() + + l.tile([]) + + body = [s for s in scripts if "set bounds of" in s][0] + assert "first window whose id is 9001" in body + # Master gets the whole usable area: (8, 8, 1592, 902) minus 6px gap. + assert "{8, 8, 1586, 896}" in body diff --git a/csshx-latest/tests/test_launcher_conformance.py b/csshx-latest/tests/test_launcher_conformance.py new file mode 100644 index 0000000..34fe4ec --- /dev/null +++ b/csshx-latest/tests/test_launcher_conformance.py @@ -0,0 +1,122 @@ +"""Conformance tests every registered launcher must pass. + +Author: Aditya Kapadia. + +The Launcher Protocol is structural — Python won't catch a typo'd +``open_block`` signature at import time. These tests instantiate every +backend from the registry and assert that: + +* it satisfies the runtime-checkable :class:`Launcher` protocol, +* it exposes the required attributes (``name``), +* every required method is callable with the documented signature + (smoke-only, no side effects). + +If you add a new launcher, register it in +``csshx_latest.launcher._LAUNCHERS`` and these checks will exercise it +automatically — no per-backend boilerplate. +""" +from __future__ import annotations + +import inspect + +import pytest + +from csshx_latest.launcher import ( + BlockHandle, + Color, + Launcher, + _LAUNCHERS, + _by_name, +) + + +@pytest.fixture(params=sorted(_LAUNCHERS)) +def launcher_name(request): + """Yield every registered launcher name in turn.""" + return request.param + + +def _instantiate_or_skip(name: str): + """Build a launcher; skip the test if the backend's binary isn't installed. + + A backend whose ``__init__`` raises ``RuntimeError`` (e.g. kitty + without ``kitty`` on PATH) can't be tested in this environment but + its conformance is checked on developer/CI machines that do have it. + """ + try: + return _by_name(name) + except RuntimeError as exc: + pytest.skip(f"{name} backend not installed on this host: {exc}") + + +def test_launcher_satisfies_protocol(launcher_name): + """Every registered backend must structurally implement Launcher.""" + inst = _instantiate_or_skip(launcher_name) + assert isinstance(inst, Launcher), ( + f"{launcher_name} doesn't implement the Launcher protocol" + ) + + +def test_launcher_has_name_attribute(launcher_name): + """``name`` is used in logs + telemetry — must be a non-empty str.""" + inst = _instantiate_or_skip(launcher_name) + assert isinstance(inst.name, str) and inst.name, ( + f"{launcher_name} has invalid .name attribute: {inst.name!r}" + ) + + +@pytest.mark.parametrize( + "method,sig_args", + [ + ("start", ["total"]), + ("open_block", ["attach_cmd", "title"]), + ("close_block", ["handle"]), + ("tile", ["handles"]), + ("set_title", ["handle", "title"]), + ("set_color", ["handle", "color"]), + ], +) +def test_launcher_method_signature(launcher_name, method, sig_args): + """Every method documented in the Protocol must exist with matching args. + + We don't require exact param names (some backends use clearer + domain terms), but the *positional arity* has to match so the + orchestrator's call sites work. + """ + inst = _instantiate_or_skip(launcher_name) + fn = getattr(inst, method, None) + assert callable(fn), f"{launcher_name}.{method} missing or not callable" + sig = inspect.signature(fn) + positional = [ + p + for p in sig.parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + assert len(positional) == len(sig_args), ( + f"{launcher_name}.{method} expected {len(sig_args)} args " + f"({sig_args}), got {len(positional)} ({[p.name for p in positional]})" + ) + + +def test_launcher_set_color_accepts_every_state(launcher_name): + """A backend may no-op set_color, but it must accept every Color value. + + We pass a dummy BlockHandle so a crash means the backend tried to + dereference handle.data — those backends should guard against an + unknown / handle-less call instead. Errors are tolerated only if + they are KeyError/AttributeError from the dummy handle; any other + exception type signals a broken signature. + """ + inst = _instantiate_or_skip(launcher_name) + handle = BlockHandle(backend=launcher_name, data={}) + for color in Color: + try: + inst.set_color(handle, color) + except (KeyError, AttributeError, RuntimeError, FileNotFoundError): + # Expected: the dummy handle lacks real backend identifiers, + # or the external binary (tmux, kitty, ...) isn't installed. + pass + except TypeError as exc: # pragma: no cover - regression guard + pytest.fail( + f"{launcher_name}.set_color rejected Color.{color.name}: {exc}" + ) diff --git a/csshx-latest/tests/test_launcher_iterm2.py b/csshx-latest/tests/test_launcher_iterm2.py new file mode 100644 index 0000000..c05d33e --- /dev/null +++ b/csshx-latest/tests/test_launcher_iterm2.py @@ -0,0 +1,198 @@ +"""Tests for the iTerm2 launcher (osascript mocked). + +iTerm2's AppleScript bridge is fundamentally opaque from Python's side +— the only thing we can really verify without an actual iTerm2 process +is the *shape* of the AppleScript we send. + +Two contracts pinned here: + +1. **The p10k fix:** every attach command is passed as the new + session's ``command`` (which iTerm execvps directly) rather than + via ``write text`` (which types into the user's interactive shell, + where p10k's instant-prompt can intercept keystrokes). + +2. **Master + slave co-tiling:** every block splits the master TUI's + current session. v1.0 created a brand-new window for the first + slave, leaving the master orphaned in its own window; iTerm2 only + rearranged the slaves. v1.1+ always splits, so iTerm2's automatic + pane balancing rearranges master and slaves together. +""" +from __future__ import annotations + +import subprocess + +import pytest + +from csshx_latest.launcher import BlockHandle, Color +from csshx_latest.launchers import iterm2 as iterm_mod + + +@pytest.fixture +def fake_osascript(monkeypatch): + """Capture every osascript invocation; return the AppleScript bodies.""" + scripts: list[str] = [] + + def runner(args, check=False, capture_output=False, text=False): + # Real call shape: ``osascript -e <script>``. + if args[:2] == ["osascript", "-e"]: + scripts.append(args[2]) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(iterm_mod.subprocess, "run", runner) + return scripts + + +def test_first_open_splits_current_session_not_new_window(fake_osascript): + """First block → ``split vertically`` of the master's current session. + + v1.0 issued ``create window with default profile`` here, which + parked the master TUI in a sibling window where iTerm2's auto-tile + couldn't reach it. v1.1+ splits the master's session so master + + every slave share one window and iTerm2 rearranges them together. + """ + l = iterm_mod.ITerm2Launcher() + l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/a"], "web01") + + assert len(fake_osascript) == 1 + s = fake_osascript[0] + # The p10k fix: pass attach as the new session's ``command``, + # not via ``write text`` (which types into the interactive shell). + assert "split vertically with default profile" in s + assert "create window with default profile" not in s + assert 'command "' in s + assert "write text" not in s + # Title is applied via ``set name`` on the new session. + assert 'set name to "web01"' in s + # Attach argv must appear inside the command string. + assert "socat" in s + + +def test_second_open_also_uses_split_vertically_with_command(fake_osascript): + """Subsequent blocks also split the current session, again via ``command``.""" + l = iterm_mod.ITerm2Launcher() + l.open_block(["echo", "first"], "first") + l.open_block(["echo", "second"], "second") + + assert len(fake_osascript) == 2 + for body in fake_osascript: + assert "split vertically with default profile" in body + assert "create window with default profile" not in body + assert 'command "' in body + assert "write text" not in body + assert 'set name to "first"' in fake_osascript[0] + assert 'set name to "second"' in fake_osascript[1] + + +def test_special_chars_in_attach_command_are_escaped(fake_osascript): + """Backslashes and double-quotes in argv must not break out of the literal.""" + l = iterm_mod.ITerm2Launcher() + l.open_block(['echo', 'has "quote" and \\back'], "evil") + + s = fake_osascript[0] + # Raw double-quote / backslash must be escaped in the AppleScript body. + # We can't easily count exact escaping without re-parsing, but we + # can verify that unescaped sequences that would terminate the + # AppleScript string do NOT appear adjacent to the cmd boundary. + cmd_start = s.index('command "') + len('command "') + # The next unescaped " marks the end of the AppleScript literal. + # Make sure the user's literal `"quote"` chars don't terminate early. + tail = s[cmd_start:] + # Find first un-escaped quote (i.e. a `"` not preceded by `\`). + i = 0 + while i < len(tail): + if tail[i] == '"' and (i == 0 or tail[i - 1] != "\\"): + break + i += 1 + closing_quote = i + # Everything up to closing_quote is the embedded command. It MUST + # still contain the original 'echo' token — if escaping broke, the + # AppleScript would have terminated before that. + embedded = tail[:closing_quote] + assert "echo" in embedded + + +def test_close_block_is_noop(fake_osascript): + """iTerm2 sessions die when ssh exits; close_block is intentionally no-op.""" + l = iterm_mod.ITerm2Launcher() + h = l.open_block(["echo"], "h") + fake_osascript.clear() + + l.close_block(h) + + assert fake_osascript == [] + + +def test_tile_is_noop(fake_osascript): + """iTerm2 auto-balances splits — explicit tiling is unnecessary.""" + l = iterm_mod.ITerm2Launcher() + fake_osascript.clear() + + l.tile([]) + + assert fake_osascript == [] + + +def test_set_title_renames_current_session(fake_osascript): + """Best-effort: ``set name to`` on the current session.""" + l = iterm_mod.ITerm2Launcher() + h = l.open_block(["echo"], "h") + fake_osascript.clear() + + l.set_title(h, "renamed") + + assert len(fake_osascript) == 1 + assert "set name to" in fake_osascript[0] + assert "renamed" in fake_osascript[0] + + +def test_set_color_writes_session_background_per_state(fake_osascript): + """``set_color`` writes a per-state RGB triple to the matched session. + + Exact RGB values come from :data:`_SESSION_BG`; we look them up + rather than hard-coding so a future palette retune only changes + one place. The contract pinned here is the AppleScript shape + (correct session id, ``background color`` write) and the one-to- + one mapping from Color state to palette entry. + """ + l = iterm_mod.ITerm2Launcher() + h = BlockHandle( + backend="iterm2", + data={"title": "x", "window_id": "w1", "session_id": "sess-42"}, + ) + fake_osascript.clear() + + l.set_color(h, Color.ENABLED) + l.set_color(h, Color.DISABLED) + l.set_color(h, Color.DEAD) + + assert len(fake_osascript) == 3 + # All three scripts target the captured session id and write + # ``background color`` (not a no-op anymore). + assert all('if id of s is "sess-42"' in s for s in fake_osascript) + assert all("set background color of s" in s for s in fake_osascript) + for script, color in zip(fake_osascript, (Color.ENABLED, Color.DISABLED, Color.DEAD)): + r, g, b = iterm_mod._SESSION_BG[color] + assert f"{{{r}, {g}, {b}}}" in script + + +def test_session_bg_palette_is_distinct_and_in_range(): + """All three Color states map to distinct, valid 16-bit RGB triples.""" + palette = iterm_mod._SESSION_BG + assert set(palette.keys()) == {Color.ENABLED, Color.DISABLED, Color.DEAD} + triples = list(palette.values()) + assert len(set(triples)) == 3 + for r, g, b in triples: + assert 0 <= r <= 65535 + assert 0 <= g <= 65535 + assert 0 <= b <= 65535 + + +def test_set_color_is_noop_without_session_id(fake_osascript): + """No captured session_id → set_color silently no-ops.""" + l = iterm_mod.ITerm2Launcher() + h = BlockHandle(backend="iterm2", data={"title": "x", "window_id": "w1"}) + fake_osascript.clear() + + l.set_color(h, Color.ENABLED) + + assert fake_osascript == [] diff --git a/csshx-latest/tests/test_launcher_kitty.py b/csshx-latest/tests/test_launcher_kitty.py new file mode 100644 index 0000000..a10e714 --- /dev/null +++ b/csshx-latest/tests/test_launcher_kitty.py @@ -0,0 +1,121 @@ +"""Tests for the Kitty launcher (subprocess.run and shutil.which mocked). + +Kitty calls go through ``kitty @ launch / @ close-window / @ +goto-layout / @ set-window-title``. We verify the argv shape rather +than ``kitty``'s actual behavior — the launcher's contract is "emit +the right command line", and kitty's remote-control protocol is +covered by its own test suite upstream. +""" +from __future__ import annotations + +import subprocess + +import pytest + +from csshx_latest.launchers import kitty as kitty_mod + + +@pytest.fixture +def fake_kitty(monkeypatch): + """Pretend ``kitty`` is on PATH; record every subprocess.run argv.""" + monkeypatch.setattr(kitty_mod.shutil, "which", lambda _name: "/usr/local/bin/kitty") + calls: list[list[str]] = [] + + def runner(args, check=False, capture_output=False, text=False): + calls.append(list(args)) + # ``kitty @ launch`` prints the new window id on stdout. + if args[:3] == ["kitty", "@", "launch"]: + return subprocess.CompletedProcess(args, 0, stdout="17\n", stderr="") + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(kitty_mod.subprocess, "run", runner) + return calls + + +def test_constructor_raises_if_kitty_missing(monkeypatch): + """Operator-visible failure when ``kitty`` isn't on PATH.""" + monkeypatch.setattr(kitty_mod.shutil, "which", lambda _name: None) + with pytest.raises(RuntimeError, match="kitty CLI not found"): + kitty_mod.KittyLauncher() + + +def test_open_block_uses_type_tab_and_captures_window_id(fake_kitty): + """v1.1 default is ``--type=tab`` (NOT ``--type=window``).""" + l = kitty_mod.KittyLauncher() + h = l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/a"], "web01") + + launch = fake_kitty[0] + assert launch[:3] == ["kitty", "@", "launch"] + assert "--type=tab" in launch + # ``--type=window`` would open a new OS window per host, which + # was the v1.0 footgun we fixed — pin it explicitly. + assert "--type=window" not in launch + # ``--keep-focus`` keeps the master TUI focused so the user can keep typing. + assert "--keep-focus" in launch + # Both ``--tab-title`` and ``--title`` carry the title so kitty + # tabbar AND window list show the host. + assert "--tab-title" in launch and "web01" in launch + assert h.data["window_id"] == "17" + assert h.data["title"] == "web01" + + +def test_open_block_raises_when_kitty_returncode_nonzero(monkeypatch): + """A failed ``kitty @ launch`` surfaces a clear remote-control error.""" + monkeypatch.setattr(kitty_mod.shutil, "which", lambda _name: "/usr/local/bin/kitty") + + def runner(args, check=False, capture_output=False, text=False): + return subprocess.CompletedProcess( + args, 1, stdout="", stderr="kitty: not allowed\n" + ) + + monkeypatch.setattr(kitty_mod.subprocess, "run", runner) + l = kitty_mod.KittyLauncher() + with pytest.raises(RuntimeError, match="allow_remote_control"): + l.open_block(["echo"], "h") + + +def test_tile_invokes_goto_layout_grid(fake_kitty): + l = kitty_mod.KittyLauncher() + h = l.open_block(["echo"], "h") + fake_kitty.clear() + + l.tile([h]) + + assert fake_kitty == [["kitty", "@", "goto-layout", "grid"]] + + +def test_close_block_matches_by_window_id(fake_kitty): + """We match by window id, not title — IDs survive renames.""" + l = kitty_mod.KittyLauncher() + h = l.open_block(["echo"], "h") + fake_kitty.clear() + + l.close_block(h) + + assert fake_kitty[0][:3] == ["kitty", "@", "close-window"] + assert "--match" in fake_kitty[0] + assert "id:17" in fake_kitty[0] + + +def test_close_block_noop_when_window_id_missing(monkeypatch, fake_kitty): + """If ``open_block`` couldn't capture a window id, close silently no-ops.""" + l = kitty_mod.KittyLauncher() + from csshx_latest.launcher import BlockHandle + bogus = BlockHandle(backend="kitty", data={"window_id": "", "title": "x"}) + fake_kitty.clear() + + l.close_block(bogus) + + assert fake_kitty == [] + + +def test_set_title_uses_set_window_title(fake_kitty): + l = kitty_mod.KittyLauncher() + h = l.open_block(["echo"], "h") + fake_kitty.clear() + + l.set_title(h, "renamed") + + assert fake_kitty[0][:3] == ["kitty", "@", "set-window-title"] + assert "id:17" in fake_kitty[0] + assert "renamed" in fake_kitty[0] diff --git a/csshx-latest/tests/test_launcher_manual.py b/csshx-latest/tests/test_launcher_manual.py new file mode 100644 index 0000000..1876412 --- /dev/null +++ b/csshx-latest/tests/test_launcher_manual.py @@ -0,0 +1,34 @@ +"""Tests for the Manual fallback launcher.""" +from __future__ import annotations + +from csshx_latest.launchers.manual import ManualLauncher + + +def test_manual_prints_numbered_attach_commands(capsys): + l = ManualLauncher() + h1 = l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/a.sock"], "web01") + h2 = l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/b.sock"], "web02") + out = capsys.readouterr().out + + assert "[1]" in out + assert "[2]" in out + assert "/tmp/a.sock" in out and "web01" in out + assert "/tmp/b.sock" in out and "web02" in out + assert h1.backend == "manual" + assert h2.data["index"] == 2 + + +def test_manual_quotes_paths_with_spaces(capsys): + l = ManualLauncher() + l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/with space.sock"], "h") + out = capsys.readouterr().out + # shlex.quote either single-quotes or escapes the space. + assert "'" in out or "\\ " in out + + +def test_manual_ops_are_noops_and_do_not_raise(): + l = ManualLauncher() + h = l.open_block(["echo", "hi"], "h") + l.tile([h]) + l.set_title(h, "renamed") + l.close_block(h) diff --git a/csshx-latest/tests/test_launcher_tmux.py b/csshx-latest/tests/test_launcher_tmux.py new file mode 100644 index 0000000..b179e4e --- /dev/null +++ b/csshx-latest/tests/test_launcher_tmux.py @@ -0,0 +1,129 @@ +"""Tests for the Tmux launcher (subprocess.run mocked).""" +from __future__ import annotations + +import subprocess + +import pytest + +from csshx_latest.launchers import tmux as tmux_mod + + +@pytest.fixture +def fake_run(monkeypatch): + """Replace ``subprocess.run`` with a recorder that mimics tmux output.""" + calls: list[list[str]] = [] + + def runner(args, check=False, capture_output=False, text=False): + calls.append(list(args)) + if "split-window" in args or "new-window" in args: + return subprocess.CompletedProcess(args, 0, stdout="%42\n", stderr="") + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(tmux_mod.subprocess, "run", runner) + return calls + + +@pytest.fixture(autouse=True) +def _clear_host_count(monkeypatch): + """The launcher no longer reads CSSHX_HOST_COUNT; ensure it's unset.""" + monkeypatch.delenv("CSSHX_HOST_COUNT", raising=False) + + +def test_open_block_runs_split_window_and_titles(fake_run): + l = tmux_mod.TmuxLauncher() + h = l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/a"], "web01") + assert h.backend == "tmux" + assert h.data["pane_id"] == "%42" + + assert fake_run[0][:2] == ["tmux", "split-window"] + assert "-P" in fake_run[0] + title_call = next(c for c in fake_run if "select-pane" in c) + assert "web01" in title_call + + +def test_tile_calls_select_layout_tiled(fake_run): + l = tmux_mod.TmuxLauncher() + h = l.open_block(["echo"], "h") + fake_run.clear() + l.tile([h]) + assert any("select-layout" in c and "tiled" in c for c in fake_run) + + +def test_close_block_kills_pane(fake_run): + l = tmux_mod.TmuxLauncher() + h = l.open_block(["echo"], "h") + fake_run.clear() + l.close_block(h) + assert any("kill-pane" in c for c in fake_run) + + +def test_tile_with_no_handles_is_silent(fake_run): + l = tmux_mod.TmuxLauncher() + fake_run.clear() + l.tile([]) + assert fake_run == [] + + +def test_set_title_runs_select_pane_T(fake_run): + l = tmux_mod.TmuxLauncher() + h = l.open_block(["echo"], "h") + fake_run.clear() + l.set_title(h, "renamed") + assert any("select-pane" in c and "renamed" in c for c in fake_run) + + +def test_first_open_uses_new_window_when_many_hosts(monkeypatch, fake_run): + """With >PANE_THRESHOLD hosts, the first block carves out a new window.""" + l = tmux_mod.TmuxLauncher() + l.start(8) + l.open_block(["echo", "first"], "h1") + + # First tmux call must be ``new-window`` (not ``split-window``). + assert fake_run[0][:2] == ["tmux", "new-window"] + # And it must capture the pane id so subsequent splits anchor to it. + assert "-P" in fake_run[0] + assert any(a.startswith("-n") or a == "-n" for a in fake_run[0]) + + +def test_second_open_anchors_split_to_new_window(monkeypatch, fake_run): + """After new-window, subsequent splits must target the new window's pane id.""" + l = tmux_mod.TmuxLauncher() + l.start(8) + l.open_block(["echo", "first"], "h1") + fake_run.clear() + l.open_block(["echo", "second"], "h2") + + split_calls = [c for c in fake_run if "split-window" in c] + assert split_calls, "second open should split inside the new window" + # The split must be anchored to the new window's pane id (%42 from fake). + split = split_calls[0] + assert "-t" in split + t_idx = split.index("-t") + assert split[t_idx + 1] == "%42" + + +def test_small_cluster_uses_split_from_the_start(monkeypatch, fake_run): + """At or below PANE_THRESHOLD, never call new-window.""" + l = tmux_mod.TmuxLauncher() + l.start(3) + l.open_block(["echo"], "h1") + l.open_block(["echo"], "h2") + + assert all("new-window" not in c for c in fake_run), ( + "small clusters should never create a dedicated window" + ) + assert any("split-window" in c for c in fake_run) + + +def test_explicit_target_disables_new_window_heuristic(monkeypatch, fake_run): + """If the caller passed an explicit ``target``, never auto-new-window.""" + l = tmux_mod.TmuxLauncher(target="my-session:0") + l.start(8) + l.open_block(["echo"], "h1") + + assert all("new-window" not in c for c in fake_run) + assert fake_run[0][:2] == ["tmux", "split-window"] + # And the explicit target must be honored. + assert "-t" in fake_run[0] + t_idx = fake_run[0].index("-t") + assert fake_run[0][t_idx + 1] == "my-session:0" diff --git a/csshx-latest/tests/test_launcher_waveterm.py b/csshx-latest/tests/test_launcher_waveterm.py new file mode 100644 index 0000000..2703d77 --- /dev/null +++ b/csshx-latest/tests/test_launcher_waveterm.py @@ -0,0 +1,321 @@ +"""Tests for the WaveTerm launcher (subprocess.run mocked).""" +from __future__ import annotations + +import os +import subprocess + +import pytest + +from csshx_latest.launcher import BlockHandle, Color +from csshx_latest.launchers import waveterm as waveterm_mod + + +@pytest.fixture(autouse=True) +def _pin_wsh(monkeypatch, request): + """Force ``_resolve_wsh`` to return the literal ``"wsh"`` so tests can + assert on argv[0] without caring whether the host has wsh installed. + + Tests whose names start with ``test_resolve_wsh_`` opt out — they need + the real resolver to verify its behavior. + """ + if request.node.name.startswith(("test_resolve_wsh_", "test_swap_", "test_parse_bash_")): + return + monkeypatch.setattr(waveterm_mod, "_resolve_wsh", lambda: "wsh") + # Don't let the launcher's __init__ swap a real token from the test + # runner's env (e.g. when running tests inside a WaveTerm block). + monkeypatch.setattr(waveterm_mod, "_swap_waveterm_token", lambda _wsh: True) + + +@pytest.fixture +def fake_run(monkeypatch): + """Replace ``subprocess.run`` with a recorder that mimics ``wsh``.""" + calls: list[list[str]] = [] + + def runner(args, check=False, capture_output=False, text=False): + calls.append(list(args)) + if args[:2] == ["wsh", "run"]: + return subprocess.CompletedProcess(args, 0, stdout="block-7\n", stderr="") + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(waveterm_mod.subprocess, "run", runner) + return calls + + +def test_open_block_invokes_wsh_run(fake_run): + l = waveterm_mod.WaveTermLauncher() + h = l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/a"], "web01") + assert fake_run[0][:3] == ["wsh", "run", "--"] + assert "socat" in fake_run[0] + assert h.data["block_id"] == "block-7" + + +def test_tile_attempts_layout_subcommands(fake_run): + l = waveterm_mod.WaveTermLauncher() + fake_run.clear() + l.tile([]) + assert fake_run, "tile should attempt at least one wsh subcommand" + assert all(c[0] == "wsh" for c in fake_run) + # All attempts target a tiled-style layout. + flat = [arg for c in fake_run for arg in c] + assert any("tile" in a for a in flat) or any("tiled" in a for a in flat) + + +def test_close_block_invokes_deleteblock(fake_run): + l = waveterm_mod.WaveTermLauncher() + h = l.open_block(["echo", "hi"], "h") + fake_run.clear() + l.close_block(h) + assert fake_run and fake_run[0][:2] == ["wsh", "deleteblock"] + assert "block-7" in fake_run[0] + + +def test_set_title_invokes_settitle(fake_run): + l = waveterm_mod.WaveTermLauncher() + h = l.open_block(["echo", "hi"], "h") + fake_run.clear() + l.set_title(h, "renamed") + assert fake_run and fake_run[0][:2] == ["wsh", "settitle"] + assert "renamed" in fake_run[0] + + +def test_tile_stops_at_first_zero_exit(fake_run): + """``setlayout`` succeeds first; we should not retry the others.""" + l = waveterm_mod.WaveTermLauncher() + fake_run.clear() + l.tile([]) + assert len(fake_run) == 1 + + +def test_tile_caches_first_successful_subcommand(monkeypatch): + """Once a variant works, every subsequent tile() uses it without re-probing.""" + calls: list[list[str]] = [] + + def runner(args, check=False, capture_output=False, text=False): + calls.append(list(args)) + # Only the LAST variant in _TILE_VARIANTS succeeds. + if args == ["wsh", "tile"]: + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + return subprocess.CompletedProcess(args, 1, stdout="", stderr="unknown cmd") + + monkeypatch.setattr(waveterm_mod.subprocess, "run", runner) + l = waveterm_mod.WaveTermLauncher() + + # First tile() probes three variants. + l.tile([]) + first_probe_count = len(calls) + assert first_probe_count == 3 + assert calls[-1] == ["wsh", "tile"] + + # Subsequent tile() calls reuse the cached winner — exactly one call each. + calls.clear() + l.tile([]) + l.tile([]) + assert calls == [["wsh", "tile"], ["wsh", "tile"]] + + +def test_tile_does_not_reprobe_when_all_variants_fail(monkeypatch): + """If nothing works, remember that — don't keep probing forever.""" + calls: list[list[str]] = [] + + def runner(args, check=False, capture_output=False, text=False): + calls.append(list(args)) + return subprocess.CompletedProcess(args, 1, stdout="", stderr="x") + + monkeypatch.setattr(waveterm_mod.subprocess, "run", runner) + l = waveterm_mod.WaveTermLauncher() + + l.tile([]) + first = len(calls) + l.tile([]) # Should be a no-op now — nothing cached, probe already done. + assert len(calls) == first + + +def test_tile_with_multiple_handles_still_runs_cached_subcommand(fake_run): + """tile() ignores handle topology — it just delegates to the cached wsh + subcommand. With 4 handles the call shape is the same as with 0; we only + care that it doesn't crash and that a tile-style subcommand was sent.""" + l = waveterm_mod.WaveTermLauncher() + fake_run.clear() + handles = [ + BlockHandle(backend="waveterm", data={"block_id": f"b-{i}", "title": f"h{i}"}) + for i in range(4) + ] + l.tile(handles) + assert fake_run, "tile([handles]) should still invoke wsh" + flat = [arg for c in fake_run for arg in c] + assert any(a in ("tile", "tiled") for a in flat) + + +def test_set_color_calls_wsh_setbg_with_enabled_hex(fake_run): + """ENABLED state must produce ``wsh setbg -b <id> #0e3d0e``.""" + l = waveterm_mod.WaveTermLauncher() + h = l.open_block(["echo"], "h") + fake_run.clear() + l.set_color(h, Color.ENABLED) + assert fake_run == [["wsh", "setbg", "-b", "block-7", "#0e3d0e"]] + + +def test_set_color_uses_distinct_hex_per_state(fake_run): + """Each Color state must map to its own hex from _BG_HEX.""" + l = waveterm_mod.WaveTermLauncher() + h = l.open_block(["echo"], "h") + fake_run.clear() + l.set_color(h, Color.ENABLED) + l.set_color(h, Color.DISABLED) + l.set_color(h, Color.DEAD) + hexes = [c[-1] for c in fake_run] + assert hexes == [ + waveterm_mod._BG_HEX[Color.ENABLED], + waveterm_mod._BG_HEX[Color.DISABLED], + waveterm_mod._BG_HEX[Color.DEAD], + ] + # Sanity: all three hexes are distinct. + assert len(set(hexes)) == 3 + + +def test_set_color_noop_when_no_block_id(fake_run): + """A handle without a block_id has nothing to tint — must not call wsh.""" + l = waveterm_mod.WaveTermLauncher() + h = BlockHandle(backend="waveterm", data={"title": "no-id"}) + fake_run.clear() + l.set_color(h, Color.ENABLED) + assert fake_run == [] + + +def test_set_color_caches_unsupported_after_first_failure(monkeypatch): + """If ``wsh setbg`` exits nonzero once, every later call must short-circuit.""" + calls: list[list[str]] = [] + + def runner(args, check=False, capture_output=False, text=False): + calls.append(list(args)) + if args[:2] == ["wsh", "run"]: + return subprocess.CompletedProcess(args, 0, stdout="block-9\n", stderr="") + if args[1] == "setbg": + return subprocess.CompletedProcess(args, 1, stdout="", stderr="unknown cmd") + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(waveterm_mod.subprocess, "run", runner) + l = waveterm_mod.WaveTermLauncher() + h = l.open_block(["echo"], "h") + calls.clear() + + l.set_color(h, Color.ENABLED) # probes, fails, caches False + first = len(calls) + assert first == 1 + assert l._setbg_supported is False + + # Subsequent calls must short-circuit entirely. + l.set_color(h, Color.DISABLED) + l.set_color(h, Color.DEAD) + assert len(calls) == first, "set_color must not re-probe after caching unsupported" + + +def test_set_color_keeps_supported_flag_after_first_success(monkeypatch): + """A successful setbg flips _setbg_supported True so the fast path stays on.""" + def runner(args, check=False, capture_output=False, text=False): + if args[:2] == ["wsh", "run"]: + return subprocess.CompletedProcess(args, 0, stdout="block-9\n", stderr="") + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(waveterm_mod.subprocess, "run", runner) + l = waveterm_mod.WaveTermLauncher() + h = l.open_block(["echo"], "h") + l.set_color(h, Color.ENABLED) + assert l._setbg_supported is True + + +def test_resolve_wsh_prefers_path(monkeypatch): + """If ``wsh`` is on PATH, that's what we use (don't probe fallback dirs).""" + monkeypatch.setattr(waveterm_mod.shutil, "which", lambda name: "/usr/local/bin/wsh") + assert waveterm_mod._resolve_wsh() == "/usr/local/bin/wsh" + + +def test_resolve_wsh_falls_back_to_known_install_paths(monkeypatch, tmp_path): + """When PATH lookup fails, scan the WaveTerm install dirs. + + Simulates the widget case: ``controller: cmd`` execvp's csshx-latest with + only the bare system PATH, which doesn't include WaveTerm's bin dir. + The launcher must still find ``wsh`` so ``wsh run`` doesn't ENOENT. + """ + fake_wsh = tmp_path / "wsh" + fake_wsh.write_text("#!/bin/sh\nexit 0\n") + fake_wsh.chmod(0o755) + monkeypatch.setattr(waveterm_mod.shutil, "which", lambda name: None) + monkeypatch.setattr(waveterm_mod, "_WSH_FALLBACK_PATHS", (str(fake_wsh),)) + assert waveterm_mod._resolve_wsh() == str(fake_wsh) + + +def test_resolve_wsh_returns_literal_when_nothing_found(monkeypatch): + """Last-resort: return ``"wsh"`` so subprocess raises a clear ENOENT.""" + monkeypatch.setattr(waveterm_mod.shutil, "which", lambda name: None) + monkeypatch.setattr(waveterm_mod, "_WSH_FALLBACK_PATHS", ()) + assert waveterm_mod._resolve_wsh() == "wsh" + + +def test_parse_bash_exports_handles_quoting_variants(): + """``wsh token`` emits double-quoted, single-quoted, and unquoted exports.""" + script = '\n'.join([ + 'export WAVETERM_JWT="abc.def.ghi"', + "export WAVETERM_BLOCKID='7f1791ee-62bf'", + 'export WAVETERM_VERSION=0.14.5', + '# comment line', + 'echo something else', + ]) + out = waveterm_mod._parse_bash_exports(script) + assert out["WAVETERM_JWT"] == "abc.def.ghi" + assert out["WAVETERM_BLOCKID"] == "7f1791ee-62bf" + assert out["WAVETERM_VERSION"] == "0.14.5" + assert "echo" not in out + + +def test_swap_waveterm_token_populates_env(monkeypatch): + """A successful swap copies JWT (and friends) into os.environ.""" + monkeypatch.delenv("WAVETERM_JWT", raising=False) + monkeypatch.setenv("WAVETERM_SWAPTOKEN", "totally-real-swap-token") + + def fake_run(args, check=False, capture_output=False, text=False, timeout=None): + assert args[1:] == ["token", "totally-real-swap-token", "bash"] + return subprocess.CompletedProcess( + args, 0, + stdout='export WAVETERM_JWT="jwt-xyz"\nexport WAVETERM_BLOCKID="bid-1"\n', + stderr="", + ) + + monkeypatch.setattr(waveterm_mod.subprocess, "run", fake_run) + assert waveterm_mod._swap_waveterm_token("/fake/wsh") is True + assert os.environ["WAVETERM_JWT"] == "jwt-xyz" + assert os.environ["WAVETERM_BLOCKID"] == "bid-1" + + +def test_swap_waveterm_token_is_noop_when_jwt_already_set(monkeypatch): + """If WAVETERM_JWT is already exported, don't fork wsh again.""" + monkeypatch.setenv("WAVETERM_JWT", "pre-existing") + called = [] + + def fake_run(*args, **kwargs): + called.append(args) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(waveterm_mod.subprocess, "run", fake_run) + assert waveterm_mod._swap_waveterm_token("/fake/wsh") is True + assert called == [] + + +def test_swap_waveterm_token_returns_false_when_no_swaptoken(monkeypatch): + """No swap token and no JWT means we can't authenticate — report it.""" + monkeypatch.delenv("WAVETERM_JWT", raising=False) + monkeypatch.delenv("WAVETERM_SWAPTOKEN", raising=False) + assert waveterm_mod._swap_waveterm_token("/fake/wsh") is False + + +def test_swap_waveterm_token_returns_false_on_wsh_nonzero(monkeypatch): + """wsh token exit != 0 must not corrupt os.environ.""" + monkeypatch.delenv("WAVETERM_JWT", raising=False) + monkeypatch.setenv("WAVETERM_SWAPTOKEN", "bad") + + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess(args, 1, stdout="", stderr="invalid") + + monkeypatch.setattr(waveterm_mod.subprocess, "run", fake_run) + assert waveterm_mod._swap_waveterm_token("/fake/wsh") is False + assert "WAVETERM_JWT" not in os.environ diff --git a/csshx-latest/tests/test_logging_setup.py b/csshx-latest/tests/test_logging_setup.py new file mode 100644 index 0000000..25fd968 --- /dev/null +++ b/csshx-latest/tests/test_logging_setup.py @@ -0,0 +1,63 @@ +"""Tests for :mod:`csshx_latest.logging_setup`. + +We verify that :func:`configure_logging` installs a stderr handler at +the requested level and is idempotent (repeated calls don't double the +output, which would be a footgun during tests that import the module +multiple times). +""" +from __future__ import annotations + +import logging +import sys + +from csshx_latest.logging_setup import configure_logging + + +def test_default_level_is_warning(): + configure_logging(debug=False) + assert logging.getLogger().level == logging.WARNING + + +def test_debug_flag_sets_debug_level(): + configure_logging(debug=True) + assert logging.getLogger().level == logging.DEBUG + + +def test_repeated_calls_do_not_accumulate_handlers(): + """Calling configure_logging twice must replace, not append, handlers.""" + configure_logging(debug=False) + first_count = len(logging.getLogger().handlers) + configure_logging(debug=True) + second_count = len(logging.getLogger().handlers) + assert first_count == second_count + + +def test_handler_writes_to_stderr(): + """The single root handler is a StreamHandler on sys.stderr.""" + configure_logging(debug=False) + handlers = logging.getLogger().handlers + assert any( + isinstance(h, logging.StreamHandler) and getattr(h, "stream", None) is sys.stderr + for h in handlers + ) + + +def test_debug_logs_actually_render(capsys): + """A getLogger(...) call after configure_logging emits to stderr at DEBUG. + + ``configure_logging`` replaces every root handler — including + pytest's ``caplog`` plumbing — so we have to assert on the real + stderr stream rather than via the caplog fixture. That's also the + surface the user actually sees, so this is the right thing to pin. + """ + configure_logging(debug=True) + log = logging.getLogger("csshx_latest.test_marker") + log.debug("hello-from-test") + # Logging handlers are line-buffered; flush before reading. + for h in logging.getLogger().handlers: + h.flush() + err = capsys.readouterr().err + assert "hello-from-test" in err + # Format check: includes level + logger name so users can grep. + assert "DEBUG" in err + assert "csshx_latest.test_marker" in err diff --git a/csshx-latest/tests/test_main_cli.py b/csshx-latest/tests/test_main_cli.py new file mode 100644 index 0000000..c2d23ef --- /dev/null +++ b/csshx-latest/tests/test_main_cli.py @@ -0,0 +1,137 @@ +"""Tests for ``csshx_latest.__main__.main`` argument parsing. + +Author: Aditya Kapadia. + +We don't actually run the master loop -- that needs a tty and forks +ssh. Instead we stub ``asyncio.run`` and assert on the args that +``run_master`` would have received. +""" +from __future__ import annotations + +import pytest + +from csshx_latest import __main__ as cli + + +@pytest.fixture +def captured_run(monkeypatch): + """Stub ``asyncio.run`` and capture the coro's bound args.""" + captured: dict[str, object] = {} + + async def fake_coro( + hosts, + ssh_args, + login, + launcher, + *, + max_hosts=16, + strict_preflight=False, + reconnect=False, + skip_preflight=False, + command_key=b"\x14", + ): + captured["hosts"] = list(hosts) + captured["ssh_args"] = list(ssh_args) + captured["login"] = login + captured["launcher"] = launcher + captured["max_hosts"] = max_hosts + captured["strict_preflight"] = strict_preflight + captured["reconnect"] = reconnect + captured["skip_preflight"] = skip_preflight + captured["command_key"] = command_key + return 0 + + monkeypatch.setattr(cli, "run_master", fake_coro) + + def fake_asyncio_run(coro): + import asyncio + return asyncio.new_event_loop().run_until_complete(coro) + + monkeypatch.setattr(cli.asyncio, "run", fake_asyncio_run) + return captured + + +@pytest.fixture(autouse=True) +def _no_clusters(monkeypatch): + """Clusters should not be loaded from the user's real home dir in tests.""" + monkeypatch.setattr(cli, "load_clusters", lambda: {}) + + +def test_version_flag_exits_zero(capsys): + """``--version`` should print the package version and exit 0.""" + with pytest.raises(SystemExit) as exc: + cli.main(["--version"]) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "csshx-latest" in out + + +def test_brace_expansion_happens_before_run_master(captured_run, monkeypatch): + """``web0{1..3}`` must be expanded to three hosts before run_master sees them.""" + monkeypatch.setattr(cli, "detect_launcher", lambda _name: object()) + cli.main(["--launcher", "manual", "web0{1..3}"]) + assert captured_run["hosts"] == ["web01", "web02", "web03"] + + +def test_launcher_choices_include_all_registered_backends(): + """The ``--launcher`` choices must come from the registry (not hardcoded).""" + from csshx_latest.launcher import available_launcher_names + + names = available_launcher_names() + for expected in ("auto", "tmux", "iterm2", "terminal", "kitty", "waveterm", "wezterm", "manual"): + assert expected in names, f"missing launcher choice: {expected}" + + +def test_debug_flag_does_not_raise(captured_run, monkeypatch): + """``--debug`` is accepted and reconfigures logging without error.""" + monkeypatch.setattr(cli, "detect_launcher", lambda _name: object()) + rc = cli.main(["--debug", "--launcher", "manual", "host1"]) + assert rc == 0 + + +def test_ssh_args_are_split_with_shlex(captured_run, monkeypatch): + """``--ssh-args`` accepts a single quoted string; we shlex-split it.""" + monkeypatch.setattr(cli, "detect_launcher", lambda _name: object()) + cli.main(["--launcher", "manual", "--ssh-args", "-o StrictHostKeyChecking=no -p 2222", "h"]) + assert captured_run["ssh_args"] == ["-o", "StrictHostKeyChecking=no", "-p", "2222"] + + +def test_invalid_launcher_choice_exits_nonzero(capsys): + """Argparse rejects unknown launcher names.""" + with pytest.raises(SystemExit): + cli.main(["--launcher", "bogus-name", "host"]) + + +def test_max_hosts_default_is_sixteen(captured_run, monkeypatch): + """Default ``--max-hosts`` should match the orchestrator constant.""" + from csshx_latest.orchestrator import DEFAULT_MAX_HOSTS + + monkeypatch.setattr(cli, "detect_launcher", lambda _name: object()) + cli.main(["--launcher", "manual", "host"]) + assert captured_run["max_hosts"] == DEFAULT_MAX_HOSTS == 16 + + +def test_strict_flag_propagates(captured_run, monkeypatch): + monkeypatch.setattr(cli, "detect_launcher", lambda _name: object()) + cli.main(["--launcher", "manual", "--strict", "h"]) + assert captured_run["strict_preflight"] is True + + +def test_reconnect_flag_propagates(captured_run, monkeypatch): + monkeypatch.setattr(cli, "detect_launcher", lambda _name: object()) + cli.main(["--launcher", "manual", "--reconnect", "h"]) + assert captured_run["reconnect"] is True + + +def test_no_preflight_flag_propagates(captured_run, monkeypatch): + monkeypatch.setattr(cli, "detect_launcher", lambda _name: object()) + cli.main(["--launcher", "manual", "--no-preflight", "h"]) + assert captured_run["skip_preflight"] is True + + +def test_cluster_alias_expansion(captured_run, monkeypatch): + """A ``cluster`` name on the CLI should expand to its host list.""" + monkeypatch.setattr(cli, "detect_launcher", lambda _name: object()) + monkeypatch.setattr(cli, "load_clusters", lambda: {"web": ["web01", "web02"]}) + cli.main(["--launcher", "manual", "web"]) + assert captured_run["hosts"] == ["web01", "web02"] diff --git a/csshx-latest/tests/test_orchestrator.py b/csshx-latest/tests/test_orchestrator.py new file mode 100644 index 0000000..0a0df9d --- /dev/null +++ b/csshx-latest/tests/test_orchestrator.py @@ -0,0 +1,186 @@ +"""Tests for orchestrator helpers: preflight, kill-reap, ssh-arg injection.""" +from __future__ import annotations + +import asyncio +import os +import signal +import subprocess +import sys +import time + +import pytest + +from csshx_latest import orchestrator + + +def test_inject_strict_host_key_when_user_did_not_set_it(): + out = orchestrator.maybe_inject_strict_host_key_opts([]) + assert out == ["-o", "StrictHostKeyChecking=accept-new"] + + +def test_inject_strict_host_key_skips_when_user_set_no(): + """If the user passes any -o ... StrictHostKeyChecking value, leave it alone.""" + user = ["-o", "StrictHostKeyChecking=no", "-p", "2222"] + out = orchestrator.maybe_inject_strict_host_key_opts(user) + assert out == user + + +def test_inject_strict_host_key_skips_when_user_set_yes(): + user = ["-oStrictHostKeyChecking=yes"] + out = orchestrator.maybe_inject_strict_host_key_opts(user) + assert out == user + + +def test_preflight_keeps_reachable_drops_unreachable(monkeypatch): + async def fake_probe(host, port=22, timeout=1.0): + return host in {"alive1", "alive2"} + + monkeypatch.setattr(orchestrator, "_probe_host", fake_probe) + out = asyncio.new_event_loop().run_until_complete( + orchestrator.preflight_hosts(["alive1", "dead", "alive2"], strict=False) + ) + assert out == ["alive1", "alive2"] + + +def test_preflight_strict_raises_on_any_dead(monkeypatch): + async def fake_probe(host, port=22, timeout=1.0): + return host != "dead" + + monkeypatch.setattr(orchestrator, "_probe_host", fake_probe) + with pytest.raises(RuntimeError) as exc: + asyncio.new_event_loop().run_until_complete( + orchestrator.preflight_hosts(["alive", "dead"], strict=True) + ) + assert "dead" in str(exc.value) + + +def test_preflight_handles_user_at_host(monkeypatch): + """``user@host`` should be stripped down to ``host`` before the TCP probe.""" + seen: list[str] = [] + + async def fake_probe(host, port=22, timeout=1.0): + seen.append(host) + return True + + monkeypatch.setattr(orchestrator, "_probe_host", fake_probe) + asyncio.new_event_loop().run_until_complete( + orchestrator.preflight_hosts(["deploy@web01"], strict=False) + ) + assert seen == ["deploy@web01"] + + +def test_kill_and_reap_returns_for_already_exited_child(): + """If the child has already exited, _kill_and_reap returns promptly.""" + proc = subprocess.Popen( + [sys.executable, "-c", "import sys; sys.exit(0)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + proc.wait() # already exited + start = time.monotonic() + orchestrator._kill_and_reap(proc.pid, grace=2.0) + assert time.monotonic() - start < 0.5 + + +def test_kill_and_reap_kills_with_sigkill_on_grace_expiry(): + """If SIGTERM is ignored, SIGKILL closes the child within grace + epsilon.""" + proc = subprocess.Popen( + [ + sys.executable, + "-c", + "import signal, time;" + " signal.signal(signal.SIGTERM, signal.SIG_IGN);" + " time.sleep(30)", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + time.sleep(0.1) # let the child install the handler + try: + os.kill(proc.pid, signal.SIGTERM) + start = time.monotonic() + orchestrator._kill_and_reap(proc.pid, grace=0.3) + # Should return within grace + a small headroom. + assert time.monotonic() - start < 1.5 + # And the child must actually be gone. + assert proc.poll() is not None or _is_zombie_or_dead(proc.pid) + finally: + try: + os.kill(proc.pid, signal.SIGKILL) + except OSError: + pass + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + + +def _is_zombie_or_dead(pid: int) -> bool: + try: + os.kill(pid, 0) + return False + except OSError: + return True + + +def _bare_slave(**overrides): + """Build a minimal Slave for decision-helper tests (no PTY / sockets).""" + from csshx_latest.slave import Slave + + defaults = dict( + index=1, + host="h", + sock_path="/tmp/x", + token="t", + pty_master=-1, + pid=0, + ) + defaults.update(overrides) + return Slave(**defaults) + + +def test_should_reconnect_true_when_flag_on_and_not_user_closed(): + """Normal ssh death with ``--reconnect`` → respawn.""" + s = _bare_slave(dead=True, user_closed=False) + assert orchestrator._should_reconnect(s, reconnect_enabled=True) is True + + +def test_should_reconnect_false_when_flag_off(): + """Without ``--reconnect``, no respawn even for an unexpected death.""" + s = _bare_slave(dead=True, user_closed=False) + assert orchestrator._should_reconnect(s, reconnect_enabled=False) is False + + +def test_should_reconnect_false_when_user_closed_block(): + """User closed the terminal block → BYE → must NOT respawn even with --reconnect. + + This is the guard that prevents a closed slave from silently + coming back to life one backoff cycle later. + """ + s = _bare_slave(dead=True, user_closed=True) + assert orchestrator._should_reconnect(s, reconnect_enabled=True) is False + + +def test_run_master_refuses_above_max_hosts(monkeypatch, capsys): + """The hard cap rejects oversize host lists before touching launchers.""" + class FakeLauncher: + name = "fake" + def start(self, total): pass + def open_block(self, c, t): raise AssertionError("must not be called") + def close_block(self, h): pass + def tile(self, h): pass + def set_title(self, h, t): pass + + rc = asyncio.new_event_loop().run_until_complete( + orchestrator.run_master( + ["h"] * 50, + ssh_args=[], + login=None, + launcher=FakeLauncher(), + max_hosts=16, + skip_preflight=True, + ) + ) + assert rc == 2 + err = capsys.readouterr().err + assert "max-hosts" in err diff --git a/csshx-latest/tests/test_slave_bridge.py b/csshx-latest/tests/test_slave_bridge.py new file mode 100644 index 0000000..2f53bde --- /dev/null +++ b/csshx-latest/tests/test_slave_bridge.py @@ -0,0 +1,139 @@ +"""Integration-ish tests for the PTY <-> socket bridge. + +These exercise the bug fix where late-connecting terminal blocks would +miss the SSH banner / login prompt because the PTY reader had no +clients to forward to. With the scrollback buffer, connecting *after* +the PTY has emitted bytes should still deliver every byte to the new +client right after AUTH succeeds. + +Two macOS-specific gotchas the fixtures handle: + +* ``short_socket_dir`` keeps the AF_UNIX path under macOS's 104-byte + ``sun_path`` limit (pytest's ``tmp_path`` does not). +* ``harmless_pid`` ensures the Slave has a real PID — ``shutdown_slave`` + calls ``os.kill(pid, SIGTERM)`` and ``os.kill(0, ...)`` would signal + the entire process group (i.e. pytest itself). +""" +from __future__ import annotations + +import asyncio +import os +import sys + +import pytest + +pytest.importorskip("fcntl", reason="bridge tests need Unix pipes/sockets") +if sys.platform == "win32": # pragma: no cover - skip path + pytest.skip("asyncio.start_unix_server is not available on Windows", allow_module_level=True) +if not hasattr(asyncio, "start_unix_server"): # pragma: no cover + pytest.skip("Unix-domain asyncio server not available", allow_module_level=True) + +from csshx_latest.slave import Slave, run_slave_bridge, shutdown_slave + + +def _make_slave(sock_path: str, pty_read_fd: int, pid: int, *, token: str = "TOK") -> Slave: + return Slave( + index=1, + host="h", + sock_path=sock_path, + token=token, + pty_master=pty_read_fd, + pid=pid, + ) + + +def test_late_client_receives_scrollback(short_socket_dir, harmless_pid): + """Bytes emitted before any client connected must be replayed on AUTH.""" + sock_path = os.path.join(short_socket_dir, "slave.sock") + pty_r, pty_w = os.pipe() + slave = _make_slave(sock_path, pty_r, harmless_pid) + + async def go() -> bytes: + # Bridge sets up server + pty_reader_task. After this returns, the + # reader task is running and will pick up bytes from pty_r. + await run_slave_bridge(slave) + # Emit "banner" bytes before any client has connected. These must + # land in the scrollback buffer. + os.write(pty_w, b"SSH banner\nlogin: ") + # Yield enough times for the reader task to drain the pipe into + # scrollback. + for _ in range(10): + await asyncio.sleep(0.01) + # Now connect a client, AUTH, and read the replay. + reader, writer = await asyncio.open_unix_connection(sock_path) + writer.write(f"AUTH {slave.token}\n".encode()) + await writer.drain() + data = await asyncio.wait_for(reader.read(4096), timeout=1.0) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return data + + try: + received = asyncio.run(go()) + finally: + os.close(pty_w) + shutdown_slave(slave) + + assert b"SSH banner" in received + assert b"login: " in received + + +def test_scrollback_is_capped(short_socket_dir, harmless_pid): + """A flood of pre-connect output must not unbounded-grow the buffer.""" + sock_path = os.path.join(short_socket_dir, "slave.sock") + pty_r, pty_w = os.pipe() + slave = _make_slave(sock_path, pty_r, harmless_pid) + slave.scrollback_max = 1024 # tighten the cap for the test + + async def go() -> int: + await run_slave_bridge(slave) + os.write(pty_w, b"x" * 4096) + for _ in range(20): + await asyncio.sleep(0.01) + if len(slave.scrollback) >= slave.scrollback_max: + break + return len(slave.scrollback) + + try: + size = asyncio.run(go()) + finally: + os.close(pty_w) + shutdown_slave(slave) + + assert size <= slave.scrollback_max + assert size > 0 + + +def test_wrong_token_is_rejected_and_does_not_get_scrollback(short_socket_dir, harmless_pid): + """Failed AUTH must drop the connection without leaking scrollback.""" + sock_path = os.path.join(short_socket_dir, "slave.sock") + pty_r, pty_w = os.pipe() + slave = _make_slave(sock_path, pty_r, harmless_pid, token="REAL_TOKEN") + + async def go() -> bytes: + await run_slave_bridge(slave) + os.write(pty_w, b"super secret prompt") + for _ in range(10): + await asyncio.sleep(0.01) + reader, writer = await asyncio.open_unix_connection(sock_path) + writer.write(b"AUTH wrong-token\n") + await writer.drain() + # Server should close the connection without sending anything. + data = await asyncio.wait_for(reader.read(4096), timeout=1.0) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return data + + try: + received = asyncio.run(go()) + finally: + os.close(pty_w) + shutdown_slave(slave) + + assert received == b"" diff --git a/csshx-latest/tests/test_slave_control_socket.py b/csshx-latest/tests/test_slave_control_socket.py new file mode 100644 index 0000000..7eaa5f0 --- /dev/null +++ b/csshx-latest/tests/test_slave_control_socket.py @@ -0,0 +1,208 @@ +"""Tests for the slave control socket (WINSZ propagation, AUTH gating).""" +from __future__ import annotations + +import asyncio +import os +import struct +import sys + +import pytest + +pytest.importorskip("fcntl", reason="control-socket tests need Unix pipes/sockets") +if sys.platform == "win32": # pragma: no cover + pytest.skip("AF_UNIX not available", allow_module_level=True) + +from csshx_latest.slave import Slave, _apply_control_line, run_slave_bridge, shutdown_slave + + +def _make_slave(sock_path: str, ctl_path: str, pty_fd: int, pid: int, token: str = "TOK") -> Slave: + return Slave( + index=1, + host="h", + sock_path=sock_path, + ctl_sock_path=ctl_path, + token=token, + pty_master=pty_fd, + pid=pid, + ) + + +def test_apply_control_line_resizes_pty(): + """A well-formed WINSZ line should call TIOCSWINSZ on the slave's PTY.""" + import fcntl + import pty + import termios + + pty_master, pty_slave = pty.openpty() + try: + slave = _make_slave("", "", pty_master, 0) + _apply_control_line(slave, b"WINSZ 42 137 0 0\n") + packed = fcntl.ioctl(pty_slave, termios.TIOCGWINSZ, b"\x00" * 8) + rows, cols, _, _ = struct.unpack("HHHH", packed) + assert rows == 42 + assert cols == 137 + finally: + os.close(pty_master) + os.close(pty_slave) + + +def test_apply_control_line_rejects_bad_grammar(): + """Malformed lines are ignored without raising.""" + import pty + + pty_master, pty_slave = pty.openpty() + try: + slave = _make_slave("", "", pty_master, 0) + # All these should be no-ops, never raise. + _apply_control_line(slave, b"WINSZ\n") + _apply_control_line(slave, b"WINSZ abc def\n") + _apply_control_line(slave, b"HELLO 1 2\n") + _apply_control_line(slave, b"\n") + _apply_control_line(slave, b"\xff\xfe\n") # non-ascii + _apply_control_line(slave, b"WINSZ -1 80\n") # non-positive + _apply_control_line(slave, b"BYE extra\n") # BYE takes no args + finally: + os.close(pty_master) + os.close(pty_slave) + + +def test_apply_control_line_bye_marks_user_closed_and_sigterms_ssh(harmless_pid): + """``BYE`` sets ``user_closed=True`` and sends SIGTERM to the ssh pid. + + This is the path that makes "close the terminal window → slave + actually exits" work. We mock os.kill so we don't actually kill the + fixture's helper process; we only need to verify the SIGTERM was + issued with the right pid. + """ + import pty + import signal as _signal + import unittest.mock + + from csshx_latest import slave as slave_mod + + pty_master, pty_slave = pty.openpty() + try: + slave = _make_slave("", "", pty_master, harmless_pid) + assert slave.user_closed is False + + with unittest.mock.patch.object(slave_mod.os, "kill") as fake_kill: + _apply_control_line(slave, b"BYE\n") + + assert slave.user_closed is True + fake_kill.assert_called_once_with(harmless_pid, _signal.SIGTERM) + finally: + os.close(pty_master) + os.close(pty_slave) + + +def test_apply_control_line_bye_is_idempotent(harmless_pid): + """A second ``BYE`` after the first is a silent no-op (no extra SIGTERM).""" + import pty + import unittest.mock + + from csshx_latest import slave as slave_mod + + pty_master, pty_slave = pty.openpty() + try: + slave = _make_slave("", "", pty_master, harmless_pid) + with unittest.mock.patch.object(slave_mod.os, "kill") as fake_kill: + _apply_control_line(slave, b"BYE\n") + _apply_control_line(slave, b"BYE\n") + assert fake_kill.call_count == 1 + finally: + os.close(pty_master) + os.close(pty_slave) + + +def test_apply_control_line_bye_skips_kill_for_already_dead_slave(harmless_pid): + """If ``slave.dead`` is already set (natural ssh exit), BYE does not SIGTERM.""" + import pty + import unittest.mock + + from csshx_latest import slave as slave_mod + + pty_master, pty_slave = pty.openpty() + try: + slave = _make_slave("", "", pty_master, harmless_pid) + slave.dead = True # PTY EOF beat the BYE to the punch + with unittest.mock.patch.object(slave_mod.os, "kill") as fake_kill: + _apply_control_line(slave, b"BYE\n") + assert slave.user_closed is True + fake_kill.assert_not_called() + finally: + os.close(pty_master) + os.close(pty_slave) + + +def test_control_socket_requires_auth(short_socket_dir, harmless_pid): + """A client that fails AUTH on the control socket must not be able to resize.""" + import pty + + pty_master, pty_slave = pty.openpty() + sock_path = os.path.join(short_socket_dir, "slave.sock") + ctl_path = os.path.join(short_socket_dir, "slave.ctl") + slave = _make_slave(sock_path, ctl_path, pty_master, harmless_pid, token="REAL") + + async def go() -> None: + await run_slave_bridge(slave) + # Connect to the control socket and send a wrong AUTH. + reader, writer = await asyncio.open_unix_connection(ctl_path) + writer.write(b"AUTH WRONG\n") + writer.write(b"WINSZ 99 200 0 0\n") + await writer.drain() + await asyncio.sleep(0.1) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + try: + asyncio.run(go()) + finally: + os.close(pty_slave) + shutdown_slave(slave) + # The PTY should NOT have been resized -- but we can't read it post-close. + # The strong assertion is just that no exception was raised and AUTH dropped + # the connection before WINSZ was honored. + + +def test_control_socket_accepts_winsz_after_auth(short_socket_dir, harmless_pid): + """A correctly-authenticated client can resize the slave's PTY.""" + import fcntl + import pty + import termios + + pty_master, pty_slave = pty.openpty() + sock_path = os.path.join(short_socket_dir, "slave.sock") + ctl_path = os.path.join(short_socket_dir, "slave.ctl") + slave = _make_slave(sock_path, ctl_path, pty_master, harmless_pid, token="OK") + + async def go() -> None: + await run_slave_bridge(slave) + reader, writer = await asyncio.open_unix_connection(ctl_path) + writer.write(b"AUTH OK\n") + writer.write(b"WINSZ 50 123 0 0\n") + await writer.drain() + # Let the server process the line. + for _ in range(10): + await asyncio.sleep(0.02) + packed = fcntl.ioctl(pty_slave, termios.TIOCGWINSZ, b"\x00" * 8) + rows, cols, _, _ = struct.unpack("HHHH", packed) + if rows == 50 and cols == 123: + break + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + try: + asyncio.run(go()) + packed = fcntl.ioctl(pty_slave, termios.TIOCGWINSZ, b"\x00" * 8) + rows, cols, _, _ = struct.unpack("HHHH", packed) + assert rows == 50 + assert cols == 123 + finally: + os.close(pty_slave) + shutdown_slave(slave) diff --git a/csshx-latest/tests/test_slave_max_writers.py b/csshx-latest/tests/test_slave_max_writers.py new file mode 100644 index 0000000..ed886b0 --- /dev/null +++ b/csshx-latest/tests/test_slave_max_writers.py @@ -0,0 +1,137 @@ +"""Tests for the ``Slave.max_writers`` fan-out cap on the data socket. + +A leaked AUTH token would otherwise let an attacker attach an +unbounded number of writers to the same slave socket. ``max_writers`` +caps the simultaneously-attached count *after* the AUTH handshake (so +the check itself isn't a probe oracle). +""" +from __future__ import annotations + +import asyncio +import os +import sys + +import pytest + +pytest.importorskip("fcntl", reason="max-writers tests need Unix pipes/sockets") +if sys.platform == "win32": # pragma: no cover + pytest.skip("AF_UNIX not available", allow_module_level=True) + +from csshx_latest.slave import ( + DEFAULT_MAX_WRITERS, + Slave, + run_slave_bridge, + shutdown_slave, +) + + +def _make_slave(sock_path: str, ctl_path: str, pty_fd: int, pid: int, *, max_writers: int) -> Slave: + return Slave( + index=1, + host="h", + sock_path=sock_path, + ctl_sock_path=ctl_path, + token="TOK", + pty_master=pty_fd, + pid=pid, + max_writers=max_writers, + ) + + +async def _attach(sock_path: str, token: str) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """Open the data socket and complete the ``AUTH <token>\\n`` handshake.""" + reader, writer = await asyncio.open_unix_connection(sock_path) + writer.write(f"AUTH {token}\n".encode("ascii")) + await writer.drain() + return reader, writer + + +def test_default_max_writers_constant_is_reasonable(): + """A regression guard: DEFAULT_MAX_WRITERS must be small but > 1.""" + assert 1 < DEFAULT_MAX_WRITERS <= 16 + + +def test_max_writers_caps_concurrent_attachments(short_socket_dir, harmless_pid): + """Above the cap, additional attachments are closed by the server.""" + import pty + + pty_master, pty_slave = pty.openpty() + sock_path = os.path.join(short_socket_dir, "slave.sock") + ctl_path = os.path.join(short_socket_dir, "slave.ctl") + slave = _make_slave(sock_path, ctl_path, pty_master, harmless_pid, max_writers=2) + + async def go() -> None: + await run_slave_bridge(slave) + # First two attaches must succeed and register. + _, w1 = await _attach(sock_path, slave.token) + _, w2 = await _attach(sock_path, slave.token) + # Give the server a tick to process AUTH + register. + for _ in range(10): + await asyncio.sleep(0.02) + if len(slave.connected_writers) >= 2: + break + assert len(slave.connected_writers) == 2 + + # Third attach passes AUTH but the server rejects + closes it. + _, w3 = await _attach(sock_path, slave.token) + try: + await asyncio.wait_for(w3.wait_closed(), timeout=1.5) + except Exception: + pass + # After rejection, the cap is still 2. + assert len(slave.connected_writers) == 2 + + for w in (w1, w2): + w.close() + try: + await w.wait_closed() + except Exception: + pass + + try: + asyncio.run(go()) + finally: + os.close(pty_slave) + shutdown_slave(slave) + + +def test_unauthenticated_attach_does_not_count_against_cap(short_socket_dir, harmless_pid): + """A bad token never makes it past AUTH, so it never consumes a slot.""" + import pty + + pty_master, pty_slave = pty.openpty() + sock_path = os.path.join(short_socket_dir, "slave.sock") + ctl_path = os.path.join(short_socket_dir, "slave.ctl") + slave = _make_slave(sock_path, ctl_path, pty_master, harmless_pid, max_writers=1) + + async def go() -> None: + await run_slave_bridge(slave) + # Bad AUTH: server should close immediately. + reader, writer = await asyncio.open_unix_connection(sock_path) + writer.write(b"AUTH not-the-real-token\n") + await writer.drain() + try: + await asyncio.wait_for(writer.wait_closed(), timeout=1.0) + except Exception: + pass + assert slave.connected_writers == [] + + # Now a legitimate attach should still succeed — the bad attempt + # never consumed the one allowed slot. + _, w2 = await _attach(sock_path, slave.token) + for _ in range(10): + await asyncio.sleep(0.02) + if len(slave.connected_writers) >= 1: + break + assert len(slave.connected_writers) == 1 + w2.close() + try: + await w2.wait_closed() + except Exception: + pass + + try: + asyncio.run(go()) + finally: + os.close(pty_slave) + shutdown_slave(slave) diff --git a/csshx-latest/tests/test_status_footer.py b/csshx-latest/tests/test_status_footer.py new file mode 100644 index 0000000..4a48e62 --- /dev/null +++ b/csshx-latest/tests/test_status_footer.py @@ -0,0 +1,76 @@ +"""Tests for ``render_status`` — the one-line stderr footer. + +pytest's ``capsys`` captures the real stderr write, so we lean on that +for assertions. To flip the "is stderr a tty?" branch in +``render_status`` we monkeypatch the function the TUI actually calls +(the bound ``sys.stderr.isatty`` method on the live stderr object). +""" +from __future__ import annotations + +import sys + +import pytest + +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.slave import Slave +from csshx_latest.tui import render_status + + +def _bcast() -> Broadcaster: + b = Broadcaster() + b.add(Slave(index=1, host="h1", sock_path="/tmp/s1", token="t", pty_master=-1, pid=0, enabled=True)) + b.add(Slave(index=2, host="h2", sock_path="/tmp/s2", token="t", pty_master=-1, pid=0, enabled=False)) + b.add(Slave(index=3, host="h3", sock_path="/tmp/s3", token="t", pty_master=-1, pid=0, enabled=False, dead=True)) + return b + + +def _patch_isatty(monkeypatch, value: bool) -> None: + """Force ``sys.stderr.isatty()`` to return ``value`` for the duration.""" + monkeypatch.setattr(sys.stderr, "isatty", lambda: value, raising=False) + + +def test_status_footer_includes_counts(capsys, monkeypatch): + """Total / enabled / dead counts must all appear in the footer.""" + _patch_isatty(monkeypatch, False) + render_status(_bcast()) + err = capsys.readouterr().err + assert "hosts: 3" in err + assert "enabled: 1" in err + assert "dead: 1" in err + + +def test_status_footer_uses_ansi_when_stderr_is_tty(capsys, monkeypatch): + """A tty stderr gets ANSI colors on the enabled / dead counters.""" + _patch_isatty(monkeypatch, True) + render_status(_bcast()) + err = capsys.readouterr().err + # Green ENABLED + red DEAD when both are non-zero. + assert "\x1b[32m" in err # green + assert "\x1b[31m" in err # red + assert "\x1b[0m" in err # reset + + +def test_status_footer_skips_ansi_when_not_a_tty(capsys, monkeypatch): + """Plain pipe / log capture must never see ANSI escape codes.""" + _patch_isatty(monkeypatch, False) + render_status(_bcast()) + err = capsys.readouterr().err + assert "\x1b[" not in err + + +def test_status_footer_dims_zero_counters(capsys, monkeypatch): + """Zero values render dim on a tty so the eye lands on non-zero state.""" + b = Broadcaster() + b.add(Slave(index=1, host="h1", sock_path="/tmp/s1", token="t", pty_master=-1, pid=0, enabled=False)) + _patch_isatty(monkeypatch, True) + render_status(b) + err = capsys.readouterr().err + assert "\x1b[2m" in err # dim escape + + +def test_status_footer_renders_command_key_label(capsys, monkeypatch): + """Non-default ``command_key`` should appear in the footer's menu hint.""" + _patch_isatty(monkeypatch, False) + render_status(_bcast(), command_key=b"\x01") # Ctrl-A + err = capsys.readouterr().err + assert "Ctrl-A" in err diff --git a/csshx-latest/tests/test_tui_command_mode.py b/csshx-latest/tests/test_tui_command_mode.py new file mode 100644 index 0000000..9877da8 --- /dev/null +++ b/csshx-latest/tests/test_tui_command_mode.py @@ -0,0 +1,144 @@ +"""Tests for the Ctrl-T command-mode dispatch in :mod:`csshx_latest.tui`. + +We poke ``_handle_command_byte`` directly rather than wiring up a full +``tui_loop`` because the loop needs a real tty for raw mode. The +dispatch function is the interesting piece — the byte → effect mapping +is the contract we want to lock down. +""" +from __future__ import annotations + +import asyncio + +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.slave import Slave +from csshx_latest.tui import KEY_COMMAND_PREFIX, _handle_command_byte + + +def _make_slave(index: int, *, enabled: bool = True, dead: bool = False) -> Slave: + return Slave( + index=index, + host=f"h{index}", + sock_path=f"/tmp/s{index}", + token="t", + pty_master=-1, + pid=0, + enabled=enabled, + dead=dead, + ) + + +def _bcast_with(*slaves: Slave) -> Broadcaster: + b = Broadcaster() + for s in slaves: + b.add(s) + return b + + +def test_b_toggles_all_alive_slaves_off_when_any_enabled(): + """``b`` with mixed state turns every alive slave OFF.""" + s1 = _make_slave(1, enabled=True) + s2 = _make_slave(2, enabled=False) + s3 = _make_slave(3, enabled=True, dead=True) + b = _bcast_with(s1, s2, s3) + quit_ev = asyncio.Event() + + extra = asyncio.run(_handle_command_byte(b, ord("b"), quit_ev)) + + assert extra == b"" + assert s1.enabled is False + assert s2.enabled is False + # Dead slaves are excluded from set_all_enabled — their flag is + # meaningless and changing it would mask the dead-count UI. + assert s3.enabled is True + + +def test_b_toggles_all_on_when_none_enabled(): + """``b`` from all-off → every alive slave ends ON.""" + s1 = _make_slave(1, enabled=False) + s2 = _make_slave(2, enabled=False) + b = _bcast_with(s1, s2) + + asyncio.run(_handle_command_byte(b, ord("b"), asyncio.Event())) + + assert s1.enabled is True + assert s2.enabled is True + + +def test_q_sets_quit_event(): + b = _bcast_with(_make_slave(1)) + quit_ev = asyncio.Event() + + asyncio.run(_handle_command_byte(b, ord("q"), quit_ev)) + + assert quit_ev.is_set() + + +def test_doubled_prefix_returns_literal_prefix_byte(): + """Ctrl-T then Ctrl-T → broadcast a single literal Ctrl-T.""" + b = _bcast_with(_make_slave(1)) + quit_ev = asyncio.Event() + + extra = asyncio.run( + _handle_command_byte(b, KEY_COMMAND_PREFIX[0], quit_ev) + ) + + assert extra == KEY_COMMAND_PREFIX + assert not quit_ev.is_set() + + +def test_unknown_printable_byte_cancels_and_echoes(): + """Unmapped *printable* byte cancels command mode and echoes the byte. + + Matches the original csshX behavior: a typo'd letter after Ctrl-T + isn't silently swallowed — command mode unwinds and the letter is + broadcast to the slaves. + """ + s1 = _make_slave(1, enabled=True) + b = _bcast_with(s1) + quit_ev = asyncio.Event() + + extra = asyncio.run(_handle_command_byte(b, ord("z"), quit_ev)) + + assert extra == b"z" + assert s1.enabled is True + assert not quit_ev.is_set() + + +def test_unknown_control_byte_cancels_silently(): + """A non-printable byte (Esc, Ctrl-C) cancels with no echo.""" + s1 = _make_slave(1, enabled=True) + b = _bcast_with(s1) + quit_ev = asyncio.Event() + + extra = asyncio.run(_handle_command_byte(b, 0x1B, quit_ev)) # Esc + + assert extra == b"" + assert s1.enabled is True + assert not quit_ev.is_set() + + +def test_help_byte_does_not_modify_state(): + """``?`` should print help but leave slaves and quit_event untouched.""" + s1 = _make_slave(1, enabled=True) + b = _bcast_with(s1) + quit_ev = asyncio.Event() + + asyncio.run(_handle_command_byte(b, ord("?"), quit_ev)) + + assert s1.enabled is True + assert not quit_ev.is_set() + + +def test_l_byte_does_not_modify_state(): + """``l`` lists slaves — must not toggle enabled/dead/quit.""" + s1 = _make_slave(1, enabled=True) + s2 = _make_slave(2, enabled=False, dead=True) + b = _bcast_with(s1, s2) + quit_ev = asyncio.Event() + + asyncio.run(_handle_command_byte(b, ord("l"), quit_ev)) + + assert s1.enabled is True + assert s2.enabled is False + assert s2.dead is True + assert not quit_ev.is_set() diff --git a/csshx-latest/tests/test_tui_focus_toggle.py b/csshx-latest/tests/test_tui_focus_toggle.py new file mode 100644 index 0000000..fecc8e3 --- /dev/null +++ b/csshx-latest/tests/test_tui_focus_toggle.py @@ -0,0 +1,68 @@ +"""Tests for the per-slave focus toggle (Ctrl-T <digit>) in tui.py.""" +from __future__ import annotations + +import asyncio + +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.slave import Slave +from csshx_latest.tui import _handle_command_byte + + +def _make_slave(index: int, *, enabled: bool = True, dead: bool = False) -> Slave: + return Slave( + index=index, + host=f"h{index}", + sock_path=f"/tmp/s{index}", + token="t", + pty_master=-1, + pid=0, + enabled=enabled, + dead=dead, + ) + + +def _bcast_with(*slaves: Slave) -> Broadcaster: + b = Broadcaster() + for s in slaves: + b.add(s) + return b + + +def test_digit_toggles_only_that_slave(): + s1 = _make_slave(1, enabled=True) + s2 = _make_slave(2, enabled=True) + s3 = _make_slave(3, enabled=True) + b = _bcast_with(s1, s2, s3) + + asyncio.run(_handle_command_byte(b, ord("2"), asyncio.Event())) + + assert s1.enabled is True + assert s2.enabled is False + assert s3.enabled is True + + +def test_digit_for_missing_index_is_no_op(): + s1 = _make_slave(1, enabled=True) + b = _bcast_with(s1) + + extra = asyncio.run(_handle_command_byte(b, ord("9"), asyncio.Event())) + + assert extra == b"" + assert s1.enabled is True + + +def test_digit_toggles_back_on_second_press(): + s1 = _make_slave(1, enabled=True) + b = _bcast_with(s1) + + asyncio.run(_handle_command_byte(b, ord("1"), asyncio.Event())) + assert s1.enabled is False + asyncio.run(_handle_command_byte(b, ord("1"), asyncio.Event())) + assert s1.enabled is True + + +def test_broadcaster_toggle_returns_new_state(): + s1 = _make_slave(1, enabled=True) + b = _bcast_with(s1) + assert b.toggle(1) is False + assert b.toggle(1) is True diff --git a/csshx-latest/tests/test_waveterm_export_parser.py b/csshx-latest/tests/test_waveterm_export_parser.py new file mode 100644 index 0000000..92ee086 --- /dev/null +++ b/csshx-latest/tests/test_waveterm_export_parser.py @@ -0,0 +1,48 @@ +"""Tests for the WaveTerm launcher's robust export parser.""" +from __future__ import annotations + +from csshx_latest.launchers.waveterm import _parse_bash_exports + + +def test_unquoted_export_is_parsed(): + out = _parse_bash_exports("export WAVETERM_JWT=abc.def.ghi\n") + assert out == {"WAVETERM_JWT": "abc.def.ghi"} + + +def test_double_quoted_export_is_parsed(): + out = _parse_bash_exports('export WAVETERM_JWT="abc.def.ghi"\n') + assert out == {"WAVETERM_JWT": "abc.def.ghi"} + + +def test_single_quoted_export_is_parsed(): + out = _parse_bash_exports("export WAVETERM_JWT='abc.def.ghi'\n") + assert out == {"WAVETERM_JWT": "abc.def.ghi"} + + +def test_multiple_exports_are_collected(): + out = _parse_bash_exports( + 'export WAVETERM_JWT="abc"\n' + 'export WAVETERM_BLOCKID="b1"\n' + "non-export line\n" + "# a comment\n" + ) + assert out == {"WAVETERM_JWT": "abc", "WAVETERM_BLOCKID": "b1"} + + +def test_malformed_lines_are_skipped_not_raised(): + """An unterminated quote must not raise -- skip the line, move on.""" + out = _parse_bash_exports( + 'export BROKEN="no end quote\n' + 'export GOOD="x.y"\n' + ) + assert "GOOD" in out + assert out["GOOD"] == "x.y" + + +def test_invalid_identifier_is_rejected(): + out = _parse_bash_exports('export 1BAD="value"\n') + assert out == {} + + +def test_empty_input_returns_empty_dict(): + assert _parse_bash_exports("") == {}