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