@neoline/hostbridge 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. package/src/server_control_mcp/transports/pty.py +0 -434
@@ -4,15 +4,62 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import hashlib
7
+ import math
7
8
  import uuid
8
- from collections.abc import Iterable, Sequence
9
+ from collections import deque
10
+ from collections.abc import AsyncGenerator, Sequence
9
11
  from contextlib import suppress
10
12
  from dataclasses import dataclass
11
- from pathlib import Path
12
13
 
13
14
  import asyncssh
14
15
 
15
- from .base import ExecResult, TransferResult, TransportCapabilities
16
+ from ..async_lifecycle import (
17
+ finish_task_before_cancellation,
18
+ finish_task_despite_cancellation,
19
+ stop_input_after_peer_eof,
20
+ )
21
+ from .base import (
22
+ ByteStream,
23
+ ExecResult,
24
+ ShellAttachment,
25
+ ShellReadResult,
26
+ ShellTerminalResult,
27
+ TransferResult,
28
+ TransportCapabilities,
29
+ TransportError,
30
+ iterate_byte_stream,
31
+ )
32
+
33
+
34
+ class _CombinedOutputBuffer:
35
+ def __init__(self, limit: int) -> None:
36
+ self._limit = limit
37
+ self._segments: deque[tuple[bool, bytes]] = deque()
38
+ self._size = 0
39
+
40
+ def append(self, chunk: bytes, *, stderr: bool) -> None:
41
+ if not chunk:
42
+ return
43
+ self._segments.append((stderr, bytes(chunk)))
44
+ self._size += len(chunk)
45
+ excess = self._size - self._limit
46
+ while excess > 0:
47
+ segment_stderr, segment = self._segments[0]
48
+ if len(segment) <= excess:
49
+ self._segments.popleft()
50
+ self._size -= len(segment)
51
+ excess -= len(segment)
52
+ else:
53
+ self._segments[0] = (segment_stderr, segment[excess:])
54
+ self._size -= excess
55
+ excess = 0
56
+
57
+ def outputs(self) -> tuple[bytes, bytes]:
58
+ stdout = bytearray()
59
+ stderr = bytearray()
60
+ for is_stderr, segment in self._segments:
61
+ (stderr if is_stderr else stdout).extend(segment)
62
+ return bytes(stdout), bytes(stderr)
16
63
 
17
64
 
18
65
  @dataclass(frozen=True, slots=True)
@@ -21,21 +68,41 @@ class SshConnectionConfig:
21
68
  port: int = 22
22
69
  username: str | None = None
23
70
  client_keys: Sequence[str] = ()
24
- known_hosts: str | None = None
71
+ known_hosts: str | None | tuple[()] = ()
72
+ tunnel: str | None = None
73
+ password: str | None = None
74
+
25
75
 
76
+ DEFAULT_SFTP_CLEANUP_TIMEOUT_SECONDS = 5.0
26
77
 
27
- class SshTransport:
78
+
79
+ class AsyncSshConnection:
28
80
  capabilities = TransportCapabilities(
29
81
  separate_stderr=True,
30
82
  native_binary_transfer=True,
31
83
  parallel_channels=8,
32
84
  )
33
85
 
34
- def __init__(self, connection: asyncssh.SSHClientConnection) -> None:
86
+ def __init__(
87
+ self,
88
+ connection: asyncssh.SSHClientConnection,
89
+ *,
90
+ sftp_cleanup_timeout: float = DEFAULT_SFTP_CLEANUP_TIMEOUT_SECONDS,
91
+ ) -> None:
92
+ if (
93
+ not isinstance(sftp_cleanup_timeout, (int, float))
94
+ or isinstance(sftp_cleanup_timeout, bool)
95
+ or not math.isfinite(sftp_cleanup_timeout)
96
+ or sftp_cleanup_timeout <= 0
97
+ ):
98
+ raise ValueError("sftp_cleanup_timeout must be a finite positive number")
35
99
  self._connection = connection
100
+ self._sftp_cleanup_timeout = float(sftp_cleanup_timeout)
101
+ self._shells: dict[str, asyncssh.SSHClientProcess[bytes]] = {}
102
+ self._shell_creation_lock = asyncio.Lock()
36
103
 
37
104
  @classmethod
38
- async def connect(cls, config: SshConnectionConfig) -> SshTransport:
105
+ async def connect(cls, config: SshConnectionConfig, *, connect_timeout: float | None = None) -> AsyncSshConnection:
39
106
  if not config.host.strip():
40
107
  raise ValueError("SSH host must not be empty")
41
108
  if not 1 <= config.port <= 65535:
@@ -46,6 +113,9 @@ class SshTransport:
46
113
  username=config.username,
47
114
  client_keys=list(config.client_keys),
48
115
  known_hosts=config.known_hosts,
116
+ tunnel=config.tunnel,
117
+ password=config.password,
118
+ connect_timeout=connect_timeout,
49
119
  encoding=None,
50
120
  )
51
121
  return cls(connection)
@@ -54,7 +124,7 @@ class SshTransport:
54
124
  self,
55
125
  command: str,
56
126
  *,
57
- stdin: Iterable[bytes] | None,
127
+ stdin: ByteStream | None,
58
128
  timeout: float,
59
129
  output_limit: int,
60
130
  ) -> ExecResult:
@@ -64,121 +134,181 @@ class SshTransport:
64
134
  raise ValueError("timeout must be positive")
65
135
  if output_limit <= 0:
66
136
  raise ValueError("output_limit must be positive")
67
- process = await self._connection.create_process(command, encoding=None)
68
- stdout_task = asyncio.create_task(self._read_stream(process.stdout, output_limit))
69
- stderr_task = asyncio.create_task(self._read_stream(process.stderr, output_limit))
137
+ open_task: asyncio.Future[asyncssh.SSHClientProcess[bytes]] = asyncio.ensure_future(
138
+ self._connection.create_process(command, encoding=None)
139
+ )
140
+ try:
141
+ process = await open_task
142
+ except asyncio.CancelledError:
143
+ if not open_task.done():
144
+ open_task.cancel()
145
+ await asyncio.gather(open_task, return_exceptions=True)
146
+ raise
147
+ output = _CombinedOutputBuffer(output_limit)
148
+ stdout_task = asyncio.create_task(self._read_stream(process.stdout, output, stderr=False))
149
+ stderr_task = asyncio.create_task(self._read_stream(process.stderr, output, stderr=True))
70
150
  input_task = asyncio.create_task(self._write_stdin(process, stdin))
151
+ wait_task = asyncio.create_task(process.wait_closed())
152
+ tasks = (stdout_task, stderr_task, input_task, wait_task)
71
153
  try:
72
- await asyncio.wait_for(process.wait_closed(), timeout=timeout)
73
- await input_task
74
- stdout, stderr = await asyncio.gather(stdout_task, stderr_task)
154
+ await asyncio.wait_for(asyncio.gather(asyncio.shield(wait_task), input_task), timeout=timeout)
155
+ await asyncio.gather(stdout_task, stderr_task)
156
+ stdout, stderr = output.outputs()
75
157
  return ExecResult(process.exit_status, stdout, stderr)
76
158
  except TimeoutError:
77
- process.terminate()
159
+ cleanup = asyncio.create_task(self._abort_process(process, tasks))
160
+ await finish_task_before_cancellation(cleanup)
161
+ stdout, stderr = output.outputs()
162
+ return ExecResult(None, stdout, stderr, timed_out=True)
163
+ except BaseException:
164
+ cleanup = asyncio.create_task(self._abort_process(process, tasks))
165
+ await finish_task_despite_cancellation(cleanup)
166
+ raise
167
+
168
+ @staticmethod
169
+ async def _abort_process(
170
+ process: asyncssh.SSHClientProcess[bytes],
171
+ tasks: tuple[asyncio.Task[None], asyncio.Task[None], asyncio.Task[None], asyncio.Task[None]],
172
+ ) -> None:
173
+ process.terminate()
174
+ process.close()
175
+ wait_task = tasks[-1]
176
+ try:
177
+ await asyncio.wait_for(asyncio.shield(wait_task), timeout=1)
178
+ except Exception:
179
+ process.kill()
180
+ process.close()
78
181
  with suppress(Exception):
79
- await process.wait_closed()
80
- for task in (stdout_task, stderr_task, input_task):
182
+ await asyncio.wait_for(asyncio.shield(wait_task), timeout=1)
183
+ for task in tasks:
184
+ if not task.done():
81
185
  task.cancel()
82
- await asyncio.gather(stdout_task, stderr_task, input_task, return_exceptions=True)
83
- return ExecResult(None, b"", b"", timed_out=True)
186
+ await asyncio.gather(*tasks, return_exceptions=True)
84
187
 
85
188
  @staticmethod
86
- async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin: Iterable[bytes] | None) -> None:
189
+ async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin: ByteStream | None) -> None:
87
190
  if stdin is not None:
88
- for chunk in stdin:
89
- if not isinstance(chunk, bytes):
90
- raise TypeError("stdin chunks must be bytes")
191
+ async for chunk in iterate_byte_stream(stdin, label="stdin"):
91
192
  process.stdin.write(chunk)
92
193
  await process.stdin.drain()
93
194
  process.stdin.write_eof()
94
195
 
95
196
  @staticmethod
96
- async def _read_stream(stream, output_limit: int) -> bytes:
97
- output = bytearray()
197
+ async def _read_stream(stream, output: _CombinedOutputBuffer, *, stderr: bool) -> None:
98
198
  while True:
99
199
  chunk = await stream.read(64 * 1024)
100
200
  if not chunk:
101
201
  break
102
- output.extend(chunk)
103
- if len(output) > output_limit:
104
- del output[: len(output) - output_limit]
105
- return bytes(output)
202
+ output.append(bytes(chunk), stderr=stderr)
106
203
 
107
- async def upload_file(
204
+ async def upload(
108
205
  self,
109
- local_path: Path | str,
206
+ chunks: ByteStream,
110
207
  remote_path: str,
111
208
  *,
112
- chunk_size: int = 256 * 1024,
113
- mode: int = 0o644,
209
+ mode: int,
210
+ expected_size: int | None,
211
+ expected_sha256: str | None,
114
212
  ) -> TransferResult:
115
- source = Path(local_path)
116
- self._validate_chunk_size(chunk_size)
117
213
  self._validate_remote_path(remote_path)
118
214
  temporary = f"{remote_path}.hostbridge-{uuid.uuid4().hex}.tmp"
119
215
  digest = hashlib.sha256()
120
216
  transferred = 0
121
217
  sftp = await self._connection.start_sftp_client()
122
218
  try:
123
- local_file = source.open("rb")
124
- try:
125
- async with sftp.open(temporary, "wb") as remote_file:
126
- while True:
127
- chunk = await asyncio.to_thread(local_file.read, chunk_size)
128
- if not chunk:
129
- break
130
- await remote_file.write(chunk)
131
- digest.update(chunk)
132
- transferred += len(chunk)
133
- finally:
134
- local_file.close()
219
+ async with sftp.open(temporary, "wb", encoding=None) as remote_file:
220
+ async for chunk in iterate_byte_stream(chunks, label="upload"):
221
+ await remote_file.write(chunk)
222
+ digest.update(chunk)
223
+ transferred += len(chunk)
224
+ if expected_size is not None and transferred != expected_size:
225
+ raise TransportError(f"upload size mismatch: expected {expected_size}, received {transferred}")
226
+ actual_sha256 = digest.hexdigest()
227
+ if expected_sha256 is not None and actual_sha256.lower() != expected_sha256.lower():
228
+ raise TransportError(
229
+ f"upload SHA-256 mismatch: expected {expected_sha256.lower()}, received {actual_sha256}"
230
+ )
135
231
  await sftp.chmod(temporary, mode)
136
232
  await sftp.rename(temporary, remote_path)
137
- except Exception:
138
- with suppress(Exception):
139
- await sftp.remove(temporary)
233
+ except BaseException as primary_error:
234
+ cleanup = asyncio.create_task(self._finish_sftp(sftp, temporary=temporary))
235
+ try:
236
+ await finish_task_despite_cancellation(cleanup)
237
+ except BaseException as cleanup_error:
238
+ primary_error.add_note(f"SFTP temporary cleanup failed: {cleanup_error}")
140
239
  raise
141
- finally:
142
- sftp.exit()
240
+ cleanup = asyncio.create_task(self._finish_sftp(sftp))
241
+ await finish_task_before_cancellation(cleanup)
143
242
  return TransferResult(transferred, digest.hexdigest())
144
243
 
145
- async def download_file(
146
- self,
147
- remote_path: str,
148
- local_path: Path | str,
149
- *,
150
- chunk_size: int = 256 * 1024,
151
- ) -> TransferResult:
152
- destination = Path(local_path)
244
+ async def _finish_sftp(self, sftp: asyncssh.SFTPClient, *, temporary: str | None = None) -> None:
245
+ removed = await self._close_sftp_channel(sftp, temporary=temporary)
246
+ if temporary is None or removed:
247
+ return
248
+
249
+ replacement: asyncssh.SFTPClient | None = None
250
+ try:
251
+ async with asyncio.timeout(self._sftp_cleanup_timeout):
252
+ replacement = await self._connection.start_sftp_client()
253
+ except Exception as exc:
254
+ self._abort_connection()
255
+ raise TransportError(f"could not reopen SFTP to remove temporary file {temporary!r}") from exc
256
+
257
+ if await self._close_sftp_channel(replacement, temporary=temporary):
258
+ return
259
+ self._abort_connection()
260
+ raise TransportError(f"could not remove SFTP temporary file {temporary!r}")
261
+
262
+ async def _close_sftp_channel(self, sftp: asyncssh.SFTPClient, *, temporary: str | None) -> bool:
263
+ exit_sent = False
264
+ removed = temporary is None
265
+ try:
266
+ async with asyncio.timeout(self._sftp_cleanup_timeout):
267
+ if temporary is not None:
268
+ try:
269
+ await sftp.remove(temporary)
270
+ except asyncssh.SFTPNoSuchFile:
271
+ removed = True
272
+ else:
273
+ removed = True
274
+ sftp.exit()
275
+ exit_sent = True
276
+ with suppress(Exception):
277
+ await sftp.wait_closed()
278
+ except Exception:
279
+ pass
280
+ finally:
281
+ if not exit_sent:
282
+ with suppress(Exception):
283
+ sftp.exit()
284
+ return removed
285
+
286
+ def _abort_connection(self) -> None:
287
+ try:
288
+ self._connection.abort()
289
+ except Exception as exc:
290
+ try:
291
+ self._connection.close()
292
+ except Exception as fallback_error:
293
+ fallback_error.add_note(f"SSH connection abort failed: {exc}")
294
+ raise TransportError(
295
+ "could not terminate SSH connection after SFTP cleanup failure"
296
+ ) from fallback_error
297
+
298
+ async def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]:
153
299
  self._validate_chunk_size(chunk_size)
154
300
  self._validate_remote_path(remote_path)
155
- temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
156
- digest = hashlib.sha256()
157
- transferred = 0
158
301
  sftp = await self._connection.start_sftp_client()
159
302
  try:
160
- destination.parent.mkdir(parents=True, exist_ok=True)
161
- local_file = temporary.open("wb")
162
- try:
163
- async with sftp.open(remote_path, "rb") as remote_file:
164
- while True:
165
- raw_chunk = await remote_file.read(chunk_size)
166
- if not raw_chunk:
167
- break
168
- chunk = raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
169
- await asyncio.to_thread(local_file.write, chunk)
170
- digest.update(chunk)
171
- transferred += len(chunk)
172
- finally:
173
- local_file.close()
174
- temporary.chmod(0o600)
175
- temporary.replace(destination)
176
- except Exception:
177
- temporary.unlink(missing_ok=True)
178
- raise
303
+ async with sftp.open(remote_path, "rb", encoding=None) as remote_file:
304
+ while True:
305
+ raw_chunk = await remote_file.read(chunk_size)
306
+ if not raw_chunk:
307
+ break
308
+ yield raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
179
309
  finally:
180
- sftp.exit()
181
- return TransferResult(transferred, digest.hexdigest())
310
+ cleanup = asyncio.create_task(self._finish_sftp(sftp))
311
+ await finish_task_before_cancellation(cleanup)
182
312
 
183
313
  @staticmethod
184
314
  def _validate_chunk_size(chunk_size: int) -> None:
@@ -190,6 +320,107 @@ class SshTransport:
190
320
  if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
191
321
  raise ValueError("remote_path must be a non-empty path without NUL bytes")
192
322
 
323
+ async def shell_write(self, shell_id: str, data: bytes) -> None:
324
+ if not isinstance(data, bytes):
325
+ raise TypeError("shell data must be bytes")
326
+ process = await self._ensure_shell(shell_id)
327
+ process.stdin.write(data)
328
+ await process.stdin.drain()
329
+
330
+ async def shell_read(self, shell_id: str, *, timeout: float | None, max_bytes: int) -> ShellReadResult:
331
+ if timeout is not None and timeout < 0:
332
+ raise ValueError("timeout must be non-negative")
333
+ if max_bytes <= 0:
334
+ raise ValueError("max_bytes must be positive")
335
+ process = await self._ensure_shell(shell_id)
336
+ if timeout is None:
337
+ data = await process.stdout.read(max_bytes)
338
+ else:
339
+ try:
340
+ data = await asyncio.wait_for(process.stdout.read(max_bytes), timeout=timeout)
341
+ except TimeoutError:
342
+ data = b""
343
+ return ShellReadResult(bytes(data), process.exit_status is None and not self._connection.is_closed())
344
+
345
+ async def attach_shell(self, shell_id: str, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment:
346
+ process = await self._ensure_shell(shell_id)
347
+ terminal = asyncio.get_running_loop().create_future()
348
+
349
+ async def send_input() -> None:
350
+ async for chunk in iterate_byte_stream(stdin, label="shell input"):
351
+ if len(chunk) > max_bytes:
352
+ raise TransportError(f"shell input chunk exceeds {max_bytes} bytes")
353
+ process.stdin.write(chunk)
354
+ await process.stdin.drain()
355
+ process.stdin.write_eof()
356
+
357
+ async def output() -> AsyncGenerator[bytes, None]:
358
+ input_task = asyncio.create_task(send_input(), name=f"hostbridge-native-shell-input-{shell_id}")
359
+ try:
360
+ while data := await process.stdout.read(max_bytes):
361
+ yield bytes(data)
362
+ await stop_input_after_peer_eof(input_task)
363
+ await process.wait_closed()
364
+ terminal.set_result(_ssh_shell_terminal(process))
365
+ except BaseException as exc:
366
+ if not terminal.done():
367
+ terminal.set_exception(exc)
368
+ terminal.exception()
369
+ raise
370
+ finally:
371
+ if not input_task.done():
372
+ input_task.cancel()
373
+ cleanup = asyncio.gather(input_task, return_exceptions=True)
374
+ await finish_task_before_cancellation(cleanup)
375
+
376
+ return ShellAttachment(output(), terminal)
377
+
378
+ async def _ensure_shell(self, shell_id: str) -> asyncssh.SSHClientProcess[bytes]:
379
+ if not shell_id:
380
+ raise ValueError("shell_id must not be empty")
381
+ async with self._shell_creation_lock:
382
+ shell = self._shells.get(shell_id)
383
+ if shell is None or shell.exit_status is not None:
384
+ shell = await self._connection.create_process(term_type="xterm", encoding=None)
385
+ self._shells[shell_id] = shell
386
+ return shell
387
+
388
+ async def shell_close(self, shell_id: str) -> None:
389
+ async with self._shell_creation_lock:
390
+ shell = self._shells.pop(shell_id, None)
391
+ if shell is None:
392
+ return
393
+ cleanup = asyncio.create_task(self._close_shell_process(shell))
394
+ await finish_task_before_cancellation(cleanup)
395
+
396
+ @staticmethod
397
+ async def _close_shell_process(shell: asyncssh.SSHClientProcess[bytes]) -> None:
398
+ shell.terminate()
399
+ shell.close()
400
+ try:
401
+ await asyncio.wait_for(shell.wait_closed(), timeout=1)
402
+ except TimeoutError:
403
+ shell.kill()
404
+ shell.close()
405
+ with suppress(Exception):
406
+ await asyncio.wait_for(shell.wait_closed(), timeout=1)
407
+ except Exception:
408
+ pass
409
+
193
410
  async def close(self) -> None:
411
+ await asyncio.gather(*(self.shell_close(shell_id) for shell_id in tuple(self._shells)), return_exceptions=True)
194
412
  self._connection.close()
195
413
  await self._connection.wait_closed()
414
+
415
+
416
+ def _ssh_shell_terminal(process: asyncssh.SSHClientProcess[bytes]) -> ShellTerminalResult:
417
+ exit_status = process.exit_status
418
+ raw_signal = process.exit_signal
419
+ signal_name: str | None = None
420
+ if isinstance(raw_signal, tuple) and raw_signal and isinstance(raw_signal[0], str):
421
+ signal_name = raw_signal[0]
422
+ elif isinstance(raw_signal, str):
423
+ signal_name = raw_signal
424
+ if signal_name:
425
+ return ShellTerminalResult(None, signal_name)
426
+ return ShellTerminalResult(exit_status, None)