|
|
| """Shell service — safe command execution."""
|
|
|
| from dataclasses import dataclass
|
| from typing import Optional, AsyncIterator
|
| import asyncio
|
| from pathlib import Path
|
|
|
|
|
| @dataclass
|
| class ShellResult:
|
| """Result of a shell command."""
|
| stdout: str
|
| stderr: str
|
| exit_code: int
|
| timed_out: bool = False
|
|
|
|
|
| class ShellService:
|
| """
|
| Shell execution service.
|
|
|
| Usage:
|
| service = ShellService()
|
| result = await service.execute("ls -la")
|
| print(result.stdout)
|
| """
|
|
|
| def __init__(self, timeout: int = 30, max_output: int = 200_000):
|
| self.timeout = timeout
|
| self.max_output = max_output
|
| self.cwd = str(Path.home())
|
|
|
| async def execute(
|
| self,
|
| command: str,
|
| timeout: Optional[int] = None,
|
| cwd: Optional[str] = None,
|
| ) -> ShellResult:
|
| """
|
| Execute a shell command.
|
|
|
| Args:
|
| command: Shell command to run
|
| timeout: Timeout in seconds (default: self.timeout)
|
| cwd: Working directory (default: home)
|
|
|
| Returns:
|
| ShellResult with stdout, stderr, exit_code
|
| """
|
| timeout = timeout or self.timeout
|
| cwd = cwd or self.cwd
|
|
|
| proc = None
|
| try:
|
| proc = await asyncio.create_subprocess_shell(
|
| command,
|
| stdout=asyncio.subprocess.PIPE,
|
| stderr=asyncio.subprocess.PIPE,
|
| cwd=cwd,
|
| )
|
| stdout_b, stderr_b = await asyncio.wait_for(
|
| proc.communicate(), timeout=timeout
|
| )
|
| stdout = stdout_b.decode(errors="replace")[:self.max_output]
|
| stderr = stderr_b.decode(errors="replace")[:self.max_output]
|
| return ShellResult(
|
| stdout=stdout,
|
| stderr=stderr,
|
| exit_code=proc.returncode,
|
| )
|
| except asyncio.TimeoutError:
|
| if proc:
|
| try:
|
| proc.kill()
|
| await proc.wait()
|
| except ProcessLookupError:
|
| pass
|
| return ShellResult(
|
| stdout="",
|
| stderr=f"Command timed out after {timeout}s",
|
| exit_code=-1,
|
| timed_out=True,
|
| )
|
| except Exception as e:
|
| return ShellResult(stdout="", stderr=str(e), exit_code=-1)
|
|
|
| async def stream(
|
| self,
|
| command: str,
|
| timeout: int = 120,
|
| ) -> AsyncIterator[dict]:
|
| """
|
| Execute a command and stream output.
|
|
|
| Yields:
|
| {"stream": "stdout"|"stderr", "data": line}
|
| {"exit_code": int}
|
| """
|
|
|
| proc = None
|
| reader_tasks = []
|
| try:
|
| proc = await asyncio.create_subprocess_shell(
|
| command,
|
| stdout=asyncio.subprocess.PIPE,
|
| stderr=asyncio.subprocess.PIPE,
|
| cwd=self.cwd,
|
| )
|
|
|
| q: asyncio.Queue = asyncio.Queue()
|
|
|
| async def _reader(stream, name):
|
| try:
|
| while True:
|
| line = await stream.readline()
|
| if not line:
|
| break
|
| await q.put((name, line.decode(errors="replace").rstrip("\n")))
|
| finally:
|
| await q.put((name, None))
|
|
|
| reader_tasks = [
|
| asyncio.create_task(_reader(proc.stdout, "stdout")),
|
| asyncio.create_task(_reader(proc.stderr, "stderr")),
|
| ]
|
|
|
| loop = asyncio.get_running_loop()
|
| finished = 0
|
| deadline = loop.time() + timeout
|
| while finished < 2:
|
| remaining = deadline - loop.time()
|
| if remaining <= 0:
|
| raise asyncio.TimeoutError()
|
|
|
| try:
|
| name, text = await asyncio.wait_for(q.get(), timeout=min(remaining, 2.0))
|
| except asyncio.TimeoutError:
|
| continue
|
|
|
| if text is None:
|
| finished += 1
|
| continue
|
| yield {"stream": name, "data": text}
|
|
|
| await proc.wait()
|
| yield {"exit_code": proc.returncode}
|
|
|
| except asyncio.TimeoutError:
|
| if proc:
|
| try:
|
| proc.kill()
|
| await proc.wait()
|
| except ProcessLookupError:
|
| pass
|
| yield {"stream": "stderr", "data": f"Command timed out after {timeout}s"}
|
| yield {"exit_code": -1}
|
| except Exception as e:
|
| yield {"stream": "stderr", "data": str(e)}
|
| yield {"exit_code": -1}
|
| finally:
|
| for t in reader_tasks:
|
| t.cancel()
|
|
|