diff --git a/claude_desktop/CHANGELOG.md b/claude_desktop/CHANGELOG.md index 50733300cc..8dac59a729 100644 --- a/claude_desktop/CHANGELOG.md +++ b/claude_desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +## 1.32 (17-07-2026) + +- Bump `tokensave` from 7.2.0 to 7.4.0 (`rtk` was already pinned to its current latest GitHub release, `v0.43.0`; `headroom-ai` is intentionally installed unpinned from PyPI, so it already tracks latest at every build and had nothing to bump). Reviewed the intervening 7.3.0/7.4.0 release notes against every tokensave surface this add-on drives (`install --agent claude --git-hook yes`, `uninstall --agent claude`, `sync`, `init`, `doctor --agent claude`, `gain --all --range 30d`, and the `mcp__tokensave__*` tool set granted in `settings.json`): no flag, output shape, or MCP tool name used here changed. Directly relevant fixes carried along: `tokensave sync` auto-migrates a v12 database (missing the trait-dispatch caller cache) to v13 as a normal part of syncing, which the add-on's corruption-quarantine logic won't mistake for corruption since the schema migration doesn't produce a "malformed"/"not a database" error; and `install`/`uninstall`'s JSON writer now resolves a symlinked `~/.claude/settings.json` before its atomic rename instead of replacing the symlink with a plain file, so a dotfiles-managed settings file survives untouched. +- Fix startup TokenSave repository preparation silently doing nothing: both `81-tokensave_repositories.sh` and the indexing loop in `82-claude_tools.sh` read `tokensave_project_paths` through `done < <(bashio::config ...)`, but `bashio::config` begins with a `read -d ''` heredoc that always returns non-zero, and a process substitution inherits the `errexit` enabled by the bashio wrapper itself — so the subshell died before printing and every boot iterated over an empty list (no `safe.directory` persistence, no startup `sync`/`init`; command substitutions were unaffected because subshells drop `errexit` when `inherit_errexit` is off, which is why every other option lookup worked). Repositories only got indexed when tokensave's own git hooks or a manual `tokensave init` happened to run. The list is now captured with a command substitution first and the loop reads from the captured variable (here-string); same fix applied to `claude-tools-doctor.sh`. The indexing loop also moved ahead of the MCP-registration merge, and the merge now re-tightens the 0600 mode on the token-bearing configs even on no-change boots — a first-time `tokensave init` rewrites `~/.claude.json` itself at default permissions, which previously could leave the stored Home Assistant token world-readable until the next registration change. +- Simplification pass over the startup logic: every remaining line now serves a live purpose, with no change to what gets configured — Headroom (proxy routing, MCP registration, CLAUDE.md guidance, PostToolUse auto-compression, dashboard exposure), RTK (global files + PreToolUse hook), and TokenSave (full agent integration + per-repo indexing) are still applied automatically to every new session type (terminal, Desktop cowork/dispatch, cron). + - `82-claude_tools.sh`: the three hand-rolled `~/.claude/settings.json` hook mutators (rtk add, rtk remove, headroom PostToolUse) are replaced by one shared `manage_settings_hook` helper using the proven strip-then-re-append pass (same dedup/matcher-migration semantics; additionally no longer creates an empty `settings.json` when asked to remove a hook from a machine that never had one). The two copy-pasted CLAUDE.md guidance managers (headroom, ha-api-helper) collapse into one `manage_claude_md_block` helper producing byte-identical blocks, so existing installs are recognized without a rewrite. + - `81-tokensave_repositories.sh` is merged into the TokenSave loop of `82-claude_tools.sh`: the same path list was parsed twice with identical trimming/validation only so `safe.directory` could be persisted before repository detection ran as root. Detection now runs directly as the runtime user with a one-shot `safe.directory` override (the persisted entry is still written for tokensave's git hooks and Claude sessions), removing the duplicate loop and the root-reads-abc-gitconfig coupling. The battle-tested defensive sync/init block (flock, retries, corruption quarantine, init sentinel) is unchanged. + - The `/tmp/claude-desktop-command` indirection is gone: `82-claude_tools.sh` wrote the default launch command to a file that only `defaults/autostart` read, with the identical default hardcoded as its fallback — nothing else ever wrote it. `autostart` now launches Claude Desktop directly (keyring bootstrap unchanged). + - `82-claude_tools.sh` no longer ends with its own recursive chown of `~/.claude`, `~/.claude.json`, and `~/.config/Claude`: `84-claude_runtime_ownership.sh` already reconciles exactly those paths after all Claude configuration scripts have run. + - `83-claude_permissions.sh` drops the hidden `.addon-permission-mode.json` state file in favor of the same managed-value semantics used for `ANTHROPIC_BASE_URL`: `auto`/`bypass` set `permissions.defaultMode`, `strict` removes it only while it still holds an add-on-managed value (`auto`/`bypassPermissions`), and a hand-set custom value is never deleted. The old restore-from-state behavior could resurrect a stale value recorded on the first managed boot; the stale state file is cleaned up on upgrade. + - `80-configuration.sh` sheds branches that were unreachable in this image: `apk`/`pacman` installers (the base is Debian), the `pip3` fallback (`uv` is always baked in), and the no-op timezone error path (invalid `TZ` values are now actually detected against `/usr/share/zoneinfo` before the symlink is written). + - Remove the dead `auto_update` option from `config.yaml` and the README: its schema entry was removed back in 1.22 and `81-claude_update.sh` has updated Claude Desktop unconditionally (best-effort, offline-safe) ever since; the README now states that behavior instead of documenting a switch that did nothing. + ## 1.31 (16-07-2026) - Pin the LinuxServer selkies base image to a fixed version (`…-debianbookworm-45960cc3-ls113`) instead of the rolling `…-debianbookworm` tag. The rolling tag is rebuilt continuously (and itself installs selkies "latest" at base-build time), so the desktop/stream runtime could change under the add-on with no change to its own files — builds are now reproducible and the base only moves when this value is bumped deliberately. The pinned tags resolve to exactly the image the rolling tag currently points at (amd64 `sha256:6a4d5154…`, aarch64 `sha256:90914dfd…`). diff --git a/claude_desktop/Dockerfile b/claude_desktop/Dockerfile index 3d047e78f5..3894c5acb9 100644 --- a/claude_desktop/Dockerfile +++ b/claude_desktop/Dockerfile @@ -11,7 +11,7 @@ ARG BUILD_FROM ARG BUILD_VERSION ARG RTK_VERSION="v0.43.0" ARG RTK_COMMIT="5a7880d404db8364d602f2ecdc41dd790f64013f" -ARG TOKENSAVE_VERSION="7.2.0" +ARG TOKENSAVE_VERSION="7.4.0" # The upstream aarch64 release is cross-built on ubuntu-latest and requires # GLIBC 2.39. Build the pinned source on Bookworm instead so it is compatible diff --git a/claude_desktop/README.md b/claude_desktop/README.md index 5468d5cb7b..b08069ac96 100644 --- a/claude_desktop/README.md +++ b/claude_desktop/README.md @@ -70,7 +70,8 @@ Git synchronization hooks. A repository is indexed only when it is listed in approval, or explicit full bypass for trusted installations. - Automatic non-root runtime enforcement for bypass mode, including root-console wrapper launches. -- Optional runtime Claude Desktop updates from Anthropic's apt repository. +- Best-effort Claude Desktop update from Anthropic's apt repository at every + startup (skipped silently when offline). - Optional extra apt and pip package installation (pip installs use `uv`). - Baked-in `git`, GitHub CLI (`gh`), `ripgrep`, `jq`, `shellcheck`, `yamllint`, `hadolint`, and `actionlint`. @@ -95,7 +96,6 @@ Git synchronization hooks. A repository is indexed only when it is listed in | `PASSWORD` | | Optional password for direct Selkies ports. | | `DRINODE` | | Optional GPU device override for Selkies. | | `DNS_server` | `8.8.8.8` | DNS server used by the standard DNS module. | -| `auto_update` | `true` | Upgrade `claude-desktop` from Anthropic's apt repository at startup. | | `permission_mode` | `auto` | Claude Code permission policy: `strict`, `auto`, or `bypass`. | | `install_headroom` | `true` | Register Headroom MCP and run the supervised local proxy. | | `headroom_wrap_claude_code` | `true` | Route PATH-based Claude Code launches through the already-running Headroom proxy. | diff --git a/claude_desktop/config.yaml b/claude_desktop/config.yaml index 46e5af66a1..9e1cd469e9 100644 --- a/claude_desktop/config.yaml +++ b/claude_desktop/config.yaml @@ -39,7 +39,6 @@ options: data_location: /data/data additional_apps: "" additional_pip: "" - auto_update: true github_email: "" enable_ha_mcp: false ha_mcp_url: http://homeassistant:8123/api/mcp @@ -111,5 +110,5 @@ slug: claude_desktop tmpfs: true udev: true url: https://github.com/alexbelgium/hassio-addons -version: "1.31" +version: "1.32" video: true diff --git a/claude_desktop/rootfs/defaults/autostart b/claude_desktop/rootfs/defaults/autostart index a2ee4e6ad4..07421c580c 100644 --- a/claude_desktop/rootfs/defaults/autostart +++ b/claude_desktop/rootfs/defaults/autostart @@ -12,23 +12,7 @@ export GNOME_KEYRING_CONTROL SSH_AUTH_SOCK dbus-update-activation-environment --all >/dev/null 2>&1 || true # --password-store=gnome-libsecret forces Electron onto the libsecret backend instead of -# falling back to plaintext (and warning that the sign-in will not be saved). -CLAUDE_DESKTOP_COMMAND_FILE="/tmp/claude-desktop-command" -DEFAULT_CLAUDE_DESKTOP_COMMAND="claude-desktop --no-sandbox --disable-dev-shm-usage --password-store=gnome-libsecret" -if [ -s "$CLAUDE_DESKTOP_COMMAND_FILE" ]; then - CLAUDE_DESKTOP_COMMAND="$(cat "$CLAUDE_DESKTOP_COMMAND_FILE")" -else - CLAUDE_DESKTOP_COMMAND="$DEFAULT_CLAUDE_DESKTOP_COMMAND" -fi - -# Headroom is intentionally not injected into the Desktop process. Claude Desktop overrides -# ANTHROPIC_BASE_URL, so Desktop uses the registered Headroom MCP tools instead. - -# Launch the configured command. If a custom/wrapped command fails to start, fall back to -# the plain Claude Desktop launch so the app always comes up for the user. -if ! sh -c "$CLAUDE_DESKTOP_COMMAND"; then - if [ "$CLAUDE_DESKTOP_COMMAND" != "$DEFAULT_CLAUDE_DESKTOP_COMMAND" ]; then - echo "autostart: '$CLAUDE_DESKTOP_COMMAND' failed; falling back to default Claude Desktop launch" >&2 - exec sh -c "$DEFAULT_CLAUDE_DESKTOP_COMMAND" - fi -fi +# falling back to plaintext (and warning that the sign-in will not be saved). Headroom is +# intentionally not injected into the Desktop process: Claude Desktop force-overrides +# ANTHROPIC_BASE_URL (headroom #869), so Desktop uses the registered Headroom MCP tools. +exec claude-desktop --no-sandbox --disable-dev-shm-usage --password-store=gnome-libsecret diff --git a/claude_desktop/rootfs/etc/cont-init.d/80-configuration.sh b/claude_desktop/rootfs/etc/cont-init.d/80-configuration.sh index 1e920d46fa..fec6070156 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/80-configuration.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/80-configuration.sh @@ -1,58 +1,48 @@ #!/usr/bin/with-contenv bashio # shellcheck shell=bash -# shellcheck disable=SC2015 set -e +# The image is Debian-based (apt) and always ships uv, so those are the only installers used. if bashio::config.has_value 'additional_apps'; then bashio::log.info "Installing additional apps :" - NEWAPPS=$(bashio::config 'additional_apps') - if command -v "apt-get" &> /dev/null; then - apt-get update -o Acquire::http::Timeout=10 -o Acquire::https::Timeout=10 &> /dev/null || bashio::log.warning "Unable to update apt package lists" - fi - for packagestoinstall in ${NEWAPPS//,/ }; do + apt-get update -o Acquire::http::Timeout=10 -o Acquire::https::Timeout=10 &> /dev/null || bashio::log.warning "Unable to update apt package lists" + for packagestoinstall in $(bashio::config 'additional_apps' | tr ',' ' '); do bashio::log.green "... $packagestoinstall" - if command -v "apk" &> /dev/null; then - apk add --no-cache "$packagestoinstall" &> /dev/null || (bashio::log.fatal "Error : $packagestoinstall not found") - elif command -v "apt-get" &> /dev/null; then - apt-get install -yqq --no-install-recommends "$packagestoinstall" &> /dev/null || (bashio::log.fatal "Error : $packagestoinstall not found") - elif command -v "pacman" &> /dev/null; then - pacman --noconfirm -S "$packagestoinstall" &> /dev/null || (bashio::log.fatal "Error : $packagestoinstall not found") - fi + apt-get install -yqq --no-install-recommends "$packagestoinstall" &> /dev/null || bashio::log.fatal "Error : $packagestoinstall not found" done fi if bashio::config.has_value 'additional_pip'; then for p in $(bashio::config 'additional_pip' | tr ',' ' '); do bashio::log.green "... pip: $p" - # Prefer uv (much faster resolver/installer); fall back to pip3 when unavailable. - if command -v uv &> /dev/null; then - uv pip install --system --break-system-packages "$p" || bashio::log.fatal "Error: pip package $p failed" - else - pip3 install --break-system-packages "$p" || bashio::log.fatal "Error: pip package $p failed" - fi + uv pip install --system --break-system-packages "$p" || bashio::log.fatal "Error: pip package $p failed" done fi if bashio::config.has_value 'TZ'; then TIMEZONE=$(bashio::config 'TZ') - bashio::log.info "Setting timezone to $TIMEZONE" - ln -snf /usr/share/zoneinfo/"$TIMEZONE" /etc/localtime - echo "$TIMEZONE" > /etc/timezone -fi || (bashio::log.fatal "Error : $TIMEZONE not found. Here is a list of valid timezones : https://manpages.ubuntu.com/manpages/focal/man3/DateTime::TimeZone::Catalog.3pm.html") + if [ -f "/usr/share/zoneinfo/$TIMEZONE" ]; then + bashio::log.info "Setting timezone to $TIMEZONE" + ln -snf "/usr/share/zoneinfo/$TIMEZONE" /etc/localtime + echo "$TIMEZONE" > /etc/timezone + else + bashio::log.fatal "Error : $TIMEZONE not found. Here is a list of valid timezones : https://manpages.ubuntu.com/manpages/focal/man3/DateTime::TimeZone::Catalog.3pm.html" + fi +fi if bashio::config.has_value 'KEYBOARD'; then KEYBOARD=$(bashio::config 'KEYBOARD') bashio::log.info "Setting keyboard to $KEYBOARD" if [ -d /var/run/s6/container_environment ]; then printf "%s" "$KEYBOARD" > /var/run/s6/container_environment/KEYBOARD; fi -grep -qxF "KEYBOARD=\"$KEYBOARD\"" ~/.bashrc 2>/dev/null || printf "%s\n" "KEYBOARD=\"$KEYBOARD\"" >> ~/.bashrc -fi || true + grep -qxF "KEYBOARD=\"$KEYBOARD\"" ~/.bashrc 2> /dev/null || printf "%s\n" "KEYBOARD=\"$KEYBOARD\"" >> ~/.bashrc +fi if bashio::config.has_value 'PASSWORD'; then bashio::log.info "Setting password to the value defined in options" PASSWORD=$(bashio::config 'PASSWORD') passwd -d abc echo -e "$PASSWORD\n$PASSWORD" | passwd abc -elif ! bashio::config.has_value 'PASSWORD' && { [[ -n "$(bashio::addon.port "3000")" ]] || [[ -n "$(bashio::addon.port "3001")" ]] }; then +elif [[ -n "$(bashio::addon.port "3000")" ]] || [[ -n "$(bashio::addon.port "3001")" ]]; then bashio::log.warning "SEVERE RISK IDENTIFIED" bashio::log.warning "You are opening an external port but your password is not defined" bashio::log.warning "You risk being hacked ! Please disable the external ports, or use a password" diff --git a/claude_desktop/rootfs/etc/cont-init.d/81-tokensave_repositories.sh b/claude_desktop/rootfs/etc/cont-init.d/81-tokensave_repositories.sh deleted file mode 100755 index 1bb0e63c4a..0000000000 --- a/claude_desktop/rootfs/etc/cont-init.d/81-tokensave_repositories.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/with-contenv bashio -# shellcheck shell=bash -set -e -set -o pipefail - -if ! bashio::config.true 'install_tokensave' || ! command -v git > /dev/null 2>&1; then - exit 0 -fi - -declare -A REPOS_SEEN=() -# bashio::config prints its result without a trailing newline, so the last record arrives -# with read returning non-zero; the extra test keeps that final path in the loop. -while IFS= read -r configured_path || [ -n "$configured_path" ]; do - configured_path="${configured_path#"${configured_path%%[![:space:]]*}"}" - configured_path="${configured_path%"${configured_path##*[![:space:]]}"}" - if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then - continue - fi - - case "$configured_path" in - /*) ;; - *) continue ;; - esac - [ -d "$configured_path" ] || continue - - # The one-shot safe.directory override is used only to discover the repository root. - # Persist the resolved root in the shared runtime user's Git config before 82-claude_tools.sh - # performs normal repository detection, avoiding Git's dubious-ownership rejection. - repo_root="$(s6-setuidgid abc env HOME="$HOME" \ - git -c safe.directory='*' -C "$configured_path" rev-parse --show-toplevel 2> /dev/null || true)" - [ -n "$repo_root" ] && [ "$repo_root" != "/" ] || continue - [[ -z "${REPOS_SEEN[$repo_root]:-}" ]] || continue - REPOS_SEEN[$repo_root]=1 - - if ! s6-setuidgid abc env HOME="$HOME" git config --global --get-all safe.directory \ - | grep -Fxq -- "$repo_root"; then - s6-setuidgid abc env HOME="$HOME" git config --global --add safe.directory "$repo_root" - bashio::log.info "Marked TokenSave repository as safe for Git: ${repo_root}" - fi -# bashio::config prints list options one entry per line ("null" when the key is absent); -# bashio::config.array only exists in the repo's standalone bashio, not in the real bashio here. -done < <(bashio::config 'tokensave_project_paths') diff --git a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh index 9c18e3995f..07b5096e3d 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh @@ -3,19 +3,125 @@ set -e set -o pipefail -# 20-folders.sh already remapped abc to the effective runtime identity (never root in bypass -# mode), so follow abc instead of re-reading the raw PUID/PGID options here. -RUNTIME_UID="$(id -u abc)" -RUNTIME_GID="$(id -g abc)" mkdir -p "$HOME/.claude" +CLAUDE_MD="$HOME/.claude/CLAUDE.md" run_as_runtime_user() { s6-setuidgid abc env HOME="$HOME" "$@" } -CLAUDE_DESKTOP_COMMAND_FILE="/tmp/claude-desktop-command" -DEFAULT_CLAUDE_DESKTOP_COMMAND='claude-desktop --no-sandbox --disable-dev-shm-usage --password-store=gnome-libsecret' -printf '%s\n' "$DEFAULT_CLAUDE_DESKTOP_COMMAND" > "$CLAUDE_DESKTOP_COMMAND_FILE" +# Managed, idempotent guidance block in the user's global CLAUDE.md, delimited by +# "" markers. `add` appends the +# block (body on stdin) unless the marker is already present; `remove` strips the whole +# block, surrounding blank padding included, and leaves everything else untouched. +manage_claude_md_block() { + local name="$1" action="$2" + local begin="" + if [ "$action" = "add" ]; then + if ! { [ -f "$CLAUDE_MD" ] && grep -qF "$begin" "$CLAUDE_MD"; }; then + bashio::log.info "Adding ${name} guidance to CLAUDE.md" + mkdir -p "$(dirname "$CLAUDE_MD")" + { + [ -s "$CLAUDE_MD" ] && printf '\n' + printf '%s\n' "$begin" + cat + printf '%s\n' "" + } >> "$CLAUDE_MD" + fi + elif [ -f "$CLAUDE_MD" ] && grep -qF "$begin" "$CLAUDE_MD"; then + bashio::log.info "Removing ${name} guidance from CLAUDE.md" + CLAUDE_MD="$CLAUDE_MD" BLOCK_NAME="$name" python3 - <<'PY' || bashio::log.warning "Unable to remove the ${name} guidance automatically" +import os +import re +from pathlib import Path + +path = Path(os.environ["CLAUDE_MD"]) +name = re.escape(os.environ["BLOCK_NAME"]) +text = path.read_text(encoding="utf-8") +pattern = re.compile( + rf"\n*.*?" + rf"\n?", + re.DOTALL, +) +new = pattern.sub("", text) +if new != text: + path.write_text(new, encoding="utf-8") +PY + fi +} + +# Managed hook entry in ~/.claude/settings.json (settings hooks apply to terminal, cowork, +# dispatch and cron sessions alike). The managed command is stripped everywhere first and +# re-appended when adding, so one pass handles removal, de-duplication, and matcher migration +# on upgrades; hooks owned by other tools (e.g. tokensave's own entries) are preserved, and +# the final text comparison keeps the write idempotent across boots. +manage_settings_hook() { + # manage_settings_hook + HOOK_EVENT="$1" HOOK_MATCHER="$2" HOOK_COMMAND="$3" HOOK_ACTION="$4" \ + python3 - <<'PY' || bashio::log.warning "Unable to update the $1 hook for '$3'" +import json +import os +from pathlib import Path + +event = os.environ["HOOK_EVENT"] +matcher = os.environ["HOOK_MATCHER"] +command = os.environ["HOOK_COMMAND"] +action = os.environ["HOOK_ACTION"] + +path = Path.home() / ".claude" / "settings.json" +original = path.read_text() if path.exists() else None +if original is None and action != "add": + raise SystemExit(0) +try: + data = json.loads(original) if original is not None else {} + if not isinstance(data, dict): + data = {} +except Exception: + if action != "add": + raise SystemExit(0) + path.rename(path.with_suffix(path.suffix + ".bak")) + original = None + data = {} + +hooks = data.get("hooks") if isinstance(data.get("hooks"), dict) else {} +entries = hooks.get(event) if isinstance(hooks.get(event), list) else [] + +filtered = [] +for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list): + filtered.append(entry) + continue + kept = [ + item + for item in entry["hooks"] + if not (isinstance(item, dict) and item.get("command") == command) + ] + if len(kept) != len(entry["hooks"]): + if not kept: + continue + entry = dict(entry) + entry["hooks"] = kept + filtered.append(entry) +entries = filtered + +if action == "add": + entries.append({"matcher": matcher, "hooks": [{"type": "command", "command": command}]}) + +if entries: + hooks[event] = entries +else: + hooks.pop(event, None) +if hooks: + data["hooks"] = hooks +else: + data.pop("hooks", None) + +serialized = json.dumps(data, indent=2) + "\n" +if serialized != original: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(serialized) +PY +} # Headroom's proxy routing works by setting ANTHROPIC_BASE_URL, which the Claude Desktop # Electron app force-overrides to the production endpoint (headroom #869). Desktop therefore @@ -57,6 +163,136 @@ elif command -v tokensave &> /dev/null; then || bashio::log.warning "tokensave Claude Code integration removal failed" fi +# Initialize or incrementally sync only explicitly configured repositories. TokenSave deliberately +# requires one-time per-project opt-in; an empty list therefore has no startup or storage cost. +# Runs before the MCP registration merge below on purpose: a first-time `tokensave init` also +# rewrites ~/.claude.json itself (at default permissions), and the merge afterwards reconciles +# the managed entries and re-tightens the file mode around the stored HA token. +if $TOKENSAVE_ENABLED; then + declare -A TOKENSAVE_REPOS_SEEN=() + # Capture the list BEFORE looping: bashio::config's internals trip the errexit that + # process substitution inherits from the bashio wrapper (a `read -d ''` that always + # returns non-zero), so `done < <(bashio::config ...)` silently fed the loop an EMPTY + # list — the startup index/sync never ran. Command substitution runs without errexit + # (inherit_errexit is off), making this form reliable. bashio::config prints list + # entries one per line, without a trailing newline and as "null" when the key is absent + # (bashio::config.array only exists in the repo's standalone bashio, not the real one + # here); the `|| [ -n ... ]` test keeps the final unterminated record in the loop. + TOKENSAVE_PROJECT_PATHS="$(bashio::config 'tokensave_project_paths')" + while IFS= read -r configured_path || [ -n "$configured_path" ]; do + # Trim surrounding whitespace while preserving spaces inside paths. + configured_path="${configured_path#"${configured_path%%[![:space:]]*}"}" + configured_path="${configured_path%"${configured_path##*[![:space:]]}"}" + if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then + continue + fi + + case "$configured_path" in + /*) ;; + *) + bashio::log.warning "Skipping non-absolute tokensave_project_paths entry: ${configured_path}" + continue + ;; + esac + if [ ! -d "$configured_path" ]; then + bashio::log.warning "Skipping missing TokenSave project path: ${configured_path}" + continue + fi + + # The one-shot safe.directory override is used only to discover the repository root. + repo_root="$(run_as_runtime_user git -c safe.directory='*' -C "$configured_path" rev-parse --show-toplevel 2> /dev/null || true)" + if [ -z "$repo_root" ] || [ "$repo_root" = "/" ]; then + bashio::log.warning "Skipping TokenSave path that is not a supported Git repository: ${configured_path}" + continue + fi + if [[ -n "${TOKENSAVE_REPOS_SEEN[$repo_root]:-}" ]]; then + continue + fi + TOKENSAVE_REPOS_SEEN[$repo_root]=1 + + # Persist the resolved root in the runtime user's Git config so the sync/init below, + # tokensave's git hooks, and Claude sessions all pass Git's dubious-ownership check. + if ! run_as_runtime_user git config --global --get-all safe.directory \ + | grep -Fxq -- "$repo_root"; then + run_as_runtime_user git config --global --add safe.directory "$repo_root" + bashio::log.info "Marked TokenSave repository as safe for Git: ${repo_root}" + fi + + bashio::log.info "Preparing TokenSave index: ${repo_root}" + # Prepare the per-repo semantic graph defensively so a hard add-on stop or storage + # hiccup can never leave a broken index that fails every subsequent boot: + # * a startup-scoped flock serializes against an overlapping restart (and any git + # post-commit/checkout sync hook that fires mid-boot); waits up to 60s for the + # other writer to finish rather than silently skipping, since a held lock clears + # itself the moment its holder exits or dies (the kernel releases flock on exit); + # * an existing index is refreshed with a cheap incremental `sync`, retried a few + # times because SQLITE_BUSY under lock contention is transient, not corruption; + # * quarantine is reserved for sync failures whose stderr actually names database + # corruption (SQLite's own "malformed"/"not a database"/"disk image" wording) or + # a half-written index from an interrupted `init` (sentinel-flagged). Any other + # failure (permissions, disk full, missing binary, ...) leaves the existing index + # untouched and simply retries on the next start — corruption should self-heal, + # a transient environment problem should not nuke a healthy graph; + # * `init` is bracketed by a sentinel file so an interrupted full build is detected + # as incomplete on the next start and rebuilt rather than trusted. + # All file operations run as the abc runtime user because the repo `.tokensave` + # directory is not covered by the startup ownership pass. + # shellcheck disable=SC2016 # single-quoted on purpose: $1/$db/etc. expand in the abc shell + run_as_runtime_user bash -c ' + set -o pipefail + repo_root="$1" + ts_dir="$repo_root/.tokensave" + db="$ts_dir/tokensave.db" + lock="$ts_dir/.startup.lock" + initflag="$ts_dir/.init-incomplete" + mkdir -p "$ts_dir" + exec 9>"$lock" + if ! flock -w 60 9; then + echo "TokenSave: index still locked for $repo_root after 60s; skipping startup sync" >&2 + exit 0 + fi + is_corruption() { + printf "%s" "$1" | grep -qiE "malformed|not a database|file is encrypted|disk image|database.*corrupt" + } + quarantine() { + stamp="$(date +%Y%m%d-%H%M%S)" + bdir="$ts_dir/corrupt-$stamp" + mkdir -p "$bdir" + for f in "$db" "$db-wal" "$db-shm"; do + [ -e "$f" ] && mv -f "$f" "$bdir/" 2>/dev/null || true + done + echo "TokenSave: quarantined suspect index to $bdir" >&2 + } + if [ -f "$db" ] && [ ! -f "$initflag" ]; then + attempt=1 + while :; do + sync_err="$(tokensave sync "$repo_root" 2>&1 1>/dev/null)" && exit 0 + [ "$attempt" -ge 3 ] && break + echo "TokenSave: sync attempt $attempt failed for $repo_root; retrying" >&2 + attempt=$((attempt + 1)) + sleep 2 + done + if is_corruption "$sync_err"; then + echo "TokenSave: sync failed after retries for $repo_root (corruption detected); rebuilding index" >&2 + quarantine + else + echo "TokenSave: sync failed after retries for $repo_root (no corruption signature); leaving index in place, will retry next start" >&2 + echo "TokenSave: last sync error: $sync_err" >&2 + exit 1 + fi + elif [ -f "$db" ]; then + echo "TokenSave: previous init did not finish for $repo_root; rebuilding index" >&2 + quarantine + fi + : > "$initflag" + tokensave init "$repo_root" && { rm -f "$initflag"; exit 0; } + echo "TokenSave: init failed for $repo_root; will retry on next start" >&2 + exit 1 + ' _ "$repo_root" \ + || bashio::log.warning "TokenSave preparation failed for ${repo_root}" + done <<< "$TOKENSAVE_PROJECT_PATHS" +fi + HA_MCP_ENABLED=false HA_MCP_URL="" HA_MCP_TOKEN="" @@ -157,144 +393,24 @@ for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_C elif existing is not None and is_managed(name, existing): del servers[name] changed = True - if not changed: - continue - if servers: - data["mcpServers"] = servers - else: - data.pop("mcpServers", None) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2) + "\n") - # The Home Assistant long-lived access token is stored here in clear text. - path.chmod(0o600) + if changed: + if servers: + data["mcpServers"] = servers + else: + data.pop("mcpServers", None) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n") + # The Home Assistant long-lived access token is stored here in clear text. Enforced even + # on no-change boots because tokensave's own writes can recreate the file with default + # permissions between merges. + if path.exists(): + path.chmod(0o600) PY -# Initialize or incrementally sync only explicitly configured repositories. TokenSave deliberately -# requires one-time per-project opt-in; an empty list therefore has no startup or storage cost. -if $TOKENSAVE_ENABLED; then - declare -A TOKENSAVE_REPOS_SEEN=() - # bashio::config prints its result without a trailing newline, so the last record arrives - # with read returning non-zero; the extra test keeps that final path in the loop. - while IFS= read -r configured_path || [ -n "$configured_path" ]; do - # Trim surrounding whitespace while preserving spaces inside paths. - configured_path="${configured_path#"${configured_path%%[![:space:]]*}"}" - configured_path="${configured_path%"${configured_path##*[![:space:]]}"}" - if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then - continue - fi - - case "$configured_path" in - /*) ;; - *) - bashio::log.warning "Skipping non-absolute tokensave_project_paths entry: ${configured_path}" - continue - ;; - esac - if [ ! -d "$configured_path" ]; then - bashio::log.warning "Skipping missing TokenSave project path: ${configured_path}" - continue - fi - - repo_root="$(git -C "$configured_path" rev-parse --show-toplevel 2> /dev/null || true)" - if [ -z "$repo_root" ] || [ "$repo_root" = "/" ]; then - bashio::log.warning "Skipping TokenSave path that is not a supported Git repository: ${configured_path}" - continue - fi - if [[ -n "${TOKENSAVE_REPOS_SEEN[$repo_root]:-}" ]]; then - continue - fi - TOKENSAVE_REPOS_SEEN[$repo_root]=1 - - bashio::log.info "Preparing TokenSave index: ${repo_root}" - # Prepare the per-repo semantic graph defensively so a hard add-on stop or storage - # hiccup can never leave a broken index that fails every subsequent boot: - # * a startup-scoped flock serializes against an overlapping restart (and any git - # post-commit/checkout sync hook that fires mid-boot); waits up to 60s for the - # other writer to finish rather than silently skipping, since a held lock clears - # itself the moment its holder exits or dies (the kernel releases flock on exit); - # * an existing index is refreshed with a cheap incremental `sync`, retried a few - # times because SQLITE_BUSY under lock contention is transient, not corruption; - # * quarantine is reserved for sync failures whose stderr actually names database - # corruption (SQLite's own "malformed"/"not a database"/"disk image" wording) or - # a half-written index from an interrupted `init` (sentinel-flagged). Any other - # failure (permissions, disk full, missing binary, ...) leaves the existing index - # untouched and simply retries on the next start — corruption should self-heal, - # a transient environment problem should not nuke a healthy graph; - # * `init` is bracketed by a sentinel file so an interrupted full build is detected - # as incomplete on the next start and rebuilt rather than trusted. - # All file operations run as the abc runtime user because the repo `.tokensave` - # directory is not covered by this script's final ownership pass. - # shellcheck disable=SC2016 # single-quoted on purpose: $1/$db/etc. expand in the abc shell - run_as_runtime_user bash -c ' - set -o pipefail - repo_root="$1" - ts_dir="$repo_root/.tokensave" - db="$ts_dir/tokensave.db" - lock="$ts_dir/.startup.lock" - initflag="$ts_dir/.init-incomplete" - mkdir -p "$ts_dir" - exec 9>"$lock" - if ! flock -w 60 9; then - echo "TokenSave: index still locked for $repo_root after 60s; skipping startup sync" >&2 - exit 0 - fi - is_corruption() { - printf "%s" "$1" | grep -qiE "malformed|not a database|file is encrypted|disk image|database.*corrupt" - } - quarantine() { - stamp="$(date +%Y%m%d-%H%M%S)" - bdir="$ts_dir/corrupt-$stamp" - mkdir -p "$bdir" - for f in "$db" "$db-wal" "$db-shm"; do - [ -e "$f" ] && mv -f "$f" "$bdir/" 2>/dev/null || true - done - echo "TokenSave: quarantined suspect index to $bdir" >&2 - } - if [ -f "$db" ] && [ ! -f "$initflag" ]; then - attempt=1 - while :; do - sync_err="$(tokensave sync "$repo_root" 2>&1 1>/dev/null)" && exit 0 - [ "$attempt" -ge 3 ] && break - echo "TokenSave: sync attempt $attempt failed for $repo_root; retrying" >&2 - attempt=$((attempt + 1)) - sleep 2 - done - if is_corruption "$sync_err"; then - echo "TokenSave: sync failed after retries for $repo_root (corruption detected); rebuilding index" >&2 - quarantine - else - echo "TokenSave: sync failed after retries for $repo_root (no corruption signature); leaving index in place, will retry next start" >&2 - echo "TokenSave: last sync error: $sync_err" >&2 - exit 1 - fi - elif [ -f "$db" ]; then - echo "TokenSave: previous init did not finish for $repo_root; rebuilding index" >&2 - quarantine - fi - : > "$initflag" - tokensave init "$repo_root" && { rm -f "$initflag"; exit 0; } - echo "TokenSave: init failed for $repo_root; will retry on next start" >&2 - exit 1 - ' _ "$repo_root" \ - || bashio::log.warning "TokenSave preparation failed for ${repo_root}" - # bashio::config prints list options one entry per line ("null" when the key is absent); - # bashio::config.array only exists in the repo's standalone bashio, not in the real bashio here. - done < <(bashio::config 'tokensave_project_paths') -fi - # Guide Claude to actually use the Headroom compression tools so the MCP integration produces -# real savings when transparent proxying is unavailable. Managed, idempotent block appended to -# the user's global CLAUDE.md; removed when Headroom is disabled. -CLAUDE_MD="$HOME/.claude/CLAUDE.md" -HEADROOM_GUIDE_BEGIN="" +# real savings when transparent proxying is unavailable. if $HEADROOM_ENABLED; then - mkdir -p "$(dirname "$CLAUDE_MD")" - if ! { [ -f "$CLAUDE_MD" ] && grep -qF "$HEADROOM_GUIDE_BEGIN" "$CLAUDE_MD"; }; then - bashio::log.info "Adding headroom usage guidance to CLAUDE.md" - { - [ -s "$CLAUDE_MD" ] && printf '\n' - cat <<'MD' - + manage_claude_md_block headroom add <<'MD' ## Headroom context compression A local Headroom proxy (127.0.0.1:8787) backs the `headroom` MCP tools. To save context tokens: @@ -304,28 +420,9 @@ search results, JSON/config dumps, big command outputs, roughly >500 tokens — the raw content. Call `mcp__headroom__headroom_retrieve` with that hash when you need the full original back. Skip compression for error/stack-trace output (Headroom deliberately protects it) and for small or one-off content. Use `mcp__headroom__headroom_stats` to check savings. - MD - } >> "$CLAUDE_MD" - fi -elif [ -f "$CLAUDE_MD" ] && grep -qF "$HEADROOM_GUIDE_BEGIN" "$CLAUDE_MD"; then - bashio::log.info "Removing headroom usage guidance from CLAUDE.md" - CLAUDE_MD="$CLAUDE_MD" python3 - <<'PY' || bashio::log.warning "Unable to remove headroom guidance automatically" -import os -import re -from pathlib import Path - -path = Path(os.environ["CLAUDE_MD"]) -text = path.read_text() -pattern = re.compile( - r"\n*.*?" - r"\n?", - re.DOTALL, -) -new = pattern.sub("", text) -if new != text: - path.write_text(new) -PY +else + manage_claude_md_block headroom remove fi # Route every Claude Code session through the Headroom proxy via the `env` block in the user's @@ -386,8 +483,7 @@ if changed: PY # Compress large tool outputs automatically in every Claude Code session via a managed -# PostToolUse hook (settings.json hooks apply to terminal, cowork, dispatch and cron sessions -# alike). Desktop-spawned sessions pin ANTHROPIC_BASE_URL to the production endpoint +# PostToolUse hook. Desktop-spawned sessions pin ANTHROPIC_BASE_URL to the production endpoint # (headroom #869) so the proxy never sees their traffic, and the CLAUDE.md guidance above only # helps when the model remembers to call the MCP tools. The hook closes that gap: outputs over # ~4000 chars from Bash/Grep/Glob/WebFetch are compressed with Headroom's rule-based pipeline @@ -405,84 +501,12 @@ if $HEADROOM_ENABLED && bashio::config.true 'headroom_auto_compress'; then bashio::log.warning "headroom-posttooluse-compress.py --self-test failed; not registering the auto-compression hook" fi fi -HEADROOM_HOOK_ACTION="$HEADROOM_HOOK_ACTION" HEADROOM_HOOK_CMD="$HEADROOM_HOOK_CMD" \ - HEADROOM_HOOK_MATCHER="Bash|Grep|Glob|WebFetch" \ - python3 - <<'PY' || bashio::log.warning "Unable to manage the Headroom auto-compression hook" -import json -import os -from pathlib import Path - -action = os.environ["HEADROOM_HOOK_ACTION"] -command = os.environ["HEADROOM_HOOK_CMD"] -matcher = os.environ["HEADROOM_HOOK_MATCHER"] - -path = Path.home() / ".claude" / "settings.json" -original = path.read_text() if path.exists() else None -try: - data = json.loads(original) if original is not None else {} - if not isinstance(data, dict): - data = {} -except Exception: - if action != "add": - raise SystemExit(0) - path.rename(path.with_suffix(path.suffix + ".bak")) - original = None - data = {} - -hooks = data.get("hooks") if isinstance(data.get("hooks"), dict) else {} -entries = hooks.get("PostToolUse") if isinstance(hooks.get("PostToolUse"), list) else [] - -# Strip the managed command everywhere first, then re-append when enabled: the same pass -# handles removal, de-duplication, and matcher migration on version upgrades. The final -# text comparison keeps the write idempotent across boots. -filtered = [] -for entry in entries: - if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list): - filtered.append(entry) - continue - kept = [ - item - for item in entry["hooks"] - if not (isinstance(item, dict) and item.get("command") == command) - ] - if len(kept) != len(entry["hooks"]): - if not kept: - continue - entry = dict(entry) - entry["hooks"] = kept - filtered.append(entry) -entries = filtered - -if action == "add": - entries.append({"matcher": matcher, "hooks": [{"type": "command", "command": command}]}) - -if entries: - hooks["PostToolUse"] = entries -else: - hooks.pop("PostToolUse", None) -if hooks: - data["hooks"] = hooks -else: - data.pop("hooks", None) - -serialized = json.dumps(data, indent=2) + "\n" -if serialized != original: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(serialized) -PY +manage_settings_hook PostToolUse "Bash|Grep|Glob|WebFetch" "$HEADROOM_HOOK_CMD" "$HEADROOM_HOOK_ACTION" # Tell Claude Code that it can configure Home Assistant over the Core API via the shipped -# `ha-cli` helper (no /config filesystem mount needed). Managed, idempotent block appended to -# the user's global CLAUDE.md; removed when the helper is disabled. Mirrors the headroom block. -HA_HELPER_GUIDE_BEGIN="" +# `ha-cli` helper (no /config filesystem mount needed). if bashio::config.true 'enable_ha_api_helper'; then - mkdir -p "$(dirname "$CLAUDE_MD")" - if ! { [ -f "$CLAUDE_MD" ] && grep -qF "$HA_HELPER_GUIDE_BEGIN" "$CLAUDE_MD"; }; then - bashio::log.info "Adding Home Assistant API helper guidance to CLAUDE.md" - { - [ -s "$CLAUDE_MD" ] && printf '\n' - cat <<'MD' - + manage_claude_md_block ha-api-helper add <<'MD' ## Configuring Home Assistant You can configure this Home Assistant instance through its Core API using the `ha-cli` @@ -502,120 +526,25 @@ Rules: run `ha-cli config` first to confirm connectivity; **read the current obj the user the intended change, then wait for confirmation** before any create/update/delete or any state-changing `call`; after writing, read the object back and reload if needed (e.g. `ha-cli call automation.reload`). - MD - } >> "$CLAUDE_MD" - fi -elif [ -f "$CLAUDE_MD" ] && grep -qF "$HA_HELPER_GUIDE_BEGIN" "$CLAUDE_MD"; then - bashio::log.info "Removing Home Assistant API helper guidance from CLAUDE.md" - CLAUDE_MD="$CLAUDE_MD" python3 - <<'PY' || bashio::log.warning "Unable to remove Home Assistant API helper guidance automatically" -import os -import re -from pathlib import Path - -path = Path(os.environ["CLAUDE_MD"]) -text = path.read_text(encoding="utf-8") -pattern = re.compile( - r"\n*.*?" - r"\n?", - re.DOTALL, -) -new = pattern.sub("", text) -if new != text: - path.write_text(new, encoding="utf-8") -PY +else + manage_claude_md_block ha-api-helper remove fi if bashio::config.true 'install_rtk'; then if command -v rtk &> /dev/null; then bashio::log.info "Configuring rtk Claude Code integration" + # `rtk init -g` writes ~/.claude/RTK.md and its @RTK.md include in CLAUDE.md, but in + # non-interactive mode it deliberately refuses to patch settings.json, so the hook + # entry that actually rewrites Bash commands is registered here. run_as_runtime_user env RTK_NONINTERACTIVE=1 rtk init -g \ || bashio::log.warning "rtk global files configuration failed" - python3 - <<'PY' || bashio::log.warning "Unable to configure rtk hook automatically" -import json -from pathlib import Path - -path = Path.home() / ".claude" / "settings.json" -try: - data = json.loads(path.read_text()) if path.exists() else {} - if not isinstance(data, dict): - data = {} -except Exception: - if path.exists(): - path.rename(path.with_suffix(path.suffix + ".bak")) - data = {} -hooks = data.setdefault("hooks", {}) -pre = hooks.setdefault("PreToolUse", []) -rtk_entry = {"matcher": "Bash", "hooks": [{"type": "command", "command": "rtk hook claude"}]} -if not any("rtk hook claude" in json.dumps(entry) for entry in pre if isinstance(entry, dict)): - pre.append(rtk_entry) -path.parent.mkdir(parents=True, exist_ok=True) -path.write_text(json.dumps(data, indent=2) + "\n") -PY + manage_settings_hook PreToolUse Bash "rtk hook claude" add else bashio::log.warning "rtk is not available" fi -elif [ -f "$HOME/.claude/settings.json" ]; then - bashio::log.info "Removing the add-on-managed rtk Claude Code hook" - python3 - <<'PY' || bashio::log.warning "Unable to remove rtk hook automatically" -import json -from pathlib import Path - -path = Path.home() / ".claude" / "settings.json" -data = json.loads(path.read_text()) -if not isinstance(data, dict): - raise TypeError("Claude settings must contain a JSON object") - -hooks = data.get("hooks") -if not isinstance(hooks, dict): - raise SystemExit(0) - -entries = hooks.get("PreToolUse") -if not isinstance(entries, list): - raise SystemExit(0) - -changed = False -filtered_entries = [] -for entry in entries: - if not isinstance(entry, dict) or entry.get("matcher") != "Bash": - filtered_entries.append(entry) - continue - - commands = entry.get("hooks") - if not isinstance(commands, list): - filtered_entries.append(entry) - continue - - filtered_commands = [ - command - for command in commands - if not ( - isinstance(command, dict) - and command.get("type") == "command" - and command.get("command") == "rtk hook claude" - ) - ] - if len(filtered_commands) == len(commands): - filtered_entries.append(entry) - continue - - changed = True - if filtered_commands: - updated_entry = dict(entry) - updated_entry["hooks"] = filtered_commands - filtered_entries.append(updated_entry) - -if changed: - if filtered_entries: - hooks["PreToolUse"] = filtered_entries - else: - hooks.pop("PreToolUse", None) - if hooks: - data["hooks"] = hooks - else: - data.pop("hooks", None) - path.write_text(json.dumps(data, indent=2) + "\n") -PY +else + manage_settings_hook PreToolUse Bash "rtk hook claude" remove fi if bashio::config.true 'install_caveman'; then @@ -631,10 +560,5 @@ else find "$HOME/.claude" -maxdepth 4 -iname '*caveman*' -exec rm -rf {} + 2> /dev/null || true fi -# Startup configuration runs as root, while Claude Desktop runs as abc. Return managed -# persistent files to the effective runtime UID/GID after all writes complete. -for managed_path in "$HOME/.claude" "$HOME/.claude.json" "$HOME/.config/Claude"; do - if [ -e "$managed_path" ]; then - chown -R -- "${RUNTIME_UID}:${RUNTIME_GID}" "$managed_path" || bashio::log.warning "Unable to set ownership on $managed_path" - fi -done +# Ownership of everything written above is reconciled by 84-claude_runtime_ownership.sh after +# the remaining Claude configuration scripts have run. diff --git a/claude_desktop/rootfs/etc/cont-init.d/83-claude_permissions.sh b/claude_desktop/rootfs/etc/cont-init.d/83-claude_permissions.sh index 99df9ed7e5..ecb016a544 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/83-claude_permissions.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/83-claude_permissions.sh @@ -3,31 +3,31 @@ set -e set -o pipefail -# 20-folders.sh already remapped abc to the effective runtime identity (never root in bypass -# mode), so follow abc instead of re-reading the raw PUID/PGID options here. -RUNTIME_UID="$(id -u abc)" -RUNTIME_GID="$(id -g abc)" PERMISSION_MODE="$(bashio::config 'permission_mode')" SETTINGS_PATH="$HOME/.claude/settings.json" -STATE_PATH="$HOME/.claude/.addon-permission-mode.json" case "$PERMISSION_MODE" in - strict|auto|bypass) ;; + strict | auto | bypass) ;; *) bashio::log.warning "Unknown permission_mode '${PERMISSION_MODE}'; falling back to strict" PERMISSION_MODE="strict" ;; esac +# Managed-value semantics, matching the ANTHROPIC_BASE_URL handling in 82-claude_tools.sh: +# auto/bypass set permissions.defaultMode to the add-on-managed value, and strict removes it +# only while it still holds one of those managed values — a defaultMode the user set by hand +# is never deleted. Ownership of the written file is reconciled by 84-claude_runtime_ownership.sh. mkdir -p "$(dirname "$SETTINGS_PATH")" -PERMISSION_MODE="$PERMISSION_MODE" SETTINGS_PATH="$SETTINGS_PATH" STATE_PATH="$STATE_PATH" python3 - <<'PY' +PERMISSION_MODE="$PERMISSION_MODE" SETTINGS_PATH="$SETTINGS_PATH" python3 - <<'PY' import json import os from pathlib import Path +MANAGED_VALUES = {"auto", "bypassPermissions"} + mode = os.environ["PERMISSION_MODE"] settings_path = Path(os.environ["SETTINGS_PATH"]) -state_path = Path(os.environ["STATE_PATH"]) try: settings = json.loads(settings_path.read_text()) if settings_path.exists() else {} @@ -38,33 +38,14 @@ except (OSError, json.JSONDecodeError): if not isinstance(settings, dict): settings = {} -try: - state = json.loads(state_path.read_text()) if state_path.exists() else None -except (OSError, json.JSONDecodeError): - state = None -if not isinstance(state, dict): - state = None - permissions = settings.get("permissions") if not isinstance(permissions, dict): permissions = {} if mode == "strict": - # Restore the value that existed before the add-on first managed this setting. - if state is not None: - if state.get("previous_exists"): - permissions["defaultMode"] = state.get("previous_value") - else: - permissions.pop("defaultMode", None) - state_path.unlink(missing_ok=True) + if permissions.get("defaultMode") in MANAGED_VALUES: + permissions.pop("defaultMode") else: - if state is None: - state = { - "previous_exists": "defaultMode" in permissions, - "previous_value": permissions.get("defaultMode"), - } - state_path.write_text(json.dumps(state, indent=2) + "\n") - state_path.chmod(0o600) permissions["defaultMode"] = "auto" if mode == "auto" else "bypassPermissions" if permissions: @@ -76,6 +57,9 @@ settings_path.write_text(json.dumps(settings, indent=2) + "\n") settings_path.chmod(0o600) PY +# Drop the state file older add-on versions used to remember the pre-add-on defaultMode. +rm -f "$HOME/.claude/.addon-permission-mode.json" + case "$PERMISSION_MODE" in strict) bashio::log.info "Claude Code permission mode: strict (normal prompts)" @@ -87,8 +71,3 @@ case "$PERMISSION_MODE" in bashio::log.warning "Claude Code permission mode: bypass (permission checks disabled for mounted data and available tools)" ;; esac - -chown -- "${RUNTIME_UID}:${RUNTIME_GID}" "$SETTINGS_PATH" 2> /dev/null || true -if [ -e "$STATE_PATH" ]; then - chown -- "${RUNTIME_UID}:${RUNTIME_GID}" "$STATE_PATH" 2> /dev/null || true -fi diff --git a/claude_desktop/rootfs/usr/local/bin/claude-tools-doctor.sh b/claude_desktop/rootfs/usr/local/bin/claude-tools-doctor.sh index 41a25e847b..81609470ce 100755 --- a/claude_desktop/rootfs/usr/local/bin/claude-tools-doctor.sh +++ b/claude_desktop/rootfs/usr/local/bin/claude-tools-doctor.sh @@ -56,7 +56,6 @@ else: print(f"permissions.defaultMode: {permissions.get('defaultMode', '')}") else: print("permissions: INVALID") -print(f"managed-state marker: {(Path.home() / '.claude/.addon-permission-mode.json').exists()}") PY section "MCP registrations (environment values redacted)" @@ -149,6 +148,9 @@ section "TokenSave" if bashio::config.true 'install_tokensave'; then tokensave doctor --agent claude || true tokensave gain --all --range 30d || true + # Capture before looping — see the matching comment in 82-claude_tools.sh: feeding the + # loop straight from `< <(bashio::config ...)` yields an empty list under errexit. + TOKENSAVE_PROJECT_PATHS="$(bashio::config 'tokensave_project_paths')" while IFS= read -r configured_path || [ -n "$configured_path" ]; do if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then continue @@ -161,7 +163,7 @@ if bashio::config.true 'install_tokensave'; then else echo "${repo_root}: NOT INITIALIZED" fi - done < <(bashio::config 'tokensave_project_paths') + done <<< "$TOKENSAVE_PROJECT_PATHS" else echo "disabled" fi