5.6 KiB
Sandboxed Claude Code via open-terminal
Runs the Claude Code agent inside 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
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:
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 intopipe(); its stableidbecomes the sandbox account prefix.cfg.resume_session_id— look up thechat_id → claude_session_idmap 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
cwdpost-run for generated PDFs/CSVs/images and uploads them as OWUI artifacts. In the sandbox version, swap the filesystem scan forclient.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_idperchat_idin-process (same as the current pipe). Needs testing thatclaude --resumeworks cleanly across separatePOST /executecalls — 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.
Operations
Pinning the Claude Code CLI version
The Dockerfile pins via an ARG. Two clean builds produce identical claude --version:
docker compose build --build-arg CLAUDE_CODE_VERSION=2.1.120 open-terminal
Bump the default in the Dockerfile when you want the repo to track a new version.
Disk cleanup
Nothing is auto-deleted. /opt/cleanup.sh is installed in the image for explicit runs:
# Dry-run (safe): see what would be deleted, nothing touched.
docker compose exec \
-e CHAT_TTL_DAYS=30 -e SESSION_TTL_DAYS=90 -e CLEANUP_DRY_RUN=true \
open-terminal /opt/cleanup.sh
# Execute:
docker compose exec \
-e CHAT_TTL_DAYS=30 -e SESSION_TTL_DAYS=90 -e CLEANUP_DRY_RUN=false \
open-terminal /opt/cleanup.sh
Schedule nightly via host cron if desired. Defaults to dry-run to prevent surprise deletions.