The docx/pdf/pptx/xlsx skills' scripts assume a working set of Python
libraries (pypdf, pdfplumber, reportlab, openpyxl, lxml, defusedxml,
Pillow, markitdown) and native tools (soffice headless, pdftoppm). Without
them, first-turn invocations of those skills fail with ImportError — and
the egress allowlist's pypi.org entry can't save us because pip install
on every turn adds latency and exercises an attack surface we'd rather
not hit on the critical path.
Bakes everything into the image:
- libreoffice-{core,writer,calc,impress}: drops the GUI/Java bloat of
the meta package while keeping headless conversion for all three
office formats. Adds ~500 MB vs ~800+ MB for the full meta.
- poppler-utils + fonts-dejavu: pdftoppm binary, pdf2image backend,
and baseline glyph set so rendered PDFs aren't empty boxes.
- pip: pypdf pdfplumber pdf2image reportlab Pillow openpyxl
python-docx python-pptx pandas numpy matplotlib lxml defusedxml
markitdown[pptx]. Also covers the explicit DS ask (pandas/numpy/
matplotlib) so ad-hoc analysis doesn't hit pip on first use.
- npm -g docx: the JS library the docx skill prefers for generating
richly formatted new documents.
Final image is ~4.6 GB (up from ~1 GB). LibreOffice is the dominant term.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .. | ||
| Dockerfile | ||
| README.md | ||
| claude_runner.py | ||
| cleanup.sh | ||
| docker-compose.yml | ||
| managed-settings.json | ||
| open_terminal_client.py | ||
| smoke_test.py | ||
README.md
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.