bradlives commited on
Commit
18473c0
·
verified ·
1 Parent(s): d125562

Upload SshBridge.cs with huggingface_hub

Browse files
Files changed (1) hide show
  1. SshBridge.cs +856 -24
SshBridge.cs CHANGED
@@ -1,9 +1,12 @@
1
  using System;
 
2
  using System.Drawing;
3
  using System.IO;
 
4
  using System.Net;
5
  using System.Net.Sockets;
6
  using System.Text;
 
7
  using System.Threading;
8
  using System.Threading.Tasks;
9
  using System.Windows.Forms;
@@ -38,12 +41,33 @@ namespace SshBridge
38
  private Thread? _serverThread;
39
  private CancellationTokenSource? _cts;
40
  private SshClient? _sshClient;
 
41
  private bool _isConnected;
42
  private int _commandCount;
43
  private string _currentHost = "";
44
  private string _currentUser = "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  private const int PORT = 52718;
 
 
 
 
 
 
47
 
48
  public SshBridgeForm()
49
  {
@@ -142,10 +166,48 @@ namespace SshBridge
142
  Height = 28,
143
  Anchor = AnchorStyles.Right,
144
  };
145
- _disconnectButton.Location = new Point(topBar.Width - _disconnectButton.Width - 10, 6);
146
  _disconnectButton.Click += (s, e) => Disconnect();
147
  topBar.Controls.Add(_disconnectButton);
148
- topBar.Resize += (s, e) => _disconnectButton.Location = new Point(topBar.Width - _disconnectButton.Width - 10, 6);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  _outputBox = new RichTextBox
151
  {
@@ -157,11 +219,45 @@ namespace SshBridge
157
  WordWrap = false,
158
  };
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  _sessionPanel.Controls.Add(_outputBox);
161
  _sessionPanel.Controls.Add(topBar);
162
 
163
  this.Controls.Add(_loginPanel);
164
  this.Controls.Add(_sessionPanel);
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  }
166
 
167
  private void Connect()
@@ -196,10 +292,27 @@ namespace SshBridge
196
  _currentUser = user;
197
  _commandCount = 0;
198
 
 
 
 
 
 
 
 
 
199
  this.Invoke(() =>
200
  {
 
201
  _passwordBox.Clear();
202
  OnConnected();
 
 
 
 
 
 
 
 
203
  });
204
  }
205
  else
@@ -232,23 +345,102 @@ namespace SshBridge
232
  private void Disconnect()
233
  {
234
  _isConnected = false;
 
 
 
235
 
236
  try
237
  {
 
238
  _sshClient?.Disconnect();
239
  _sshClient?.Dispose();
240
  }
241
  catch { }
242
 
 
243
  _sshClient = null;
244
  _currentHost = "";
245
  _currentUser = "";
 
246
 
247
  _loginPanel.Visible = true;
248
  _sessionPanel.Visible = false;
249
  _connectButton.Enabled = true;
250
  _connectButton.Text = "Connect";
251
  _statusLabel.Text = "Disconnected";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  }
253
 
254
  private void AppendOutput(string? text, Color? color = null)
@@ -274,44 +466,490 @@ namespace SshBridge
274
 
275
  public string ExecuteCommand(string command)
276
  {
277
- if (!_isConnected || _sshClient == null || !_sshClient.IsConnected)
278
  {
279
  return "ERROR: Not connected. Open SSH Bridge and connect first.";
280
  }
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  try
283
  {
284
- _commandCount++;
285
- this.Invoke(() => _statusLabel.Text = $"Connected to {_currentUser}@{_currentHost} ({_commandCount} commands)");
286
-
287
- AppendOutput($"> {command}", Color.Cyan);
288
-
289
- using var cmd = _sshClient.CreateCommand(command);
290
- cmd.CommandTimeout = TimeSpan.FromSeconds(30);
291
- var result = cmd.Execute();
292
- var error = cmd.Error;
293
 
294
- var output = result.Trim();
295
- if (!string.IsNullOrEmpty(error))
 
 
 
296
  {
297
- output += "\n[stderr] " + error.Trim();
 
 
 
298
  }
 
 
 
299
 
300
- if (string.IsNullOrEmpty(output))
 
 
 
 
 
 
 
 
 
301
  {
302
- output = "(no output)";
303
  }
 
 
 
304
 
305
- AppendOutput(output);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
 
307
- return output;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  }
309
- catch (Exception ex)
 
 
 
 
 
 
 
 
 
 
 
 
310
  {
311
- var error = $"ERROR: {ex.Message}";
312
- AppendOutput(error, Color.Red);
313
- return error;
314
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  }
316
 
317
  private void StartTcpServer()
@@ -353,6 +991,193 @@ namespace SshBridge
353
  {
354
  response = _isConnected ? $"CONNECTED:{_currentUser}@{_currentHost}" : "DISCONNECTED";
355
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  else if (command.StartsWith("__PREFILL__:"))
357
  {
358
  // Format: __PREFILL__:host:port:user:password
@@ -386,12 +1211,19 @@ namespace SshBridge
386
  this.Invoke(() => Connect());
387
  response = "CONNECTING";
388
  }
 
 
 
 
 
 
 
389
  else
390
  {
391
  response = ExecuteCommand(command);
392
  }
393
  // Encode newlines so they survive the single-line protocol
394
- response = response.Replace("\r\n", "<<CRLF>>").Replace("\n", "<<LF>>");
395
  writer.WriteLine(response);
396
  }
397
  }
 
1
  using System;
2
+ using System.Collections.Generic;
3
  using System.Drawing;
4
  using System.IO;
5
+ using System.Linq;
6
  using System.Net;
7
  using System.Net.Sockets;
8
  using System.Text;
9
+ using System.Text.RegularExpressions;
10
  using System.Threading;
11
  using System.Threading.Tasks;
12
  using System.Windows.Forms;
 
41
  private Thread? _serverThread;
42
  private CancellationTokenSource? _cts;
43
  private SshClient? _sshClient;
44
+ private ShellStream? _shellStream;
45
  private bool _isConnected;
46
  private int _commandCount;
47
  private string _currentHost = "";
48
  private string _currentUser = "";
49
+ private string _promptPattern = "";
50
+ private readonly object _shellLock = new object();
51
+
52
+ // UI state
53
+ private bool _stayOnTop;
54
+ private bool _penLifted;
55
+ private bool _allowSudo;
56
+ private bool _isRunning;
57
+ private string _storedPassword = "";
58
+ private Button _stayOnTopButton = null!;
59
+ private Button _penButton = null!;
60
+ private Button _sudoButton = null!;
61
+ private System.Windows.Forms.Timer _runningTimer = null!;
62
+ private int _runningDots;
63
 
64
  private const int PORT = 52718;
65
+ private const int MAX_OUTPUT_BYTES = 500 * 1024; // 500KB limit for MCP
66
+ private const int MAX_OUTPUT_LINES = 150; // Keep last N lines for large output
67
+ private const int DEFAULT_TIMEOUT_MS = 30000;
68
+
69
+ private int _nextCommandTimeoutMs = DEFAULT_TIMEOUT_MS; // Configurable per-command
70
+ private Dictionary<string, int> _spawnedProcesses = new(); // Track background processes
71
 
72
  public SshBridgeForm()
73
  {
 
166
  Height = 28,
167
  Anchor = AnchorStyles.Right,
168
  };
 
169
  _disconnectButton.Click += (s, e) => Disconnect();
170
  topBar.Controls.Add(_disconnectButton);
171
+
172
+ _penButton = new Button
173
+ {
174
+ Text = "✏️ Lift Pen",
175
+ ForeColor = Color.White,
176
+ BackColor = Color.FromArgb(70, 130, 180),
177
+ FlatStyle = FlatStyle.Flat,
178
+ Width = 100,
179
+ Height = 28,
180
+ };
181
+ _penButton.Click += (s, e) => TogglePen();
182
+ topBar.Controls.Add(_penButton);
183
+
184
+ _stayOnTopButton = new Button
185
+ {
186
+ Text = "📌 Pin",
187
+ ForeColor = Color.White,
188
+ BackColor = Color.FromArgb(80, 80, 85),
189
+ FlatStyle = FlatStyle.Flat,
190
+ Width = 70,
191
+ Height = 28,
192
+ };
193
+ _stayOnTopButton.Click += (s, e) => ToggleStayOnTop();
194
+ topBar.Controls.Add(_stayOnTopButton);
195
+
196
+ _sudoButton = new Button
197
+ {
198
+ Text = "🔓 Sudo",
199
+ ForeColor = Color.White,
200
+ BackColor = Color.FromArgb(80, 80, 85),
201
+ FlatStyle = FlatStyle.Flat,
202
+ Width = 80,
203
+ Height = 28,
204
+ };
205
+ _sudoButton.Click += (s, e) => ToggleSudo();
206
+ topBar.Controls.Add(_sudoButton);
207
+
208
+ // Position buttons from right edge
209
+ topBar.Resize += (s, e) => RepositionTopBarButtons();
210
+ this.Load += (s, e) => RepositionTopBarButtons();
211
 
212
  _outputBox = new RichTextBox
213
  {
 
219
  WordWrap = false,
220
  };
221
 
222
+ // Right-click context menu
223
+ var contextMenu = new ContextMenuStrip();
224
+ var copyItem = new ToolStripMenuItem("Copy", null, (s, e) =>
225
+ {
226
+ if (_outputBox.SelectionLength > 0)
227
+ Clipboard.SetText(_outputBox.SelectedText);
228
+ });
229
+ var copyAllItem = new ToolStripMenuItem("Copy All", null, (s, e) =>
230
+ {
231
+ if (!string.IsNullOrEmpty(_outputBox.Text))
232
+ Clipboard.SetText(_outputBox.Text);
233
+ });
234
+ var clearItem = new ToolStripMenuItem("Clear", null, (s, e) =>
235
+ {
236
+ _outputBox.Clear();
237
+ });
238
+ contextMenu.Items.Add(copyItem);
239
+ contextMenu.Items.Add(copyAllItem);
240
+ contextMenu.Items.AddRange(new ToolStripItem[] { new ToolStripSeparator(), clearItem });
241
+ _outputBox.ContextMenuStrip = contextMenu;
242
+
243
  _sessionPanel.Controls.Add(_outputBox);
244
  _sessionPanel.Controls.Add(topBar);
245
 
246
  this.Controls.Add(_loginPanel);
247
  this.Controls.Add(_sessionPanel);
248
+
249
+ // Timer for running indicator
250
+ _runningTimer = new System.Windows.Forms.Timer { Interval = 400 };
251
+ _runningTimer.Tick += (s, e) =>
252
+ {
253
+ if (_isRunning)
254
+ {
255
+ _runningDots = (_runningDots + 1) % 4;
256
+ var dots = new string('.', _runningDots);
257
+ _statusLabel.Text = $"⚡ Running{dots}";
258
+ _statusLabel.ForeColor = Color.Yellow;
259
+ }
260
+ };
261
  }
262
 
263
  private void Connect()
 
292
  _currentUser = user;
293
  _commandCount = 0;
294
 
295
+ // Create persistent shell stream
296
+ _shellStream = _sshClient.CreateShellStream("xterm", 200, 50, 800, 600, 65536);
297
+
298
+ // Wait for initial prompt and detect the pattern
299
+ Thread.Sleep(500);
300
+ var initial = ReadAvailable();
301
+ _promptPattern = DetectPrompt(initial, user);
302
+
303
  this.Invoke(() =>
304
  {
305
+ _storedPassword = _passwordBox.Text;
306
  _passwordBox.Clear();
307
  OnConnected();
308
+ if (!string.IsNullOrEmpty(initial))
309
+ {
310
+ var cleaned = StripAnsiCodes(initial).Trim();
311
+ if (!string.IsNullOrEmpty(cleaned))
312
+ {
313
+ AppendOutput(cleaned, Color.Gray);
314
+ }
315
+ }
316
  });
317
  }
318
  else
 
345
  private void Disconnect()
346
  {
347
  _isConnected = false;
348
+ _penLifted = false;
349
+ _allowSudo = false;
350
+ _storedPassword = "";
351
 
352
  try
353
  {
354
+ _shellStream?.Dispose();
355
  _sshClient?.Disconnect();
356
  _sshClient?.Dispose();
357
  }
358
  catch { }
359
 
360
+ _shellStream = null;
361
  _sshClient = null;
362
  _currentHost = "";
363
  _currentUser = "";
364
+ _promptPattern = "";
365
 
366
  _loginPanel.Visible = true;
367
  _sessionPanel.Visible = false;
368
  _connectButton.Enabled = true;
369
  _connectButton.Text = "Connect";
370
  _statusLabel.Text = "Disconnected";
371
+ UpdatePenButton();
372
+ UpdateSudoButton();
373
+ }
374
+
375
+ private void RepositionTopBarButtons()
376
+ {
377
+ int rightEdge = _sessionPanel.Width - 10;
378
+ _disconnectButton.Location = new Point(rightEdge - _disconnectButton.Width, 6);
379
+ _penButton.Location = new Point(_disconnectButton.Left - _penButton.Width - 5, 6);
380
+ _stayOnTopButton.Location = new Point(_penButton.Left - _stayOnTopButton.Width - 5, 6);
381
+ _sudoButton.Location = new Point(_stayOnTopButton.Left - _sudoButton.Width - 5, 6);
382
+ }
383
+
384
+ private void ToggleStayOnTop()
385
+ {
386
+ _stayOnTop = !_stayOnTop;
387
+ this.TopMost = _stayOnTop;
388
+ _stayOnTopButton.BackColor = _stayOnTop
389
+ ? Color.FromArgb(60, 140, 60) // Green when active
390
+ : Color.FromArgb(80, 80, 85); // Gray when inactive
391
+ _stayOnTopButton.Text = _stayOnTop ? "📌 Pinned" : "📌 Pin";
392
+ }
393
+
394
+ private void TogglePen()
395
+ {
396
+ _penLifted = !_penLifted;
397
+ UpdatePenButton();
398
+
399
+ if (_penLifted)
400
+ {
401
+ AppendOutput("=== PEN LIFTED - Claude paused ===", Color.Orange);
402
+ }
403
+ else
404
+ {
405
+ AppendOutput("=== PEN DOWN - Claude resumed ===", Color.LimeGreen);
406
+ }
407
+ }
408
+
409
+ private void UpdatePenButton()
410
+ {
411
+ _penButton.BackColor = _penLifted
412
+ ? Color.FromArgb(200, 120, 50) // Orange when lifted
413
+ : Color.FromArgb(70, 130, 180); // Blue when down
414
+ _penButton.Text = _penLifted ? "✏️ Pen Up!" : "✏️ Lift Pen";
415
+ }
416
+
417
+ private void ToggleSudo()
418
+ {
419
+ if (string.IsNullOrEmpty(_storedPassword))
420
+ {
421
+ MessageBox.Show("No password stored. Reconnect to enable sudo.", "Sudo", MessageBoxButtons.OK, MessageBoxIcon.Warning);
422
+ return;
423
+ }
424
+
425
+ _allowSudo = !_allowSudo;
426
+ UpdateSudoButton();
427
+
428
+ if (_allowSudo)
429
+ {
430
+ AppendOutput("=== SUDO ENABLED - Password will auto-send ===", Color.FromArgb(180, 100, 255));
431
+ }
432
+ else
433
+ {
434
+ AppendOutput("=== SUDO DISABLED ===", Color.Gray);
435
+ }
436
+ }
437
+
438
+ private void UpdateSudoButton()
439
+ {
440
+ _sudoButton.BackColor = _allowSudo
441
+ ? Color.FromArgb(140, 80, 200) // Purple when enabled
442
+ : Color.FromArgb(80, 80, 85); // Gray when disabled
443
+ _sudoButton.Text = _allowSudo ? "🔐 Sudo On" : "🔓 Sudo";
444
  }
445
 
446
  private void AppendOutput(string? text, Color? color = null)
 
466
 
467
  public string ExecuteCommand(string command)
468
  {
469
+ if (!_isConnected || _sshClient == null || !_sshClient.IsConnected || _shellStream == null)
470
  {
471
  return "ERROR: Not connected. Open SSH Bridge and connect first.";
472
  }
473
 
474
+ lock (_shellLock)
475
+ {
476
+ try
477
+ {
478
+ _commandCount++;
479
+ SetRunning(true);
480
+
481
+ // Clear any pending output
482
+ ReadAvailable();
483
+
484
+ // Block interactive commands that would break the shell
485
+ if (IsBlockedCommand(command, out string blockReason))
486
+ {
487
+ AppendOutput($"> {command}", Color.Gray);
488
+ AppendOutput($"[BLOCKED - Interactive command]\n{blockReason}", Color.Red);
489
+ return $"BLOCKED: Interactive command not supported.\n{blockReason}";
490
+ }
491
+
492
+ // Block sudo commands if sudo not enabled - BEFORE sending
493
+ if (command.TrimStart().StartsWith("sudo") && !_allowSudo)
494
+ {
495
+ AppendOutput($"> {command}", Color.White);
496
+ AppendOutput("[SUDO BLOCKED - Command not sent. Enable sudo button to allow.]", Color.Red);
497
+ return "SUDO_BLOCKED: Sudo commands are disabled. Click the Sudo button to enable.";
498
+ }
499
+
500
+ // Send command
501
+ _shellStream.WriteLine(command);
502
+
503
+ // Wait for output and prompt
504
+ var output = WaitForPrompt(_nextCommandTimeoutMs);
505
+ _nextCommandTimeoutMs = DEFAULT_TIMEOUT_MS; // Reset after use
506
+
507
+ // Handle sudo password prompt (only runs if sudo IS enabled)
508
+ if (command.TrimStart().StartsWith("sudo") && IsSudoPrompt(output))
509
+ {
510
+ AppendOutput("[sudo password auto-sent]", Color.FromArgb(180, 100, 255));
511
+ _shellStream.WriteLine(_storedPassword);
512
+ var sudoOutput = WaitForPrompt(30000);
513
+ output += sudoOutput;
514
+ }
515
+
516
+
517
+ // Clean up output - remove the echoed command and prompt
518
+ output = CleanOutput(output, command);
519
+
520
+ if (string.IsNullOrEmpty(output))
521
+ {
522
+ output = "(no output)";
523
+ }
524
+
525
+ // Tail large output - keep last N lines
526
+ var lines = output.Split('\n');
527
+ string returnOutput;
528
+ if (lines.Length > MAX_OUTPUT_LINES)
529
+ {
530
+ var tailLines = lines.Skip(lines.Length - MAX_OUTPUT_LINES).ToArray();
531
+ returnOutput = $"[... {lines.Length - MAX_OUTPUT_LINES} lines truncated ...]\n" + string.Join("\n", tailLines);
532
+ }
533
+ else
534
+ {
535
+ returnOutput = output;
536
+ }
537
+
538
+ // Output already displayed in real-time by WaitForPrompt
539
+ if (lines.Length > MAX_OUTPUT_LINES)
540
+ {
541
+ AppendOutput($"[Returned last {MAX_OUTPUT_LINES} of {lines.Length} lines to Claude]", Color.Gray);
542
+ }
543
+
544
+ // Final size check
545
+ if (Encoding.UTF8.GetByteCount(returnOutput) > MAX_OUTPUT_BYTES)
546
+ {
547
+ returnOutput = TruncateToBytes(returnOutput, MAX_OUTPUT_BYTES);
548
+ returnOutput += $"\n\n[OUTPUT TRUNCATED - exceeded 500KB limit]";
549
+ }
550
+
551
+ return returnOutput;
552
+ }
553
+ catch (Exception ex)
554
+ {
555
+ var error = $"ERROR: {ex.Message}";
556
+ AppendOutput(error, Color.Red);
557
+ return error;
558
+ }
559
+ finally
560
+ {
561
+ SetRunning(false);
562
+ }
563
+ }
564
+ }
565
+
566
+ private void SetRunning(bool running)
567
+ {
568
+ _isRunning = running;
569
+ if (this.InvokeRequired)
570
+ {
571
+ this.Invoke(() => SetRunning(running));
572
+ return;
573
+ }
574
+
575
+ if (running)
576
+ {
577
+ _runningDots = 0;
578
+ _runningTimer.Start();
579
+ }
580
+ else
581
+ {
582
+ _runningTimer.Stop();
583
+ _statusLabel.Text = $"Connected to {_currentUser}@{_currentHost}";
584
+ _statusLabel.ForeColor = Color.White;
585
+ }
586
+ }
587
+
588
+ private static string TruncateToBytes(string text, int maxBytes)
589
+ {
590
+ if (string.IsNullOrEmpty(text)) return text;
591
+ var bytes = Encoding.UTF8.GetBytes(text);
592
+ if (bytes.Length <= maxBytes) return text;
593
+
594
+ // Find a safe cut point (don't break UTF-8 sequences)
595
+ int cutPoint = maxBytes;
596
+ while (cutPoint > 0 && (bytes[cutPoint] & 0xC0) == 0x80)
597
+ cutPoint--;
598
+
599
+ return Encoding.UTF8.GetString(bytes, 0, cutPoint);
600
+ }
601
+
602
+ private bool IsProcessRunning(int pid)
603
+ {
604
+ if (pid <= 0) return false;
605
  try
606
  {
607
+ var proc = System.Diagnostics.Process.GetProcessById(pid);
608
+ return !proc.HasExited;
609
+ }
610
+ catch
611
+ {
612
+ return false;
613
+ }
614
+ }
 
615
 
616
+ private void SendAbort()
617
+ {
618
+ if (_shellStream != null && _isConnected)
619
+ {
620
+ try
621
  {
622
+ _shellStream.Write("\x03"); // Ctrl+C
623
+ Thread.Sleep(100);
624
+ _shellStream.Write("\x03"); // Send twice for stubborn processes
625
+ AppendOutput("[ABORT SIGNAL SENT - Ctrl+C]", Color.Orange);
626
  }
627
+ catch { }
628
+ }
629
+ }
630
 
631
+ private string ReadAvailable()
632
+ {
633
+ if (_shellStream == null) return "";
634
+
635
+ var sb = new StringBuilder();
636
+ while (_shellStream.DataAvailable)
637
+ {
638
+ var buffer = new byte[4096];
639
+ var read = _shellStream.Read(buffer, 0, buffer.Length);
640
+ if (read > 0)
641
  {
642
+ sb.Append(Encoding.UTF8.GetString(buffer, 0, read));
643
  }
644
+ }
645
+ return sb.ToString();
646
+ }
647
 
648
+ private string WaitForPrompt(int timeoutMs)
649
+ {
650
+ if (_shellStream == null) return "";
651
+
652
+ var sb = new StringBuilder();
653
+ var sw = System.Diagnostics.Stopwatch.StartNew();
654
+ var lastDataTime = sw.ElapsedMilliseconds;
655
+
656
+ // Minimum wait time to ensure we get all output
657
+ const int minWaitMs = 300;
658
+ const int quietTimeMs = 150;
659
+
660
+ while (sw.ElapsedMilliseconds < timeoutMs)
661
+ {
662
+ // Check for pen-lift interrupt - abort immediately
663
+ if (_penLifted)
664
+ {
665
+ try { _shellStream.Write("\x03"); } catch { } // Send Ctrl+C
666
+ Thread.Sleep(200);
667
+ sb.Append(ReadAvailable()); // Grab remaining output
668
+ sb.Append("\n[ABORTED BY USER - Pen lifted]");
669
+ AppendOutput("[Command aborted - Pen lifted]", Color.Orange);
670
+ return sb.ToString();
671
+ }
672
 
673
+ if (_shellStream.DataAvailable)
674
+ {
675
+ var buffer = new byte[4096];
676
+ var read = _shellStream.Read(buffer, 0, buffer.Length);
677
+ if (read > 0)
678
+ {
679
+ var chunk = Encoding.UTF8.GetString(buffer, 0, read);
680
+ sb.Append(chunk);
681
+ lastDataTime = sw.ElapsedMilliseconds;
682
+
683
+ // Real-time display update
684
+ var cleaned = StripAnsiCodes(chunk);
685
+ if (!string.IsNullOrEmpty(cleaned))
686
+ {
687
+ AppendOutput(cleaned, Color.FromArgb(180, 180, 180));
688
+ }
689
+ }
690
+ }
691
+ else
692
+ {
693
+ // Only check for prompt after minimum wait AND data stops flowing
694
+ if (sw.ElapsedMilliseconds > minWaitMs &&
695
+ sw.ElapsedMilliseconds - lastDataTime > quietTimeMs)
696
+ {
697
+ var current = sb.ToString();
698
+ if (LooksLikePrompt(current))
699
+ {
700
+ break;
701
+ }
702
+ }
703
+ Thread.Sleep(5); // Fast polling for responsive terminal
704
+ }
705
+ }
706
+
707
+ return sb.ToString();
708
+ }
709
+
710
+ private bool LooksLikePrompt(string text)
711
+ {
712
+ if (string.IsNullOrEmpty(text)) return false;
713
+
714
+ // Normalize line endings and get last non-empty line
715
+ var normalized = text.Replace("\r\n", "\n").Replace("\r", "\n");
716
+ var lines = normalized.Split('\n');
717
+
718
+ // Find last non-empty line
719
+ string lastLine = "";
720
+ for (int i = lines.Length - 1; i >= 0; i--)
721
+ {
722
+ var trimmed = lines[i].Trim();
723
+ if (!string.IsNullOrEmpty(trimmed))
724
+ {
725
+ lastLine = trimmed;
726
+ break;
727
+ }
728
+ }
729
+
730
+ if (string.IsNullOrEmpty(lastLine)) return false;
731
+
732
+ // Known prompt pattern from connection (most reliable)
733
+ if (!string.IsNullOrEmpty(_promptPattern) && lastLine.Contains(_promptPattern)) return true;
734
+
735
+ // Linux/Unix prompts: user@host patterns ending with $ or #
736
+ if ((lastLine.EndsWith("$") || lastLine.EndsWith("#")) && lastLine.Contains("@")) return true;
737
+
738
+ // Windows cmd prompt: ends with > and contains drive letter or path
739
+ if (lastLine.EndsWith(">") && (lastLine.Contains(":\\") || lastLine.Contains(":/"))) return true;
740
+
741
+ // PowerShell prompt
742
+ if (lastLine.EndsWith("PS>") || lastLine.EndsWith("PS >")) return true;
743
+
744
+ return false;
745
+ }
746
+
747
+ private bool IsSudoPrompt(string text)
748
+ {
749
+ if (string.IsNullOrEmpty(text)) return false;
750
+
751
+ // Only check the last line - prevents false matches on buffer history
752
+ var lines = text.Split('\n');
753
+ var lastLine = lines[lines.Length - 1].Trim().ToLowerInvariant();
754
+
755
+ // Match password prompts
756
+ if (lastLine.EndsWith("password:")) return true;
757
+ if (lastLine.Contains("[sudo]") && lastLine.Contains("password")) return true;
758
+ if (Regex.IsMatch(lastLine, @"password for \w+:")) return true;
759
+
760
+ return false;
761
+ }
762
+
763
+ private bool IsBlockedCommand(string command, out string reason)
764
+ {
765
+ var trimmed = command.Trim();
766
+ var parts = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries);
767
+ var firstWord = parts.FirstOrDefault()?.ToLowerInvariant() ?? "";
768
+ var fullLower = trimmed.ToLowerInvariant();
769
+
770
+ reason = "";
771
+
772
+ switch (firstWord)
773
+ {
774
+ // Editors - always blocked (no non-interactive mode)
775
+ case "nano":
776
+ case "vim":
777
+ case "vi":
778
+ case "nvim":
779
+ case "emacs":
780
+ case "pico":
781
+ case "joe":
782
+ case "mcedit":
783
+ reason = "Interactive editors not supported. Use:\n" +
784
+ "• echo \"content\" > file.txt (create/overwrite)\n" +
785
+ "• echo \"more\" >> file.txt (append)\n" +
786
+ "• cat << 'EOF' > file.txt (multi-line)\n" +
787
+ "• sed -i 's/old/new/g' file.txt (find/replace)";
788
+ return true;
789
+
790
+ // Pagers - always blocked
791
+ case "less":
792
+ case "more":
793
+ reason = "Use 'cat', 'head -n 100', or 'tail -n 100' instead.";
794
+ return true;
795
+
796
+ // TUI monitors - allow batch mode
797
+ case "top":
798
+ if (!fullLower.Contains("-b"))
799
+ {
800
+ reason = "Use 'top -b -n 1' for batch mode, or 'ps aux' instead.";
801
+ return true;
802
+ }
803
+ break;
804
+ case "htop":
805
+ case "btop":
806
+ case "atop":
807
+ case "nmon":
808
+ case "glances":
809
+ reason = "Use 'ps aux', 'free -h', 'df -h', or 'top -b -n 1' instead.";
810
+ return true;
811
+
812
+ // Databases - allow with query flags
813
+ case "mysql":
814
+ if (!fullLower.Contains("-e"))
815
+ {
816
+ reason = "Use 'mysql -e \"SELECT...\"' for non-interactive query.";
817
+ return true;
818
+ }
819
+ break;
820
+ case "psql":
821
+ if (!fullLower.Contains("-c"))
822
+ {
823
+ reason = "Use 'psql -c \"SELECT...\"' for non-interactive query.";
824
+ return true;
825
+ }
826
+ break;
827
+ case "mongo":
828
+ case "mongosh":
829
+ if (!fullLower.Contains("--eval"))
830
+ {
831
+ reason = "Use 'mongosh --eval \"db.collection.find()\"' for non-interactive.";
832
+ return true;
833
+ }
834
+ break;
835
+ case "redis-cli":
836
+ if (parts.Length == 1)
837
+ {
838
+ reason = "Add command: 'redis-cli GET key' or 'redis-cli INFO'.";
839
+ return true;
840
+ }
841
+ break;
842
+ case "sqlite3":
843
+ if (!fullLower.Contains("-cmd") && !trimmed.Contains("\""))
844
+ {
845
+ reason = "Use 'sqlite3 db.sqlite \"SELECT...\"' for non-interactive.";
846
+ return true;
847
+ }
848
+ break;
849
+
850
+ // Terminal multiplexers - always blocked
851
+ case "tmux":
852
+ case "screen":
853
+ case "byobu":
854
+ reason = "Terminal multiplexers not supported in this shell.";
855
+ return true;
856
+
857
+ // File managers - always blocked
858
+ case "mc":
859
+ case "ranger":
860
+ case "nnn":
861
+ reason = "Use 'ls -la', 'find', or 'tree' instead.";
862
+ return true;
863
+
864
+ // Nested shells - block unless -c flag
865
+ case "bash":
866
+ case "zsh":
867
+ case "fish":
868
+ case "sh":
869
+ case "csh":
870
+ case "tcsh":
871
+ if (!fullLower.Contains("-c"))
872
+ {
873
+ reason = "Nested shells not supported. Use 'bash -c \"command\"' for one-offs.";
874
+ return true;
875
+ }
876
+ break;
877
+
878
+ // SSH/remote - always blocked
879
+ case "ssh":
880
+ case "telnet":
881
+ reason = "Nested SSH not supported. Disconnect and connect to the other server.";
882
+ return true;
883
+
884
+ // Man pages - always blocked
885
+ case "man":
886
+ case "info":
887
+ reason = "Use 'command --help' or search online.";
888
+ return true;
889
+
890
+ // FTP - always blocked
891
+ case "ftp":
892
+ case "sftp":
893
+ reason = "Interactive FTP not supported. Use 'scp' or 'curl' instead.";
894
+ return true;
895
  }
896
+
897
+ return false;
898
+ }
899
+
900
+ private string DetectPrompt(string initialOutput, string user)
901
+ {
902
+ if (string.IsNullOrEmpty(initialOutput)) return ">";
903
+
904
+ var lines = initialOutput.Split('\n');
905
+ var lastLine = lines[^1].Trim();
906
+
907
+ // Return the last line as the prompt pattern
908
+ if (lastLine.EndsWith(">") || lastLine.EndsWith("$") || lastLine.EndsWith("#"))
909
  {
910
+ return lastLine;
 
 
911
  }
912
+
913
+ return ">";
914
+ }
915
+
916
+ private string CleanOutput(string output, string command)
917
+ {
918
+ if (string.IsNullOrEmpty(output)) return "";
919
+
920
+ // Strip ANSI escape codes and normalize line endings
921
+ var cleaned = StripAnsiCodes(output);
922
+ // Convert all line endings to \n only
923
+ cleaned = cleaned.Replace("\r\n", "\n").Replace("\r", "\n");
924
+ return cleaned.Trim();
925
+ }
926
+
927
+ private static string StripAnsiCodes(string text)
928
+ {
929
+ if (string.IsNullOrEmpty(text)) return text;
930
+
931
+ // Remove ANSI escape sequences:
932
+ // CSI sequences: ESC [ ... final_byte
933
+ // OSC sequences: ESC ] ... BEL or ESC \
934
+ // Simple escapes: ESC followed by single char
935
+
936
+ var result = text;
937
+
938
+ // CSI sequences (ESC [ or 0x9B followed by parameters and final byte)
939
+ result = Regex.Replace(result, @"\x1B\[[0-9;?]*[A-Za-z]", "");
940
+ result = Regex.Replace(result, @"\x1B\[[0-9;?]*[ -/]*[@-~]", "");
941
+
942
+ // OSC sequences (ESC ] ... BEL or ST)
943
+ result = Regex.Replace(result, @"\x1B\][^\x07\x1B]*(\x07|\x1B\\)?", "");
944
+
945
+ // Other escape sequences
946
+ result = Regex.Replace(result, @"\x1B[()][AB012]", ""); // Character set selection
947
+ result = Regex.Replace(result, @"\x1B[@-_]", ""); // C1 control codes
948
+
949
+ // Clean up any remaining escape chars and control chars (except newline, tab, CR)
950
+ result = Regex.Replace(result, @"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "");
951
+
952
+ return result;
953
  }
954
 
955
  private void StartTcpServer()
 
991
  {
992
  response = _isConnected ? $"CONNECTED:{_currentUser}@{_currentHost}" : "DISCONNECTED";
993
  }
994
+ else if (command == "__PEN_STATUS__")
995
+ {
996
+ response = _penLifted ? "PEN_LIFTED" : "PEN_DOWN";
997
+ }
998
+ else if (command == "__PEN_DOWN__")
999
+ {
1000
+ if (_penLifted)
1001
+ {
1002
+ this.Invoke(() => TogglePen());
1003
+ response = "PEN_LOWERED";
1004
+ }
1005
+ else
1006
+ {
1007
+ response = "PEN_ALREADY_DOWN";
1008
+ }
1009
+ }
1010
+ else if (command == "__ABORT__")
1011
+ {
1012
+ SendAbort();
1013
+ response = _isRunning ? "ABORT_SENT" : "NO_COMMAND_RUNNING";
1014
+ }
1015
+ else if (command == "__IS_RUNNING__")
1016
+ {
1017
+ response = _isRunning ? "RUNNING" : "IDLE";
1018
+ }
1019
+ else if (command.StartsWith("__TIMEOUT__:"))
1020
+ {
1021
+ var secStr = command.Substring(12);
1022
+ if (int.TryParse(secStr, out int seconds) && seconds > 0 && seconds <= 3600)
1023
+ {
1024
+ _nextCommandTimeoutMs = seconds * 1000;
1025
+ response = $"TIMEOUT_SET:{seconds}s";
1026
+ AppendOutput($"[Timeout set to {seconds}s for next command]", Color.Cyan);
1027
+ }
1028
+ else
1029
+ {
1030
+ response = "ERROR: Invalid timeout (1-3600 seconds)";
1031
+ }
1032
+ }
1033
+ else if (command.StartsWith("__KILL_PORT__:"))
1034
+ {
1035
+ var portStr = command.Substring(14);
1036
+ if (int.TryParse(portStr, out int portNum) && portNum > 0 && portNum <= 65535)
1037
+ {
1038
+ // Execute netstat to find PID, then taskkill
1039
+ var killCmd = $"for /f \"tokens=5\" %a in ('netstat -ano ^| findstr :{portNum} ^| findstr LISTENING') do @taskkill /PID %a /F 2>nul & echo Killed PID %a";
1040
+ response = ExecuteCommand(killCmd);
1041
+ }
1042
+ else
1043
+ {
1044
+ response = "ERROR: Invalid port number";
1045
+ }
1046
+ }
1047
+ else if (command.StartsWith("__WRITE_FILE__:"))
1048
+ {
1049
+ // Format: __WRITE_FILE__:C:\path\file.txt|content
1050
+ // Uses | as delimiter since it's not valid in Windows paths
1051
+ // Use <<LF>> for newlines, <<CR>> for carriage returns
1052
+ var rest = command.Substring(15);
1053
+ var pipeIdx = rest.IndexOf('|');
1054
+ if (pipeIdx > 0)
1055
+ {
1056
+ var path = rest.Substring(0, pipeIdx);
1057
+ var content = rest.Substring(pipeIdx + 1)
1058
+ .Replace("<<CRLF>>", "\r\n")
1059
+ .Replace("<<LF>>", "\r\n") // Convert to Windows line endings
1060
+ .Replace("<<CR>>", "\r");
1061
+ try
1062
+ {
1063
+ // Base64 encode to avoid ALL escaping issues
1064
+ var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(content));
1065
+ var psCmd = $"[System.IO.File]::WriteAllText('{path}', [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{base64}')))";
1066
+ ExecuteCommand($"powershell -Command \"{psCmd}\"");
1067
+ response = $"WRITTEN:{path} ({content.Length} chars)";
1068
+ AppendOutput($"[Wrote {content.Length} chars to {path}]", Color.Green);
1069
+ }
1070
+ catch (Exception ex)
1071
+ {
1072
+ response = $"ERROR: {ex.Message}";
1073
+ }
1074
+ }
1075
+ else
1076
+ {
1077
+ response = "ERROR: Use __WRITE_FILE__:path|content (use <<LF>> for newlines)";
1078
+ }
1079
+ }
1080
+ else if (command.StartsWith("__APPEND_FILE__:"))
1081
+ {
1082
+ // Format: __APPEND_FILE__:C:\path\file.txt|content
1083
+ var rest = command.Substring(16);
1084
+ var pipeIdx = rest.IndexOf('|');
1085
+ if (pipeIdx > 0)
1086
+ {
1087
+ var path = rest.Substring(0, pipeIdx);
1088
+ var content = rest.Substring(pipeIdx + 1)
1089
+ .Replace("<<CRLF>>", "\r\n")
1090
+ .Replace("<<LF>>", "\r\n")
1091
+ .Replace("<<CR>>", "\r");
1092
+ try
1093
+ {
1094
+ // Base64 encode to avoid ALL escaping issues
1095
+ var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(content));
1096
+ var psCmd = $"[System.IO.File]::AppendAllText('{path}', [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{base64}')))";
1097
+ ExecuteCommand($"powershell -Command \"{psCmd}\"");
1098
+ response = $"APPENDED:{path} ({content.Length} chars)";
1099
+ AppendOutput($"[Appended {content.Length} chars to {path}]", Color.Green);
1100
+ }
1101
+ catch (Exception ex)
1102
+ {
1103
+ response = $"ERROR: {ex.Message}";
1104
+ }
1105
+ }
1106
+ else
1107
+ {
1108
+ response = "ERROR: Use __APPEND_FILE__:path|content (use <<LF>> for newlines)";
1109
+ }
1110
+ }
1111
+ else if (command.StartsWith("__SPAWN__:"))
1112
+ {
1113
+ // Format: __SPAWN__:name:command
1114
+ var rest = command.Substring(10);
1115
+ var colonIdx = rest.IndexOf(':');
1116
+ if (colonIdx > 0)
1117
+ {
1118
+ var name = rest.Substring(0, colonIdx);
1119
+ var spawnCmd = rest.Substring(colonIdx + 1);
1120
+ // Run in background, capture PID via PowerShell
1121
+ var psCmd = $"$p = Start-Process -FilePath cmd -ArgumentList '/c {spawnCmd.Replace("'", "''")}' -PassThru -WindowStyle Hidden; $p.Id";
1122
+ var pidResult = ExecuteCommand($"powershell -Command \"{psCmd}\"");
1123
+ if (int.TryParse(pidResult.Trim().Split('\n').Last().Trim(), out int pid))
1124
+ {
1125
+ _spawnedProcesses[name] = pid;
1126
+ response = $"SPAWNED:{name}:PID={pid}";
1127
+ }
1128
+ else
1129
+ {
1130
+ response = $"SPAWN_STARTED:{name} (PID unknown)";
1131
+ }
1132
+ }
1133
+ else
1134
+ {
1135
+ response = "ERROR: Use __SPAWN__:name:command";
1136
+ }
1137
+ }
1138
+ else if (command == "__LIST_SPAWNED__")
1139
+ {
1140
+ if (_spawnedProcesses.Count == 0)
1141
+ {
1142
+ response = "NO_SPAWNED_PROCESSES";
1143
+ }
1144
+ else
1145
+ {
1146
+ var sb = new StringBuilder();
1147
+ foreach (var kvp in _spawnedProcesses)
1148
+ {
1149
+ // Check if still running
1150
+ var checkCmd = $"tasklist /FI \"PID eq {kvp.Value}\" /NH 2>nul | findstr {kvp.Value}";
1151
+ var checkResult = ExecuteCommand(checkCmd);
1152
+ var status = checkResult.Contains(kvp.Value.ToString()) ? "RUNNING" : "STOPPED";
1153
+ sb.AppendLine($"{kvp.Key}: PID={kvp.Value} ({status})");
1154
+ }
1155
+ response = sb.ToString().TrimEnd();
1156
+ }
1157
+ }
1158
+ else if (command == "__TAIL__")
1159
+ {
1160
+ // Get last 50 lines from the output textbox
1161
+ string text = "";
1162
+ this.Invoke(() => text = _outputBox.Text);
1163
+ var lines = text.Split('\n');
1164
+ var tail = lines.Skip(Math.Max(0, lines.Length - 50)).ToArray();
1165
+ response = string.Join("\n", tail);
1166
+ }
1167
+ else if (command.StartsWith("__KILL_SPAWNED__:"))
1168
+ {
1169
+ var name = command.Substring(17);
1170
+ if (_spawnedProcesses.TryGetValue(name, out int pid))
1171
+ {
1172
+ ExecuteCommand($"taskkill /PID {pid} /F /T 2>nul");
1173
+ _spawnedProcesses.Remove(name);
1174
+ response = $"KILLED:{name}:PID={pid}";
1175
+ }
1176
+ else
1177
+ {
1178
+ response = $"ERROR: No spawned process named '{name}'";
1179
+ }
1180
+ }
1181
  else if (command.StartsWith("__PREFILL__:"))
1182
  {
1183
  // Format: __PREFILL__:host:port:user:password
 
1211
  this.Invoke(() => Connect());
1212
  response = "CONNECTING";
1213
  }
1214
+ else if (_penLifted)
1215
+ {
1216
+ // Block command execution when pen is lifted
1217
+ AppendOutput($"> {command}", Color.Gray);
1218
+ AppendOutput("[BLOCKED - Pen lifted by user]", Color.Orange);
1219
+ response = "PEN_LIFTED: User has paused command execution. Use SshPenDown to resume, or wait for user to click 'Lift Pen' button again.";
1220
+ }
1221
  else
1222
  {
1223
  response = ExecuteCommand(command);
1224
  }
1225
  // Encode newlines so they survive the single-line protocol
1226
+ response = response.Replace("\r\n", "<<CRLF>>").Replace("\n", "<<LF>>").Replace("\r", "<<CR>>");
1227
  writer.WriteLine(response);
1228
  }
1229
  }