Skip to content

Commit f3bd64a

Browse files
committed
feat: add support for non-interactive command running
This is really just so I can run MCPs over SSH
1 parent 0e6f654 commit f3bd64a

5 files changed

Lines changed: 304 additions & 171 deletions

File tree

src/Channel.php

Lines changed: 42 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Whisp;
66

7+
use Whisp\Command\PtyCommandRunner;
8+
use Whisp\Command\CommandRunner;
79
use Whisp\Concerns\WritesLogs;
810
use Whisp\Loggers\NullLogger;
911
use Whisp\Values\TerminalInfo;
@@ -22,10 +24,9 @@ class Channel
2224

2325
private bool $outputClosed = false;
2426

25-
private $process = null; // Process resource
26-
2727
private ?int $childPid = null;
2828
private array $pendingEnv = [];
29+
private ?CommandRunner $commandRunner = null;
2930

3031
public function __construct(
3132
public readonly int $recipientChannel, // Their channel ID
@@ -107,19 +108,15 @@ public function createPty(): bool
107108
}
108109

109110
/**
110-
* Read data from the PTY and forward it to the SSH client
111+
* Read data from the command and forward it to the SSH client
111112
* This should be called regularly by the connection's main loop
112113
*
113114
* @return int The number of bytes written to the client
114115
*/
115-
public function forwardFromPty(): int
116+
public function forwardFromCommand(): int
116117
{
117-
if (! $this->pty) {
118-
return 0;
119-
}
120-
121118
// Read and forward immediately
122-
$chunk = $this->pty->read(8192);
119+
$chunk = $this->commandRunner->read(8192);
123120
if ($chunk === '') {
124121
return 0;
125122
}
@@ -128,42 +125,36 @@ public function forwardFromPty(): int
128125
}
129126

130127
/**
131-
* Start a command connected to the PTY
128+
* Start a command - either with a PTY or without based on whether there's a Pty.
132129
*/
133130
public function startCommand(string $command): int|bool
134131
{
135-
if (! $this->pty) {
136-
$this->debug('No PTY, creating one');
137-
if (! $this->createPty()) {
138-
$this->error('Failed to create PTY');
132+
$this->commandRunner = ($this->pty) ? new PtyCommandRunner($this->pty) : new CommandRunner;
133+
$this->commandRunner->setLogger($this->logger);
139134

140-
return false;
141-
}
142-
}
135+
$this->logger->debug(sprintf('Starting command: %s, PTY: %s', $command, $this->pty ? 'yes' : 'no'));
143136

144137
// Set environment variables
145-
$this->setEnvironmentVariable('PATH', getenv('PATH'));
138+
$this->commandRunner->env('PATH', getenv('PATH'));
146139
if ($this->terminalInfo) {
147-
$this->setEnvironmentVariable('TERM', $this->terminalInfo->term);
148-
$this->setEnvironmentVariable('WHISP_TERM', $this->terminalInfo->term);
149-
$this->setEnvironmentVariable('WHISP_COLS', (string) $this->terminalInfo->widthChars);
150-
$this->setEnvironmentVariable('WHISP_ROWS', (string) $this->terminalInfo->heightRows);
151-
$this->setEnvironmentVariable('WHISP_WIDTH_PX', (string) $this->terminalInfo->widthPixels);
152-
$this->setEnvironmentVariable('WHISP_HEIGHT_PX', (string) $this->terminalInfo->heightPixels);
140+
$this->commandRunner->env('TERM', $this->terminalInfo->term);
141+
$this->commandRunner->env('WHISP_TERM', $this->terminalInfo->term);
142+
$this->commandRunner->env('WHISP_COLS', (string) $this->terminalInfo->widthChars);
143+
$this->commandRunner->env('WHISP_ROWS', (string) $this->terminalInfo->heightRows);
144+
$this->commandRunner->env('WHISP_WIDTH_PX', (string) $this->terminalInfo->widthPixels);
145+
$this->commandRunner->env('WHISP_HEIGHT_PX', (string) $this->terminalInfo->heightPixels);
153146
}
154147

155148
// Env added while we didn't have a PTY, but now we do, so let's ensure we set it
156149
foreach ($this->pendingEnv as $name => $value) {
157-
$this->setEnvironmentVariable($name, $value);
150+
$this->commandRunner->env($name, $value);
158151
}
159152

160153
// Log environment variables for debugging
161-
$this->debug('Command environment variables: '.json_encode($this->pty->getEnvironment()));
162-
163-
$this->debug('Starting command: '.$command);
154+
$this->debug('Command environment variables: '.json_encode($this->commandRunner->getEnvironment()));
164155

165156
// Start the command and store the child PID first
166-
$this->childPid = $this->pty->startCommand($command);
157+
$this->childPid = $this->commandRunner->start($command);
167158
if ($this->childPid === false) {
168159
$this->error('Failed to start command');
169160

@@ -191,7 +182,6 @@ public function startCommand(string $command): int|bool
191182
$this->connection->sendExitStatus($this, $exitCode); // TODO: This should be in Channel, not Connection. Weird back and forth of responsibilities in Connection and Channel!
192183
}
193184

194-
$this->process = null;
195185
$this->childPid = null;
196186
}
197187
});
@@ -202,13 +192,9 @@ public function startCommand(string $command): int|bool
202192
/**
203193
* Write data from SSH client to the running command via PTY
204194
*/
205-
public function writeToPty(string $data): int
195+
public function writeToCommand(string $data): int
206196
{
207-
if (! $this->pty) {
208-
return 0;
209-
}
210-
211-
return $this->pty->write($data);
197+
return $this->commandRunner->write($data);
212198
}
213199

214200
/**
@@ -261,23 +247,15 @@ public function getPty(): ?Pty
261247
*/
262248
public function stopCommand(): void
263249
{
264-
if ($this->pty) {
265-
$this->pty->stopCommand();
250+
if (!is_null($this->commandRunner)) {
251+
$this->commandRunner->stop();
266252
}
267253

268254
if ($this->childPid) {
269255
$this->debug('Stopping command with PID: '.$this->childPid);
270256
posix_kill($this->childPid, SIGTERM);
271257
$this->childPid = null;
272258
}
273-
274-
if ($this->process && is_resource($this->process)) {
275-
$this->debug('Stopping command with PID: '.$this->childPid);
276-
proc_terminate($this->process, SIGTERM);
277-
proc_close($this->process);
278-
}
279-
280-
$this->process = null;
281259
}
282260

283261
/**
@@ -310,12 +288,25 @@ public function setConnection(Connection $connection): void
310288
*/
311289
public function setEnvironmentVariable(string $name, string $value): void
312290
{
313-
if (!is_null($this->pty)) {
314-
$this->pty->setEnvironmentVariable($name, $value);
315-
} else {
316-
$this->pendingEnv[$name] = $value;
291+
$this->pendingEnv[$name] = $value;
292+
$this->debug("Set environment variable: {$name}={$value}");
293+
}
294+
295+
public function commandIsRunning(): bool
296+
{
297+
if (! $this->commandRunner) {
298+
return false;
317299
}
318300

319-
$this->debug("Set environment variable: {$name}={$value}");
301+
return $this->commandRunner->isRunning();
302+
}
303+
304+
public function getCommandStdout()
305+
{
306+
if (! $this->commandRunner) {
307+
return null;
308+
}
309+
310+
return $this->commandRunner->getStdout();
320311
}
321312
}

src/Command/CommandRunner.php

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Whisp\Command;
6+
7+
use Whisp\Concerns\WritesLogs;
8+
9+
class CommandRunner
10+
{
11+
use WritesLogs;
12+
13+
protected array $env = [];
14+
protected ?int $childPid = null;
15+
protected bool $running = false;
16+
protected $process = null;
17+
protected array $pipes = [];
18+
19+
public function isRunning(): bool
20+
{
21+
return $this->running;
22+
}
23+
24+
public function env(string $name, string $value): void
25+
{
26+
$this->env[$name] = $value;
27+
}
28+
29+
public function getEnvironment(): array
30+
{
31+
return $this->env;
32+
}
33+
34+
public function read(int $length = 2048): string
35+
{
36+
return fread($this->getStdout(), $length);
37+
}
38+
39+
public function write(string $data): int
40+
{
41+
return fwrite($this->getStdin(), $data);
42+
}
43+
44+
public function getStdin()
45+
{
46+
return $this->pipes[0];
47+
}
48+
49+
public function getStdout()
50+
{
51+
return $this->pipes[1];
52+
}
53+
54+
public function getStderr()
55+
{
56+
return $this->pipes[2];
57+
}
58+
59+
/**
60+
* Start the command and return the child PID
61+
*/
62+
public function start(string $command): int|false
63+
{
64+
$descriptorSpec = [
65+
0 => ['pipe', 'r'],
66+
1 => ['pipe', 'w'],
67+
2 => ['pipe', 'w'],
68+
];
69+
70+
$this->process = proc_open(
71+
$command,
72+
$descriptorSpec,
73+
$this->pipes,
74+
null,
75+
$this->env
76+
);
77+
78+
if (! is_resource($this->process)) {
79+
throw new \RuntimeException('Failed to start process: '.error_get_last()['message'] ?? 'unknown error');
80+
}
81+
82+
$status = proc_get_status($this->process);
83+
$this->childPid = $status['pid'];
84+
$this->running = true;
85+
86+
$this->info(sprintf('Started command: %s with pid %d', $command, $this->childPid));
87+
88+
// Make master stream non-blocking for reading from the process
89+
stream_set_blocking($this->pipes[0], false);
90+
91+
// Verify process is still running
92+
$status = proc_get_status($this->process);
93+
if (! $status['running']) {
94+
throw new \RuntimeException("Process exited immediately with status {$status['exitcode']}");
95+
}
96+
97+
// Set up SIGCHLD handler
98+
pcntl_signal(SIGCHLD, function ($signo) {
99+
if ($signo === SIGCHLD) {
100+
$status = 0;
101+
$pid = pcntl_wait($status);
102+
if ($pid > 0) {
103+
$this->debug(sprintf('proc_open child process %d exited with status %d', $pid, $status['exitcode']));
104+
$this->running = false;
105+
$this->process = null;
106+
$this->childPid = null;
107+
}
108+
}
109+
});
110+
111+
return $this->childPid;
112+
}
113+
114+
public function stop(): void
115+
{
116+
if ($this->process && is_resource($this->process)) {
117+
proc_terminate($this->process, SIGTERM);
118+
if (is_resource($this->process)) {
119+
proc_close($this->process);
120+
}
121+
}
122+
123+
$this->running = false;
124+
$this->process = null;
125+
$this->childPid = null;
126+
}
127+
}

0 commit comments

Comments
 (0)