openwebui-claude-code/sandbox
Thomas Friedel 5c41f57f0b feat(sandbox): vendor Anthropic document + frontend skills into image
Bakes docx, pdf, pptx, xlsx, and frontend-design skills from
github.com/anthropics/skills into /opt/claude-skills/ at image build time
(sparse-checkout, shallow clone). On each chat turn the pipe symlinks them
into $CLAUDE_CONFIG_DIR/skills/ via the new ensure_skills() method.

Symlinks (not copies) mean:
  - zero per-chat disk cost regardless of user count;
  - image rebuilds that add or update a skill propagate automatically to
    existing chats on their next turn (ln -sfn replaces stale targets);
  - skills are read-only from the user's perspective — any attempt to
    mutate them hits /opt/claude-skills, which is root-owned.

The skill list is surfaced as a valve (SKILLS) so operators can disable
individual skills without a rebuild; it must remain a subset of what the
Dockerfile CLAUDE_SKILLS arg pulled in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:44:20 +02:00
..
Dockerfile feat(sandbox): vendor Anthropic document + frontend skills into image 2026-04-18 23:44:20 +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.