diff --git a/claude_agent_pipe_sandboxed.py b/claude_agent_pipe_sandboxed.py new file mode 100644 index 0000000..669d588 --- /dev/null +++ b/claude_agent_pipe_sandboxed.py @@ -0,0 +1,868 @@ +""" +title: Claude Code (Sandboxed) +description: Run Claude Code inside an open-webui/open-terminal sandbox, one + Linux account per OpenWebUI user. Isolates the agent's file/process reach + from the Open WebUI backend host. +author: Thomas Friedel +version: 0.1 +license: MIT +requirements: httpx>=0.27 +""" + +# Why this pipe exists +# -------------------- +# The sibling `claude_agent_pipe.py` runs Claude Code's agent loop *in-process* +# via the Claude Agent SDK. That means Bash/Read/Write/Edit calls hit the +# Open WebUI backend's filesystem with whatever UID that process has — fine +# for a solo dev setup, dangerous in any shared deployment. +# +# This variant shells the `claude` CLI inside a per-user Linux account on an +# open-webui/open-terminal container. Isolation is by standard Unix +# permissions (shared kernel). Ship this when you have multiple users who +# you'd rather keep from stomping on each other's workspaces. +# +# Not yet ported from the SDK pipe: +# - Knowledge-base MCP tool (Workspace Model KB attachments). The SDK pipe +# registers an in-process MCP tool that calls OpenWebUI's retrieval layer +# directly. In the sandbox the `claude` CLI can't reach Python functions; +# we'd need to expose the KB as an HTTP MCP server from the OWUI backend +# and `claude mcp add` it inside the container. Left as a follow-up. +# - Partial-message token streaming for thinking blocks. stream-json emits +# full `assistant` events, which is fine for text but slightly chunkier +# UX than the SDK's `include_partial_messages`. Can be upgraded later +# via `claude --verbose` stream_event passthrough. + +from __future__ import annotations + +import asyncio +import json +import logging +import mimetypes +import re +import shlex +import time +import uuid +from dataclasses import dataclass +from typing import Any, AsyncGenerator, AsyncIterator, Callable, Dict, List, Optional + +import httpx +from pydantic import BaseModel, Field + +log = logging.getLogger(__name__) + +_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"} +_DOWNLOAD_EXTENSIONS = { + ".pdf", ".csv", ".tsv", ".txt", ".md", ".json", ".yaml", ".yml", + ".html", ".xml", ".xlsx", ".docx", ".pptx", ".zip", +} +_ARTIFACT_EXTENSIONS = _IMAGE_EXTENSIONS | _DOWNLOAD_EXTENSIONS +_MAX_ARTIFACT_BYTES = 50 * 1024 * 1024 + +# OpenWebUI calls pipe() fresh for each chat turn. Same pattern as the SDK +# pipe — in-process map of chat_id → claude session_id so `claude --resume` +# picks up prior turns' state. +_chat_sessions: Dict[str, str] = {} + + +# ============================================================================ +# open-terminal client +# ============================================================================ + +_USER_ID_SAFE = re.compile(r"[^a-zA-Z0-9_-]") +_CHAT_ID_SAFE = re.compile(r"[^a-zA-Z0-9_-]") + + +def _sanitize_user_id(raw: str) -> str: + """Match open-terminal's server-side sanitation so we can predict the + Linux username it provisions (useful for logging).""" + return _USER_ID_SAFE.sub("", raw)[:32] or "anon" + + +def _safe_chat_id(raw: str) -> str: + """Strip everything except alphanumerics, dash, and underscore. OWUI's + chat_ids are UUIDs so this is a no-op in practice, but it lets us embed + the id directly in shell commands without shlex.quote — which is + necessary because shlex.quote wraps in single quotes and single quotes + suppress the `~` and `$HOME` expansion our workdir templates rely on.""" + return _CHAT_ID_SAFE.sub("", raw) or "default" + + +@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 _uh(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, + session_id: Optional[str] = None, + ) -> _ProcessHandle: + assert self._client is not None + payload: dict = {"command": command} + if cwd: + payload["cwd"] = cwd + r = await self._client.post( + "/execute", json=payload, headers=self._uh(user_id, session_id) + ) + if r.status_code >= 400: + raise OpenTerminalError(f"POST /execute {r.status_code}: {r.text}") + # open-terminal returns {id, command, status, output, next_offset, ...}. + # `id` here is the process identifier (not related to X-User-Id). + 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]]: + """Yield (stream, chunk) tuples until the process exits. + + Uses the long-poll pattern: `GET /execute/{id}/status?wait=N&offset=K` + returns as soon as new output appears or the process exits, up to N + seconds. `offset` is advanced by the cumulative number of entries + we've already consumed (from the server's `next_offset`). + + Stream values: 'output' for merged stdout/stderr (open-terminal runs + commands through a PTY so both streams are combined), and 'exit' as + a synthetic terminator carrying the exit code. + """ + assert self._client is not None + headers = self._uh(handle.user_id, session_id) + offset = 0 + # Per-request timeout must exceed `wait`, or httpx aborts while the + # server is still happily long-polling. + 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: {r.status_code} {r.text}") + data = r.json() + for entry in data.get("output") or []: + # All entries currently arrive with type='output'. Strip the + # PTY's CR so downstream splitting on '\n' works cleanly. + 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 + try: + await self._client.delete( + f"/execute/{handle.process_id}", + params={"force": str(force).lower()}, + headers=self._uh(handle.user_id), + ) + except Exception: + pass # best-effort cleanup + + 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._uh(user_id) + ) + if r.status_code >= 400: + return [] + return r.json().get("entries", []) + + async def read_file(self, user_id: str, path: str) -> bytes: + """Download a file's raw bytes. Uses `/files/view` (not `/files/read`) + — /read returns a JSON envelope `{path, content}` for text and 415 + for binary, so it's unusable for PNG/PDF/ZIP artifacts.""" + assert self._client is not None + r = await self._client.get( + "/files/view", params={"path": path}, headers=self._uh(user_id) + ) + if r.status_code >= 400: + raise OpenTerminalError(f"view {path}: {r.status_code}") + return r.content + + async def ensure_dir(self, user_id: str, path: str) -> None: + """Create a directory in the user's home if missing. `path` must be + pre-sanitized (alphanumeric + `/._-` + `~` / `$HOME`): it's embedded + unquoted so bash can expand `~` and variables. We can't shlex.quote + here — single quotes suppress the very expansion we need.""" + handle = await self.start(user_id, f"mkdir -p {path}") + async for _ in self.stream_output(handle): + pass + + +# ============================================================================ +# claude CLI invocation + stream-json parsing +# ============================================================================ + + +@dataclass +class _ClaudeRunConfig: + model: str + permission_mode: str + allowed_tools: List[str] + max_turns: int + resume_session_id: Optional[str] + oauth_token: Optional[str] + api_key: Optional[str] + workdir: str + system_prompt_append: Optional[str] + + +# Baked into the system prompt on every turn so Claude knows the sandbox's +# quirks without the user having to phrase requests carefully. Kept short — +# it's prepended to whatever the Workspace Model's system prompt says. +_SANDBOX_ENV_NOTE = ( + "You are running in a headless Linux sandbox (no display, no audio). " + "For any plot or chart, ALWAYS save it to a file in the current working " + "directory with an explicit extension (e.g. matplotlib → " + "plt.savefig('name.png'); pandas/plotly → .to_html or kaleido png). " + "plt.show() is a no-op here. The host automatically inlines any new " + "PNG/PDF/CSV/HTML/… files from the working directory into the chat " + "after each turn, so the user will see saved files without you needing " + "to read them back." +) + + +def _claude_command(prompt: str, cfg: _ClaudeRunConfig) -> str: + env_parts: List[str] = [] + if cfg.oauth_token: + env_parts.append(f"CLAUDE_CODE_OAUTH_TOKEN={shlex.quote(cfg.oauth_token)}") + elif cfg.api_key: + env_parts.append(f"ANTHROPIC_API_KEY={shlex.quote(cfg.api_key)}") + # `claude --dangerously-skip-permissions` still refuses root unless told + # it's sandboxed. open-terminal runs processes as the per-user UID so this + # rarely matters, but setting it is harmless. + env_parts.append("IS_SANDBOX=1") + # Force matplotlib's non-interactive backend. Without this, any stray + # `plt.show()` can trigger a DISPLAY-not-found exception that aborts the + # script before savefig runs. + env_parts.append("MPLBACKEND=Agg") + + cli_parts = [ + "claude", "--print", + "--output-format", "stream-json", + "--verbose", # required for stream-json + "--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: + cli_parts += ["--max-turns", str(cfg.max_turns)] + if cfg.resume_session_id: + cli_parts += ["--resume", shlex.quote(cfg.resume_session_id)] + + # Always prepend the sandbox environment note; merge in whatever the + # Workspace Model configured. One --append-system-prompt flag carries both. + system_append = _SANDBOX_ENV_NOTE + if cfg.system_prompt_append: + system_append = f"{_SANDBOX_ENV_NOTE}\n\n{cfg.system_prompt_append}" + cli_parts += ["--append-system-prompt", shlex.quote(system_append)] + + cli_parts.append(shlex.quote(prompt)) + + return " ".join(env_parts + cli_parts) + + +async def _stream_claude_events( + client: OpenTerminalClient, + user_id: str, + prompt: str, + cfg: _ClaudeRunConfig, + *, + session_id: Optional[str], +) -> AsyncIterator[dict]: + """Start claude in the sandbox and yield parsed stream-json events. + Synthetic event types (underscore-prefixed) surface transport-level + signals the caller may want (stderr diagnostics, exit code).""" + await client.ensure_dir(user_id, cfg.workdir) + cmd = _claude_command(prompt, cfg) + # workdir is unquoted on purpose — see ensure_dir docstring. cfg.workdir + # has already been rendered through _safe_chat_id so it's shell-safe. + full = f"cd {cfg.workdir} && {cmd}" + handle = await client.start(user_id, full, session_id=session_id) + + # open-terminal merges stdout+stderr through a PTY, so anything the CLI + # prints on stderr (auth warnings, config notices) will interleave with + # the stream-json lines on stdout. We parse every line as JSON; those + # that fail become synthetic _raw events so the caller can log them + # without disrupting the agent loop. + buf = "" + try: + async for stream, chunk in client.stream_output(handle, session_id=session_id): + if stream == "exit": + if buf.strip(): + # Flush any trailing unterminated line. + try: + yield json.loads(buf) + except json.JSONDecodeError: + yield {"type": "_raw", "text": buf.strip()} + buf = "" + yield {"type": "_exit", "code": int(chunk)} + return + buf += chunk + 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: + yield {"type": "_raw", "text": line} + except asyncio.CancelledError: + await client.kill(handle, force=True) + raise + + +# ============================================================================ +# Rendering helpers (kept in sync with claude_agent_pipe.py) +# ============================================================================ + +_TOOL_PREVIEW_FIELDS = { + "Bash": "command", "Read": "file_path", "Write": "file_path", + "Edit": "file_path", "Glob": "pattern", "Grep": "pattern", + "WebSearch": "query", "WebFetch": "url", "Task": "description", +} +_FENCE_LANG_PER_TOOL = { + "Bash": "bash", "Glob": "text", "Grep": "text", + "WebSearch": "text", "WebFetch": "text", +} + + +def _tool_preview(name: str, tool_input: Dict[str, Any]) -> str: + key = _TOOL_PREVIEW_FIELDS.get(name) + if key and key in tool_input: + raw = str(tool_input[key]) + elif tool_input: + raw = ", ".join(f"{k}={str(v)[:40]}" for k, v in list(tool_input.items())[:2]) + else: + return "" + first = raw.split("\n", 1)[0] + truncated = first if len(first) <= 120 else first[:117] + "…" + return truncated + (" …" if "\n" in raw and not truncated.endswith("…") else "") + + +def _tool_input_block(name: str, tool_input: Dict[str, Any]) -> str: + if not tool_input: + return "```\n(no input)\n```" + primary = _TOOL_PREVIEW_FIELDS.get(name) + if primary and primary in tool_input: + lang = _FENCE_LANG_PER_TOOL.get(name, "text") + parts = [f"```{lang}\n{tool_input[primary]}\n```"] + others = {k: v for k, v in tool_input.items() if k != primary} + if others: + parts.append( + f"```json\n{json.dumps(others, indent=2, ensure_ascii=False)}\n```" + ) + return "\n\n".join(parts) + return f"```json\n{json.dumps(tool_input, indent=2, ensure_ascii=False)}\n```" + + +def _format_tool_result(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: List[str] = [] + for item in content: + if isinstance(item, dict): + text = item.get("text") + parts.append(text if isinstance(text, str) else repr(item)) + else: + parts.append(str(item)) + return "\n".join(parts) + return str(content) + + +# ============================================================================ +# body / metadata extraction (ported verbatim from claude_agent_pipe.py) +# ============================================================================ + + +def _extract_latest_user_prompt(body: Dict[str, Any]) -> str: + messages = body.get("messages") or [] + for message in reversed(messages): + if message.get("role") != "user": + continue + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [ + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ] + if texts: + return "\n".join(texts) + return "" + + +def _extract_system_prompt(body: Dict[str, Any]) -> Optional[str]: + parts: List[str] = [] + for msg in body.get("messages") or []: + if msg.get("role") != "system": + continue + content = msg.get("content") + if isinstance(content, str): + parts.append(content) + elif isinstance(content, list): + for piece in content: + if isinstance(piece, dict) and piece.get("type") == "text": + parts.append(piece.get("text", "")) + merged = "\n\n".join(p for p in parts if p and p.strip()) + return merged or None + + +# ============================================================================ +# Artifact upload (walks the sandbox workspace, uploads new files to OWUI) +# ============================================================================ + + +async def _snapshot_workspace( + client: OpenTerminalClient, user_id: str, workdir: str +) -> Dict[str, int]: + """Take a name→mtime_ns snapshot of artifact-like files in the workspace. + We compare before/after to detect what the agent produced this turn.""" + # Use find with -printf to get path + mtime in one call, avoiding N+1 + # HTTP hits against /files/list for every subdirectory. + # workdir unquoted so `~` / `$HOME` expand; see OpenTerminalClient.ensure_dir. + cmd = ( + f"find {workdir} -type f " + rf"\( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' " + rf"-o -iname '*.gif' -o -iname '*.svg' -o -iname '*.webp' " + rf"-o -iname '*.pdf' -o -iname '*.csv' -o -iname '*.tsv' " + rf"-o -iname '*.txt' -o -iname '*.md' -o -iname '*.json' " + rf"-o -iname '*.yaml' -o -iname '*.yml' -o -iname '*.html' " + rf"-o -iname '*.xml' -o -iname '*.xlsx' -o -iname '*.docx' " + rf"-o -iname '*.pptx' -o -iname '*.zip' \) " + rf"-printf '%T@\t%p\n' 2>/dev/null" + ) + handle = await client.start(user_id, cmd) + out = "" + async for stream, chunk in client.stream_output(handle): + if stream == "output": + out += chunk + snapshot: Dict[str, int] = {} + for line in out.splitlines(): + if "\t" not in line: + continue + mtime_str, path = line.split("\t", 1) + try: + snapshot[path] = int(float(mtime_str) * 1e9) + except ValueError: + continue + return snapshot + + +async def _inline_new_artifacts( + client: OpenTerminalClient, + user_id: str, + workdir: str, + before: Dict[str, int], + owui_user_id: Optional[str], +) -> List[str]: + """Detect files created/modified since `before`, download them from the + sandbox, and register them as OpenWebUI artifacts. See the SDK pipe for + why we serve via /api/v1/files/{id}/content instead of data URIs.""" + if not owui_user_id: + return ["\n\n_(Can't save artifacts: no user context.)_\n"] + + try: + from open_webui.models.files import FileForm, Files + from open_webui.storage.provider import Storage + except Exception as exc: + log.warning("artifact inline: OWUI file store unavailable: %s", exc) + return [f"\n\n_(File store unavailable: {exc})_\n"] + + after = await _snapshot_workspace(client, user_id, workdir) + new_paths = [p for p, m in after.items() if before.get(p) != m] + new_paths.sort() + log.info( + "artifact inline: workdir=%s before=%d after=%d new=%d paths=%s", + workdir, len(before), len(after), len(new_paths), new_paths[:10], + ) + + chunks: List[str] = [] + for remote_path in new_paths: + name = remote_path.rsplit("/", 1)[-1] + ext = ("." + name.rsplit(".", 1)[-1]).lower() if "." in name else "" + if ext not in _ARTIFACT_EXTENSIONS: + continue + + try: + content = await client.read_file(user_id, remote_path) + except Exception as exc: + chunks.append(f"\n\n_(Couldn't fetch {name}: {exc})_\n") + continue + + size = len(content) + if size > _MAX_ARTIFACT_BYTES: + chunks.append( + f"\n\n_(Skipped {name}: {size // 1024 // 1024} MiB exceeds " + f"{_MAX_ARTIFACT_BYTES // 1024 // 1024} MiB limit.)_\n" + ) + continue + + is_image = ext in _IMAGE_EXTENSIONS + mime = mimetypes.guess_type(name)[0] or ( + "image/png" if is_image else "application/octet-stream" + ) + file_id = str(uuid.uuid4()) + storage_filename = f"{file_id}_{name}" + + # Write through a temp buffer OWUI's Storage layer can consume. + # It expects a file-like with .read(); BytesIO fits. + import io + try: + stored, storage_path = Storage.upload_file( + io.BytesIO(content), + storage_filename, + {"OpenWebUI-User-Id": owui_user_id, "OpenWebUI-File-Id": file_id}, + ) + except Exception as exc: + log.exception("Artifact upload failed: %s", remote_path) + chunks.append(f"\n\n_(Failed to save {name}: {exc})_\n") + continue + + try: + Files.insert_new_file( + owui_user_id, + FileForm( + id=file_id, + filename=name, + path=storage_path, + data={}, + meta={"name": name, "content_type": mime, "size": len(stored)}, + ), + ) + except Exception as exc: + log.exception("Artifact DB row failed: %s", remote_path) + chunks.append(f"\n\n_(Saved but not linkable: {name}: {exc})_\n") + continue + + if is_image: + chunks.append(f"\n\n![{name}](/api/v1/files/{file_id}/content)\n") + else: + kib = size // 1024 + chunks.append( + f"\n\n📎 [{name}](/api/v1/files/{file_id}/content) · {kib} KiB\n" + ) + return chunks + + +# ============================================================================ +# Pipe +# ============================================================================ + + +class Pipe: + class Valves(BaseModel): + OPEN_TERMINAL_URL: str = Field( + default="http://open-terminal:8000", + description=( + "Base URL of the open-terminal sandbox. Use the docker-compose " + "service name when both run in the same network." + ), + ) + OPEN_TERMINAL_API_KEY: str = Field( + default="", + description=( + "Shared secret matching OPEN_TERMINAL_API_KEY on the sandbox " + "container. Authenticates this pipe as a privileged caller; " + "the end user is then conveyed via the X-User-Id header." + ), + ) + ANTHROPIC_API_KEY: str = Field( + default="", + description="Anthropic API key (pay-per-token). Forwarded into the sandbox as an env var.", + ) + CLAUDE_CODE_OAUTH_TOKEN: str = Field( + default="", + description=( + "Claude subscription OAuth token. Takes priority over " + "ANTHROPIC_API_KEY — don't share with untrusted users (per " + "Anthropic's subscription terms)." + ), + ) + MODEL: str = Field(default="claude-haiku-4-5") + PERMISSION_MODE: str = Field(default="bypassPermissions") + ALLOWED_TOOLS: str = Field( + default="Read,Write,Edit,Bash,Glob,Grep,WebSearch,WebFetch" + ) + MAX_TURNS: int = Field(default=30) + WORKDIR_TEMPLATE: str = Field( + default="~/chat-{chat_id}", + description=( + "Per-chat workspace path inside the user's sandbox home. " + "{chat_id} is substituted. Artifacts persist across turns." + ), + ) + + def __init__(self) -> None: + self.valves = self.Valves() + + def pipes(self) -> List[Dict[str, str]]: + return [{"id": "claude-code-sandboxed", "name": "Claude Code (Sandboxed)"}] + + async def pipe( + self, + body: Dict[str, Any], + __chat_id__: Optional[str] = None, + __event_emitter__: Optional[Callable] = None, + __user__: Optional[Dict[str, Any]] = None, + __metadata__: Optional[Dict[str, Any]] = None, + __files__: Optional[List[Dict[str, Any]]] = None, + ) -> AsyncGenerator[str, None]: + if not self.valves.OPEN_TERMINAL_API_KEY: + yield "_Sandbox not configured: set the OPEN_TERMINAL_API_KEY valve._" + return + + prompt = _extract_latest_user_prompt(body) + if not prompt: + yield "_No user message to send to Claude Code._" + return + + chat_id = __chat_id__ or "default" + owui_user_id = (__user__ or {}).get("id") or "anon" + # The sandbox's X-User-Id drives Linux account provisioning. Tie it + # to OWUI's stable user.id (not email/name — those can change). + sandbox_user = owui_user_id + # Render the workdir through _safe_chat_id so it's safe to embed + # unquoted in shell commands. Unquoted is required for `~` and + # `$HOME` in the template to expand. + workdir = self.valves.WORKDIR_TEMPLATE.format( + chat_id=_safe_chat_id(chat_id) + ) + + allowed_tools = [ + t.strip() for t in self.valves.ALLOWED_TOOLS.split(",") if t.strip() + ] + cfg = _ClaudeRunConfig( + model=self.valves.MODEL, + permission_mode=self.valves.PERMISSION_MODE, + allowed_tools=allowed_tools, + max_turns=self.valves.MAX_TURNS, + resume_session_id=_chat_sessions.get(chat_id), + oauth_token=self.valves.CLAUDE_CODE_OAUTH_TOKEN or None, + api_key=self.valves.ANTHROPIC_API_KEY or None, + workdir=workdir, + system_prompt_append=_extract_system_prompt(body), + ) + + async def emit_status(description: str, done: bool = False) -> None: + if __event_emitter__ is None: + return + await __event_emitter__( + {"type": "status", "data": {"description": description, "done": done}} + ) + + active_tools: Dict[str, Dict[str, Any]] = {} + heartbeat_task: Optional[asyncio.Task] = None + + async def _heartbeat() -> None: + try: + while active_tools: + await asyncio.sleep(2) + if not active_tools: + return + oldest = min(active_tools.values(), key=lambda t: t["started"]) + elapsed = int(time.monotonic() - oldest["started"]) + count = len(active_tools) + label = ( + oldest["label"] + if count == 1 + else f"{count} tools · longest {oldest['label']}" + ) + await emit_status(f"⏳ {label} · running {elapsed}s…") + except asyncio.CancelledError: + pass + + def _ensure_heartbeat() -> None: + nonlocal heartbeat_task + if heartbeat_task is None or heartbeat_task.done(): + heartbeat_task = asyncio.create_task(_heartbeat()) + + await emit_status(f"Starting Claude Code in sandbox ({_sanitize_user_id(sandbox_user)})…") + + try: + async with OpenTerminalClient( + self.valves.OPEN_TERMINAL_URL, + self.valves.OPEN_TERMINAL_API_KEY, + ) as client: + await client.ensure_dir(sandbox_user, workdir) + before_snapshot = await _snapshot_workspace( + client, sandbox_user, workdir + ) + + async for event in _stream_claude_events( + client, sandbox_user, prompt, cfg, session_id=chat_id + ): + async for out in _handle_event( + event, + chat_id=chat_id, + active_tools=active_tools, + ensure_heartbeat=_ensure_heartbeat, + emit_status=emit_status, + ): + yield out + + await emit_status("Done.", done=True) + for chunk in await _inline_new_artifacts( + client, sandbox_user, workdir, before_snapshot, owui_user_id + ): + yield chunk + + except Exception as exc: + log.exception("Sandboxed Claude Code pipe failed") + await emit_status(f"Error: {exc}", done=True) + yield f"\n\n**Claude Code error:** `{type(exc).__name__}: {exc}`\n" + finally: + active_tools.clear() + if heartbeat_task is not None and not heartbeat_task.done(): + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + + +async def _handle_event( + event: dict, + *, + chat_id: str, + active_tools: Dict[str, Dict[str, Any]], + ensure_heartbeat: Callable[[], None], + emit_status: Callable[..., Any], +) -> AsyncGenerator[str, None]: + """Translate one stream-json event into UI output. + + Event shapes (from `claude --output-format stream-json --verbose`): + system — {type:"system", subtype:"init", session_id:"..."} + assistant — {type:"assistant", message:{content:[{type:"text"|"tool_use"|"thinking",...}]}} + user — {type:"user", message:{content:[{type:"tool_result", tool_use_id, content, is_error}]}} + result — {type:"result", subtype:"success"|..., total_cost_usd, duration_ms} + _stderr/_exit/_raw — synthetic transport-layer signals from our runner. + """ + etype = event.get("type") + + if etype == "system": + if event.get("subtype") == "init": + sid = event.get("session_id") + if sid: + _chat_sessions[chat_id] = sid + return + + if etype == "assistant": + content = ((event.get("message") or {}).get("content")) or [] + for block in content: + btype = block.get("type") + if btype == "text": + yield block.get("text", "") + elif btype == "thinking": + text = (block.get("thinking") or "").strip() + if text: + await emit_status("💭 Thinking…") + yield ( + "\n\n
\n💭 Thinking\n\n" + f"{text}\n\n
\n\n" + ) + elif btype == "tool_use": + name = block.get("name", "tool") + tool_input = block.get("input") or {} + tool_id = block.get("id", "") + preview = _tool_preview(name, tool_input) + label = f"{name}: {preview}" if preview else name + await emit_status(f"🔧 {label}") + active_tools[tool_id] = {"label": label, "started": time.monotonic()} + ensure_heartbeat() + summary_text = f"🔧 {name}" + (f" · {preview}" if preview else "") + body_block = _tool_input_block(name, tool_input) + yield ( + "\n\n
\n" + f"{summary_text}\n\n" + f"{body_block}\n\n
\n\n" + ) + return + + if etype == "user": + content = ((event.get("message") or {}).get("content")) or [] + for block in content: + if block.get("type") != "tool_result": + continue + active_tools.pop(block.get("tool_use_id", ""), None) + if block.get("is_error"): + err = _format_tool_result(block.get("content"))[:800] + yield ( + "\n\n
\n" + "⚙️ tool hiccup (retrying)" + "\n\n" + f"```\n{err}\n```\n\n
\n\n" + ) + return + + if etype == "result": + subtype = event.get("subtype") + if subtype and subtype != "success": + yield f"\n\n_Agent stopped: {subtype}_\n" + cost = event.get("total_cost_usd") + dur = event.get("duration_ms") + if cost is not None and dur is not None: + yield f"\n\n_Cost: ${float(cost):.4f} · {dur}ms_\n" + return + + if etype == "_stderr": + # claude CLI prints auth/config warnings on stderr. Log but don't surface + # to the chat unless they're fatal — the _exit event will carry that. + log.debug("claude stderr: %s", event.get("text", "").strip()) + return + + if etype == "_exit": + code = event.get("code", 0) + if code != 0: + yield f"\n\n_Sandbox process exited with code {code}._\n" + return + + if etype == "_raw": + log.debug("unparsed line from claude: %s", event.get("text", "")) + return diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile new file mode 100644 index 0000000..49c8b81 --- /dev/null +++ b/sandbox/Dockerfile @@ -0,0 +1,9 @@ +FROM ghcr.io/open-webui/open-terminal:latest + +# Base image runs as uid=1000(user) with npm prefix /usr, which requires +# root to write. Switch to root for the global install, then back so the +# container's runtime user and entrypoint stay identical to upstream. +USER root +RUN npm install -g @anthropic-ai/claude-code@latest \ + && claude --version +USER user diff --git a/sandbox/README.md b/sandbox/README.md new file mode 100644 index 0000000..b66aabf --- /dev/null +++ b/sandbox/README.md @@ -0,0 +1,75 @@ +# Sandboxed Claude Code via open-terminal + +Runs the Claude Code agent inside [open-webui/open-terminal](https://github.com/open-webui/open-terminal) instead of directly in the Open WebUI backend process. Each Open WebUI user gets a dedicated Linux account (via `OPEN_TERMINAL_MULTI_USER=true`), so files, processes, and commands are isolated by standard Unix permissions. + +> **Threat model:** small, trusted groups. One shared kernel, no hard multi-tenant boundaries. Good enough to stop accidental cross-user damage and to keep the agent out of the Open WebUI host's filesystem. Not a substitute for microVMs if you're exposing this to untrusted users. + +## Components + +| File | Purpose | +| --- | --- | +| `Dockerfile` | Extends `ghcr.io/open-webui/open-terminal` with `@anthropic-ai/claude-code` preinstalled. | +| `docker-compose.yml` | Runs the sandbox on `:8000` with multi-user mode and a named volume for `/home`. | +| `open_terminal_client.py` | Async HTTP client: `start()`, `stream_output()`, `read_file()`, `write_file()`. | +| `claude_runner.py` | Builds + invokes the `claude --output-format stream-json` command in the user's account and yields parsed events. | + +## Why this architecture + +The existing `claude_agent_pipe.py` uses the Claude Agent SDK in-process — which means Bash/Read/Write tool calls hit the Open WebUI host's filesystem with whatever permissions that process has. That's fine for a solo dev setup, dangerous in any shared deployment. + +The Agent SDK has no "run on a remote host" hook: it always spawns `claude` locally. So instead of using the SDK, we invoke the `claude` CLI directly with `--output-format stream-json` inside the sandbox. The event stream that comes back is identical to what the SDK surfaces (system/assistant/user/result messages), just newline-delimited JSON — so the existing pipe's renderer can stay largely unchanged. + +## Bringing up the sandbox + +```bash +cd sandbox +echo "OPEN_TERMINAL_API_KEY=$(openssl rand -hex 32)" > .env +docker compose up -d --build +curl -s http://localhost:8000/health +``` + +Quick smoke test, impersonating two different OWUI users to verify isolation: + +```bash +API_KEY=$(grep OPEN_TERMINAL_API_KEY .env | cut -d= -f2) + +# User A writes a secret +curl -s http://localhost:8000/execute \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-User-Id: alice" \ + -H "Content-Type: application/json" \ + -d '{"command": "echo hunter2 > ~/secret.txt && ls -la ~"}' + +# User B tries to read it (should fail — different /home/owui_bob) +curl -s http://localhost:8000/execute \ + -H "Authorization: Bearer $API_KEY" \ + -H "X-User-Id: bob" \ + -H "Content-Type: application/json" \ + -d '{"command": "cat /home/owui_alice/secret.txt || echo DENIED"}' +``` + +## Wiring into the pipe + +In `claude_agent_pipe.py`, replace the SDK-backed code path with a call into `claude_runner.run_claude()`, passing: + +- `user_id = __user__["id"]` — Open WebUI injects the user object into `pipe()`; its stable `id` becomes the sandbox account prefix. +- `cfg.resume_session_id` — look up the `chat_id → claude_session_id` map the same way the existing pipe does. The first event from the stream (`type: "system"`, `subtype: "init"`) carries the new session id; stash it. +- `cfg.workdir = f"~/chat-{chat_id}"` — per-chat subdirectory inside the user's home. Carries artifacts across turns without leaking between chats. + +Event shape differences from the SDK: + +| SDK type | stream-json equivalent | +| --- | --- | +| `AssistantMessage.content[ToolUseBlock]` | `{"type":"assistant","message":{"content":[{"type":"tool_use",...}]}}` | +| `UserMessage.content[ToolResultBlock]` | `{"type":"user","message":{"content":[{"type":"tool_result",...}]}}` | +| `ResultMessage` | `{"type":"result","subtype":"success","total_cost_usd":...}` | +| `SystemMessage(subtype="init")` | `{"type":"system","subtype":"init","session_id":"..."}` | + +The renderer's `_tool_preview` / `_tool_input_block` helpers already work on the `tool_use.input` dict, so they port over directly. + +## Open questions / next steps + +- **Artifacts:** the existing pipe scans `cwd` post-run for generated PDFs/CSVs/images and uploads them as OWUI artifacts. In the sandbox version, swap the filesystem scan for `client.list_files(user, workdir)` + `client.read_file()`. +- **Image context:** when the user attaches images in the chat, `client.write_file()` them into the workdir before invoking claude, then reference by path in the prompt. +- **Session resume:** store `claude_session_id` per `chat_id` in-process (same as the current pipe). Needs testing that `claude --resume` works cleanly across separate `POST /execute` calls — each call is a fresh process, but claude persists session state to `~/.claude/` inside the user's home. +- **Cold start:** first request per user spawns `useradd`; measure and decide whether to pre-warm on Open WebUI login. diff --git a/sandbox/claude_runner.py b/sandbox/claude_runner.py new file mode 100644 index 0000000..dac1830 --- /dev/null +++ b/sandbox/claude_runner.py @@ -0,0 +1,132 @@ +"""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 ` 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} diff --git a/sandbox/docker-compose.yml b/sandbox/docker-compose.yml new file mode 100644 index 0000000..539004a --- /dev/null +++ b/sandbox/docker-compose.yml @@ -0,0 +1,20 @@ +services: + open-terminal: + build: + context: . + dockerfile: Dockerfile + image: helm/open-terminal-claude:latest + container_name: open-terminal + ports: + - "8000:8000" + environment: + OPEN_TERMINAL_MULTI_USER: "true" + OPEN_TERMINAL_API_KEY: ${OPEN_TERMINAL_API_KEY:?set in .env} + OPEN_TERMINAL_USER_PREFIX: "owui_" + EXECUTE_TIMEOUT: "600" + volumes: + - open-terminal-home:/home + restart: unless-stopped + +volumes: + open-terminal-home: diff --git a/sandbox/open_terminal_client.py b/sandbox/open_terminal_client.py new file mode 100644 index 0000000..48deafb --- /dev/null +++ b/sandbox/open_terminal_client.py @@ -0,0 +1,183 @@ +"""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}") diff --git a/sandbox/smoke_test.py b/sandbox/smoke_test.py new file mode 100644 index 0000000..24ca819 --- /dev/null +++ b/sandbox/smoke_test.py @@ -0,0 +1,60 @@ +"""Smoke test: run Claude Code inside the sandbox as a fake OWUI user. + +Run from the `sandbox/` directory after `docker compose up -d`: + + OPEN_TERMINAL_API_KEY=$(grep OPEN_TERMINAL_API_KEY .env | cut -d= -f2) \ + ANTHROPIC_API_KEY=... \ + python -m smoke_test +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys + +from open_terminal_client import OpenTerminalClient +from claude_runner import ClaudeRunConfig, run_claude + + +async def main() -> int: + base_url = os.environ.get("OPEN_TERMINAL_URL", "http://localhost:8000") + api_key = os.environ["OPEN_TERMINAL_API_KEY"] + anthropic_key = os.environ.get("ANTHROPIC_API_KEY") + oauth_token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + + if not (anthropic_key or oauth_token): + print("Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN", file=sys.stderr) + return 2 + + cfg = ClaudeRunConfig( + model=os.environ.get("CLAUDE_MODEL", "claude-haiku-4-5"), + api_key=anthropic_key, + oauth_token=oauth_token, + workdir="~/chat-smoke", + max_turns=5, + ) + + prompt = ( + "Create a file called hello.py that prints 'hi from sandbox' and " + "then run it. Report the output." + ) + + async with OpenTerminalClient(base_url, api_key) as client: + async for event in run_claude(client, user_id="smoke", prompt=prompt, cfg=cfg): + etype = event.get("type", "?") + if etype == "_stderr": + print(f"[stderr] {event['text']}", end="", file=sys.stderr) + elif etype == "_exit": + print(f"\n[exit code: {event['code']}]") + elif etype == "_raw": + print(f"[raw] {event['text']}") + else: + # Compact dump so you can eyeball the event stream. + print(json.dumps(event)[:200]) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/sync_pipe.py b/sync_pipe.py new file mode 100644 index 0000000..5e6679c --- /dev/null +++ b/sync_pipe.py @@ -0,0 +1,145 @@ +"""Push the local claude_agent_pipe_sandboxed.py to a running OpenWebUI. + +Usage (typical, from this directory): + set -a; source /home/thomas/projects/helm_assistant/.env; set +a + python3 sync_pipe.py + +Behaviour: + 1. Reads OPENWEBUI_URL (default http://localhost:${OPENWEBUI_PORT:-13000}) + and OPENWEBUI_API_KEY from the environment. The key never enters this + script's logs or output. + 2. GETs /api/v1/functions/ to find the sandboxed pipe's id. Match + priority: explicit `--id`, then exact id == SANDBOXED_PIPE_ID, + then a unique function whose content starts with the pipe's + `title: Claude Code (Sandboxed)` marker. + 3. POSTs /api/v1/functions/id/{id}/update with the current file + contents, preserving the function's existing `name` / `meta`. + + Existing valves and the `is_active` flag are untouched — the update + endpoint only rewrites `content` + `meta.manifest` + `name`. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +SANDBOXED_PIPE_ID = "claude-code-sandboxed" +SANDBOXED_PIPE_MARKER = "title: Claude Code (Sandboxed)" +PIPE_FILE = Path(__file__).parent / "claude_agent_pipe_sandboxed.py" + + +def api(method: str, url: str, key: str, body: dict | None = None) -> dict: + req = urllib.request.Request( + url, + method=method, + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + data=json.dumps(body).encode("utf-8") if body is not None else None, + ) + try: + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read().decode("utf-8") + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + raise SystemExit(f"HTTP {e.code} {method} {url}\n → {detail[:500]}") + + +def find_function_id(base: str, key: str, explicit_id: str | None) -> tuple[str, dict]: + """Return (id, current_function_record).""" + functions = api("GET", f"{base}/api/v1/functions/", key) + by_id = {f["id"]: f for f in functions} + + if explicit_id: + if explicit_id not in by_id: + raise SystemExit( + f"No function with id={explicit_id!r}. " + f"Available: {sorted(by_id)}" + ) + return explicit_id, by_id[explicit_id] + + if SANDBOXED_PIPE_ID in by_id: + return SANDBOXED_PIPE_ID, by_id[SANDBOXED_PIPE_ID] + + # Fall back: find functions whose content contains the title marker. + # The list endpoint doesn't include `content`, so fetch each candidate + # individually. Keep the search narrow — only unloaded pipes need it. + matches: list[tuple[str, dict]] = [] + for fid, meta in by_id.items(): + if meta.get("type") != "pipe": + continue + detail = api("GET", f"{base}/api/v1/functions/id/{fid}", key) + if SANDBOXED_PIPE_MARKER in (detail.get("content") or ""): + matches.append((fid, detail)) + if len(matches) == 1: + return matches[0] + if not matches: + raise SystemExit( + f"No function matches id={SANDBOXED_PIPE_ID!r} and no pipe " + f"contains '{SANDBOXED_PIPE_MARKER}'. " + f"Create it once in the OWUI UI, or pass --id ." + ) + raise SystemExit( + f"Multiple pipes contain '{SANDBOXED_PIPE_MARKER}': " + f"{[m[0] for m in matches]}. Pass --id explicitly." + ) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--id", help="Override function id (default: auto-detect).") + ap.add_argument( + "--url", + default=os.environ.get( + "OPENWEBUI_URL", + f"http://localhost:{os.environ.get('OPENWEBUI_PORT', '13000')}", + ), + help="OpenWebUI base URL.", + ) + args = ap.parse_args() + + key = os.environ.get("OPENWEBUI_API_KEY") + if not key: + print( + "OPENWEBUI_API_KEY not set. Source the helm_assistant .env first:\n" + " set -a; source /home/thomas/projects/helm_assistant/.env; set +a", + file=sys.stderr, + ) + return 2 + + if not PIPE_FILE.exists(): + print(f"Pipe file not found: {PIPE_FILE}", file=sys.stderr) + return 2 + + content = PIPE_FILE.read_text(encoding="utf-8") + # Pull the pipe's own name from its docstring frontmatter so we don't + # overwrite whatever the user titled it. Fall back to a sensible default. + m = re.search(r"^title:\s*(.+)$", content, re.M) + title = m.group(1).strip() if m else "Claude Code (Sandboxed)" + + base = args.url.rstrip("/") + fid, current = find_function_id(base, key, args.id) + + form = { + "id": fid, + "name": current.get("name") or title, + "content": content, + "meta": current.get("meta") or {"description": title, "manifest": {}}, + } + api("POST", f"{base}/api/v1/functions/id/{fid}/update", key, form) + print(f"✓ Synced {PIPE_FILE.name} → {base} (id={fid}, {len(content)} bytes)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())