diff --git a/claude_agent_pipe_sandboxed.py b/claude_agent_pipe_sandboxed.py index 8bea235..226a237 100644 --- a/claude_agent_pipe_sandboxed.py +++ b/claude_agent_pipe_sandboxed.py @@ -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) # ============================================================================ diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile index 49c8b81..bb79d35 100644 --- a/sandbox/Dockerfile +++ b/sandbox/Dockerfile @@ -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 diff --git a/sandbox/README.md b/sandbox/README.md index b66aabf..933e5c7 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -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. diff --git a/sandbox/cleanup.sh b/sandbox/cleanup.sh new file mode 100755 index 0000000..de069a7 --- /dev/null +++ b/sandbox/cleanup.sh @@ -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-/ 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-/, 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-/ 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 </dev/null) +EOF + fi +done + +log "cleanup done — chat_deleted=$chat_deleted chat_kept=$chat_kept session_deleted=$session_deleted"