Odysseus v1.0
This commit is contained in:
6
services/shell/__init__.py
Normal file
6
services/shell/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# services/shell/__init__.py
|
||||
"""Shell service — safe command execution."""
|
||||
|
||||
from .service import ShellService, ShellResult
|
||||
|
||||
__all__ = ["ShellService", "ShellResult"]
|
||||
162
services/shell/service.py
Normal file
162
services/shell/service.py
Normal file
@@ -0,0 +1,162 @@
|
||||
# services/shell/service.py
|
||||
"""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")),
|
||||
]
|
||||
|
||||
finished = 0
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
while finished < 2:
|
||||
remaining = deadline - asyncio.get_event_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()
|
||||
Reference in New Issue
Block a user