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:
parent
4158af9ebe
commit
3cec4dc871
|
|
@ -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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue