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
|
# overwrites with the real credential. The placeholder value is never
|
||||||
# seen by Anthropic.
|
# seen by Anthropic.
|
||||||
env_parts.append("ANTHROPIC_AUTH_TOKEN=proxied")
|
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
|
# `claude --dangerously-skip-permissions` still refuses root unless told
|
||||||
# it's sandboxed. open-terminal runs processes as the per-user UID so this
|
# it's sandboxed. open-terminal runs processes as the per-user UID so this
|
||||||
# rarely matters, but setting it is harmless.
|
# 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
|
# that fail become synthetic _raw events so the caller can log them
|
||||||
# without disrupting the agent loop.
|
# without disrupting the agent loop.
|
||||||
buf = ""
|
buf = ""
|
||||||
|
exited_cleanly = False
|
||||||
try:
|
try:
|
||||||
async for stream, chunk in client.stream_output(handle, session_id=session_id):
|
async for stream, chunk in client.stream_output(handle, session_id=session_id):
|
||||||
if stream == "exit":
|
if stream == "exit":
|
||||||
|
|
@ -347,6 +354,7 @@ async def _stream_claude_events(
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
yield {"type": "_raw", "text": buf.strip()}
|
yield {"type": "_raw", "text": buf.strip()}
|
||||||
buf = ""
|
buf = ""
|
||||||
|
exited_cleanly = True
|
||||||
yield {"type": "_exit", "code": int(chunk)}
|
yield {"type": "_exit", "code": int(chunk)}
|
||||||
return
|
return
|
||||||
buf += chunk
|
buf += chunk
|
||||||
|
|
@ -359,9 +367,16 @@ async def _stream_claude_events(
|
||||||
yield json.loads(line)
|
yield json.loads(line)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
yield {"type": "_raw", "text": line}
|
yield {"type": "_raw", "text": line}
|
||||||
except asyncio.CancelledError:
|
finally:
|
||||||
await client.kill(handle, force=True)
|
# Reap orphan claude processes on *any* abnormal exit path —
|
||||||
raise
|
# 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
|
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
|
# 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
|
# root to write. Switch to root for the global install, then back so the
|
||||||
# container's runtime user and entrypoint stay identical to upstream.
|
# container's runtime user and entrypoint stay identical to upstream.
|
||||||
USER root
|
USER root
|
||||||
RUN npm install -g @anthropic-ai/claude-code@latest \
|
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||||
&& claude --version
|
&& claude --version
|
||||||
|
COPY cleanup.sh /opt/cleanup.sh
|
||||||
|
RUN chmod +x /opt/cleanup.sh
|
||||||
USER user
|
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.
|
- **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.
|
- **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.
|
- **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