feat: sandboxed Claude Code pipe via open-terminal
Adds `claude_agent_pipe_sandboxed.py`, a self-contained OpenWebUI pipe that shells `claude --output-format stream-json` inside an open-webui/ open-terminal container instead of running the Claude Agent SDK in-process. Each OWUI user gets a dedicated Linux account (mapped via the X-User-Id header) with a per-chat workspace directory that persists across turns and whose generated PNG/PDF/CSV artifacts are auto-inlined into the chat. Scaffolding under `sandbox/`: - Dockerfile extending ghcr.io/open-webui/open-terminal with Claude Code pre-installed system-wide (inherited by every provisioned user) - docker-compose.yml + README covering standalone deployment - Async HTTP client + stream-json runner for programmatic use `sync_pipe.py` pushes the pipe to a running OWUI via the admin functions API so iteration doesn't require manual repaste. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a4898f6d9a
commit
7e7fa4cb77
|
|
@ -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\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<details>\n<summary>💭 Thinking</summary>\n\n"
|
||||||
|
f"{text}\n\n</details>\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<details>\n"
|
||||||
|
f"<summary>{summary_text}</summary>\n\n"
|
||||||
|
f"{body_block}\n\n</details>\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<details>\n<summary>"
|
||||||
|
"<sub>⚙️ tool hiccup (retrying)</sub>"
|
||||||
|
"</summary>\n\n"
|
||||||
|
f"```\n{err}\n```\n\n</details>\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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 <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}
|
||||||
|
|
@ -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:
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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()))
|
||||||
|
|
@ -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 <existing_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())
|
||||||
Loading…
Reference in New Issue