184 lines
6.2 KiB
Python
184 lines
6.2 KiB
Python
"""Async client for the open-webui/open-terminal HTTP API.
|
|
|
|
Scoped to what the Claude Code pipe needs: run a command as a specific
|
|
end-user (via X-User-Id), poll its streaming output, and pull back any
|
|
artifact files the agent produces.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import AsyncIterator, Optional
|
|
|
|
import httpx
|
|
|
|
_USER_ID_SAFE = re.compile(r"[^a-zA-Z0-9_-]")
|
|
|
|
|
|
def sanitize_user_id(raw: str) -> str:
|
|
"""Mirror open-terminal's own sanitation so we can predict the Linux
|
|
username it will provision. The server sanitizes server-side too; doing
|
|
it here lets callers log/reference the resolved identity."""
|
|
return _USER_ID_SAFE.sub("", raw)[:32] or "anon"
|
|
|
|
|
|
@dataclass
|
|
class ProcessHandle:
|
|
process_id: str
|
|
user_id: str
|
|
|
|
|
|
class OpenTerminalError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class OpenTerminalClient:
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
api_key: str,
|
|
*,
|
|
timeout: float = 30.0,
|
|
):
|
|
self._base = base_url.rstrip("/")
|
|
self._headers = {"Authorization": f"Bearer {api_key}"}
|
|
self._timeout = timeout
|
|
self._client: Optional[httpx.AsyncClient] = None
|
|
|
|
async def __aenter__(self):
|
|
self._client = httpx.AsyncClient(
|
|
base_url=self._base,
|
|
headers=self._headers,
|
|
timeout=self._timeout,
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, *_exc):
|
|
if self._client is not None:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
def _user_headers(self, user_id: str, session_id: Optional[str] = None) -> dict:
|
|
h = {"X-User-Id": sanitize_user_id(user_id)}
|
|
if session_id:
|
|
h["X-Session-Id"] = session_id
|
|
return h
|
|
|
|
async def start(
|
|
self,
|
|
user_id: str,
|
|
command: str,
|
|
*,
|
|
cwd: Optional[str] = None,
|
|
stdin: Optional[str] = None,
|
|
session_id: Optional[str] = None,
|
|
) -> ProcessHandle:
|
|
"""Start a command in the user's account. Returns immediately with a
|
|
process handle; use `stream_output` to consume stdout/stderr."""
|
|
assert self._client is not None, "use as async context manager"
|
|
payload: dict = {"command": command}
|
|
if cwd:
|
|
payload["cwd"] = cwd
|
|
if stdin is not None:
|
|
payload["stdin"] = stdin
|
|
r = await self._client.post(
|
|
"/execute",
|
|
json=payload,
|
|
headers=self._user_headers(user_id, session_id),
|
|
)
|
|
if r.status_code >= 400:
|
|
raise OpenTerminalError(f"POST /execute {r.status_code}: {r.text}")
|
|
return ProcessHandle(process_id=r.json()["id"], user_id=user_id)
|
|
|
|
async def stream_output(
|
|
self,
|
|
handle: ProcessHandle,
|
|
*,
|
|
session_id: Optional[str] = None,
|
|
wait: float = 10.0,
|
|
) -> AsyncIterator[tuple[str, str]]:
|
|
"""Long-poll status with `wait=N&offset=K` to yield new output
|
|
entries as they arrive. Yields ('output', text) for each chunk and
|
|
a final ('exit', exit_code_str). Stdout and stderr are merged by
|
|
the server's PTY — we strip the resulting CRLFs."""
|
|
assert self._client is not None
|
|
headers = self._user_headers(handle.user_id, session_id)
|
|
offset = 0
|
|
read_timeout = wait + 10.0
|
|
while True:
|
|
r = await self._client.get(
|
|
f"/execute/{handle.process_id}/status",
|
|
params={"wait": wait, "offset": offset},
|
|
headers=headers,
|
|
timeout=read_timeout,
|
|
)
|
|
if r.status_code >= 400:
|
|
raise OpenTerminalError(
|
|
f"status {handle.process_id}: {r.status_code} {r.text}"
|
|
)
|
|
data = r.json()
|
|
for entry in data.get("output") or []:
|
|
text = (entry.get("data") or "").replace("\r\n", "\n").replace("\r", "\n")
|
|
if text:
|
|
yield "output", text
|
|
offset = data.get("next_offset", offset)
|
|
if data.get("status") != "running":
|
|
yield "exit", str(data.get("exit_code") or 0)
|
|
return
|
|
|
|
async def kill(self, handle: ProcessHandle, *, force: bool = False) -> None:
|
|
assert self._client is not None
|
|
await self._client.delete(
|
|
f"/execute/{handle.process_id}",
|
|
params={"force": str(force).lower()},
|
|
headers=self._user_headers(handle.user_id),
|
|
)
|
|
|
|
async def send_stdin(self, handle: ProcessHandle, data: str) -> None:
|
|
assert self._client is not None
|
|
r = await self._client.post(
|
|
f"/execute/{handle.process_id}/input",
|
|
json={"input": data},
|
|
headers=self._user_headers(handle.user_id),
|
|
)
|
|
if r.status_code >= 400:
|
|
raise OpenTerminalError(f"stdin: {r.status_code} {r.text}")
|
|
|
|
# ---- file ops (used to pull artifacts Claude produces in $HOME) ----
|
|
|
|
async def read_file(self, user_id: str, path: str) -> bytes:
|
|
assert self._client is not None
|
|
r = await self._client.get(
|
|
"/files/read",
|
|
params={"path": path},
|
|
headers=self._user_headers(user_id),
|
|
)
|
|
if r.status_code >= 400:
|
|
raise OpenTerminalError(f"read {path}: {r.status_code}")
|
|
return r.content
|
|
|
|
async def list_files(self, user_id: str, path: str = "~") -> list[dict]:
|
|
assert self._client is not None
|
|
r = await self._client.get(
|
|
"/files/list",
|
|
params={"path": path},
|
|
headers=self._user_headers(user_id),
|
|
)
|
|
if r.status_code >= 400:
|
|
raise OpenTerminalError(f"list {path}: {r.status_code}")
|
|
return r.json().get("entries", [])
|
|
|
|
async def write_file(self, user_id: str, path: str, content: bytes | str) -> None:
|
|
assert self._client is not None
|
|
if isinstance(content, str):
|
|
content = content.encode("utf-8")
|
|
r = await self._client.post(
|
|
"/files/write",
|
|
params={"path": path},
|
|
content=content,
|
|
headers=self._user_headers(user_id),
|
|
)
|
|
if r.status_code >= 400:
|
|
raise OpenTerminalError(f"write {path}: {r.status_code} {r.text}")
|