openwebui-claude-code/sandbox
Thomas Friedel 05d53ade55 feat(sandbox): install document + DS tooling for vendored skills
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>
2026-04-18 23:49:08 +02:00
..
Dockerfile feat(sandbox): install document + DS tooling for vendored skills 2026-04-18 23:49:08 +02:00
README.md feat(sandbox): pin CLI, reap orphans, isolate chats, cleanup script 2026-04-18 22:20:29 +02:00
claude_runner.py feat: sandboxed Claude Code pipe via open-terminal 2026-04-18 21:02:01 +02:00
cleanup.sh feat(sandbox): pin CLI, reap orphans, isolate chats, cleanup script 2026-04-18 22:20:29 +02:00
docker-compose.yml feat: sandboxed Claude Code pipe via open-terminal 2026-04-18 21:02:01 +02:00
managed-settings.json feat(sandbox): extend managed allowlist with build + dataset egress domains 2026-04-18 23:39:34 +02:00
open_terminal_client.py feat: sandboxed Claude Code pipe via open-terminal 2026-04-18 21:02:01 +02:00
smoke_test.py feat: sandboxed Claude Code pipe via open-terminal 2026-04-18 21:02:01 +02:00

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 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.

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.