From fb979cbe22c6a7bf9fa2cbf34439ffa413c08101 Mon Sep 17 00:00:00 2001 From: Virat Gohil Date: Tue, 25 Sep 2018 11:21:24 -0700 Subject: [PATCH 01/13] Fix MacOS Mojave compatibility. --- csshX | 272 +++++++--------------------------------------------------- 1 file changed, 29 insertions(+), 243 deletions(-) diff --git a/csshX b/csshX index a9d6015..bf38342 100755 --- a/csshX +++ b/csshX @@ -3,7 +3,7 @@ #==============================================================================# # csshX -- Cluster SSH tool for Mac OS X Terminal.app # #==============================================================================# -# Copyright 2011 by Gavin Brock # +# Copyright 2010 by Gavin Brock # # # # This program is free software; you may redistribute it and/or modify it # # under the same terms as Perl itself. # @@ -12,7 +12,7 @@ use strict; use warnings; -use version; (our $VERSION = '$Rev: 0.73-38-g5c0f684$') =~ s/\$Rev:\s*(.*)\$/$1/; +use version; (our $VERSION = '$Rev: 0.74$') =~ s/\$Rev:(.*)\$/$1/; my $config; # Global configuration object.. @@ -37,7 +37,7 @@ my @config_keys = qw( slave slavehost slaveid sock version osver session_max ping_test ping_timeout ssh interleave master_settings_set slave_settings_set - sorthosts clusters list_clusters bash_env + sorthosts ); foreach my $prop (@config_keys) { @@ -91,7 +91,7 @@ sub new { 'session_max=i', 'help|h', 'man|m', 'version|v', 'ssh=s', 'hosts=s@', 'remote_command=s','no_growl', 'master_settings_set|mss=s', 'slave_settings_set|sss=s', - 'interleave|i=i', 'sorthosts', 'list_clusters', 'bash_env' + 'interleave|i=i', 'sorthosts' ) || $obj->pod(-msg => "$0: bad usage\n"); # Load any extra configs specified in config file or command line @@ -437,11 +437,10 @@ use base qw(IO::Handle); # Define ScriptingBridge/AppKit objects that we will use @NSWorkspace::ISA = @SBApplication::ISA = @NSScreen::ISA = @NSColor::ISA = -@NSEvent::ISA = qw(PerlObjCBridge); -my ($terminal,$sysevents); +my $terminal; sub init { eval "use Foundation; use List::Util qw(min max) "; die $@ if $@; @@ -453,11 +452,8 @@ sub init { "com.apple.terminal" ); - $sysevents = SBApplication->applicationWithBundleIdentifier_( - "com.apple.SystemEvents" - ); - Growl->init; + } my ($cur_bounds, $max_bounds); @@ -516,35 +512,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}; } @@ -760,59 +754,6 @@ sub space { ", $obj->windowid); } - -# Cheeesy test to block until no modifier keys are pressed -sub wait_for_no_mod_keys { - 1 while NSEvent->modifierFlags != 0 -} - -sub split { - my ($obj) = @_; - $obj->winobj->setFrontmost_(1); - wait_for_no_mod_keys(); - $obj->winobj->setFrontmost_(1); - $sysevents->keystroke_using_('d', OSType('Kcmd')); - $obj->winobj->setFrontmost_(1); -} - -sub unsplit { - my ($obj) = @_; - $obj->winobj->setFrontmost_(1); - wait_for_no_mod_keys(); - $obj->winobj->setFrontmost_(1); - $sysevents->keystroke_using_('D', OSType('Kcmd')); - $obj->winobj->setFrontmost_(1); -} - -sub font_shrink { - my ($obj) = @_; - $obj->winobj->setFrontmost_(1); - wait_for_no_mod_keys(); - $obj->winobj->setFrontmost_(1); - $sysevents->keystroke_using_('-', OSType('Kcmd')); - $obj->winobj->setFrontmost_(1); -} - -# This is failing due to "shift" being pressed :-( -sub font_grow { - my ($obj) = @_; - $obj->winobj->setFrontmost_(1); - wait_for_no_mod_keys(); - $obj->winobj->setFrontmost_(1); - $sysevents->keystroke_using_('+', OSType('Kcmd')); - $obj->winobj->setFrontmost_(1); -} - -sub clear_scrollback { - my ($obj) = @_; - $obj->winobj->setFrontmost_(1); - wait_for_no_mod_keys(); - $obj->winobj->setFrontmost_(2); - $sysevents->keystroke_using_('k', OSType('Kcmd')); - $obj->winobj->setFrontmost_(1); -} - - sub terminate { my ($obj) = @_; $obj->set_windowid(undef); @@ -1526,7 +1467,7 @@ my $modes = { "[c]reate window, [r]etile, s[o]rt, [e]nable/disable input, e[n]able all, ". ( (@slaves > 1) && (@enabled == 1) ? "[Space] Enable next " : ''). "[t]oggle enabled, [m]inimise, [h]ide, [s]end text, change [b]ounds, ". - "[g/G]rid, [f/F]ont size, split [p/P]anes, clear s[k]rollback, [d]ump scrollback to file e[x]it\r\n"; + "chan[g]e [G]rid, e[x]it\r\n"; }, parse_buffer => sub { my ($obj, $buffer) = @_; @@ -1565,36 +1506,6 @@ my $modes = { $x = $slaves if $x > $slaves; $config->set('tile_x', $x); $obj->master->arrange_windows; - } elsif ($buffer =~ s/^p//) { - foreach my $window (CsshX::Master::Socket::Slave->slaves) { - $window->split; - } - $obj->master->arrange_windows; - return $obj->set_mode_and_parse('input', $buffer); - } elsif ($buffer =~ s/^P//) { - foreach my $window (CsshX::Master::Socket::Slave->slaves) { - $window->unsplit; - } - $obj->master->arrange_windows; - return $obj->set_mode_and_parse('input', $buffer); - } elsif ($buffer =~ s/^f//) { - foreach my $window (CsshX::Master::Socket::Slave->slaves) { - $window->font_shrink; - } - $obj->master->font_shrink; - } elsif ($buffer =~ s/^F//) { - foreach my $window (CsshX::Master::Socket::Slave->slaves) { - $window->font_grow; - } - $obj->master->font_grow; - } elsif ($buffer =~ s/^k//) { - foreach my $window (CsshX::Master::Socket::Slave->slaves) { - $window->clear_scrollback; - } - $obj->master->winobj->setFrontmost_(1); - return $obj->set_mode_and_parse('input', $buffer); - } elsif ($buffer =~ s/^d//) { - return $obj->set_mode_and_parse('dumpscrollback', $buffer); } elsif ($buffer =~ s/^n//) { foreach my $window (CsshX::Master::Socket::Slave->slaves) { $window->unzoom; @@ -1851,41 +1762,6 @@ my $modes = { $obj->set_read_buffer($buffer); }, }, - 'dumpscrollback' => { - prompt => sub { 'File base name (will be relative to your home folder) [Desktop/csshx_scrollback]: ' }, - onchange => sub { system '/bin/stty', 'sane' }, - parse_buffer => sub { - my ($obj, $buffer) = @_; - if ($buffer =~ s/^([^\n]*)\e//) { - return $obj->set_mode_and_parse('input', $buffer); - } elsif ($buffer =~ s/^(.*?)\r?\n//) { - my $filename = $1; - $filename = "Desktop/csshx_scrollback" unless length $filename; - - my %seen; - foreach my $window (CsshX::Master::Socket::Slave->slaves) { - # Keep only good file name chars - this is not exhaustive - (my $extension = $window->hostname) =~ s/[^-@.+()=\w]+/_/g; - - # Create a unique extension if we have many hosts with the same name - if ($seen{$extension}) { - my $n = 1; - $n++ while $seen{"$extension.$n"}; - $extension = "$extension.$n"; - } - - $seen{$extension} = 1; - print "Writing to [$filename.$extension.txt]\n" if $config->debug; - open(my $out, ">", "$filename.$extension.txt") || warn $!; - print $out $window->tabobj->history->UTF8String; - close($out); - } - - return $obj->set_mode_and_parse('input', $buffer); - } - $obj->set_read_buffer($buffer); - }, - }, }; @@ -2077,35 +1953,6 @@ sub terminate { } - -#==============================================================================# - -package CsshX::Env; - -use FindBin qw($Bin $Script);; - - -sub list_clusters { - print join(' ', keys %{$config->clusters})."\n" -} - -sub bash { - print qq{ - # USAGE - In your ~/.bash_profile add: - # eval "\$($Bin/$Script --bash_env)" - - function _complete_csshx () { - COMPREPLY=() - cur="\${COMP_WORDS[COMP_CWORD]}" - host_list=`$Bin/$Script --list_clusters` - COMPREPLY=( \$(compgen -W "\${host_list}" -- \$cur)) - return 0 - } - complete -F _complete_csshx csshX - } -} - - #==============================================================================# # Growl support - This is the distilled essence of Mac::Growl # @@ -2207,11 +2054,7 @@ package main; $config = CsshX::Config->new; -die "Sorry, need OS-X 10.5 or higher!\n" - if ($config->osver lt qv(10.5.0)); - -die "csshX must be run as the logged in user!\n" - if (-t STDOUT) && ($> != (stat POSIX::ttyname(0))[4]); +die "Sorry, need OS-X 10.5 or higher\n" if ($config->osver lt qv(10.5.0)); # Workaround for boolean ObjCBridge bug in 10.6 (fixed in 10.7) # For calls that return bools (which we don't actully use) generate @@ -2237,8 +2080,6 @@ eval 'use Carp; $SIG{ __DIE__ } = sub { Carp::confess( @_ ); sleep 10; }; $PerlO if $config->debug; # Stack trace on death if ($config->help) { $config->pod(-verbose => 1) } -elsif ($config->list_clusters){ CsshX::Env->list_clusters() } -elsif ($config->bash_env){ CsshX::Env->bash() } elsif ($config->man) { $config->pod(-verbose => 2) } elsif ($config->version) { die sprintf "csshX $VERSION\n", $VERSION } elsif ($config->master) { CsshX::Master->new() } @@ -2417,10 +2258,6 @@ as opposed to the default clusterA3 clusterB1 clusterB2 clusterB3 -=item B<--bash_env> - -Dump environment for bash completion of clusters - see L - =item B<--debug> I Sets the debug level. Number is optional and will default to 1 if omitted. @@ -2517,33 +2354,6 @@ Increase the number of grid columns used for tiling windows Decrease the number of grid columns used for tiling windows -=item B - -Split all the terminal panes - -=item B - -Close split panes - -=item B - -Decrease the font size in all windows - -=item B - -Increase the font size in all windows (note: you have to release the -shift key before this reacts) - -=item B - -Clear the scroll-back in all slave terminals (by sending Command-k to each one) - -=item B - -Dump the terminal scrollback histories to files. You will be promted for -a base filename (defaults to ~/Desktop/csshx_scrollback). A unique terminal name will -be appended to this base. - =item B Minimise all windows. (Use retile to restore) @@ -2933,27 +2743,6 @@ See --debug in L =back -=head1 SHELL COMPLETION - -Automatic shell completion of cluster names can be enabled by adding the -following line to your ~/.bash_profile, or similar: - - eval "$(csshX --bash_env)" - -This will mean that pressing B after csshX in your shell will display -a list of clusters from your configuration files. - -This uses the super secret B<--list_clusters> arguement. - -For zsh support, bash compatiblity can be used by doing: - - autoload bashcompinit - bashcompinit - eval "$(csshX --bash_env)" - -=back - - =head1 GROWL SUPPORT If Growl is installed, certain events will trigger notifications. @@ -2965,10 +2754,7 @@ For full details of Growl, visit L. =head1 BUGS -There is explicit support for bash and fish shells - most other -shells will work, but may suffer from history pollution. - -Please submit any bugs you might encounter, or feature +None known. Please submit any bugs you might encounter, or feature requests to L @@ -2993,7 +2779,7 @@ Project page L =head1 COPYRIGHT AND LICENSE -Copyright 2012 by Gavin Brock . +Copyright 2010 by Gavin Brock . This program is free software; you may redistribute it and/or modify it under the same terms as Perl itself. From 2f1394911a2fc0fa71ea6c78528ed70a21a0d144 Mon Sep 17 00:00:00 2001 From: Virat Gohil Date: Tue, 25 Sep 2018 11:24:08 -0700 Subject: [PATCH 02/13] Fix compatibility with MacOS Mojave. --- csshX | 232 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 222 insertions(+), 10 deletions(-) diff --git a/csshX b/csshX index bf38342..589708f 100755 --- a/csshX +++ b/csshX @@ -3,7 +3,7 @@ #==============================================================================# # csshX -- Cluster SSH tool for Mac OS X Terminal.app # #==============================================================================# -# Copyright 2010 by Gavin Brock # +# Copyright 2011 by Gavin Brock # # # # This program is free software; you may redistribute it and/or modify it # # under the same terms as Perl itself. # @@ -12,7 +12,7 @@ use strict; use warnings; -use version; (our $VERSION = '$Rev: 0.74$') =~ s/\$Rev:(.*)\$/$1/; +use version; (our $VERSION = '$Rev: 0.73-38-g5c0f684$') =~ s/\$Rev:\s*(.*)\$/$1/; my $config; # Global configuration object.. @@ -37,7 +37,7 @@ my @config_keys = qw( slave slavehost slaveid sock version osver session_max ping_test ping_timeout ssh interleave master_settings_set slave_settings_set - sorthosts + sorthosts clusters list_clusters bash_env ); foreach my $prop (@config_keys) { @@ -91,7 +91,7 @@ sub new { 'session_max=i', 'help|h', 'man|m', 'version|v', 'ssh=s', 'hosts=s@', 'remote_command=s','no_growl', 'master_settings_set|mss=s', 'slave_settings_set|sss=s', - 'interleave|i=i', 'sorthosts' + 'interleave|i=i', 'sorthosts', 'list_clusters', 'bash_env' ) || $obj->pod(-msg => "$0: bad usage\n"); # Load any extra configs specified in config file or command line @@ -437,10 +437,11 @@ use base qw(IO::Handle); # Define ScriptingBridge/AppKit objects that we will use @NSWorkspace::ISA = @SBApplication::ISA = @NSScreen::ISA = @NSColor::ISA = +@NSEvent::ISA = qw(PerlObjCBridge); -my $terminal; +my ($terminal,$sysevents); sub init { eval "use Foundation; use List::Util qw(min max) "; die $@ if $@; @@ -452,8 +453,11 @@ sub init { "com.apple.terminal" ); - Growl->init; + $sysevents = SBApplication->applicationWithBundleIdentifier_( + "com.apple.SystemEvents" + ); + Growl->init; } my ($cur_bounds, $max_bounds); @@ -754,6 +758,59 @@ sub space { ", $obj->windowid); } + +# Cheeesy test to block until no modifier keys are pressed +sub wait_for_no_mod_keys { + 1 while NSEvent->modifierFlags != 0 +} + +sub split { + my ($obj) = @_; + $obj->winobj->setFrontmost_(1); + wait_for_no_mod_keys(); + $obj->winobj->setFrontmost_(1); + $sysevents->keystroke_using_('d', OSType('Kcmd')); + $obj->winobj->setFrontmost_(1); +} + +sub unsplit { + my ($obj) = @_; + $obj->winobj->setFrontmost_(1); + wait_for_no_mod_keys(); + $obj->winobj->setFrontmost_(1); + $sysevents->keystroke_using_('D', OSType('Kcmd')); + $obj->winobj->setFrontmost_(1); +} + +sub font_shrink { + my ($obj) = @_; + $obj->winobj->setFrontmost_(1); + wait_for_no_mod_keys(); + $obj->winobj->setFrontmost_(1); + $sysevents->keystroke_using_('-', OSType('Kcmd')); + $obj->winobj->setFrontmost_(1); +} + +# This is failing due to "shift" being pressed :-( +sub font_grow { + my ($obj) = @_; + $obj->winobj->setFrontmost_(1); + wait_for_no_mod_keys(); + $obj->winobj->setFrontmost_(1); + $sysevents->keystroke_using_('+', OSType('Kcmd')); + $obj->winobj->setFrontmost_(1); +} + +sub clear_scrollback { + my ($obj) = @_; + $obj->winobj->setFrontmost_(1); + wait_for_no_mod_keys(); + $obj->winobj->setFrontmost_(2); + $sysevents->keystroke_using_('k', OSType('Kcmd')); + $obj->winobj->setFrontmost_(1); +} + + sub terminate { my ($obj) = @_; $obj->set_windowid(undef); @@ -1467,7 +1524,7 @@ my $modes = { "[c]reate window, [r]etile, s[o]rt, [e]nable/disable input, e[n]able all, ". ( (@slaves > 1) && (@enabled == 1) ? "[Space] Enable next " : ''). "[t]oggle enabled, [m]inimise, [h]ide, [s]end text, change [b]ounds, ". - "chan[g]e [G]rid, e[x]it\r\n"; + "[g/G]rid, [f/F]ont size, split [p/P]anes, clear s[k]rollback, [d]ump scrollback to file e[x]it\r\n"; }, parse_buffer => sub { my ($obj, $buffer) = @_; @@ -1506,6 +1563,36 @@ my $modes = { $x = $slaves if $x > $slaves; $config->set('tile_x', $x); $obj->master->arrange_windows; + } elsif ($buffer =~ s/^p//) { + foreach my $window (CsshX::Master::Socket::Slave->slaves) { + $window->split; + } + $obj->master->arrange_windows; + return $obj->set_mode_and_parse('input', $buffer); + } elsif ($buffer =~ s/^P//) { + foreach my $window (CsshX::Master::Socket::Slave->slaves) { + $window->unsplit; + } + $obj->master->arrange_windows; + return $obj->set_mode_and_parse('input', $buffer); + } elsif ($buffer =~ s/^f//) { + foreach my $window (CsshX::Master::Socket::Slave->slaves) { + $window->font_shrink; + } + $obj->master->font_shrink; + } elsif ($buffer =~ s/^F//) { + foreach my $window (CsshX::Master::Socket::Slave->slaves) { + $window->font_grow; + } + $obj->master->font_grow; + } elsif ($buffer =~ s/^k//) { + foreach my $window (CsshX::Master::Socket::Slave->slaves) { + $window->clear_scrollback; + } + $obj->master->winobj->setFrontmost_(1); + return $obj->set_mode_and_parse('input', $buffer); + } elsif ($buffer =~ s/^d//) { + return $obj->set_mode_and_parse('dumpscrollback', $buffer); } elsif ($buffer =~ s/^n//) { foreach my $window (CsshX::Master::Socket::Slave->slaves) { $window->unzoom; @@ -1762,6 +1849,41 @@ my $modes = { $obj->set_read_buffer($buffer); }, }, + 'dumpscrollback' => { + prompt => sub { 'File base name (will be relative to your home folder) [Desktop/csshx_scrollback]: ' }, + onchange => sub { system '/bin/stty', 'sane' }, + parse_buffer => sub { + my ($obj, $buffer) = @_; + if ($buffer =~ s/^([^\n]*)\e//) { + return $obj->set_mode_and_parse('input', $buffer); + } elsif ($buffer =~ s/^(.*?)\r?\n//) { + my $filename = $1; + $filename = "Desktop/csshx_scrollback" unless length $filename; + + my %seen; + foreach my $window (CsshX::Master::Socket::Slave->slaves) { + # Keep only good file name chars - this is not exhaustive + (my $extension = $window->hostname) =~ s/[^-@.+()=\w]+/_/g; + + # Create a unique extension if we have many hosts with the same name + if ($seen{$extension}) { + my $n = 1; + $n++ while $seen{"$extension.$n"}; + $extension = "$extension.$n"; + } + + $seen{$extension} = 1; + print "Writing to [$filename.$extension.txt]\n" if $config->debug; + open(my $out, ">", "$filename.$extension.txt") || warn $!; + print $out $window->tabobj->history->UTF8String; + close($out); + } + + return $obj->set_mode_and_parse('input', $buffer); + } + $obj->set_read_buffer($buffer); + }, + }, }; @@ -1953,6 +2075,35 @@ sub terminate { } + +#==============================================================================# + +package CsshX::Env; + +use FindBin qw($Bin $Script);; + + +sub list_clusters { + print join(' ', keys %{$config->clusters})."\n" +} + +sub bash { + print qq{ + # USAGE - In your ~/.bash_profile add: + # eval "\$($Bin/$Script --bash_env)" + + function _complete_csshx () { + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + host_list=`$Bin/$Script --list_clusters` + COMPREPLY=( \$(compgen -W "\${host_list}" -- \$cur)) + return 0 + } + complete -F _complete_csshx csshX + } +} + + #==============================================================================# # Growl support - This is the distilled essence of Mac::Growl # @@ -2054,7 +2205,11 @@ package main; $config = CsshX::Config->new; -die "Sorry, need OS-X 10.5 or higher\n" if ($config->osver lt qv(10.5.0)); +die "Sorry, need OS-X 10.5 or higher!\n" + if ($config->osver lt qv(10.5.0)); + +die "csshX must be run as the logged in user!\n" + if (-t STDOUT) && ($> != (stat POSIX::ttyname(0))[4]); # Workaround for boolean ObjCBridge bug in 10.6 (fixed in 10.7) # For calls that return bools (which we don't actully use) generate @@ -2080,6 +2235,8 @@ eval 'use Carp; $SIG{ __DIE__ } = sub { Carp::confess( @_ ); sleep 10; }; $PerlO if $config->debug; # Stack trace on death if ($config->help) { $config->pod(-verbose => 1) } +elsif ($config->list_clusters){ CsshX::Env->list_clusters() } +elsif ($config->bash_env){ CsshX::Env->bash() } elsif ($config->man) { $config->pod(-verbose => 2) } elsif ($config->version) { die sprintf "csshX $VERSION\n", $VERSION } elsif ($config->master) { CsshX::Master->new() } @@ -2258,6 +2415,10 @@ as opposed to the default clusterA3 clusterB1 clusterB2 clusterB3 +=item B<--bash_env> + +Dump environment for bash completion of clusters - see L + =item B<--debug> I Sets the debug level. Number is optional and will default to 1 if omitted. @@ -2354,6 +2515,33 @@ Increase the number of grid columns used for tiling windows Decrease the number of grid columns used for tiling windows +=item B + +Split all the terminal panes + +=item B + +Close split panes + +=item B + +Decrease the font size in all windows + +=item B + +Increase the font size in all windows (note: you have to release the +shift key before this reacts) + +=item B + +Clear the scroll-back in all slave terminals (by sending Command-k to each one) + +=item B + +Dump the terminal scrollback histories to files. You will be promted for +a base filename (defaults to ~/Desktop/csshx_scrollback). A unique terminal name will +be appended to this base. + =item B Minimise all windows. (Use retile to restore) @@ -2743,6 +2931,27 @@ See --debug in L =back +=head1 SHELL COMPLETION + +Automatic shell completion of cluster names can be enabled by adding the +following line to your ~/.bash_profile, or similar: + + eval "$(csshX --bash_env)" + +This will mean that pressing B after csshX in your shell will display +a list of clusters from your configuration files. + +This uses the super secret B<--list_clusters> arguement. + +For zsh support, bash compatiblity can be used by doing: + + autoload bashcompinit + bashcompinit + eval "$(csshX --bash_env)" + +=back + + =head1 GROWL SUPPORT If Growl is installed, certain events will trigger notifications. @@ -2754,7 +2963,10 @@ For full details of Growl, visit L. =head1 BUGS -None known. Please submit any bugs you might encounter, or feature +There is explicit support for bash and fish shells - most other +shells will work, but may suffer from history pollution. + +Please submit any bugs you might encounter, or feature requests to L @@ -2779,7 +2991,7 @@ Project page L =head1 COPYRIGHT AND LICENSE -Copyright 2010 by Gavin Brock . +Copyright 2012 by Gavin Brock . This program is free software; you may redistribute it and/or modify it under the same terms as Perl itself. From ff333a48439a0a007f8cf6abdf7956543bad6d9d Mon Sep 17 00:00:00 2001 From: Guillem Parera Date: Fri, 28 Sep 2018 12:25:50 +0200 Subject: [PATCH 03/13] Adding homebrew Formula A Formula/csshx.rb --- Formula/csshx.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 Formula/csshx.rb diff --git a/Formula/csshx.rb b/Formula/csshx.rb new file mode 100644 index 0000000..bcea517 --- /dev/null +++ b/Formula/csshx.rb @@ -0,0 +1,16 @@ +class Csshx < Formula + desc "Cluster ssh tool for Terminal.app" + homepage "https://github.com/parera10/homebrew-csshx" + url "https://github.com/parera10/homebrew-csshx/archive/0.73-1.tar.gz" + head "https://github.com/parera10/homebrew-csshx.git" + + bottle :unneeded + + def install + bin.install "csshX" + end + + test do + assert_match version.to_s, shell_output("#{bin}/csshX --version 2>&1", 2) + end +end From 8da9f4357c834d4cf2b6bcff50734996c7c3ac0b Mon Sep 17 00:00:00 2001 From: Guillem Parera Date: Wed, 3 Oct 2018 20:55:56 +0200 Subject: [PATCH 04/13] Splitting repos. D Formula/csshx.rb --- Formula/csshx.rb | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 Formula/csshx.rb diff --git a/Formula/csshx.rb b/Formula/csshx.rb deleted file mode 100644 index bcea517..0000000 --- a/Formula/csshx.rb +++ /dev/null @@ -1,16 +0,0 @@ -class Csshx < Formula - desc "Cluster ssh tool for Terminal.app" - homepage "https://github.com/parera10/homebrew-csshx" - url "https://github.com/parera10/homebrew-csshx/archive/0.73-1.tar.gz" - head "https://github.com/parera10/homebrew-csshx.git" - - bottle :unneeded - - def install - bin.install "csshX" - end - - test do - assert_match version.to_s, shell_output("#{bin}/csshX --version 2>&1", 2) - end -end From 6bde786b3117edcdcfafe1c0d20549f99bdb808c Mon Sep 17 00:00:00 2001 From: TAGAWA Takao Date: Fri, 27 Nov 2020 19:58:19 +0900 Subject: [PATCH 05/13] Fix compatibility with macOS Big Sur --- csshX | 2 +- csshX.iterm | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/csshX b/csshX index 589708f..94b9529 100755 --- a/csshX +++ b/csshX @@ -1188,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 { From 14ff7ada8eddfaa1afa5ecb8e9327ef2ff5ab2d7 Mon Sep 17 00:00:00 2001 From: Aditya Kapadia Date: Thu, 30 Apr 2026 02:09:03 -0700 Subject: [PATCH 06/13] Add csshx-latest: Python rewrite of csshX with pluggable terminal backends Stdlib-only Python 3 cluster-SSH tool under csshx-latest/. Replaces the original csshX TIOCSTI keystroke-injection hack with real PTYs and a pluggable Launcher protocol. Backends shipped: WaveTerm, tmux, iTerm2, Terminal.app, Kitty, WezTerm, plus a Manual fallback that prints attach commands for any terminal. Master process owns every PTY and bridges them through per-slave UNIX sockets gated by 32-byte AUTH tokens. Suite of 33 pytest cases. The original Perl csshX, README.txt, and surrounding files are left exactly as-is. A new top-level README.md points users at both. Co-Authored-By: Claude Opus 4.7 --- README.md | 76 +++++++ csshx-latest/.gitignore | 11 + csshx-latest/README.md | 146 +++++++++++++ csshx-latest/csshx_latest/__init__.py | 3 + csshx-latest/csshx_latest/__main__.py | 45 ++++ csshx-latest/csshx_latest/attach.py | 79 +++++++ csshx-latest/csshx_latest/auth.py | 42 ++++ csshx-latest/csshx_latest/launcher.py | 101 +++++++++ .../csshx_latest/launchers/__init__.py | 1 + .../csshx_latest/launchers/apple_terminal.py | 47 ++++ csshx-latest/csshx_latest/launchers/iterm2.py | 77 +++++++ csshx-latest/csshx_latest/launchers/kitty.py | 63 ++++++ csshx-latest/csshx_latest/launchers/manual.py | 39 ++++ csshx-latest/csshx_latest/launchers/tmux.py | 61 ++++++ .../csshx_latest/launchers/waveterm.py | 63 ++++++ .../csshx_latest/launchers/wezterm.py | 41 ++++ csshx-latest/csshx_latest/master.py | 203 ++++++++++++++++++ csshx-latest/csshx_latest/slave.py | 199 +++++++++++++++++ csshx-latest/csshx_latest/terminal.py | 70 ++++++ csshx-latest/pyproject.toml | 33 +++ csshx-latest/tests/__init__.py | 0 csshx-latest/tests/test_auth.py | 63 ++++++ csshx-latest/tests/test_broadcaster.py | 87 ++++++++ csshx-latest/tests/test_detect_launcher.py | 85 ++++++++ csshx-latest/tests/test_launcher_manual.py | 34 +++ csshx-latest/tests/test_launcher_tmux.py | 66 ++++++ csshx-latest/tests/test_launcher_waveterm.py | 68 ++++++ 27 files changed, 1803 insertions(+) create mode 100644 README.md create mode 100644 csshx-latest/.gitignore create mode 100644 csshx-latest/README.md create mode 100644 csshx-latest/csshx_latest/__init__.py create mode 100644 csshx-latest/csshx_latest/__main__.py create mode 100644 csshx-latest/csshx_latest/attach.py create mode 100644 csshx-latest/csshx_latest/auth.py create mode 100644 csshx-latest/csshx_latest/launcher.py create mode 100644 csshx-latest/csshx_latest/launchers/__init__.py create mode 100644 csshx-latest/csshx_latest/launchers/apple_terminal.py create mode 100644 csshx-latest/csshx_latest/launchers/iterm2.py create mode 100644 csshx-latest/csshx_latest/launchers/kitty.py create mode 100644 csshx-latest/csshx_latest/launchers/manual.py create mode 100644 csshx-latest/csshx_latest/launchers/tmux.py create mode 100644 csshx-latest/csshx_latest/launchers/waveterm.py create mode 100644 csshx-latest/csshx_latest/launchers/wezterm.py create mode 100644 csshx-latest/csshx_latest/master.py create mode 100644 csshx-latest/csshx_latest/slave.py create mode 100644 csshx-latest/csshx_latest/terminal.py create mode 100644 csshx-latest/pyproject.toml create mode 100644 csshx-latest/tests/__init__.py create mode 100644 csshx-latest/tests/test_auth.py create mode 100644 csshx-latest/tests/test_broadcaster.py create mode 100644 csshx-latest/tests/test_detect_launcher.py create mode 100644 csshx-latest/tests/test_launcher_manual.py create mode 100644 csshx-latest/tests/test_launcher_tmux.py create mode 100644 csshx-latest/tests/test_launcher_waveterm.py 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-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/README.md b/csshx-latest/README.md new file mode 100644 index 0000000..c77f55e --- /dev/null +++ b/csshx-latest/README.md @@ -0,0 +1,146 @@ +# 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. + +## 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. +- **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 — no surprise tmux sessions. +- **Stdlib-only Python 3.10+** — zero hard runtime dependencies. + +## Install + +From a checkout of this repo: + +```bash +cd csshx-latest/ + +# with uv +uv venv && uv pip install -e '.[test]' + +# or plain pip +python3 -m venv .venv && source .venv/bin/activate +pip install -e '.[test]' +``` + +## 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 +``` + +`--launcher` choices: `auto` (default), `waveterm`, `tmux`, `iterm2`, +`terminal`, `kitty`, `wezterm`, `manual`. + +Press **Ctrl-Q** in the master TUI to exit. SIGINT / SIGTERM / SIGHUP +also shut down cleanly. SIGWINCH on the master propagates the new +window size to every slave PTY via TIOCSWINSZ. + +## Architecture + +``` + ┌────────────────────────── master process ──────────────────────────┐ + │ │ + │ raw stdin ──► Broadcaster ─┬─► Slave[1] PTY ─► ssh host1 │ + │ (your tty) ├─► Slave[2] PTY ─► ssh host2 │ + │ └─► Slave[N] PTY ─► ssh hostN │ + │ │ + │ per slave: │ + │ PTY master fd ──► UNIX socket (0600 + AUTH token) │ + │ ▲ │ + │ │ bidirectional, per-fd write lock │ + │ ▼ │ + │ Launcher.open_block(attach_cmd, host) │ + │ │ │ + │ ┌────────────────┴─────────────────┐ │ + │ │ whichever backend you have: │ │ + │ │ waveterm / tmux / iterm2 / ... │ │ + │ │ (or `manual`: print and paste) │ │ + │ └──────────────────────────────────┘ │ + └────────────────────────────────────────────────────────────────────┘ +``` + +Output flows one way (PTY → socket → terminal block). Input arrives +from two writers: the master broadcaster *and* whichever terminal block +is focused. A per-slave `asyncio.Lock` serializes PTY writes so an +escape sequence can never get torn between them. + +Each slave 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. + +## Backend support matrix + +| Backend | Open block | Tile | Set title | Set color | Platform | +| ------------- | ---------- | ---------------- | --------- | --------- | ------------------ | +| WaveTerm | yes | yes (best-effort)| yes | n/a (v2) | mac / linux / win | +| tmux | yes | yes | yes | partial | anywhere with tmux | +| iTerm2 | yes | auto-balanced | yes | n/a (v2) | macOS | +| Terminal.app | yes | manual | yes | n/a (v2) | macOS | +| Kitty | yes | yes (`grid`) | yes | n/a (v2) | mac / linux | +| WezTerm | yes | auto-balanced | yes | n/a (v2) | mac / linux / win | +| Manual | print only | n/a | n/a | n/a | anywhere | + +Notes: + +- **Kitty** requires `allow_remote_control yes` in `kitty.conf`. The + launcher surfaces a clear error if it's not enabled. +- **WaveTerm** tiling tries `wsh setlayout tiled`, then `wsh layout + tiled`, then `wsh tile` — it degrades quietly if the wsh CLI grammar + drifts between releases. +- **Set color** is a v2 hook reserved on `BlockHandle.data`; none of + the v1 launchers wire it up. + +## 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 — anyone local could sniff a slave | 32-byte token per socket | +| Per-slave typing | Hidden window per slave | Authenticated, bidirectional socket | +| Globals | ~6 file-level `my` vars | Zero | +| Lazy module loading | `eval "use $mod"` | `importlib.import_module` | +| Key handler | Giant if/elsif chain | Future v2 dispatch dict | + +## Run the tests + +```bash +uv run pytest -q +# or +pytest -q +``` + +The test suite exercises the broadcaster routing logic (with real +pipes), the AUTH handshake (with a `StreamReader`), the +launcher-detection environment matrix, and the Manual / Tmux / +WaveTerm launchers (with `subprocess.run` mocked). + +The package itself can't run on Windows — `pty`, `termios`, `tty`, and +`fcntl` are Unix-only. The tests assume a Unix-like host. + +## v1 scope + +In: spawn N ssh PTYs, broadcast keystrokes, all six concrete launchers +plus the manual fallback, clean shutdown, SIGWINCH propagation, socket +auth. + +Out (designed to slot in later): action mode, `.csshrc` parsing, +per-slave focus toggling commands from the master TUI, ping pre-test, +color themes per slave. diff --git a/csshx-latest/csshx_latest/__init__.py b/csshx-latest/csshx_latest/__init__.py new file mode 100644 index 0000000..f115c12 --- /dev/null +++ b/csshx-latest/csshx_latest/__init__.py @@ -0,0 +1,3 @@ +"""csshx-latest: modern, terminal-agnostic cluster-SSH.""" + +__version__ = "0.1.0" diff --git a/csshx-latest/csshx_latest/__main__.py b/csshx-latest/csshx_latest/__main__.py new file mode 100644 index 0000000..8275820 --- /dev/null +++ b/csshx-latest/csshx_latest/__main__.py @@ -0,0 +1,45 @@ +"""Command-line entry point for ``csshx-latest``.""" +from __future__ import annotations + +import argparse +import asyncio +import shlex +import sys +from typing import Optional + +from csshx_latest.launcher import detect_launcher +from csshx_latest.master import run_master + + +def main(argv: Optional[list[str]] = None) -> int: + """Parse args and run the master event loop. Returns the exit code.""" + parser = argparse.ArgumentParser( + prog="csshx-latest", + description="Modern, terminal-agnostic cluster-SSH (csshX rewrite).", + ) + parser.add_argument("hosts", nargs="+", help="Hosts to ssh to.") + 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=["auto", "waveterm", "tmux", "iterm2", "terminal", "kitty", "wezterm", "manual"], + help="Terminal backend (default: auto-detect).", + ) + args = parser.parse_args(argv) + + launcher = detect_launcher(args.launcher) + ssh_extra = shlex.split(args.ssh_args) if args.ssh_args else [] + + try: + return asyncio.run(run_master(args.hosts, ssh_extra, args.login, launcher)) + except KeyboardInterrupt: + return 130 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/csshx-latest/csshx_latest/attach.py b/csshx-latest/csshx_latest/attach.py new file mode 100644 index 0000000..42460ef --- /dev/null +++ b/csshx-latest/csshx_latest/attach.py @@ -0,0 +1,79 @@ +"""Tiny stdlib-only attach client used when ``socat`` isn't installed. + +Connects to a slave's UNIX socket, performs the AUTH handshake, then +shuttles bytes between stdin/stdout and the socket. Run as a module so +spawned terminal blocks can launch it without any extra dependency:: + + python3 -m csshx_latest.attach +""" +from __future__ import annotations + +import os +import select +import socket +import sys + +BUFSIZE = 4096 + + +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 = argv[1], argv[2] + + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(path) + except OSError as exc: + sys.stderr.write(f"connect {path}: {exc}\n") + return 1 + sock.sendall(f"AUTH {token}\n".encode("ascii")) + + in_fd = sys.stdin.fileno() + out_fd = sys.stdout.fileno() + + saved = None + if os.isatty(in_fd): + import termios + import tty + saved = termios.tcgetattr(in_fd) + tty.setraw(in_fd) + + watch_in = True + try: + while True: + watches = [sock] + if watch_in: + watches.append(in_fd) + r, _, _ = select.select(watches, [], []) + if sock in r: + data = sock.recv(BUFSIZE) + if not data: + return 0 + 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: + watch_in = False + try: + sock.shutdown(socket.SHUT_WR) + except OSError: + pass + else: + sock.sendall(data) + except KeyboardInterrupt: + return 130 + finally: + if saved is not None: + import termios + termios.tcsetattr(in_fd, termios.TCSADRAIN, saved) + sock.close() + + +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..0d8b99c --- /dev/null +++ b/csshx-latest/csshx_latest/auth.py @@ -0,0 +1,42 @@ +"""Token generation and authentication handshake for slave sockets. + +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 HANDSHAKE_TIMEOUT seconds; otherwise their connection is +dropped. This prevents other local users from injecting keystrokes +into your SSH sessions. +""" +from __future__ import annotations + +import asyncio +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) + + +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 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/launcher.py b/csshx-latest/csshx_latest/launcher.py new file mode 100644 index 0000000..797e3d7 --- /dev/null +++ b/csshx-latest/csshx_latest/launcher.py @@ -0,0 +1,101 @@ +"""Launcher Protocol and environment-based auto-detection. + +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:`detect_launcher` so that selecting one +backend doesn't pay the import cost of the others. +""" +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass, field +from typing import Any, Optional, Protocol, runtime_checkable + + +@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 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.""" + ... + + +_LAUNCHERS = { + "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 _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 the priority + order documented in the project README. Falls back to the Manual + launcher if nothing is recognized — never silently picks tmux. + """ + if name and name != "auto": + return _by_name(name) + + 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") + if os.environ.get("TMUX") and shutil.which("tmux"): + return _by_name("tmux") + 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..002c887 --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/apple_terminal.py @@ -0,0 +1,47 @@ +"""Apple Terminal.app launcher via ``osascript``. + +Terminal.app has no built-in tiling and AppleScript can't reliably +position windows from outside, so :meth:`tile` is a no-op — the user +arranges the windows themselves. +""" +from __future__ import annotations + +import shlex +import subprocess + +from csshx_latest.launcher import BlockHandle + + +def _escape(s: str) -> str: + return s.replace("\\", "\\\\").replace('"', '\\"') + + +class AppleTerminalLauncher: + """Open each block as a new Terminal.app window.""" + + name = "terminal" + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Tell Terminal.app to ``do script`` with the attach command.""" + cmd_esc = _escape(" ".join(shlex.quote(a) for a in attach_cmd)) + title_esc = _escape(title) + script = ( + 'tell application "Terminal"\n' + ' activate\n' + f' set newTab to do script "{cmd_esc}"\n' + f' set custom title of newTab to "{title_esc}"\n' + 'end tell\n' + ) + subprocess.run( + ["osascript", "-e", script], check=False, capture_output=True, text=True + ) + return BlockHandle(backend=self.name, data={"title": title}) + + def close_block(self, handle: BlockHandle) -> None: + """No-op: tabs close when the user closes them or ssh exits.""" + + def tile(self, handles: list[BlockHandle]) -> None: + """No-op: Terminal.app has no programmatic tiling.""" + + def set_title(self, handle: BlockHandle, title: str) -> None: + """No-op: we don't track tab references after creation.""" diff --git a/csshx-latest/csshx_latest/launchers/iterm2.py b/csshx-latest/csshx_latest/launchers/iterm2.py new file mode 100644 index 0000000..fe8e3bd --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/iterm2.py @@ -0,0 +1,77 @@ +"""iTerm2 launcher via ``osascript`` and iTerm's AppleScript dictionary. + +The first block creates a new window with the default profile; each +subsequent block splits the current session vertically. iTerm2 auto- +balances split panes, so :meth:`tile` is a no-op. +""" +from __future__ import annotations + +import shlex +import subprocess + +from csshx_latest.launcher import BlockHandle + + +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 __init__(self) -> None: + self._first = True + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Create or split-then-write — running ``attach_cmd`` in the new session.""" + cmd_str = " ".join(shlex.quote(a) for a in attach_cmd) + cmd_esc = _escape(cmd_str) + title_esc = _escape(title) + + if self._first: + script = ( + 'tell application "iTerm"\n' + ' activate\n' + ' set newWindow to (create window with default profile)\n' + ' tell current session of newWindow\n' + f' write text "{cmd_esc}"\n' + f' set name to "{title_esc}"\n' + ' end tell\n' + 'end tell\n' + ) + self._first = False + else: + script = ( + 'tell application "iTerm"\n' + ' tell current session of current window\n' + ' set newSession to (split vertically with default profile)\n' + ' end tell\n' + ' tell newSession\n' + f' write text "{cmd_esc}"\n' + f' set name to "{title_esc}"\n' + ' end tell\n' + 'end tell\n' + ) + _osascript(script) + return BlockHandle(backend=self.name, data={"title": title}) + + def close_block(self, handle: BlockHandle) -> None: + """No-op: iTerm2 sessions die when the user closes them or ssh exits.""" + + def tile(self, handles: list[BlockHandle]) -> None: + """No-op: iTerm2 evenly balances split panes automatically.""" + + def set_title(self, handle: BlockHandle, title: str) -> None: + """Best-effort rename of the current session.""" + title_esc = _escape(title) + _osascript( + 'tell application "iTerm" to tell current session of current window ' + f'to set name to "{title_esc}"' + ) diff --git a/csshx-latest/csshx_latest/launchers/kitty.py b/csshx-latest/csshx_latest/launchers/kitty.py new file mode 100644 index 0000000..79fad09 --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/kitty.py @@ -0,0 +1,63 @@ +"""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. +""" +from __future__ import annotations + +import shutil +import subprocess + +from csshx_latest.launcher import BlockHandle + + +class KittyLauncher: + """Open each block as a new kitty window. 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 open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Spawn a new kitty window via ``kitty @ launch --type=window``.""" + out = self._run( + ["kitty", "@", "launch", "--type=window", "--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()}" + ) + 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]) diff --git a/csshx-latest/csshx_latest/launchers/manual.py b/csshx-latest/csshx_latest/launchers/manual.py new file mode 100644 index 0000000..bd7eb13 --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/manual.py @@ -0,0 +1,39 @@ +"""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 + + +class ManualLauncher: + """Print attach commands; tile/title/close are no-ops.""" + + name = "manual" + + def __init__(self) -> None: + self._counter = 0 + + 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.""" diff --git a/csshx-latest/csshx_latest/launchers/tmux.py b/csshx-latest/csshx_latest/launchers/tmux.py new file mode 100644 index 0000000..eea9ce9 --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/tmux.py @@ -0,0 +1,61 @@ +"""Tmux launcher — spawn each block as a pane in the active session. + +Detects the ambient ``$TMUX`` session via ``detect_launcher``; the +launcher itself just shells out to ``tmux``. Operates on whatever +window the spawned panes ended up in. +""" +from __future__ import annotations + +import shlex +import subprocess +from typing import Optional + +from csshx_latest.launcher import BlockHandle + + +class TmuxLauncher: + """Open each block as a tmux pane in the user's current session.""" + + name = "tmux" + + def __init__(self, target: Optional[str] = None) -> None: + self._target = target + + @staticmethod + def _run(args: list[str], capture: bool = False) -> subprocess.CompletedProcess: + return subprocess.run(args, check=False, capture_output=capture, text=True) + + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: + """Run ``tmux split-window`` to open a new pane running ``attach_cmd``.""" + cmd = ["tmux", "split-window", "-P", "-F", "#{pane_id}"] + if self._target: + cmd += ["-t", self._target] + cmd.append(" ".join(shlex.quote(a) for a in attach_cmd)) + out = self._run(cmd, capture=True) + pane_id = (out.stdout or "").strip() + if title and pane_id: + self._run(["tmux", "select-pane", "-t", pane_id, "-T", title]) + 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]) diff --git a/csshx-latest/csshx_latest/launchers/waveterm.py b/csshx-latest/csshx_latest/launchers/waveterm.py new file mode 100644 index 0000000..6953d7b --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/waveterm.py @@ -0,0 +1,63 @@ +"""WaveTerm launcher — opens and tiles blocks via the ``wsh`` CLI. + +The wsh subcommand grammar has changed across WaveTerm versions, so +:meth:`tile` tries a few likely incantations in order and stops at the +first one that exits 0. +""" +from __future__ import annotations + +import subprocess + +from csshx_latest.launcher import BlockHandle + + +class WaveTermLauncher: + """Open each block via ``wsh run`` and tile via the closest available subcommand.""" + + name = "waveterm" + + def __init__(self) -> None: + self._counter = 0 + + @staticmethod + def _run(args: list[str], capture: bool = False) -> subprocess.CompletedProcess: + 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``.""" + self._counter += 1 + out = self._run(["wsh", "run", "--", *attach_cmd], capture=True) + 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(["wsh", "deleteblock", "-b", block_id]) + + def tile(self, handles: list[BlockHandle]) -> None: + """Try several ``wsh`` layout subcommands; keep the first that succeeds.""" + for attempt in ( + ["wsh", "setlayout", "tiled"], + ["wsh", "layout", "tiled"], + ["wsh", "tile"], + ): + r = self._run(attempt) + if r.returncode == 0: + return + + 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(["wsh", "settitle", "-b", block_id, title]) diff --git a/csshx-latest/csshx_latest/launchers/wezterm.py b/csshx-latest/csshx_latest/launchers/wezterm.py new file mode 100644 index 0000000..f59330c --- /dev/null +++ b/csshx-latest/csshx_latest/launchers/wezterm.py @@ -0,0 +1,41 @@ +"""WezTerm launcher via ``wezterm cli spawn``.""" +from __future__ import annotations + +import subprocess + +from csshx_latest.launcher import BlockHandle + + +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 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]) diff --git a/csshx-latest/csshx_latest/master.py b/csshx-latest/csshx_latest/master.py new file mode 100644 index 0000000..a865502 --- /dev/null +++ b/csshx-latest/csshx_latest/master.py @@ -0,0 +1,203 @@ +"""Master: orchestrates slaves, runs the broadcast TUI, drives shutdown. + +The master process is the single source of truth: it owns every PTY, +every UNIX socket, every ssh subprocess. Terminal blocks (rendered by +whichever Launcher is active) are pure renderers — they connect to a +slave's socket, send keystrokes when focused, and display whatever the +PTY emits. +""" +from __future__ import annotations + +import asyncio +import os +import shutil +import signal +import sys +import tempfile +from dataclasses import dataclass, field +from typing import Optional + +from csshx_latest.auth import make_token +from csshx_latest.launcher import BlockHandle, Launcher +from csshx_latest.slave import ( + Slave, + run_slave_bridge, + shutdown_slave, + spawn_slave, + write_to_slave, +) +from csshx_latest.terminal import get_winsize, raw_mode, set_winsize + + +@dataclass +class Broadcaster: + """Routes bytes to enabled slaves. Pure logic — owns no fds.""" + + slaves: list[Slave] = field(default_factory=list) + + 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] + + def toggle(self, index: int) -> None: + """Flip the ``enabled`` flag of the slave with the given index.""" + for s in self.slaves: + if s.index == index: + s.enabled = not s.enabled + return + raise KeyError(index) + + async def broadcast(self, data: bytes) -> None: + """Write ``data`` to every enabled slave concurrently.""" + await asyncio.gather( + *(write_to_slave(s, data) for s in self.slaves), + return_exceptions=True, + ) + + +def make_socket_dir() -> str: + """Create a 0700 directory for slave sockets. + + Prefers ``$XDG_RUNTIME_DIR`` when present and a directory; falls + back to the system temp dir (``/tmp`` on macOS). + """ + 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: str) -> list[str]: + """Build the attach command for a terminal block. + + Prefers ``socat`` when available, wrapped in a tiny ``sh -c`` so we + can flip the local terminal into raw mode and inject the AUTH line + before forwarding keystrokes. Falls back to the bundled stdlib + attach client when ``socat`` isn't on PATH. + """ + if shutil.which("socat"): + sh_cmd = ( + 'stty raw -echo 2>/dev/null; ' + f'{{ printf \'AUTH %s\\n\' \'{token}\'; cat; }} | ' + f'socat - UNIX-CONNECT:{sock_path}; ' + 'stty sane 2>/dev/null' + ) + return ["sh", "-c", sh_cmd] + return [sys.executable, "-m", "csshx_latest.attach", sock_path, token] + + +async def run_master( + hosts: list[str], + ssh_args: list[str], + login: Optional[str], + launcher: Launcher, +) -> int: + """Top-level entry: spawn slaves, run the TUI, tear down on exit.""" + sock_dir = make_socket_dir() + bcast = Broadcaster() + handles: list[BlockHandle] = [] + + try: + for i, host in enumerate(hosts, start=1): + token = make_token() + s = await spawn_slave(i, host, sock_dir, ssh_args, login, token) + await run_slave_bridge(s) + bcast.add(s) + handle = launcher.open_block(attach_command(s.sock_path, s.token), host) + handles.append(handle) + + try: + launcher.tile(handles) + except Exception as exc: + sys.stderr.write(f"warning: tile() failed: {exc}\n") + + await tui_loop(bcast) + finally: + for h in handles: + try: + launcher.close_block(h) + except Exception: + pass + for s in bcast.slaves: + shutdown_slave(s) + try: + os.rmdir(sock_dir) + except OSError: + pass + return 0 + + +async def tui_loop(bcast: Broadcaster) -> None: + """Read stdin in raw mode and broadcast keystrokes; render a status line. + + Exits when stdin EOFs, when Ctrl-Q is pressed, or when one of + SIGINT / SIGTERM / SIGHUP is received. SIGWINCH propagates the + master terminal's winsize to every slave PTY. + """ + 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() + render_status(bcast) + + 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) + + async def reader_task() -> None: + while True: + data = await reader.read(64) + if not data: + quit_event.set() + return + if b"\x11" in data: # Ctrl-Q + quit_event.set() + return + await bcast.broadcast(data) + + task = asyncio.create_task(reader_task()) + try: + await quit_event.wait() + finally: + task.cancel() + transport.close() + + +def render_status(bcast: Broadcaster) -> None: + """Write a one-line status footer to stderr.""" + enabled = bcast.enabled_indices() + total = len(bcast.slaves) + sys.stderr.write( + f"\r[csshx-latest] hosts: {total} enabled: {len(enabled)} (Ctrl-Q to quit)\n" + ) + sys.stderr.flush() diff --git a/csshx-latest/csshx_latest/slave.py b/csshx-latest/csshx_latest/slave.py new file mode 100644 index 0000000..4a43c4d --- /dev/null +++ b/csshx-latest/csshx_latest/slave.py @@ -0,0 +1,199 @@ +"""One SSH slave: PTY + ssh subprocess + UNIX-socket bridge. + +The master forks ``ssh <host>`` attached to a fresh PTY and exposes +that PTY through a UNIX domain socket gated by an AUTH token. + +Output direction (PTY -> socket) is one-way: bytes the SSH session +emits are fanned out to every authenticated socket connection (the +visible terminal block). + +Input direction (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. +""" +from __future__ import annotations + +import asyncio +import os +import signal +import socket +from dataclasses import dataclass, field +from typing import Optional + +from csshx_latest.auth import authenticate +from csshx_latest.terminal import set_winsize + + +@dataclass +class Slave: + """State for one SSH connection. + + ``enabled`` is the broadcast filter — keystrokes from the master + TUI are only delivered to slaves with ``enabled=True``. Per-slave + typing through the socket bridge ignores this flag (you can always + type to a focused block). + """ + + index: int + host: str + sock_path: str + token: str + pty_master: int + pid: int + enabled: bool = True + write_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + 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) + + +async def spawn_slave( + index: int, + host: str, + sock_dir: str, + ssh_args: list[str], + login: Optional[str], + token: str, +) -> Slave: + """Fork ``ssh <host>`` attached to a new PTY and return its :class:`Slave`.""" + import pty # local import so the package can be imported on non-Unix + pty_master, pty_slave = pty.openpty() + set_winsize(pty_master, 24, 80) + + 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") + return Slave( + index=index, + host=host, + sock_path=sock_path, + token=token, + pty_master=pty_master, + pid=pid, + ) + + +async def run_slave_bridge(slave: Slave) -> None: + """Start the bidirectional PTY <-> socket bridge for ``slave``. + + Spawns: + * a UNIX-domain server bound at ``slave.sock_path`` (mode 0600) + that AUTH-gates every incoming connection and forwards bytes + to the PTY master fd; + * a background task that reads from the PTY master fd and fans + bytes out to all currently connected, authenticated writers. + """ + loop = asyncio.get_running_loop() + + async def handle_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 + slave.connected_writers.append(writer) + try: + while True: + data = await reader.read(4096) + if not data: + 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 + + server = await asyncio.start_unix_server(handle_client, path=slave.sock_path) + os.chmod(slave.sock_path, 0o600) + slave.server = 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 + for w in list(slave.connected_writers): + try: + w.write(data) + await w.drain() + except Exception: + try: + slave.connected_writers.remove(w) + except ValueError: + pass + finally: + transport.close() + + slave.pty_reader_task = asyncio.create_task(pty_to_sockets()) + + +async def write_to_slave(slave: Slave, data: bytes) -> None: + """Write ``data`` to ``slave``'s PTY iff the slave is ``enabled``.""" + if not slave.enabled: + return + async with slave.write_lock: + _write_all(slave.pty_master, data) + + +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 the server, kill ssh, close fds, unlink the socket.""" + if slave.server is not None: + slave.server.close() + if slave.pty_reader_task is not None and not slave.pty_reader_task.done(): + slave.pty_reader_task.cancel() + try: + os.kill(slave.pid, signal.SIGTERM) + except OSError: + pass + try: + os.close(slave.pty_master) + except OSError: + pass + try: + os.unlink(slave.sock_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..ed1f3e9 --- /dev/null +++ b/csshx-latest/csshx_latest/terminal.py @@ -0,0 +1,70 @@ +"""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 + + +@contextmanager +def raw_mode(fd: Optional[int] = None) -> Iterator[None]: + """Put ``fd`` (default stdin) into termios raw mode; restore on exit. + + No-ops on non-Unix or when the fd is not a TTY, so callers don't + need to special-case those cases themselves. + """ + if not _UNIX: + yield + return + if fd is None: + fd = sys.stdin.fileno() + if not os.isatty(fd): + yield + return + saved = termios.tcgetattr(fd) + try: + tty.setraw(fd) + yield + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) diff --git a/csshx-latest/pyproject.toml b/csshx-latest/pyproject.toml new file mode 100644 index 0000000..6e7b4f5 --- /dev/null +++ b/csshx-latest/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "csshx-latest" +version = "0.1.0" +description = "Modern, terminal-agnostic cluster-SSH (csshX rewrite)." +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [{name = "csshx-latest contributors"}] +keywords = ["ssh", "cluster", "csshx", "terminal", "broadcast"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Topic :: System :: Networking", + "Topic :: System :: Systems Administration", +] +dependencies = [] + +[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/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_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_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..65572a4 --- /dev/null +++ b/csshx-latest/tests/test_launcher_tmux.py @@ -0,0 +1,66 @@ +"""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: + 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 + + +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) diff --git a/csshx-latest/tests/test_launcher_waveterm.py b/csshx-latest/tests/test_launcher_waveterm.py new file mode 100644 index 0000000..07aa0d9 --- /dev/null +++ b/csshx-latest/tests/test_launcher_waveterm.py @@ -0,0 +1,68 @@ +"""Tests for the WaveTerm launcher (subprocess.run mocked).""" +from __future__ import annotations + +import subprocess + +import pytest + +from csshx_latest.launchers import waveterm as waveterm_mod + + +@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 From 410c472077579ec19d13d87c98c4f47255d57f29 Mon Sep 17 00:00:00 2001 From: Aditya Kapadia <aditya.kapadia5@gmail.com> Date: Thu, 30 Apr 2026 02:52:33 -0700 Subject: [PATCH 07/13] Fix lost SSH banner; silence wsh probe spam in waveterm launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slave.py: add a per-slave scrollback buffer (capped at 64 KiB) that captures every byte the PTY emits before any terminal block has connected, then replays it to each new client right after AUTH. The bridge previously dropped any output that arrived between fork-exec of ssh and the launcher spawning the visible block — which on WaveTerm is long enough to lose the banner, MOTD, and login prompt, leaving the block blank. A small state_lock makes the "extend scrollback / snapshot writers" and "replay scrollback / register writer" paths atomic so no chunk is duplicated or lost across the race. waveterm.py: flip _run's default to capture=True so the legacy wsh probes in tile() (setlayout, layout, tile) plus deleteblock and settitle no longer spit error text into the user's terminal on modern wsh builds where those subcommands have been renamed or removed. Adds tests/test_slave_bridge.py covering: late-client scrollback replay, scrollback cap, and that a wrong AUTH token never receives any buffered output. Skips cleanly on hosts without Unix-domain sockets. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- .../csshx_latest/launchers/waveterm.py | 5 +- csshx-latest/csshx_latest/slave.py | 47 ++++++- csshx-latest/tests/test_slave_bridge.py | 131 ++++++++++++++++++ 3 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 csshx-latest/tests/test_slave_bridge.py diff --git a/csshx-latest/csshx_latest/launchers/waveterm.py b/csshx-latest/csshx_latest/launchers/waveterm.py index 6953d7b..19b6373 100644 --- a/csshx-latest/csshx_latest/launchers/waveterm.py +++ b/csshx-latest/csshx_latest/launchers/waveterm.py @@ -20,7 +20,10 @@ def __init__(self) -> None: self._counter = 0 @staticmethod - def _run(args: list[str], capture: bool = False) -> subprocess.CompletedProcess: + 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: diff --git a/csshx-latest/csshx_latest/slave.py b/csshx-latest/csshx_latest/slave.py index 4a43c4d..48b7e5b 100644 --- a/csshx-latest/csshx_latest/slave.py +++ b/csshx-latest/csshx_latest/slave.py @@ -5,7 +5,11 @@ Output direction (PTY -> socket) is one-way: bytes the SSH session emits are fanned out to every authenticated socket connection (the -visible terminal block). +visible terminal block). 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 — otherwise the SSH +banner and login prompt would be silently dropped during the time it +takes the launcher to spawn the visible block. Input direction (socket -> PTY) accepts bytes from the focused terminal block AND from the master's broadcaster, both serialized through a @@ -46,6 +50,15 @@ class Slave: 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) + # Bytes the PTY has emitted so far. Replayed to each new client after AUTH + # so late-connecting terminal blocks see the full session (banner, prompt, + # whatever scrolled by while the launcher was spawning them). + scrollback: bytearray = field(default_factory=bytearray, repr=False) + scrollback_max: int = 65536 + # Held during the brief window where we (a) extend scrollback + snapshot + # writers, or (b) replay scrollback + register a new writer. Ensures every + # byte the PTY emits reaches every client exactly once and in order. + state_lock: asyncio.Lock = field(default_factory=asyncio.Lock) async def spawn_slave( @@ -113,7 +126,17 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit except Exception: pass return - slave.connected_writers.append(writer) + # Replay scrollback and register the writer atomically with respect to + # pty_to_sockets — no await between buffering scrollback and joining + # the writer list — so we can never duplicate or lose a chunk. + async with slave.state_lock: + 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) @@ -146,9 +169,25 @@ async def pty_to_sockets() -> None: data = await reader.read(4096) if not data: break - for w in list(slave.connected_writers): + # Atomically: append to scrollback, snapshot current writers, + # buffer the chunk to each. No await inside this block — the + # writes go to in-memory asyncio buffers; drain() runs after. + async with slave.state_lock: + slave.scrollback.extend(data) + excess = len(slave.scrollback) - slave.scrollback_max + if excess > 0: + del slave.scrollback[:excess] + 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: - w.write(data) await w.drain() except Exception: try: diff --git a/csshx-latest/tests/test_slave_bridge.py b/csshx-latest/tests/test_slave_bridge.py new file mode 100644 index 0000000..3d45339 --- /dev/null +++ b/csshx-latest/tests/test_slave_bridge.py @@ -0,0 +1,131 @@ +"""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. +""" +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, *, token: str = "TOK") -> Slave: + return Slave( + index=1, + host="h", + sock_path=sock_path, + token=token, + pty_master=pty_read_fd, + pid=0, + ) + + +def test_late_client_receives_scrollback(tmp_path): + """Bytes emitted before any client connected must be replayed on AUTH.""" + sock_path = str(tmp_path / "slave.sock") + pty_r, pty_w = os.pipe() + slave = _make_slave(sock_path, pty_r) + + 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(tmp_path): + """A flood of pre-connect output must not unbounded-grow the buffer.""" + sock_path = str(tmp_path / "slave.sock") + pty_r, pty_w = os.pipe() + slave = _make_slave(sock_path, pty_r) + 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(tmp_path): + """Failed AUTH must drop the connection without leaking scrollback.""" + sock_path = str(tmp_path / "slave.sock") + pty_r, pty_w = os.pipe() + slave = _make_slave(sock_path, pty_r, 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"" From 535c42b5804b2f0a589271e7802aaead445db12b Mon Sep 17 00:00:00 2001 From: Aditya Kapadia <aditya.kapadia5@gmail.com> Date: Thu, 30 Apr 2026 02:54:54 -0700 Subject: [PATCH 08/13] attach: surface AUTH rejection clearly instead of exiting 0 silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the master rejects an AUTH attempt it closes the socket without sending any data. The fallback attach client used to read 0 bytes, fall through to ``return 0``, and exit silently — the spawned terminal block flashed and disappeared with no explanation. Track whether any bytes have been received; on EOF with received_any=False, print a specific diagnostic to stderr (token mismatch is the realistic cause) and exit 1. Adds tests/test_attach.py covering: - AUTH rejection (server closes immediately) → rc=1 with stderr - Normal EOF after data → rc=0, no rejection message - Bad argv → rc=2 - Connect to missing socket → rc=1 Skips on Windows since attach.py is a Unix-only client. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- csshx-latest/csshx_latest/attach.py | 10 +++ csshx-latest/tests/test_attach.py | 111 ++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 csshx-latest/tests/test_attach.py diff --git a/csshx-latest/csshx_latest/attach.py b/csshx-latest/csshx_latest/attach.py index 42460ef..3c5285d 100644 --- a/csshx-latest/csshx_latest/attach.py +++ b/csshx-latest/csshx_latest/attach.py @@ -42,6 +42,7 @@ def main(argv: list[str]) -> int: tty.setraw(in_fd) watch_in = True + received_any = False try: while True: watches = [sock] @@ -51,7 +52,16 @@ def main(argv: list[str]) -> int: if sock in r: 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: diff --git a/csshx-latest/tests/test_attach.py b/csshx-latest/tests/test_attach.py new file mode 100644 index 0000000..9ecdef9 --- /dev/null +++ b/csshx-latest/tests/test_attach.py @@ -0,0 +1,111 @@ +"""Tests for the fallback attach client. + +Focused on the new "AUTH rejected" exit path: when the master closes +the socket before sending any data, the client should print a clear +diagnostic to stderr and exit 1 (so the user notices the problem +instead of seeing the spawned terminal block silently flash and +disappear). +""" +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 test_auth_rejection_returns_1_with_clear_stderr(tmp_path, capsys): + """Server closes immediately after reading AUTH → client must exit 1.""" + sock_path = str(tmp_path / "rejecting.sock") + + 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 + + srv, t = _start_unix_server(sock_path, reject) + try: + rc = attach.main(["attach", sock_path, "BAD_TOKEN"]) + 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(tmp_path, capsys): + """Server sends some bytes then closes → client exits 0 (normal disconnect).""" + sock_path = str(tmp_path / "happy.sock") + + def serve(conn: socket.socket) -> None: + try: + conn.recv(4096) # consume AUTH line + conn.sendall(b"hello from master\n") + except OSError: + pass + # Connection closes when this returns. + + srv, t = _start_unix_server(sock_path, serve) + try: + # Redirect stdout so the test doesn't pollute the pytest terminal with + # the bytes we sent above. + rc = attach.main(["attach", sock_path, "TOKEN"]) + 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(tmp_path, capsys): + """Connecting to a nonexistent socket prints an error and returns 1.""" + sock_path = str(tmp_path / "does-not-exist.sock") + rc = attach.main(["attach", sock_path, "TOKEN"]) + err = capsys.readouterr().err + assert rc == 1 + assert "connect" in err From ef3bd7c351be9f476014f2b9c10b0980af245c4d Mon Sep 17 00:00:00 2001 From: Aditya Kapadia <aditya.kapadia5@gmail.com> Date: Thu, 30 Apr 2026 03:20:01 -0700 Subject: [PATCH 09/13] tests: fix two macOS-specific failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues bit only on macOS, both via tests that exercised AF_UNIX sockets and a Slave shutdown path: * macOS limits ``sockaddr_un.sun_path`` to 104 bytes (Linux is 108). pytest's ``tmp_path`` lives at ``/private/var/folders/.../pytest-of-USER/pytest-N/test_name0/`` which routinely blew past the limit and aborted ``bind()``. Replace ``tmp_path`` with a new ``short_socket_dir`` fixture that uses ``tempfile.mkdtemp(prefix="csshx-")`` for a path with plenty of headroom under the cap. * ``shutdown_slave`` calls ``os.kill(slave.pid, SIGTERM)``. POSIX treats ``os.kill(0, ...)`` as "signal every process in my process group" — i.e. pytest itself. Tests that built a Slave with ``pid=0`` were one accidental cleanup-call away from the test runner killing itself. Add a ``harmless_pid`` fixture that spawns a short-lived ``time.sleep`` subprocess and yields its PID; reap it on teardown. ``test_broadcaster.py`` still passes ``pid=0`` because that suite never reaches ``shutdown_slave``, only the broadcaster write path. Both fixtures live in ``tests/conftest.py`` so they're available to any future test that touches the bridge or shutdown path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- csshx-latest/tests/conftest.py | 60 +++++++++++++++++++++++++ csshx-latest/tests/test_attach.py | 12 ++--- csshx-latest/tests/test_slave_bridge.py | 30 ++++++++----- 3 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 csshx-latest/tests/conftest.py diff --git a/csshx-latest/tests/conftest.py b/csshx-latest/tests/conftest.py new file mode 100644 index 0000000..6319a4d --- /dev/null +++ b/csshx-latest/tests/conftest.py @@ -0,0 +1,60 @@ +"""Shared pytest fixtures. + +Two fixtures here exist to work around quirks that bite specifically on +macOS: + +* ``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. +""" +from __future__ import annotations + +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 diff --git a/csshx-latest/tests/test_attach.py b/csshx-latest/tests/test_attach.py index 9ecdef9..d2d26ec 100644 --- a/csshx-latest/tests/test_attach.py +++ b/csshx-latest/tests/test_attach.py @@ -45,9 +45,9 @@ def loop() -> None: return srv, t -def test_auth_rejection_returns_1_with_clear_stderr(tmp_path, capsys): +def test_auth_rejection_returns_1_with_clear_stderr(short_socket_dir, capsys): """Server closes immediately after reading AUTH → client must exit 1.""" - sock_path = str(tmp_path / "rejecting.sock") + sock_path = os.path.join(short_socket_dir, "rejecting.sock") def reject(conn: socket.socket) -> None: # Drain whatever AUTH bytes the client sends so its sendall completes, @@ -70,9 +70,9 @@ def reject(conn: socket.socket) -> None: assert "AUTH rejected" in err -def test_clean_eof_after_data_returns_0(tmp_path, capsys): +def test_clean_eof_after_data_returns_0(short_socket_dir, capsys): """Server sends some bytes then closes → client exits 0 (normal disconnect).""" - sock_path = str(tmp_path / "happy.sock") + sock_path = os.path.join(short_socket_dir, "happy.sock") def serve(conn: socket.socket) -> None: try: @@ -102,9 +102,9 @@ def test_bad_argv_returns_2(capsys): assert "usage:" in capsys.readouterr().err -def test_connect_failure_returns_1(tmp_path, capsys): +def test_connect_failure_returns_1(short_socket_dir, capsys): """Connecting to a nonexistent socket prints an error and returns 1.""" - sock_path = str(tmp_path / "does-not-exist.sock") + sock_path = os.path.join(short_socket_dir, "does-not-exist.sock") rc = attach.main(["attach", sock_path, "TOKEN"]) err = capsys.readouterr().err assert rc == 1 diff --git a/csshx-latest/tests/test_slave_bridge.py b/csshx-latest/tests/test_slave_bridge.py index 3d45339..2f53bde 100644 --- a/csshx-latest/tests/test_slave_bridge.py +++ b/csshx-latest/tests/test_slave_bridge.py @@ -5,6 +5,14 @@ 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 @@ -23,22 +31,22 @@ from csshx_latest.slave import Slave, run_slave_bridge, shutdown_slave -def _make_slave(sock_path: str, pty_read_fd: int, *, token: str = "TOK") -> 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=0, + pid=pid, ) -def test_late_client_receives_scrollback(tmp_path): +def test_late_client_receives_scrollback(short_socket_dir, harmless_pid): """Bytes emitted before any client connected must be replayed on AUTH.""" - sock_path = str(tmp_path / "slave.sock") + sock_path = os.path.join(short_socket_dir, "slave.sock") pty_r, pty_w = os.pipe() - slave = _make_slave(sock_path, pty_r) + slave = _make_slave(sock_path, pty_r, harmless_pid) async def go() -> bytes: # Bridge sets up server + pty_reader_task. After this returns, the @@ -73,11 +81,11 @@ async def go() -> bytes: assert b"login: " in received -def test_scrollback_is_capped(tmp_path): +def test_scrollback_is_capped(short_socket_dir, harmless_pid): """A flood of pre-connect output must not unbounded-grow the buffer.""" - sock_path = str(tmp_path / "slave.sock") + sock_path = os.path.join(short_socket_dir, "slave.sock") pty_r, pty_w = os.pipe() - slave = _make_slave(sock_path, pty_r) + slave = _make_slave(sock_path, pty_r, harmless_pid) slave.scrollback_max = 1024 # tighten the cap for the test async def go() -> int: @@ -99,11 +107,11 @@ async def go() -> int: assert size > 0 -def test_wrong_token_is_rejected_and_does_not_get_scrollback(tmp_path): +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 = str(tmp_path / "slave.sock") + sock_path = os.path.join(short_socket_dir, "slave.sock") pty_r, pty_w = os.pipe() - slave = _make_slave(sock_path, pty_r, token="REAL_TOKEN") + slave = _make_slave(sock_path, pty_r, harmless_pid, token="REAL_TOKEN") async def go() -> bytes: await run_slave_bridge(slave) From bca63475ecaf3ffc5455bf7705f9b8b233a5e984 Mon Sep 17 00:00:00 2001 From: Aditya Kapadia <aditya.kapadia5@gmail.com> Date: Thu, 30 Apr 2026 03:30:50 -0700 Subject: [PATCH 10/13] tests: give attach.main() real stdin/stdout fds via os.devnull fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attach.py calls sys.stdin.fileno() and sys.stdout.fileno() to drive its select() loop. pytest's default capsys swaps both with StringIO mocks that have no real fd, so attach.main() raises io.UnsupportedOperation before it can ever observe the AUTH reject. Add a stdio_devnull fixture in tests/conftest.py that opens os.devnull (rb/wb), monkeypatches sys.stdin/sys.stdout to point at those file objects, and closes them on teardown. Wire it into the two attach tests that actually call attach.main() with a connecting socket (test_auth_rejection_returns_1_with_clear_stderr and test_clean_eof_after_data_returns_0). sys.stderr is deliberately left alone so capsys.readouterr().err keeps capturing the error message we assert on — no need to switch to capfd. The other two attach tests (bad argv, missing socket) return before ever touching stdin/stdout fds, so they don't need the fixture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --- csshx-latest/tests/conftest.py | 32 ++++++++++++++++++++++++++++--- csshx-latest/tests/test_attach.py | 6 ++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/csshx-latest/tests/conftest.py b/csshx-latest/tests/conftest.py index 6319a4d..4b855d1 100644 --- a/csshx-latest/tests/conftest.py +++ b/csshx-latest/tests/conftest.py @@ -1,8 +1,5 @@ """Shared pytest fixtures. -Two fixtures here exist to work around quirks that bite specifically on -macOS: - * ``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`` @@ -17,9 +14,18 @@ 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 @@ -58,3 +64,23 @@ def harmless_pid(): proc.wait(timeout=3) except subprocess.TimeoutExpired: # pragma: no cover pass + + +@pytest.fixture +def stdio_devnull(monkeypatch): + """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. + """ + 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_attach.py b/csshx-latest/tests/test_attach.py index d2d26ec..5212ccc 100644 --- a/csshx-latest/tests/test_attach.py +++ b/csshx-latest/tests/test_attach.py @@ -45,7 +45,9 @@ def loop() -> None: return srv, t -def test_auth_rejection_returns_1_with_clear_stderr(short_socket_dir, capsys): +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") @@ -70,7 +72,7 @@ def reject(conn: socket.socket) -> None: assert "AUTH rejected" in err -def test_clean_eof_after_data_returns_0(short_socket_dir, capsys): +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") From 3b1f3c5f3718bf19ee9238385551ddcc52b670eb Mon Sep 17 00:00:00 2001 From: Aditya Kapadia <aditya@example.com> Date: Sun, 17 May 2026 13:04:39 -0700 Subject: [PATCH 11/13] Fix all CRITICAL and MUST HAVE issues; add features CRITICAL fixes: - Bounded reap with SIGKILL fallback (no more hang-on-shutdown) - StrictHostKeyChecking=accept-new injected when user did not set it - --max-hosts cap (default 16) to prevent fork-bomb typos - Broadcaster logs per-slave write failures (no more silent drops) - WaveTerm bash export parser uses shlex.split (handles all quote forms) MUST HAVE features: - Per-slave focus toggle: Ctrl-T <digit> for 1-9, Ctrl-T i for 10+ - iTerm2 and Apple Terminal close_block actually closes the pane now - Launcher.start(total) lifecycle hook (drops CSSHX_HOST_COUNT env smuggle) - ~/.config/csshx-latest/config.toml or ~/.csshrc cluster aliases - TCP-22 preflight, --strict / --no-preflight flags - Per-block SIGWINCH via dedicated control socket (WINSZ commands) - Real-PTY round-trip integration test - --reconnect with exponential backoff on slave death - Tile after every spawn so panes stay balanced Other: - Alpha brace expansion: host-{a..c} - Always use the stdlib attach client (control socket needs dual sockets) - 150 tests passing (was 111) --- csshx-latest/README.md | 202 ++++++---- csshx-latest/csshx_latest/__main__.py | 97 ++++- csshx-latest/csshx_latest/attach.py | 181 ++++++++- csshx-latest/csshx_latest/auth.py | 40 +- csshx-latest/csshx_latest/broadcaster.py | 74 ++++ csshx-latest/csshx_latest/config.py | 129 +++++++ csshx-latest/csshx_latest/hosts.py | 92 +++++ csshx-latest/csshx_latest/launcher.py | 60 ++- .../csshx_latest/launchers/apple_terminal.py | 75 +++- csshx-latest/csshx_latest/launchers/iterm2.py | 93 ++++- csshx-latest/csshx_latest/launchers/kitty.py | 30 +- csshx-latest/csshx_latest/launchers/manual.py | 3 + csshx-latest/csshx_latest/launchers/tmux.py | 72 +++- .../csshx_latest/launchers/waveterm.py | 174 ++++++++- .../csshx_latest/launchers/wezterm.py | 3 + csshx-latest/csshx_latest/logging_setup.py | 33 ++ csshx-latest/csshx_latest/master.py | 223 ++--------- csshx-latest/csshx_latest/orchestrator.py | 365 ++++++++++++++++++ csshx-latest/csshx_latest/slave.py | 251 +++++++++--- csshx-latest/csshx_latest/terminal.py | 87 ++++- csshx-latest/csshx_latest/tui.py | 263 +++++++++++++ csshx-latest/pyproject.toml | 6 +- csshx-latest/tests/conftest.py | 6 +- csshx-latest/tests/test_attach.py | 78 +++- csshx-latest/tests/test_config.py | 87 +++++ csshx-latest/tests/test_dead_slave.py | 92 +++++ csshx-latest/tests/test_hosts.py | 61 +++ csshx-latest/tests/test_integration_pty.py | 101 +++++ .../tests/test_launcher_apple_terminal.py | 94 +++++ csshx-latest/tests/test_launcher_iterm2.py | 127 ++++++ csshx-latest/tests/test_launcher_kitty.py | 121 ++++++ csshx-latest/tests/test_launcher_tmux.py | 65 +++- csshx-latest/tests/test_launcher_waveterm.py | 158 ++++++++ csshx-latest/tests/test_logging_setup.py | 63 +++ csshx-latest/tests/test_main_cli.py | 135 +++++++ csshx-latest/tests/test_orchestrator.py | 148 +++++++ .../tests/test_slave_control_socket.py | 139 +++++++ csshx-latest/tests/test_tui_command_mode.py | 126 ++++++ csshx-latest/tests/test_tui_focus_toggle.py | 68 ++++ .../tests/test_waveterm_export_parser.py | 48 +++ csshx-latest/uv.lock | 155 ++++++++ 41 files changed, 3966 insertions(+), 459 deletions(-) create mode 100644 csshx-latest/csshx_latest/broadcaster.py create mode 100644 csshx-latest/csshx_latest/config.py create mode 100644 csshx-latest/csshx_latest/hosts.py create mode 100644 csshx-latest/csshx_latest/logging_setup.py create mode 100644 csshx-latest/csshx_latest/orchestrator.py create mode 100644 csshx-latest/csshx_latest/tui.py create mode 100644 csshx-latest/tests/test_config.py create mode 100644 csshx-latest/tests/test_dead_slave.py create mode 100644 csshx-latest/tests/test_hosts.py create mode 100644 csshx-latest/tests/test_integration_pty.py create mode 100644 csshx-latest/tests/test_launcher_apple_terminal.py create mode 100644 csshx-latest/tests/test_launcher_iterm2.py create mode 100644 csshx-latest/tests/test_launcher_kitty.py create mode 100644 csshx-latest/tests/test_logging_setup.py create mode 100644 csshx-latest/tests/test_main_cli.py create mode 100644 csshx-latest/tests/test_orchestrator.py create mode 100644 csshx-latest/tests/test_slave_control_socket.py create mode 100644 csshx-latest/tests/test_tui_command_mode.py create mode 100644 csshx-latest/tests/test_tui_focus_toggle.py create mode 100644 csshx-latest/tests/test_waveterm_export_parser.py create mode 100644 csshx-latest/uv.lock diff --git a/csshx-latest/README.md b/csshx-latest/README.md index c77f55e..71a5070 100644 --- a/csshx-latest/README.md +++ b/csshx-latest/README.md @@ -5,6 +5,8 @@ A modern, terminal-agnostic cluster-SSH tool — a spiritual successor to 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 @@ -16,22 +18,14 @@ hack. 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 — no surprise tmux sessions. + doesn't recognize the environment. - **Stdlib-only Python 3.10+** — zero hard runtime dependencies. ## Install -From a checkout of this repo: - ```bash cd csshx-latest/ - -# with uv uv venv && uv pip install -e '.[test]' - -# or plain pip -python3 -m venv .venv && source .venv/bin/activate -pip install -e '.[test]' ``` ## Usage @@ -40,107 +34,157 @@ pip install -e '.[test]' 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 --launcher manual host1 host2 # prints attach commands +csshx-latest --reconnect --strict web0{1..10} # safer mode +csshx-latest production-cluster # uses ~/.csshrc alias ``` -`--launcher` choices: `auto` (default), `waveterm`, `tmux`, `iterm2`, -`terminal`, `kitty`, `wezterm`, `manual`. - -Press **Ctrl-Q** in the master TUI to exit. SIGINT / SIGTERM / SIGHUP -also shut down cleanly. SIGWINCH on the master propagates the new -window size to every slave PTY via TIOCSWINSZ. +### 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. | +| `--debug` | off | Verbose logging to stderr. | + +## Cluster aliases + +Two config sources, first-match wins: + +1. `~/.config/csshx-latest/config.toml` (preferred): + + ```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 + +| 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 `?` | Help | +| `Ctrl-T` then `Ctrl-T` | Send a literal Ctrl-T to slaves | + +SIGINT / SIGTERM / SIGHUP also shut down cleanly. SIGWINCH on the +master propagates the new window size to every slave PTY via TIOCSWINSZ; +each individual block also reports its own resizes back through the +control socket. ## Architecture ``` - ┌────────────────────────── master process ──────────────────────────┐ - │ │ - │ raw stdin ──► Broadcaster ─┬─► Slave[1] PTY ─► ssh host1 │ - │ (your tty) ├─► Slave[2] PTY ─► ssh host2 │ - │ └─► Slave[N] PTY ─► ssh hostN │ - │ │ - │ per slave: │ - │ PTY master fd ──► UNIX socket (0600 + AUTH token) │ - │ ▲ │ - │ │ bidirectional, per-fd write lock │ - │ ▼ │ - │ Launcher.open_block(attach_cmd, host) │ - │ │ │ - │ ┌────────────────┴─────────────────┐ │ - │ │ whichever backend you have: │ │ - │ │ waveterm / tmux / iterm2 / ... │ │ - │ │ (or `manual`: print and paste) │ │ - │ └──────────────────────────────────┘ │ - └────────────────────────────────────────────────────────────────────┘ + 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 <rows> <cols> ... + per-fd write_lock -- escape sequences stay whole + + per launcher: + BlockHandle (backend, data{...}) + open_block / close_block / tile / set_title / start(total) ``` -Output flows one way (PTY → socket → terminal block). Input arrives -from two writers: the master broadcaster *and* whichever terminal block -is focused. A per-slave `asyncio.Lock` serializes PTY writes so an -escape sequence can never get torn between them. - -Each slave socket is gated by a 32-byte hex token; clients have 2 -seconds to send `AUTH <token>\n` or they're dropped. Sockets live in -`$XDG_RUNTIME_DIR/csshx-<pid>/` (or `/tmp/csshx-<pid>/` on macOS), with -the directory at mode 0700 and each socket at 0600. +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` serializes PTY +writes so an escape sequence can never get torn between them. + +Each socket is gated by a 32-byte hex token; clients have 2 seconds +to send `AUTH <token>\n` or they're dropped. Sockets live in +`$XDG_RUNTIME_DIR/csshx-<pid>/` (or `/tmp/csshx-<pid>/` on macOS), +with the directory at mode 0700 and each socket at 0600. + +## 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. +- **`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. ## Backend support matrix -| Backend | Open block | Tile | Set title | Set color | Platform | -| ------------- | ---------- | ---------------- | --------- | --------- | ------------------ | -| WaveTerm | yes | yes (best-effort)| yes | n/a (v2) | mac / linux / win | -| tmux | yes | yes | yes | partial | anywhere with tmux | -| iTerm2 | yes | auto-balanced | yes | n/a (v2) | macOS | -| Terminal.app | yes | manual | yes | n/a (v2) | macOS | -| Kitty | yes | yes (`grid`) | yes | n/a (v2) | mac / linux | -| WezTerm | yes | auto-balanced | yes | n/a (v2) | mac / linux / win | -| Manual | print only | n/a | n/a | n/a | anywhere | +| Backend | Open | Close | Tile | Title | Platform | +| --- | --- | --- | --- | --- | --- | +| WaveTerm | yes | yes | yes (best-effort) | yes | mac / linux / win | +| tmux | yes | yes | yes (`select-layout tiled`) | yes | anywhere with tmux | +| iTerm2 | yes | yes (by session id) | auto-balanced | yes | macOS | +| Terminal.app | yes | yes (by tty id) | manual | yes | macOS | +| Kitty | yes | yes | yes (`grid`) | yes | mac / linux | +| WezTerm | yes | yes | auto-balanced | yes | mac / linux / win | +| Manual | print only | n/a | n/a | n/a | anywhere | Notes: -- **Kitty** requires `allow_remote_control yes` in `kitty.conf`. The - launcher surfaces a clear error if it's not enabled. +- **Kitty** requires `allow_remote_control yes` in `kitty.conf`. - **WaveTerm** tiling tries `wsh setlayout tiled`, then `wsh layout tiled`, then `wsh tile` — it degrades quietly if the wsh CLI grammar drifts between releases. -- **Set color** is a v2 hook reserved on `BlockHandle.data`; none of - the v1 launchers wire it up. +- The orchestrator calls `launcher.tile()` after every spawn so panes + stay balanced as blocks are added. ## 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 — anyone local could sniff a slave | 32-byte token per socket | +| Auth | None | 32-byte token per socket, constant-time compare | | Per-slave typing | Hidden window per slave | Authenticated, bidirectional socket | -| Globals | ~6 file-level `my` vars | Zero | -| Lazy module loading | `eval "use $mod"` | `importlib.import_module` | -| Key handler | Giant if/elsif chain | Future v2 dispatch dict | +| Per-slave focus toggle | Action menu | `Ctrl-T <digit>` (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 | ## Run the tests ```bash uv run pytest -q -# or -pytest -q ``` -The test suite exercises the broadcaster routing logic (with real -pipes), the AUTH handshake (with a `StreamReader`), the -launcher-detection environment matrix, and the Manual / Tmux / -WaveTerm launchers (with `subprocess.run` mocked). +150+ tests cover the broadcaster, the AUTH handshake, the launcher +auto-detect matrix, every concrete launcher (subprocess mocked), the +TUI command mode (including per-slave focus toggle), the orchestrator's +preflight / kill-and-reap / max-hosts cap, the slave control socket's +WINSZ grammar, and a real-PTY round-trip integration test. The package itself can't run on Windows — `pty`, `termios`, `tty`, and -`fcntl` are Unix-only. The tests assume a Unix-like host. - -## v1 scope - -In: spawn N ssh PTYs, broadcast keystrokes, all six concrete launchers -plus the manual fallback, clean shutdown, SIGWINCH propagation, socket -auth. - -Out (designed to slot in later): action mode, `.csshrc` parsing, -per-slave focus toggling commands from the master TUI, ping pre-test, -color themes per slave. +`fcntl` are Unix-only. diff --git a/csshx-latest/csshx_latest/__main__.py b/csshx-latest/csshx_latest/__main__.py index 8275820..bfaec01 100644 --- a/csshx-latest/csshx_latest/__main__.py +++ b/csshx-latest/csshx_latest/__main__.py @@ -1,23 +1,48 @@ -"""Command-line entry point for ``csshx-latest``.""" +"""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 shlex import sys +from importlib import metadata from typing import Optional -from csshx_latest.launcher import detect_launcher -from csshx_latest.master import run_master +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 -def main(argv: Optional[list[str]] = None) -> int: - """Parse args and run the master event loop. Returns the exit code.""" +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("hosts", nargs="+", help="Hosts to ssh to.") + 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="", @@ -27,16 +52,68 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument( "--launcher", default="auto", - choices=["auto", "waveterm", "tmux", "iterm2", "terminal", "kitty", "wezterm", "manual"], + 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( + "--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") + launcher = detect_launcher(args.launcher) - ssh_extra = shlex.split(args.ssh_args) if args.ssh_args else [] + ssh_extra = _shlex.split(args.ssh_args) if args.ssh_args else [] try: - return asyncio.run(run_master(args.hosts, ssh_extra, args.login, launcher)) + 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, + ) + ) except KeyboardInterrupt: return 130 diff --git a/csshx-latest/csshx_latest/attach.py b/csshx-latest/csshx_latest/attach.py index 3c5285d..a569b1b 100644 --- a/csshx-latest/csshx_latest/attach.py +++ b/csshx-latest/csshx_latest/attach.py @@ -1,38 +1,147 @@ -"""Tiny stdlib-only attach client used when ``socat`` isn't installed. +"""Stdlib attach client used by every spawned terminal block. -Connects to a slave's UNIX socket, performs the AUTH handshake, then -shuttles bytes between stdin/stdout and the socket. Run as a module so -spawned terminal blocks can launch it without any extra dependency:: +Author: Aditya Kapadia. - python3 -m csshx_latest.attach <socket_path> <token> +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 <socket_path> <token_path> + +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 ``<token_path>`` 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. """ from __future__ import annotations +import errno +import io import os import select +import signal import socket +import struct import sys 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 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 <socket_path> <token>\n") + sys.stderr.write( + "usage: python3 -m csshx_latest.attach <socket_path> <token_path>\n" + ) return 2 - path, token = argv[1], argv[2] + path, token_path = argv[1], argv[2] - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: - sock.connect(path) + 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 - sock.sendall(f"AUTH {token}\n".encode("ascii")) - in_fd = sys.stdin.fileno() - out_fd = sys.stdout.fileno() + ctl_sock: socket.socket | None = 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): @@ -41,20 +150,41 @@ def main(argv: list[str]) -> int: 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) + watch_in = True received_any = False try: while True: - watches = [sock] + 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) - r, _, _ = select.select(watches, [], []) - if sock in r: - data = sock.recv(BUFSIZE) + 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 " + "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" @@ -71,18 +201,29 @@ def main(argv: list[str]) -> int: if not data: watch_in = False try: - sock.shutdown(socket.SHUT_WR) + data_sock.shutdown(socket.SHUT_WR) except OSError: pass else: - sock.sendall(data) + data_sock.sendall(data) except KeyboardInterrupt: return 130 finally: if saved is not None: import termios termios.tcsetattr(in_fd, termios.TCSADRAIN, saved) - sock.close() + 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 diff --git a/csshx-latest/csshx_latest/auth.py b/csshx-latest/csshx_latest/auth.py index 0d8b99c..77f7dc1 100644 --- a/csshx-latest/csshx_latest/auth.py +++ b/csshx-latest/csshx_latest/auth.py @@ -1,14 +1,21 @@ -"""Token generation and authentication handshake for slave sockets. +"""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 <token>\\n` as the first -line within HANDSHAKE_TIMEOUT seconds; otherwise their connection is -dropped. This prevents other local users from injecting keystrokes -into your SSH sessions. +token. Connecting clients must send ``AUTH <token>\\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 @@ -20,12 +27,29 @@ def make_token() -> str: 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 <expected>\\n`` (\\r is - tolerated) within HANDSHAKE_TIMEOUT seconds. Uses a constant-time - comparison for the token. + Returns True iff the client sent ``AUTH <expected>\\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) diff --git a/csshx-latest/csshx_latest/broadcaster.py b/csshx-latest/csshx_latest/broadcaster.py new file mode 100644 index 0000000..123f54c --- /dev/null +++ b/csshx-latest/csshx_latest/broadcaster.py @@ -0,0 +1,74 @@ +"""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 csshx_latest.slave import Slave, write_to_slave + +log = logging.getLogger(__name__) + + +@dataclass +class Broadcaster: + """Routes bytes to enabled slaves.""" + + slaves: list[Slave] = field(default_factory=list) + + 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 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 + 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: + s.enabled = enabled + + 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 <name> = 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 index 797e3d7..d3f64fb 100644 --- a/csshx-latest/csshx_latest/launcher.py +++ b/csshx-latest/csshx_latest/launcher.py @@ -1,12 +1,29 @@ """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:`detect_launcher` so that selecting one -backend doesn't pay the import cost of the others. +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 @@ -35,6 +52,10 @@ class Launcher(Protocol): 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.""" ... @@ -52,7 +73,11 @@ def set_title(self, handle: BlockHandle, title: str) -> None: ... -_LAUNCHERS = { +# (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"), @@ -63,6 +88,11 @@ def set_title(self, handle: BlockHandle, title: str) -> None: } +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: @@ -77,13 +107,29 @@ 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 the priority - order documented in the project README. Falls back to the Manual - launcher if nothing is recognized — never silently picks tmux. + 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"): @@ -96,6 +142,4 @@ def detect_launcher(name: Optional[str] = None) -> Launcher: return _by_name("kitty") if term_program == "WezTerm" and shutil.which("wezterm"): return _by_name("wezterm") - if os.environ.get("TMUX") and shutil.which("tmux"): - return _by_name("tmux") return _by_name("manual") diff --git a/csshx-latest/csshx_latest/launchers/apple_terminal.py b/csshx-latest/csshx_latest/launchers/apple_terminal.py index 002c887..d6e5250 100644 --- a/csshx-latest/csshx_latest/launchers/apple_terminal.py +++ b/csshx-latest/csshx_latest/launchers/apple_terminal.py @@ -1,47 +1,102 @@ """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. + +The id of the spawned ``tty`` is captured into ``BlockHandle.data`` +so :meth:`close_block` can close the tab on shutdown instead of +leaving the user to ``cmd-W`` every dead pane manually. + Terminal.app has no built-in tiling and AppleScript can't reliably -position windows from outside, so :meth:`tile` is a no-op — the user -arranges the windows themselves. +position windows from outside, so :meth:`tile` is a no-op. """ from __future__ import annotations +import logging import shlex import subprocess from csshx_latest.launcher import BlockHandle +log = logging.getLogger(__name__) + 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) + + class AppleTerminalLauncher: - """Open each block as a new Terminal.app window.""" + """Open each block as a new Terminal.app tab via ``do script``.""" name = "terminal" + def start(self, total: int) -> None: + """No-op: Terminal.app has no programmatic tiling.""" + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: """Tell Terminal.app to ``do script`` with the attach command.""" - cmd_esc = _escape(" ".join(shlex.quote(a) for a in attach_cmd)) + 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' f' set custom title of newTab to "{title_esc}"\n' + ' return tty of newTab\n' 'end tell\n' ) - subprocess.run( - ["osascript", "-e", script], check=False, capture_output=True, text=True - ) - return BlockHandle(backend=self.name, data={"title": title}) + result = _osascript(script) + tty_id = (result.stdout or "").strip().splitlines()[-1].strip() if result.stdout 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}) def close_block(self, handle: BlockHandle) -> None: - """No-op: tabs close when the user closes them or ssh exits.""" + """Close the tab whose tty matches the captured id.""" + 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: """No-op: Terminal.app has no programmatic tiling.""" def set_title(self, handle: BlockHandle, title: str) -> None: - """No-op: we don't track tab references after creation.""" + """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' + ) diff --git a/csshx-latest/csshx_latest/launchers/iterm2.py b/csshx-latest/csshx_latest/launchers/iterm2.py index fe8e3bd..005a2b0 100644 --- a/csshx-latest/csshx_latest/launchers/iterm2.py +++ b/csshx-latest/csshx_latest/launchers/iterm2.py @@ -1,16 +1,29 @@ """iTerm2 launcher via ``osascript`` and iTerm's AppleScript dictionary. -The first block creates a new window with the default profile; each -subsequent block splits the current session vertically. iTerm2 auto- -balances split panes, so :meth:`tile` is a no-op. +Author: Aditya Kapadia. + +The first block creates a new window using the default profile; each +subsequent block splits the current session vertically. Both forms +pass 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, so :meth:`tile` stays a no-op. """ from __future__ import annotations +import logging import shlex import subprocess from csshx_latest.launcher import BlockHandle +log = logging.getLogger(__name__) + def _osascript(script: str) -> subprocess.CompletedProcess: return subprocess.run(["osascript", "-e", script], check=False, capture_output=True, text=True) @@ -29,8 +42,11 @@ class ITerm2Launcher: def __init__(self) -> None: self._first = True + def start(self, total: int) -> None: + """No-op: iTerm2 split panes balance automatically.""" + def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: - """Create or split-then-write — running ``attach_cmd`` in the new session.""" + """Create or split-then-run -- running ``attach_cmd`` as the session's command.""" cmd_str = " ".join(shlex.quote(a) for a in attach_cmd) cmd_esc = _escape(cmd_str) title_esc = _escape(title) @@ -39,11 +55,12 @@ def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: script = ( 'tell application "iTerm"\n' ' activate\n' - ' set newWindow to (create window with default profile)\n' + ' set newWindow to (create window with default profile ' + f' command "{cmd_esc}")\n' ' tell current session of newWindow\n' - f' write text "{cmd_esc}"\n' f' set name to "{title_esc}"\n' ' end tell\n' + ' return (id of newWindow) & "|" & (id of current session of newWindow)\n' 'end tell\n' ) self._first = False @@ -51,27 +68,75 @@ def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: script = ( 'tell application "iTerm"\n' ' tell current session of current window\n' - ' set newSession to (split vertically with default profile)\n' + ' set newSession to (split vertically with default profile ' + f' command "{cmd_esc}")\n' ' end tell\n' ' tell newSession\n' - f' write text "{cmd_esc}"\n' f' set name to "{title_esc}"\n' ' end tell\n' + ' return (id of current window) & "|" & (id of newSession)\n' 'end tell\n' ) - _osascript(script) - return BlockHandle(backend=self.name, data={"title": title}) + 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: - """No-op: iTerm2 sessions die when the user closes them or ssh exits.""" + """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: - """Best-effort rename of the current session.""" + """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" to tell current session of current window ' - f'to set name to "{title_esc}"' + '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 _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 index 79fad09..0e68d86 100644 --- a/csshx-latest/csshx_latest/launchers/kitty.py +++ b/csshx-latest/csshx_latest/launchers/kitty.py @@ -5,6 +5,13 @@ 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 @@ -15,7 +22,7 @@ class KittyLauncher: - """Open each block as a new kitty window. Tile via ``goto-layout grid``.""" + """Open each block as a new kitty tab. Tile via ``goto-layout grid``.""" name = "kitty" @@ -30,10 +37,24 @@ def __init__(self) -> None: 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 window via ``kitty @ launch --type=window``.""" + """Spawn a new kitty tab via ``kitty @ launch --type=tab``.""" out = self._run( - ["kitty", "@", "launch", "--type=window", "--title", title, *attach_cmd], + [ + "kitty", + "@", + "launch", + "--type=tab", + "--keep-focus", + "--tab-title", + title, + "--title", + title, + *attach_cmd, + ], capture=True, ) if out.returncode != 0: @@ -41,6 +62,9 @@ def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: "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}) diff --git a/csshx-latest/csshx_latest/launchers/manual.py b/csshx-latest/csshx_latest/launchers/manual.py index bd7eb13..5b8aa0d 100644 --- a/csshx-latest/csshx_latest/launchers/manual.py +++ b/csshx-latest/csshx_latest/launchers/manual.py @@ -20,6 +20,9 @@ class ManualLauncher: 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] <quoted attach command> # <title>`` to stdout.""" self._counter += 1 diff --git a/csshx-latest/csshx_latest/launchers/tmux.py b/csshx-latest/csshx_latest/launchers/tmux.py index eea9ce9..b1e8795 100644 --- a/csshx-latest/csshx_latest/launchers/tmux.py +++ b/csshx-latest/csshx_latest/launchers/tmux.py @@ -1,8 +1,23 @@ -"""Tmux launcher — spawn each block as a pane in the active session. +"""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``. Operates on whatever -window the spawned panes ended up in. +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 @@ -12,29 +27,62 @@ from csshx_latest.launcher import BlockHandle +#: 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 + class TmuxLauncher: - """Open each block as a tmux pane in the user's current session.""" + """Open each block as a tmux pane; isolate large clusters in a new window.""" name = "tmux" - def __init__(self, target: Optional[str] = None) -> None: + 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`` to open a new pane running ``attach_cmd``.""" - cmd = ["tmux", "split-window", "-P", "-F", "#{pane_id}"] - if self._target: - cmd += ["-t", self._target] - cmd.append(" ".join(shlex.quote(a) for a in attach_cmd)) - out = self._run(cmd, capture=True) - pane_id = (out.stdout or "").strip() + """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: diff --git a/csshx-latest/csshx_latest/launchers/waveterm.py b/csshx-latest/csshx_latest/launchers/waveterm.py index 19b6373..a34a067 100644 --- a/csshx-latest/csshx_latest/launchers/waveterm.py +++ b/csshx-latest/csshx_latest/launchers/waveterm.py @@ -1,15 +1,136 @@ """WaveTerm launcher — opens and tiles blocks via the ``wsh`` CLI. -The wsh subcommand grammar has changed across WaveTerm versions, so -:meth:`tile` tries a few likely incantations in order and stops at the -first one that exits 0. +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 +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",), +) + +#: 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.""" @@ -18,6 +139,13 @@ class WaveTermLauncher: def __init__(self) -> None: self._counter = 0 + self._tile_cmd: Optional[tuple[str, ...]] = None + self._tile_probed = False + 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: @@ -27,9 +155,20 @@ def _run(args: list[str], capture: bool = True) -> subprocess.CompletedProcess: 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``.""" + """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(["wsh", "run", "--", *attach_cmd], capture=True) + 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() @@ -45,22 +184,25 @@ def close_block(self, handle: BlockHandle) -> None: block_id = handle.data.get("block_id") if not block_id: return - self._run(["wsh", "deleteblock", "-b", block_id]) + self._run([self._wsh, "deleteblock", "-b", block_id]) def tile(self, handles: list[BlockHandle]) -> None: - """Try several ``wsh`` layout subcommands; keep the first that succeeds.""" - for attempt in ( - ["wsh", "setlayout", "tiled"], - ["wsh", "layout", "tiled"], - ["wsh", "tile"], - ): - r = self._run(attempt) - if r.returncode == 0: - return + """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(["wsh", "settitle", "-b", block_id, title]) + self._run([self._wsh, "settitle", "-b", block_id, title]) diff --git a/csshx-latest/csshx_latest/launchers/wezterm.py b/csshx-latest/csshx_latest/launchers/wezterm.py index f59330c..1c0d96e 100644 --- a/csshx-latest/csshx_latest/launchers/wezterm.py +++ b/csshx-latest/csshx_latest/launchers/wezterm.py @@ -15,6 +15,9 @@ class WezTermLauncher: 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) 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 index a865502..f81d969 100644 --- a/csshx-latest/csshx_latest/master.py +++ b/csshx-latest/csshx_latest/master.py @@ -1,203 +1,30 @@ -"""Master: orchestrates slaves, runs the broadcast TUI, drives shutdown. +"""Backward-compatibility shim. -The master process is the single source of truth: it owns every PTY, -every UNIX socket, every ssh subprocess. Terminal blocks (rendered by -whichever Launcher is active) are pure renderers — they connect to a -slave's socket, send keystrokes when focused, and display whatever the -PTY emits. +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 -import asyncio -import os -import shutil -import signal -import sys -import tempfile -from dataclasses import dataclass, field -from typing import Optional - -from csshx_latest.auth import make_token -from csshx_latest.launcher import BlockHandle, Launcher -from csshx_latest.slave import ( - Slave, - run_slave_bridge, - shutdown_slave, - spawn_slave, - write_to_slave, +from csshx_latest.broadcaster import Broadcaster +from csshx_latest.orchestrator import ( + attach_command, + make_socket_dir, + run_master, ) -from csshx_latest.terminal import get_winsize, raw_mode, set_winsize - - -@dataclass -class Broadcaster: - """Routes bytes to enabled slaves. Pure logic — owns no fds.""" - - slaves: list[Slave] = field(default_factory=list) - - 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] - - def toggle(self, index: int) -> None: - """Flip the ``enabled`` flag of the slave with the given index.""" - for s in self.slaves: - if s.index == index: - s.enabled = not s.enabled - return - raise KeyError(index) - - async def broadcast(self, data: bytes) -> None: - """Write ``data`` to every enabled slave concurrently.""" - await asyncio.gather( - *(write_to_slave(s, data) for s in self.slaves), - return_exceptions=True, - ) - - -def make_socket_dir() -> str: - """Create a 0700 directory for slave sockets. - - Prefers ``$XDG_RUNTIME_DIR`` when present and a directory; falls - back to the system temp dir (``/tmp`` on macOS). - """ - 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: str) -> list[str]: - """Build the attach command for a terminal block. - - Prefers ``socat`` when available, wrapped in a tiny ``sh -c`` so we - can flip the local terminal into raw mode and inject the AUTH line - before forwarding keystrokes. Falls back to the bundled stdlib - attach client when ``socat`` isn't on PATH. - """ - if shutil.which("socat"): - sh_cmd = ( - 'stty raw -echo 2>/dev/null; ' - f'{{ printf \'AUTH %s\\n\' \'{token}\'; cat; }} | ' - f'socat - UNIX-CONNECT:{sock_path}; ' - 'stty sane 2>/dev/null' - ) - return ["sh", "-c", sh_cmd] - return [sys.executable, "-m", "csshx_latest.attach", sock_path, token] - - -async def run_master( - hosts: list[str], - ssh_args: list[str], - login: Optional[str], - launcher: Launcher, -) -> int: - """Top-level entry: spawn slaves, run the TUI, tear down on exit.""" - sock_dir = make_socket_dir() - bcast = Broadcaster() - handles: list[BlockHandle] = [] - - try: - for i, host in enumerate(hosts, start=1): - token = make_token() - s = await spawn_slave(i, host, sock_dir, ssh_args, login, token) - await run_slave_bridge(s) - bcast.add(s) - handle = launcher.open_block(attach_command(s.sock_path, s.token), host) - handles.append(handle) - - try: - launcher.tile(handles) - except Exception as exc: - sys.stderr.write(f"warning: tile() failed: {exc}\n") - - await tui_loop(bcast) - finally: - for h in handles: - try: - launcher.close_block(h) - except Exception: - pass - for s in bcast.slaves: - shutdown_slave(s) - try: - os.rmdir(sock_dir) - except OSError: - pass - return 0 - - -async def tui_loop(bcast: Broadcaster) -> None: - """Read stdin in raw mode and broadcast keystrokes; render a status line. - - Exits when stdin EOFs, when Ctrl-Q is pressed, or when one of - SIGINT / SIGTERM / SIGHUP is received. SIGWINCH propagates the - master terminal's winsize to every slave PTY. - """ - 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() - render_status(bcast) - - 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) - - async def reader_task() -> None: - while True: - data = await reader.read(64) - if not data: - quit_event.set() - return - if b"\x11" in data: # Ctrl-Q - quit_event.set() - return - await bcast.broadcast(data) - - task = asyncio.create_task(reader_task()) - try: - await quit_event.wait() - finally: - task.cancel() - transport.close() - - -def render_status(bcast: Broadcaster) -> None: - """Write a one-line status footer to stderr.""" - enabled = bcast.enabled_indices() - total = len(bcast.slaves) - sys.stderr.write( - f"\r[csshx-latest] hosts: {total} enabled: {len(enabled)} (Ctrl-Q to quit)\n" - ) - sys.stderr.flush() +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..f31ac4b --- /dev/null +++ b/csshx-latest/csshx_latest/orchestrator.py @@ -0,0 +1,365 @@ +"""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, 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 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 _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], +) -> None: + """Re-spawn ssh for a dead slave with exponential backoff.""" + 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 + 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, +) -> 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() + + def on_slave_dead(s: Slave) -> None: + log.info("slave %d (%s) exited", s.index, s.host) + try: + render_status(bcast) + except Exception: # pragma: no cover - defensive + pass + if reconnect: + asyncio.run_coroutine_threadsafe( + _attempt_reconnect(s, ssh_args, login, winsize), 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) + handles.append(handle) + await _tile(launcher, handles) + + 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 index 48b7e5b..562ef02 100644 --- a/csshx-latest/csshx_latest/slave.py +++ b/csshx-latest/csshx_latest/slave.py @@ -1,43 +1,57 @@ """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 a UNIX domain socket gated by an AUTH token. - -Output direction (PTY -> socket) is one-way: bytes the SSH session -emits are fanned out to every authenticated socket connection (the -visible terminal block). 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 — otherwise the SSH -banner and login prompt would be silently dropped during the time it -takes the launcher to spawn the visible block. - -Input direction (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. +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 commands. Today the only command is + ``WINSZ rows cols [xpixel ypixel]`` which 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. + +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 +from contextlib import contextmanager from dataclasses import dataclass, field -from typing import Optional +from typing import Callable, Iterator, Optional from csshx_latest.auth import authenticate from csshx_latest.terminal import set_winsize +log = logging.getLogger(__name__) + @dataclass class Slave: - """State for one SSH connection. - - ``enabled`` is the broadcast filter — keystrokes from the master - TUI are only delivered to slaves with ``enabled=True``. Per-slave - typing through the socket bridge ignores this flag (you can always - type to a focused block). - """ + """State for one SSH connection.""" index: int host: str @@ -45,20 +59,46 @@ class Slave: token: str pty_master: int pid: int + token_path: str = "" + ctl_sock_path: str = "" enabled: bool = True + dead: bool = False 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) - # Bytes the PTY has emitted so far. Replayed to each new client after AUTH - # so late-connecting terminal blocks see the full session (banner, prompt, - # whatever scrolled by while the launcher was spawning them). scrollback: bytearray = field(default_factory=bytearray, repr=False) scrollback_max: int = 65536 - # Held during the brief window where we (a) extend scrollback + snapshot - # writers, or (b) replay scrollback + register a new writer. Ensures every - # byte the PTY emits reaches every client exactly once and in order. state_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + on_dead: Optional[Callable[["Slave"], None]] = 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. Only called on the event loop + during single-threaded slave setup, so safe in practice. + """ + 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( @@ -68,11 +108,15 @@ async def spawn_slave( 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 # local import so the package can be imported on non-Unix + import pty pty_master, pty_slave = pty.openpty() - set_winsize(pty_master, 24, 80) + 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: @@ -96,29 +140,25 @@ async def spawn_slave( 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 bidirectional PTY <-> socket bridge for ``slave``. - - Spawns: - * a UNIX-domain server bound at ``slave.sock_path`` (mode 0600) - that AUTH-gates every incoming connection and forwards bytes - to the PTY master fd; - * a background task that reads from the PTY master fd and fans - bytes out to all currently connected, authenticated writers. - """ + """Start the data + control sockets and the PTY-fanout task for ``slave``.""" loop = asyncio.get_running_loop() - async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + async def handle_data_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: if not await authenticate(reader, slave.token): writer.close() try: @@ -126,9 +166,6 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit except Exception: pass return - # Replay scrollback and register the writer atomically with respect to - # pty_to_sockets — no await between buffering scrollback and joining - # the writer list — so we can never duplicate or lose a chunk. async with slave.state_lock: if slave.scrollback: writer.write(bytes(slave.scrollback)) @@ -142,6 +179,8 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit 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: @@ -155,9 +194,39 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit except Exception: pass - server = await asyncio.start_unix_server(handle_client, path=slave.sock_path) - os.chmod(slave.sock_path, 0o600) + 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() @@ -169,14 +238,9 @@ async def pty_to_sockets() -> None: data = await reader.read(4096) if not data: break - # Atomically: append to scrollback, snapshot current writers, - # buffer the chunk to each. No await inside this block — the - # writes go to in-memory asyncio buffers; drain() runs after. async with slave.state_lock: slave.scrollback.extend(data) - excess = len(slave.scrollback) - slave.scrollback_max - if excess > 0: - del slave.scrollback[:excess] + _trim_scrollback(slave.scrollback, slave.scrollback_max) writers = list(slave.connected_writers) for w in writers: try: @@ -196,16 +260,67 @@ async def pty_to_sockets() -> None: 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>] + + 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] != "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) + + async def write_to_slave(slave: Slave, data: bytes) -> None: - """Write ``data`` to ``slave``'s PTY iff the slave is ``enabled``.""" - if not slave.enabled: + """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: - _write_all(slave.pty_master, data) + 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: @@ -219,20 +334,30 @@ def _write_all(fd: int, data: bytes) -> None: def shutdown_slave(slave: Slave) -> None: - """Tear down a slave: stop the server, kill ssh, close fds, unlink the socket.""" + """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() - try: - os.kill(slave.pid, signal.SIGTERM) - except OSError: - pass + 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 - try: - os.unlink(slave.sock_path) - 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 index ed1f3e9..c707db5 100644 --- a/csshx-latest/csshx_latest/terminal.py +++ b/csshx-latest/csshx_latest/terminal.py @@ -47,12 +47,84 @@ def set_winsize(fd: int, rows: int, cols: int, xpixel: int = 0, ypixel: int = 0) 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. - No-ops on non-Unix or when the fd is not a TTY, so callers don't - need to special-case those cases themselves. + 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 @@ -63,8 +135,19 @@ def raw_mode(fd: Optional[int] = None) -> Iterator[None]: 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..79414c7 --- /dev/null +++ b/csshx-latest/csshx_latest/tui.py @@ -0,0 +1,263 @@ +"""Master TUI: raw-mode stdin, status line, and command-mode dispatch. + +Author: Aditya Kapadia. + +Command mode (``Ctrl-T`` prefix, 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 +* ``Ctrl-T`` -- send a literal ``Ctrl-T`` byte to slaves +* (any other key) -- cancel command mode +""" +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 +KEY_INDEX_PROMPT = b"i" + + +def render_status(bcast: Broadcaster) -> None: + """Write a one-line status footer to stderr.""" + total = len(bcast.slaves) + enabled = len(bcast.enabled_indices()) + dead = sum(1 for s in bcast.slaves if s.dead) + sys.stderr.write( + f"\r[csshx-latest] hosts: {total} enabled: {enabled} " + f"dead: {dead} (Ctrl-Q quit, Ctrl-T 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() -> None: + _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(" Ctrl-T send a literal Ctrl-T") + _write_msg(" (other) cancel command mode") + + +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 +) -> bytes: + """Apply one command-mode keystroke. + + Returns any bytes that should still be broadcast (e.g. the user + pressed Ctrl-T twice -> send a literal Ctrl-T). Empty bytes mean + "consumed, broadcast nothing". + """ + ch = bytes([byte]) + if ch == KEY_COMMAND_PREFIX: + return KEY_COMMAND_PREFIX + 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) + 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) + return b"" + if ch == b"l": + _render_list(bcast) + render_status(bcast) + return b"" + if ch == b"q": + _write_msg("quitting...") + quit_event.set() + return b"" + if ch == b"?": + _render_help() + render_status(bcast) + return b"" + _write_msg("(command-mode cancelled)") + render_status(bcast) + return b"" + + +async def tui_loop(bcast: Broadcaster) -> 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() + render_status(bcast) + + 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 KEY_COMMAND_PREFIX not in data + ): + await bcast.broadcast(data) + continue + await _drain_with_command_handling(data, bcast, state, quit_event) + + 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, +) -> 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) + 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) + state.in_command = False + if extra: + buf.extend(extra) + continue + if bytes([b]) == KEY_COMMAND_PREFIX: + 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) -> 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) + 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) + 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 index 6e7b4f5..0a9c2ec 100644 --- a/csshx-latest/pyproject.toml +++ b/csshx-latest/pyproject.toml @@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta" [project] name = "csshx-latest" -version = "0.1.0" +version = "0.2.0" description = "Modern, terminal-agnostic cluster-SSH (csshX rewrite)." readme = "README.md" requires-python = ">=3.10" license = {text = "MIT"} -authors = [{name = "csshx-latest contributors"}] +authors = [{name = "Aditya Kapadia"}] keywords = ["ssh", "cluster", "csshx", "terminal", "broadcast"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Environment :: Console", "License :: OSI Approved :: MIT License", "Operating System :: MacOS", diff --git a/csshx-latest/tests/conftest.py b/csshx-latest/tests/conftest.py index 4b855d1..8bc1ae5 100644 --- a/csshx-latest/tests/conftest.py +++ b/csshx-latest/tests/conftest.py @@ -67,13 +67,17 @@ def harmless_pid(): @pytest.fixture -def stdio_devnull(monkeypatch): +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") diff --git a/csshx-latest/tests/test_attach.py b/csshx-latest/tests/test_attach.py index 5212ccc..657a582 100644 --- a/csshx-latest/tests/test_attach.py +++ b/csshx-latest/tests/test_attach.py @@ -1,10 +1,15 @@ """Tests for the fallback attach client. -Focused on the new "AUTH rejected" exit path: when the master closes -the socket before sending any data, the client should print a clear -diagnostic to stderr and exit 1 (so the user notices the problem -instead of seeing the spawned terminal block silently flash and -disappear). +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 @@ -45,11 +50,23 @@ def loop() -> None: 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, @@ -59,10 +76,11 @@ def reject(conn: socket.socket) -> None: conn.recv(4096) except OSError: pass + auth_sent.set() srv, t = _start_unix_server(sock_path, reject) try: - rc = attach.main(["attach", sock_path, "BAD_TOKEN"]) + rc = attach.main(["attach", sock_path, token_path]) finally: srv.close() t.join(timeout=2) @@ -75,6 +93,7 @@ def reject(conn: socket.socket) -> None: 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: @@ -82,13 +101,10 @@ def serve(conn: socket.socket) -> None: conn.sendall(b"hello from master\n") except OSError: pass - # Connection closes when this returns. srv, t = _start_unix_server(sock_path, serve) try: - # Redirect stdout so the test doesn't pollute the pytest terminal with - # the bytes we sent above. - rc = attach.main(["attach", sock_path, "TOKEN"]) + rc = attach.main(["attach", sock_path, token_path]) finally: srv.close() t.join(timeout=2) @@ -107,7 +123,47 @@ def test_bad_argv_returns_2(capsys): 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") - rc = attach.main(["attach", sock_path, "TOKEN"]) + 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_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_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_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..59ee106 --- /dev/null +++ b/csshx-latest/tests/test_launcher_apple_terminal.py @@ -0,0 +1,94 @@ +"""Tests for the Apple Terminal launcher (osascript mocked). + +The p10k fix here is the ``exec /bin/sh -c '...'`` wrapper around the +attach command: zsh's first parsed line exec-replaces the user's +interactive shell with ``/bin/sh`` running our command, so p10k's +instant-prompt never runs and never gets a chance to swallow +keystrokes. These tests pin that contract — the generated AppleScript +must include the ``exec`` prefix. +""" +from __future__ import annotations + +import subprocess + +import pytest + +from csshx_latest.launchers import apple_terminal as term_mod + + +@pytest.fixture +def fake_osascript(monkeypatch): + scripts: list[str] = [] + + def runner(args, check=False, capture_output=False, text=False): + if args[:2] == ["osascript", "-e"]: + scripts.append(args[2]) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(term_mod.subprocess, "run", runner) + return scripts + + +def test_open_block_wraps_attach_in_exec_sh(fake_osascript): + """The do-script body must start with ``exec /bin/sh -c`` (p10k fix).""" + l = term_mod.AppleTerminalLauncher() + l.open_block(["socat", "-", "UNIX-CONNECT:/tmp/a"], "web01") + + assert len(fake_osascript) == 1 + s = fake_osascript[0] + # The wrapper is the contract: without it, p10k can swallow keystrokes. + assert "exec /bin/sh -c" in s + # Custom title is applied to the new tab. + assert 'set custom title of newTab to "web01"' in s + # ``do script`` is Terminal.app's only AppleScript entry point. + assert "do script" in s + + +def test_open_block_returns_handle_with_title(fake_osascript): + """The handle records the title so future ``set_title`` calls can find it.""" + l = term_mod.AppleTerminalLauncher() + h = l.open_block(["echo", "hi"], "host-x") + assert h.backend == "terminal" + assert h.data["title"] == "host-x" + + +def test_close_block_is_noop(fake_osascript): + """Terminal.app gives us no tab handle, so close_block is intentionally no-op.""" + l = term_mod.AppleTerminalLauncher() + h = l.open_block(["echo"], "h") + fake_osascript.clear() + + l.close_block(h) + + assert fake_osascript == [] + + +def test_tile_is_noop(fake_osascript): + """Terminal.app has no programmatic tiling.""" + l = term_mod.AppleTerminalLauncher() + fake_osascript.clear() + + l.tile([]) + + assert fake_osascript == [] + + +def test_set_title_is_noop(fake_osascript): + """We don't track tab references after creation, so set_title no-ops.""" + l = term_mod.AppleTerminalLauncher() + h = l.open_block(["echo"], "h") + fake_osascript.clear() + + l.set_title(h, "x") + + assert fake_osascript == [] + + +def test_special_chars_in_title_are_escaped(fake_osascript): + """A title containing a double-quote must not break the AppleScript string.""" + l = term_mod.AppleTerminalLauncher() + l.open_block(["echo"], 'evil"title') + + s = fake_osascript[0] + # The literal `"` in the title must be backslash-escaped in the script. + assert 'evil\\"title' in s diff --git a/csshx-latest/tests/test_launcher_iterm2.py b/csshx-latest/tests/test_launcher_iterm2.py new file mode 100644 index 0000000..0bfe361 --- /dev/null +++ b/csshx-latest/tests/test_launcher_iterm2.py @@ -0,0 +1,127 @@ +"""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. Specifically, the v1.1 +powerlevel10k fix relies on running the attach command 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). These tests pin that +contract. +""" +from __future__ import annotations + +import subprocess + +import pytest + +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_creates_window_with_command_not_write_text(fake_osascript): + """First block → ``create window with default profile command "<cmd>"``.""" + 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 "create window with default profile" 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_uses_split_vertically_with_command(fake_osascript): + """Subsequent blocks 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 + s2 = fake_osascript[1] + assert "split vertically with default profile" in s2 + assert 'command "' in s2 + assert "write text" not in s2 + assert 'set name to "second"' in s2 + + +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] 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_tmux.py b/csshx-latest/tests/test_launcher_tmux.py index 65572a4..b179e4e 100644 --- a/csshx-latest/tests/test_launcher_tmux.py +++ b/csshx-latest/tests/test_launcher_tmux.py @@ -15,7 +15,7 @@ def fake_run(monkeypatch): def runner(args, check=False, capture_output=False, text=False): calls.append(list(args)) - if "split-window" in 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="") @@ -23,6 +23,12 @@ def runner(args, check=False, capture_output=False, text=False): 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") @@ -64,3 +70,60 @@ def test_set_title_runs_select_pane_T(fake_run): 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 index 07aa0d9..f6aa08e 100644 --- a/csshx-latest/tests/test_launcher_waveterm.py +++ b/csshx-latest/tests/test_launcher_waveterm.py @@ -1,6 +1,7 @@ """Tests for the WaveTerm launcher (subprocess.run mocked).""" from __future__ import annotations +import os import subprocess import pytest @@ -8,6 +9,22 @@ 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``.""" @@ -66,3 +83,144 @@ def test_tile_stops_at_first_zero_exit(fake_run): 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_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..5961898 --- /dev/null +++ b/csshx-latest/tests/test_main_cli.py @@ -0,0 +1,135 @@ +"""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, + ): + 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 + 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..56756ee --- /dev/null +++ b/csshx-latest/tests/test_orchestrator.py @@ -0,0 +1,148 @@ +"""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 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_control_socket.py b/csshx-latest/tests/test_slave_control_socket.py new file mode 100644 index 0000000..f6a31b8 --- /dev/null +++ b/csshx-latest/tests/test_slave_control_socket.py @@ -0,0 +1,139 @@ +"""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 + 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_tui_command_mode.py b/csshx-latest/tests/test_tui_command_mode.py new file mode 100644 index 0000000..6c78939 --- /dev/null +++ b/csshx-latest/tests/test_tui_command_mode.py @@ -0,0 +1,126 @@ +"""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_byte_cancels_command_mode(): + """An unmapped byte does nothing destructive — just cancels.""" + 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"" + 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("") == {} diff --git a/csshx-latest/uv.lock b/csshx-latest/uv.lock new file mode 100644 index 0000000..310b728 --- /dev/null +++ b/csshx-latest/uv.lock @@ -0,0 +1,155 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, +] + +[[package]] +name = "csshx-latest" +version = "0.2.0" +source = { editable = "." } + +[package.optional-dependencies] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pytest", marker = "extra == 'test'", specifier = ">=7" }] +provides-extras = ["test"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396" }, + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } +sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } +wheels = [ + { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, +] From 0a3f4684699f4d862a191d15704ab2f4569e2d6c Mon Sep 17 00:00:00 2001 From: Aditya Kapadia <aditya@example.com> Date: Sun, 17 May 2026 13:11:43 -0700 Subject: [PATCH 12/13] docs: add AGENTS.md with done / pending checklist for future contributors --- csshx-latest/AGENTS.md | 301 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 csshx-latest/AGENTS.md diff --git a/csshx-latest/AGENTS.md b/csshx-latest/AGENTS.md new file mode 100644 index 0000000..2549268 --- /dev/null +++ b/csshx-latest/AGENTS.md @@ -0,0 +1,301 @@ +# 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.10+, stdlib +only (no runtime deps), real PTYs, pluggable terminal launchers, token- +authenticated UNIX sockets. + +**Status:** v0.2.0 Beta. 150 tests passing in ~1.1s. Safe for daily use +on trusted networks with up to 16 hosts (raise `--max-hosts` for more). + +``` +csshx_latest/ +├── __main__.py CLI entry, argparse +├── 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 +├── 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 +├── logging_setup.py stderr formatter +└── 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 + ~365. 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+). + +### Test layout + +- `tests/test_<unit>.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/conftest.py` — shared fixtures (`short_socket_dir`, + `harmless_pid`, `stdio_devnull`). + +Run: `uv run pytest -q`. Target: <2 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 <num>` 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 was already there; now + 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. + +### Test coverage delta + +| | Before | After | +| --- | --- | --- | +| Test files | 16 | 21 | +| Tests | 111 | 150 | +| Wall clock | 0.45s | 1.10s | + +New test modules: `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`. + +--- + +## PENDING (Good-to-have, in priority order) + +None of these block daily use. Pick the top one when you next sit down +with the project. + +### High value + +1. **Color blocks by state (`set_color` hook).** `BlockHandle.data` + reserves the field; no launcher wires it. Dim disabled, red dead, + green enabled would be a huge visual aid. Start with WaveTerm (`wsh + setbg` or similar) and tmux (`select-pane -P`). 60-90 LOC. + +2. **Action mode: `--action 'uptime' host1 host2`.** One-shot command + broadcast, capture stdout per host, print a summary table, exit. No + TUI. Useful for ops scripts. Original csshX had this. ~120 LOC. + +3. **Backend conformance test harness.** Parametrized + `@pytest.fixture(params=[KittyLauncher, TmuxLauncher, ...])` that + verifies each launcher honors the Protocol contract uniformly + (open returns non-empty handle, close uses that handle's data, etc). + Catches drift cheaply. + +4. **Bound `connected_writers` per slave.** Currently unbounded after + AUTH; add a max-attaches cap (e.g. 4) so a leaked token can't fan + out infinitely. + +### Medium value + +5. **Lower Python floor to 3.9 OR adopt 3.11+ `TaskGroup`.** Currently + declares `>=3.10` but uses no 3.10-only feature. Either widen the + install base (3.9) or actually use TaskGroup for parallel slave + spawn (would parallelize the ~50ms-per-host launcher round-trips). + +6. **Action menu help discoverability.** `_render_help` lists keys but + users have to enter command mode to see it. Print a one-line hint + on startup ("press Ctrl-T then ? for menu"). + +7. **Slave-status colors in the master status footer.** Today + `render_status` writes `hosts: 3 enabled: 2 dead: 1` in plain + text. ANSI-color the dead count red, enabled count green; users + spot dead hosts faster. + +8. **Configurable command-mode prefix.** Today `Ctrl-T` is hardcoded. + Some users have `Ctrl-T` bound elsewhere (jed, emacs). Add + `--command-key Ctrl-B` (or read from config). + +9. **`_handle_command_byte` swallows unknown bytes silently.** The + docstring documents "any other key cancels" but the user types a + letter and it vanishes. Friendlier: echo the cancelled byte after + exiting command mode. + +### Low value / polish + +10. **`master.py` shim.** Still a 28-line re-export. Either delete with + a CHANGELOG note ("import from `csshx_latest.orchestrator`") or + stop apologizing for it. + +11. **`[project.urls]` in pyproject.toml.** Homepage / repo / issue + tracker links. Standard hygiene. + +12. **`_temporary_umask` thread-safety note.** Today it's only called + on the event loop during single-threaded setup, so safe. If + `asyncio.to_thread` launchers ever use it, add a lock. Currently + has a comment to that effect; consider asserting it instead. + +13. **WaveTerm `wsh token` JSON output.** When/if WaveTerm exposes a + JSON variant of `wsh token`, switch to it from `shlex.split` of + the bash output. Less brittle. + +14. **Improve `--reconnect` UX.** Today it writes a one-liner to + stderr. Could also re-set the block title to indicate + "reconnecting...", clear scrollback or insert a divider, etc. + +15. **Document the `WINSZ` control protocol** in `slave.py`'s + module docstring so future protocol extensions have a precedent + (e.g. `BELL`, `FOCUS`, `RESIZE`). + +--- + +## 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. +- `slave-N.ctl` (control): line-oriented. Today only `WINSZ rows cols + [xpixel ypixel]`. Future: `BELL`, `FOCUS`, etc. + +The stdlib attach client opens both; socat-based attach is no longer +supported because it can't multiplex two sockets cleanly. + +### 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. + +### 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. Sleeps for the next backoff value. +2. Re-probes tcp/22. +3. Re-spawns `ssh` with the SAME token and socket paths. +4. Rebinds `slave.pty_master` and `slave.pid`, clears `dead`. +5. Re-runs the bridge. + +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. + +--- + +## 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)`. +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. Tests: copy any existing `tests/test_launcher_*.py` and adapt. + +### 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). + +--- + +## 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. From 5c79878c42df8555d51e24ad3ebaf39807c53a35 Mon Sep 17 00:00:00 2001 From: Aditya Kapadia <aditya.kapadia5@gmail.com> Date: Mon, 18 May 2026 01:27:15 -0700 Subject: [PATCH 13/13] feat: 0.2 overwrite --- csshx-latest/AGENTS.md | 418 +++++++++++---- csshx-latest/README.md | 198 +++++-- csshx-latest/csshx_latest/__init__.py | 2 +- csshx-latest/csshx_latest/__main__.py | 49 +- csshx-latest/csshx_latest/action.py | 137 +++++ csshx-latest/csshx_latest/attach.py | 72 ++- csshx-latest/csshx_latest/broadcaster.py | 24 +- csshx-latest/csshx_latest/launcher.py | 28 + .../csshx_latest/launchers/apple_terminal.py | 279 +++++++++- csshx-latest/csshx_latest/launchers/iterm2.py | 120 +++-- csshx-latest/csshx_latest/launchers/kitty.py | 31 +- csshx-latest/csshx_latest/launchers/manual.py | 5 +- csshx-latest/csshx_latest/launchers/tmux.py | 28 +- .../csshx_latest/launchers/waveterm.py | 43 +- .../csshx_latest/launchers/wezterm.py | 5 +- csshx-latest/csshx_latest/orchestrator.py | 96 +++- csshx-latest/csshx_latest/slave.py | 97 +++- csshx-latest/csshx_latest/tui.py | 161 ++++-- csshx-latest/pyproject.toml | 12 +- csshx-latest/tests/test_action.py | 137 +++++ csshx-latest/tests/test_attach.py | 91 ++++ csshx-latest/tests/test_color_state.py | 90 ++++ csshx-latest/tests/test_command_key.py | 104 ++++ .../tests/test_launcher_apple_terminal.py | 486 ++++++++++++++++-- .../tests/test_launcher_conformance.py | 122 +++++ csshx-latest/tests/test_launcher_iterm2.py | 103 +++- csshx-latest/tests/test_launcher_waveterm.py | 95 ++++ csshx-latest/tests/test_main_cli.py | 2 + csshx-latest/tests/test_orchestrator.py | 38 ++ .../tests/test_slave_control_socket.py | 69 +++ csshx-latest/tests/test_slave_max_writers.py | 137 +++++ csshx-latest/tests/test_status_footer.py | 76 +++ csshx-latest/tests/test_tui_command_mode.py | 22 +- csshx-latest/uv.lock | 155 ------ 34 files changed, 3068 insertions(+), 464 deletions(-) create mode 100644 csshx-latest/csshx_latest/action.py create mode 100644 csshx-latest/tests/test_action.py create mode 100644 csshx-latest/tests/test_color_state.py create mode 100644 csshx-latest/tests/test_command_key.py create mode 100644 csshx-latest/tests/test_launcher_conformance.py create mode 100644 csshx-latest/tests/test_slave_max_writers.py create mode 100644 csshx-latest/tests/test_status_footer.py delete mode 100644 csshx-latest/uv.lock diff --git a/csshx-latest/AGENTS.md b/csshx-latest/AGENTS.md index 2549268..5ed03b9 100644 --- a/csshx-latest/AGENTS.md +++ b/csshx-latest/AGENTS.md @@ -9,28 +9,31 @@ 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.10+, stdlib +[csshX](https://github.com/brockgr/csshx). Async Python 3.9+, stdlib only (no runtime deps), real PTYs, pluggable terminal launchers, token- -authenticated UNIX sockets. +authenticated UNIX sockets, with master + slaves tiled together on +every backend that can address the master window. -**Status:** v0.2.0 Beta. 150 tests passing in ~1.1s. Safe for daily use -on trusted networks with up to 16 hosts (raise `--max-hosts` for more). +**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 +├── __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 +├── 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 +├── 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 @@ -47,15 +50,18 @@ csshx_latest/ - **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 - ~365. Split before crossing 600. +- **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 @@ -63,10 +69,12 @@ csshx_latest/ - `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: <2 seconds wall-clock. +Run: `uv run pytest -q`. Target: < 4 seconds wall-clock. --- @@ -101,102 +109,220 @@ Run: `uv run pytest -q`. Target: <2 seconds wall-clock. - 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 was already there; now - joined by per-block SIGWINCH via the control socket. +- 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 -| | Before | After | -| --- | --- | --- | -| Test files | 16 | 21 | -| Tests | 111 | 150 | -| Wall clock | 0.45s | 1.10s | +| | 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: `test_config.py`, `test_orchestrator.py`, +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_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 (Good-to-have, in priority order) - -None of these block daily use. Pick the top one when you next sit down -with the project. - -### High value - -1. **Color blocks by state (`set_color` hook).** `BlockHandle.data` - reserves the field; no launcher wires it. Dim disabled, red dead, - green enabled would be a huge visual aid. Start with WaveTerm (`wsh - setbg` or similar) and tmux (`select-pane -P`). 60-90 LOC. - -2. **Action mode: `--action 'uptime' host1 host2`.** One-shot command - broadcast, capture stdout per host, print a summary table, exit. No - TUI. Useful for ops scripts. Original csshX had this. ~120 LOC. - -3. **Backend conformance test harness.** Parametrized - `@pytest.fixture(params=[KittyLauncher, TmuxLauncher, ...])` that - verifies each launcher honors the Protocol contract uniformly - (open returns non-empty handle, close uses that handle's data, etc). - Catches drift cheaply. - -4. **Bound `connected_writers` per slave.** Currently unbounded after - AUTH; add a max-attaches cap (e.g. 4) so a leaked token can't fan - out infinitely. - -### Medium value - -5. **Lower Python floor to 3.9 OR adopt 3.11+ `TaskGroup`.** Currently - declares `>=3.10` but uses no 3.10-only feature. Either widen the - install base (3.9) or actually use TaskGroup for parallel slave - spawn (would parallelize the ~50ms-per-host launcher round-trips). - -6. **Action menu help discoverability.** `_render_help` lists keys but - users have to enter command mode to see it. Print a one-line hint - on startup ("press Ctrl-T then ? for menu"). - -7. **Slave-status colors in the master status footer.** Today - `render_status` writes `hosts: 3 enabled: 2 dead: 1` in plain - text. ANSI-color the dead count red, enabled count green; users - spot dead hosts faster. - -8. **Configurable command-mode prefix.** Today `Ctrl-T` is hardcoded. - Some users have `Ctrl-T` bound elsewhere (jed, emacs). Add - `--command-key Ctrl-B` (or read from config). - -9. **`_handle_command_byte` swallows unknown bytes silently.** The - docstring documents "any other key cancels" but the user types a - letter and it vanishes. Friendlier: echo the cancelled byte after - exiting command mode. - -### Low value / polish - -10. **`master.py` shim.** Still a 28-line re-export. Either delete with - a CHANGELOG note ("import from `csshx_latest.orchestrator`") or - stop apologizing for it. - -11. **`[project.urls]` in pyproject.toml.** Homepage / repo / issue - tracker links. Standard hygiene. - -12. **`_temporary_umask` thread-safety note.** Today it's only called - on the event loop during single-threaded setup, so safe. If - `asyncio.to_thread` launchers ever use it, add a lock. Currently - has a comment to that effect; consider asserting it instead. - -13. **WaveTerm `wsh token` JSON output.** When/if WaveTerm exposes a - JSON variant of `wsh token`, switch to it from `shlex.split` of - the bash output. Less brittle. - -14. **Improve `--reconnect` UX.** Today it writes a one-liner to - stderr. Could also re-set the block title to indicate - "reconnecting...", clear scrollback or insert a divider, etc. - -15. **Document the `WINSZ` control protocol** in `slave.py`'s - module docstring so future protocol extensions have a precedent - (e.g. `BELL`, `FOCUS`, `RESIZE`). +## 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. --- @@ -207,12 +333,25 @@ with the project. Each slave exposes two AUTH-gated UNIX sockets: - `slave-N.sock` (data): bidirectional bytes. PTY output fans out; - client keystrokes flow in. + 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. + [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. -The stdlib attach client 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 @@ -228,7 +367,9 @@ 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. +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 @@ -236,11 +377,14 @@ or drop a chunk. exits. With `--reconnect`, it schedules `_attempt_reconnect` on the loop via `run_coroutine_threadsafe`. That coroutine: -1. Sleeps for the next backoff value. -2. Re-probes tcp/22. -3. Re-spawns `ssh` with the SAME token and socket paths. -4. Rebinds `slave.pty_master` and `slave.pid`, clears `dead`. -5. Re-runs the bridge. +1. Retitles the block `<host> [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 @@ -251,7 +395,41 @@ underlying ssh died. The token file is re-written to the same path 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. +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;<mod>;<key>~` — 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. --- @@ -261,12 +439,20 @@ layouts during launch. 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)`. + `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. Tests: copy any existing `tests/test_launcher_*.py` and adapt. +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 @@ -284,6 +470,16 @@ layouts during launch. 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 @@ -299,3 +495,5 @@ layouts during launch. 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 index 71a5070..d4786cb 100644 --- a/csshx-latest/README.md +++ b/csshx-latest/README.md @@ -13,13 +13,21 @@ Author: Aditya Kapadia. 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. -- **Stdlib-only Python 3.10+** — zero hard runtime dependencies. +- **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 @@ -28,6 +36,9 @@ 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 @@ -37,6 +48,7 @@ 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 @@ -50,13 +62,33 @@ csshx-latest production-cluster # uses ~/.csshrc alias | `--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): +1. `~/.config/csshx-latest/config.toml` (preferred; respects + `$XDG_CONFIG_HOME`): ```toml [clusters] @@ -78,6 +110,9 @@ 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 | @@ -86,13 +121,26 @@ recursively before brace expansion runs. | `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 `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 block also reports its own resizes back through the -control socket. +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 @@ -108,28 +156,71 @@ control socket. data socket (0600, AUTH-gated) -- bidirectional bytes control socket(0600, AUTH-gated) -- WINSZ <rows> <cols> ... 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{...}) - open_block / close_block / tile / set_title / start(total) + start(total) / open_block / close_block / tile / set_title / + set_color ``` -Output flows one way (PTY -> data socket -> terminal block). Input +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` serializes PTY -writes so an escape sequence can never get torn between them. +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 <token>\n` or they're dropped. Sockets live in `$XDG_RUNTIME_DIR/csshx-<pid>/` (or `/tmp/csshx-<pid>/` on macOS), -with the directory at mode 0700 and each socket at 0600. +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 <rows> <cols> [<xpixel> <ypixel>]` — 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 `<host> [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 +- **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. + 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. @@ -137,42 +228,67 @@ with the directory at mode 0700 and each socket at 0600. 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 | Platform | -| --- | --- | --- | --- | --- | --- | -| WaveTerm | yes | yes | yes (best-effort) | yes | mac / linux / win | -| tmux | yes | yes | yes (`select-layout tiled`) | yes | anywhere with tmux | -| iTerm2 | yes | yes (by session id) | auto-balanced | yes | macOS | -| Terminal.app | yes | yes (by tty id) | manual | yes | macOS | -| Kitty | yes | yes | yes (`grid`) | yes | mac / linux | -| WezTerm | yes | yes | auto-balanced | yes | mac / linux / win | -| Manual | print only | n/a | n/a | n/a | anywhere | +| 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`. +- **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` — it degrades quietly if the wsh CLI grammar - drifts between releases. + 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. + 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 | +| 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 | -| Per-slave typing | Hidden window per slave | Authenticated, bidirectional socket | +| 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 <digit>` (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 | +| Config | `~/.csshrc` only | TOML preferred, `~/.csshrc` fallback | +| One-shot fan-out | `--remote_command` | `--action` (same semantics, with per-host timeout) | ## Run the tests @@ -180,11 +296,15 @@ Notes: uv run pytest -q ``` -150+ tests cover the broadcaster, the AUTH handshake, the launcher -auto-detect matrix, every concrete launcher (subprocess mocked), the -TUI command mode (including per-slave focus toggle), the orchestrator's -preflight / kill-and-reap / max-hosts cap, the slave control socket's -WINSZ grammar, and a real-PTY round-trip integration test. - -The package itself can't run on Windows — `pty`, `termios`, `tty`, and -`fcntl` are Unix-only. +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 index f115c12..80af851 100644 --- a/csshx-latest/csshx_latest/__init__.py +++ b/csshx-latest/csshx_latest/__init__.py @@ -1,3 +1,3 @@ """csshx-latest: modern, terminal-agnostic cluster-SSH.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/csshx-latest/csshx_latest/__main__.py b/csshx-latest/csshx_latest/__main__.py index bfaec01..56884b7 100644 --- a/csshx-latest/csshx_latest/__main__.py +++ b/csshx-latest/csshx_latest/__main__.py @@ -13,11 +13,13 @@ 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: @@ -76,6 +78,29 @@ def _build_parser() -> argparse.ArgumentParser: 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", @@ -98,9 +123,30 @@ def main(argv: Optional[list[str]] = None) -> int: if not expanded: parser.error("no hosts after brace / cluster expansion") - launcher = detect_launcher(args.launcher) 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( @@ -112,6 +158,7 @@ def main(argv: Optional[list[str]] = None) -> int: strict_preflight=args.strict, reconnect=args.reconnect, skip_preflight=args.no_preflight, + command_key=command_key, ) ) except KeyboardInterrupt: 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 <host> <command>`` +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 <host> <command>``; 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 index a569b1b..2158719 100644 --- a/csshx-latest/csshx_latest/attach.py +++ b/csshx-latest/csshx_latest/attach.py @@ -22,6 +22,19 @@ 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 @@ -33,6 +46,7 @@ import socket import struct import sys +from typing import Optional BUFSIZE = 4096 @@ -114,6 +128,22 @@ def _push_winsize(ctl_sock: socket.socket, in_fd: int) -> None: 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: @@ -135,7 +165,7 @@ def main(argv: list[str]) -> int: sys.stderr.write(f"connect {path}: {exc}\n") return 1 - ctl_sock: socket.socket | None = None + ctl_sock: Optional[socket.socket] = None try: ctl_sock = _connect_auth(_ctl_path_for(path), token) except OSError: @@ -162,6 +192,36 @@ def on_sigwinch(_signo, _frame) -> None: 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: @@ -199,6 +259,13 @@ def on_sigwinch(_signo, _frame) -> None: 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) @@ -207,6 +274,9 @@ def on_sigwinch(_signo, _frame) -> None: 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: diff --git a/csshx-latest/csshx_latest/broadcaster.py b/csshx-latest/csshx_latest/broadcaster.py index 123f54c..028ed77 100644 --- a/csshx-latest/csshx_latest/broadcaster.py +++ b/csshx-latest/csshx_latest/broadcaster.py @@ -11,6 +11,7 @@ import asyncio import logging from dataclasses import dataclass, field +from typing import Callable, Optional from csshx_latest.slave import Slave, write_to_slave @@ -19,9 +20,18 @@ @dataclass class Broadcaster: - """Routes bytes to enabled slaves.""" + """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.""" @@ -35,6 +45,14 @@ 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. @@ -44,14 +62,16 @@ def toggle(self, index: int) -> bool: 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: + 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. diff --git a/csshx-latest/csshx_latest/launcher.py b/csshx-latest/csshx_latest/launcher.py index d3f64fb..349a05a 100644 --- a/csshx-latest/csshx_latest/launcher.py +++ b/csshx-latest/csshx_latest/launcher.py @@ -27,12 +27,36 @@ """ 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`. @@ -72,6 +96,10 @@ 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 -- diff --git a/csshx-latest/csshx_latest/launchers/apple_terminal.py b/csshx-latest/csshx_latest/launchers/apple_terminal.py index d6e5250..50240a7 100644 --- a/csshx-latest/csshx_latest/launchers/apple_terminal.py +++ b/csshx-latest/csshx_latest/launchers/apple_terminal.py @@ -11,23 +11,89 @@ exec-replaces itself with ``/bin/sh`` running our command, so the prompt never gets a chance to render and no shell init code runs. -The id of the spawned ``tty`` is captured into ``BlockHandle.data`` -so :meth:`close_block` can close the tab on shutdown instead of -leaving the user to ``cmd-W`` every dead pane manually. +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. -Terminal.app has no built-in tiling and AppleScript can't reliably -position windows from outside, so :meth:`tile` is a no-op. +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 +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('"', '\\"') @@ -37,16 +103,98 @@ 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 a new Terminal.app tab via ``do script``.""" + """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: - """No-op: Terminal.app has no programmatic tiling.""" + """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: - """Tell Terminal.app to ``do script`` with the attach command.""" + """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) @@ -55,18 +203,41 @@ def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: '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 tty of newTab\n' + ' return (id of newWin as text) & "\\n" & (tty of newTab)\n' 'end tell\n' ) result = _osascript(script) - tty_id = (result.stdout or "").strip().splitlines()[-1].strip() if result.stdout else "" + 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}) + 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 tab whose tty matches the captured id.""" + """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 @@ -82,7 +253,60 @@ def close_block(self, handle: BlockHandle) -> None: ) def tile(self, handles: list[BlockHandle]) -> None: - """No-op: Terminal.app has no programmatic tiling.""" + """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.""" @@ -100,3 +324,28 @@ def set_title(self, handle: BlockHandle, title: str) -> None: ' 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 index 005a2b0..b1ead43 100644 --- a/csshx-latest/csshx_latest/launchers/iterm2.py +++ b/csshx-latest/csshx_latest/launchers/iterm2.py @@ -2,17 +2,23 @@ Author: Aditya Kapadia. -The first block creates a new window using the default profile; each -subsequent block splits the current session vertically. Both forms -pass 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. +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, so :meth:`tile` stays a no-op. +iTerm2 auto-balances split panes whenever a new one is added, so +:meth:`tile` itself stays a no-op. """ from __future__ import annotations @@ -20,10 +26,23 @@ import shlex import subprocess -from csshx_latest.launcher import BlockHandle +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) @@ -39,44 +58,37 @@ class ITerm2Launcher: name = "iterm2" - def __init__(self) -> None: - self._first = True - def start(self, total: int) -> None: """No-op: iTerm2 split panes balance automatically.""" def open_block(self, attach_cmd: list[str], title: str) -> BlockHandle: - """Create or split-then-run -- running ``attach_cmd`` as the session's command.""" + """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) - if self._first: - script = ( - 'tell application "iTerm"\n' - ' activate\n' - ' set newWindow to (create window with default profile ' - f' command "{cmd_esc}")\n' - ' tell current session of newWindow\n' - f' set name to "{title_esc}"\n' - ' end tell\n' - ' return (id of newWindow) & "|" & (id of current session of newWindow)\n' - 'end tell\n' - ) - self._first = False - else: - script = ( - 'tell application "iTerm"\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' - ) + 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: @@ -131,6 +143,38 @@ def set_title(self, handle: BlockHandle, title: str) -> None: ) + 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: diff --git a/csshx-latest/csshx_latest/launchers/kitty.py b/csshx-latest/csshx_latest/launchers/kitty.py index 0e68d86..c98a5d9 100644 --- a/csshx-latest/csshx_latest/launchers/kitty.py +++ b/csshx-latest/csshx_latest/launchers/kitty.py @@ -18,7 +18,16 @@ import shutil import subprocess -from csshx_latest.launcher import BlockHandle +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: @@ -85,3 +94,23 @@ def set_title(self, handle: BlockHandle, title: str) -> None: 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 index 5b8aa0d..2fd4d3c 100644 --- a/csshx-latest/csshx_latest/launchers/manual.py +++ b/csshx-latest/csshx_latest/launchers/manual.py @@ -9,7 +9,7 @@ import shlex import sys -from csshx_latest.launcher import BlockHandle +from csshx_latest.launcher import BlockHandle, Color class ManualLauncher: @@ -40,3 +40,6 @@ def tile(self, handles: list[BlockHandle]) -> None: 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 index b1e8795..b7dd368 100644 --- a/csshx-latest/csshx_latest/launchers/tmux.py +++ b/csshx-latest/csshx_latest/launchers/tmux.py @@ -25,13 +25,23 @@ import subprocess from typing import Optional -from csshx_latest.launcher import BlockHandle +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.""" @@ -107,3 +117,19 @@ def set_title(self, handle: BlockHandle, title: str) -> None: 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 index a34a067..d827b5c 100644 --- a/csshx-latest/csshx_latest/launchers/waveterm.py +++ b/csshx-latest/csshx_latest/launchers/waveterm.py @@ -15,7 +15,7 @@ import subprocess from typing import Optional -from csshx_latest.launcher import BlockHandle +from csshx_latest.launcher import BlockHandle, Color log = logging.getLogger(__name__) @@ -27,6 +27,15 @@ ("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 @@ -141,6 +150,12 @@ 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) @@ -206,3 +221,29 @@ def set_title(self, handle: BlockHandle, title: str) -> None: 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 index 1c0d96e..dc5d04b 100644 --- a/csshx-latest/csshx_latest/launchers/wezterm.py +++ b/csshx-latest/csshx_latest/launchers/wezterm.py @@ -3,7 +3,7 @@ import subprocess -from csshx_latest.launcher import BlockHandle +from csshx_latest.launcher import BlockHandle, Color class WezTermLauncher: @@ -42,3 +42,6 @@ def set_title(self, handle: BlockHandle, title: str) -> None: 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/orchestrator.py b/csshx-latest/csshx_latest/orchestrator.py index f31ac4b..a999025 100644 --- a/csshx-latest/csshx_latest/orchestrator.py +++ b/csshx-latest/csshx_latest/orchestrator.py @@ -46,7 +46,7 @@ from csshx_latest.auth import make_token, write_token_file from csshx_latest.broadcaster import Broadcaster -from csshx_latest.launcher import BlockHandle, Launcher +from csshx_latest.launcher import BlockHandle, Color, Launcher from csshx_latest.slave import ( Slave, run_slave_bridge, @@ -54,7 +54,7 @@ spawn_slave, ) from csshx_latest.terminal import get_winsize -from csshx_latest.tui import render_status, tui_loop +from csshx_latest.tui import KEY_COMMAND_PREFIX, render_status, tui_loop log = logging.getLogger(__name__) @@ -136,6 +136,49 @@ async def _start_launcher(launcher: Launcher, total: int) -> None: 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 @@ -224,8 +267,18 @@ async def _attempt_reconnect( 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.""" + """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) @@ -254,6 +307,9 @@ async def _attempt_reconnect( 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 @@ -270,6 +326,7 @@ async def run_master( 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: @@ -297,15 +354,39 @@ async def run_master( 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: - log.info("slave %d (%s) exited", s.index, s.host) + 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 - if reconnect: + # 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), loop + _attempt_reconnect(s, ssh_args, login, winsize, launcher), loop ) await _start_launcher(launcher, len(hosts)) @@ -328,8 +409,11 @@ def on_slave_dead(s: Slave) -> None: 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) diff --git a/csshx-latest/csshx_latest/slave.py b/csshx-latest/csshx_latest/slave.py index 562ef02..9f3bdeb 100644 --- a/csshx-latest/csshx_latest/slave.py +++ b/csshx-latest/csshx_latest/slave.py @@ -12,10 +12,28 @@ client immediately after AUTH succeeds. * ``slave-N.ctl`` -- the control socket. After AUTH it accepts - line-oriented commands. Today the only command is - ``WINSZ rows cols [xpixel ypixel]`` which 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. + 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 @@ -39,15 +57,22 @@ import os import signal import socket +import threading from contextlib import contextmanager from dataclasses import dataclass, field -from typing import Callable, Iterator, Optional +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: @@ -63,6 +88,11 @@ class Slave: 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) @@ -72,15 +102,27 @@ class Slave: 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. Only called on the event loop - during single-threaded slave setup, so safe in practice. + 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 @@ -167,6 +209,20 @@ async def handle_data_client(reader: asyncio.StreamReader, writer: asyncio.Strea 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) @@ -284,6 +340,7 @@ def _apply_control_line(slave: Slave, line: bytes) -> None: 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. @@ -295,6 +352,9 @@ def _apply_control_line(slave: Slave, line: bytes) -> None: 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 @@ -311,6 +371,29 @@ def _apply_control_line(slave: Slave, line: bytes) -> None: 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: diff --git a/csshx-latest/csshx_latest/tui.py b/csshx-latest/csshx_latest/tui.py index 79414c7..95c3a69 100644 --- a/csshx-latest/csshx_latest/tui.py +++ b/csshx-latest/csshx_latest/tui.py @@ -2,7 +2,7 @@ Author: Aditya Kapadia. -Command mode (``Ctrl-T`` prefix, then one key): +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 @@ -10,8 +10,10 @@ * ``l`` -- list slaves with their state * ``q`` -- quit * ``?`` -- show command-mode help -* ``Ctrl-T`` -- send a literal ``Ctrl-T`` byte to slaves -* (any other key) -- cancel command mode +* ``<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 @@ -27,18 +29,78 @@ log = logging.getLogger(__name__) KEY_QUIT = b"\x11" # Ctrl-Q -KEY_COMMAND_PREFIX = b"\x14" # Ctrl-T +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 render_status(bcast: Broadcaster) -> None: - """Write a one-line status footer to stderr.""" + +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: {enabled} " - f"dead: {dead} (Ctrl-Q quit, Ctrl-T menu)\r\n" + 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() @@ -48,7 +110,8 @@ def _write_msg(msg: str) -> None: sys.stderr.flush() -def _render_help() -> None: +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") @@ -56,8 +119,8 @@ def _render_help() -> None: _write_msg(" l list slaves and their state") _write_msg(" q quit") _write_msg(" ? show this help") - _write_msg(" Ctrl-T send a literal Ctrl-T") - _write_msg(" (other) cancel command mode") + _write_msg(f" {label:<7} send a literal {label}") + _write_msg(" (other) cancel command mode (printable echoes)") def _render_list(bcast: Broadcaster) -> None: @@ -91,45 +154,62 @@ def reset(self) -> None: async def _handle_command_byte( - bcast: Broadcaster, byte: int, quit_event: asyncio.Event + 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 (e.g. the user - pressed Ctrl-T twice -> send a literal Ctrl-T). Empty bytes mean - "consumed, broadcast nothing". + 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 == KEY_COMMAND_PREFIX: - return KEY_COMMAND_PREFIX + 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) + 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) + render_status(bcast, command_key) return b"" if ch == b"l": _render_list(bcast) - render_status(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() - render_status(bcast) + _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) + render_status(bcast, command_key) return b"" -async def tui_loop(bcast: Broadcaster) -> None: +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() @@ -157,7 +237,14 @@ def on_quit_signal() -> None: pass on_sigwinch() - render_status(bcast) + # 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() @@ -179,11 +266,13 @@ async def reader_task() -> None: if ( not state.in_command and not state.in_index_prompt - and KEY_COMMAND_PREFIX not in data + and command_key not in data ): await bcast.broadcast(data) continue - await _drain_with_command_handling(data, bcast, state, quit_event) + await _drain_with_command_handling( + data, bcast, state, quit_event, command_key + ) task = asyncio.create_task(reader_task()) try: @@ -198,6 +287,7 @@ async def _drain_with_command_handling( 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() @@ -206,7 +296,7 @@ async def _drain_with_command_handling( if buf: await bcast.broadcast(bytes(buf)) buf.clear() - _consume_index_prompt_byte(b, bcast, state) + _consume_index_prompt_byte(b, bcast, state, command_key) continue if state.in_command: if buf: @@ -218,12 +308,12 @@ async def _drain_with_command_handling( 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) + extra = await _handle_command_byte(bcast, b, quit_event, command_key) state.in_command = False if extra: buf.extend(extra) continue - if bytes([b]) == KEY_COMMAND_PREFIX: + if bytes([b]) == command_key: if buf: await bcast.broadcast(bytes(buf)) buf.clear() @@ -235,12 +325,17 @@ async def _drain_with_command_handling( await bcast.broadcast(bytes(buf)) -def _consume_index_prompt_byte(b: int, bcast: Broadcaster, state: _CommandState) -> None: +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) + render_status(bcast, command_key) return if b in (ord("\r"), ord("\n")): if not state.index_buffer: @@ -253,7 +348,7 @@ def _consume_index_prompt_byte(b: int, bcast: Broadcaster, state: _CommandState) else: _toggle_slave(bcast, idx) state.reset() - render_status(bcast) + render_status(bcast, command_key) return if b in (0x7F, 0x08): if state.index_buffer: diff --git a/csshx-latest/pyproject.toml b/csshx-latest/pyproject.toml index 0a9c2ec..fa87d82 100644 --- a/csshx-latest/pyproject.toml +++ b/csshx-latest/pyproject.toml @@ -7,7 +7,7 @@ name = "csshx-latest" version = "0.2.0" description = "Modern, terminal-agnostic cluster-SSH (csshX rewrite)." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.9" license = {text = "MIT"} authors = [{name = "Aditya Kapadia"}] keywords = ["ssh", "cluster", "csshx", "terminal", "broadcast"] @@ -18,11 +18,21 @@ classifiers = [ "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"] 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 index 657a582..5c7e0f4 100644 --- a/csshx-latest/tests/test_attach.py +++ b/csshx-latest/tests/test_attach.py @@ -140,6 +140,97 @@ def test_missing_token_file_returns_1(short_socket_dir, capsys): 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") 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_launcher_apple_terminal.py b/csshx-latest/tests/test_launcher_apple_terminal.py index 59ee106..a55fd16 100644 --- a/csshx-latest/tests/test_launcher_apple_terminal.py +++ b/csshx-latest/tests/test_launcher_apple_terminal.py @@ -1,11 +1,22 @@ """Tests for the Apple Terminal launcher (osascript mocked). -The p10k fix here is the ``exec /bin/sh -c '...'`` wrapper around the -attach command: zsh's first parsed line exec-replaces the user's -interactive shell with ``/bin/sh`` running our command, so p10k's -instant-prompt never runs and never gets a chance to swallow -keystrokes. These tests pin that contract — the generated AppleScript -must include the ``exec`` prefix. +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 @@ -13,82 +24,493 @@ 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"]: - scripts.append(args[2]) + 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 + 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(fake_osascript) == 1 - s = fake_osascript[0] - # The wrapper is the contract: without it, p10k can swallow keystrokes. + assert len(scripts) == 1 + s = scripts[0] assert "exec /bin/sh -c" in s - # Custom title is applied to the new tab. assert 'set custom title of newTab to "web01"' in s - # ``do script`` is Terminal.app's only AppleScript entry point. assert "do script" in s -def test_open_block_returns_handle_with_title(fake_osascript): - """The handle records the title so future ``set_title`` calls can find it.""" +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_is_noop(fake_osascript): - """Terminal.app gives us no tab handle, so close_block is intentionally no-op.""" +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") - fake_osascript.clear() + scripts.clear() l.close_block(h) - assert fake_osascript == [] + 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_tile_is_noop(fake_osascript): - """Terminal.app has no programmatic tiling.""" +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() - fake_osascript.clear() + 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 fake_osascript == [] + 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_set_title_is_noop(fake_osascript): - """We don't track tab references after creation, so set_title no-ops.""" + +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") - fake_osascript.clear() + scripts.clear() - l.set_title(h, "x") + l.set_title(h, "new-title") - assert fake_osascript == [] + 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') - s = fake_osascript[0] - # The literal `"` in the title must be backslash-escaped in the script. - assert 'evil\\"title' in s + 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 index 0bfe361..c05d33e 100644 --- a/csshx-latest/tests/test_launcher_iterm2.py +++ b/csshx-latest/tests/test_launcher_iterm2.py @@ -2,12 +2,20 @@ 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. Specifically, the v1.1 -powerlevel10k fix relies on running the attach command 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). These tests pin that -contract. +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 @@ -15,6 +23,7 @@ import pytest +from csshx_latest.launcher import BlockHandle, Color from csshx_latest.launchers import iterm2 as iterm_mod @@ -33,8 +42,14 @@ def runner(args, check=False, capture_output=False, text=False): return scripts -def test_first_open_creates_window_with_command_not_write_text(fake_osascript): - """First block → ``create window with default profile command "<cmd>"``.""" +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") @@ -42,7 +57,8 @@ def test_first_open_creates_window_with_command_not_write_text(fake_osascript): 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 "create window with default profile" in s + 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. @@ -51,18 +67,20 @@ def test_first_open_creates_window_with_command_not_write_text(fake_osascript): assert "socat" in s -def test_second_open_uses_split_vertically_with_command(fake_osascript): - """Subsequent blocks split the current session, again via ``command``.""" +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 - s2 = fake_osascript[1] - assert "split vertically with default profile" in s2 - assert 'command "' in s2 - assert "write text" not in s2 - assert 'set name to "second"' in s2 + 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): @@ -125,3 +143,56 @@ def test_set_title_renames_current_session(fake_osascript): 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_waveterm.py b/csshx-latest/tests/test_launcher_waveterm.py index f6aa08e..2703d77 100644 --- a/csshx-latest/tests/test_launcher_waveterm.py +++ b/csshx-latest/tests/test_launcher_waveterm.py @@ -6,6 +6,7 @@ import pytest +from csshx_latest.launcher import BlockHandle, Color from csshx_latest.launchers import waveterm as waveterm_mod @@ -129,6 +130,100 @@ def runner(args, check=False, capture_output=False, text=False): 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") diff --git a/csshx-latest/tests/test_main_cli.py b/csshx-latest/tests/test_main_cli.py index 5961898..c2d23ef 100644 --- a/csshx-latest/tests/test_main_cli.py +++ b/csshx-latest/tests/test_main_cli.py @@ -28,6 +28,7 @@ async def fake_coro( strict_preflight=False, reconnect=False, skip_preflight=False, + command_key=b"\x14", ): captured["hosts"] = list(hosts) captured["ssh_args"] = list(ssh_args) @@ -37,6 +38,7 @@ async def fake_coro( 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) diff --git a/csshx-latest/tests/test_orchestrator.py b/csshx-latest/tests/test_orchestrator.py index 56756ee..0a0df9d 100644 --- a/csshx-latest/tests/test_orchestrator.py +++ b/csshx-latest/tests/test_orchestrator.py @@ -123,6 +123,44 @@ def _is_zombie_or_dead(pid: int) -> bool: 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: diff --git a/csshx-latest/tests/test_slave_control_socket.py b/csshx-latest/tests/test_slave_control_socket.py index f6a31b8..7eaa5f0 100644 --- a/csshx-latest/tests/test_slave_control_socket.py +++ b/csshx-latest/tests/test_slave_control_socket.py @@ -60,6 +60,75 @@ def test_apply_control_line_rejects_bad_grammar(): _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) 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 index 6c78939..9877da8 100644 --- a/csshx-latest/tests/test_tui_command_mode.py +++ b/csshx-latest/tests/test_tui_command_mode.py @@ -86,14 +86,32 @@ def test_doubled_prefix_returns_literal_prefix_byte(): assert not quit_ev.is_set() -def test_unknown_byte_cancels_command_mode(): - """An unmapped byte does nothing destructive — just cancels.""" +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() diff --git a/csshx-latest/uv.lock b/csshx-latest/uv.lock deleted file mode 100644 index 310b728..0000000 --- a/csshx-latest/uv.lock +++ /dev/null @@ -1,155 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, -] - -[[package]] -name = "csshx-latest" -version = "0.2.0" -source = { editable = "." } - -[package.optional-dependencies] -test = [ - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [{ name = "pytest", marker = "extra == 'test'", specifier = ">=7" }] -provides-extras = ["test"] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9" }, -] - -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396" }, - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/simple" } -sdist = { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } -wheels = [ - { url = "https://pypi.ci.artifacts.walmart.com/artifactory/api/pypi/external-pypi/packages/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, -]