feat(sandbox): pin CLI, reap orphans, isolate chats, cleanup script

Addresses four hardening items in one change:

- #8 Pin @anthropic-ai/claude-code to 2.1.114 via Dockerfile ARG.
  `docker compose build --build-arg CLAUDE_CODE_VERSION=X.Y.Z` upgrades.

- #2 Move `client.kill()` from an `except CancelledError` branch into a
  `finally` guarded by an `exited_cleanly` flag. Any abnormal exit path
  (httpx timeout, generator GC'd, unexpected exception) now reaps the
  open-terminal child — previously only asyncio cancellation did, so
  dropped browsers left claude billing tokens until EXECUTE_TIMEOUT.

- #9 Per-chat CLAUDE_CONFIG_DIR=~/chat-<id>/.claude. Two concurrent
  chats for the same OWUI user no longer race each other on a shared
  ~/.claude/. Safe to shard now that credentials live in the proxy
  rather than .credentials.json.

- #1 sandbox/cleanup.sh installed at /opt/cleanup.sh. Not auto-run;
  README documents dry-run-by-default usage via `docker compose exec`.
  CHAT_TTL_DAYS and SESSION_TTL_DAYS control retention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Friedel 2026-04-18 22:20:29 +02:00
parent 4158af9ebe
commit 3cec4dc871
4 changed files with 154 additions and 4 deletions

View File

@ -279,6 +279,12 @@ def _claude_command(prompt: str, cfg: _ClaudeRunConfig) -> str:
# overwrites with the real credential. The placeholder value is never
# seen by Anthropic.
env_parts.append("ANTHROPIC_AUTH_TOKEN=proxied")
# Per-chat CLAUDE_CONFIG_DIR isolates session JSONL, settings.json, and
# any file state the CLI writes. Without this, two concurrent chats for
# the same OWUI user race each other on ~/.claude/. With the proxy
# holding credentials, the config dir stores only session history —
# safe to shard per chat and discard with the workdir.
env_parts.append(f"CLAUDE_CONFIG_DIR={shlex.quote(cfg.workdir + '/.claude')}")
# `claude --dangerously-skip-permissions` still refuses root unless told
# it's sandboxed. open-terminal runs processes as the per-user UID so this
# rarely matters, but setting it is harmless.
@ -337,6 +343,7 @@ async def _stream_claude_events(
# that fail become synthetic _raw events so the caller can log them
# without disrupting the agent loop.
buf = ""
exited_cleanly = False
try:
async for stream, chunk in client.stream_output(handle, session_id=session_id):
if stream == "exit":
@ -347,6 +354,7 @@ async def _stream_claude_events(
except json.JSONDecodeError:
yield {"type": "_raw", "text": buf.strip()}
buf = ""
exited_cleanly = True
yield {"type": "_exit", "code": int(chunk)}
return
buf += chunk
@ -359,9 +367,16 @@ async def _stream_claude_events(
yield json.loads(line)
except json.JSONDecodeError:
yield {"type": "_raw", "text": line}
except asyncio.CancelledError:
await client.kill(handle, force=True)
raise
finally:
# Reap orphan claude processes on *any* abnormal exit path —
# cancellation, httpx timeout, pipe generator GC'd mid-stream,
# unhandled exception. Prior to this, only CancelledError
# triggered the kill, so a dropped browser with OWUI still
# running left claude billing tokens for up to EXECUTE_TIMEOUT.
# `kill` is best-effort (swallows HTTP errors) so double-kills
# on already-exited processes are harmless.
if not exited_cleanly:
await client.kill(handle, force=True)
# ============================================================================

View File

@ -1,9 +1,18 @@
FROM ghcr.io/open-webui/open-terminal:latest
# Pin claude-code CLI so two clean builds from the same source produce
# identical behaviour. Override at build time:
# docker compose build --build-arg CLAUDE_CODE_VERSION=X.Y.Z open-terminal
# When bumping the default here, keep the image rebuild + test cycle
# together — the stream-json schema can change between versions.
ARG CLAUDE_CODE_VERSION=2.1.114
# Base image runs as uid=1000(user) with npm prefix /usr, which requires
# root to write. Switch to root for the global install, then back so the
# container's runtime user and entrypoint stay identical to upstream.
USER root
RUN npm install -g @anthropic-ai/claude-code@latest \
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& claude --version
COPY cleanup.sh /opt/cleanup.sh
RUN chmod +x /opt/cleanup.sh
USER user

View File

@ -73,3 +73,33 @@ The renderer's `_tool_preview` / `_tool_input_block` helpers already work on the
- **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`:
```sh
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:
```sh
# 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.

96
sandbox/cleanup.sh Executable file
View File

@ -0,0 +1,96 @@
#!/bin/sh
# Reclaim disk inside the open-terminal /home volume.
#
# Not installed into the image's startup — shipped as an explicit-action
# script so nothing gets surprise-deleted. Run from the host via cron/timer,
# or `docker compose exec open-terminal /opt/cleanup.sh`.
#
# Env vars (all default to "disabled" — no destructive defaults):
# CHAT_TTL_DAYS delete ~/chat-<id>/ dirs whose newest-file mtime
# exceeds this many days. 0 = skip.
# SESSION_TTL_DAYS delete session JSONLs under
# ~/*/.claude/projects/*/*.jsonl older than this
# many days. 0 = skip. Note: we now put .claude
# *inside* each chat-<id>/, so this is redundant
# once CHAT_TTL_DAYS is in use — kept for pre-
# migration homes that still have a flat ~/.claude.
# CLEANUP_DRY_RUN if "true", print what would be deleted. Default true
# the first time you run this — flip explicitly after
# reviewing the output.
#
# Exit 0 on normal completion (including dry-run). Non-zero only on hard
# errors (bad env, cannot read /home).
set -eu
CHAT_TTL_DAYS="${CHAT_TTL_DAYS:-0}"
SESSION_TTL_DAYS="${SESSION_TTL_DAYS:-0}"
DRY_RUN="${CLEANUP_DRY_RUN:-true}"
log() { printf "[%s] %s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*"; }
if [ "$CHAT_TTL_DAYS" = 0 ] && [ "$SESSION_TTL_DAYS" = 0 ]; then
log "both TTLs are 0; nothing to do. Set CHAT_TTL_DAYS and/or SESSION_TTL_DAYS."
exit 0
fi
log "cleanup start — DRY_RUN=$DRY_RUN CHAT_TTL_DAYS=$CHAT_TTL_DAYS SESSION_TTL_DAYS=$SESSION_TTL_DAYS"
chat_deleted=0
chat_kept=0
session_deleted=0
# Iterate OWUI users. Skip root, distro accounts, etc.
for home in /home/owui_*; do
[ -d "$home" ] || continue
user=$(basename "$home")
# --- chat-<id>/ dirs ---
if [ "$CHAT_TTL_DAYS" -gt 0 ]; then
# `find ... -type d` can't filter on newest-file-mtime cheaply;
# emulate with a per-dir check. One process per user, bounded by
# #chats (rarely >100).
for chatdir in "$home"/chat-*; do
[ -d "$chatdir" ] || continue
# Most recent mtime of any file *inside* (not the dir itself,
# which gets touched by subdir creation).
newest=$(find "$chatdir" -type f -printf '%T@\n' 2>/dev/null | sort -nr | head -1)
if [ -z "$newest" ]; then
# Empty chat dir. Treat as old.
newest=0
fi
now=$(date +%s)
age_days=$(awk "BEGIN { printf \"%d\", ($now - $newest) / 86400 }")
if [ "$age_days" -gt "$CHAT_TTL_DAYS" ]; then
if [ "$DRY_RUN" = "true" ]; then
log " would delete $chatdir (idle ${age_days}d)"
else
rm -rf -- "$chatdir"
log " deleted $chatdir (idle ${age_days}d)"
fi
chat_deleted=$((chat_deleted + 1))
else
chat_kept=$((chat_kept + 1))
fi
done
fi
# --- legacy flat ~/.claude/projects/ ---
if [ "$SESSION_TTL_DAYS" -gt 0 ] && [ -d "$home/.claude/projects" ]; then
# Find JSONLs older than TTL; delete whole parent slug dirs if
# they're fully aged out.
while IFS= read -r jsonl; do
if [ "$DRY_RUN" = "true" ]; then
log " would delete $jsonl"
else
rm -f -- "$jsonl"
log " deleted $jsonl"
fi
session_deleted=$((session_deleted + 1))
done <<EOF
$(find "$home/.claude/projects" -type f -name '*.jsonl' -mtime "+$SESSION_TTL_DAYS" 2>/dev/null)
EOF
fi
done
log "cleanup done — chat_deleted=$chat_deleted chat_kept=$chat_kept session_deleted=$session_deleted"