openwebui-claude-code/sandbox/claude_runner.py

133 lines
4.8 KiB
Python

"""Run Claude Code inside an open-terminal sandbox.
Replaces the Claude Agent SDK's in-process invocation with a remote one: we
shell the `claude` CLI inside a per-user Linux account on the open-terminal
container, and consume its stream-json output.
Why stream-json over the SDK: the SDK runs `claude` in the *local* process;
there's no hook for "spawn it on a remote machine." Shelling `claude` with
`--output-format stream-json` gives us the same event stream (assistant
messages, tool_use, tool_result, result) that the SDK surfaces, just as
newline-delimited JSON we can forward straight to the pipe's UI renderer.
"""
from __future__ import annotations
import json
import shlex
from dataclasses import dataclass
from typing import AsyncIterator, Optional
from open_terminal_client import OpenTerminalClient, ProcessHandle
@dataclass
class ClaudeRunConfig:
model: str = "claude-haiku-4-5"
permission_mode: str = "bypassPermissions"
allowed_tools: tuple[str, ...] = (
"Read", "Write", "Edit", "Bash", "Glob", "Grep", "WebSearch", "WebFetch",
)
max_turns: int = 30
# Persisted across turns: `claude --resume <session_id>` picks up
# conversation state. We store the id per OWUI chat on the pipe side.
resume_session_id: Optional[str] = None
# OAuth token or API key. Passed via env so it never hits argv.
oauth_token: Optional[str] = None
api_key: Optional[str] = None
# Working dir inside the user's home. The pipe should stick to one
# directory per OWUI chat so artifacts persist across turns.
workdir: str = "~/workspace"
def _build_env(cfg: ClaudeRunConfig) -> dict[str, str]:
env: dict[str, str] = {}
if cfg.oauth_token:
# OAuth takes priority — matches the existing pipe's contract.
env["CLAUDE_CODE_OAUTH_TOKEN"] = cfg.oauth_token
elif cfg.api_key:
env["ANTHROPIC_API_KEY"] = cfg.api_key
return env
def _env_prefix(env: dict[str, str]) -> str:
"""Render env assignments as a safely-quoted shell prefix.
shlex.quote guarantees no injection via the values."""
return " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items())
def _build_claude_command(prompt: str, cfg: ClaudeRunConfig) -> str:
parts = [
"claude",
"--print",
"--output-format", "stream-json",
"--verbose",
"--model", shlex.quote(cfg.model),
"--permission-mode", shlex.quote(cfg.permission_mode),
"--allowed-tools", shlex.quote(",".join(cfg.allowed_tools)),
]
if cfg.max_turns and cfg.max_turns > 0:
parts += ["--max-turns", str(cfg.max_turns)]
if cfg.resume_session_id:
parts += ["--resume", shlex.quote(cfg.resume_session_id)]
parts.append(shlex.quote(prompt))
return " ".join(parts)
async def run_claude(
client: OpenTerminalClient,
user_id: str,
prompt: str,
cfg: ClaudeRunConfig,
*,
session_id: Optional[str] = None,
) -> AsyncIterator[dict]:
"""Yield parsed stream-json events from Claude Code running in the sandbox.
Each event is a dict with a `type` key: 'system', 'assistant', 'user'
(tool results), 'result'. The pipe's renderer already understands these
shapes from the SDK path — they're the same schema, just transport-wrapped.
"""
env = _build_env(cfg)
# Ensure workspace exists, then run claude. A single compound command
# keeps it one process the pipe can cancel with one DELETE.
mkdir = f"mkdir -p {shlex.quote(cfg.workdir)}"
cd = f"cd {shlex.quote(cfg.workdir)}"
prefix = _env_prefix(env)
cmd = _build_claude_command(prompt, cfg)
full = f"{mkdir} && {cd} && {prefix} {cmd}"
handle: ProcessHandle = await client.start(
user_id=user_id,
command=full,
session_id=session_id,
)
# open-terminal merges stdout/stderr via PTY, so auth warnings printed
# by `claude` on stderr will interleave with stream-json on stdout.
# Non-JSON lines surface as synthetic _raw events.
buf = ""
async for stream, chunk in client.stream_output(handle, session_id=session_id):
if stream == "exit":
if buf.strip():
try:
yield json.loads(buf)
except json.JSONDecodeError:
yield {"type": "_raw", "text": buf.strip()}
buf = ""
yield {"type": "_exit", "code": int(chunk)}
return
buf += chunk
# stream-json is newline-delimited; one JSON object per line.
while "\n" in buf:
line, buf = buf.split("\n", 1)
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError:
# Claude CLI prints warnings before the stream starts;
# expose them rather than swallow silently.
yield {"type": "_raw", "text": line}